diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26fe5a904..b093ff4d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,13 @@ name: CI on: push: branches: [master] + # Every pull request runs the checks, whatever its base: stacked PRs are + # reviewed against the PR below them. pull_request: - branches: [master] + +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: CARGO_TERM_COLOR: always @@ -390,41 +395,23 @@ 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 - - name: ProviderName generator drift (checked-in files must match generator output) - run: python3 scripts/gen_provider_names.py --check - name: Providers doc generator drift (docs/api/providers.md must match generator output) 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 @@ -432,7 +419,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 fe45c4d4b..8157ad3a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking +- 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 + strings: `provider("groq", ...)`. Built-in and overlay-registered names now + share one string path. The provider list lives in + [docs/api/providers.md](docs/api/providers.md); Rust also gains + `provider_names()`. + **Rust (aimux-stream)** - `SseStream` is now a thin adapter over the `sse-stream` crate (WHATWG diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ab11942a7..cc1b87ac3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,7 +13,6 @@ aimux/ ├── aimux-provider-utils/ # HTTP utilities: retry, backoff, error parsing, API-key loading ├── 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/README.md b/README.md index 02ee0ed0d..f0654816a 100644 --- a/README.md +++ b/README.md @@ -275,18 +275,16 @@ session.close() ```rust // OpenAI → DeepSeek: only the provider name changes (registry-backed; // key read from the provider's env var) -use aimux_providers::{provider, provider_from_env, ProviderName}; +use aimux_providers::{provider, provider_from_env}; -// 推荐:类型化 ProviderName(IDE 补全 + 编译期检查) -let model = provider(ProviderName::Deepseek, None, "deepseek-chat", None)?; -// 字符串形式同样可用: +let model = provider("deepseek", None, "deepseek-chat", None)?; let model = provider_from_env("deepseek", "deepseek-chat", None)?; // model usage is identical — it's all dyn LanguageModel ``` All registry-backed OpenAI-compatible providers share one entry: -`provider(name, ...)` in every binding, with typed `ProviderName` -(enum/union/consts per language). +`provider(name, ...)` in every binding. Provider names are runtime strings; +the generated provider list is the discovery surface. The retired per-provider shell types (`XxxConfig`/`XxxProvider`) are gone — see [docs/API.md](docs/API.md#providers). diff --git a/ROADMAP.md b/ROADMAP.md index c952fa816..bcd4c3d8e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -58,7 +58,7 @@ aimux 是 **provider 接入与治理的运行时**: 版本语义: - **Rust 层 breaking**:A2 删枚举、#185 换类型。 -- **绑定源码级 breaking**:A2 删除 Go / Java / Kotlin / Swift / Flutter / Node / Python 的 `ProviderName` 类型化常量,改为字符串(Node 由 `gen_ts_types.py` 生成 string-literal union 保留补全);CHANGELOG 需给迁移说明。 +- **绑定源码级 breaking**:A2 删除 Go / Java / Kotlin / Swift / Flutter / Node / Python 的 `ProviderName` 类型化常量,改为字符串,Node 也不保留 string-literal union(overlay 注册的名字无法进入编译期联合类型,保留它就是保留第二条路径);CHANGELOG 需给迁移说明。 - binding wire 与 C ABI 不变。 ### 0.7.0 ——「L1 协议层 + auth」(~5-6 周) 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/m11_m12_m6_test.rs b/aimux-core/tests/m11_m12_m6_test.rs deleted file mode 100644 index 74a7510a7..000000000 --- a/aimux-core/tests/m11_m12_m6_test.rs +++ /dev/null @@ -1,366 +0,0 @@ -//! Tests for M11 (streamText aggregation), M12 (generateObject), and M6 (proxy). - -use aimux_core::error::AiMuxError; -use aimux_core::generate::{GenerateTextOptions, generate_object}; -use aimux_core::language_model::LanguageModel; -use aimux_core::message::MessageContent; -use aimux_core::options::{CallOptions, ResponseFormat}; -use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; -use aimux_core::stream_part::StreamPart; -use aimux_core::types::{FinishReason, FinishReasonUnified, Usage}; - -use async_trait::async_trait; -use futures::stream; - -// ── Mock models ───────────────────────────────────────────────────────────── - -/// A mock model whose `do_generate` returns a JSON string (for M12 tests). -struct JsonModel; - -#[async_trait] -impl LanguageModel for JsonModel { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - "mock-json" - } - async fn do_generate(&self, _options: &CallOptions) -> Result { - Ok(GenerateResult { - content: vec![GenerateContent::Text { - text: r#"{"name":"John","age":25}"#.into(), - provider_metadata: None, - }], - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: Some("stop".into()), - }, - usage: Usage::default(), - warnings: vec![], - provider_metadata: None, - response: Default::default(), - request_body: None, - response_headers: None, - }) - } - async fn do_stream(&self, _options: &CallOptions) -> Result { - unimplemented!() - } -} - -/// A mock model whose `do_generate` returns non-JSON text (for M12 failure test). -struct NonJsonModel; - -#[async_trait] -impl LanguageModel for NonJsonModel { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - "mock-nonjson" - } - async fn do_generate(&self, _options: &CallOptions) -> Result { - Ok(GenerateResult { - content: vec![GenerateContent::Text { - text: "This is not JSON at all.".into(), - provider_metadata: None, - }], - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: Usage::default(), - warnings: vec![], - provider_metadata: None, - response: Default::default(), - request_body: None, - response_headers: None, - }) - } - async fn do_stream(&self, _options: &CallOptions) -> Result { - unimplemented!() - } -} - -/// A mock model whose `do_stream` yields a rich set of StreamParts (for M11). -struct StreamModel; - -#[async_trait] -impl LanguageModel for StreamModel { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - "mock-stream" - } - 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::ReasoningStart { - id: "r1".into(), - provider_metadata: None, - }), - Ok(StreamPart::ReasoningDelta { - id: "r1".into(), - delta: "Thinking...".into(), - provider_metadata: None, - }), - Ok(StreamPart::ReasoningEnd { - id: "r1".into(), - provider_metadata: None, - }), - Ok(StreamPart::TextDelta { - id: "t1".into(), - delta: "Hello ".into(), - provider_metadata: None, - }), - Ok(StreamPart::TextDelta { - id: "t1".into(), - delta: "world!".into(), - provider_metadata: None, - }), - Ok(StreamPart::Source { - id: "s1".into(), - source_type: "url".into(), - url: Some("https://example.com".into()), - title: Some("Example".into()), - provider_metadata: None, - }), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: Some("stop".into()), - }, - usage: Usage { - input_tokens: aimux_core::types::TokenUsage { - total: Some(10), - ..Default::default() - }, - output_tokens: aimux_core::types::TokenUsage { - total: Some(20), - ..Default::default() - }, - ..Default::default() - }, - provider_metadata: None, - }), - ]; - Ok(StreamResult { - stream: Box::pin(stream::iter(parts)), - request_body: None, - response_headers: None, - }) - } -} - -// ── M12: generateObject ────────────────────────────────────────────────────── - -#[tokio::test] -async fn generate_object_parses_json_output() { - let result = generate_object( - &JsonModel, - "Extract user info", - GenerateTextOptions { - response_format: Some(ResponseFormat::Json { - schema: Some(serde_json::json!({"type": "object"})), - name: None, - description: None, - }), - ..Default::default() - }, - ) - .await - .unwrap(); - - assert_eq!(result.object["name"], "John"); - assert_eq!(result.object["age"], 25); - assert_eq!(result.raw.text, r#"{"name":"John","age":25}"#); -} - -#[tokio::test] -async fn generate_object_non_json_fails() { - let err = generate_object(&NonJsonModel, "whatever", GenerateTextOptions::default()) - .await - .unwrap_err(); - - assert!( - matches!(err, AiMuxError::JsonParse(ref m) if m.contains("not valid JSON")), - "expected JsonParse error, got {err:?}" - ); -} - -// ── M11: streamText aggregation ────────────────────────────────────────────── - -#[tokio::test] -async fn consume_stream_aggregates_text_and_reasoning() { - let result = - aimux_core::generate::stream_text(&StreamModel, "hi", GenerateTextOptions::default()) - .await - .unwrap(); - let agg = result.consume().await.unwrap(); - - assert_eq!(agg.text, "Hello world!"); - assert_eq!(agg.reasoning.len(), 1); - assert_eq!(agg.reasoning[0].text, "Thinking..."); - assert_eq!(agg.reasoning_text, "Thinking..."); -} - -#[tokio::test] -async fn consume_stream_captures_usage_and_finish_reason() { - let result = - aimux_core::generate::stream_text(&StreamModel, "hi", GenerateTextOptions::default()) - .await - .unwrap(); - let agg = result.consume().await.unwrap(); - - assert_eq!(agg.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(agg.raw_finish_reason.as_deref(), Some("stop")); - assert_eq!(agg.usage.input_tokens.total, Some(10)); - assert_eq!(agg.usage.output_tokens.total, Some(20)); -} - -#[tokio::test] -async fn consume_stream_captures_sources() { - let result = - aimux_core::generate::stream_text(&StreamModel, "hi", GenerateTextOptions::default()) - .await - .unwrap(); - let agg = result.consume().await.unwrap(); - - assert_eq!(agg.sources.len(), 1); - assert_eq!(agg.sources[0].id, "s1"); - assert_eq!(agg.sources[0].url.as_deref(), Some("https://example.com")); -} - -#[tokio::test] -async fn consume_stream_builds_response_messages() { - let result = - aimux_core::generate::stream_text(&StreamModel, "hi", GenerateTextOptions::default()) - .await - .unwrap(); - let agg = result.consume().await.unwrap(); - - assert_eq!(agg.response_messages.len(), 1); - assert_eq!( - agg.response_messages[0].role, - aimux_core::message::Role::Assistant - ); - // The text must be in the response message. - match &agg.response_messages[0].content { - MessageContent::Parts(parts) => { - assert!(parts.iter().any(|p| matches!(p, - aimux_core::content::ContentPart::Text { text, .. } if text == "Hello world!" - ))); - } - _ => panic!("expected Parts"), - } -} - -/// A mock model whose stream carries a reasoning block with provider -/// metadata but NO summary text (OpenAI Responses with store=false can -/// return encrypted reasoning without any summary deltas). -struct MetadataOnlyReasoningModel; - -#[async_trait] -impl LanguageModel for MetadataOnlyReasoningModel { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - "mock-meta-reasoning" - } - async fn do_generate(&self, _options: &CallOptions) -> Result { - unimplemented!() - } - async fn do_stream(&self, _options: &CallOptions) -> Result { - let meta = serde_json::json!({ - "openai": { - "itemId": "rs_1", - "reasoningEncryptedContent": "enc-blob-123", - } - }); - let parts: Vec> = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::ReasoningStart { - id: "rs_1:0".into(), - provider_metadata: Some(meta.clone()), - }), - // No ReasoningDelta at all — no visible summary text. - Ok(StreamPart::ReasoningEnd { - id: "rs_1:0".into(), - provider_metadata: Some(meta), - }), - Ok(StreamPart::TextDelta { - id: "t1".into(), - delta: "Done.".into(), - provider_metadata: None, - }), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: Some("stop".into()), - }, - usage: Usage::default(), - provider_metadata: None, - }), - ]; - Ok(StreamResult { - stream: Box::pin(stream::iter(parts)), - request_body: None, - response_headers: None, - }) - } -} - -/// Regression: a reasoning part with provider metadata but no summary text -/// used to be dropped from response_messages entirely, losing the -/// encrypted reasoning blob (it must round-trip on the next turn). -#[tokio::test] -async fn consume_keeps_metadata_only_reasoning_in_response_messages() { - let result = aimux_core::generate::stream_text( - &MetadataOnlyReasoningModel, - "hi", - GenerateTextOptions::default(), - ) - .await - .unwrap(); - let agg = result.consume().await.unwrap(); - - // No visible reasoning text at the top level... - assert!( - agg.reasoning.is_empty(), - "no summary text, no ReasoningPart" - ); - - // ...but the reasoning part must survive in response_messages with its - // provider metadata, or the next turn loses the encrypted blob. - let msg = &agg.response_messages[0]; - match &msg.content { - MessageContent::Parts(parts) => { - let reasoning = parts - .iter() - .find(|p| matches!(p, aimux_core::content::ContentPart::Reasoning { .. })); - match reasoning { - Some(aimux_core::content::ContentPart::Reasoning { - text, - provider_options: Some(po), - .. - }) => { - assert!(text.is_empty()); - assert_eq!(po["openai"]["itemId"], serde_json::json!("rs_1")); - assert_eq!( - po["openai"]["reasoningEncryptedContent"], - serde_json::json!("enc-blob-123") - ); - } - other => panic!( - "response_messages must keep the metadata-carrying reasoning part, got {other:?}" - ), - } - } - _ => panic!("expected Parts"), - } -} diff --git a/aimux-core/tests/m7_aggregation_test.rs b/aimux-core/tests/m7_aggregation_test.rs deleted file mode 100644 index 51fecfe5f..000000000 --- a/aimux-core/tests/m7_aggregation_test.rs +++ /dev/null @@ -1,333 +0,0 @@ -//! Tests for M7: top-level result aggregation (reasoning/sources/files/responseMessages). -//! -//! Verifies that `generate_text` extracts reasoning, sources, files, and -//! response_messages from the raw `GenerateResult.content` to the top level. - -use aimux_core::error::AiMuxError; -use aimux_core::generate::{GenerateTextOptions, generate_text}; -use aimux_core::language_model::LanguageModel; -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::types::{FinishReason, FinishReasonUnified, Usage}; - -use async_trait::async_trait; - -/// A mock model that returns a fixed `GenerateResult` with all content types. -struct RichModel; - -#[async_trait] -impl LanguageModel for RichModel { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - "mock-1" - } - async fn do_generate(&self, _options: &CallOptions) -> Result { - Ok(GenerateResult { - content: vec![ - GenerateContent::Reasoning { - text: "Let me think...".into(), - provider_metadata: Some(serde_json::json!({ - "anthropic": { "signature": "sig-abc123" } - })), - }, - GenerateContent::Text { - text: "Hello!".into(), - provider_metadata: None, - }, - GenerateContent::ToolCall { - 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(), - url: Some("https://example.com".into()), - title: Some("Example".into()), - provider_metadata: None, - }, - GenerateContent::File { - data: FileData::Data { - data: FileBytes::Base64("iVBOR".into()), - }, - media_type: "image/png".into(), - provider_metadata: None, - }, - ], - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: Usage::default(), - warnings: vec![], - provider_metadata: None, - response: Default::default(), - request_body: None, - response_headers: None, - }) - } - async fn do_stream(&self, _options: &CallOptions) -> Result { - unimplemented!("streaming not needed for M7 tests") - } -} - -/// A mock model that returns only text (no reasoning/sources/files). -struct PlainModel; - -#[async_trait] -impl LanguageModel for PlainModel { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - "mock-2" - } - async fn do_generate(&self, _options: &CallOptions) -> Result { - Ok(GenerateResult { - content: vec![GenerateContent::Text { - text: "Just text.".into(), - provider_metadata: None, - }], - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: Usage::default(), - warnings: vec![], - provider_metadata: None, - response: Default::default(), - request_body: None, - response_headers: None, - }) - } - async fn do_stream(&self, _options: &CallOptions) -> Result { - unimplemented!() - } -} - -#[tokio::test] -async fn extracts_reasoning_to_top_level() { - let result = generate_text(&RichModel, "hi", GenerateTextOptions::default()) - .await - .unwrap(); - - assert_eq!(result.reasoning.len(), 1); - assert_eq!(result.reasoning[0].text, "Let me think..."); - assert_eq!(result.reasoning_text, "Let me think..."); -} - -#[tokio::test] -async fn extracts_sources_to_top_level() { - let result = generate_text(&RichModel, "hi", GenerateTextOptions::default()) - .await - .unwrap(); - - assert_eq!(result.sources.len(), 1); - assert_eq!(result.sources[0].id, "src-1"); - assert_eq!(result.sources[0].source_type, "url"); - assert_eq!( - result.sources[0].url.as_deref(), - Some("https://example.com") - ); - assert_eq!(result.sources[0].title.as_deref(), Some("Example")); -} - -#[tokio::test] -async fn extracts_files_to_top_level() { - let result = generate_text(&RichModel, "hi", GenerateTextOptions::default()) - .await - .unwrap(); - - assert_eq!(result.files.len(), 1); - assert_eq!(result.files[0].media_type, "image/png"); - assert!(matches!( - &result.files[0].data, - FileData::Data { data: FileBytes::Base64(b) } if b == "iVBOR" - )); -} - -#[tokio::test] -async fn response_messages_roundtrip_to_next_turn() { - let result = generate_text(&RichModel, "hi", GenerateTextOptions::default()) - .await - .unwrap(); - - // response_messages must contain exactly one assistant message. - assert_eq!(result.response_messages.len(), 1); - let msg = &result.response_messages[0]; - assert_eq!(msg.role, Role::Assistant); - - // Its content must be Parts (not bare Text), containing the text. - match &msg.content { - MessageContent::Parts(parts) => { - assert!( - parts.iter().any(|p| matches!(p, - aimux_core::content::ContentPart::Text { text, .. } if text == "Hello!" - )), - "response message must contain the generated text" - ); - } - other => panic!("expected Parts, got {other:?}"), - } - - // The response message must be appendable to a prompt for the next turn. - let next_prompt: ModelPrompt = vec![ - aimux_core::message::ModelMessage::user("hi"), - result.response_messages[0].clone(), - aimux_core::message::ModelMessage::user("follow-up"), - ] - .into(); - // If this compiles + runs, the round-trip type-checks. - let _ = next_prompt; -} - -#[tokio::test] -async fn plain_result_has_empty_aggregates() { - let result = generate_text(&PlainModel, "hi", GenerateTextOptions::default()) - .await - .unwrap(); - - assert!(result.reasoning.is_empty(), "no reasoning expected"); - assert!(result.reasoning_text.is_empty()); - assert!(result.sources.is_empty()); - assert!(result.files.is_empty()); - // response_messages still has the assistant message (with text). - assert_eq!(result.response_messages.len(), 1); - assert_eq!(result.text, "Just text."); -} - -#[tokio::test] -async fn reasoning_included_in_response_messages_with_signature() { - // Reasoning MUST appear in responseMessages — it carries the Anthropic - // thinking-block signature which must be echoed back on the next turn. - // Consistent with AI SDK's toResponseMessages. - let result = generate_text(&RichModel, "hi", GenerateTextOptions::default()) - .await - .unwrap(); - - let msg = &result.response_messages[0]; - let parts = match &msg.content { - MessageContent::Parts(p) => p, - _ => panic!("expected Parts"), - }; - let reasoning_part = parts - .iter() - .find(|p| matches!(p, aimux_core::content::ContentPart::Reasoning { .. })); - assert!( - reasoning_part.is_some(), - "reasoning must be included in responseMessages" - ); - if let Some(aimux_core::content::ContentPart::Reasoning { signature, .. }) = reasoning_part { - assert_eq!( - signature.as_deref(), - Some("sig-abc123"), - "Anthropic signature must be preserved in responseMessages" - ); - } -} - -#[tokio::test] -async fn tool_calls_included_in_response_messages() { - // Tool calls must appear in responseMessages so the assistant reply can - // be replayed in the next turn (the tool result follows as a separate - // tool message). - let result = generate_text(&RichModel, "hi", GenerateTextOptions::default()) - .await - .unwrap(); - - let msg = &result.response_messages[0]; - let parts = match &msg.content { - MessageContent::Parts(p) => p, - _ => panic!("expected Parts"), - }; - let tool_call_part = parts.iter().find(|p| { - matches!( - p, - aimux_core::content::ContentPart::ToolCall { tool_name, .. } if tool_name == "search" - ) - }); - assert!( - tool_call_part.is_some(), - "tool call must be included in responseMessages" - ); -} - -/// A mock model that returns reasoning with Bedrock-style signature metadata. -struct BedrockModel; - -#[async_trait] -impl LanguageModel for BedrockModel { - fn provider(&self) -> &str { - "bedrock" - } - fn model_id(&self) -> &str { - "bedrock-1" - } - async fn do_generate(&self, _options: &CallOptions) -> Result { - Ok(GenerateResult { - content: vec![ - GenerateContent::Text { - text: "Hello from Bedrock!".into(), - provider_metadata: None, - }, - GenerateContent::Reasoning { - text: "Thinking...".into(), - // Bedrock stores signature under BOTH "bedrock" and - // "amazonBedrock" keys (see bedrock/model.rs:535-540). - provider_metadata: Some(serde_json::json!({ - "amazonBedrock": { "signature": "bedrock-sig-xyz" }, - "bedrock": { "signature": "bedrock-sig-xyz" } - })), - }, - ], - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: Usage::default(), - warnings: vec![], - provider_metadata: None, - response: Default::default(), - request_body: None, - response_headers: None, - }) - } - async fn do_stream(&self, _options: &CallOptions) -> Result { - unimplemented!() - } -} - -#[tokio::test] -async fn bedrock_signature_extracted_from_reasoning_metadata() { - // Bedrock stores the thinking signature under "bedrock"/"amazonBedrock" - // keys, not "anthropic". The extraction must find it regardless. - let result = generate_text(&BedrockModel, "hi", GenerateTextOptions::default()) - .await - .unwrap(); - - let msg = &result.response_messages[0]; - let parts = match &msg.content { - MessageContent::Parts(p) => p, - _ => panic!("expected Parts"), - }; - let reasoning_part = parts - .iter() - .find_map(|p| match p { - aimux_core::content::ContentPart::Reasoning { signature, .. } => Some(signature), - _ => None, - }) - .expect("reasoning must be in responseMessages"); - assert_eq!( - reasoning_part.as_deref(), - Some("bedrock-sig-xyz"), - "Bedrock signature must be extracted from bedrock/amazonBedrock metadata key" - ); -} diff --git a/aimux-core/tests/recording_e2e_test.rs b/aimux-core/tests/recording_e2e_test.rs deleted file mode 100644 index 28e46cf85..000000000 --- a/aimux-core/tests/recording_e2e_test.rs +++ /dev/null @@ -1,236 +0,0 @@ -//! RFC-0023 P1 端到端集成测试:层 A 接入(generate_text/stream_text) + -//! 流式终结观测 + JsonlRecorder 落盘。 -//! -//! 全局录制器是进程级单例,所有场景放在同一测试内串行执行(init→验证→替换)。 - -use aimux_core::language_model::LanguageModel; -use aimux_core::options::CallOptions; -use aimux_core::recording::{self, JsonlRecorder, OutcomeStatus, Recording}; -use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; -use aimux_core::stream_part::StreamPart; -use aimux_core::types::{FinishReason, FinishReasonUnified, Usage}; -use async_trait::async_trait; -use futures::StreamExt; -use std::sync::Arc; - -/// Mock model:非流式固定回话;流式按参数决定行为。 -#[derive(Default)] -struct EchoModel { - /// do_generate 立即失败。 - gen_fails: bool, - /// 流中途发 Error part。 - part_error: bool, - /// do_stream 立即失败。 - stream_fails: bool, -} - -#[async_trait] -impl LanguageModel for EchoModel { - fn provider(&self) -> &str { - "mock-echo" - } - - fn model_id(&self) -> &str { - "echo-1" - } - - async fn do_generate( - &self, - _options: &CallOptions, - ) -> Result { - if self.gen_fails { - return Err(aimux_core::error::AiMuxError::ApiCall(Box::new( - aimux_core::error::ApiCallError::new( - "gen boom", - "https://example.test/v1", - serde_json::json!({}), - ), - ))); - } - Ok(GenerateResult { - content: vec![GenerateContent::Text { - text: "pong".into(), - provider_metadata: None, - }], - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: Usage::default(), - warnings: vec![], - provider_metadata: None, - response: aimux_core::types::ResponseMetadata::default(), - request_body: None, - response_headers: None, - }) - } - - async fn do_stream( - &self, - _options: &CallOptions, - ) -> Result { - if self.stream_fails { - return Err(aimux_core::error::AiMuxError::InvalidResponseData( - "boom".into(), - )); - } - if self.part_error { - let parts = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::Error { - error: aimux_core::error::AiMuxError::InvalidResponseData("mid-stream".into()), - }), - ]; - return Ok(StreamResult { - stream: Box::pin(futures::stream::iter(parts)), - request_body: None, - response_headers: None, - }); - } - let parts = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::TextDelta { - id: "1".into(), - delta: "hi".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(futures::stream::iter(parts)), - request_body: None, - response_headers: None, - }) - } -} - -fn run(f: F) -> F::Output { - tokio::runtime::Runtime::new().unwrap().block_on(f) -} - -fn wait_sorted_line(dir: &std::path::Path) -> Recording { - let path = dir.join("recordings.jsonl"); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - loop { - if let Ok(s) = std::fs::read_to_string(&path) - && let Some(line) = s.lines().next() - && !line.trim().is_empty() - && let Ok(rec) = serde_json::from_str::(line.trim()) - { - return rec; - } - if std::time::Instant::now() > deadline { - panic!("recording not written: {}", path.display()); - } - std::thread::sleep(std::time::Duration::from_millis(20)); - } -} - -#[test] -fn layer_a_records_all_outcome_paths() { - let pid = std::process::id(); - let dir_ok = std::env::temp_dir().join(format!("aimux-e2e-ok-{pid}")); - let dir_err = std::env::temp_dir().join(format!("aimux-e2e-err-{pid}")); - let dir_finish = std::env::temp_dir().join(format!("aimux-e2e-fin-{pid}")); - let dir_part_err = std::env::temp_dir().join(format!("aimux-e2e-pe-{pid}")); - let dir_cancel = std::env::temp_dir().join(format!("aimux-e2e-cancel-{pid}")); - for d in [&dir_ok, &dir_err, &dir_finish, &dir_part_err, &dir_cancel] { - let _ = std::fs::remove_dir_all(d); - } - - // ① generate 成功 → Success + finish_reason。 - recording::init_recording(Some(Arc::new(JsonlRecorder::new(&dir_ok)))); - run(async { - let r = aimux_core::generate::generate_text( - &EchoModel::default(), - "ping", - aimux_core::generate::GenerateTextOptions::default(), - ) - .await; - assert_eq!(r.unwrap().text, "pong"); - }); - let rec = wait_sorted_line(&dir_ok); - assert!(rec.call_id.starts_with("call-")); - assert_eq!(rec.provider.provider, "mock-echo"); - assert_eq!(rec.outcome.status, OutcomeStatus::Success); - assert_eq!(rec.outcome.finish_reason.as_deref(), Some("stop")); - - // ② generate 失败 → Error。 - recording::init_recording(Some(Arc::new(JsonlRecorder::new(&dir_err)))); - run(async { - let r = aimux_core::generate::generate_text( - &EchoModel { - gen_fails: true, - ..Default::default() - }, - "ping", - aimux_core::generate::GenerateTextOptions::default(), - ) - .await; - assert!(r.is_err()); - }); - let rec = wait_sorted_line(&dir_err); - assert_eq!(rec.outcome.status, OutcomeStatus::Error); - assert!(rec.outcome.error.is_some()); - - // ③ 流式正常终结(消费完) → Success。 - recording::init_recording(Some(Arc::new(JsonlRecorder::new(&dir_finish)))); - run(async { - let result = aimux_core::generate::stream_text( - &EchoModel::default(), - "ping", - aimux_core::generate::GenerateTextOptions::default(), - ) - .await - .unwrap(); - let parts: Vec<_> = result.stream.collect::>().await; - assert_eq!(parts.len(), 3); - }); - let rec = wait_sorted_line(&dir_finish); - assert_eq!(rec.outcome.status, OutcomeStatus::Success); - - // ④ 流式 Error part → Error。 - recording::init_recording(Some(Arc::new(JsonlRecorder::new(&dir_part_err)))); - run(async { - let result = aimux_core::generate::stream_text( - &EchoModel { - part_error: true, - ..Default::default() - }, - "ping", - aimux_core::generate::GenerateTextOptions::default(), - ) - .await - .unwrap(); - let parts: Vec<_> = result.stream.collect::>().await; - assert_eq!(parts.len(), 2); - }); - let rec = wait_sorted_line(&dir_part_err); - assert_eq!(rec.outcome.status, OutcomeStatus::Error); - assert!(rec.outcome.error.unwrap_or_default().contains("mid-stream")); - - // ⑤ 流式提前 drop(不消费完)→ Cancelled。 - recording::init_recording(Some(Arc::new(JsonlRecorder::new(&dir_cancel)))); - run(async { - let result = aimux_core::generate::stream_text( - &EchoModel::default(), - "ping", - aimux_core::generate::GenerateTextOptions::default(), - ) - .await - .unwrap(); - drop(result.stream); // 未 poll 完即抛弃 → Drop 终结 Cancelled - }); - let rec = wait_sorted_line(&dir_cancel); - assert_eq!(rec.outcome.status, OutcomeStatus::Cancelled); - - recording::init_recording(None); - let _ = std::fs::remove_dir_all(&dir_ok); -} diff --git a/aimux-core/tests/response_messages_tool_test.rs b/aimux-core/tests/response_messages_tool_test.rs deleted file mode 100644 index f93a2c8ee..000000000 --- a/aimux-core/tests/response_messages_tool_test.rs +++ /dev/null @@ -1,500 +0,0 @@ -use aimux_core::error::AiMuxError; -use aimux_core::generate::{GenerateTextOptions, generate_text, stream_text}; -use aimux_core::language_model::LanguageModel; -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::types::{FinishReason, FinishReasonUnified, Usage}; -use async_trait::async_trait; -use serde_json::json; - -struct ProviderTranscriptModel; - -fn finish_reason() -> FinishReason { - FinishReason { - unified: FinishReasonUnified::Stop, - raw: Some("end_turn".to_string()), - } -} - -#[async_trait] -impl LanguageModel for ProviderTranscriptModel { - fn provider(&self) -> &str { - "mock" - } - - fn model_id(&self) -> &str { - "provider-transcript" - } - - async fn do_generate(&self, _options: &CallOptions) -> Result { - Ok(GenerateResult { - content: vec![ - GenerateContent::Text { - text: "before".to_string(), - provider_metadata: None, - }, - GenerateContent::ToolCall { - 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 { - tool_call_id: "srv-1".to_string(), - tool_name: "server_search".to_string(), - result: json!({ "answer": "still running" }), - is_error: Some(false), - preliminary: Some(true), - dynamic: Some(true), - provider_metadata: Some(json!({ - "mock": { "serverResultId": "wire-preliminary-1" } - })), - }, - GenerateContent::ToolResult { - tool_call_id: "srv-1".to_string(), - tool_name: "server_search".to_string(), - result: json!({ "answer": 42 }), - is_error: Some(false), - preliminary: Some(false), - dynamic: Some(true), - provider_metadata: Some(json!({ - "mock": { "serverResultId": "wire-result-1" } - })), - }, - GenerateContent::Text { - text: "after".to_string(), - provider_metadata: None, - }, - ], - finish_reason: finish_reason(), - usage: Usage::default(), - warnings: vec![], - provider_metadata: None, - response: Default::default(), - request_body: None, - response_headers: None, - }) - } - - async fn do_stream(&self, _options: &CallOptions) -> Result { - let parts = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::TextDelta { - id: "text-1".to_string(), - delta: "before".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::ToolCall { - tool_call_id: "srv-1".to_string(), - tool_name: "server_search".to_string(), - input: json!(r#"{"query":"Rust"}"#), - 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 { - tool_call_id: "srv-1".to_string(), - tool_name: "server_search".to_string(), - result: json!({ "answer": "still running" }), - is_error: Some(false), - preliminary: Some(true), - dynamic: Some(true), - provider_metadata: Some(json!({ - "mock": { "serverResultId": "wire-preliminary-1" } - })), - }), - Ok(StreamPart::ToolResult { - tool_call_id: "srv-1".to_string(), - tool_name: "server_search".to_string(), - result: json!({ "answer": 42 }), - is_error: Some(false), - preliminary: Some(false), - dynamic: Some(true), - provider_metadata: Some(json!({ - "mock": { "serverResultId": "wire-result-1" } - })), - }), - Ok(StreamPart::TextDelta { - id: "text-2".to_string(), - delta: "after".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::Finish { - finish_reason: finish_reason(), - usage: Usage::default(), - provider_metadata: None, - }), - ]; - Ok(StreamResult { - stream: Box::pin(futures::stream::iter(parts)), - request_body: None, - response_headers: None, - }) - } -} - -fn assert_provider_transcript(messages: &[aimux_core::message::ModelMessage]) { - assert_eq!(messages.len(), 1); - assert_eq!(messages[0].role, Role::Assistant); - let MessageContent::Parts(parts) = &messages[0].content else { - panic!("expected assistant parts"); - }; - assert_eq!( - parts.len(), - 4, - "provider order must be preserved: {parts:?}" - ); - assert!(matches!( - &parts[0], - aimux_core::content::ContentPart::Text { text, .. } if text == "before" - )); - assert!(matches!( - &parts[1], - aimux_core::content::ContentPart::ToolCall { - tool_call_id, - tool_name, - input, - provider_executed: Some(true), - provider_options: Some(options), - .. - } if tool_call_id == "srv-1" - && tool_name == "server_search" - && input == &json!({ "query": "Rust" }) - && options["mock"]["serverCallId"] == "wire-1" - )); - assert!(matches!( - &parts[2], - aimux_core::content::ContentPart::ToolResult { - tool_call_id, - tool_name: Some(tool_name), - result, - is_error: Some(false), - preliminary: Some(false), - dynamic: Some(true), - provider_options: Some(options), - } if tool_call_id == "srv-1" - && tool_name == "server_search" - && result == &json!({ "answer": 42 }) - && options["mock"]["serverResultId"] == "wire-result-1" - )); - assert!(matches!( - &parts[3], - aimux_core::content::ContentPart::Text { text, .. } if text == "after" - )); -} - -#[tokio::test] -async fn generate_response_messages_keep_provider_tool_transcript() { - let result = generate_text( - &ProviderTranscriptModel, - "search", - GenerateTextOptions::default(), - ) - .await - .unwrap(); - - assert_provider_transcript(&result.response_messages); -} - -#[tokio::test] -async fn stream_response_messages_keep_provider_tool_transcript() { - let result = stream_text( - &ProviderTranscriptModel, - "search", - GenerateTextOptions::default(), - ) - .await - .unwrap() - .consume() - .await - .unwrap(); - - assert_provider_transcript(&result.response_messages); -} - -struct InvalidToolInputModel; - -#[async_trait] -impl LanguageModel for InvalidToolInputModel { - fn provider(&self) -> &str { - "mock" - } - - fn model_id(&self) -> &str { - "invalid-tool-input" - } - - async fn do_generate(&self, _options: &CallOptions) -> Result { - Ok(GenerateResult { - content: vec![GenerateContent::ToolCall { - 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![], - provider_metadata: None, - response: Default::default(), - request_body: None, - response_headers: None, - }) - } - - async fn do_stream(&self, _options: &CallOptions) -> Result { - unimplemented!() - } -} - -#[tokio::test] -async fn invalid_primitive_tool_input_is_not_replayed() { - let result = generate_text( - &InvalidToolInputModel, - "search", - GenerateTextOptions::default(), - ) - .await - .unwrap(); - assert_eq!(result.tool_calls[0].input, json!("{")); - assert_eq!(result.tool_calls[0].invalid, Some(true)); - - let MessageContent::Parts(parts) = &result.response_messages[0].content else { - panic!("expected assistant parts"); - }; - assert!(matches!( - &parts[0], - aimux_core::content::ContentPart::ToolCall { input, .. } if input == &json!({}) - )); -} - -struct EmptyModel; - -#[async_trait] -impl LanguageModel for EmptyModel { - fn provider(&self) -> &str { - "mock" - } - - fn model_id(&self) -> &str { - "empty" - } - - async fn do_generate(&self, _options: &CallOptions) -> Result { - Ok(GenerateResult { - content: vec![GenerateContent::Text { - text: String::new(), - provider_metadata: None, - }], - finish_reason: finish_reason(), - usage: Usage::default(), - warnings: vec![], - provider_metadata: None, - response: Default::default(), - request_body: None, - response_headers: None, - }) - } - - async fn do_stream(&self, _options: &CallOptions) -> Result { - unimplemented!() - } -} - -#[tokio::test] -async fn empty_generation_has_no_empty_assistant_response_message() { - let result = generate_text(&EmptyModel, "", GenerateTextOptions::default()) - .await - .unwrap(); - assert!(result.response_messages.is_empty()); -} - -struct ContentMetadataModel; - -#[async_trait] -impl LanguageModel for ContentMetadataModel { - fn provider(&self) -> &str { - "mock" - } - - fn model_id(&self) -> &str { - "content-metadata" - } - - async fn do_generate(&self, _options: &CallOptions) -> Result { - Ok(GenerateResult { - content: vec![ - GenerateContent::Text { - text: "answer".to_string(), - provider_metadata: Some(json!({ - "google": { "thoughtSignature": "text-generate" } - })), - }, - GenerateContent::Reasoning { - text: "think".to_string(), - provider_metadata: Some(json!({ - "anthropic": { "signature": "reason-generate" } - })), - }, - ], - finish_reason: finish_reason(), - usage: Usage::default(), - warnings: vec![], - provider_metadata: None, - response: Default::default(), - request_body: None, - response_headers: None, - }) - } - - async fn do_stream(&self, _options: &CallOptions) -> Result { - let parts = vec![ - Ok(StreamPart::TextStart { - id: "text-1".to_string(), - provider_metadata: Some(json!({ "mock": { "phase": "start" } })), - }), - Ok(StreamPart::TextDelta { - id: "text-1".to_string(), - delta: "answer".to_string(), - provider_metadata: Some(json!({ - "google": { "thoughtSignature": "text-delta" } - })), - }), - Ok(StreamPart::TextEnd { - id: "text-1".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::ReasoningStart { - id: "reason-1".to_string(), - provider_metadata: Some(json!({ - "anthropic": { "signature": "reason-start" } - })), - }), - Ok(StreamPart::ReasoningDelta { - id: "reason-1".to_string(), - delta: "think".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::ReasoningEnd { - id: "reason-1".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::ReasoningStart { - id: "reason-2".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::ReasoningDelta { - id: "reason-2".to_string(), - delta: "again".to_string(), - provider_metadata: Some(json!({ - "anthropic": { "signature": "reason-delta" } - })), - }), - Ok(StreamPart::ReasoningEnd { - id: "reason-2".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::Finish { - finish_reason: finish_reason(), - usage: Usage::default(), - provider_metadata: None, - }), - ]; - Ok(StreamResult { - stream: Box::pin(futures::stream::iter(parts)), - request_body: None, - response_headers: None, - }) - } -} - -#[tokio::test] -async fn generate_response_messages_keep_text_and_reasoning_metadata() { - let result = generate_text( - &ContentMetadataModel, - "metadata", - GenerateTextOptions::default(), - ) - .await - .unwrap(); - let MessageContent::Parts(parts) = &result.response_messages[0].content else { - panic!("expected assistant parts"); - }; - assert!(matches!( - &parts[0], - aimux_core::content::ContentPart::Text { - text, - provider_options: Some(options), - } if text == "answer" && options["google"]["thoughtSignature"] == "text-generate" - )); - assert!(matches!( - &parts[1], - aimux_core::content::ContentPart::Reasoning { - text, - signature: Some(signature), - provider_options: Some(options), - } if text == "think" - && signature == "reason-generate" - && options["anthropic"]["signature"] == "reason-generate" - )); -} - -#[tokio::test] -async fn stream_response_messages_keep_latest_segment_metadata() { - let result = stream_text( - &ContentMetadataModel, - "metadata", - GenerateTextOptions::default(), - ) - .await - .unwrap() - .consume() - .await - .unwrap(); - let MessageContent::Parts(parts) = &result.response_messages[0].content else { - panic!("expected assistant parts"); - }; - assert_eq!(parts.len(), 3); - assert!(matches!( - &parts[0], - aimux_core::content::ContentPart::Text { - text, - provider_options: Some(options), - } if text == "answer" && options["google"]["thoughtSignature"] == "text-delta" - )); - assert!(matches!( - &parts[1], - aimux_core::content::ContentPart::Reasoning { - text, - signature: Some(signature), - provider_options: Some(options), - } if text == "think" - && signature == "reason-start" - && options["anthropic"]["signature"] == "reason-start" - )); - assert!(matches!( - &parts[2], - aimux_core::content::ContentPart::Reasoning { - text, - signature: Some(signature), - provider_options: Some(options), - } if text == "again" - && signature == "reason-delta" - && options["anthropic"]["signature"] == "reason-delta" - )); -} diff --git a/aimux-core/tests/session_test.rs b/aimux-core/tests/session_test.rs deleted file mode 100644 index 7b1e23c4f..000000000 --- a/aimux-core/tests/session_test.rs +++ /dev/null @@ -1,182 +0,0 @@ -//! Integration tests for RFC-0024 session grouping: the `generate_text` / -//! `stream_text` entry points resolve the session id (explicit first, opt-in -//! inference) and record the call in the registered `SessionStore`. -//! -//! NOTE: all tests touching the global session store/inferer live in ONE test -//! function. `cargo test` runs test functions in parallel, and the global -//! registration is a process-wide singleton — separate parallel tests would -//! race on it. The single function keeps the assertions serial and stable. - -use std::sync::Arc; - -use futures::executor::block_on; - -use aimux_core::error::AiMuxError; -use aimux_core::generate::{GenerateTextOptions, generate_text}; -use aimux_core::language_model::LanguageModel; -use aimux_core::message::{ModelMessage, ModelPrompt}; -use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateResult, StreamResult}; -use aimux_core::session::{ - SessionSource, SessionStore, init_session_infer, init_session_store, list_sessions, - session_calls, -}; -use aimux_core::types::{FinishReason, FinishReasonUnified, Usage}; - -struct MockModel; - -#[async_trait::async_trait] -impl LanguageModel for MockModel { - fn provider(&self) -> &str { - "mock" - } - - fn model_id(&self) -> &str { - "mock-1" - } - - async fn do_generate(&self, _options: &CallOptions) -> Result { - Ok(GenerateResult { - content: vec![], - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: Usage::default(), - warnings: vec![], - provider_metadata: None, - response: Default::default(), - request_body: None, - response_headers: None, - }) - } - - async fn do_stream(&self, _options: &CallOptions) -> Result { - Ok(StreamResult { - stream: Box::pin(futures::stream::empty()), - request_body: None, - response_headers: None, - }) - } -} - -#[test] -fn generate_text_session_integration() { - // Fresh store + inference off (explicit ids only). - init_session_store(Arc::new(SessionStore::new())); - init_session_infer(false); - - // 1. Without an explicit session_id and inference off → no grouping. - block_on(generate_text( - &MockModel, - "hello", - GenerateTextOptions::default(), - )) - .unwrap(); - assert!( - list_sessions().is_empty(), - "no store recording without session id" - ); - - // 2. Explicit session_id groups consecutive calls (steps 0, 1). - block_on(generate_text( - &MockModel, - "first", - GenerateTextOptions { - session_id: Some("sess-1".to_string()), - ..Default::default() - }, - )) - .unwrap(); - block_on(generate_text( - &MockModel, - "second", - GenerateTextOptions { - session_id: Some("sess-1".to_string()), - ..Default::default() - }, - )) - .unwrap(); - - let calls = session_calls("sess-1"); - assert_eq!(calls.len(), 2); - assert_eq!(calls[0].step, 0); - assert_eq!(calls[1].step, 1); - assert_ne!(calls[0].call_id, calls[1].call_id); - - let views = list_sessions(); - assert_eq!(views.len(), 1); - assert_eq!(views[0].session_id, "sess-1"); - assert_eq!(views[0].source, SessionSource::Explicit); - assert_eq!(views[0].calls.len(), 2); - - // 3. Different explicit ids are separate sessions. - block_on(generate_text( - &MockModel, - "other", - GenerateTextOptions { - session_id: Some("sess-2".to_string()), - ..Default::default() - }, - )) - .unwrap(); - assert_eq!(list_sessions().len(), 2); - assert_eq!(session_calls("sess-2").len(), 1); - - // 4. Inference on: prompt-prefix continuation joins an auto session. - init_session_infer(true); - block_on(generate_text( - &MockModel, - "u1", - GenerateTextOptions::default(), - )) - .unwrap(); - let auto_ids: Vec = list_sessions() - .iter() - .filter(|v| v.session_id.starts_with("auto-")) - .map(|v| v.session_id.clone()) - .collect(); - assert_eq!( - auto_ids.len(), - 1, - "first inferred call starts an auto session" - ); - - // Continuation: ["u1", "a1", "u2"] fully contains ["u1"] as prefix. - block_on(generate_text( - &MockModel, - ModelPrompt::Messages(vec![ - ModelMessage::user("u1"), - ModelMessage::assistant("a1"), - ModelMessage::user("u2"), - ]), - GenerateTextOptions::default(), - )) - .unwrap(); - assert_eq!( - list_sessions() - .iter() - .filter(|v| v.session_id.starts_with("auto-")) - .count(), - 1, - "prefix continuation stays in the same auto session" - ); - let auto = list_sessions() - .into_iter() - .find(|v| v.session_id.starts_with("auto-")) - .unwrap(); - assert_eq!(auto.calls.len(), 2); - assert_eq!(auto.source, SessionSource::Inferred); - - // 5. Explicit still wins over inference. - block_on(generate_text( - &MockModel, - "u1", - GenerateTextOptions { - session_id: Some("sess-1".to_string()), - ..Default::default() - }, - )) - .unwrap(); - assert_eq!(session_calls("sess-1").len(), 3); -} diff --git a/aimux-core/tests/stream_timeout_matrix_test.rs b/aimux-core/tests/stream_timeout_matrix_test.rs deleted file mode 100644 index 8121bf7d6..000000000 --- a/aimux-core/tests/stream_timeout_matrix_test.rs +++ /dev/null @@ -1,179 +0,0 @@ -//! RFC-0031 §13 acceptance: chunk-timer semantics (item 17), -//! producer-side timing (consumer delay never counts), and -//! no-lingering-timer-state after drop (item 21b). -//! -//! Paused tokio time makes every assertion exact: auto-advance jumps to the -//! earliest armed deadline, so elapsed times distinguish "reset" from -//! "not reset" deterministically. - -use std::time::Duration; - -use aimux_core::AbortSignal; -use aimux_core::error::AiMuxError; -use aimux_core::generate::{GenerateTextOptions, stream_text}; -use aimux_core::language_model::LanguageModel; -use aimux_core::options::{CallOptions, TimeoutConfiguration}; -use aimux_core::result::{GenerateResult, StreamResult}; -use aimux_core::stream_part::StreamPart; -use async_trait::async_trait; -use futures::StreamExt; - -/// Streams: TextDelta at t=0, then after 600ms one more part (semantic or -/// not), then pends forever. The follow-up part is the probe: only semantic -/// output may reset the chunk timer. -struct ProbeModel { - second_part_is_output: bool, -} - -fn delta(text: &str) -> StreamPart { - StreamPart::TextDelta { - id: "1".into(), - delta: text.into(), - provider_metadata: None, - } -} - -#[async_trait] -impl LanguageModel for ProbeModel { - fn provider(&self) -> &str { - "mock-probe" - } - - fn model_id(&self) -> &str { - "probe-1" - } - - async fn do_generate(&self, _options: &CallOptions) -> Result { - unimplemented!("streaming-only mock") - } - - async fn do_stream(&self, _options: &CallOptions) -> Result { - let second_is_output = self.second_part_is_output; - Ok(StreamResult { - stream: Box::pin(async_stream::stream! { - yield Ok(delta("first")); - tokio::time::sleep(Duration::from_millis(600)).await; - if second_is_output { - yield Ok(delta("second")); - } else { - // Non-semantic per the AI SDK's isOutputChunk: must NOT - // reset the chunk timer. - yield Ok(StreamPart::TextStart { - id: "1".into(), - provider_metadata: None, - }); - } - futures::future::pending::<()>().await; - }), - request_body: None, - response_headers: None, - }) - } -} - -fn chunk_options() -> GenerateTextOptions { - GenerateTextOptions { - max_retries: Some(0), - timeout: Some(TimeoutConfiguration { - chunk_ms: Some(1_000), - ..Default::default() - }), - ..Default::default() - } -} - -/// Drives the probe stream to the chunk timeout and returns when it fired. -async fn time_to_chunk_timeout(second_part_is_output: bool) -> Duration { - let model = ProbeModel { - second_part_is_output, - }; - let mut result = stream_text(&model, "hello", chunk_options()).await.unwrap(); - let start = tokio::time::Instant::now(); - loop { - match result.stream.next().await.expect("stream must not end") { - Ok(_) => {} - Err(AiMuxError::Timeout(message)) => { - assert_eq!(message, "Chunk timeout of 1000ms exceeded"); - return start.elapsed(); - } - Err(other) => panic!("unexpected error: {other}"), - } - } -} - -#[tokio::test(start_paused = true)] -async fn non_semantic_part_does_not_reset_the_chunk_timer() { - // TextStart at t=600 must not push the deadline: timeout fires 1000ms - // after the only semantic output at t=0, not 1600ms. - assert_eq!( - time_to_chunk_timeout(false).await, - Duration::from_millis(1_000) - ); -} - -#[tokio::test(start_paused = true)] -async fn semantic_output_resets_the_chunk_timer() { - // The second TextDelta at t=600 re-arms the deadline to t=1600. - assert_eq!( - time_to_chunk_timeout(true).await, - Duration::from_millis(1_600) - ); -} - -#[tokio::test(start_paused = true)] -async fn consumer_delay_does_not_count_against_the_chunk_timer() { - // The second delta arrives at t=600, inside the 1000ms chunk budget - // measured from the first delta. A consumer that sleeps until t=2000 - // before polling again must still receive it; only the *next* poll sees - // the timeout, which fired at t=1600 on the producer side. - let model = ProbeModel { - second_part_is_output: true, - }; - let mut result = stream_text(&model, "hello", chunk_options()).await.unwrap(); - assert!(result.stream.next().await.expect("first part").is_ok()); - - tokio::time::sleep(Duration::from_millis(2_000)).await; - - match result.stream.next().await.expect("second part") { - Ok(StreamPart::TextDelta { delta, .. }) => assert_eq!(delta, "second"), - other => panic!("expected the buffered second delta, got {other:?}"), - } - match result.stream.next().await.expect("timeout") { - Err(AiMuxError::Timeout(message)) => { - assert_eq!(message, "Chunk timeout of 1000ms exceeded"); - } - other => panic!("expected chunk timeout, got {other:?}"), - } - assert!(result.stream.next().await.is_none()); -} - -#[tokio::test(start_paused = true)] -async fn dropped_operation_leaves_no_armed_timer_state() { - // §8.0/§13-21b: deadlines live inside the pump task that drives the - // provider stream, and that task is aborted when the returned stream is - // dropped. Dropping the stream mid-flight must leave no timer that could - // later fire and mutate the caller's signal (the rejected abort_after - // design would trip this). - let caller = AbortSignal::new(); - let model = ProbeModel { - second_part_is_output: false, - }; - let options = GenerateTextOptions { - max_retries: Some(0), - abort_signal: Some(caller.clone()), - timeout: Some(TimeoutConfiguration { - total_ms: Some(5_000), - chunk_ms: Some(1_000), - ..Default::default() - }), - ..Default::default() - }; - let mut result = stream_text(&model, "hello", options).await.unwrap(); - let first = result.stream.next().await.expect("first part"); - assert!(first.is_ok()); - drop(result); - - // Sail far past every configured deadline. - tokio::time::sleep(Duration::from_secs(60)).await; - assert!(!caller.is_aborted()); -} diff --git a/aimux-providers/src/lib.rs b/aimux-providers/src/lib.rs index 90e926f31..102dec8f4 100644 --- a/aimux-providers/src/lib.rs +++ b/aimux-providers/src/lib.rs @@ -35,11 +35,10 @@ macro_rules! delegate_list_models { // The 250 per-provider `XxxConfig`/`XxxProvider` shell types were retired in // phase 4 — use [`provider`] / [`provider_from_env`] instead. pub mod provider; -pub mod provider_name; pub mod replay; pub use provider::{ ExternalProviderEntry, ProviderOptions, ProviderProfile, is_external_provider, - load_providers_from_json, provider, provider_from_env, provider_handle, + load_providers_from_json, provider, provider_from_env, provider_handle, provider_names, provider_registry_entry, register_provider, }; pub use replay::{rebuild_decision_provider, rebuild_provider}; diff --git a/aimux-providers/src/provider.rs b/aimux-providers/src/provider.rs index ea74bc148..f067b4ffd 100644 --- a/aimux-providers/src/provider.rs +++ b/aimux-providers/src/provider.rs @@ -144,7 +144,7 @@ pub struct ProviderOptions { /// - `options` overrides individual fields of the resolved entry /// (replaces the retired `with_base_url` etc.). /// - Unknown names return [`AiMuxError::NoSuchProvider`] naming the requested -/// provider; built-in names are enumerated by the generated `ProviderName` +/// provider; built-in names are available through [`provider_names`] /// (overlay-registered names are not). /// /// # Errors @@ -417,7 +417,7 @@ pub fn provider_handle( let entry = registry().iter().find(|e| e.name == name).ok_or_else(|| { AiMuxError::NoSuchProvider { // Display derives from the id alone; valid names are discoverable - // via the generated `ProviderName` — listing 250 names here would + // via `provider_names()` — listing 250 names here would // ride along in every error, across the C ABI. provider_id: name.to_string(), } @@ -594,6 +594,11 @@ pub fn provider_from_env( provider(name, None, model_id, options) } +/// Names of all built-in registry providers. +pub fn provider_names() -> impl Iterator { + registry().iter().map(|entry| entry.name.as_str()) +} + /// Public lookup of a registered provider's runtime profile — used by tests /// that assert registry wiring (e.g. `max_tokens_key`) without constructing a /// model. Returns `None` for unknown provider names. @@ -608,7 +613,6 @@ pub fn provider_registry_entry(name: &str) -> Option { #[cfg(test)] mod tests { use super::*; - use crate::provider_name::ProviderName; #[test] fn provider_builds_groq_model() { @@ -637,23 +641,12 @@ mod tests { } #[test] - fn provider_accepts_typed_and_string_names() { - // Both spellings work: typed ProviderName (recommended) and string. - let typed = match provider( - ProviderName::Groq, - Some("sk-test".into()), - "llama-3.3-70b", - None, - ) { - Ok(m) => m, - Err(e) => panic!("typed name should construct: {e}"), - }; - assert_eq!(typed.model_id(), "llama-3.3-70b"); - let string = match provider("groq", Some("sk-test".into()), "llama-3.3-70b", None) { + fn provider_accepts_string_names() { + let model = match provider("groq", Some("sk-test".into()), "llama-3.3-70b", None) { Ok(m) => m, Err(e) => panic!("string name should construct: {e}"), }; - assert_eq!(string.model_id(), "llama-3.3-70b"); + assert_eq!(model.model_id(), "llama-3.3-70b"); } #[test] @@ -784,38 +777,10 @@ mod tests { } #[test] - fn provider_name_roundtrip() { - assert_eq!(ProviderName::Groq.as_str(), "groq"); - assert_eq!( - "deepseek".parse::().ok(), - Some(ProviderName::Deepseek) - ); - assert_eq!("nope".parse::().ok(), None); - assert!(ProviderName::all_names().contains("groq")); - assert_eq!(ProviderName::ALL.len(), 251); - } - - #[test] - fn provider_name_matches_registry_json() { - // Anti-drift: every registry name must exist as a ProviderName variant - // and round-trip; counts must match (guards against editing the JSON - // without regenerating provider_name.rs). - let registry: serde_json::Value = - serde_json::from_str(include_str!("provider_registry.json")) - .expect("registry JSON is valid"); - let names: Vec<&str> = registry - .as_array() - .expect("registry is an array") - .iter() - .map(|e| e["name"].as_str().expect("name is a string")) - .collect(); - assert_eq!(ProviderName::ALL.len(), names.len()); - for name in &names { - let variant = name - .parse::() - .unwrap_or_else(|_| panic!("registry name {name} missing from ProviderName")); - assert_eq!(variant.as_str(), *name); - } + fn provider_names_list_the_registry() { + let names: Vec<_> = provider_names().collect(); + assert_eq!(names.len(), registry().len()); + assert!(names.contains(&"groq")); } // ── RFC-0020: external provider overlay ──────────────────────────────── diff --git a/aimux-providers/src/provider_name.rs b/aimux-providers/src/provider_name.rs deleted file mode 100644 index 7d8467e31..000000000 --- a/aimux-providers/src/provider_name.rs +++ /dev/null @@ -1,1053 +0,0 @@ -//! Provider name enum — GENERATED by `scripts/gen_provider_names.py`. -//! Do not edit manually; single source of truth is `provider_registry.json`. - -/// Names of all built-in OpenAI-compatible providers (registry-backed). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -#[non_exhaustive] -pub enum ProviderName { - Abacus, - AbliterationAi, - Ai21, - Ai302, - AiRouter, - Aiand, - Aibadgr, - Aigc2d, - Aihubmix, - Ails, - Aiml, - AkiIo, - Albert, - Alibaba, - AlibabaCodingPlan, - AlibabaCodingPlanCn, - AlibabaTokenPlan, - AlibabaTokenPlanCn, - Ambient, - Anyapi, - Anyscale, - Apertis, - Api2d, - Api2gpt, - Apiserpent, - Atlascloud, - AtomicChat, - Auriko, - AzureAi, - Baichuan, - Baidu, - BaiduV2, - Bailing, - Baseten, - Berget, - Bigmodel, - Blueclaw, - Bytedance, - Byteplus, - Bytez, - Canopywave, - Cerebras, - Chatgpt, - Cherryin, - Chutes, - Clarifai, - Claudinio, - ClinePass, - Closeai, - CloudferroSherlock, - Cloudflare, - CloudflareWorkersAi, - Codestral, - Cometapi, - Commandcode, - Compactifai, - Copilot, - Cortecs, - Coze, - Crof, - Crossmodel, - Crusoe, - Daoxe, - Darkbloom, - Databricks, - Datarobot, - Deepbricks, - Deepinfra, - Deepseek, - Digitalocean, - Dinference, - Doubao, - Doubleword, - Drun, - Ebcloud, - Embercloud, - Empiriolabs, - Evroc, - Fastcrw, - Fastgpt, - Fastrouter, - FeatherlessAi, - Firepass, - Fireworks, - Freemodel, - Friendliai, - Frogbot, - Galadriel, - Gdc, - Gigachat, - Github, - Gmi, - Gmicloud, - Gonka24, - GradientAi, - Groq, - Helicone, - Heroku, - Hetzner, - HostedVllm, - HpcAi, - Hyperbolic, - Iflowcn, - Inception, - Inceptron, - InferenceNet, - Inferencehub, - Inferx, - Infinity, - IoNet, - Jiekou, - Kenari, - Kilo, - Kimi, - KimiForCoding, - Kiro, - KlusterAi, - Krutrim, - KuaeCloudCodingPlan, - LambdaAi, - Lemonade, - LemonfoxAi, - Libertai, - Lilac, - Lingyiwanwu, - Llama, - Llamagate, - Llmgateway, - Llmtr, - Longcat, - Lucidquery, - Lynkr, - Matterai, - Meganova, - MergeGateway, - Meta, - MetaLlama, - Mimo, - Minimax, - MinimaxCn, - MinimaxCnCodingPlan, - MinimaxCodingPlan, - Mira, - Mixlayer, - Moark, - Modal, - ModelOracleAi, - Modelscope, - Moonshotai, - MoonshotaiCn, - Morph, - Nanogpt, - Ncompass, - Nearai, - Nebius, - Neon, - Neuralwatt, - Nextbit, - NlpCloud, - NousResearch, - Novita, - Nscale, - NvidiaNim, - Oci, - Ofox, - Ohmygpt, - OllamaCloud, - Openaimax, - Openaisb, - Opencode, - OpencodeGo, - OpencodeZen, - Orcarouter, - Ovhcloud, - Parasail, - Perfxcloud, - Perplexity, - PerplexityAgent, - Petals, - Pinstripes, - Pioneer, - Poe, - Poolside, - Portkey, - Ppinfra, - Predibase, - PrivatemodeAi, - Publicai, - QihangAi, - Qihoo360, - QiniuAi, - RegoloAi, - RekaAi, - Requesty, - Reve, - RoutingRun, - Sakana, - Sambanova, - Sarvam, - Scaleway, - ScxAi, - Siliconflow, - Snowflake, - SnowflakeCortex, - Stackit, - Stepfun, - StepfunAiStepPlan, - StepfunStepPlan, - Subconscious, - Submodel, - Synthetic, - Tencent, - TencentCodingPlan, - TencentTokenPlan, - TencentTokenPlanEnterpriseAuto, - TencentTokenPlanEnterprisePro, - TencentTokenPlanGeneralPersonal, - TencentTokenPlanHyPersonal, - TencentTokenhub, - Tensormesh, - TheGridAi, - Thinkingmachines, - Tinfoil, - Togetherai, - Tokenflux, - Tokenpony, - Trustedrouter, - Tundra, - UmansAi, - Unorouter, - Upstage, - V0, - Venice, - Vercel, - Vivgrid, - VolcEngine, - Vultr, - Wafer, - Wandb, - XiaomiTokenPlanAms, - XiaomiTokenPlanCn, - XiaomiTokenPlanSgp, - Xiaomimimo, - Xpersona, - Xunfei, - Zai, - ZaiCodingPlan, - Zeldoc, - Zenmux, - ZhipuV4, - ZhipuaiCodingPlan, -} - -impl ProviderName { - /// All provider names, sorted. - pub const ALL: &'static [ProviderName] = &[ - Self::Abacus, - Self::AbliterationAi, - Self::Ai21, - Self::Ai302, - Self::AiRouter, - Self::Aiand, - Self::Aibadgr, - Self::Aigc2d, - Self::Aihubmix, - Self::Ails, - Self::Aiml, - Self::AkiIo, - Self::Albert, - Self::Alibaba, - Self::AlibabaCodingPlan, - Self::AlibabaCodingPlanCn, - Self::AlibabaTokenPlan, - Self::AlibabaTokenPlanCn, - Self::Ambient, - Self::Anyapi, - Self::Anyscale, - Self::Apertis, - Self::Api2d, - Self::Api2gpt, - Self::Apiserpent, - Self::Atlascloud, - Self::AtomicChat, - Self::Auriko, - Self::AzureAi, - Self::Baichuan, - Self::Baidu, - Self::BaiduV2, - Self::Bailing, - Self::Baseten, - Self::Berget, - Self::Bigmodel, - Self::Blueclaw, - Self::Bytedance, - Self::Byteplus, - Self::Bytez, - Self::Canopywave, - Self::Cerebras, - Self::Chatgpt, - Self::Cherryin, - Self::Chutes, - Self::Clarifai, - Self::Claudinio, - Self::ClinePass, - Self::Closeai, - Self::CloudferroSherlock, - Self::Cloudflare, - Self::CloudflareWorkersAi, - Self::Codestral, - Self::Cometapi, - Self::Commandcode, - Self::Compactifai, - Self::Copilot, - Self::Cortecs, - Self::Coze, - Self::Crof, - Self::Crossmodel, - Self::Crusoe, - Self::Daoxe, - Self::Darkbloom, - Self::Databricks, - Self::Datarobot, - Self::Deepbricks, - Self::Deepinfra, - Self::Deepseek, - Self::Digitalocean, - Self::Dinference, - Self::Doubao, - Self::Doubleword, - Self::Drun, - Self::Ebcloud, - Self::Embercloud, - Self::Empiriolabs, - Self::Evroc, - Self::Fastcrw, - Self::Fastgpt, - Self::Fastrouter, - Self::FeatherlessAi, - Self::Firepass, - Self::Fireworks, - Self::Freemodel, - Self::Friendliai, - Self::Frogbot, - Self::Galadriel, - Self::Gdc, - Self::Gigachat, - Self::Github, - Self::Gmi, - Self::Gmicloud, - Self::Gonka24, - Self::GradientAi, - Self::Groq, - Self::Helicone, - Self::Heroku, - Self::Hetzner, - Self::HostedVllm, - Self::HpcAi, - Self::Hyperbolic, - Self::Iflowcn, - Self::Inception, - Self::Inceptron, - Self::InferenceNet, - Self::Inferencehub, - Self::Inferx, - Self::Infinity, - Self::IoNet, - Self::Jiekou, - Self::Kenari, - Self::Kilo, - Self::Kimi, - Self::KimiForCoding, - Self::Kiro, - Self::KlusterAi, - Self::Krutrim, - Self::KuaeCloudCodingPlan, - Self::LambdaAi, - Self::Lemonade, - Self::LemonfoxAi, - Self::Libertai, - Self::Lilac, - Self::Lingyiwanwu, - Self::Llama, - Self::Llamagate, - Self::Llmgateway, - Self::Llmtr, - Self::Longcat, - Self::Lucidquery, - Self::Lynkr, - Self::Matterai, - Self::Meganova, - Self::MergeGateway, - Self::Meta, - Self::MetaLlama, - Self::Mimo, - Self::Minimax, - Self::MinimaxCn, - Self::MinimaxCnCodingPlan, - Self::MinimaxCodingPlan, - Self::Mira, - Self::Mixlayer, - Self::Moark, - Self::Modal, - Self::ModelOracleAi, - Self::Modelscope, - Self::Moonshotai, - Self::MoonshotaiCn, - Self::Morph, - Self::Nanogpt, - Self::Ncompass, - Self::Nearai, - Self::Nebius, - Self::Neon, - Self::Neuralwatt, - Self::Nextbit, - Self::NlpCloud, - Self::NousResearch, - Self::Novita, - Self::Nscale, - Self::NvidiaNim, - Self::Oci, - Self::Ofox, - Self::Ohmygpt, - Self::OllamaCloud, - Self::Openaimax, - Self::Openaisb, - Self::Opencode, - Self::OpencodeGo, - Self::OpencodeZen, - Self::Orcarouter, - Self::Ovhcloud, - Self::Parasail, - Self::Perfxcloud, - Self::Perplexity, - Self::PerplexityAgent, - Self::Petals, - Self::Pinstripes, - Self::Pioneer, - Self::Poe, - Self::Poolside, - Self::Portkey, - Self::Ppinfra, - Self::Predibase, - Self::PrivatemodeAi, - Self::Publicai, - Self::QihangAi, - Self::Qihoo360, - Self::QiniuAi, - Self::RegoloAi, - Self::RekaAi, - Self::Requesty, - Self::Reve, - Self::RoutingRun, - Self::Sakana, - Self::Sambanova, - Self::Sarvam, - Self::Scaleway, - Self::ScxAi, - Self::Siliconflow, - Self::Snowflake, - Self::SnowflakeCortex, - Self::Stackit, - Self::Stepfun, - Self::StepfunAiStepPlan, - Self::StepfunStepPlan, - Self::Subconscious, - Self::Submodel, - Self::Synthetic, - Self::Tencent, - Self::TencentCodingPlan, - Self::TencentTokenPlan, - Self::TencentTokenPlanEnterpriseAuto, - Self::TencentTokenPlanEnterprisePro, - Self::TencentTokenPlanGeneralPersonal, - Self::TencentTokenPlanHyPersonal, - Self::TencentTokenhub, - Self::Tensormesh, - Self::TheGridAi, - Self::Thinkingmachines, - Self::Tinfoil, - Self::Togetherai, - Self::Tokenflux, - Self::Tokenpony, - Self::Trustedrouter, - Self::Tundra, - Self::UmansAi, - Self::Unorouter, - Self::Upstage, - Self::V0, - Self::Venice, - Self::Vercel, - Self::Vivgrid, - Self::VolcEngine, - Self::Vultr, - Self::Wafer, - Self::Wandb, - Self::XiaomiTokenPlanAms, - Self::XiaomiTokenPlanCn, - Self::XiaomiTokenPlanSgp, - Self::Xiaomimimo, - Self::Xpersona, - Self::Xunfei, - Self::Zai, - Self::ZaiCodingPlan, - Self::Zeldoc, - Self::Zenmux, - Self::ZhipuV4, - Self::ZhipuaiCodingPlan, - ]; - - /// The registry name (snake_case), e.g. `ProviderName::Groq -> "groq"`. - #[must_use] - pub fn as_str(self) -> &'static str { - match self { - Self::Abacus => "abacus", - Self::AbliterationAi => "abliteration_ai", - Self::Ai21 => "ai21", - Self::Ai302 => "ai302", - Self::AiRouter => "ai_router", - Self::Aiand => "aiand", - Self::Aibadgr => "aibadgr", - Self::Aigc2d => "aigc2d", - Self::Aihubmix => "aihubmix", - Self::Ails => "ails", - Self::Aiml => "aiml", - Self::AkiIo => "aki_io", - Self::Albert => "albert", - Self::Alibaba => "alibaba", - Self::AlibabaCodingPlan => "alibaba_coding_plan", - Self::AlibabaCodingPlanCn => "alibaba_coding_plan_cn", - Self::AlibabaTokenPlan => "alibaba_token_plan", - Self::AlibabaTokenPlanCn => "alibaba_token_plan_cn", - Self::Ambient => "ambient", - Self::Anyapi => "anyapi", - Self::Anyscale => "anyscale", - Self::Apertis => "apertis", - Self::Api2d => "api2d", - Self::Api2gpt => "api2gpt", - Self::Apiserpent => "apiserpent", - Self::Atlascloud => "atlascloud", - Self::AtomicChat => "atomic_chat", - Self::Auriko => "auriko", - Self::AzureAi => "azure_ai", - Self::Baichuan => "baichuan", - Self::Baidu => "baidu", - Self::BaiduV2 => "baidu_v2", - Self::Bailing => "bailing", - Self::Baseten => "baseten", - Self::Berget => "berget", - Self::Bigmodel => "bigmodel", - Self::Blueclaw => "blueclaw", - Self::Bytedance => "bytedance", - Self::Byteplus => "byteplus", - Self::Bytez => "bytez", - Self::Canopywave => "canopywave", - Self::Cerebras => "cerebras", - Self::Chatgpt => "chatgpt", - Self::Cherryin => "cherryin", - Self::Chutes => "chutes", - Self::Clarifai => "clarifai", - Self::Claudinio => "claudinio", - Self::ClinePass => "cline_pass", - Self::Closeai => "closeai", - Self::CloudferroSherlock => "cloudferro_sherlock", - Self::Cloudflare => "cloudflare", - Self::CloudflareWorkersAi => "cloudflare_workers_ai", - Self::Codestral => "codestral", - Self::Cometapi => "cometapi", - Self::Commandcode => "commandcode", - Self::Compactifai => "compactifai", - Self::Copilot => "copilot", - Self::Cortecs => "cortecs", - Self::Coze => "coze", - Self::Crof => "crof", - Self::Crossmodel => "crossmodel", - Self::Crusoe => "crusoe", - Self::Daoxe => "daoxe", - Self::Darkbloom => "darkbloom", - Self::Databricks => "databricks", - Self::Datarobot => "datarobot", - Self::Deepbricks => "deepbricks", - Self::Deepinfra => "deepinfra", - Self::Deepseek => "deepseek", - Self::Digitalocean => "digitalocean", - Self::Dinference => "dinference", - Self::Doubao => "doubao", - Self::Doubleword => "doubleword", - Self::Drun => "drun", - Self::Ebcloud => "ebcloud", - Self::Embercloud => "embercloud", - Self::Empiriolabs => "empiriolabs", - Self::Evroc => "evroc", - Self::Fastcrw => "fastcrw", - Self::Fastgpt => "fastgpt", - Self::Fastrouter => "fastrouter", - Self::FeatherlessAi => "featherless_ai", - Self::Firepass => "firepass", - Self::Fireworks => "fireworks", - Self::Freemodel => "freemodel", - Self::Friendliai => "friendliai", - Self::Frogbot => "frogbot", - Self::Galadriel => "galadriel", - Self::Gdc => "gdc", - Self::Gigachat => "gigachat", - Self::Github => "github", - Self::Gmi => "gmi", - Self::Gmicloud => "gmicloud", - Self::Gonka24 => "gonka24", - Self::GradientAi => "gradient_ai", - Self::Groq => "groq", - Self::Helicone => "helicone", - Self::Heroku => "heroku", - Self::Hetzner => "hetzner", - Self::HostedVllm => "hosted_vllm", - Self::HpcAi => "hpc_ai", - Self::Hyperbolic => "hyperbolic", - Self::Iflowcn => "iflowcn", - Self::Inception => "inception", - Self::Inceptron => "inceptron", - Self::InferenceNet => "inference_net", - Self::Inferencehub => "inferencehub", - Self::Inferx => "inferx", - Self::Infinity => "infinity", - Self::IoNet => "io_net", - Self::Jiekou => "jiekou", - Self::Kenari => "kenari", - Self::Kilo => "kilo", - Self::Kimi => "kimi", - Self::KimiForCoding => "kimi_for_coding", - Self::Kiro => "kiro", - Self::KlusterAi => "kluster_ai", - Self::Krutrim => "krutrim", - Self::KuaeCloudCodingPlan => "kuae_cloud_coding_plan", - Self::LambdaAi => "lambda_ai", - Self::Lemonade => "lemonade", - Self::LemonfoxAi => "lemonfox_ai", - Self::Libertai => "libertai", - Self::Lilac => "lilac", - Self::Lingyiwanwu => "lingyiwanwu", - Self::Llama => "llama", - Self::Llamagate => "llamagate", - Self::Llmgateway => "llmgateway", - Self::Llmtr => "llmtr", - Self::Longcat => "longcat", - Self::Lucidquery => "lucidquery", - Self::Lynkr => "lynkr", - Self::Matterai => "matterai", - Self::Meganova => "meganova", - Self::MergeGateway => "merge_gateway", - Self::Meta => "meta", - Self::MetaLlama => "meta_llama", - Self::Mimo => "mimo", - Self::Minimax => "minimax", - Self::MinimaxCn => "minimax_cn", - Self::MinimaxCnCodingPlan => "minimax_cn_coding_plan", - Self::MinimaxCodingPlan => "minimax_coding_plan", - Self::Mira => "mira", - Self::Mixlayer => "mixlayer", - Self::Moark => "moark", - Self::Modal => "modal", - Self::ModelOracleAi => "model_oracle_ai", - Self::Modelscope => "modelscope", - Self::Moonshotai => "moonshotai", - Self::MoonshotaiCn => "moonshotai_cn", - Self::Morph => "morph", - Self::Nanogpt => "nanogpt", - Self::Ncompass => "ncompass", - Self::Nearai => "nearai", - Self::Nebius => "nebius", - Self::Neon => "neon", - Self::Neuralwatt => "neuralwatt", - Self::Nextbit => "nextbit", - Self::NlpCloud => "nlp_cloud", - Self::NousResearch => "nous_research", - Self::Novita => "novita", - Self::Nscale => "nscale", - Self::NvidiaNim => "nvidia_nim", - Self::Oci => "oci", - Self::Ofox => "ofox", - Self::Ohmygpt => "ohmygpt", - Self::OllamaCloud => "ollama_cloud", - Self::Openaimax => "openaimax", - Self::Openaisb => "openaisb", - Self::Opencode => "opencode", - Self::OpencodeGo => "opencode_go", - Self::OpencodeZen => "opencode_zen", - Self::Orcarouter => "orcarouter", - Self::Ovhcloud => "ovhcloud", - Self::Parasail => "parasail", - Self::Perfxcloud => "perfxcloud", - Self::Perplexity => "perplexity", - Self::PerplexityAgent => "perplexity_agent", - Self::Petals => "petals", - Self::Pinstripes => "pinstripes", - Self::Pioneer => "pioneer", - Self::Poe => "poe", - Self::Poolside => "poolside", - Self::Portkey => "portkey", - Self::Ppinfra => "ppinfra", - Self::Predibase => "predibase", - Self::PrivatemodeAi => "privatemode_ai", - Self::Publicai => "publicai", - Self::QihangAi => "qihang_ai", - Self::Qihoo360 => "qihoo360", - Self::QiniuAi => "qiniu_ai", - Self::RegoloAi => "regolo_ai", - Self::RekaAi => "reka_ai", - Self::Requesty => "requesty", - Self::Reve => "reve", - Self::RoutingRun => "routing_run", - Self::Sakana => "sakana", - Self::Sambanova => "sambanova", - Self::Sarvam => "sarvam", - Self::Scaleway => "scaleway", - Self::ScxAi => "scx_ai", - Self::Siliconflow => "siliconflow", - Self::Snowflake => "snowflake", - Self::SnowflakeCortex => "snowflake_cortex", - Self::Stackit => "stackit", - Self::Stepfun => "stepfun", - Self::StepfunAiStepPlan => "stepfun_ai_step_plan", - Self::StepfunStepPlan => "stepfun_step_plan", - Self::Subconscious => "subconscious", - Self::Submodel => "submodel", - Self::Synthetic => "synthetic", - Self::Tencent => "tencent", - Self::TencentCodingPlan => "tencent_coding_plan", - Self::TencentTokenPlan => "tencent_token_plan", - Self::TencentTokenPlanEnterpriseAuto => "tencent_token_plan_enterprise_auto", - Self::TencentTokenPlanEnterprisePro => "tencent_token_plan_enterprise_pro", - Self::TencentTokenPlanGeneralPersonal => "tencent_token_plan_general_personal", - Self::TencentTokenPlanHyPersonal => "tencent_token_plan_hy_personal", - Self::TencentTokenhub => "tencent_tokenhub", - Self::Tensormesh => "tensormesh", - Self::TheGridAi => "the_grid_ai", - Self::Thinkingmachines => "thinkingmachines", - Self::Tinfoil => "tinfoil", - Self::Togetherai => "togetherai", - Self::Tokenflux => "tokenflux", - Self::Tokenpony => "tokenpony", - Self::Trustedrouter => "trustedrouter", - Self::Tundra => "tundra", - Self::UmansAi => "umans_ai", - Self::Unorouter => "unorouter", - Self::Upstage => "upstage", - Self::V0 => "v0", - Self::Venice => "venice", - Self::Vercel => "vercel", - Self::Vivgrid => "vivgrid", - Self::VolcEngine => "volc_engine", - Self::Vultr => "vultr", - Self::Wafer => "wafer", - Self::Wandb => "wandb", - Self::XiaomiTokenPlanAms => "xiaomi_token_plan_ams", - Self::XiaomiTokenPlanCn => "xiaomi_token_plan_cn", - Self::XiaomiTokenPlanSgp => "xiaomi_token_plan_sgp", - Self::Xiaomimimo => "xiaomimimo", - Self::Xpersona => "xpersona", - Self::Xunfei => "xunfei", - Self::Zai => "zai", - Self::ZaiCodingPlan => "zai_coding_plan", - Self::Zeldoc => "zeldoc", - Self::Zenmux => "zenmux", - Self::ZhipuV4 => "zhipu_v4", - Self::ZhipuaiCodingPlan => "zhipuai_coding_plan", - } - } - - /// All registry names (for error messages / docs). - #[must_use] - pub const fn all_names() -> &'static str { - "abacus,abliteration_ai,ai21,ai302,ai_router,aiand,aibadgr,aigc2d,aihubmix,ails,aiml,aki_io,albert,alibaba,alibaba_coding_plan,alibaba_coding_plan_cn,alibaba_token_plan,alibaba_token_plan_cn,ambient,anyapi,anyscale,apertis,api2d,api2gpt,apiserpent,atlascloud,atomic_chat,auriko,azure_ai,baichuan,baidu,baidu_v2,bailing,baseten,berget,bigmodel,blueclaw,bytedance,byteplus,bytez,canopywave,cerebras,chatgpt,cherryin,chutes,clarifai,claudinio,cline_pass,closeai,cloudferro_sherlock,cloudflare,cloudflare_workers_ai,codestral,cometapi,commandcode,compactifai,copilot,cortecs,coze,crof,crossmodel,crusoe,daoxe,darkbloom,databricks,datarobot,deepbricks,deepinfra,deepseek,digitalocean,dinference,doubao,doubleword,drun,ebcloud,embercloud,empiriolabs,evroc,fastcrw,fastgpt,fastrouter,featherless_ai,firepass,fireworks,freemodel,friendliai,frogbot,galadriel,gdc,gigachat,github,gmi,gmicloud,gonka24,gradient_ai,groq,helicone,heroku,hetzner,hosted_vllm,hpc_ai,hyperbolic,iflowcn,inception,inceptron,inference_net,inferencehub,inferx,infinity,io_net,jiekou,kenari,kilo,kimi,kimi_for_coding,kiro,kluster_ai,krutrim,kuae_cloud_coding_plan,lambda_ai,lemonade,lemonfox_ai,libertai,lilac,lingyiwanwu,llama,llamagate,llmgateway,llmtr,longcat,lucidquery,lynkr,matterai,meganova,merge_gateway,meta,meta_llama,mimo,minimax,minimax_cn,minimax_cn_coding_plan,minimax_coding_plan,mira,mixlayer,moark,modal,model_oracle_ai,modelscope,moonshotai,moonshotai_cn,morph,nanogpt,ncompass,nearai,nebius,neon,neuralwatt,nextbit,nlp_cloud,nous_research,novita,nscale,nvidia_nim,oci,ofox,ohmygpt,ollama_cloud,openaimax,openaisb,opencode,opencode_go,opencode_zen,orcarouter,ovhcloud,parasail,perfxcloud,perplexity,perplexity_agent,petals,pinstripes,pioneer,poe,poolside,portkey,ppinfra,predibase,privatemode_ai,publicai,qihang_ai,qihoo360,qiniu_ai,regolo_ai,reka_ai,requesty,reve,routing_run,sakana,sambanova,sarvam,scaleway,scx_ai,siliconflow,snowflake,snowflake_cortex,stackit,stepfun,stepfun_ai_step_plan,stepfun_step_plan,subconscious,submodel,synthetic,tencent,tencent_coding_plan,tencent_token_plan,tencent_token_plan_enterprise_auto,tencent_token_plan_enterprise_pro,tencent_token_plan_general_personal,tencent_token_plan_hy_personal,tencent_tokenhub,tensormesh,the_grid_ai,thinkingmachines,tinfoil,togetherai,tokenflux,tokenpony,trustedrouter,tundra,umans_ai,unorouter,upstage,v0,venice,vercel,vivgrid,volc_engine,vultr,wafer,wandb,xiaomi_token_plan_ams,xiaomi_token_plan_cn,xiaomi_token_plan_sgp,xiaomimimo,xpersona,xunfei,zai,zai_coding_plan,zeldoc,zenmux,zhipu_v4,zhipuai_coding_plan" - } -} - -impl std::str::FromStr for ProviderName { - type Err = (); - - fn from_str(name: &str) -> Result { - match name { - "abacus" => Ok(Self::Abacus), - "abliteration_ai" => Ok(Self::AbliterationAi), - "ai21" => Ok(Self::Ai21), - "ai302" => Ok(Self::Ai302), - "ai_router" => Ok(Self::AiRouter), - "aiand" => Ok(Self::Aiand), - "aibadgr" => Ok(Self::Aibadgr), - "aigc2d" => Ok(Self::Aigc2d), - "aihubmix" => Ok(Self::Aihubmix), - "ails" => Ok(Self::Ails), - "aiml" => Ok(Self::Aiml), - "aki_io" => Ok(Self::AkiIo), - "albert" => Ok(Self::Albert), - "alibaba" => Ok(Self::Alibaba), - "alibaba_coding_plan" => Ok(Self::AlibabaCodingPlan), - "alibaba_coding_plan_cn" => Ok(Self::AlibabaCodingPlanCn), - "alibaba_token_plan" => Ok(Self::AlibabaTokenPlan), - "alibaba_token_plan_cn" => Ok(Self::AlibabaTokenPlanCn), - "ambient" => Ok(Self::Ambient), - "anyapi" => Ok(Self::Anyapi), - "anyscale" => Ok(Self::Anyscale), - "apertis" => Ok(Self::Apertis), - "api2d" => Ok(Self::Api2d), - "api2gpt" => Ok(Self::Api2gpt), - "apiserpent" => Ok(Self::Apiserpent), - "atlascloud" => Ok(Self::Atlascloud), - "atomic_chat" => Ok(Self::AtomicChat), - "auriko" => Ok(Self::Auriko), - "azure_ai" => Ok(Self::AzureAi), - "baichuan" => Ok(Self::Baichuan), - "baidu" => Ok(Self::Baidu), - "baidu_v2" => Ok(Self::BaiduV2), - "bailing" => Ok(Self::Bailing), - "baseten" => Ok(Self::Baseten), - "berget" => Ok(Self::Berget), - "bigmodel" => Ok(Self::Bigmodel), - "blueclaw" => Ok(Self::Blueclaw), - "bytedance" => Ok(Self::Bytedance), - "byteplus" => Ok(Self::Byteplus), - "bytez" => Ok(Self::Bytez), - "canopywave" => Ok(Self::Canopywave), - "cerebras" => Ok(Self::Cerebras), - "chatgpt" => Ok(Self::Chatgpt), - "cherryin" => Ok(Self::Cherryin), - "chutes" => Ok(Self::Chutes), - "clarifai" => Ok(Self::Clarifai), - "claudinio" => Ok(Self::Claudinio), - "cline_pass" => Ok(Self::ClinePass), - "closeai" => Ok(Self::Closeai), - "cloudferro_sherlock" => Ok(Self::CloudferroSherlock), - "cloudflare" => Ok(Self::Cloudflare), - "cloudflare_workers_ai" => Ok(Self::CloudflareWorkersAi), - "codestral" => Ok(Self::Codestral), - "cometapi" => Ok(Self::Cometapi), - "commandcode" => Ok(Self::Commandcode), - "compactifai" => Ok(Self::Compactifai), - "copilot" => Ok(Self::Copilot), - "cortecs" => Ok(Self::Cortecs), - "coze" => Ok(Self::Coze), - "crof" => Ok(Self::Crof), - "crossmodel" => Ok(Self::Crossmodel), - "crusoe" => Ok(Self::Crusoe), - "daoxe" => Ok(Self::Daoxe), - "darkbloom" => Ok(Self::Darkbloom), - "databricks" => Ok(Self::Databricks), - "datarobot" => Ok(Self::Datarobot), - "deepbricks" => Ok(Self::Deepbricks), - "deepinfra" => Ok(Self::Deepinfra), - "deepseek" => Ok(Self::Deepseek), - "digitalocean" => Ok(Self::Digitalocean), - "dinference" => Ok(Self::Dinference), - "doubao" => Ok(Self::Doubao), - "doubleword" => Ok(Self::Doubleword), - "drun" => Ok(Self::Drun), - "ebcloud" => Ok(Self::Ebcloud), - "embercloud" => Ok(Self::Embercloud), - "empiriolabs" => Ok(Self::Empiriolabs), - "evroc" => Ok(Self::Evroc), - "fastcrw" => Ok(Self::Fastcrw), - "fastgpt" => Ok(Self::Fastgpt), - "fastrouter" => Ok(Self::Fastrouter), - "featherless_ai" => Ok(Self::FeatherlessAi), - "firepass" => Ok(Self::Firepass), - "fireworks" => Ok(Self::Fireworks), - "freemodel" => Ok(Self::Freemodel), - "friendliai" => Ok(Self::Friendliai), - "frogbot" => Ok(Self::Frogbot), - "galadriel" => Ok(Self::Galadriel), - "gdc" => Ok(Self::Gdc), - "gigachat" => Ok(Self::Gigachat), - "github" => Ok(Self::Github), - "gmi" => Ok(Self::Gmi), - "gmicloud" => Ok(Self::Gmicloud), - "gonka24" => Ok(Self::Gonka24), - "gradient_ai" => Ok(Self::GradientAi), - "groq" => Ok(Self::Groq), - "helicone" => Ok(Self::Helicone), - "heroku" => Ok(Self::Heroku), - "hetzner" => Ok(Self::Hetzner), - "hosted_vllm" => Ok(Self::HostedVllm), - "hpc_ai" => Ok(Self::HpcAi), - "hyperbolic" => Ok(Self::Hyperbolic), - "iflowcn" => Ok(Self::Iflowcn), - "inception" => Ok(Self::Inception), - "inceptron" => Ok(Self::Inceptron), - "inference_net" => Ok(Self::InferenceNet), - "inferencehub" => Ok(Self::Inferencehub), - "inferx" => Ok(Self::Inferx), - "infinity" => Ok(Self::Infinity), - "io_net" => Ok(Self::IoNet), - "jiekou" => Ok(Self::Jiekou), - "kenari" => Ok(Self::Kenari), - "kilo" => Ok(Self::Kilo), - "kimi" => Ok(Self::Kimi), - "kimi_for_coding" => Ok(Self::KimiForCoding), - "kiro" => Ok(Self::Kiro), - "kluster_ai" => Ok(Self::KlusterAi), - "krutrim" => Ok(Self::Krutrim), - "kuae_cloud_coding_plan" => Ok(Self::KuaeCloudCodingPlan), - "lambda_ai" => Ok(Self::LambdaAi), - "lemonade" => Ok(Self::Lemonade), - "lemonfox_ai" => Ok(Self::LemonfoxAi), - "libertai" => Ok(Self::Libertai), - "lilac" => Ok(Self::Lilac), - "lingyiwanwu" => Ok(Self::Lingyiwanwu), - "llama" => Ok(Self::Llama), - "llamagate" => Ok(Self::Llamagate), - "llmgateway" => Ok(Self::Llmgateway), - "llmtr" => Ok(Self::Llmtr), - "longcat" => Ok(Self::Longcat), - "lucidquery" => Ok(Self::Lucidquery), - "lynkr" => Ok(Self::Lynkr), - "matterai" => Ok(Self::Matterai), - "meganova" => Ok(Self::Meganova), - "merge_gateway" => Ok(Self::MergeGateway), - "meta" => Ok(Self::Meta), - "meta_llama" => Ok(Self::MetaLlama), - "mimo" => Ok(Self::Mimo), - "minimax" => Ok(Self::Minimax), - "minimax_cn" => Ok(Self::MinimaxCn), - "minimax_cn_coding_plan" => Ok(Self::MinimaxCnCodingPlan), - "minimax_coding_plan" => Ok(Self::MinimaxCodingPlan), - "mira" => Ok(Self::Mira), - "mixlayer" => Ok(Self::Mixlayer), - "moark" => Ok(Self::Moark), - "modal" => Ok(Self::Modal), - "model_oracle_ai" => Ok(Self::ModelOracleAi), - "modelscope" => Ok(Self::Modelscope), - "moonshotai" => Ok(Self::Moonshotai), - "moonshotai_cn" => Ok(Self::MoonshotaiCn), - "morph" => Ok(Self::Morph), - "nanogpt" => Ok(Self::Nanogpt), - "ncompass" => Ok(Self::Ncompass), - "nearai" => Ok(Self::Nearai), - "nebius" => Ok(Self::Nebius), - "neon" => Ok(Self::Neon), - "neuralwatt" => Ok(Self::Neuralwatt), - "nextbit" => Ok(Self::Nextbit), - "nlp_cloud" => Ok(Self::NlpCloud), - "nous_research" => Ok(Self::NousResearch), - "novita" => Ok(Self::Novita), - "nscale" => Ok(Self::Nscale), - "nvidia_nim" => Ok(Self::NvidiaNim), - "oci" => Ok(Self::Oci), - "ofox" => Ok(Self::Ofox), - "ohmygpt" => Ok(Self::Ohmygpt), - "ollama_cloud" => Ok(Self::OllamaCloud), - "openaimax" => Ok(Self::Openaimax), - "openaisb" => Ok(Self::Openaisb), - "opencode" => Ok(Self::Opencode), - "opencode_go" => Ok(Self::OpencodeGo), - "opencode_zen" => Ok(Self::OpencodeZen), - "orcarouter" => Ok(Self::Orcarouter), - "ovhcloud" => Ok(Self::Ovhcloud), - "parasail" => Ok(Self::Parasail), - "perfxcloud" => Ok(Self::Perfxcloud), - "perplexity" => Ok(Self::Perplexity), - "perplexity_agent" => Ok(Self::PerplexityAgent), - "petals" => Ok(Self::Petals), - "pinstripes" => Ok(Self::Pinstripes), - "pioneer" => Ok(Self::Pioneer), - "poe" => Ok(Self::Poe), - "poolside" => Ok(Self::Poolside), - "portkey" => Ok(Self::Portkey), - "ppinfra" => Ok(Self::Ppinfra), - "predibase" => Ok(Self::Predibase), - "privatemode_ai" => Ok(Self::PrivatemodeAi), - "publicai" => Ok(Self::Publicai), - "qihang_ai" => Ok(Self::QihangAi), - "qihoo360" => Ok(Self::Qihoo360), - "qiniu_ai" => Ok(Self::QiniuAi), - "regolo_ai" => Ok(Self::RegoloAi), - "reka_ai" => Ok(Self::RekaAi), - "requesty" => Ok(Self::Requesty), - "reve" => Ok(Self::Reve), - "routing_run" => Ok(Self::RoutingRun), - "sakana" => Ok(Self::Sakana), - "sambanova" => Ok(Self::Sambanova), - "sarvam" => Ok(Self::Sarvam), - "scaleway" => Ok(Self::Scaleway), - "scx_ai" => Ok(Self::ScxAi), - "siliconflow" => Ok(Self::Siliconflow), - "snowflake" => Ok(Self::Snowflake), - "snowflake_cortex" => Ok(Self::SnowflakeCortex), - "stackit" => Ok(Self::Stackit), - "stepfun" => Ok(Self::Stepfun), - "stepfun_ai_step_plan" => Ok(Self::StepfunAiStepPlan), - "stepfun_step_plan" => Ok(Self::StepfunStepPlan), - "subconscious" => Ok(Self::Subconscious), - "submodel" => Ok(Self::Submodel), - "synthetic" => Ok(Self::Synthetic), - "tencent" => Ok(Self::Tencent), - "tencent_coding_plan" => Ok(Self::TencentCodingPlan), - "tencent_token_plan" => Ok(Self::TencentTokenPlan), - "tencent_token_plan_enterprise_auto" => Ok(Self::TencentTokenPlanEnterpriseAuto), - "tencent_token_plan_enterprise_pro" => Ok(Self::TencentTokenPlanEnterprisePro), - "tencent_token_plan_general_personal" => Ok(Self::TencentTokenPlanGeneralPersonal), - "tencent_token_plan_hy_personal" => Ok(Self::TencentTokenPlanHyPersonal), - "tencent_tokenhub" => Ok(Self::TencentTokenhub), - "tensormesh" => Ok(Self::Tensormesh), - "the_grid_ai" => Ok(Self::TheGridAi), - "thinkingmachines" => Ok(Self::Thinkingmachines), - "tinfoil" => Ok(Self::Tinfoil), - "togetherai" => Ok(Self::Togetherai), - "tokenflux" => Ok(Self::Tokenflux), - "tokenpony" => Ok(Self::Tokenpony), - "trustedrouter" => Ok(Self::Trustedrouter), - "tundra" => Ok(Self::Tundra), - "umans_ai" => Ok(Self::UmansAi), - "unorouter" => Ok(Self::Unorouter), - "upstage" => Ok(Self::Upstage), - "v0" => Ok(Self::V0), - "venice" => Ok(Self::Venice), - "vercel" => Ok(Self::Vercel), - "vivgrid" => Ok(Self::Vivgrid), - "volc_engine" => Ok(Self::VolcEngine), - "vultr" => Ok(Self::Vultr), - "wafer" => Ok(Self::Wafer), - "wandb" => Ok(Self::Wandb), - "xiaomi_token_plan_ams" => Ok(Self::XiaomiTokenPlanAms), - "xiaomi_token_plan_cn" => Ok(Self::XiaomiTokenPlanCn), - "xiaomi_token_plan_sgp" => Ok(Self::XiaomiTokenPlanSgp), - "xiaomimimo" => Ok(Self::Xiaomimimo), - "xpersona" => Ok(Self::Xpersona), - "xunfei" => Ok(Self::Xunfei), - "zai" => Ok(Self::Zai), - "zai_coding_plan" => Ok(Self::ZaiCodingPlan), - "zeldoc" => Ok(Self::Zeldoc), - "zenmux" => Ok(Self::Zenmux), - "zhipu_v4" => Ok(Self::ZhipuV4), - "zhipuai_coding_plan" => Ok(Self::ZhipuaiCodingPlan), - _ => Err(()), - } - } -} - -impl AsRef for ProviderName { - fn as_ref(&self) -> &str { - self.as_str() - } -} - -impl std::fmt::Display for ProviderName { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) - } -} 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 deleted file mode 100644 index 8b618fdb8..000000000 --- a/aimux-providers/tests/anthropic_aws_model_test.rs +++ /dev/null @@ -1,594 +0,0 @@ -//! Wiremock tests for the Anthropic-AWS (Claude Platform on AWS) provider. -//! -//! Tests cover: -//! - Non-streaming text generation (Anthropic Messages API JSON response) -//! - Non-streaming tool call extraction -//! - Streaming via SSE (Anthropic message events) -//! - API key authentication -//! - Error handling - -use futures::StreamExt; -use serde_json::{Value, json}; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateContent, StreamResult}; -use aimux_core::stream_part::StreamPart; -use aimux_core::types::FinishReasonUnified; - -use aimux_providers::anthropic_aws::{AnthropicAwsAuth, AnthropicAwsConfig, AnthropicAwsModel}; - -// ── Helpers ────────────────────────────────────────────────────────────────── - -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -fn make_model(server: &MockServer) -> AnthropicAwsModel { - AnthropicAwsModel::new( - "claude-sonnet-4-20250514".to_string(), - AnthropicAwsConfig { - base_url: server.uri(), - auth: AnthropicAwsAuth::ApiKey("test-api-key".to_string()), - api_version: "2023-06-01".to_string(), - workspace_id: None, - api_key_source: None, - retry_config: aimux_provider_utils::RetryConfig::default(), - }, - ) -} - -async fn mock_messages_json(server: &MockServer, status: u16, body: Value) { - Mock::given(method("POST")) - .and(path("/messages")) - .respond_with(ResponseTemplate::new(status).set_body_json(body)) - .mount(server) - .await; -} - -async fn mock_messages_sse(server: &MockServer, sse_body: &str) { - Mock::given(method("POST")) - .and(path("/messages")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body.to_string()), - ) - .mount(server) - .await; -} - -fn sse(data: &Value) -> String { - format!("data: {data}\n\n") -} - -fn sse_stream(events: &[Value]) -> String { - events.iter().map(sse).collect() -} - -fn as_text(item: &GenerateContent) -> &str { - match item { - GenerateContent::Text { text, .. } => text, - _ => panic!("expected Text content, got {item:?}"), - } -} - -fn as_tool_call(item: &GenerateContent) -> (&str, &str, &str) { - match item { - GenerateContent::ToolCall { - tool_call_id, - tool_name, - input, - .. - } => (tool_call_id, tool_name, input), - _ => panic!("expected ToolCall content, got {item:?}"), - } -} - -async fn collect_stream(result: StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -fn text_response(text: &str) -> Value { - json!({ - "id": "msg_017TfcQ4AgGxKyBduUpqYPZn", - "type": "message", - "role": "assistant", - "content": [{ "type": "text", "text": text }], - "model": "claude-sonnet-4-20250514", - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": { "input_tokens": 4, "output_tokens": 30 } - }) -} - -// ═════════════════════════════════════════════════════════════════════════════ -// doGenerate tests -// ═════════════════════════════════════════════════════════════════════════════ - -/// Test: non-streaming text generation extracts text, usage, and finish reason. -#[tokio::test] -async fn anthropic_aws_generate_text_response() { - let server = MockServer::start().await; - mock_messages_json(&server, 200, text_response("Hello from Claude on AWS!")).await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - assert_eq!(as_text(&result.content[0]), "Hello from Claude on AWS!"); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(result.usage.input_tokens.total, Some(4)); - assert_eq!(result.usage.output_tokens.total, Some(30)); - assert_eq!( - result.response.id.as_deref(), - Some("msg_017TfcQ4AgGxKyBduUpqYPZn") - ); -} - -/// Test: non-streaming tool call extraction. -#[tokio::test] -async fn anthropic_aws_generate_tool_call() { - let server = MockServer::start().await; - mock_messages_json( - &server, - 200, - json!({ - "id": "msg_001", - "type": "message", - "role": "assistant", - "content": [ - { "type": "text", "text": "I'll check that for you." }, - { - "type": "tool_use", - "id": "toolu_01A", - "name": "getWeather", - "input": { "location": "Paris" } - } - ], - "model": "claude-sonnet-4-20250514", - "stop_reason": "tool_use", - "usage": { "input_tokens": 10, "output_tokens": 20 } - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 2); - assert_eq!(as_text(&result.content[0]), "I'll check that for you."); - - let (id, name, input) = as_tool_call(&result.content[1]); - assert_eq!(id, "toolu_01A"); - assert_eq!(name, "getWeather"); - assert_eq!(input, &json!(r#"{"location":"Paris"}"#)); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); -} - -/// Test: HTTP error status is propagated. -#[tokio::test] -async fn anthropic_aws_generate_error() { - let server = MockServer::start().await; - mock_messages_json( - &server, - 429, - json!({ - "type": "error", - "error": { "type": "rate_limit_error", "message": "Too many requests" } - }), - ) - .await; - - let model = make_model(&server); - let result = model.do_generate(&default_options(test_prompt())).await; - - assert!(result.is_err(), "should return error for 429 status"); -} - -/// Test: request body uses Anthropic format with anthropic-version header. -#[tokio::test] -async fn anthropic_aws_request_body_and_headers() { - let server = MockServer::start().await; - - // Mount a mock that also verifies the x-api-key header. - Mock::given(method("POST")) - .and(path("/messages")) - .and(header("x-api-key", "test-api-key")) - .and(header("anthropic-version", "2023-06-01")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_response("OK"))) - .mount(&server) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(as_text(&result.content[0]), "OK"); - - // Verify request body format. - let body = result.request_body.expect("should have request body"); - assert_eq!(body["model"], "claude-sonnet-4-20250514"); - assert_eq!(body["messages"][0]["role"], "user"); - assert_eq!(body["messages"][0]["content"][0]["text"], "Hello"); - assert_eq!(body["max_tokens"], 64000); // model default for claude-sonnet-4 -} - -// ═════════════════════════════════════════════════════════════════════════════ -// doStream tests -// ═════════════════════════════════════════════════════════════════════════════ - -/// Test: streaming text via Anthropic SSE events. -#[tokio::test] -async fn anthropic_aws_stream_text() { - let server = MockServer::start().await; - - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_001", - "model": "claude-sonnet-4-20250514", - "usage": { "input_tokens": 3 } - } - }), - json!({ - "type": "content_block_start", - "index": 0, - "content_block": { "type": "text", "text": "" } - }), - json!({ - "type": "content_block_delta", - "index": 0, - "delta": { "type": "text_delta", "text": "Hello" } - }), - json!({ - "type": "content_block_delta", - "index": 0, - "delta": { "type": "text_delta", "text": " streamed!" } - }), - json!({ - "type": "content_block_stop", - "index": 0 - }), - json!({ - "type": "message_delta", - "delta": { "stop_reason": "end_turn" }, - "usage": { "output_tokens": 5 } - }), - json!({ "type": "message_stop" }), - ]); - - mock_messages_sse(&server, &sse_body).await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - - let text_deltas: Vec = parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect(); - assert_eq!(text_deltas, vec!["Hello", " streamed!"]); - - let finish = parts.iter().find_map(|p| match p { - StreamPart::Finish { finish_reason, .. } => Some(finish_reason.clone()), - _ => None, - }); - assert!(finish.is_some()); - assert_eq!(finish.unwrap().unified, FinishReasonUnified::Stop); -} - -/// Test: streaming tool calls via Anthropic SSE events. -#[tokio::test] -async fn anthropic_aws_stream_tool_call() { - let server = MockServer::start().await; - - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_002", - "model": "claude-sonnet-4-20250514", - "usage": { "input_tokens": 5 } - } - }), - json!({ - "type": "content_block_start", - "index": 0, - "content_block": { - "type": "tool_use", - "id": "toolu_01B", - "name": "getWeather", - "input": {} - } - }), - json!({ - "type": "content_block_delta", - "index": 0, - "delta": { "type": "input_json_delta", "partial_json": "{\"location\":" } - }), - json!({ - "type": "content_block_delta", - "index": 0, - "delta": { "type": "input_json_delta", "partial_json": "\"Berlin\"}" } - }), - json!({ - "type": "content_block_stop", - "index": 0 - }), - json!({ - "type": "message_delta", - "delta": { "stop_reason": "tool_use" }, - "usage": { "output_tokens": 10 } - }), - json!({ "type": "message_stop" }), - ]); - - mock_messages_sse(&server, &sse_body).await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - - let tool_calls: Vec<_> = parts - .iter() - .filter_map(|p| match p { - StreamPart::ToolCall { - tool_call_id, - tool_name, - input, - .. - } => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), - _ => None, - }) - .collect(); - assert_eq!(tool_calls.len(), 1); - assert_eq!(tool_calls[0].0, "toolu_01B"); - assert_eq!(tool_calls[0].1, "getWeather"); - assert_eq!( - tool_calls[0].2, - Value::String(r#"{"location":"Berlin"}"#.into()) - ); -} - -/// Test: SigV4 authentication adds Authorization header. -#[tokio::test] -async fn anthropic_aws_sigv4_auth() { - let server = MockServer::start().await; - mock_messages_json(&server, 200, text_response("Signed!")).await; - - let model = AnthropicAwsModel::new( - "claude-sonnet-4-20250514".to_string(), - AnthropicAwsConfig { - base_url: server.uri(), - auth: AnthropicAwsAuth::SigV4(aimux_providers::bedrock::AwsCredentials { - access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(), - secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(), - session_token: None, - region: "us-east-1".to_string(), - }), - api_version: "2023-06-01".to_string(), - workspace_id: None, - api_key_source: None, - retry_config: aimux_provider_utils::RetryConfig::default(), - }, - ); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed with SigV4"); - - assert_eq!(as_text(&result.content[0]), "Signed!"); -} - -// ═════════════════════════════════════════════════════════════════════════════ -// Additional cases — finish reasons, settings, usage, headers, errors. -// ═════════════════════════════════════════════════════════════════════════════ - -/// TS: max_tokens stop reason → Length -#[tokio::test] -async fn anthropic_aws_generate_finish_reason_max_tokens() { - let server = MockServer::start().await; - mock_messages_json( - &server, - 200, - json!({ - "id": "msg_max", - "type": "message", - "role": "assistant", - "content": [{ "type": "text", "text": "truncated" }], - "model": "claude-sonnet-4-20250514", - "stop_reason": "max_tokens", - "usage": { "input_tokens": 4, "output_tokens": 30 } - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Length); - assert_eq!(result.finish_reason.raw.as_deref(), Some("max_tokens")); -} - -/// TS: an unknown stop reason → Other -#[tokio::test] -async fn anthropic_aws_generate_finish_reason_unknown() { - let server = MockServer::start().await; - mock_messages_json( - &server, - 200, - json!({ - "id": "msg_unk", - "type": "message", - "role": "assistant", - "content": [{ "type": "text", "text": "hi" }], - "model": "claude-sonnet-4-20250514", - "stop_reason": "pause_turn", - "usage": { "input_tokens": 4, "output_tokens": 30 } - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Other); - assert_eq!(result.finish_reason.raw.as_deref(), Some("pause_turn")); -} - -/// TS: settings — max_tokens override, temperature, top_p land in the body. -#[tokio::test] -async fn anthropic_aws_generate_settings() { - let server = MockServer::start().await; - mock_messages_json(&server, 200, text_response("ok")).await; - - let model = make_model(&server); - let mut opts = default_options(test_prompt()); - opts.max_output_tokens = Some(1024); - opts.temperature = Some(0.5); - opts.top_p = Some(0.9); - opts.stop_sequences = Some(vec!["END".to_string()]); - - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["max_tokens"], json!(1024)); - assert!((body["temperature"].as_f64().unwrap() - 0.5).abs() < 1e-6); - assert!((body["top_p"].as_f64().unwrap() - 0.9).abs() < 1e-6); - assert_eq!(body["stop_sequences"], json!(["END"])); -} - -/// TS: a 401 response maps to `AiMuxError::ApiCall` (401 in `status_code`). -#[tokio::test] -async fn anthropic_aws_generate_auth_error() { - let server = MockServer::start().await; - mock_messages_json( - &server, - 401, - json!({ "type": "error", "error": { "type": "authentication_error", "message": "invalid x-api-key" } }), - ) - .await; - - let model = make_model(&server); - let result = model.do_generate(&default_options(test_prompt())).await; - assert!( - matches!(result, Err(ref e) if e.status_code() == Some(401)), - "expected Auth, got {result:?}" - ); -} - -/// TS: a 429 response maps to `AiMuxError::ApiCall` (429 in `status_code`). -#[tokio::test] -async fn anthropic_aws_generate_rate_limit_error() { - let server = MockServer::start().await; - mock_messages_json( - &server, - 429, - json!({ "type": "error", "error": { "type": "rate_limit_error", "message": "Too many requests" } }), - ) - .await; - - let model = make_model(&server); - let result = model.do_generate(&default_options(test_prompt())).await; - assert!( - matches!(result, Err(ref e) if e.status_code() == Some(429)), - "expected RateLimited, got {result:?}" - ); -} - -/// TS: response headers are exposed on the stream result. -#[tokio::test] -async fn anthropic_aws_stream_response_headers() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { "id": "msg_h", "model": "claude-sonnet-4-20250514", "usage": { "input_tokens": 3 } } - }), - json!({ - "type": "content_block_start", "index": 0, - "content_block": { "type": "text", "text": "" } - }), - json!({ - "type": "content_block_delta", "index": 0, - "delta": { "type": "text_delta", "text": "Hi" } - }), - json!({ "type": "content_block_stop", "index": 0 }), - json!({ - "type": "message_delta", - "delta": { "stop_reason": "end_turn" }, - "usage": { "output_tokens": 1 } - }), - json!({ "type": "message_stop" }), - ]); - Mock::given(method("POST")) - .and(path("/messages")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .insert_header("test-header", "test-value") - .set_body_string(sse_body), - ) - .mount(&server) - .await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} diff --git a/aimux-providers/tests/anthropic_cache_control_test.rs b/aimux-providers/tests/anthropic_cache_control_test.rs deleted file mode 100644 index ae63e2380..000000000 --- a/aimux-providers/tests/anthropic_cache_control_test.rs +++ /dev/null @@ -1,721 +0,0 @@ -// Panic convert wrappers are #[deprecated]; these tests still use them. -#![allow(deprecated)] -//! Rust port of the Anthropic `cache_control` tests. -//! -//! Translated from the Vercel AI SDK TypeScript test suites: -//! - `packages/anthropic/src/convert-to-anthropic-prompt.test.ts` -//! `describe('cache control')` (L3557-4263) — conversion-level tests that -//! verify `cache_control` is emitted on the correct Anthropic content blocks. -//! - `packages/anthropic/src/anthropic-language-model.test.ts` -//! `it('should support cache control')` (L2298) and -//! `it('should support cache control and return extra fields in provider metadata')` (L2457) -//! — exercised here at the conversion level (the request-body shape that -//! `doGenerate` builds), since the cache_control feature lives in -//! `convert_prompt_to_anthropic_full`. -//! -//! `cache_control` is read from `providerOptions.anthropic.cacheControl` -//! (`{ "type": "ephemeral" }`) at three levels: -//! 1. **Part-level** — on `ContentPart` (`Text`, `ToolCall`, `ToolResult`, …). -//! 2. **Message-level** — on `LanguageModelPromptMessage.provider_options`, -//! applied to the last part of the message. -//! 3. **Tool-result output-level** — on a structured `output.providerOptions` -//! or on the first content part of a `content` output. -//! -//! A `CacheControlValidator` caps the total breakpoints at 4 and rejects -//! `cache_control` on thinking / redacted-thinking blocks (emitting a warning). - -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::LanguageModelPromptMessage; -use aimux_core::message::Role; -use aimux_core::types::Warning; -use aimux_providers::anthropic::convert::convert_prompt_to_anthropic_full; -use serde_json::{Value, json}; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -fn msg(role: Role, parts: Vec) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role, - content: parts, - provider_options: None, - } -} - -/// Build a message with message-level `provider_options` (for message-level -/// cache_control). -fn msg_with_opts(role: Role, parts: Vec, opts: Value) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role, - content: parts, - provider_options: Some(opts), - } -} - -fn prompt(msgs: Vec) -> Vec { - msgs -} - -fn convert_full( - msgs: Vec, - send_reasoning: bool, -) -> aimux_providers::anthropic::convert::AnthropicPromptConversion { - convert_prompt_to_anthropic_full(&msgs, send_reasoning) -} - -/// The message-level / part-level `providerOptions` shape carrying -/// `anthropic.cacheControl`. -fn cache_control_opts(cache_control: Value) -> Value { - json!({ "anthropic": { "cacheControl": cache_control } }) -} - -/// Create a text part with `provider_options` set (for part-level cache_control). -fn text_part_with_cache_control(t: &str, cache_control: Value) -> ContentPart { - ContentPart::Text { - text: t.to_string(), - provider_options: Some(cache_control_opts(cache_control)), - } -} - -/// Extract the content array from the first message in the conversion result. -fn first_message_content( - result: &aimux_providers::anthropic::convert::AnthropicPromptConversion, -) -> &Vec { - result - .messages - .first() - .expect("expected at least one message") - .get("content") - .and_then(|c| c.as_array()) - .expect("expected content array") -} - -/// Extract the first system block from the conversion result. -fn first_system_block( - result: &aimux_providers::anthropic::convert::AnthropicPromptConversion, -) -> &Value { - result - .system - .as_ref() - .expect("expected system blocks") - .first() - .expect("expected at least one system block") -} - -// =========================================================================== -// describe('cache control') > describe('system message') -// =========================================================================== - -mod system_message { - use super::*; - - // TS: "should set cache_control on system message with message cache control" - // - // Message-level `providerOptions.anthropic.cacheControl` on a system - // message. The cache_control is applied to the system text block. - #[test] - fn should_set_cache_control_on_system_message_with_message_cache_control() { - let p = prompt(vec![msg_with_opts( - Role::System, - vec![ContentPart::text("system message")], - cache_control_opts(json!({ "type": "ephemeral" })), - )]); - let result = convert_full(p, true); - let system = first_system_block(&result); - assert_eq!( - system.get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - } -} - -// =========================================================================== -// describe('cache control') > describe('user message') -// =========================================================================== - -mod user_message { - use super::*; - - // TS: "should set cache_control on user message part with part cache control" - // - // Part-level `providerOptions.anthropic.cacheControl` on a text part. - #[test] - fn should_set_cache_control_on_user_message_part_with_part_cache_control() { - let p = prompt(vec![msg( - Role::User, - vec![text_part_with_cache_control( - "test", - json!({ "type": "ephemeral" }), - )], - )]); - let result = convert_full(p, true); - let content = first_message_content(&result); - assert_eq!(content.len(), 1); - assert_eq!(content[0].get("type"), Some(&json!("text"))); - assert_eq!(content[0].get("text"), Some(&json!("test"))); - assert_eq!( - content[0].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - } - - // TS: "should set cache_control on last user message part with message cache control" - // - // Message-level `providerOptions.anthropic.cacheControl` applied to the - // last part of the message. - #[test] - fn should_set_cache_control_on_last_user_message_part_with_message_cache_control() { - let p = prompt(vec![msg_with_opts( - Role::User, - vec![ContentPart::text("part1"), ContentPart::text("part2")], - cache_control_opts(json!({ "type": "ephemeral" })), - )]); - let result = convert_full(p, true); - let content = first_message_content(&result); - assert_eq!(content.len(), 2); - // First part: no cache_control - assert!(content[0].get("cache_control").is_none()); - // Last part: cache_control from message-level providerOptions - assert_eq!( - content[1].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - } -} - -// =========================================================================== -// describe('cache control') > describe('assistant message') -// =========================================================================== - -mod assistant_message { - use super::*; - - // TS: "should set cache_control on assistant message text part with part cache control" - // - // Part-level `providerOptions.anthropic.cacheControl` on an assistant text part. - #[test] - fn should_set_cache_control_on_assistant_message_text_part_with_part_cache_control() { - let p = prompt(vec![ - msg(Role::User, vec![ContentPart::text("user-content")]), - msg( - Role::Assistant, - vec![text_part_with_cache_control( - "test", - json!({ "type": "ephemeral" }), - )], - ), - ]); - let result = convert_full(p, true); - // messages[0] = user, messages[1] = assistant - let assistant_content = result - .messages - .get(1) - .expect("expected assistant message") - .get("content") - .and_then(|c| c.as_array()) - .expect("expected content array"); - assert_eq!(assistant_content.len(), 1); - assert_eq!(assistant_content[0].get("type"), Some(&json!("text"))); - assert_eq!(assistant_content[0].get("text"), Some(&json!("test"))); - assert_eq!( - assistant_content[0].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - } - - // TS: "should set cache_control on assistant tool call part with part cache control" - // - // Part-level `providerOptions` on a `tool-call` content part. - #[test] - fn should_set_cache_control_on_assistant_tool_call_part_with_part_cache_control() { - let p = prompt(vec![ - msg(Role::User, vec![ContentPart::text("user-content")]), - msg( - Role::Assistant, - vec![ContentPart::ToolCall { - tool_call_id: "test-id".to_string(), - tool_name: "test-tool".to_string(), - input: json!({ "some": "arg" }), - provider_executed: None, - thought_signature: None, - provider_options: Some(cache_control_opts(json!({ "type": "ephemeral" }))), - }], - ), - ]); - let result = convert_full(p, true); - let assistant_content = result - .messages - .get(1) - .expect("expected assistant message") - .get("content") - .and_then(|c| c.as_array()) - .expect("expected content array"); - assert_eq!(assistant_content[0].get("type"), Some(&json!("tool_use"))); - assert_eq!(assistant_content[0].get("name"), Some(&json!("test-tool"))); - assert_eq!(assistant_content[0].get("id"), Some(&json!("test-id"))); - assert_eq!( - assistant_content[0].get("input"), - Some(&json!({ "some": "arg" })) - ); - assert_eq!( - assistant_content[0].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - } - - // TS: "should wrap non-object (invalid) tool call input in an object" - // - // This lives inside the `describe('assistant message')` cache_control block - // in the TS source. The same case is also covered in - // `anthropic_convert_test.rs`; reproduced here for parity with the TS suite. - #[test] - fn should_wrap_non_object_invalid_tool_call_input_in_an_object() { - let p = prompt(vec![ - msg(Role::User, vec![ContentPart::text("user-content")]), - msg( - Role::Assistant, - vec![ContentPart::tool_call( - "test-id".to_string(), - "test-tool".to_string(), - json!("invalid"), - )], - ), - ]); - let result = convert_full(p, true); - let assistant_content = result - .messages - .get(1) - .expect("expected assistant message") - .get("content") - .and_then(|c| c.as_array()) - .expect("expected content array"); - assert_eq!(assistant_content[0].get("type"), Some(&json!("tool_use"))); - assert_eq!( - assistant_content[0].get("input"), - Some(&json!({ "rawInvalidInput": "invalid" })) - ); - } - - // TS: "should set cache_control on last assistant message part with message cache control" - // - // Message-level `providerOptions.anthropic.cacheControl`. - #[test] - fn should_set_cache_control_on_last_assistant_message_part_with_message_cache_control() { - let p = prompt(vec![ - msg(Role::User, vec![ContentPart::text("user-content")]), - msg_with_opts( - Role::Assistant, - vec![ContentPart::text("part1"), ContentPart::text("part2")], - cache_control_opts(json!({ "type": "ephemeral" })), - ), - ]); - let result = convert_full(p, true); - let assistant_content = result - .messages - .get(1) - .expect("expected assistant message") - .get("content") - .and_then(|c| c.as_array()) - .expect("expected content array"); - assert_eq!(assistant_content.len(), 2); - // First part: no cache_control - assert!(assistant_content[0].get("cache_control").is_none()); - // Last part: cache_control from message-level providerOptions - assert_eq!( - assistant_content[1].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - } -} - -// =========================================================================== -// describe('cache control') > describe('tool message') -// =========================================================================== - -mod tool_message { - use super::*; - - // TS: "should set cache_control on tool result message part with part cache control" - // - // Part-level `providerOptions` on a `tool-result` content part. - #[test] - fn should_set_cache_control_on_tool_result_message_part_with_part_cache_control() { - let p = prompt(vec![msg( - Role::Tool, - vec![ContentPart::ToolResult { - tool_call_id: "test".to_string(), - result: json!({ "test": "test" }), - tool_name: None, - is_error: None, - preliminary: None, - dynamic: None, - provider_options: Some(cache_control_opts(json!({ "type": "ephemeral" }))), - }], - )]); - let result = convert_full(p, true); - let content = first_message_content(&result); - assert_eq!(content[0].get("type"), Some(&json!("tool_result"))); - assert_eq!(content[0].get("tool_use_id"), Some(&json!("test"))); - assert_eq!( - content[0].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - } - - // TS: "should set cache_control on tool result with output cache control" - // - // The `output` of a tool-result carries `providerOptions` (here a `text` - // output with `providerOptions.anthropic.cacheControl`). - #[test] - fn should_set_cache_control_on_tool_result_with_output_cache_control() { - let p = prompt(vec![msg( - Role::Tool, - vec![ContentPart::tool_result( - "test".to_string(), - json!({ - "type": "text", - "value": "test", - "providerOptions": cache_control_opts(json!({ "type": "ephemeral" })), - }), - )], - )]); - let result = convert_full(p, true); - let content = first_message_content(&result); - assert_eq!(content[0].get("type"), Some(&json!("tool_result"))); - assert_eq!( - content[0].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - } - - // TS: "should set cache_control on tool result with content output cache control" - // - // The `output` is a `content` output whose first content part carries - // `providerOptions.anthropic.cacheControl`. - #[test] - fn should_set_cache_control_on_tool_result_with_content_output_cache_control() { - let p = prompt(vec![msg( - Role::Tool, - vec![ContentPart::tool_result( - "test".to_string(), - json!({ - "type": "content", - "value": [{ - "type": "text", - "text": "test", - "providerOptions": cache_control_opts(json!({ "type": "ephemeral" })), - }], - }), - )], - )]); - let result = convert_full(p, true); - let content = first_message_content(&result); - assert_eq!(content[0].get("type"), Some(&json!("tool_result"))); - assert_eq!( - content[0].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - } - - // TS: "should set cache_control on last tool result message part with message cache control" - // - // Message-level `providerOptions.anthropic.cacheControl`. - #[test] - fn should_set_cache_control_on_last_tool_result_message_part_with_message_cache_control() { - let p = prompt(vec![msg_with_opts( - Role::Tool, - vec![ - ContentPart::tool_result("part1".to_string(), json!({ "test": "part1" })), - ContentPart::tool_result("part2".to_string(), json!({ "test": "part2" })), - ], - cache_control_opts(json!({ "type": "ephemeral" })), - )]); - let result = convert_full(p, true); - let content = first_message_content(&result); - assert_eq!(content.len(), 2); - // First part: no cache_control - assert!(content[0].get("cache_control").is_none()); - // Last part: cache_control from message-level providerOptions - assert_eq!( - content[1].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - } -} - -// =========================================================================== -// describe('cache control') > describe('cache control validation') -// =========================================================================== - -mod cache_control_validation { - use super::*; - - // TS: "should reject cache_control on thinking blocks" - // - // When a reasoning part (with a signature → thinking block) has - // `providerOptions.anthropic.cacheControl`, the CacheControlValidator emits - // a warning and does NOT include cache_control on the thinking block. - #[test] - fn should_reject_cache_control_on_thinking_blocks() { - let p = prompt(vec![msg( - Role::Assistant, - vec![ContentPart::Reasoning { - text: "thinking content".to_string(), - signature: Some("test-sig".to_string()), - provider_options: Some(cache_control_opts(json!({ "type": "ephemeral" }))), - }], - )]); - let result = convert_full(p, true); - let content = first_message_content(&result); - assert_eq!(content[0].get("type"), Some(&json!("thinking"))); - assert_eq!(content[0].get("thinking"), Some(&json!("thinking content"))); - assert_eq!(content[0].get("signature"), Some(&json!("test-sig"))); - // cache_control should NOT be present on thinking blocks - assert!(content[0].get("cache_control").is_none()); - - // A warning should be emitted about the rejected cache_control. - let has_warning = result.warnings.iter().any(|w| match w { - Warning::Unsupported { - feature, details, .. - } => { - feature == "cache_control on non-cacheable context" - && details - .as_deref() - .is_some_and(|d| d.contains("thinking block")) - } - _ => false, - }); - assert!( - has_warning, - "expected a cache_control rejection warning, got: {:?}", - result.warnings - ); - } - - // TS: "should reject cache_control on redacted thinking blocks" - // - // A reasoning part with `providerOptions.anthropic.redactedData` (and no - // signature) becomes a `redacted_thinking` block; cache_control is rejected. - #[test] - fn should_reject_cache_control_on_redacted_thinking_blocks() { - let p = prompt(vec![msg( - Role::Assistant, - vec![ContentPart::Reasoning { - text: "redacted".to_string(), - signature: None, - provider_options: Some(json!({ - "anthropic": { - "redactedData": "abc123", - "cacheControl": { "type": "ephemeral" }, - } - })), - }], - )]); - let result = convert_full(p, true); - let content = first_message_content(&result); - assert_eq!(content[0].get("type"), Some(&json!("redacted_thinking"))); - assert_eq!(content[0].get("data"), Some(&json!("abc123"))); - // cache_control should NOT be present on redacted thinking blocks - assert!(content[0].get("cache_control").is_none()); - - let has_warning = result.warnings.iter().any(|w| match w { - Warning::Unsupported { - feature, details, .. - } => { - feature == "cache_control on non-cacheable context" - && details - .as_deref() - .is_some_and(|d| d.contains("redacted thinking block")) - } - _ => false, - }); - assert!( - has_warning, - "expected a cache_control rejection warning, got: {:?}", - result.warnings - ); - } -} - -// =========================================================================== -// describe('cache control') > "should limit cache breakpoints to 4" -// =========================================================================== - -mod breakpoint_limit { - use super::*; - - // TS: "should limit cache breakpoints to 4" - // - // The CacheControlValidator tracks the number of cache_control breakpoints - // across the entire prompt and rejects any beyond the 4th. The first 4 - // get cache_control; the 5th is rejected with a warning. - #[test] - fn should_limit_cache_breakpoints_to_4() { - // Build a prompt with 5 cache_control breakpoints: - // 1. system 1 (cache_control) - // 2. system 2 (cache_control) - // 3. user 1 (cache_control) - // 4. assistant 1 (cache_control) - // 5. user 2 (cache_control — should be rejected) - let p = prompt(vec![ - msg( - Role::System, - vec![text_part_with_cache_control( - "system 1", - json!({ "type": "ephemeral" }), - )], - ), - msg( - Role::System, - vec![text_part_with_cache_control( - "system 2", - json!({ "type": "ephemeral" }), - )], - ), - msg( - Role::User, - vec![text_part_with_cache_control( - "user 1", - json!({ "type": "ephemeral" }), - )], - ), - msg( - Role::Assistant, - vec![text_part_with_cache_control( - "assistant 1", - json!({ "type": "ephemeral" }), - )], - ), - msg( - Role::User, - vec![text_part_with_cache_control( - "user 2 (should be rejected)", - json!({ "type": "ephemeral" }), - )], - ), - ]); - let result = convert_full(p, true); - - // First 4 should have cache_control - let system = result.system.as_ref().expect("expected system blocks"); - assert_eq!(system.len(), 2); - assert_eq!( - system[0].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - assert_eq!( - system[1].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - - let msg0_content = result - .messages - .first() - .expect("expected message 0") - .get("content") - .and_then(|c| c.as_array()) - .expect("expected content array"); - assert_eq!( - msg0_content[0].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - - let msg1_content = result - .messages - .get(1) - .expect("expected message 1") - .get("content") - .and_then(|c| c.as_array()) - .expect("expected content array"); - assert_eq!( - msg1_content[0].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - - // 5th should be rejected (no cache_control) - let msg2_content = result - .messages - .get(2) - .expect("expected message 2") - .get("content") - .and_then(|c| c.as_array()) - .expect("expected content array"); - assert!( - msg2_content[0].get("cache_control").is_none(), - "5th breakpoint should be rejected" - ); - - // Should have warning about exceeding limit - let has_limit_warning = result.warnings.iter().any(|w| match w { - Warning::Unsupported { - feature, details, .. - } => { - feature == "cacheControl breakpoint limit" - && details - .as_deref() - .is_some_and(|d| d.contains("Maximum 4 cache breakpoints exceeded")) - } - _ => false, - }); - assert!( - has_limit_warning, - "expected a breakpoint limit warning, got: {:?}", - result.warnings - ); - } -} - -// =========================================================================== -// Integration tests from anthropic-language-model.test.ts -// -// The TS integration tests exercise the full `doGenerate` path with a mock -// server. The cache_control feature itself lives in -// `convert_prompt_to_anthropic_full` (the request-body builder), so these are -// exercised here at the conversion level — verifying the request shape that -// `doGenerate` would send. -// =========================================================================== - -mod integration { - use super::*; - - // TS: "should support cache control" (L2298) - // - // A user message with message-level `providerOptions.anthropic.cacheControl` - // produces a request whose text block carries `cache_control`. - #[test] - fn should_support_cache_control() { - let p = prompt(vec![msg_with_opts( - Role::User, - vec![ContentPart::text("Hello")], - cache_control_opts(json!({ "type": "ephemeral" })), - )]); - let result = convert_full(p, true); - let content = first_message_content(&result); - assert_eq!(content[0].get("type"), Some(&json!("text"))); - assert_eq!(content[0].get("text"), Some(&json!("Hello"))); - assert_eq!( - content[0].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - } - - // TS: "should support cache control and return extra fields in provider metadata" (L2457) - // - // Same as above but with `ttl: '1h'`; the request block carries the full - // `cache_control` object including `ttl`. - #[test] - fn should_support_cache_control_and_return_extra_fields_in_provider_metadata() { - let p = prompt(vec![msg_with_opts( - Role::User, - vec![ContentPart::text("Hello")], - cache_control_opts(json!({ "type": "ephemeral", "ttl": "1h" })), - )]); - let result = convert_full(p, true); - let content = first_message_content(&result); - assert_eq!( - content[0].get("cache_control"), - Some(&json!({ "type": "ephemeral", "ttl": "1h" })) - ); - } -} diff --git a/aimux-providers/tests/anthropic_convert_full_test.rs b/aimux-providers/tests/anthropic_convert_full_test.rs deleted file mode 100644 index 8fa080e20..000000000 --- a/aimux-providers/tests/anthropic_convert_full_test.rs +++ /dev/null @@ -1,778 +0,0 @@ -// Panic convert wrappers are #[deprecated]; these tests still use them. -#![allow(deprecated)] -//! Rust port of the remaining `convert-to-anthropic-prompt.test.ts` cases that -//! exercise features now supported by the Rust data model: mid-conversation -//! system messages, trailing-whitespace trimming, reasoning/thinking parts, URL -//! & base64 file parts, PDF beta, top-level media-type detection, and -//! provider-referenced files. -//! -//! Cases that remain out of scope (they need richer per-part provider options or -//! server-tool content parts not present in the Rust model) are documented in -//! the task summary: cache_control, citations, mid-conversation tool-changes, -//! server tools (web_search/web_fetch/code_execution/mcp/advisor), and -//! tool-result `content` outputs whose inner parts need conversion. - -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::LanguageModelPromptMessage; -use aimux_core::message::Role; -use aimux_core::types::Warning; -use aimux_providers::anthropic::convert::convert_prompt_to_anthropic_full; -use serde_json::{Value, json}; -use std::collections::BTreeSet; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -fn msg(role: Role, parts: Vec) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role, - content: parts, - provider_options: None, - } -} - -fn text_part(t: &str) -> ContentPart { - ContentPart::text(t) -} - -fn prompt(msgs: Vec) -> Vec { - msgs -} - -fn convert_full( - msgs: Vec, - send_reasoning: bool, -) -> aimux_providers::anthropic::convert::AnthropicPromptConversion { - convert_prompt_to_anthropic_full(&msgs, send_reasoning) -} - -fn assert_other_warning(warnings: &[Warning], message: &str) { - let found = warnings.iter().any(|w| match w { - Warning::Other { message: m } => m == message, - _ => false, - }); - assert!( - found, - "expected a Warning::Other with message {message:?}, got {warnings:?}" - ); -} - -fn reasoning_part(text: &str, signature: Option<&str>) -> ContentPart { - ContentPart::Reasoning { - text: text.to_string(), - signature: signature.map(std::string::ToString::to_string), - provider_options: None, - } -} - -fn file_url(url: &str, media_type: &str) -> ContentPart { - ContentPart::file_url(url.to_string(), media_type.to_string()) -} - -fn file_base64(data: &str, media_type: &str) -> ContentPart { - ContentPart::file_base64(data.to_string(), media_type.to_string()) -} - -fn file_bytes(data: &[u8], media_type: &str) -> ContentPart { - ContentPart::file(data.to_vec(), media_type.to_string()) -} - -fn file_bytes_named(data: &[u8], media_type: &str, filename: &str) -> ContentPart { - ContentPart::File { - data: data.to_vec(), - media_type: media_type.to_string(), - filename: Some(filename.to_string()), - provider_options: None, - } -} - -fn file_ref(media_type: &str, reference: Value) -> ContentPart { - ContentPart::file_reference(media_type.to_string(), reference) -} - -// PNG magic bytes and their base64 encoding (matches the TS `pngBase64`). -const PNG_BYTES: [u8; 8] = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; -const PNG_BASE64: &str = "iVBORw0KGgo="; -// PDF magic bytes and their base64 encoding (matches the TS `pdfBase64`). -const PDF_BYTES: [u8; 5] = [0x25, 0x50, 0x44, 0x46, 0x2d]; -const PDF_BASE64: &str = "JVBERi0="; - -// --------------------------------------------------------------------------- -// mid-conversation system -// --------------------------------------------------------------------------- - -#[test] -fn should_emit_a_mid_conversation_system_message_inline_and_add_the_beta() { - let p = prompt(vec![ - msg(Role::System, vec![text_part("initial")]), - msg(Role::User, vec![text_part("hi")]), - msg(Role::Assistant, vec![text_part("hello")]), - msg(Role::System, vec![text_part("switch tone")]), - msg(Role::User, vec![text_part("go")]), - ]); - let result = convert_full(p, true); - assert_eq!( - result.system, - Some(vec![json!({ "type": "text", "text": "initial" })]) - ); - assert!( - result.messages.iter().any(|m| m - == &json!({ - "role": "system", - "content": [{ "type": "text", "text": "switch tone" }] - })), - "expected an inline system message, got {:?}", - result.messages - ); - assert!(result.betas.contains("mid-conversation-system-2026-04-07")); -} - -// --------------------------------------------------------------------------- -// trailing whitespace -// --------------------------------------------------------------------------- - -#[test] -fn should_remove_trailing_whitespace_from_last_assistant_message_when_no_further_user_message() { - let p = prompt(vec![ - msg(Role::User, vec![text_part("user content")]), - msg(Role::Assistant, vec![text_part("assistant content ")]), - ]); - let result = convert_full(p, true); - assert_eq!( - result.messages, - vec![ - json!({ "role": "user", "content": [{ "type": "text", "text": "user content" }] }), - json!({ "role": "assistant", "content": [{ "type": "text", "text": "assistant content" }] }), - ] - ); -} - -#[test] -fn should_remove_trailing_whitespace_from_last_assistant_message_with_multi_part_content() { - let p = prompt(vec![ - msg(Role::User, vec![text_part("user content")]), - msg( - Role::Assistant, - vec![text_part("assistant "), text_part("content ")], - ), - ]); - let result = convert_full(p, true); - assert_eq!( - result.messages, - vec![ - json!({ "role": "user", "content": [{ "type": "text", "text": "user content" }] }), - json!({ - "role": "assistant", - "content": [ - { "type": "text", "text": "assistant " }, - { "type": "text", "text": "content" }, - ] - }), - ] - ); -} - -#[test] -fn should_keep_trailing_whitespace_from_assistant_message_when_there_is_a_further_user_message() { - let p = prompt(vec![ - msg(Role::User, vec![text_part("user content")]), - msg(Role::Assistant, vec![text_part("assistant content ")]), - msg(Role::User, vec![text_part("user content 2")]), - ]); - let result = convert_full(p, true); - assert_eq!( - result.messages, - vec![ - json!({ "role": "user", "content": [{ "type": "text", "text": "user content" }] }), - json!({ "role": "assistant", "content": [{ "type": "text", "text": "assistant content " }] }), - json!({ "role": "user", "content": [{ "type": "text", "text": "user content 2" }] }), - ] - ); -} - -// --------------------------------------------------------------------------- -// reasoning / thinking -// --------------------------------------------------------------------------- - -#[test] -fn should_convert_reasoning_parts_with_signature_into_thinking_parts_when_send_reasoning_is_true() { - let p = prompt(vec![msg( - Role::Assistant, - vec![ - reasoning_part( - "I need to count the number of \"r\"s in the word \"strawberry\".", - Some("test-signature"), - ), - text_part("The word \"strawberry\" has 2 \"r\"s."), - ], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages, - vec![json!({ - "role": "assistant", - "content": [ - { - "type": "thinking", - "thinking": "I need to count the number of \"r\"s in the word \"strawberry\".", - "signature": "test-signature", - }, - { "type": "text", "text": "The word \"strawberry\" has 2 \"r\"s." }, - ] - })] - ); - assert!(result.warnings.is_empty()); -} - -#[test] -fn should_ignore_reasoning_parts_without_signature_when_send_reasoning_is_true() { - let p = prompt(vec![msg( - Role::Assistant, - vec![ - reasoning_part( - "I need to count the number of \"r\"s in the word \"strawberry\".", - None, - ), - text_part("The word \"strawberry\" has 2 \"r\"s."), - ], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages, - vec![json!({ - "role": "assistant", - "content": [ - { "type": "text", "text": "The word \"strawberry\" has 2 \"r\"s." }, - ] - })] - ); - assert_eq!(result.warnings.len(), 1); - assert_other_warning(&result.warnings, "unsupported reasoning metadata"); -} - -#[test] -fn should_omit_reasoning_parts_with_signature_when_send_reasoning_is_false() { - let p = prompt(vec![msg( - Role::Assistant, - vec![ - reasoning_part( - "I need to count the number of \"r\"s in the word \"strawberry\".", - Some("test-signature"), - ), - text_part("The word \"strawberry\" has 2 \"r\"s."), - ], - )]); - let result = convert_full(p, false); - assert_eq!( - result.messages, - vec![json!({ - "role": "assistant", - "content": [ - { "type": "text", "text": "The word \"strawberry\" has 2 \"r\"s." }, - ] - })] - ); - assert_eq!(result.warnings.len(), 1); - assert_other_warning( - &result.warnings, - "sending reasoning content is disabled for this model", - ); -} - -#[test] -fn should_omit_reasoning_parts_without_signature_when_send_reasoning_is_false() { - let p = prompt(vec![msg( - Role::Assistant, - vec![ - reasoning_part( - "I need to count the number of \"r\"s in the word \"strawberry\".", - None, - ), - text_part("The word \"strawberry\" has 2 \"r\"s."), - ], - )]); - let result = convert_full(p, false); - assert_eq!( - result.messages, - vec![json!({ - "role": "assistant", - "content": [ - { "type": "text", "text": "The word \"strawberry\" has 2 \"r\"s." }, - ] - })] - ); - assert_eq!(result.warnings.len(), 1); - assert_other_warning( - &result.warnings, - "sending reasoning content is disabled for this model", - ); -} - -// --------------------------------------------------------------------------- -// Extended-thinking input echo (build_request_body_with_warnings wiring) -// --------------------------------------------------------------------------- - -#[test] -fn thinking_enabled_echoes_reasoning_parts_as_thinking_blocks() { - use aimux_core::options::CallOptions; - use aimux_providers::anthropic::convert::build_request_body_with_warnings; - - let mut options = CallOptions::new(vec![msg( - Role::Assistant, - vec![ - reasoning_part("Let me weigh the options.", Some("sig-echo-1")), - text_part("Here is the answer."), - ], - )]); - options.provider_options = Some( - [( - "anthropic".to_string(), - json!({ "thinking": { "type": "enabled", "budgetTokens": 1024 } }), - )] - .into_iter() - .collect(), - ); - let result = - build_request_body_with_warnings("claude-sonnet-4-20250514", &options, false).unwrap(); - let blocks = result.body["messages"][0]["content"].as_array().unwrap(); - let thinking = blocks - .iter() - .find(|b| b["type"] == "thinking") - .expect("reasoning part must be echoed as a thinking block"); - assert_eq!(thinking["signature"], "sig-echo-1"); - assert_eq!(thinking["thinking"], "Let me weigh the options."); - assert!( - !result - .warnings - .iter() - .any(|w| matches!(w, Warning::Other { message } if message.contains("disabled"))), - "no 'disabled' warning when thinking is enabled: {:?}", - result.warnings - ); -} - -#[test] -fn thinking_disabled_omits_reasoning_parts() { - use aimux_core::options::CallOptions; - use aimux_providers::anthropic::convert::build_request_body_with_warnings; - - let mut options = CallOptions::new(vec![msg( - Role::Assistant, - vec![ - reasoning_part("Let me weigh the options.", Some("sig-echo-1")), - text_part("Here is the answer."), - ], - )]); - options.provider_options = Some( - [( - "anthropic".to_string(), - json!({ "thinking": { "type": "disabled" } }), - )] - .into_iter() - .collect(), - ); - let result = - build_request_body_with_warnings("claude-sonnet-4-20250514", &options, false).unwrap(); - let blocks = result.body["messages"][0]["content"].as_array().unwrap(); - assert!( - !blocks.iter().any(|b| b["type"] == "thinking"), - "thinking block must be omitted when thinking is disabled" - ); -} - -// --------------------------------------------------------------------------- -// URL images -// --------------------------------------------------------------------------- - -#[test] -fn should_add_image_parts_for_url_images() { - let p = prompt(vec![msg( - Role::User, - vec![file_url("https://example.com/image.png", "image/*")], - )]); - let result = convert_full(p, true); - assert!(result.system.is_none()); - assert_eq!( - result.messages, - vec![json!({ - "role": "user", - "content": [{ - "type": "image", - "source": { "type": "url", "url": "https://example.com/image.png" }, - }], - })] - ); - assert!(result.betas.is_empty()); -} - -#[test] -fn should_treat_url_strings_in_image_file_data_as_urls_not_base64() { - let p = prompt(vec![msg( - Role::User, - vec![file_url("https://example.com/image.png", "image/png")], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages, - vec![json!({ - "role": "user", - "content": [{ - "type": "image", - "source": { "type": "url", "url": "https://example.com/image.png" }, - }], - })] - ); -} - -#[test] -fn passes_through_url_for_top_level_only_image() { - let p = prompt(vec![msg( - Role::User, - vec![file_url("https://example.com/x.png", "image")], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages[0]["content"][0], - json!({ - "type": "image", - "source": { "type": "url", "url": "https://example.com/x.png" }, - }) - ); -} - -// --------------------------------------------------------------------------- -// URL PDFs -// --------------------------------------------------------------------------- - -#[test] -fn should_treat_url_strings_in_pdf_file_data_as_urls_not_base64() { - let p = prompt(vec![msg( - Role::User, - vec![file_url( - "https://example.com/document.pdf", - "application/pdf", - )], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages, - vec![json!({ - "role": "user", - "content": [{ - "type": "document", - "source": { "type": "url", "url": "https://example.com/document.pdf" }, - }], - })] - ); - let mut expected_betas = BTreeSet::new(); - expected_betas.insert("pdfs-2024-09-25".to_string()); - assert_eq!(result.betas, expected_betas); -} - -#[test] -fn should_add_pdf_file_parts_for_url_pdfs() { - let p = prompt(vec![msg( - Role::User, - vec![file_url( - "https://example.com/document.pdf", - "application/pdf", - )], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages[0]["content"][0], - json!({ - "type": "document", - "source": { "type": "url", "url": "https://example.com/document.pdf" }, - }) - ); - assert!(result.betas.contains("pdfs-2024-09-25")); -} - -// --------------------------------------------------------------------------- -// base64 PDFs -// --------------------------------------------------------------------------- - -#[test] -fn should_add_pdf_file_parts_for_base64_pdfs() { - // The TS test uses the opaque placeholder string 'base64PDFdata'; the Rust - // `FileBase64` variant holds the base64 string verbatim (it is NOT - // decoded/re-encoded), so the same placeholder round-trips exactly. - let p = prompt(vec![msg( - Role::User, - vec![file_base64("base64PDFdata", "application/pdf")], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages, - vec![json!({ - "role": "user", - "content": [{ - "type": "document", - "source": { - "type": "base64", - "media_type": "application/pdf", - "data": "base64PDFdata", - }, - }], - })] - ); - assert!(result.betas.contains("pdfs-2024-09-25")); -} - -// --------------------------------------------------------------------------- -// text/plain documents (filename -> title) -// --------------------------------------------------------------------------- - -#[test] -fn should_add_text_file_parts_for_text_plain_documents() { - let p = prompt(vec![msg( - Role::User, - vec![file_bytes_named( - b"sample text content", - "text/plain", - "sample.txt", - )], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages[0]["content"][0], - json!({ - "type": "document", - "source": { "type": "text", "media_type": "text/plain", "data": "sample text content" }, - "title": "sample.txt", - }) - ); - assert!(result.betas.is_empty()); -} - -#[test] -fn should_map_inline_text_file_parts_to_inline_text_document_source() { - let p = prompt(vec![msg( - Role::User, - vec![file_bytes_named( - b"inline text content", - "text/plain", - "inline.txt", - )], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages[0]["content"][0], - json!({ - "type": "document", - "source": { "type": "text", "media_type": "text/plain", "data": "inline text content" }, - "title": "inline.txt", - }) - ); -} - -#[test] -fn still_routes_text_plain_inline_text_through_document_source() { - let p = prompt(vec![msg( - Role::User, - vec![file_bytes(b"hello", "text/plain")], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages[0]["content"][0], - json!({ - "type": "document", - "source": { "type": "text", "media_type": "text/plain", "data": "hello" }, - }) - ); -} - -// --------------------------------------------------------------------------- -// unsupported file types -// --------------------------------------------------------------------------- - -#[test] -#[should_panic(expected = "media type: video/mp4")] -fn should_throw_error_for_unsupported_file_types() { - let p = prompt(vec![msg( - Role::User, - vec![file_base64("base64data", "video/mp4")], - )]); - let _ = convert_full(p, true); -} - -// --------------------------------------------------------------------------- -// top-level-only media type resolution (byte sniffing) -// --------------------------------------------------------------------------- - -#[test] -fn detects_image_subtype_from_inline_bytes_for_top_level_image() { - let p = prompt(vec![msg( - Role::User, - vec![file_base64(PNG_BASE64, "image")], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages[0]["content"][0], - json!({ - "type": "image", - "source": { "type": "base64", "media_type": "image/png", "data": PNG_BASE64 }, - }) - ); -} - -#[test] -fn normalizes_image_star_via_detection() { - let p = prompt(vec![msg( - Role::User, - vec![file_base64(PNG_BASE64, "image/*")], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages[0]["content"][0]["source"]["media_type"], - json!("image/png") - ); -} - -#[test] -fn detects_pdf_subtype_from_inline_bytes_for_top_level_application() { - let p = prompt(vec![msg( - Role::User, - vec![file_base64(PDF_BASE64, "application")], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages[0]["content"][0]["source"]["media_type"], - json!("application/pdf") - ); - assert!(result.betas.contains("pdfs-2024-09-25")); -} - -#[test] -fn preserves_full_image_png_pass_through() { - let p = prompt(vec![msg( - Role::User, - vec![file_base64(PNG_BASE64, "image/png")], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages[0]["content"][0], - json!({ - "type": "image", - "source": { "type": "base64", "media_type": "image/png", "data": PNG_BASE64 }, - }) - ); -} - -#[test] -fn detects_image_subtype_from_inline_bytes_for_top_level_image_via_file_bytes() { - // Same as above but through the raw-bytes `File` variant, which re-encodes - // the bytes to base64 (rather than passing a string through verbatim). - let p = prompt(vec![msg(Role::User, vec![file_bytes(&PNG_BYTES, "image")])]); - let result = convert_full(p, true); - assert_eq!( - result.messages[0]["content"][0], - json!({ - "type": "image", - "source": { "type": "base64", "media_type": "image/png", "data": PNG_BASE64 }, - }) - ); -} - -#[test] -fn detects_pdf_subtype_from_inline_bytes_via_file_bytes() { - let p = prompt(vec![msg( - Role::User, - vec![file_bytes(&PDF_BYTES, "application")], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages[0]["content"][0]["source"]["media_type"], - json!("application/pdf") - ); - assert!(result.betas.contains("pdfs-2024-09-25")); -} - -// --------------------------------------------------------------------------- -// provider-referenced files -// --------------------------------------------------------------------------- - -#[test] -fn should_convert_messages_with_image_file_parts_using_provider_reference() { - let p = prompt(vec![msg( - Role::User, - vec![file_ref( - "image/png", - json!({ "anthropic": "file-img-12345" }), - )], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages, - vec![json!({ - "role": "user", - "content": [{ - "type": "image", - "source": { "type": "file", "file_id": "file-img-12345" }, - }], - })] - ); - assert!(result.betas.contains("files-api-2025-04-14")); -} - -#[test] -fn should_convert_messages_with_pdf_file_parts_using_provider_reference() { - let p = prompt(vec![msg( - Role::User, - vec![file_ref( - "application/pdf", - json!({ "anthropic": "file-pdf-12345" }), - )], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages[0]["content"][0], - json!({ - "type": "document", - "source": { "type": "file", "file_id": "file-pdf-12345" }, - }) - ); - assert!(result.betas.contains("files-api-2025-04-14")); -} - -#[test] -fn should_convert_provider_referenced_file_parts_to_container_uploads_when_requested() { - let part = ContentPart::FileReference { - media_type: "text/csv".to_string(), - reference: json!({ "anthropic": "file-csv-12345" }), - filename: None, - provider_options: Some(json!({ "anthropic": { "containerUpload": true } })), - }; - let p = prompt(vec![msg( - Role::User, - vec![text_part("Analyze this data."), part], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages, - vec![json!({ - "role": "user", - "content": [ - { "type": "text", "text": "Analyze this data." }, - { "type": "container_upload", "file_id": "file-csv-12345" }, - ], - })] - ); - assert!(result.betas.contains("files-api-2025-04-14")); -} - -#[test] -#[should_panic(expected = "No provider reference found for provider 'anthropic'")] -fn should_throw_when_provider_reference_does_not_contain_anthropic_key() { - let p = prompt(vec![msg( - Role::User, - vec![file_ref("application/pdf", json!({ "openai": "file-xyz" }))], - )]); - let _ = convert_full(p, true); -} - -// Keep the `Value` import used even if some tests only use `json!`. -#[allow(dead_code)] -fn _ensure_value_import_used(_v: Value) {} diff --git a/aimux-providers/tests/anthropic_convert_test.rs b/aimux-providers/tests/anthropic_convert_test.rs deleted file mode 100644 index 8dcba218f..000000000 --- a/aimux-providers/tests/anthropic_convert_test.rs +++ /dev/null @@ -1,1025 +0,0 @@ -// Panic convert wrappers are #[deprecated]; these tests still use them. -#![allow(deprecated)] -//! Rust port of the Anthropic provider pure-function tests. -//! -//! Translated from the Vercel AI SDK TypeScript tests: -//! - `packages/anthropic/src/convert-anthropic-usage.test.ts` (15 cases) -//! - `packages/anthropic/src/anthropic-prepare-tools.test.ts` (function-tool + -//! tool-choice subset) -//! - `packages/anthropic/src/convert-to-anthropic-prompt.test.ts` (subset -//! supported by the current Rust data model) -//! -//! Tests that exercise features absent from the Rust `aimux-core` data model -//! (reasoning parts, cache control, URL images, provider-defined tools, -//! mid-conversation system messages, citations, ...) are intentionally omitted -//! and documented in the task summary rather than translated here. - -use std::collections::HashMap; - -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::LanguageModelPromptMessage; -use aimux_core::message::Role; -use aimux_core::tool::{FunctionTool, ToolChoice}; -use aimux_core::types::Warning; -use aimux_providers::anthropic::convert::convert_prompt_to_anthropic; -use aimux_providers::anthropic::prepare_tools::prepare_tools; -use aimux_providers::anthropic::usage::{ - AnthropicInputTokens, AnthropicOutputTokens, convert_anthropic_usage, -}; -use base64::Engine; -use serde_json::{Value, json}; - -// ───────────────────────────────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────────────────────────────── - -fn msg(role: Role, parts: Vec) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role, - content: parts, - provider_options: None, - } -} - -fn text_part(t: &str) -> ContentPart { - ContentPart::text(t.to_string()) -} - -fn prompt(msgs: Vec) -> Vec { - msgs -} - -/// Build a function tool with the common defaults (no strict, no provider -/// options, no input examples). -fn ftool(name: &str, desc: &str, schema: Value) -> FunctionTool { - FunctionTool::new(name.to_string(), schema).with_description(desc.to_string()) -} - -fn anthropic_opts(value: Value) -> Option> { - let mut map = HashMap::new(); - map.insert("anthropic".to_string(), value); - Some(map) -} - -/// Decode a base64 string into raw bytes (mirrors the TS `data: { type: 'data', -/// data: '' }` input form, which the Rust `ContentPart::Image` holds as -/// raw bytes). -fn decode_b64(s: &str) -> Vec { - base64::engine::general_purpose::STANDARD - .decode(s) - .expect("valid base64") -} - -// ═════════════════════════════════════════════════════════════════════════════ -// convertAnthropicUsage (15 cases) -// ═════════════════════════════════════════════════════════════════════════════ - -#[cfg(test)] -mod convert_anthropic_usage_tests { - use super::*; - - #[test] - fn should_use_usage_as_raw_when_raw_usage_is_not_provided() { - let usage = json!({ "input_tokens": 10, "output_tokens": 20 }); - let result = convert_anthropic_usage(&usage, None); - assert_eq!(result.raw, usage); - } - - #[test] - fn should_use_raw_usage_as_raw_when_provided() { - let usage = json!({ "input_tokens": 10, "output_tokens": 20 }); - let raw_usage = json!({ - "input_tokens": 10, - "output_tokens": 20, - "service_tier": "standard", - "inference_geo": "not_available", - "cache_creation": { - "ephemeral_5m_input_tokens": 0, - "ephemeral_1h_input_tokens": 0, - }, - }); - let result = convert_anthropic_usage(&usage, Some(&raw_usage)); - assert_eq!(result.raw, raw_usage); - } - - #[test] - fn should_compute_token_totals_correctly_with_cache_tokens() { - let usage = json!({ - "input_tokens": 10, - "output_tokens": 20, - "cache_creation_input_tokens": 5, - "cache_read_input_tokens": 3, - }); - let result = convert_anthropic_usage(&usage, None); - assert_eq!( - result.input_tokens, - AnthropicInputTokens { - total: 18, - no_cache: 10, - cache_read: 3, - cache_write: 5 - } - ); - assert_eq!( - result.output_tokens, - AnthropicOutputTokens { - total: 20, - text: None, - reasoning: None - } - ); - } - - #[test] - fn should_handle_null_cache_tokens() { - let usage = json!({ - "input_tokens": 100, - "output_tokens": 50, - "cache_creation_input_tokens": null, - "cache_read_input_tokens": null, - }); - let result = convert_anthropic_usage(&usage, None); - assert_eq!(result.input_tokens.total, 100); - assert_eq!(result.input_tokens.cache_read, 0); - assert_eq!(result.input_tokens.cache_write, 0); - } - - #[test] - fn should_sum_across_all_iterations_when_iterations_array_is_present() { - let usage = json!({ - "input_tokens": 45000, - "output_tokens": 1234, - "iterations": [ - { "type": "compaction", "input_tokens": 180000, "output_tokens": 3500 }, - { "type": "message", "input_tokens": 23000, "output_tokens": 1000 }, - ], - }); - let result = convert_anthropic_usage(&usage, None); - // Total should be sum of iterations, not top-level values. - assert_eq!(result.input_tokens.total, 203000); // 180000 + 23000 - assert_eq!(result.input_tokens.no_cache, 203000); - assert_eq!(result.output_tokens.total, 4500); // 3500 + 1000 - assert_eq!(result.raw, usage); - } - - #[test] - fn should_handle_single_iteration_message_only_no_compaction_triggered() { - let usage = json!({ - "input_tokens": 5000, - "output_tokens": 500, - "iterations": [ - { "type": "message", "input_tokens": 5000, "output_tokens": 500 }, - ], - }); - let result = convert_anthropic_usage(&usage, None); - assert_eq!(result.input_tokens.total, 5000); - assert_eq!(result.output_tokens.total, 500); - } - - #[test] - fn should_handle_multiple_compaction_iterations_long_running_task() { - let usage = json!({ - "input_tokens": 10000, - "output_tokens": 500, - "iterations": [ - { "type": "compaction", "input_tokens": 200000, "output_tokens": 4000 }, - { "type": "message", "input_tokens": 50000, "output_tokens": 2000 }, - { "type": "compaction", "input_tokens": 180000, "output_tokens": 3500 }, - { "type": "message", "input_tokens": 30000, "output_tokens": 1500 }, - ], - }); - let result = convert_anthropic_usage(&usage, None); - // Total = 200000 + 50000 + 180000 + 30000 = 460000 input - assert_eq!(result.input_tokens.total, 460000); - // Total = 4000 + 2000 + 3500 + 1500 = 11000 output - assert_eq!(result.output_tokens.total, 11000); - } - - #[test] - fn should_combine_iterations_with_cache_tokens() { - let usage = json!({ - "input_tokens": 45000, - "output_tokens": 1234, - "cache_creation_input_tokens": 1000, - "cache_read_input_tokens": 500, - "iterations": [ - { "type": "compaction", "input_tokens": 180000, "output_tokens": 3500 }, - { "type": "message", "input_tokens": 23000, "output_tokens": 1000 }, - ], - }); - let result = convert_anthropic_usage(&usage, None); - // noCache = sum of iterations only - assert_eq!(result.input_tokens.no_cache, 203000); // 180000 + 23000 - assert_eq!(result.input_tokens.cache_write, 1000); - assert_eq!(result.input_tokens.cache_read, 500); - assert_eq!(result.input_tokens.total, 204500); // 203000 + 1000 + 500 - assert_eq!(result.output_tokens.total, 4500); // 3500 + 1000 - } - - #[test] - fn should_use_raw_usage_as_raw_even_when_iterations_are_present() { - let usage = json!({ - "input_tokens": 45000, - "output_tokens": 1234, - "iterations": [ - { "type": "compaction", "input_tokens": 180000, "output_tokens": 3500 }, - { "type": "message", "input_tokens": 23000, "output_tokens": 1000 }, - ], - }); - let raw_usage = json!({ - "input_tokens": 45000, - "output_tokens": 1234, - "service_tier": "standard", - }); - let result = convert_anthropic_usage(&usage, Some(&raw_usage)); - assert_eq!(result.raw, raw_usage); - assert_eq!(result.input_tokens.total, 203000); - } - - #[test] - fn should_use_top_level_values_when_iterations_is_null() { - let usage = json!({ - "input_tokens": 100, - "output_tokens": 50, - "iterations": null, - }); - let result = convert_anthropic_usage(&usage, None); - assert_eq!(result.input_tokens.total, 100); - assert_eq!(result.output_tokens.total, 50); - } - - #[test] - fn should_use_top_level_values_when_iterations_is_undefined() { - let usage = json!({ "input_tokens": 100, "output_tokens": 50 }); - let result = convert_anthropic_usage(&usage, None); - assert_eq!(result.input_tokens.total, 100); - assert_eq!(result.output_tokens.total, 50); - } - - #[test] - fn should_use_top_level_values_when_iterations_array_is_empty() { - let usage = json!({ - "input_tokens": 100, - "output_tokens": 50, - "iterations": [], - }); - let result = convert_anthropic_usage(&usage, None); - assert_eq!(result.input_tokens.total, 100); - assert_eq!(result.output_tokens.total, 50); - } - - #[test] - fn should_handle_zero_tokens_in_iterations() { - let usage = json!({ - "input_tokens": 0, - "output_tokens": 0, - "iterations": [ - { "type": "compaction", "input_tokens": 0, "output_tokens": 0 }, - { "type": "message", "input_tokens": 0, "output_tokens": 0 }, - ], - }); - let result = convert_anthropic_usage(&usage, None); - assert_eq!(result.input_tokens.total, 0); - assert_eq!(result.output_tokens.total, 0); - } - - #[test] - fn should_match_documentation_example_exactly() { - let usage = json!({ - "input_tokens": 45000, - "output_tokens": 1234, - "iterations": [ - { "type": "compaction", "input_tokens": 180000, "output_tokens": 3500 }, - { "type": "message", "input_tokens": 23000, "output_tokens": 1000 }, - ], - }); - let result = convert_anthropic_usage(&usage, None); - let expected_total_input = 180000 + 23000; // 203000 - let expected_total_output = 3500 + 1000; // 4500 - assert_eq!(result.input_tokens.total, expected_total_input); - assert_eq!(result.output_tokens.total, expected_total_output); - // The top-level values (45000, 1234) are NOT the billed amounts when - // iterations is present. - assert_ne!( - result.input_tokens.total, - usage["input_tokens"].as_u64().unwrap() - ); - assert_ne!( - result.output_tokens.total, - usage["output_tokens"].as_u64().unwrap() - ); - } - - #[test] - fn should_handle_re_applying_previous_compaction_block_no_new_compaction() { - let usage = json!({ - "input_tokens": 15000, - "output_tokens": 800, - }); - let result = convert_anthropic_usage(&usage, None); - // Top-level values are accurate when no new compaction triggered. - assert_eq!(result.input_tokens.total, 15000); - assert_eq!(result.output_tokens.total, 800); - } -} - -// ═════════════════════════════════════════════════════════════════════════════ -// prepareTools (function-tool + tool-choice subset) -// ═════════════════════════════════════════════════════════════════════════════ - -#[cfg(test)] -mod prepare_tools_tests { - use super::*; - - fn assert_strict_warning(warnings: &[Warning], tool_name: &str, strict_val: bool) { - assert_eq!(warnings.len(), 1, "expected exactly one warning"); - match &warnings[0] { - Warning::Unsupported { feature, details } => { - assert_eq!(feature, "strict"); - let expected = format!( - "Tool '{tool_name}' has strict: {strict_val}, but strict mode is not supported by this provider. The strict property will be ignored." - ); - assert_eq!(details.as_ref().unwrap(), &expected); - } - other => panic!("expected Unsupported warning, got {other:?}"), - } - } - - #[test] - fn should_return_undefined_tools_and_tool_choice_when_tools_are_null() { - let result = prepare_tools(None, None, false, true, true, false); - assert!(result.tools.is_none()); - assert!(result.tool_choice.is_none()); - assert!(result.tool_warnings.is_empty()); - assert!(result.betas.is_empty()); - } - - #[test] - fn should_return_undefined_tools_and_tool_choice_when_tools_are_empty() { - let tools: Vec = vec![]; - let result = prepare_tools(Some(&tools), None, false, true, true, false); - assert!(result.tools.is_none()); - assert!(result.tool_choice.is_none()); - assert!(result.tool_warnings.is_empty()); - assert!(result.betas.is_empty()); - } - - #[test] - fn should_correctly_prepare_function_tools() { - let mut tool = ftool( - "testFunction", - "A test function", - json!({ "type": "object", "properties": {} }), - ); - tool.provider_options = anthropic_opts(json!({ "eagerInputStreaming": true })); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, true, true, false); - - let expected = json!({ - "name": "testFunction", - "description": "A test function", - "input_schema": { "type": "object", "properties": {} }, - "eager_input_streaming": true, - }); - assert_eq!(result.tools.unwrap()[0], expected); - assert!(result.tool_choice.is_none()); - assert!(result.tool_warnings.is_empty()); - } - - #[test] - fn should_correctly_preserve_tool_input_examples() { - let mut tool = ftool( - "tool_with_examples", - "tool with examples", - json!({ "type": "object", "properties": { "a": { "type": "number" } } }), - ); - tool.input_examples = Some(vec![ - json!({ "input": { "a": 1 } }), - json!({ "input": { "a": 2 } }), - ]); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, true, true, false); - - assert!(result.betas.contains("structured-outputs-2025-11-13")); - assert!(result.betas.contains("advanced-tool-use-2025-11-20")); - assert!(result.tool_choice.is_none()); - assert!(result.tool_warnings.is_empty()); - - let expected = json!({ - "name": "tool_with_examples", - "description": "tool with examples", - "input_schema": { "type": "object", "properties": { "a": { "type": "number" } } }, - "input_examples": [{ "a": 1 }, { "a": 2 }], - }); - assert_eq!(result.tools.unwrap()[0], expected); - } - - // ── strict mode for function tools ── - - #[test] - fn strict_included_and_beta_when_supports_structured_output_and_strict_true() { - let mut tool = ftool( - "testFunction", - "A test function", - json!({ "type": "object", "properties": {} }), - ); - tool.strict = Some(true); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, true, true, false); - - let expected = json!({ - "name": "testFunction", - "description": "A test function", - "input_schema": { "type": "object", "properties": {} }, - "strict": true, - }); - assert_eq!(result.tools.unwrap()[0], expected); - assert!(result.betas.contains("structured-outputs-2025-11-13")); - assert_eq!(result.betas.len(), 1); - assert!(result.tool_warnings.is_empty()); - } - - #[test] - fn beta_but_not_strict_when_strict_undefined_and_supports_structured_output() { - let tool = ftool( - "testFunction", - "A test function", - json!({ "type": "object", "properties": {} }), - ); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, true, true, false); - - let expected = json!({ - "name": "testFunction", - "description": "A test function", - "input_schema": { "type": "object", "properties": {} }, - }); - assert_eq!(result.tools.unwrap()[0], expected); - assert!(result.betas.contains("structured-outputs-2025-11-13")); - assert!(result.tool_warnings.is_empty()); - } - - #[test] - fn no_strict_emit_warning_no_beta_when_both_supports_flags_false() { - let mut tool = ftool( - "testFunction", - "A test function", - json!({ "type": "object", "properties": {} }), - ); - tool.strict = Some(true); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, false, false, false); - - let expected = json!({ - "name": "testFunction", - "description": "A test function", - "input_schema": { "type": "object", "properties": {} }, - }); - assert_eq!(result.tools.unwrap()[0], expected); - assert!(result.betas.is_empty()); - assert_strict_warning(&result.tool_warnings, "testFunction", true); - } - - #[test] - fn strict_but_no_beta_when_supports_structured_output_false_but_strict_tools_true() { - let mut tool = ftool( - "testFunction", - "A test function", - json!({ "type": "object", "properties": {} }), - ); - tool.strict = Some(true); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, false, true, false); - - let expected = json!({ - "name": "testFunction", - "description": "A test function", - "input_schema": { "type": "object", "properties": {} }, - "strict": true, - }); - assert_eq!(result.tools.unwrap()[0], expected); - assert!(result.betas.is_empty()); - assert!(result.tool_warnings.is_empty()); - } - - #[test] - fn beta_when_strict_false_and_supports_structured_output_true() { - let mut tool = ftool( - "testFunction", - "A test function", - json!({ "type": "object", "properties": {} }), - ); - tool.strict = Some(false); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, true, true, false); - - let expected = json!({ - "name": "testFunction", - "description": "A test function", - "input_schema": { "type": "object", "properties": {} }, - "strict": false, - }); - assert_eq!(result.tools.unwrap()[0], expected); - assert!(result.betas.contains("structured-outputs-2025-11-13")); - assert!(result.tool_warnings.is_empty()); - } - - // ── deferLoading for function tools ── - - #[test] - fn should_include_defer_loading_when_set_to_true() { - let mut tool = ftool( - "testFunction", - "A test function", - json!({ "type": "object", "properties": {} }), - ); - tool.provider_options = anthropic_opts(json!({ "deferLoading": true })); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, true, true, false); - - let expected = json!({ - "name": "testFunction", - "description": "A test function", - "input_schema": { "type": "object", "properties": {} }, - "defer_loading": true, - }); - assert_eq!(result.tools.unwrap()[0], expected); - assert!(result.betas.contains("structured-outputs-2025-11-13")); - assert_eq!(result.betas.len(), 1); - } - - #[test] - fn should_include_defer_loading_when_set_to_false() { - let mut tool = ftool( - "testFunction", - "A test function", - json!({ "type": "object", "properties": {} }), - ); - tool.provider_options = anthropic_opts(json!({ "deferLoading": false })); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, true, true, false); - - let expected = json!({ - "name": "testFunction", - "description": "A test function", - "input_schema": { "type": "object", "properties": {} }, - "defer_loading": false, - }); - assert_eq!(result.tools.unwrap()[0], expected); - } - - #[test] - fn should_not_include_defer_loading_when_not_specified() { - let tool = ftool( - "testFunction", - "A test function", - json!({ "type": "object", "properties": {} }), - ); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, true, true, false); - let tool_def = result.tools.unwrap()[0].as_object().unwrap().clone(); - assert!(!tool_def.contains_key("defer_loading")); - } - - // ── allowedCallers for function tools ── - - #[test] - fn should_include_allowed_callers_and_advanced_tool_use_beta_when_set() { - let mut tool = ftool( - "query_database", - "Query a database", - json!({ "type": "object", "properties": { "sql": { "type": "string" } } }), - ); - tool.provider_options = - anthropic_opts(json!({ "allowedCallers": ["code_execution_20250825"] })); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, true, true, false); - - assert!(result.betas.contains("structured-outputs-2025-11-13")); - assert!(result.betas.contains("advanced-tool-use-2025-11-20")); - - let expected = json!({ - "name": "query_database", - "description": "Query a database", - "input_schema": { "type": "object", "properties": { "sql": { "type": "string" } } }, - "allowed_callers": ["code_execution_20250825"], - }); - assert_eq!(result.tools.unwrap()[0], expected); - } - - #[test] - fn should_not_include_allowed_callers_when_not_specified() { - let tool = ftool( - "testFunction", - "A test function", - json!({ "type": "object", "properties": {} }), - ); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, true, true, false); - let tool_def = result.tools.unwrap()[0].as_object().unwrap().clone(); - assert!(!tool_def.contains_key("allowed_callers")); - } - - #[test] - fn should_include_both_defer_loading_and_allowed_callers_when_both_set() { - let mut tool = ftool( - "query_database", - "Query a database", - json!({ "type": "object", "properties": { "sql": { "type": "string" } } }), - ); - tool.provider_options = anthropic_opts(json!({ - "deferLoading": true, - "allowedCallers": ["code_execution_20250825"], - })); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, true, true, false); - - let tool_def = result.tools.unwrap()[0].as_object().unwrap().clone(); - assert_eq!(tool_def.get("defer_loading"), Some(&json!(true))); - assert_eq!( - tool_def.get("allowed_callers"), - Some(&json!(["code_execution_20250825"])) - ); - assert!(result.betas.contains("advanced-tool-use-2025-11-20")); - } - - #[test] - fn should_include_allowed_callers_with_code_execution_20260120() { - let mut tool = ftool( - "query_database", - "Query a database", - json!({ "type": "object", "properties": { "sql": { "type": "string" } } }), - ); - tool.provider_options = - anthropic_opts(json!({ "allowedCallers": ["code_execution_20260120"] })); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, true, true, false); - - assert!(result.betas.contains("structured-outputs-2025-11-13")); - assert!(result.betas.contains("advanced-tool-use-2025-11-20")); - - let expected = json!({ - "name": "query_database", - "description": "Query a database", - "input_schema": { "type": "object", "properties": { "sql": { "type": "string" } } }, - "allowed_callers": ["code_execution_20260120"], - }); - assert_eq!(result.tools.unwrap()[0], expected); - } - - // ── tool choice ── - - #[test] - fn should_handle_tool_choice_auto() { - let tool = ftool("testFunction", "Test", json!({})); - let tools = vec![tool]; - let tc = ToolChoice::Auto; - let result = prepare_tools(Some(&tools), Some(&tc), false, true, true, false); - assert_eq!(result.tool_choice.unwrap(), json!({ "type": "auto" })); - } - - #[test] - fn should_handle_tool_choice_required() { - let tool = ftool("testFunction", "Test", json!({})); - let tools = vec![tool]; - let tc = ToolChoice::Required; - let result = prepare_tools(Some(&tools), Some(&tc), false, true, true, false); - assert_eq!(result.tool_choice.unwrap(), json!({ "type": "any" })); - } - - #[test] - fn should_handle_tool_choice_none() { - let tool = ftool("testFunction", "Test", json!({})); - let tools = vec![tool]; - let tc = ToolChoice::None; - let result = prepare_tools(Some(&tools), Some(&tc), false, true, true, false); - assert!(result.tools.is_none()); - assert!(result.tool_choice.is_none()); - } - - #[test] - fn should_handle_tool_choice_tool() { - let tool = ftool("testFunction", "Test", json!({})); - let tools = vec![tool]; - let tc = ToolChoice::Tool { - tool_name: "testFunction".to_string(), - }; - let result = prepare_tools(Some(&tools), Some(&tc), false, true, true, false); - assert_eq!( - result.tool_choice.unwrap(), - json!({ "type": "tool", "name": "testFunction" }) - ); - } -} - -// ═════════════════════════════════════════════════════════════════════════════ -// convertToAnthropicPrompt (subset supported by the Rust data model) -// ═════════════════════════════════════════════════════════════════════════════ - -#[cfg(test)] -mod convert_prompt_tests { - use super::*; - - #[test] - fn should_convert_a_single_system_message_into_an_anthropic_system_message() { - let p = prompt(vec![msg( - Role::System, - vec![text_part("This is a system message")], - )]); - let (system, messages) = convert_prompt_to_anthropic(&p); - assert_eq!( - system, - Some(vec![ - json!({ "type": "text", "text": "This is a system message" }) - ]) - ); - assert!(messages.is_empty()); - } - - #[test] - fn should_convert_multiple_system_messages_into_an_anthropic_system_message() { - let p = prompt(vec![ - msg(Role::System, vec![text_part("This is a system message")]), - msg( - Role::System, - vec![text_part("This is another system message")], - ), - ]); - let (system, messages) = convert_prompt_to_anthropic(&p); - assert_eq!( - system, - Some(vec![ - json!({ "type": "text", "text": "This is a system message" }), - json!({ "type": "text", "text": "This is another system message" }), - ]) - ); - assert!(messages.is_empty()); - } - - #[test] - fn should_add_image_parts_for_uint8array_images() { - // TS input uses `data: { type: 'data', data: 'AAECAw==' }`; the Rust - // `ContentPart::Image` holds the decoded raw bytes and re-encodes them. - let p = prompt(vec![msg( - Role::User, - vec![ContentPart::image( - decode_b64("AAECAw=="), - "image/png".to_string(), - )], - )]); - let (system, messages) = convert_prompt_to_anthropic(&p); - assert!(system.is_none()); - assert_eq!( - messages, - vec![json!({ - "role": "user", - "content": [{ - "type": "image", - "source": { - "type": "base64", - "media_type": "image/png", - "data": "AAECAw==", - }, - }], - })] - ); - } - - #[test] - fn should_convert_a_single_tool_result_into_an_anthropic_user_message() { - let p = prompt(vec![msg( - Role::Tool, - vec![ContentPart::tool_result( - "tool-call-1".to_string(), - json!({ "type": "json", "value": { "test": "This is a tool message" } }), - )], - )]); - let (system, messages) = convert_prompt_to_anthropic(&p); - assert!(system.is_none()); - assert_eq!( - messages, - vec![json!({ - "role": "user", - "content": [{ - "type": "tool_result", - "tool_use_id": "tool-call-1", - "content": "{\"test\":\"This is a tool message\"}", - }], - })] - ); - } - - #[test] - fn should_convert_multiple_tool_results_into_an_anthropic_user_message() { - let p = prompt(vec![msg( - Role::Tool, - vec![ - ContentPart::tool_result( - "tool-call-1".to_string(), - json!({ "type": "json", "value": { "test": "This is a tool message" } }), - ), - ContentPart::tool_result( - "tool-call-2".to_string(), - json!({ "type": "json", "value": { "something": "else" } }), - ), - ], - )]); - let (system, messages) = convert_prompt_to_anthropic(&p); - assert!(system.is_none()); - assert_eq!( - messages, - vec![json!({ - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": "tool-call-1", - "content": "{\"test\":\"This is a tool message\"}", - }, - { - "type": "tool_result", - "tool_use_id": "tool-call-2", - "content": "{\"something\":\"else\"}", - }, - ], - })] - ); - } - - #[test] - fn should_combine_user_and_tool_messages() { - let p = prompt(vec![ - msg( - Role::Tool, - vec![ContentPart::tool_result( - "tool-call-1".to_string(), - json!({ "type": "json", "value": { "test": "This is a tool message" } }), - )], - ), - msg(Role::User, vec![text_part("This is a user message")]), - ]); - let (system, messages) = convert_prompt_to_anthropic(&p); - assert!(system.is_none()); - assert_eq!( - messages, - vec![json!({ - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": "tool-call-1", - "content": "{\"test\":\"This is a tool message\"}", - }, - { "type": "text", "text": "This is a user message" }, - ], - })] - ); - } - - #[test] - fn should_combine_multiple_sequential_assistant_messages_into_a_single_message() { - let p = prompt(vec![ - msg(Role::User, vec![text_part("Hi!")]), - msg(Role::Assistant, vec![text_part("Hello")]), - msg(Role::Assistant, vec![text_part("World")]), - msg(Role::Assistant, vec![text_part("!")]), - ]); - let (system, messages) = convert_prompt_to_anthropic(&p); - assert!(system.is_none()); - assert_eq!( - messages, - vec![ - json!({ "role": "user", "content": [{ "type": "text", "text": "Hi!" }] }), - json!({ - "role": "assistant", - "content": [ - { "type": "text", "text": "Hello" }, - { "type": "text", "text": "World" }, - { "type": "text", "text": "!" }, - ], - }), - ] - ); - } - - #[test] - fn should_wrap_non_object_invalid_tool_call_input_in_an_object() { - let p = prompt(vec![msg( - Role::Assistant, - vec![ContentPart::tool_call( - "call-1".to_string(), - "cityAttractions".to_string(), - // malformed JSON the model produced, kept as a raw string - json!("{ \"city\": \"San Francisco\", }"), - )], - )]); - let (system, messages) = convert_prompt_to_anthropic(&p); - assert!(system.is_none()); - assert_eq!( - messages, - vec![json!({ - "role": "assistant", - "content": [{ - "type": "tool_use", - "name": "cityAttractions", - "id": "call-1", - "input": { "rawInvalidInput": "{ \"city\": \"San Francisco\", }" }, - }], - })] - ); - } - - #[test] - fn should_convert_user_assistant_tool_assistant_user_message_sequence_with_multiple_tool_calls() - { - let p = prompt(vec![ - msg( - Role::User, - vec![text_part("weather for berlin, london and paris")], - ), - msg( - Role::Assistant, - vec![ - text_part( - "I will use the weather tool to get the weather for berlin, london and paris", - ), - ContentPart::tool_call( - "weather-call-1".to_string(), - "weather".to_string(), - json!({ "location": "berlin" }), - ), - ContentPart::tool_call( - "weather-call-2".to_string(), - "weather".to_string(), - json!({ "location": "london" }), - ), - ContentPart::tool_call( - "weather-call-3".to_string(), - "weather".to_string(), - json!({ "location": "paris" }), - ), - ], - ), - msg( - Role::Tool, - vec![ - ContentPart::tool_result( - "weather-call-1".to_string(), - json!({ "type": "json", "value": { "weather": "sunny" } }), - ), - ContentPart::tool_result( - "weather-call-2".to_string(), - json!({ "type": "json", "value": { "weather": "cloudy" } }), - ), - ContentPart::tool_result( - "weather-call-3".to_string(), - json!({ "type": "json", "value": { "weather": "rainy" } }), - ), - ], - ), - msg( - Role::Assistant, - vec![text_part( - "The weather for berlin is sunny, the weather for london is cloudy, and the weather for paris is rainy", - )], - ), - msg(Role::User, vec![text_part("and for new york?")]), - ]); - let (system, messages) = convert_prompt_to_anthropic(&p); - assert!(system.is_none()); - assert_eq!( - messages, - vec![ - json!({ - "role": "user", - "content": [{ "type": "text", "text": "weather for berlin, london and paris" }], - }), - json!({ - "role": "assistant", - "content": [ - { "type": "text", "text": "I will use the weather tool to get the weather for berlin, london and paris" }, - { "type": "tool_use", "id": "weather-call-1", "name": "weather", "input": { "location": "berlin" } }, - { "type": "tool_use", "id": "weather-call-2", "name": "weather", "input": { "location": "london" } }, - { "type": "tool_use", "id": "weather-call-3", "name": "weather", "input": { "location": "paris" } }, - ], - }), - json!({ - "role": "user", - "content": [ - { "type": "tool_result", "tool_use_id": "weather-call-1", "content": "{\"weather\":\"sunny\"}" }, - { "type": "tool_result", "tool_use_id": "weather-call-2", "content": "{\"weather\":\"cloudy\"}" }, - { "type": "tool_result", "tool_use_id": "weather-call-3", "content": "{\"weather\":\"rainy\"}" }, - ], - }), - json!({ - "role": "assistant", - "content": [{ "type": "text", "text": "The weather for berlin is sunny, the weather for london is cloudy, and the weather for paris is rainy" }], - }), - json!({ - "role": "user", - "content": [{ "type": "text", "text": "and for new york?" }], - }), - ] - ); - } -} diff --git a/aimux-providers/tests/anthropic_model_test.rs b/aimux-providers/tests/anthropic_model_test.rs deleted file mode 100644 index d567328c7..000000000 --- a/aimux-providers/tests/anthropic_model_test.rs +++ /dev/null @@ -1,1965 +0,0 @@ -//! Rust port of the Anthropic provider `doGenerate` response-parsing tests and -//! `doStream` streaming tests. -//! -//! Translated from the Vercel AI SDK TypeScript test suite: -//! - `packages/anthropic/src/anthropic-language-model.test.ts` -//! `describe('doGenerate')` response-parsing subset (text / usage / -//! stop_reason / tool_calls / request body / headers / error status codes). -//! - `packages/anthropic/src/anthropic-language-model.test.ts` -//! `describe('doStream')` streaming subset (text deltas, tool-call streaming, -//! usage, finish-reason mapping, response metadata, ping, in-stream errors, -//! pre-stream HTTP errors). -//! -//! HTTP is mocked with `wiremock` (a real loopback HTTP server), replacing the -//! TS MSW-based `createTestServer`. Each test starts its own `MockServer` so -//! parallel `#[tokio::test]` runs do not collide. -//! -//! Tests that depend on features absent from the Rust data model (reasoning / -//! thinking config, refusal `stop_details`, citations, provider-executed tools, -//! MCP servers, code execution, JSON output-format tool wrapping, `raw` usage -//! field, `providerMetadata` on the finish part) are intentionally omitted and -//! documented in the task summary. - -use futures::StreamExt; -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::generate::{GenerateTextOptions, generate_text, stream_text}; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::{MessageContent, Role}; -use aimux_core::options::{CallOptions, Tool, ToolChoice}; -use aimux_core::result::{GenerateContent, StreamResult}; -use aimux_core::stream_part::StreamPart; -use aimux_core::tool::{FunctionTool, ProviderTool}; -use aimux_core::types::FinishReasonUnified; - -use aimux_providers::anthropic::AnthropicConfig; -use aimux_providers::anthropic::model::AnthropicModel; - -// ───────────────────────────────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────────────────────────────── - -/// The TS `TEST_PROMPT`: a single user text message "Hello". -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -/// Build `CallOptions` with everything unset except `prompt`. -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -/// Build an `AnthropicModel` whose base URL points at the wiremock server. -fn make_model(server: &MockServer) -> AnthropicModel { - AnthropicModel::new( - "claude-3-haiku-20240307".to_string(), - AnthropicConfig::new("test-api-key").with_base_url(server.uri()), - ) -} - -/// Mount a JSON response on `/v1/messages`. -async fn mock_json(server: &MockServer, status: u16, body: Value) { - Mock::given(method("POST")) - .and(path("/v1/messages")) - .respond_with(ResponseTemplate::new(status).set_body_json(body)) - .mount(server) - .await; -} - -/// Mount an SSE stream response on `/v1/messages`. -async fn mock_sse(server: &MockServer, sse_body: &str) { - Mock::given(method("POST")) - .and(path("/v1/messages")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body.to_string()), - ) - .mount(server) - .await; -} - -/// Format a single Anthropic SSE event as `data: {json}\n\n`. -fn sse(data: &Value) -> String { - format!("data: {data}\n\n") -} - -/// Concatenate an ordered list of SSE events into one response body. -fn sse_stream(events: &[Value]) -> String { - events.iter().map(sse).collect() -} - -/// Drain a `StreamResult` into a `Vec`, panicking on any error. -async fn collect_stream(result: StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -/// A minimal text response body (mirrors the TS `anthropic-text` shape). -fn text_response(text: &str) -> Value { - json!({ - "id": "msg_017TfcQ4AgGxKyBduUpqYPZn", - "type": "message", - "role": "assistant", - "content": [{ "type": "text", "text": text }], - "model": "claude-3-haiku-20240307", - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": { "input_tokens": 4, "output_tokens": 30 }, - }) -} - -/// Helper to extract the text from a `GenerateContent::Text` item. -fn as_text(item: &GenerateContent) -> &str { - match item { - GenerateContent::Text { text, .. } => text, - _ => panic!("expected Text content, got {item:?}"), - } -} - -/// Helper to destructure a `GenerateContent::ToolCall`. -fn as_tool_call(item: &GenerateContent) -> (&str, &str, &str) { - match item { - GenerateContent::ToolCall { - tool_call_id, - tool_name, - input, - .. - } => (tool_call_id, tool_name, input), - _ => panic!("expected ToolCall content, got {item:?}"), - } -} - -/// A function tool with the common TS test schema `{ value: string }`. -fn value_tool() -> Tool { - Tool::Function(FunctionTool::new( - "test-tool".to_string(), - json!({ - "type": "object", - "properties": { "value": { "type": "string" } }, - "required": ["value"], - "additionalProperties": false, - }), - )) -} - -// ═════════════════════════════════════════════════════════════════════════════ -// doGenerate — response parsing -// (anthropic-language-model.test.ts → describe('doGenerate')) -// ═════════════════════════════════════════════════════════════════════════════ - -mod do_generate { - use super::*; - - // ── content extraction ────────────────────────────────────────────────── - - /// TS: "should extract text response" - #[tokio::test] - async fn should_extract_text_response() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "id": "msg_017TfcQ4AgGxKyBduUpqYPZn", - "type": "message", - "role": "assistant", - "content": [{ "type": "text", "text": "Hello, World!" }], - "model": "claude-3-haiku-20240307", - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": { "input_tokens": 4, "output_tokens": 30 }, - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - assert_eq!(as_text(&result.content[0]), "Hello, World!"); - } - - /// TS: "should extract usage" - #[tokio::test] - async fn should_extract_usage() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "id": "msg_017TfcQ4AgGxKyBduUpqYPZn", - "type": "message", - "role": "assistant", - "content": [{ "type": "text", "text": "" }], - "model": "claude-3-haiku-20240307", - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": { "input_tokens": 20, "output_tokens": 5 }, - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.usage.input_tokens.total, Some(20)); - assert_eq!(result.usage.output_tokens.total, Some(5)); - } - - /// TS: "should send additional response information" - #[tokio::test] - async fn should_send_additional_response_information() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "id": "test-id", - "type": "message", - "role": "assistant", - "content": [{ "type": "text", "text": "" }], - "model": "test-model", - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": { "input_tokens": 4, "output_tokens": 30 }, - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.response.id.as_deref(), Some("test-id")); - assert_eq!(result.response.model_id.as_deref(), Some("test-model")); - assert!(result.response.timestamp.is_none()); - } - - /// TS: "should extract tool calls" — text + tool_use, finish_reason tool-calls. - #[tokio::test] - async fn should_extract_tool_calls() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "id": "msg_017TfcQ4AgGxKyBduUpqYPZn", - "type": "message", - "role": "assistant", - "content": [ - { "type": "text", "text": "Some text\n\n" }, - { - "type": "tool_use", - "id": "toolu_1", - "name": "test-tool", - "input": { "value": "example value" }, - }, - ], - "model": "claude-3-haiku-20240307", - "stop_reason": "tool_use", - "stop_sequence": null, - "usage": { "input_tokens": 4, "output_tokens": 30 }, - }), - ) - .await; - - let model = make_model(&server); - let opts = CallOptions { - tools: Some(vec![value_tool()]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&opts).await.expect("do_generate ok"); - - assert_eq!(result.content.len(), 2); - assert_eq!(as_text(&result.content[0]), "Some text\n\n"); - let (id, name, input) = as_tool_call(&result.content[1]); - assert_eq!(id, "toolu_1"); - assert_eq!(name, "test-tool"); - assert_eq!(input, &json!(r#"{"value":"example value"}"#)); - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); - assert_eq!(result.finish_reason.raw.as_deref(), Some("tool_use")); - } - - // ── stop_reason mapping ───────────────────────────────────────────────── - - /// `end_turn` → `Stop`. - #[tokio::test] - async fn should_map_end_turn_to_stop() { - let server = MockServer::start().await; - mock_json(&server, 200, text_response("hi")).await; - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(result.finish_reason.raw.as_deref(), Some("end_turn")); - } - - /// `max_tokens` → `Length`. - #[tokio::test] - async fn should_map_max_tokens_to_length() { - let server = MockServer::start().await; - let mut body = text_response("hi"); - body["stop_reason"] = json!("max_tokens"); - mock_json(&server, 200, body).await; - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Length); - assert_eq!(result.finish_reason.raw.as_deref(), Some("max_tokens")); - } - - /// `tool_use` → `ToolCalls`. - #[tokio::test] - async fn should_map_tool_use_to_tool_calls() { - let server = MockServer::start().await; - let mut body = text_response("hi"); - body["stop_reason"] = json!("tool_use"); - mock_json(&server, 200, body).await; - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); - assert_eq!(result.finish_reason.raw.as_deref(), Some("tool_use")); - } - - /// Unknown stop_reason → `Other`. - #[tokio::test] - async fn should_map_unknown_stop_reason_to_other() { - let server = MockServer::start().await; - let mut body = text_response("hi"); - body["stop_reason"] = json!("something_new"); - mock_json(&server, 200, body).await; - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Other); - assert_eq!(result.finish_reason.raw.as_deref(), Some("something_new")); - } - - /// Missing stop_reason → `Other` with `raw: None` (current Rust default). - #[tokio::test] - async fn should_default_finish_reason_to_other_when_missing() { - let server = MockServer::start().await; - let mut body = text_response("hi"); - body["stop_reason"] = Value::Null; - mock_json(&server, 200, body).await; - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Other); - assert!(result.finish_reason.raw.is_none()); - } - - // ── multi-block content ───────────────────────────────────────────────── - - /// Multiple text blocks are preserved in order. - #[tokio::test] - async fn should_handle_multiple_text_blocks() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "id": "msg_1", - "type": "message", - "role": "assistant", - "content": [ - { "type": "text", "text": "First" }, - { "type": "text", "text": "Second" }, - ], - "model": "claude-3-haiku-20240307", - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": { "input_tokens": 4, "output_tokens": 30 }, - }), - ) - .await; - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - assert_eq!(result.content.len(), 2); - assert_eq!(as_text(&result.content[0]), "First"); - assert_eq!(as_text(&result.content[1]), "Second"); - } - - /// Multiple tool_use blocks (parallel tool calls) are preserved in order. - #[tokio::test] - async fn should_handle_multiple_tool_use_blocks() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "id": "msg_1", - "type": "message", - "role": "assistant", - "content": [ - { - "type": "tool_use", - "id": "toolu_a", - "name": "tool-a", - "input": { "x": 1 }, - }, - { - "type": "tool_use", - "id": "toolu_b", - "name": "tool-b", - "input": { "y": 2 }, - }, - ], - "model": "claude-3-haiku-20240307", - "stop_reason": "tool_use", - "stop_sequence": null, - "usage": { "input_tokens": 4, "output_tokens": 30 }, - }), - ) - .await; - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - assert_eq!(result.content.len(), 2); - let (id_a, name_a, input_a) = as_tool_call(&result.content[0]); - assert_eq!(id_a, "toolu_a"); - assert_eq!(name_a, "tool-a"); - assert_eq!(input_a, &json!(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}"#)); - } - - /// Tool input may be a nested object. - #[tokio::test] - async fn should_handle_tool_use_with_complex_nested_input() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "id": "msg_1", - "type": "message", - "role": "assistant", - "content": [{ - "type": "tool_use", - "id": "toolu_1", - "name": "test-tool", - "input": { - "nested": { "arr": [1, 2, 3], "flag": true }, - "items": ["a", "b"], - }, - }], - "model": "claude-3-haiku-20240307", - "stop_reason": "tool_use", - "stop_sequence": null, - "usage": { "input_tokens": 4, "output_tokens": 30 }, - }), - ) - .await; - let model = make_model(&server); - let opts = CallOptions { - tools: Some(vec![value_tool()]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&opts).await.unwrap(); - let (_, _, input) = as_tool_call(&result.content[0]); - assert_eq!( - input, - &json!(r#"{"nested":{"arr":[1,2,3],"flag":true},"items":["a","b"]}"#) - ); - } - - /// Empty content array → no content items, still a valid result. - #[tokio::test] - async fn should_handle_empty_content_array() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "id": "msg_1", - "type": "message", - "role": "assistant", - "content": [], - "model": "claude-3-haiku-20240307", - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": { "input_tokens": 4, "output_tokens": 30 }, - }), - ) - .await; - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - assert!(result.content.is_empty()); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - } - - // ── request body & headers ────────────────────────────────────────────── - - /// The request body carries the model id and the single user message. - #[tokio::test] - async fn should_send_model_id_in_request_body() { - let server = MockServer::start().await; - mock_json(&server, 200, text_response("hi")).await; - let model = make_model(&server); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["model"], "claude-3-haiku-20240307"); - assert_eq!(body["messages"][0]["role"], "user"); - assert_eq!(body["messages"][0]["content"][0]["text"], "Hello"); - // Non-streaming request sets stream: false. - assert_eq!(body["stream"], false); - } - - /// Anthropic system message is a top-level `system` field, not in messages. - #[tokio::test] - async fn should_send_system_message_as_top_level_field() { - let server = MockServer::start().await; - mock_json(&server, 200, text_response("hi")).await; - let model = make_model(&server); - - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a helpful assistant.")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - ]; - let _ = model.do_generate(&default_options(prompt)).await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - // system is a top-level array of text blocks. - assert_eq!( - body["system"], - json!([{ "type": "text", "text": "You are a helpful assistant." }]) - ); - // messages array must not contain a system role. - for msg in body["messages"].as_array().unwrap() { - assert_ne!(msg["role"], "system", "system leaked into messages"); - } - } - - /// The x-api-key and anthropic-version headers are sent on every request. - #[tokio::test] - async fn should_send_api_key_and_version_headers() { - let server = MockServer::start().await; - mock_json(&server, 200, text_response("hi")).await; - let model = make_model(&server); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - let headers = &requests[0].headers; - assert_eq!( - headers.get("x-api-key").and_then(|v| v.to_str().ok()), - Some("test-api-key") - ); - assert_eq!( - headers - .get("anthropic-version") - .and_then(|v| v.to_str().ok()), - Some("2023-06-01") - ); - } - - // ── error status codes ────────────────────────────────────────────────── - - /// 401 → `AiMuxError::ApiCall` (401 in `status_code`). - #[tokio::test] - async fn should_return_auth_error_on_401() { - let server = MockServer::start().await; - mock_json( - &server, - 401, - json!({ "error": { "message": "invalid api key", "type": "authentication_error" } }), - ) - .await; - let model = make_model(&server); - let err = model - .do_generate(&default_options(test_prompt())) - .await - .expect_err("401 should error"); - assert!(matches!(err, ref e if e.status_code() == Some(401))); - } - - /// 429 → `AiMuxError::ApiCall` (429 in `status_code`). - #[tokio::test] - async fn should_return_rate_limited_on_429() { - let server = MockServer::start().await; - mock_json( - &server, - 429, - json!({ "error": { "message": "rate limited", "type": "rate_limit_error" } }), - ) - .await; - let model = make_model(&server); - let err = model - .do_generate(&default_options(test_prompt())) - .await - .expect_err("429 should error"); - assert!(matches!(err, ref e if e.status_code() == Some(429))); - } - - /// 404 → `AiMuxError::ApiCall` (404 in `status_code`). - #[tokio::test] - async fn should_return_model_not_found_on_404() { - let server = MockServer::start().await; - mock_json( - &server, - 404, - json!({ "error": { "message": "model not found", "type": "not_found_error" } }), - ) - .await; - let model = make_model(&server); - let err = model - .do_generate(&default_options(test_prompt())) - .await - .expect_err("404 should error"); - assert!(matches!(err, ref e if e.status_code() == Some(404))); - } - - /// 500 → `AiMuxError::ApiCall`. - #[tokio::test] - async fn should_return_provider_error_on_500() { - let server = MockServer::start().await; - mock_json( - &server, - 500, - json!({ "error": { "message": "internal error", "type": "api_error" } }), - ) - .await; - let model = make_model(&server); - let err = model - .do_generate(&default_options(test_prompt())) - .await - .expect_err("500 should error"); - assert!(matches!(err, ref e if e.status_code().is_some_and(|s| s >= 500))); - } - - // ── request body options ──────────────────────────────────────────────── - - /// Run `do_generate` with the given options against a fresh mock server and - /// return the serialized request body. - async fn gen_body(options: CallOptions) -> Value { - let server = MockServer::start().await; - mock_json(&server, 200, text_response("hi")).await; - let model = make_model(&server); - model.do_generate(&options).await.unwrap(); - let requests = server.received_requests().await.expect("requests recorded"); - serde_json::from_slice(&requests[0].body).unwrap() - } - - #[tokio::test] - async fn should_send_max_output_tokens() { - let mut opts = default_options(test_prompt()); - opts.max_output_tokens = Some(1234); - let body = gen_body(opts).await; - assert_eq!(body["max_tokens"], 1234); - } - - #[tokio::test] - async fn should_default_max_tokens_to_4096_when_unset() { - let body = gen_body(default_options(test_prompt())).await; - assert_eq!(body["max_tokens"], 4096); - } - - #[tokio::test] - async fn should_send_temperature() { - let mut opts = default_options(test_prompt()); - opts.temperature = Some(0.5); - let body = gen_body(opts).await; - assert_eq!(body["temperature"], 0.5); - } - - #[tokio::test] - async fn should_send_top_p() { - let mut opts = default_options(test_prompt()); - opts.top_p = Some(0.25); - let body = gen_body(opts).await; - assert_eq!(body["top_p"], 0.25); - } - - #[tokio::test] - async fn should_send_stop_sequences() { - let mut opts = default_options(test_prompt()); - opts.stop_sequences = Some(vec!["STOP".to_string(), "END".to_string()]); - let body = gen_body(opts).await; - assert_eq!(body["stop_sequences"], json!(["STOP", "END"])); - } - - #[tokio::test] - async fn should_send_tools_and_tool_choice_auto() { - let mut opts = default_options(test_prompt()); - opts.tools = Some(vec![value_tool()]); - opts.tool_choice = ToolChoice::Auto; - let body = gen_body(opts).await; - assert_eq!(body["tools"][0]["name"], "test-tool"); - assert_eq!(body["tool_choice"], json!({ "type": "auto" })); - } - - #[tokio::test] - async fn should_send_tool_choice_required_as_any() { - let mut opts = default_options(test_prompt()); - opts.tools = Some(vec![value_tool()]); - opts.tool_choice = ToolChoice::Required; - let body = gen_body(opts).await; - assert_eq!(body["tool_choice"], json!({ "type": "any" })); - } - - #[tokio::test] - async fn should_send_tool_choice_tool() { - let mut opts = default_options(test_prompt()); - opts.tools = Some(vec![value_tool()]); - opts.tool_choice = ToolChoice::Tool { - tool_name: "test-tool".to_string(), - }; - let body = gen_body(opts).await; - assert_eq!( - body["tool_choice"], - json!({ "type": "tool", "name": "test-tool" }) - ); - } - - #[tokio::test] - async fn should_omit_tools_and_tool_choice_when_no_tools() { - let body = gen_body(default_options(test_prompt())).await; - assert!(body.get("tools").is_none()); - assert!(body.get("tool_choice").is_none()); - } - - #[tokio::test] - async fn programmatic_tool_caller_reaches_the_public_result() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "id": "msg_programmatic", - "type": "message", - "role": "assistant", - "content": [{ - "type": "tool_use", - "id": "toolu_programmatic", - "name": "test-tool", - "input": { "value": "from code" }, - "caller": { - "type": "code_execution_20250825", - "tool_id": "srvtoolu_code", - }, - }], - "model": "claude-3-haiku-20240307", - "stop_reason": "tool_use", - "usage": { "input_tokens": 100, "output_tokens": 50 }, - }), - ) - .await; - let model = make_model(&server); - - let result = generate_text( - &model, - "Run the tool", - GenerateTextOptions { - tools: Some(vec![value_tool()]), - ..GenerateTextOptions::default() - }, - ) - .await - .unwrap(); - - let metadata = result.tool_calls[0] - .provider_metadata - .as_ref() - .expect("caller metadata"); - assert_eq!( - metadata["anthropic"]["caller"], - json!({ - "type": "code_execution_20250825", - "toolId": "srvtoolu_code", - }) - ); - let response_metadata = match &result.response_messages[0].content { - MessageContent::Parts(parts) => parts.iter().find_map(|part| match part { - ContentPart::ToolCall { - provider_options, .. - } => provider_options.as_ref(), - _ => None, - }), - MessageContent::Text(_) => None, - }; - assert_eq!(response_metadata, Some(metadata)); - } -} - -// ═════════════════════════════════════════════════════════════════════════════ -// doStream — streaming -// (anthropic-language-model.test.ts → describe('doStream')) -// ═════════════════════════════════════════════════════════════════════════════ - -mod do_stream { - use super::*; - - /// The canonical Anthropic text-only stream (mirrors the `anthropic-text` - /// fixture shape), with two text deltas. - fn text_stream() -> String { - sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_1", - "model": "claude-3-haiku-20240307", - "usage": { "input_tokens": 17, "output_tokens": 1 }, - }, - }), - json!({"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}), - json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}), - json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"!"}}), - json!({"type":"content_block_stop","index":0}), - json!({"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":227}}), - json!({"type":"message_stop"}), - ]) - } - - // ── text streaming ────────────────────────────────────────────────────── - - /// TS: "raw chunks" text-only stream — stream-start, response-metadata, - /// text-start, two text-deltas, text-end, finish. - #[tokio::test] - async fn should_stream_text_deltas() { - let server = MockServer::start().await; - mock_sse(&server, &text_stream()).await; - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - // Expected sequence: StreamStart, ResponseMetadata, TextStart, - // TextDelta("Hello"), TextDelta("!"), TextEnd, Finish. - assert_eq!(parts.len(), 7, "parts = {parts:?}"); - - assert!(matches!( - &parts[0], - StreamPart::StreamStart { warnings } if warnings.is_empty() - )); - match &parts[1] { - StreamPart::ResponseMetadata { id, model_id, .. } => { - assert_eq!(id.as_deref(), Some("msg_1")); - assert_eq!(model_id.as_deref(), Some("claude-3-haiku-20240307")); - } - other => panic!("expected ResponseMetadata, got {other:?}"), - } - match &parts[2] { - StreamPart::TextStart { id, .. } => assert_eq!(id, "0"), - other => panic!("expected TextStart, got {other:?}"), - } - match &parts[3] { - StreamPart::TextDelta { id, delta, .. } => { - assert_eq!(id, "0"); - assert_eq!(delta, "Hello"); - } - other => panic!("expected TextDelta Hello, got {other:?}"), - } - match &parts[4] { - StreamPart::TextDelta { id, delta, .. } => { - assert_eq!(id, "0"); - assert_eq!(delta, "!"); - } - other => panic!("expected TextDelta !, got {other:?}"), - } - match &parts[5] { - StreamPart::TextEnd { id, .. } => assert_eq!(id, "0"), - other => panic!("expected TextEnd, got {other:?}"), - } - match &parts[6] { - StreamPart::Finish { finish_reason, .. } => { - assert_eq!(finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(finish_reason.raw.as_deref(), Some("end_turn")); - } - other => panic!("expected Finish, got {other:?}"), - } - } - - /// Response metadata is emitted exactly once, right after message_start. - #[tokio::test] - async fn should_emit_response_metadata_after_message_start() { - let server = MockServer::start().await; - mock_sse(&server, &text_stream()).await; - let model = make_model(&server); - let parts = collect_stream( - model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(), - ) - .await; - - let meta_count = parts - .iter() - .filter(|p| matches!(p, StreamPart::ResponseMetadata { .. })) - .count(); - assert_eq!(meta_count, 1); - - // The metadata part must come right after StreamStart. - assert!(matches!(parts[0], StreamPart::StreamStart { .. })); - assert!(matches!(parts[1], StreamPart::ResponseMetadata { .. })); - } - - /// Usage: input_tokens from message_start, output_tokens from message_delta. - #[tokio::test] - async fn should_extract_usage_from_message_start_and_message_delta() { - let server = MockServer::start().await; - mock_sse(&server, &text_stream()).await; - let model = make_model(&server); - let parts = collect_stream( - model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(), - ) - .await; - - let finish = parts - .iter() - .find_map(|p| match p { - StreamPart::Finish { usage, .. } => Some(usage), - _ => None, - }) - .expect("a Finish part"); - assert_eq!(finish.input_tokens.total, Some(17)); - // message_delta carries output_tokens: 227 (overrides message_start's 1). - assert_eq!(finish.output_tokens.total, Some(227)); - } - - /// `end_turn` stop_reason in message_delta → `Stop`. - #[tokio::test] - async fn should_map_end_turn_stop_reason_in_stream() { - let server = MockServer::start().await; - mock_sse(&server, &text_stream()).await; - let model = make_model(&server); - let parts = collect_stream( - model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(), - ) - .await; - let finish_reason = parts - .iter() - .find_map(|p| match p { - StreamPart::Finish { finish_reason, .. } => Some(finish_reason), - _ => None, - }) - .unwrap(); - assert_eq!(finish_reason.unified, FinishReasonUnified::Stop); - } - - /// `tool_use` stop_reason in message_delta → `ToolCalls`. - #[tokio::test] - async fn should_map_tool_use_stop_reason_in_stream() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_1", - "model": "claude-3-haiku-20240307", - "usage": { "input_tokens": 10 }, - }, - }), - json!({"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}), - json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"done"}}), - json!({"type":"content_block_stop","index":0}), - json!({"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":5}}), - json!({"type":"message_stop"}), - ]); - mock_sse(&server, &sse_body).await; - let model = make_model(&server); - let parts = collect_stream( - model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(), - ) - .await; - let finish_reason = parts - .iter() - .find_map(|p| match p { - StreamPart::Finish { finish_reason, .. } => Some(finish_reason), - _ => None, - }) - .unwrap(); - assert_eq!(finish_reason.unified, FinishReasonUnified::ToolCalls); - } - - /// When no message_delta carries a stop_reason, finish defaults to `Stop`. - #[tokio::test] - async fn should_default_finish_reason_to_stop_when_missing() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_1", - "model": "claude-3-haiku-20240307", - "usage": { "input_tokens": 10 }, - }, - }), - json!({"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}), - json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}), - json!({"type":"content_block_stop","index":0}), - // message_delta with no stop_reason. - json!({"type":"message_delta","delta":{}}), - json!({"type":"message_stop"}), - ]); - mock_sse(&server, &sse_body).await; - let model = make_model(&server); - let parts = collect_stream( - model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(), - ) - .await; - let finish_reason = parts - .iter() - .find_map(|p| match p { - StreamPart::Finish { finish_reason, .. } => Some(finish_reason), - _ => None, - }) - .unwrap(); - assert_eq!(finish_reason.unified, FinishReasonUnified::Stop); - assert!(finish_reason.raw.is_none()); - } - - // ── tool-call streaming ───────────────────────────────────────────────── - - /// TS: "should stream tool deltas" — text block then a tool_use block with - /// input_json_delta fragments. Verifies ToolInputStart, ToolInputDelta*, - /// ToolInputEnd, and the final ToolCall with the parsed JSON. - #[tokio::test] - async fn should_stream_tool_input_deltas() { - let server = MockServer::start().await; - - // Five partial_json fragments that reconstruct - // `{"value":"Sparkle Day"}`. Defined as plain Rust strings so the - // escaping is unambiguous. - let frags: [&str; 5] = ["{\"value", "\":", "\"Spark", "le", " Day\"}"]; - assert_eq!(frags.concat(), r#"{"value":"Sparkle Day"}"#); - - let mut events: Vec = vec![ - json!({ - "type": "message_start", - "message": { - "id": "msg_01GouTqNCGXzrj5LQ5jEkw67", - "model": "claude-3-haiku-20240307", - "usage": { "input_tokens": 441, "output_tokens": 2 }, - }, - }), - json!({"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}), - json!({"type":"ping"}), - json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Okay"}}), - json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"!"}}), - json!({"type":"content_block_stop","index":0}), - json!({"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_01DBsB4vvYLnBDzZ5rBSxSLs","name":"test-tool","input":{}}}), - // Leading empty partial_json — must NOT produce a tool-input-delta. - json!({"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":""}}), - ]; - for frag in &frags { - events.push(json!({ - "type": "content_block_delta", - "index": 1, - "delta": { "type": "input_json_delta", "partial_json": frag } - })); - } - events.push(json!({"type":"content_block_stop","index":1})); - events.push(json!({"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":65}})); - events.push(json!({"type":"message_stop"})); - - let sse_body = sse_stream(&events); - mock_sse(&server, &sse_body).await; - let model = make_model(&server); - let opts = CallOptions { - tools: Some(vec![value_tool()]), - ..default_options(test_prompt()) - }; - let parts = collect_stream(model.do_stream(&opts).await.unwrap()).await; - - // Collect the tool-input-delta fragments (excluding the empty one). - let tool_deltas: Vec = parts - .iter() - .filter_map(|p| match p { - StreamPart::ToolInputDelta { id, delta, .. } => { - assert_eq!(id, "toolu_01DBsB4vvYLnBDzZ5rBSxSLs"); - Some(delta.clone()) - } - _ => None, - }) - .collect(); - assert_eq!( - tool_deltas, - frags - .iter() - .map(std::string::ToString::to_string) - .collect::>() - ); - - // ToolInputStart. - assert!(parts.iter().any(|p| matches!( - p, - StreamPart::ToolInputStart { id, tool_name, .. } - if id == "toolu_01DBsB4vvYLnBDzZ5rBSxSLs" && tool_name == "test-tool" - ))); - - // ToolInputEnd. - assert!(parts.iter().any(|p| matches!( - p, - StreamPart::ToolInputEnd { id, .. } - if id == "toolu_01DBsB4vvYLnBDzZ5rBSxSLs" - ))); - - // Provider output stays raw until the Core stream wrapper parses it. - let tool_call = parts - .iter() - .find_map(|p| match p { - StreamPart::ToolCall { - 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()) - ); - - // Finish reason reflects tool_use. - let finish = parts - .iter() - .find_map(|p| match p { - StreamPart::Finish { - finish_reason, - usage, - .. - } => Some((finish_reason, usage)), - _ => None, - }) - .unwrap(); - assert_eq!(finish.0.unified, FinishReasonUnified::ToolCalls); - assert_eq!(finish.1.input_tokens.total, Some(441)); - assert_eq!(finish.1.output_tokens.total, Some(65)); - } - - #[tokio::test] - async fn renamed_client_provider_tool_is_consistent_across_stream_boundary() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_1", - "model": "claude-3-5-sonnet-latest", - "usage": { "input_tokens": 10, "output_tokens": 1 }, - }, - }), - json!({ - "type": "content_block_start", - "index": 0, - "content_block": { - "type": "tool_use", - "id": "tool_1", - "name": "computer", - "input": {}, - }, - }), - json!({ - "type": "content_block_delta", - "index": 0, - "delta": { - "type": "input_json_delta", - "partial_json": "{\"action\":\"screenshot\"}", - }, - }), - json!({ "type": "content_block_stop", "index": 0 }), - json!({ - "type": "message_delta", - "delta": { "stop_reason": "tool_use" }, - "usage": { "output_tokens": 8 }, - }), - json!({ "type": "message_stop" }), - ]); - mock_sse(&server, &sse_body).await; - let model = make_model(&server); - let tool = Tool::Provider(ProviderTool { - id: "anthropic.computer_20250124".to_string(), - name: "myComputer".to_string(), - args: json!({ "displayWidthPx": 1280, "displayHeightPx": 720 }), - }); - let raw_options = CallOptions { - tools: Some(vec![tool.clone()]), - ..default_options(test_prompt()) - }; - - let raw_parts = collect_stream(model.do_stream(&raw_options).await.unwrap()).await; - assert!(raw_parts.iter().any(|part| matches!( - part, - StreamPart::ToolInputStart { tool_name, .. } if tool_name == "myComputer" - ))); - assert!(raw_parts.iter().any(|part| matches!( - part, - StreamPart::ToolCall { 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. - #[tokio::test] - async fn should_stream_tool_with_empty_input() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_1", - "model": "claude-3-haiku-20240307", - "usage": { "input_tokens": 565, "output_tokens": 7 }, - }, - }), - json!({"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_01QE","name":"updateIssueList","input":{}}}), - // Only the leading empty partial_json — no real fragments. - json!({"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}), - json!({"type":"content_block_stop","index":0}), - json!({"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":48}}), - json!({"type":"message_stop"}), - ]); - mock_sse(&server, &sse_body).await; - let model = make_model(&server); - let opts = CallOptions { - tools: Some(vec![value_tool()]), - ..default_options(test_prompt()) - }; - let parts = collect_stream(model.do_stream(&opts).await.unwrap()).await; - - // No tool-input-delta should be emitted (the only fragment is empty). - assert!( - !parts - .iter() - .any(|p| matches!(p, StreamPart::ToolInputDelta { .. })), - "no tool-input-delta expected, parts = {parts:?}" - ); - - let tool_call = parts - .iter() - .find_map(|p| match p { - StreamPart::ToolCall { input, .. } => Some(input.clone()), - _ => None, - }) - .expect("a ToolCall part"); - assert_eq!(tool_call, Value::String("{}".into())); - } - - // ── misc SSE scenarios ────────────────────────────────────────────────── - - /// `ping` events are silently ignored (no stream part emitted). - #[tokio::test] - async fn should_handle_ping_events() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_1", - "model": "claude-3-haiku-20240307", - "usage": { "input_tokens": 10 }, - }, - }), - json!({"type":"ping"}), - json!({"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}), - json!({"type":"ping"}), - json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}), - json!({"type":"content_block_stop","index":0}), - json!({"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}), - json!({"type":"message_stop"}), - ]); - mock_sse(&server, &sse_body).await; - let model = make_model(&server); - let parts = collect_stream( - model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(), - ) - .await; - - // ping must not surface as any stream part; only the expected - // text-stream sequence remains. - let text_deltas: Vec = parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect(); - assert_eq!(text_deltas, vec!["hi".to_string()]); - } - - /// Two consecutive text blocks (index 0 then index 1) each get their own - /// TextStart/TextDelta/TextEnd with the correct index-keyed id. - #[tokio::test] - async fn should_handle_multiple_text_blocks_in_sequence() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_1", - "model": "claude-3-haiku-20240307", - "usage": { "input_tokens": 10 }, - }, - }), - json!({"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}), - json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"A"}}), - json!({"type":"content_block_stop","index":0}), - json!({"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}), - json!({"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"B"}}), - json!({"type":"content_block_stop","index":1}), - json!({"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}), - json!({"type":"message_stop"}), - ]); - mock_sse(&server, &sse_body).await; - let model = make_model(&server); - let parts = collect_stream( - model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(), - ) - .await; - - // Two TextStart ids "0" and "1", two TextEnd ids, deltas keyed by id. - let starts: Vec = parts - .iter() - .filter_map(|p| match p { - StreamPart::TextStart { id, .. } => Some(id.clone()), - _ => None, - }) - .collect(); - assert_eq!(starts, vec!["0".to_string(), "1".to_string()]); - let ends: Vec = parts - .iter() - .filter_map(|p| match p { - StreamPart::TextEnd { id, .. } => Some(id.clone()), - _ => None, - }) - .collect(); - assert_eq!(ends, vec!["0".to_string(), "1".to_string()]); - } - - /// A text block followed by a tool_use block (the canonical mixed response). - #[tokio::test] - async fn should_handle_text_then_tool_use() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_1", - "model": "claude-3-haiku-20240307", - "usage": { "input_tokens": 10 }, - }, - }), - json!({"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}), - json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Calling tool"}}), - json!({"type":"content_block_stop","index":0}), - json!({"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_1","name":"test-tool","input":{}}}), - json!({"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"value\":"}}), - json!({"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\"x\"}"}}), - json!({"type":"content_block_stop","index":1}), - json!({"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":12}}), - json!({"type":"message_stop"}), - ]); - mock_sse(&server, &sse_body).await; - let model = make_model(&server); - let opts = CallOptions { - tools: Some(vec![value_tool()]), - ..default_options(test_prompt()) - }; - let parts = collect_stream(model.do_stream(&opts).await.unwrap()).await; - - // Text block: TextStart(0) / TextDelta / TextEnd(0). - assert!(parts.iter().any(|p| matches!( - p, - StreamPart::TextStart { id, .. } if id == "0" - ))); - assert!(parts.iter().any(|p| matches!( - p, - StreamPart::TextEnd { id, .. } if id == "0" - ))); - // Tool block: ToolInputStart / ToolInputDelta* / ToolInputEnd / ToolCall. - let tool_call = parts - .iter() - .find_map(|p| match p { - StreamPart::ToolCall { - 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())); - } - - /// Two parallel tool_use blocks (index 0 and index 1) each produce their - /// own ToolInputStart/Delta/End/ToolCall sequence. - #[tokio::test] - async fn should_stream_multiple_tool_calls_parallel() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_1", - "model": "claude-3-haiku-20240307", - "usage": { "input_tokens": 10 }, - }, - }), - json!({"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_a","name":"test-tool","input":{}}}), - json!({"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"a\":"}}), - json!({"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"1}"}}), - json!({"type":"content_block_stop","index":0}), - json!({"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_b","name":"test-tool","input":{}}}), - json!({"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"b\":"}}), - json!({"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"2}"}}), - json!({"type":"content_block_stop","index":1}), - json!({"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":20}}), - json!({"type":"message_stop"}), - ]); - mock_sse(&server, &sse_body).await; - let model = make_model(&server); - let opts = CallOptions { - tools: Some(vec![value_tool()]), - ..default_options(test_prompt()) - }; - let parts = collect_stream(model.do_stream(&opts).await.unwrap()).await; - - let tool_calls: Vec<(String, Value)> = parts - .iter() - .filter_map(|p| match p { - StreamPart::ToolCall { - tool_call_id, - input, - .. - } => Some((tool_call_id.clone(), input.clone())), - _ => None, - }) - .collect(); - assert_eq!(tool_calls.len(), 2); - assert_eq!(tool_calls[0].0, "toolu_a"); - assert_eq!(tool_calls[0].1, Value::String(r#"{"a":1}"#.into())); - assert_eq!(tool_calls[1].0, "toolu_b"); - assert_eq!(tool_calls[1].1, Value::String(r#"{"b":2}"#.into())); - } - - // ── pre-stream HTTP errors ────────────────────────────────────────────── - - /// 401 before streaming → `AiMuxError::ApiCall` (401 in `status_code`) (no stream is produced). - #[tokio::test] - async fn should_return_auth_error_on_401_before_streaming() { - let server = MockServer::start().await; - mock_json( - &server, - 401, - json!({ "error": { "message": "invalid api key", "type": "authentication_error" } }), - ) - .await; - let model = make_model(&server); - let err = model - .do_stream(&default_options(test_prompt())) - .await - .expect_err("401 should error before streaming"); - assert!(matches!(err, ref e if e.status_code() == Some(401))); - } - - /// 500 before streaming → `Provider` with a 5xx status. - #[tokio::test] - async fn should_return_provider_error_on_500_before_streaming() { - let server = MockServer::start().await; - mock_json( - &server, - 500, - json!({ "error": { "message": "internal error", "type": "api_error" } }), - ) - .await; - let model = make_model(&server); - let err = model - .do_stream(&default_options(test_prompt())) - .await - .expect_err("500 should error before streaming"); - assert!(matches!(err, ref e if e.status_code().is_some_and(|s| s >= 500))); - } - - // ── in-stream error event ─────────────────────────────────────────────── - - /// An Anthropic mid-stream `error` event (e.g. `overloaded_error`) is - /// surfaced as a `StreamPart::Error` and the stream terminates without a - /// `Finish`. Mirrors the TS "first stream chunk is an overloaded error" / - /// "forward overloaded error" cases. - #[tokio::test] - async fn should_forward_in_stream_error_event() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_1", - "model": "claude-3-haiku-20240307", - "usage": { "input_tokens": 10 }, - }, - }), - json!({"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}), - json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"partial"}}), - // Mid-stream error event. - json!({ - "type": "error", - "error": { - "type": "overloaded_error", - "message": "Overloaded", - }, - }), - ]); - mock_sse(&server, &sse_body).await; - let model = make_model(&server); - let parts = collect_stream( - model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(), - ) - .await; - - // The stream must contain a StreamPart::Error carrying the provider - // message, followed by an error-finish (Finish is the final-chunk - // contract, matching openai/google — see #112). - let error_idx = parts - .iter() - .position(|p| matches!(p, StreamPart::Error { .. })); - let error_idx = error_idx.expect("an Error stream part"); - match &parts[error_idx] { - StreamPart::Error { error } => { - let msg = error.to_string(); - assert!(msg.contains("Overloaded"), "msg = {msg}"); - } - _ => unreachable!(), - } - // Error is followed by exactly one Finish with unified=Error. - assert_eq!(parts.len(), error_idx + 2); - assert!(matches!( - &parts[error_idx + 1], - StreamPart::Finish { finish_reason, .. } - if matches!(finish_reason.unified, FinishReasonUnified::Error) - )); - } - - /// Extended-thinking streams: `signature_delta` pieces accumulate on the - /// thinking block and are attached to the concluding ReasoningEnd as - /// provider_metadata (`{"anthropic": {"signature": ..}}`), matching the - /// non-streaming path (#113). - #[tokio::test] - async fn should_attach_thinking_signature_to_reasoning_end() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_1", - "model": "claude-3-haiku-20240307", - "usage": { "input_tokens": 10 }, - }, - }), - json!({"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}), - json!({"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Let me think."}}), - json!({"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig-part-1"}}), - json!({"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig-part-2"}}), - json!({"type":"content_block_stop","index":0}), - json!({"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}), - json!({"type":"message_stop"}), - ]); - mock_sse(&server, &sse_body).await; - let model = make_model(&server); - let parts = collect_stream( - model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(), - ) - .await; - - assert!( - parts.iter().any(|p| matches!(p, - StreamPart::ReasoningDelta { delta, .. } if delta == "Let me think.")), - "expected the thinking text delta, got {parts:?}" - ); - let meta = parts.iter().find_map(|p| match p { - StreamPart::ReasoningEnd { - provider_metadata, .. - } => provider_metadata.clone(), - _ => None, - }); - let meta = meta.expect("ReasoningEnd with provider_metadata"); - // Incremental signature pieces are concatenated. - assert_eq!( - meta["anthropic"]["signature"].as_str(), - Some("sig-part-1sig-part-2") - ); - } - - #[tokio::test] - async fn streamed_tool_search_results_follow_their_call_ids() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_tool_search", - "model": "claude-sonnet-4-5", - "usage": { "input_tokens": 10 }, - }, - }), - json!({ - "type": "content_block_start", - "index": 0, - "content_block": { - "type": "server_tool_use", - "id": "bm25_call", - "name": "tool_search_tool_bm25", - "input": { "query": "weather" }, - }, - }), - json!({ "type": "content_block_stop", "index": 0 }), - json!({ - "type": "content_block_start", - "index": 1, - "content_block": { - "type": "tool_search_tool_result", - "tool_use_id": "bm25_call", - "content": { - "type": "tool_search_tool_search_result", - "tool_references": [{ - "type": "tool_reference", - "tool_name": "get_weather", - }], - }, - }, - }), - json!({ "type": "content_block_stop", "index": 1 }), - json!({ - "type": "content_block_start", - "index": 2, - "content_block": { - "type": "server_tool_use", - "id": "regex_call", - "name": "tool_search_tool_regex", - "input": { "pattern": "forecast.*" }, - }, - }), - json!({ "type": "content_block_stop", "index": 2 }), - json!({ - "type": "content_block_start", - "index": 3, - "content_block": { - "type": "tool_search_tool_result", - "tool_use_id": "regex_call", - "content": { - "type": "tool_search_tool_search_result", - "tool_references": [{ - "type": "tool_reference", - "tool_name": "get_forecast", - }], - }, - }, - }), - json!({ "type": "content_block_stop", "index": 3 }), - json!({ - "type": "message_delta", - "delta": { "stop_reason": "end_turn" }, - "usage": { "output_tokens": 20 }, - }), - json!({ "type": "message_stop" }), - ]); - mock_sse(&server, &sse_body).await; - let model = make_model(&server); - let options = CallOptions { - tools: Some(vec![ - Tool::Provider(ProviderTool { - id: "anthropic.tool_search_regex_20251119".to_string(), - name: "regexSearch".to_string(), - args: json!({}), - }), - Tool::Provider(ProviderTool { - id: "anthropic.tool_search_bm25_20251119".to_string(), - name: "semanticSearch".to_string(), - args: json!({}), - }), - ]), - ..default_options(test_prompt()) - }; - - let parts = collect_stream(model.do_stream(&options).await.unwrap()).await; - let result_names: std::collections::HashMap<&str, &str> = parts - .iter() - .filter_map(|part| match part { - StreamPart::ToolResult { - tool_call_id, - tool_name, - .. - } => Some((tool_call_id.as_str(), tool_name.as_str())), - _ => None, - }) - .collect(); - assert_eq!(result_names.get("bm25_call"), Some(&"semanticSearch")); - assert_eq!(result_names.get("regex_call"), Some(&"regexSearch")); - } - - #[tokio::test] - async fn streamed_programmatic_tool_call_keeps_caller_metadata() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_programmatic", - "model": "claude-3-haiku-20240307", - "usage": { "input_tokens": 100 }, - }, - }), - json!({ - "type": "content_block_start", - "index": 0, - "content_block": { - "type": "tool_use", - "id": "toolu_programmatic", - "name": "test-tool", - "input": { "value": "from code" }, - "caller": { - "type": "code_execution_20260120", - "tool_id": "srvtoolu_code", - }, - }, - }), - json!({ "type": "content_block_stop", "index": 0 }), - json!({ - "type": "message_delta", - "delta": { "stop_reason": "tool_use" }, - "usage": { "output_tokens": 50 }, - }), - json!({ "type": "message_stop" }), - ]); - mock_sse(&server, &sse_body).await; - let model = make_model(&server); - let options = CallOptions { - tools: Some(vec![value_tool()]), - ..default_options(test_prompt()) - }; - - let parts = collect_stream(model.do_stream(&options).await.unwrap()).await; - let metadata = parts.iter().find_map(|part| match part { - StreamPart::ToolCall { - provider_metadata, .. - } => provider_metadata.as_ref(), - _ => None, - }); - assert_eq!( - metadata.expect("caller metadata")["anthropic"]["caller"], - json!({ - "type": "code_execution_20260120", - "toolId": "srvtoolu_code", - }) - ); - } -} diff --git a/aimux-providers/tests/anthropic_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/anthropic_remaining_test.rs b/aimux-providers/tests/anthropic_remaining_test.rs deleted file mode 100644 index ed6c7545b..000000000 --- a/aimux-providers/tests/anthropic_remaining_test.rs +++ /dev/null @@ -1,675 +0,0 @@ -//! Remaining Anthropic provider tests — ported from the TS SDK suite. -//! -//! Covers the TS tests not already exercised by the existing six Anthropic -//! test files (`anthropic_model_test.rs`, `anthropic_convert_test.rs`, -//! `anthropic_convert_full_test.rs`, `anthropic_prepare_tools_test.rs`, -//! `anthropic_provider_tools_test.rs`, `anthropic_cache_control_test.rs`, -//! `anthropic_aws_model_test.rs`): -//! -//! - `anthropic-provider.test.ts` — provider configuration (baseURL, auth, -//! custom provider name, `supportedUrls`). -//! - `anthropic-unknown-model-max-output-tokens.test.ts` — unknown-model -//! `max_tokens` defaulting + compatibility warning. -//! - `sanitize-json-schema.test.ts` — JSON Schema sanitization (the -//! `sanitize_json_schema.rs` implementation had no dedicated tests). -//! - `anthropic-language-model.test.ts` → `mid-conversation tool changes` -//! describe block. -//! -//! HTTP is mocked with `wiremock` (a real loopback HTTP server). Env-var -//! tests are `#[serial]` and bracketed with `unsafe` `set_var`/`remove_var` -//! per the workspace convention. -//! -//! Tests for behaviour the Rust implementation does not yet expose -//! (`supportedUrls`, `toolChanges`, empty-`baseURL` rejection) are written -//! against the intended contract and marked `#[ignore]` with a -//! `// TODO: implementation gap` note, so they document the gap without -//! breaking the suite. - -use serde_json::{Value, json}; -use serial_test::serial; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::CallOptions; -use aimux_core::types::Warning; - -use aimux_providers::anthropic::AnthropicConfig; -use aimux_providers::anthropic::model::AnthropicModel; -use aimux_providers::anthropic::sanitize_json_schema::sanitize_json_schema; - -// ───────────────────────────────────────────────────────────────────────────── -// Shared helpers -// ───────────────────────────────────────────────────────────────────────────── - -/// The TS `TEST_PROMPT`: a single user text message "Hello". -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -/// Build `CallOptions` with everything unset except `prompt`. -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -/// Build an `AnthropicModel` backed by `config` (model id `claude-3-haiku-20240307`). -fn make_model_with_config(config: AnthropicConfig) -> AnthropicModel { - AnthropicModel::new("claude-3-haiku-20240307".to_string(), config) -} - -/// A minimal Anthropic text response body (mirrors the TS `anthropic-text` fixture). -fn text_response(text: &str) -> Value { - json!({ - "id": "msg_123", - "type": "message", - "role": "assistant", - "content": [{ "type": "text", "text": text }], - "model": "claude-3-haiku-20240307", - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": { "input_tokens": 1, "output_tokens": 1 }, - }) -} - -/// Mount a JSON 200 response on `POST /v1/messages`. -async fn mock_messages_ok(server: &MockServer, body: Value) { - Mock::given(method("POST")) - .and(path("/v1/messages")) - .respond_with(ResponseTemplate::new(200).set_body_json(body)) - .mount(server) - .await; -} - -/// Remove the Anthropic env vars consulted by `AnthropicConfig::from_env` -/// (test isolation for `#[serial]` env tests). -fn clear_anthropic_env() { - unsafe { - std::env::remove_var("ANTHROPIC_BASE_URL"); - std::env::remove_var("ANTHROPIC_API_KEY"); - } -} - -/// Set the API key (always required by `from_env`) plus an optional base URL. -fn set_anthropic_env(api_key: &str, base_url: Option<&str>) { - unsafe { - std::env::set_var("ANTHROPIC_API_KEY", api_key); - match base_url { - Some(url) => std::env::set_var("ANTHROPIC_BASE_URL", url), - None => std::env::remove_var("ANTHROPIC_BASE_URL"), - } - } -} - -// ═════════════════════════════════════════════════════════════════════════════ -// baseURL configuration (TS: anthropic-provider.test.ts → 'baseURL configuration') -// ═════════════════════════════════════════════════════════════════════════════ - -/// TS: "uses the default Anthropic base URL when not provided". -/// -/// The TS test mocks `fetch` and asserts the request URL is -/// `https://api.anthropic.com/v1/messages`. The Rust provider builds the -/// endpoint as `{base_url}/v1/messages`, so the equivalent observable -/// behaviour is: the default config's `base_url` is the bare Anthropic URL, -/// and a request against a stubbed server lands on `/v1/messages`. -#[tokio::test] -async fn default_base_url_when_not_provided() { - // Config-level: default base URL is the bare Anthropic API URL. - let config = AnthropicConfig::new("test-api-key"); - assert_eq!(config.base_url, "https://api.anthropic.com"); - - // HTTP-level: the endpoint path is `/v1/messages`. - let server = MockServer::start().await; - mock_messages_ok(&server, text_response("Hi")).await; - let model = - make_model_with_config(AnthropicConfig::new("test-api-key").with_base_url(server.uri())); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - assert_eq!(requests[0].url.path(), "/v1/messages"); -} - -/// TS: "uses ANTHROPIC_BASE_URL when set". -/// -/// The TS test mocks `fetch` and asserts the request URL is -/// `https://proxy.anthropic.example/v1/messages`. The Rust provider builds the -/// endpoint as `{base_url}/v1/messages`, so the observable behaviour is that -/// `from_env` picks up `ANTHROPIC_BASE_URL` and normalizes it into `base_url`. -/// (The HTTP `/v1/messages` path round-trip is covered by -/// `default_base_url_when_not_provided`.) -#[tokio::test] -#[serial] -async fn uses_anthropic_base_url_env_when_set() { - clear_anthropic_env(); - set_anthropic_env("test-api-key", Some("https://proxy.anthropic.example/v1/")); - - let config = AnthropicConfig::from_env().expect("from_env"); - // Trailing slash + `/v1` segment normalized away; the endpoint becomes - // `https://proxy.anthropic.example/v1/messages`. - assert_eq!(config.base_url, "https://proxy.anthropic.example"); - - clear_anthropic_env(); -} - -/// TS: "normalizes a bare Anthropic API URL from ANTHROPIC_BASE_URL". -#[tokio::test] -#[serial] -async fn normalizes_bare_anthropic_url_from_env() { - clear_anthropic_env(); - set_anthropic_env("test-api-key", Some("https://api.anthropic.com/")); - let config = AnthropicConfig::from_env().expect("from_env"); - // Trailing slash stripped; endpoint becomes `https://api.anthropic.com/v1/messages`. - assert_eq!(config.base_url, "https://api.anthropic.com"); - clear_anthropic_env(); -} - -/// TS: "normalizes a bare Anthropic API URL from the baseURL option". -#[tokio::test] -async fn normalizes_bare_anthropic_url_from_base_url_option() { - let config = AnthropicConfig::new("test-api-key").with_base_url("https://api.anthropic.com/"); - assert_eq!(config.base_url, "https://api.anthropic.com"); -} - -/// TS: "prefers the baseURL option over ANTHROPIC_BASE_URL". -#[tokio::test] -#[serial] -async fn prefers_base_url_option_over_env() { - clear_anthropic_env(); - set_anthropic_env("test-api-key", Some("https://env.anthropic.example/v1")); - let config = AnthropicConfig::from_env() - .expect("from_env") - .with_base_url("https://option.anthropic.example/v1/"); - // The explicit option wins; its trailing slash and `/v1` are normalized away. - assert_eq!(config.base_url, "https://option.anthropic.example"); - clear_anthropic_env(); -} - -/// TS: "rejects an empty baseURL option during provider creation". -// TODO: implementation gap — the Rust `normalize_base_url` returns the default -// Anthropic URL for an empty input instead of rejecting it with -// `InvalidArgumentError` (`argument: "baseURL"`, message: "baseURL must be a -// non-empty string."). Re-enable once `with_base_url` / the builder validate -// emptiness. -#[tokio::test] -#[ignore] -async fn rejects_empty_base_url_option() { - let result = AnthropicConfig::builder() - .api_key("test-api-key") - .base_url("") - .build(); - match result { - Err(AiMuxError::InvalidArgument(msg)) => { - assert_eq!(msg, "baseURL must be a non-empty string."); - } - other => panic!("expected InvalidArgument for empty baseURL, got {other:?}"), - } -} - -// ═════════════════════════════════════════════════════════════════════════════ -// Authentication (TS: anthropic-provider.test.ts → 'authentication') -// ═════════════════════════════════════════════════════════════════════════════ - -/// TS: "sends Authorization Bearer header when authToken is provided". -#[tokio::test] -async fn sends_authorization_bearer_when_auth_token_provided() { - let server = MockServer::start().await; - mock_messages_ok(&server, text_response("Hi")).await; - - let config = AnthropicConfig::builder() - .auth_token("test-auth-token") - .base_url(server.uri()) - .build() - .expect("builder build"); - let model = make_model_with_config(config); - - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - assert_eq!( - requests[0] - .headers - .get("authorization") - .and_then(|v| v.to_str().ok()), - Some("Bearer test-auth-token"), - "Authorization Bearer header missing or wrong" - ); - // When an auth token is set, the `x-api-key` header must NOT be sent. - assert!( - requests[0].headers.get("x-api-key").is_none(), - "x-api-key should not be sent when authToken is provided" - ); -} - -/// TS: "throws error when both apiKey and authToken options are provided". -#[test] -fn throws_when_both_api_key_and_auth_token_provided() { - let result = AnthropicConfig::builder() - .api_key("test-api-key") - .auth_token("test-auth-token") - .build(); - match result { - Err(AiMuxError::InvalidArgument(msg)) => { - assert_eq!( - msg, - "Both apiKey and authToken were provided. \ - Please use only one authentication method." - ); - } - other => { - panic!("expected InvalidArgument for conflicting apiKey + authToken, got {other:?}") - } - } -} - -// ═════════════════════════════════════════════════════════════════════════════ -// Custom provider name (TS: anthropic-provider.test.ts → 'custom provider name') -// ═════════════════════════════════════════════════════════════════════════════ - -/// TS: "should use custom provider name when specified". -#[test] -fn uses_custom_provider_name_when_specified() { - let config = AnthropicConfig::new("test-api-key").with_name("my-claude-proxy"); - let model = make_model_with_config(config); - assert_eq!(model.provider(), "my-claude-proxy"); -} - -/// TS: "should default to anthropic.messages when name not specified". -#[test] -fn defaults_to_anthropic_messages_when_not_specified() { - let config = AnthropicConfig::new("test-api-key"); - let model = make_model_with_config(config); - assert_eq!(model.provider(), "anthropic.messages"); -} - -// ═════════════════════════════════════════════════════════════════════════════ -// supportedUrls (TS: anthropic-provider.test.ts → 'supportedUrls') -// ═════════════════════════════════════════════════════════════════════════════ - -/// TS: "should support image/* URLs". -// TODO: implementation gap — the Rust `LanguageModel` trait has no -// `supported_urls` surface, so there is nothing to assert against. Re-enable -// once `supported_urls` is added to the trait and the Anthropic model exposes -// its image/PDF URL allow-list. -#[tokio::test] -#[ignore] -async fn supports_image_urls() { - let config = AnthropicConfig::new("test-api-key"); - let model = make_model_with_config(config); - // Intended: `model.supported_urls()["image/*"]` matches - // `https://example.com/image.png`. - let _ = model.provider(); -} - -/// TS: "should support application/pdf URLs". -// TODO: implementation gap — see `supports_image_urls`. -#[tokio::test] -#[ignore] -async fn supports_pdf_urls() { - let config = AnthropicConfig::new("test-api-key"); - let model = make_model_with_config(config); - let _ = model.provider(); -} - -// ═════════════════════════════════════════════════════════════════════════════ -// Unknown-model max output tokens (TS: anthropic-unknown-model-max-output-tokens.test.ts) -// ═════════════════════════════════════════════════════════════════════════════ - -/// Build a model with the given model id pointing at the wiremock server. -fn make_model_with_id(server: &MockServer, model_id: &str) -> AnthropicModel { - AnthropicModel::new( - model_id.to_string(), - AnthropicConfig::new("test-api-key").with_base_url(server.uri()), - ) -} - -/// TS: "should warn when using the default max output token limit". -#[tokio::test] -async fn warns_when_using_default_max_output_token_limit() { - let server = MockServer::start().await; - mock_messages_ok(&server, text_response("Hello!")).await; - - let model = make_model_with_id(&server, "future-model"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["model"], "future-model"); - assert_eq!(body["max_tokens"], 4096); - - assert_eq!(result.warnings.len(), 1); - match &result.warnings[0] { - Warning::Compatibility { feature, details } => { - assert_eq!(feature, "maxOutputTokens"); - assert_eq!( - details.as_deref(), - Some( - "The model \"future-model\" is unknown. The max output tokens have been \ - limited to 4096. Set maxOutputTokens explicitly to override this limit." - ) - ); - } - other => panic!("expected Compatibility warning, got {other:?}"), - } -} - -/// TS: "should not warn when max output tokens are provided". -#[tokio::test] -async fn does_not_warn_when_max_output_tokens_provided() { - let server = MockServer::start().await; - mock_messages_ok(&server, text_response("Hello!")).await; - - let model = make_model_with_id(&server, "future-model"); - let mut opts = default_options(test_prompt()); - opts.max_output_tokens = Some(123456); - let result = model - .do_generate(&opts) - .await - .expect("do_generate should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["model"], "future-model"); - assert_eq!(body["max_tokens"], 123456); - assert!( - result.warnings.is_empty(), - "warnings: {:?}", - result.warnings - ); -} - -/// TS: "should use the current-generation default and warn for an unknown Claude model". -#[tokio::test] -async fn uses_current_gen_default_and_warns_for_unknown_claude_model() { - let server = MockServer::start().await; - mock_messages_ok(&server, text_response("Hello!")).await; - - let model = make_model_with_id(&server, "claude-future-9"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["model"], "claude-future-9"); - assert_eq!(body["max_tokens"], 128000); - - assert_eq!(result.warnings.len(), 1); - match &result.warnings[0] { - Warning::Compatibility { feature, details } => { - assert_eq!(feature, "maxOutputTokens"); - assert_eq!( - details.as_deref(), - Some( - "The model \"claude-future-9\" is unknown. The max output tokens have been \ - limited to 128000. Set maxOutputTokens explicitly to override this limit." - ) - ); - } - other => panic!("expected Compatibility warning, got {other:?}"), - } -} - -// ═════════════════════════════════════════════════════════════════════════════ -// sanitize-json-schema (TS: sanitize-json-schema.test.ts) -// ═════════════════════════════════════════════════════════════════════════════ - -/// TS: "strips unsupported number constraints and adds readable descriptions". -#[test] -fn sanitize_strips_unsupported_number_constraints() { - let schema = json!({ - "type": "object", - "properties": { - "recurringIntervalMinutes": { - "type": "number", - "exclusiveMinimum": 0, - "minimum": 1, - "maximum": 60, - "exclusiveMaximum": 120 - } - }, - "required": ["recurringIntervalMinutes"], - "additionalProperties": false - }); - - let expected = json!({ - "additionalProperties": false, - "properties": { - "recurringIntervalMinutes": { - "description": - "minimum: 1; maximum: 60; exclusive minimum: 0; exclusive maximum: 120.", - "type": "number" - } - }, - "required": ["recurringIntervalMinutes"], - "type": "object" - }); - - assert_eq!(sanitize_json_schema(&schema), expected); -} - -/// TS: "strips unsupported string constraints and unsupported formats". -#[test] -fn sanitize_strips_unsupported_string_constraints_and_formats() { - let schema = json!({ - "type": "object", - "properties": { - "slug": { - "type": "string", - "description": "A URL slug", - "minLength": 1, - "maxLength": 20, - "pattern": "^[a-z0-9-]+$", - "format": "regex" - } - } - }); - - let expected = json!({ - "additionalProperties": false, - "properties": { - "slug": { - "description": - "A URL slug\nmin length: 1; max length: 20; pattern: ^[a-z0-9-]+$; format: regex.", - "type": "string" - } - }, - "type": "object" - }); - - assert_eq!(sanitize_json_schema(&schema), expected); -} - -/// TS: "recursively sanitizes arrays, definitions, and composition schemas". -#[test] -fn sanitize_recursively_handles_arrays_defs_and_composition() { - let schema = json!({ - "type": "object", - "$defs": { - "PositiveInteger": { "type": "integer", "minimum": 1 } - }, - "properties": { - "count": { "$ref": "#/$defs/PositiveInteger" }, - "tags": { - "type": "array", - "minItems": 2, - "maxItems": 4, - "uniqueItems": true, - "items": { - "anyOf": [ - { "type": "string", "minLength": 1 }, - { "type": "number", "maximum": 10 } - ] - } - } - } - }); - - let expected = json!({ - "$defs": { - "PositiveInteger": { - "description": "minimum: 1.", - "type": "integer" - } - }, - "additionalProperties": false, - "properties": { - "count": { "$ref": "#/$defs/PositiveInteger" }, - "tags": { - "description": "min items: 2; max items: 4; unique items: true.", - "items": { - "anyOf": [ - { "description": "min length: 1.", "type": "string" }, - { "description": "maximum: 10.", "type": "number" } - ] - }, - "type": "array" - } - }, - "type": "object" - }); - - assert_eq!(sanitize_json_schema(&schema), expected); -} - -/// TS: "converts oneOf to anyOf". -#[test] -fn sanitize_converts_one_of_to_any_of() { - let schema = json!({ - "oneOf": [ - { "type": "string", "minLength": 1 }, - { "type": "number", "minimum": 0 } - ] - }); - - let expected = json!({ - "anyOf": [ - { "description": "min length: 1.", "type": "string" }, - { "description": "minimum: 0.", "type": "number" } - ] - }); - - assert_eq!(sanitize_json_schema(&schema), expected); -} - -/// TS: "does not mutate the input schema". -#[test] -fn sanitize_does_not_mutate_input_schema() { - let schema = json!({ - "type": "object", - "properties": { - "value": { "type": "number", "exclusiveMinimum": 0 } - } - }); - - let snapshot = schema.clone(); - let _ = sanitize_json_schema(&schema); - assert_eq!(schema, snapshot); -} - -// ═════════════════════════════════════════════════════════════════════════════ -// Mid-conversation tool changes -// (TS: anthropic-language-model.test.ts → 'mid-conversation tool changes') -// ═════════════════════════════════════════════════════════════════════════════ - -/// TS: "should send tool change blocks and the beta header". -/// -/// The TS test sends a system message carrying -/// `providerOptions.anthropic.toolChanges = [{ type: 'tool_removal', toolName: -/// 'get_weather' }]` and asserts: -/// - `messages` contains a `system` message whose content is -/// `[{ type: 'tool_removal', tool: { type: 'tool_reference', name: -/// 'get_weather' } }]`. -/// - the `anthropic-beta` request header contains -/// `mid-conversation-tool-changes-2026-07-01`. -// TODO: implementation gap — the Rust convert does not yet read -// `toolChanges` from provider options, emit `tool_removal`/`tool_addition` -// system blocks, or set the `mid-conversation-tool-changes-2026-07-01` beta -// header. Re-enable once that pipeline is implemented. -#[tokio::test] -#[ignore] -async fn sends_tool_change_blocks_and_beta_header() { - let server = MockServer::start().await; - mock_messages_ok(&server, text_response("OK")).await; - - let config = AnthropicConfig::new("test-api-key").with_base_url(server.uri()); - let model = AnthropicModel::new("claude-opus-4-8".to_string(), config); - - // NOTE: when implemented, `toolChanges` should be carried on the system - // message's provider options. The current `LanguageModelPromptMessage` - // / `CallOptions` shape may not yet expose the required provider-options - // slot; that is part of the implementation gap. - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Say OK.")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::System, - content: vec![], - ..Default::default() - }, - ]; - let opts = default_options(prompt); - - let _ = model - .do_generate(&opts) - .await - .expect("do_generate should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - - // The system message should carry the tool_removal block. - let system_msgs: Vec<&Value> = body["messages"] - .as_array() - .expect("messages array") - .iter() - .filter(|m| m["role"] == "system") - .collect(); - assert!( - system_msgs.iter().any(|m| { - m["content"] - .as_array() - .map(|c| { - c.iter().any(|block| { - block["type"] == "tool_removal" - && block["tool"]["type"] == "tool_reference" - && block["tool"]["name"] == "get_weather" - }) - }) - .unwrap_or(false) - }), - "expected a system tool_removal block, got body: {body}" - ); - - // The beta header should advertise mid-conversation tool changes. - let beta = requests[0] - .headers - .get("anthropic-beta") - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - assert!( - beta.contains("mid-conversation-tool-changes-2026-07-01"), - "missing beta header, got: {beta}" - ); -} diff --git a/aimux-providers/tests/azure_model_test.rs b/aimux-providers/tests/azure_model_test.rs deleted file mode 100644 index a1da57d9c..000000000 --- a/aimux-providers/tests/azure_model_test.rs +++ /dev/null @@ -1,916 +0,0 @@ -//! Wiremock tests for the Azure OpenAI provider. -//! -//! These cover the Azure-specific differences from the OpenAI provider: -//! - deployment-based URL construction with an `api-version` query parameter -//! - `api-key` header authentication (API key) -//! - `Authorization: Bearer ` authentication (Azure AD token provider) -//! - request/response handling (reusing the OpenAI conversion logic) -//! - streaming -//! - error status codes -//! - config validation (missing resource/base URL, missing auth) -//! -//! Each test spins up a `wiremock` mock server, points an `AzureProvider` at -//! it via `base_url`, and asserts on the issued request and/or the parsed -//! result. - -use std::sync::Arc; -use std::sync::atomic::{AtomicU32, Ordering}; - -use async_trait::async_trait; -use futures::StreamExt; -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::CallOptions; -use aimux_core::result::GenerateContent; -use aimux_core::stream_part::StreamPart; -use aimux_core::types::{FinishReasonUnified, ReasoningEffort}; - -use aimux_providers::{AzureConfig, AzureProvider, TokenProvider}; - -// ── helpers ───────────────────────────────────────────────────────────────── - -/// The TS `TEST_PROMPT`: a single user text message "Hello". -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -/// Build `CallOptions` with everything unset except `prompt`. -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -/// Standard mock for a JSON chat completion response on a given path. -async fn mock_json_response(server: &MockServer, request_path: &str, body: Value) { - Mock::given(method("POST")) - .and(path(request_path)) - .respond_with(ResponseTemplate::new(200).set_body_json(body)) - .mount(server) - .await; -} - -/// Mock for a JSON error response on a given path with a status code. -async fn mock_json_error(server: &MockServer, request_path: &str, status: u16, body: Value) { - Mock::given(method("POST")) - .and(path(request_path)) - .respond_with(ResponseTemplate::new(status).set_body_json(body)) - .mount(server) - .await; -} - -/// Standard mock for an SSE streaming response on a given path. -async fn mock_sse_response(server: &MockServer, request_path: &str, sse_body: &str) { - Mock::given(method("POST")) - .and(path(request_path)) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body.to_string()), - ) - .mount(server) - .await; -} - -/// A simple text chat-completion response body. -fn text_completion_response(text: &str) -> Value { - json!({ - "id": "chatcmpl-test", - "object": "chat.completion", - "created": 1711115037, - "model": "gpt-4o", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": text }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - }) -} - -/// Concatenate SSE events and append `[DONE]`. -fn sse_body(events: &[&str]) -> String { - let mut body = String::new(); - for event in events { - body.push_str(event); - } - body.push_str("data: [DONE]\n\n"); - body -} - -/// Collect all `StreamPart`s from a `StreamResult` into a `Vec`. -async fn collect_stream(result: aimux_core::result::StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -/// Extract text deltas from a list of stream parts. -fn text_deltas(parts: &[StreamPart]) -> Vec { - parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect() -} - -/// A token provider that returns a fixed token. -struct StaticToken(String); -#[async_trait] -impl TokenProvider for StaticToken { - async fn get_token(&self) -> Result { - Ok(self.0.clone()) - } -} - -/// A token provider that returns a different token on each call -/// (`token-1`, `token-2`, …) so tests can assert it is invoked per request. -struct CountingToken { - count: AtomicU32, -} -#[async_trait] -impl TokenProvider for CountingToken { - async fn get_token(&self) -> Result { - let n = self.count.fetch_add(1, Ordering::SeqCst) + 1; - Ok(format!("token-{n}")) - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// URL construction -// ════════════════════════════════════════════════════════════════════════════ - -/// The default deployment-based URL form places the deployment in the path and -/// appends `?api-version=2024-10-21` (the default api version). -#[tokio::test] -async fn should_build_deployment_based_url_with_default_api_version() { - let server = MockServer::start().await; - mock_json_response( - &server, - "/deployments/gpt-4o/chat/completions", - text_completion_response("hi"), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - let url = &requests[0].url; - assert_eq!(url.path(), "/deployments/gpt-4o/chat/completions"); - assert_eq!(url.query(), Some("api-version=2024-10-21")); -} - -/// A custom `api_version` is reflected in the `api-version` query parameter. -#[tokio::test] -async fn should_use_custom_api_version() { - let server = MockServer::start().await; - mock_json_response( - &server, - "/deployments/gpt-4o/chat/completions", - text_completion_response("hi"), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key") - .with_api_version("2025-04-01-preview"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let requests = server.received_requests().await.unwrap(); - assert_eq!( - requests[0].url.query(), - Some("api-version=2025-04-01-preview") - ); -} - -/// The v1 URL form uses `/v1/chat/completions` (no deployment in the path). -/// With a non-Azure gateway `base_url`, the gateway owns versioning and -/// `api-version` is omitted — mirroring the TS `useAzureOpenAIEndpoint` gate. -/// (The real-Azure v1 case — `api-version` appended — is covered by the unit -/// tests in `azure/model.rs`, which construct the URL from a `resource_name` -/// without needing a live HTTP endpoint.) -#[tokio::test] -async fn should_use_v1_url_form_on_gateway() { - let server = MockServer::start().await; - mock_json_response( - &server, - "/v1/chat/completions", - text_completion_response("hi"), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key") - .use_v1_urls(); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let requests = server.received_requests().await.unwrap(); - assert_eq!(requests[0].url.path(), "/v1/chat/completions"); - // Non-Azure gateway owns its own versioning — no api-version query param. - assert_eq!(requests[0].url.query(), None); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Authentication -// ════════════════════════════════════════════════════════════════════════════ - -/// API-key auth sends the `api-key` header (not a Bearer token). -#[tokio::test] -async fn should_send_api_key_header() { - let server = MockServer::start().await; - mock_json_response( - &server, - "/deployments/gpt-4o/chat/completions", - text_completion_response("hi"), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let requests = server.received_requests().await.unwrap(); - let headers = &requests[0].headers; - assert_eq!( - headers.get("api-key").and_then(|v| v.to_str().ok()), - Some("test-api-key") - ); - // No Authorization header when using api-key auth. - assert!(headers.get("authorization").is_none()); -} - -/// A token provider supplies a Bearer token via the `Authorization` header. -#[tokio::test] -async fn should_send_bearer_token_from_token_provider() { - let server = MockServer::start().await; - mock_json_response( - &server, - "/deployments/gpt-4o/chat/completions", - text_completion_response("hi"), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_token_provider(Arc::new(StaticToken("test-azure-ad-token".to_string()))); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let requests = server.received_requests().await.unwrap(); - let headers = &requests[0].headers; - assert_eq!( - headers.get("authorization").and_then(|v| v.to_str().ok()), - Some("Bearer test-azure-ad-token") - ); - // No api-key header when using token auth. - assert!(headers.get("api-key").is_none()); -} - -/// The token provider is invoked on every request (tokens differ per call). -#[tokio::test] -async fn should_call_token_provider_per_request() { - let server = MockServer::start().await; - mock_json_response( - &server, - "/deployments/gpt-4o/chat/completions", - text_completion_response("hi"), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_token_provider(Arc::new(CountingToken { - count: AtomicU32::new(0), - })); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("first call"); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("second call"); - - let requests = server.received_requests().await.unwrap(); - assert_eq!(requests.len(), 2); - assert_eq!( - requests[0] - .headers - .get("authorization") - .and_then(|v| v.to_str().ok()), - Some("Bearer token-1") - ); - assert_eq!( - requests[1] - .headers - .get("authorization") - .and_then(|v| v.to_str().ok()), - Some("Bearer token-2") - ); -} - -/// Provider-level and per-request headers are merged onto the auth headers. -#[tokio::test] -async fn should_merge_provider_and_request_headers() { - let server = MockServer::start().await; - mock_json_response( - &server, - "/deployments/gpt-4o/chat/completions", - text_completion_response("hi"), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key") - .with_header("Custom-Provider-Header", "provider-header-value"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let mut options = default_options(test_prompt()); - let mut req_headers = std::collections::HashMap::new(); - req_headers.insert( - "Custom-Request-Header".to_string(), - "request-header-value".to_string(), - ); - options.headers = Some(req_headers); - - let _ = model.do_generate(&options).await.expect("do_generate"); - - let requests = server.received_requests().await.unwrap(); - let headers = &requests[0].headers; - assert_eq!( - headers.get("api-key").and_then(|v| v.to_str().ok()), - Some("test-api-key") - ); - assert_eq!( - headers - .get("custom-provider-header") - .and_then(|v| v.to_str().ok()), - Some("provider-header-value") - ); - assert_eq!( - headers - .get("custom-request-header") - .and_then(|v| v.to_str().ok()), - Some("request-header-value") - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Response parsing (reuses OpenAI conversion logic) -// ════════════════════════════════════════════════════════════════════════════ - -/// `do_generate` extracts the assistant text content. -#[tokio::test] -async fn should_extract_text_response() { - let server = MockServer::start().await; - mock_json_response( - &server, - "/deployments/gpt-4o/chat/completions", - text_completion_response("Hello, World!"), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::Text { text, .. } => assert_eq!(text, "Hello, World!"), - other => panic!("expected Text, got {other:?}"), - } - // Finish reason + response metadata. - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(result.response.id, Some("chatcmpl-test".to_string())); - assert_eq!(result.response.model_id, Some("gpt-4o".to_string())); -} - -/// `do_generate` maps Azure/OpenAI usage tokens. -#[tokio::test] -async fn should_extract_usage() { - let server = MockServer::start().await; - mock_json_response( - &server, - "/deployments/gpt-4o/chat/completions", - text_completion_response(""), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.usage.input_tokens.total, Some(4)); - assert_eq!(result.usage.output_tokens.total, Some(30)); -} - -/// The request body carries the deployment as `model` plus the user message. -#[tokio::test] -async fn should_send_deployment_and_messages_in_request_body() { - let server = MockServer::start().await; - mock_json_response( - &server, - "/deployments/gpt-4o/chat/completions", - text_completion_response("hi"), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["model"], "gpt-4o"); - assert_eq!(body["messages"][0]["role"], "user"); - assert_eq!(body["messages"][0]["content"], "Hello"); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Streaming -// ════════════════════════════════════════════════════════════════════════════ - -/// `do_stream` emits text deltas followed by a Finish part. -#[tokio::test] -async fn should_stream_text_deltas() { - let server = MockServer::start().await; - let sse = sse_body(&[ - "data: {\"id\":\"chatcmpl-1\",\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"finish_reason\":null}]}\n\n", - "data: {\"id\":\"chatcmpl-1\",\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\", World!\"},\"finish_reason\":null}]}\n\n", - "data: {\"id\":\"chatcmpl-1\",\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":4,\"completion_tokens\":10,\"total_tokens\":14}}\n\n", - ]); - mock_sse_response(&server, "/deployments/gpt-4o/chat/completions", &sse).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - let deltas = text_deltas(&parts); - assert_eq!(deltas, vec!["Hello".to_string(), ", World!".to_string()]); - - // The stream must end with a Finish part carrying the stop reason + usage. - let finish = parts - .iter() - .rev() - .find(|p| matches!(p, StreamPart::Finish { .. })) - .expect("stream should end with Finish"); - match finish { - StreamPart::Finish { - finish_reason, - usage, - .. - } => { - assert_eq!(finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(usage.output_tokens.total, Some(10)); - } - _ => unreachable!(), - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// Errors & config validation -// ════════════════════════════════════════════════════════════════════════════ - -/// A 401 response maps to `AiMuxError::ApiCall` (401 in `status_code`). -#[tokio::test] -async fn should_return_auth_error_on_401() { - let server = MockServer::start().await; - mock_json_error( - &server, - "/deployments/gpt-4o/chat/completions", - 401, - json!({ "error": { "message": "invalid api key", "type": "authentication_error" } }), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("bad-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let err = model - .do_generate(&default_options(test_prompt())) - .await - .expect_err("401 should error"); - assert!( - matches!(err, ref e if e.status_code() == Some(401)), - "{err:?}" - ); -} - -/// Constructing a provider without `resource_name` or `base_url` is rejected. -#[tokio::test] -async fn should_reject_missing_resource_and_base_url() { - let config = AzureConfig::new().with_api_key("test-api-key"); - assert!(matches!( - AzureProvider::new(config), - Err(AiMuxError::InvalidArgument(_)) - )); -} - -/// Calling a model with no auth configured returns an `Auth` error at request -/// time. -#[tokio::test] -async fn should_error_when_no_auth_configured() { - let server = MockServer::start().await; - // No mock is mounted: the request must never be sent because auth fails - // before the HTTP call. - let config = AzureConfig::new().with_base_url(server.uri()); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let err = model - .do_generate(&default_options(test_prompt())) - .await - .expect_err("no auth should error"); - assert!(matches!(err, AiMuxError::InvalidArgument(_)), "{err:?}"); - - // No request should have hit the server. - let requests = server.received_requests().await.unwrap(); - assert!(requests.is_empty()); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Additional cases — tool calls, response format, reasoning, headers, errors. -// ════════════════════════════════════════════════════════════════════════════ - -/// A chat-completion response carrying a single tool call. -fn tool_call_completion_body() -> Value { - json!({ - "id": "chatcmpl-tool", - "object": "chat.completion", - "created": 1711115037, - "model": "gpt-4o", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "tool_calls": [{ - "id": "call_abc", - "type": "function", - "function": { - "name": "get-weather", - "arguments": "{\"city\":\"SF\"}" - } - }] - }, - "finish_reason": "tool_calls" - }], - "usage": { "prompt_tokens": 10, "total_tokens": 20, "completion_tokens": 10 } - }) -} - -/// TS: tool-call extraction — `do_generate` surfaces a `ToolCall` content. -#[tokio::test] -async fn should_extract_tool_call() { - let server = MockServer::start().await; - mock_json_response( - &server, - "/deployments/gpt-4o/chat/completions", - tool_call_completion_body(), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::ToolCall { - 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())); - } - other => panic!("expected ToolCall, got {other:?}"), - } - assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); -} - -/// TS: "should stream tool call content" — streaming emits a ToolCall part. -#[tokio::test] -async fn should_stream_tool_call() { - let server = MockServer::start().await; - let sse = sse_body(&[ - "data: {\"id\":\"chatcmpl-1\",\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"call_abc\",\"type\":\"function\",\"function\":{\"name\":\"get-weather\",\"arguments\":\"\"}}]},\"finish_reason\":null}]}\n\n", - "data: {\"id\":\"chatcmpl-1\",\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"city\\\":\\\"SF\\\"}\"}}]},\"finish_reason\":null}]}\n\n", - "data: {\"id\":\"chatcmpl-1\",\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":10,\"total_tokens\":20}}\n\n", - ]); - mock_sse_response(&server, "/deployments/gpt-4o/chat/completions", &sse).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - let tool_call = parts.iter().find_map(|p| match p { - StreamPart::ToolCall { - tool_call_id, - tool_name, - input, - .. - } => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), - _ => None, - }); - let (id, name, input) = tool_call.expect("should have ToolCall"); - assert_eq!(id, "call_abc"); - assert_eq!(name, "get-weather"); - assert_eq!(input, Value::String(r#"{"city":"SF"}"#.into())); -} - -/// TS: "should send a json_schema response format for structured output" -#[tokio::test] -async fn should_send_json_schema_response_format() { - let server = MockServer::start().await; - mock_json_response( - &server, - "/deployments/gpt-4o/chat/completions", - text_completion_response("ok"), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let schema = json!({ - "type": "object", - "properties": { "sentiment": { "type": "string" } }, - "required": ["sentiment"], - "additionalProperties": false - }); - let mut options = default_options(test_prompt()); - options.response_format = Some(aimux_core::options::ResponseFormat::Json { - schema: Some(schema.clone()), - name: None, - description: None, - }); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["response_format"]["type"], json!("json_schema")); - assert_eq!(body["response_format"]["json_schema"]["schema"], schema); - assert_eq!( - body["response_format"]["json_schema"]["strict"], - json!(true) - ); -} - -/// TS: "should map top-level reasoning to Azure DeepSeek reasoning effort" — -/// a custom `reasoning` value maps to `reasoning_effort` in the request body. -#[tokio::test] -async fn should_map_reasoning_effort() { - let server = MockServer::start().await; - mock_json_response( - &server, - "/deployments/gpt-4o/chat/completions", - text_completion_response("ok"), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let mut options = default_options(test_prompt()); - options.reasoning = Some(ReasoningEffort::High); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["reasoning_effort"], json!("high")); -} - -/// TS: response headers are exposed on the generate result. -#[tokio::test] -async fn should_expose_response_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/deployments/gpt-4o/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("test-header", "test-value") - .set_body_json(text_completion_response("hi")), - ) - .mount(&server) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} - -/// TS: response headers are exposed on the stream result. -#[tokio::test] -async fn should_expose_response_headers_stream() { - let server = MockServer::start().await; - let sse = sse_body(&[ - "data: {\"id\":\"chatcmpl-1\",\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hi\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}\n\n", - ]); - Mock::given(method("POST")) - .and(path("/deployments/gpt-4o/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .insert_header("test-header", "test-value") - .set_body_string(sse), - ) - .mount(&server) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} - -/// TS: a `length` finish reason maps to `FinishReasonUnified::Length`. -#[tokio::test] -async fn should_map_length_finish_reason() { - let server = MockServer::start().await; - mock_json_response( - &server, - "/deployments/gpt-4o/chat/completions", - json!({ - "id": "chatcmpl-len", - "object": "chat.completion", - "created": 1711115037, - "model": "gpt-4o", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "truncated" }, - "finish_reason": "length" - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - }), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Length); - assert_eq!(result.finish_reason.raw.as_deref(), Some("length")); -} - -/// TS: a 429 response maps to `AiMuxError::ApiCall` (429 in `status_code`). -#[tokio::test] -async fn should_return_rate_limited_on_429() { - let server = MockServer::start().await; - mock_json_error( - &server, - "/deployments/gpt-4o/chat/completions", - 429, - json!({ "error": { "message": "Too many requests", "type": "rate_limit" } }), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let err = model - .do_generate(&default_options(test_prompt())) - .await - .expect_err("429 should error"); - assert!(matches!(err, ref e if e.status_code() == Some(429))); -} diff --git a/aimux-providers/tests/azure_responses_test.rs b/aimux-providers/tests/azure_responses_test.rs deleted file mode 100644 index f3b5de543..000000000 --- a/aimux-providers/tests/azure_responses_test.rs +++ /dev/null @@ -1,1054 +0,0 @@ -//! Wiremock tests for the Azure OpenAI Responses API provider. -//! -//! These cover the Azure-specific differences from the OpenAI Responses -//! provider: -//! - deployment-based URL construction with an `api-version` query parameter -//! - v1 URL form on a non-Azure gateway (no `api-version`) -//! - `api-key` header authentication (API key) -//! - `Authorization: Bearer ` authentication (Azure AD token provider) -//! - token provider invoked per request (tokens differ per call) -//! - custom provider/request headers + user-agent suffix -//! - Azure `assistant-` file ID prefix passthrough (file_id vs base64) -//! - doGenerate text extraction, usage, response metadata, provider metadata -//! - doStream text content streaming -//! - error status codes -//! - env-var config (serial) -//! -//! Each test spins up a `wiremock` mock server, points an `AzureProvider` at -//! it via `base_url`, and asserts on the issued request and/or the parsed -//! result. -//! -//! Translated from the TS test suite -//! `reference/ai/packages/azure/src/azure-openai-provider.test.ts` -//! (responses-specific cases). Not all 44 TS cases are translated — the focus -//! is on Azure-specific capabilities. - -use std::sync::Arc; -use std::sync::atomic::{AtomicU32, Ordering}; - -use async_trait::async_trait; -use futures::StreamExt; -use serde_json::{Value, json}; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::CallOptions; -use aimux_core::result::GenerateContent; -use aimux_core::stream_part::StreamPart; -use aimux_core::types::FinishReasonUnified; - -use aimux_providers::{AzureConfig, AzureProvider, TokenProvider}; - -// ── helpers ───────────────────────────────────────────────────────────────── - -/// The TS `TEST_PROMPT`: a single user text message "Hello". -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -/// Build `CallOptions` with everything unset except `prompt`. -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -/// The default deployment-based path used by most tests. -const DEPLOYMENT_PATH: &str = "/deployments/test-deployment/responses"; - -/// Standard mock for a JSON responses-api response on the deployment path. -async fn mock_json_response(server: &MockServer, body: Value) { - Mock::given(method("POST")) - .and(path(DEPLOYMENT_PATH)) - .respond_with(ResponseTemplate::new(200).set_body_json(body)) - .mount(server) - .await; -} - -/// Mock for a JSON responses-api response with custom headers. -async fn mock_json_response_with_headers( - server: &MockServer, - body: Value, - headers: Vec<(&str, &str)>, -) { - let mut template = ResponseTemplate::new(200).set_body_json(body); - for (k, v) in headers { - template = template.insert_header(k, v); - } - Mock::given(method("POST")) - .and(path(DEPLOYMENT_PATH)) - .respond_with(template) - .mount(server) - .await; -} - -/// Standard mock for an SSE streaming response on the deployment path. -async fn mock_sse_response(server: &MockServer, sse_body: &str) { - Mock::given(method("POST")) - .and(path(DEPLOYMENT_PATH)) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body.to_string()), - ) - .mount(server) - .await; -} - -/// A simple text responses-api response body (single message output item). -fn text_response_body() -> Value { - json!({ - "id": "resp_67c97c0203188190a025beb4a75242bc", - "object": "response", - "created_at": 1741257730, - "status": "completed", - "error": null, - "incomplete_details": null, - "model": "gpt-4o", - "output": [ - { - "id": "msg_67c97c02656c81908e080dfdf4a03cd1", - "type": "message", - "status": "completed", - "role": "assistant", - "content": [ - { - "type": "output_text", - "text": "answer text", - "annotations": [] - } - ] - } - ], - "usage": { - "input_tokens": 345, - "input_tokens_details": { - "cached_tokens": 234 - }, - "output_tokens": 538, - "output_tokens_details": { - "reasoning_tokens": 123 - } - }, - "reasoning": { "effort": null, "summary": null, "context": "current_turn" } - }) -} - -/// A responses-api response body with a function_call output item. -fn tool_call_response_body() -> Value { - json!({ - "id": "resp_tool_call_123", - "object": "response", - "created_at": 1741257730, - "status": "completed", - "error": null, - "incomplete_details": null, - "model": "gpt-4o", - "output": [ - { - "id": "fc_001", - "type": "function_call", - "status": "completed", - "call_id": "call_abc123", - "name": "getWeather", - "arguments": "{\"location\": \"San Francisco\"}" - } - ], - "usage": { - "input_tokens": 50, - "output_tokens": 20, - "total_tokens": 70 - } - }) -} - -/// Concatenate SSE events and append `[DONE]`. -fn sse_body(events: &[&str]) -> String { - let mut body = String::new(); - for event in events { - body.push_str(event); - } - body.push_str("data: [DONE]\n\n"); - body -} - -/// Build an SSE event string from a JSON string. -fn sse_event(json_str: &str) -> String { - format!("data: {json_str}\n\n") -} - -/// Collect all `StreamPart`s from a `StreamResult` into a `Vec`. -async fn collect_stream(result: aimux_core::result::StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -/// Extract text deltas from a list of stream parts. -fn text_deltas(parts: &[StreamPart]) -> Vec { - parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect() -} - -/// Extract the first recorded request body as a JSON value. -async fn first_request_body(server: &MockServer) -> Value { - let requests = server - .received_requests() - .await - .expect("no requests received"); - serde_json::from_slice(&requests[0].body).expect("invalid JSON body") -} - -/// A token provider that returns a fixed token. -struct StaticToken(String); -#[async_trait] -impl TokenProvider for StaticToken { - async fn get_token(&self) -> Result { - Ok(self.0.clone()) - } -} - -/// A token provider that returns a different token on each call -/// (`token-1`, `token-2`, …) so tests can assert it is invoked per request. -struct CountingToken { - count: AtomicU32, -} -#[async_trait] -impl TokenProvider for CountingToken { - async fn get_token(&self) -> Result { - let n = self.count.fetch_add(1, Ordering::SeqCst) + 1; - Ok(format!("token-{n}")) - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// URL construction & api-version -// ════════════════════════════════════════════════════════════════════════════ - -/// The default deployment-based URL form places the deployment in the path and -/// appends `?api-version=2024-10-21` (the Rust AzureConfig default). -#[tokio::test] -async fn should_build_deployment_url_with_default_api_version() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - assert_eq!(requests[0].url.path(), DEPLOYMENT_PATH); - assert_eq!(requests[0].url.query(), Some("api-version=2024-10-21")); -} - -/// A custom `api_version` is reflected in the `api-version` query parameter. -#[tokio::test] -async fn should_use_custom_api_version() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key") - .with_api_version("2025-04-01-preview"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let requests = server.received_requests().await.unwrap(); - assert_eq!( - requests[0].url.query(), - Some("api-version=2025-04-01-preview") - ); -} - -/// The v1 form on a non-Azure gateway baseURL omits `api-version` -/// (the gateway owns its own versioning). -#[tokio::test] -async fn should_omit_api_version_on_gateway_v1_url() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_response_body())) - .mount(&server) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key") - .use_v1_urls(); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let requests = server.received_requests().await.unwrap(); - assert_eq!(requests[0].url.path(), "/v1/responses"); - // Non-Azure gateway owns its own versioning — no api-version query param. - assert_eq!(requests[0].url.query(), None); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Authentication -// ════════════════════════════════════════════════════════════════════════════ - -/// API-key auth sends the `api-key` header (not a Bearer token). -#[tokio::test] -async fn should_send_api_key_header() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let requests = server.received_requests().await.unwrap(); - let headers = &requests[0].headers; - assert_eq!( - headers.get("api-key").and_then(|v| v.to_str().ok()), - Some("test-api-key") - ); - // No Authorization header when using api-key auth. - assert!(headers.get("authorization").is_none()); -} - -/// A token provider supplies a Bearer token via the `Authorization` header. -#[tokio::test] -async fn should_send_bearer_token_from_token_provider() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_token_provider(Arc::new(StaticToken("test-azure-ad-token".to_string()))); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let requests = server.received_requests().await.unwrap(); - let headers = &requests[0].headers; - assert_eq!( - headers.get("authorization").and_then(|v| v.to_str().ok()), - Some("Bearer test-azure-ad-token") - ); - // No api-key header when using token auth. - assert!(headers.get("api-key").is_none()); -} - -/// The token provider is invoked on every request (tokens differ per call). -#[tokio::test] -async fn should_call_token_provider_per_request() { - let server = MockServer::start().await; - // Mount two responses (one per request). - for _ in 0..2 { - mock_json_response(&server, text_response_body()).await; - } - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_token_provider(Arc::new(CountingToken { - count: AtomicU32::new(0), - })); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("first do_generate"); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("second do_generate"); - - let requests = server.received_requests().await.unwrap(); - assert_eq!( - requests[0] - .headers - .get("authorization") - .and_then(|v| v.to_str().ok()), - Some("Bearer token-1") - ); - assert_eq!( - requests[1] - .headers - .get("authorization") - .and_then(|v| v.to_str().ok()), - Some("Bearer token-2") - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Headers -// ════════════════════════════════════════════════════════════════════════════ - -/// Provider-level and per-request headers are merged; user-agent suffix is set. -#[tokio::test] -async fn should_pass_custom_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path(DEPLOYMENT_PATH)) - .and(header("api-key", "test-api-key")) - .and(header("custom-provider-header", "provider-header-value")) - .and(header("custom-request-header", "request-header-value")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_response_body())) - .mount(&server) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key") - .with_header("Custom-Provider-Header", "provider-header-value"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let options = CallOptions { - headers: Some({ - let mut m = std::collections::HashMap::new(); - m.insert( - "Custom-Request-Header".to_string(), - "request-header-value".to_string(), - ); - m - }), - ..default_options(test_prompt()) - }; - - let _ = model - .do_generate(&options) - .await - .expect("do_generate should succeed"); - - // If the mock matched, the headers were all present. - let requests = server.received_requests().await.unwrap(); - assert_eq!(requests.len(), 1); - let ua = requests[0] - .headers - .get("user-agent") - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - assert!( - ua.contains("ai-sdk/azure"), - "user-agent should contain ai-sdk/azure, got: {ua}" - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate — response parsing -// ════════════════════════════════════════════════════════════════════════════ - -/// Text content is extracted from the `output` array. -#[tokio::test] -async fn should_extract_text_content() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let text: Vec<&str> = result - .content - .iter() - .filter_map(|c| match c { - GenerateContent::Text { text, .. } => Some(text.as_str()), - _ => None, - }) - .collect(); - assert_eq!(text, vec!["answer text"]); -} - -/// Function-call output items are extracted as ToolCall content. -#[tokio::test] -async fn should_extract_tool_call_content() { - let server = MockServer::start().await; - mock_json_response(&server, tool_call_response_body()).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let tool_calls: Vec<_> = result - .content - .iter() - .filter_map(|c| match c { - GenerateContent::ToolCall { - tool_call_id, - tool_name, - input, - .. - } => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), - _ => None, - }) - .collect(); - assert_eq!(tool_calls.len(), 1); - assert_eq!(tool_calls[0].0, "call_abc123"); - assert_eq!(tool_calls[0].1, "getWeather"); - assert_eq!( - tool_calls[0].2, - Value::String(r#"{"location": "San Francisco"}"#.into()) - ); -} - -/// Usage is extracted from the `usage` field. -#[tokio::test] -async fn should_extract_usage() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.usage.input_tokens.total, Some(345)); - assert_eq!(result.usage.output_tokens.total, Some(538)); -} - -/// Response metadata (id, model, timestamp) is extracted. -#[tokio::test] -async fn should_extract_response_metadata() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!( - result.response.id.as_deref(), - Some("resp_67c97c0203188190a025beb4a75242bc") - ); - assert_eq!(result.response.model_id.as_deref(), Some("gpt-4o")); - assert!(result.response.timestamp.is_some()); -} - -/// Provider metadata uses the `azure` namespace key. -#[tokio::test] -async fn should_use_azure_provider_metadata_namespace() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let pm = result.provider_metadata.expect("provider_metadata"); - assert!(pm.get("azure").is_some(), "should have 'azure' key"); - assert_eq!( - pm["azure"]["responseId"], - "resp_67c97c0203188190a025beb4a75242bc" - ); - // The provider() method returns "azure.responses". - assert_eq!(model.provider(), "azure.responses"); -} - -/// Finish reason is `stop` for a completed text response. -#[tokio::test] -async fn should_map_finish_reason_stop() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); -} - -/// Finish reason is `tool-calls` when the output contains a function_call. -#[tokio::test] -async fn should_map_finish_reason_tool_calls() { - let server = MockServer::start().await; - mock_json_response(&server, tool_call_response_body()).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); -} - -/// Response headers are captured. -#[tokio::test] -async fn should_extract_response_headers() { - let server = MockServer::start().await; - mock_json_response_with_headers( - &server, - text_response_body(), - vec![("test-header", "test-value")], - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let headers = result.response_headers.expect("response_headers"); - assert_eq!( - headers.get("test-header").map(std::string::String::as_str), - Some("test-value") - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Azure file ID prefix (assistant-) -// ════════════════════════════════════════════════════════════════════════════ - -/// When a FileBase64 part's data starts with `assistant-`, it is passed -/// through as a `file_id` instead of being base64-encoded. -#[tokio::test] -async fn should_pass_through_assistant_file_id_for_image() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("Analyze this image"), - ContentPart::FileBase64 { - data: "assistant-abc123".to_string(), - media_type: "image/jpeg".to_string(), - filename: None, - provider_options: None, - }, - ], - ..Default::default() - }]; - - let _ = model - .do_generate(&default_options(prompt)) - .await - .expect("should succeed"); - - let body = first_request_body(&server).await; - assert_eq!(body["input"][0]["content"][1]["type"], "input_image"); - assert_eq!( - body["input"][0]["content"][1]["file_id"], - "assistant-abc123" - ); - // image_url should NOT be present. - assert!(body["input"][0]["content"][1].get("image_url").is_none()); -} - -/// When a FileBase64 part's data starts with `assistant-` for a PDF, -/// it is passed through as a `file_id` with `input_file` type. -#[tokio::test] -async fn should_pass_through_assistant_file_id_for_pdf() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("Analyze this PDF"), - ContentPart::FileBase64 { - data: "assistant-pdf123".to_string(), - media_type: "application/pdf".to_string(), - filename: None, - provider_options: None, - }, - ], - ..Default::default() - }]; - - let _ = model - .do_generate(&default_options(prompt)) - .await - .expect("should succeed"); - - let body = first_request_body(&server).await; - assert_eq!(body["input"][0]["content"][1]["type"], "input_file"); - assert_eq!( - body["input"][0]["content"][1]["file_id"], - "assistant-pdf123" - ); - // file_data and filename should NOT be present. - assert!(body["input"][0]["content"][1].get("file_data").is_none()); - assert!(body["input"][0]["content"][1].get("filename").is_none()); -} - -/// Non-`assistant-` file IDs fall back to base64 encoding (the default -/// OpenAI behavior). The Rust convert produces `input_file` for `FileBase64` -/// regardless of media type — the data is not treated as a file ID. -#[tokio::test] -async fn should_fall_back_to_base64_for_non_assistant_file_ids() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("Analyze this image"), - ContentPart::FileBase64 { - data: "file-abc123".to_string(), - media_type: "image/jpeg".to_string(), - filename: None, - provider_options: None, - }, - ], - ..Default::default() - }]; - - let _ = model - .do_generate(&default_options(prompt)) - .await - .expect("should succeed"); - - let body = first_request_body(&server).await; - // Rust convert produces input_file for FileBase64 (not input_image). - assert_eq!(body["input"][0]["content"][1]["type"], "input_file"); - assert_eq!( - body["input"][0]["content"][1]["file_data"], - "data:image/jpeg;base64,file-abc123" - ); - // file_id should NOT be present (data doesn't start with assistant-). - assert!(body["input"][0]["content"][1].get("file_id").is_none()); -} - -// ════════════════════════════════════════════════════════════════════════════ -// doStream — text content -// ════════════════════════════════════════════════════════════════════════════ - -/// Streaming text content: response.created → output_item.added → -/// output_text.delta → output_item.done → response.completed. -#[tokio::test] -async fn should_stream_text_content() { - let server = MockServer::start().await; - let sse = sse_body(&[ - &sse_event( - r#"{"type":"response.created","response":{"id":"resp_123","object":"response","created_at":1741257730,"status":"in_progress","model":"gpt-4o","output":[]}}"#, - ), - &sse_event( - r#"{"type":"response.output_item.added","output_index":0,"item":{"id":"msg_1","type":"message","status":"in_progress","role":"assistant","content":[]}}"#, - ), - &sse_event( - r#"{"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":"Hello"}"#, - ), - &sse_event( - r#"{"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":" world"}"#, - ), - &sse_event( - r#"{"type":"response.output_item.done","output_index":0,"item":{"id":"msg_1","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello world","annotations":[]}]}}"#, - ), - &sse_event( - r#"{"type":"response.completed","response":{"id":"resp_123","object":"response","created_at":1741257730,"status":"completed","model":"gpt-4o","output":[{"id":"msg_1","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello world","annotations":[]}]}],"usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15}}}"#, - ), - ]); - mock_sse_response(&server, &sse).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - - // StreamStart - assert!(matches!(&parts[0], StreamPart::StreamStart { .. })); - - // ResponseMetadata - assert!(matches!( - &parts[1], - StreamPart::ResponseMetadata { id, .. } if id.as_deref() == Some("resp_123") - )); - - // TextStart - assert!(matches!( - &parts[2], - StreamPart::TextStart { id, .. } if id == "msg_1" - )); - - // TextDelta "Hello" + " world" - let deltas = text_deltas(&parts); - assert_eq!(deltas, vec!["Hello", " world"]); - - // TextEnd - assert!(matches!( - parts.iter().find(|p| matches!(p, StreamPart::TextEnd { .. })), - Some(StreamPart::TextEnd { id, .. }) if id == "msg_1" - )); - - // Finish - let finish = parts - .iter() - .find(|p| matches!(p, StreamPart::Finish { .. })); - assert!(finish.is_some()); - if let Some(StreamPart::Finish { - finish_reason, - usage, - .. - }) = finish - { - assert_eq!(finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(usage.input_tokens.total, Some(10)); - assert_eq!(usage.output_tokens.total, Some(5)); - } else { - panic!("expected Finish part"); - } -} - -/// Streaming: api-key header is sent and api-version query param is present. -#[tokio::test] -async fn should_send_api_key_header_on_stream() { - let server = MockServer::start().await; - let sse = sse_body(&[ - &sse_event( - r#"{"type":"response.created","response":{"id":"resp_1","object":"response","created_at":1,"status":"in_progress","model":"gpt-4o","output":[]}}"#, - ), - &sse_event( - r#"{"type":"response.completed","response":{"id":"resp_1","object":"response","created_at":1,"status":"completed","model":"gpt-4o","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}"#, - ), - ]); - mock_sse_response(&server, &sse).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let _ = collect_stream(result).await; - - let requests = server.received_requests().await.unwrap(); - assert_eq!(requests.len(), 1); - assert_eq!(requests[0].url.query(), Some("api-version=2024-10-21")); - assert_eq!( - requests[0] - .headers - .get("api-key") - .and_then(|v| v.to_str().ok()), - Some("test-api-key") - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Error handling -// ════════════════════════════════════════════════════════════════════════════ - -/// A non-2xx response is mapped to a provider error. -#[tokio::test] -async fn should_handle_error_status() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path(DEPLOYMENT_PATH)) - .respond_with(ResponseTemplate::new(429).set_body_json(json!({ - "error": { - "message": "Rate limit exceeded", - "type": "rate_limit_exceeded", - "code": "rate_limit_exceeded" - } - }))) - .mount(&server) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let result = model.do_generate(&default_options(test_prompt())).await; - - assert!(result.is_err()); - let err = result.unwrap_err(); - let msg = err.to_string().to_lowercase(); - assert!( - msg.contains("rate limit"), - "error should mention rate limit, got: {err}" - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Environment variable configuration (serial) -// ════════════════════════════════════════════════════════════════════════════ - -/// `AzureConfig::from_env` reads `AZURE_API_KEY` and `AZURE_RESOURCE_NAME`. -#[tokio::test] -#[serial_test::serial] -async fn should_create_config_from_env() { - // Set env vars. - unsafe { - std::env::set_var("AZURE_API_KEY", "env-api-key"); - std::env::set_var("AZURE_RESOURCE_NAME", "env-resource"); - } - - let config = AzureConfig::from_env().expect("from_env"); - assert_eq!(config.resource_name.as_deref(), Some("env-resource")); - - // Clean up. - unsafe { - std::env::remove_var("AZURE_API_KEY"); - std::env::remove_var("AZURE_RESOURCE_NAME"); - } - - // Verify the config produces the right URL. - let model = AzureProvider::new(config) - .expect("provider") - .responses_model("my-deployment"); - assert_eq!(model.model_id(), "my-deployment"); - assert_eq!(model.provider(), "azure.responses"); -} - -/// `AzureConfig::from_env` fails when `AZURE_API_KEY` is not set. -#[tokio::test] -#[serial_test::serial] -async fn should_fail_from_env_without_api_key() { - // Ensure the env var is not set. - unsafe { - std::env::remove_var("AZURE_API_KEY"); - } - - let result = AzureConfig::from_env(); - assert!(result.is_err()); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Request body construction -// ════════════════════════════════════════════════════════════════════════════ - -/// The request body includes the model (deployment) and input array. -#[tokio::test] -async fn should_send_model_and_input_in_request_body() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let body = first_request_body(&server).await; - assert_eq!(body["model"], "test-deployment"); - assert_eq!(body["input"][0]["role"], "user"); - assert_eq!(body["input"][0]["content"][0]["type"], "input_text"); - assert_eq!(body["input"][0]["content"][0]["text"], "Hello"); -} diff --git a/aimux-providers/tests/bedrock_model_test.rs b/aimux-providers/tests/bedrock_model_test.rs deleted file mode 100644 index eebc91f39..000000000 --- a/aimux-providers/tests/bedrock_model_test.rs +++ /dev/null @@ -1,751 +0,0 @@ -//! Wiremock tests for the Amazon Bedrock provider. -//! -//! Tests cover: -//! - Non-streaming text generation (Converse API JSON response) -//! - Non-streaming tool calls -//! - Error handling (HTTP error status) -//! - Streaming via the AWS event stream binary format - -use futures::StreamExt; -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::{CallOptions, Tool, ToolChoice}; -use aimux_core::result::{GenerateContent, StreamResult}; -use aimux_core::stream_part::StreamPart; -use aimux_core::tool::FunctionTool; -use aimux_core::types::FinishReasonUnified; - -use aimux_providers::bedrock::{BedrockModel, BedrockProviderConfig}; - -// ── Helpers ────────────────────────────────────────────────────────────────── - -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -fn make_model(server: &MockServer) -> BedrockModel { - BedrockModel::new( - "anthropic.claude-3-5-sonnet-20240620-v1:0".to_string(), - aimux_providers::bedrock::BedrockConfig { - base_url: server.uri(), - auth: aimux_providers::bedrock::BedrockAuth::BearerToken("test-token".to_string()), - retry_config: aimux_provider_utils::RetryConfig::default(), - api_key_source: None, - }, - ) -} - -async fn mock_converse_json(server: &MockServer, status: u16, body: Value) { - let model_path = "/model/anthropic.claude-3-5-sonnet-20240620-v1:0/converse"; - Mock::given(method("POST")) - .and(path(model_path)) - .respond_with(ResponseTemplate::new(status).set_body_json(body)) - .mount(server) - .await; -} - -fn as_text(item: &GenerateContent) -> &str { - match item { - GenerateContent::Text { text, .. } => text, - _ => panic!("expected Text content, got {item:?}"), - } -} - -fn as_tool_call(item: &GenerateContent) -> (&str, &str, &str) { - match item { - GenerateContent::ToolCall { - tool_call_id, - tool_name, - input, - .. - } => (tool_call_id, tool_name, input), - _ => panic!("expected ToolCall content, got {item:?}"), - } -} - -async fn collect_stream(result: StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -// ═════════════════════════════════════════════════════════════════════════════ -// doGenerate tests -// ═════════════════════════════════════════════════════════════════════════════ - -/// Test: non-streaming text generation extracts text content and usage. -#[tokio::test] -async fn bedrock_generate_text_response() { - let server = MockServer::start().await; - mock_converse_json( - &server, - 200, - json!({ - "output": { - "message": { - "role": "assistant", - "content": [{ "text": "Hello from Bedrock!" }] - } - }, - "stopReason": "end_turn", - "usage": { - "inputTokens": 10, - "outputTokens": 20, - "totalTokens": 30 - } - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - assert_eq!(as_text(&result.content[0]), "Hello from Bedrock!"); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(result.finish_reason.raw.as_deref(), Some("end_turn")); - assert_eq!(result.usage.input_tokens.total, Some(10)); - assert_eq!(result.usage.output_tokens.total, Some(20)); -} - -/// Test: non-streaming tool call extraction. -#[tokio::test] -async fn bedrock_generate_tool_call() { - let server = MockServer::start().await; - mock_converse_json( - &server, - 200, - json!({ - "output": { - "message": { - "role": "assistant", - "content": [ - { "text": "Let me check the weather." }, - { - "toolUse": { - "toolUseId": "tool_use_123", - "name": "getWeather", - "input": { "location": "San Francisco" } - } - } - ] - } - }, - "stopReason": "tool_use", - "usage": { - "inputTokens": 15, - "outputTokens": 25 - } - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 2); - assert_eq!(as_text(&result.content[0]), "Let me check the weather."); - - let (id, name, input) = as_tool_call(&result.content[1]); - assert_eq!(id, "tool_use_123"); - assert_eq!(name, "getWeather"); - assert_eq!(input, &json!(r#"{"location":"San Francisco"}"#)); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); -} - -/// Test: HTTP error status is propagated as an error. -#[tokio::test] -async fn bedrock_generate_error_status() { - let server = MockServer::start().await; - mock_converse_json( - &server, - 400, - json!({ - "error": { - "message": "The model ID is invalid", - "type": "ValidationException" - } - }), - ) - .await; - - let model = make_model(&server); - let result = model.do_generate(&default_options(test_prompt())).await; - - assert!(result.is_err(), "should return error for 400 status"); - let err = result.unwrap_err().to_string(); - assert!( - err.contains("invalid") || err.contains("ValidationException") || err.contains("model ID"), - "error should contain the error message, got: {err}" - ); -} - -/// Test: request body contains the expected Converse format. -#[tokio::test] -async fn bedrock_generate_request_body() { - let server = MockServer::start().await; - mock_converse_json( - &server, - 200, - json!({ - "output": { - "message": { - "role": "assistant", - "content": [{ "text": "OK" }] - } - }, - "stopReason": "end_turn", - "usage": { "inputTokens": 1, "outputTokens": 1 } - }), - ) - .await; - - let mut opts = default_options(test_prompt()); - opts.max_output_tokens = Some(512); - opts.temperature = Some(0.7); - - let model = make_model(&server); - let result = model - .do_generate(&opts) - .await - .expect("do_generate should succeed"); - - let body = result.request_body.expect("should have request body"); - assert_eq!(body["messages"][0]["role"], "user"); - assert_eq!(body["messages"][0]["content"][0]["text"], "Hello"); - assert_eq!(body["inferenceConfig"]["maxTokens"], 512); - // temperature is f32, compare with tolerance - let temp = body["inferenceConfig"]["temperature"].as_f64().unwrap(); - assert!( - (temp - 0.7).abs() < 0.001, - "temperature should be ~0.7, got {temp}" - ); -} - -// ═════════════════════════════════════════════════════════════════════════════ -// doStream tests (AWS event stream binary format) -// ═════════════════════════════════════════════════════════════════════════════ - -/// Test: streaming text via the Bedrock event stream binary format. -#[tokio::test] -async fn bedrock_stream_text() { - let server = MockServer::start().await; - - // Build the binary event stream response. - let events: Vec<(&str, &str, &str)> = vec![ - ("event", "messageStart", r#"{"role":"assistant"}"#), - ( - "event", - "contentBlockStart", - r#"{"contentBlockIndex":0,"start":{}}"#, - ), - ( - "event", - "contentBlockDelta", - r#"{"contentBlockIndex":0,"delta":{"text":"Hello"}}"#, - ), - ( - "event", - "contentBlockDelta", - r#"{"contentBlockIndex":0,"delta":{"text":" world"}}"#, - ), - ("event", "contentBlockStop", r#"{"contentBlockIndex":0}"#), - ("event", "messageStop", r#"{"stopReason":"end_turn"}"#), - ( - "event", - "metadata", - r#"{"usage":{"inputTokens":5,"outputTokens":10}}"#, - ), - ]; - let body_bytes = aimux_providers::bedrock::event_stream::encode_messages(&events); - - let model_path = "/model/anthropic.claude-3-5-sonnet-20240620-v1:0/converse-stream"; - Mock::given(method("POST")) - .and(path(model_path)) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "application/vnd.amazon.eventstream") - .set_body_raw(body_bytes, "application/vnd.amazon.eventstream"), - ) - .mount(&server) - .await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - - // Verify stream parts: StreamStart, ResponseMetadata, TextStart, TextDelta×2, TextEnd, Finish - let text_deltas: Vec = parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect(); - assert_eq!(text_deltas, vec!["Hello", " world"]); - - // Check finish - let finish = parts.iter().find_map(|p| match p { - StreamPart::Finish { finish_reason, .. } => Some(finish_reason.clone()), - _ => None, - }); - assert!(finish.is_some()); - assert_eq!(finish.unwrap().unified, FinishReasonUnified::Stop); -} - -/// Test: streaming tool calls via the Bedrock event stream. -#[tokio::test] -async fn bedrock_stream_tool_call() { - let server = MockServer::start().await; - - let events: Vec<(&str, &str, &str)> = vec![ - ("event", "messageStart", r#"{"role":"assistant"}"#), - ( - "event", - "contentBlockStart", - r#"{"contentBlockIndex":0,"start":{"toolUse":{"name":"getWeather","toolUseId":"tool_1"}}}"#, - ), - ( - "event", - "contentBlockDelta", - r#"{"contentBlockIndex":0,"delta":{"toolUse":{"input":"{\"location\":"}}}"#, - ), - ( - "event", - "contentBlockDelta", - r#"{"contentBlockIndex":0,"delta":{"toolUse":{"input":"\"SF\"}"}}}"#, - ), - ("event", "contentBlockStop", r#"{"contentBlockIndex":0}"#), - ("event", "messageStop", r#"{"stopReason":"tool_use"}"#), - ]; - let body_bytes = aimux_providers::bedrock::event_stream::encode_messages(&events); - - let model_path = "/model/anthropic.claude-3-5-sonnet-20240620-v1:0/converse-stream"; - Mock::given(method("POST")) - .and(path(model_path)) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "application/vnd.amazon.eventstream") - .set_body_raw(body_bytes, "application/vnd.amazon.eventstream"), - ) - .mount(&server) - .await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - - // Should have a ToolCall part - let tool_calls: Vec<_> = parts - .iter() - .filter_map(|p| match p { - StreamPart::ToolCall { - tool_call_id, - tool_name, - input, - .. - } => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), - _ => None, - }) - .collect(); - assert_eq!(tool_calls.len(), 1); - assert_eq!(tool_calls[0].0, "tool_1"); - assert_eq!(tool_calls[0].1, "getWeather"); - assert_eq!( - tool_calls[0].2, - Value::String(r#"{"location":"SF"}"#.into()) - ); -} - -/// Test: SigV4 authentication adds Authorization header. -#[tokio::test] -async fn bedrock_sigv4_auth() { - let server = MockServer::start().await; - - // The mock path must match the model ID used in the test. - Mock::given(method("POST")) - .and(path("/model/test-model/converse")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "output": { - "message": { - "role": "assistant", - "content": [{ "text": "Signed!" }] - } - }, - "stopReason": "end_turn", - "usage": { "inputTokens": 1, "outputTokens": 1 } - }))) - .mount(&server) - .await; - - let model = BedrockModel::new( - "test-model".to_string(), - aimux_providers::bedrock::BedrockConfig { - base_url: server.uri(), - auth: aimux_providers::bedrock::BedrockAuth::SigV4( - aimux_providers::bedrock::AwsCredentials { - access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(), - secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(), - session_token: None, - region: "us-east-1".to_string(), - }, - ), - retry_config: aimux_provider_utils::RetryConfig::default(), - api_key_source: None, - }, - ); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed with SigV4"); - - assert_eq!(as_text(&result.content[0]), "Signed!"); -} - -/// Test: provider config from_env with bearer token. -#[tokio::test] -async fn bedrock_provider_config_bearer() { - unsafe { - std::env::set_var("AWS_BEARER_TOKEN_BEDROCK", "test-bearer-token"); - std::env::set_var("AWS_REGION", "us-west-2"); - } - - let config = BedrockProviderConfig::from_env().expect("should create config"); - match config.auth { - aimux_providers::bedrock::BedrockAuth::BearerToken(t) => { - assert_eq!(t, "test-bearer-token"); - } - _ => panic!("expected BearerToken auth"), - } - assert!(config.base_url.contains("us-west-2")); - - unsafe { - std::env::remove_var("AWS_BEARER_TOKEN_BEDROCK"); - std::env::remove_var("AWS_REGION"); - } -} - -// ═════════════════════════════════════════════════════════════════════════════ -// Additional doGenerate cases — usage, metadata, headers, finish reasons, -// tool choice, settings. -// ═════════════════════════════════════════════════════════════════════════════ - -/// A minimal "ok" Converse response body. -fn ok_converse_body() -> Value { - json!({ - "output": { - "message": { - "role": "assistant", - "content": [{ "text": "ok" }] - } - }, - "stopReason": "end_turn", - "usage": { "inputTokens": 4, "outputTokens": 7, "totalTokens": 11 } - }) -} - -/// TS: "should extract usage" -#[tokio::test] -async fn bedrock_generate_usage() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.usage.input_tokens.total, Some(4)); - assert_eq!(result.usage.output_tokens.total, Some(7)); -} - -/// TS: "should send additional response information" — the x-amzn-requestid -/// response header becomes `response.id`. -#[tokio::test] -async fn bedrock_generate_response_metadata() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path( - "/model/anthropic.claude-3-5-sonnet-20240620-v1:0/converse", - )) - .respond_with( - ResponseTemplate::new(200) - .insert_header("x-amzn-requestid", "req-abc-123") - .set_body_json(ok_converse_body()), - ) - .mount(&server) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.response.id.as_deref(), Some("req-abc-123")); - assert_eq!( - result.response.model_id.as_deref(), - Some("anthropic.claude-3-5-sonnet-20240620-v1:0") - ); -} - -/// TS: "should expose the raw response headers" -#[tokio::test] -async fn bedrock_generate_response_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path( - "/model/anthropic.claude-3-5-sonnet-20240620-v1:0/converse", - )) - .respond_with( - ResponseTemplate::new(200) - .insert_header("test-header", "test-value") - .set_body_json(ok_converse_body()), - ) - .mount(&server) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} - -/// TS: "should extract finish reason" — stop_sequence → Stop -#[tokio::test] -async fn bedrock_generate_finish_reason_stop_sequence() { - let server = MockServer::start().await; - mock_converse_json( - &server, - 200, - json!({ - "output": { "message": { "role": "assistant", "content": [{ "text": "hi" }] } }, - "stopReason": "stop_sequence", - "usage": { "inputTokens": 4, "outputTokens": 34 } - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(result.finish_reason.raw.as_deref(), Some("stop_sequence")); -} - -/// TS: max_tokens → Length -#[tokio::test] -async fn bedrock_generate_finish_reason_max_tokens() { - let server = MockServer::start().await; - mock_converse_json( - &server, - 200, - json!({ - "output": { "message": { "role": "assistant", "content": [{ "text": "truncated" }] } }, - "stopReason": "max_tokens", - "usage": { "inputTokens": 4, "outputTokens": 34 } - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Length); - assert_eq!(result.finish_reason.raw.as_deref(), Some("max_tokens")); -} - -/// TS: content_filtered → ContentFilter -#[tokio::test] -async fn bedrock_generate_finish_reason_content_filtered() { - let server = MockServer::start().await; - mock_converse_json( - &server, - 200, - json!({ - "output": { "message": { "role": "assistant", "content": [{ "text": "" }] } }, - "stopReason": "content_filtered", - "usage": { "inputTokens": 4, "outputTokens": 1 } - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!( - result.finish_reason.unified, - FinishReasonUnified::ContentFilter - ); -} - -/// TS: "should support unknown finish reason" — unknown → Other -#[tokio::test] -async fn bedrock_generate_finish_reason_unknown() { - let server = MockServer::start().await; - mock_converse_json( - &server, - 200, - json!({ - "output": { "message": { "role": "assistant", "content": [{ "text": "hi" }] } }, - "stopReason": "eos", - "usage": { "inputTokens": 4, "outputTokens": 34 } - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Other); - assert_eq!(result.finish_reason.raw.as_deref(), Some("eos")); -} - -/// TS: "should pass settings" — topP, topK, stopSequences land in inferenceConfig. -#[tokio::test] -async fn bedrock_generate_settings() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let mut opts = default_options(test_prompt()); - opts.top_p = Some(0.9); - opts.top_k = Some(40.0); - opts.stop_sequences = Some(vec!["END".to_string()]); - - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert!((body["inferenceConfig"]["topP"].as_f64().unwrap() - 0.9).abs() < 1e-6); - assert_eq!(body["inferenceConfig"]["topK"], json!(40.0)); - assert_eq!(body["inferenceConfig"]["stopSequences"], json!(["END"])); -} - -/// TS: "should pass tools and tool choice correctly" — required → {"any":{}} -#[tokio::test] -async fn bedrock_generate_tool_choice_required() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let opts = CallOptions { - tools: Some(vec![Tool::Function( - FunctionTool::new( - "get-weather".to_string(), - json!({"type":"object","properties":{"city":{"type":"string"}}}), - ) - .with_description("Get weather".to_string()), - )]), - tool_choice: ToolChoice::Required, - ..default_options(test_prompt()) - }; - - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["toolConfig"]["toolChoice"], json!({ "any": {} })); - assert_eq!( - body["toolConfig"]["tools"][0]["toolSpec"]["name"], - "get-weather" - ); -} - -/// TS: "should only send the forced tool when toolChoice specifies a specific tool" -#[tokio::test] -async fn bedrock_generate_tool_choice_specific_tool() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let opts = CallOptions { - tools: Some(vec![Tool::Function(FunctionTool::new( - "get-weather".to_string(), - json!({"type":"object"}), - ))]), - tool_choice: ToolChoice::Tool { - tool_name: "get-weather".to_string(), - }, - ..default_options(test_prompt()) - }; - - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!( - body["toolConfig"]["toolChoice"], - json!({ "tool": { "name": "get-weather" } }) - ); - // Only the forced tool is sent. - assert_eq!(body["toolConfig"]["tools"].as_array().unwrap().len(), 1); -} - -/// TS: a 429/throttling response maps to `AiMuxError::ApiCall` (429 in `status_code`). -#[tokio::test] -async fn bedrock_generate_throttling_error() { - let server = MockServer::start().await; - mock_converse_json( - &server, - 429, - json!({ "message": "throttlingException", "type": "TooManyRequestsException" }), - ) - .await; - - let model = make_model(&server); - let result = model.do_generate(&default_options(test_prompt())).await; - assert!( - matches!(result, Err(ref e) if e.status_code() == Some(429)), - "expected RateLimited, got {result:?}" - ); -} diff --git a/aimux-providers/tests/bedrock_reasoning_test.rs b/aimux-providers/tests/bedrock_reasoning_test.rs deleted file mode 100644 index f5716eb2e..000000000 --- a/aimux-providers/tests/bedrock_reasoning_test.rs +++ /dev/null @@ -1,1359 +0,0 @@ -//! Bedrock reasoning / thinking tests, translated from the TypeScript suite. -//! -//! Translation sources (under `reference/ai/packages/amazon-bedrock/src/`): -//! - `amazon-bedrock-chat-language-model.test.ts` -//! - `describe('reasoning')` under `doGenerate` — "should extract reasoning -//! and text response" (fixture `amazon-bedrock-reasoning.json`). -//! - `describe('reasoning')` under `doStream` — "should stream reasoning and -//! text parts" (fixture `amazon-bedrock-reasoning.chunks.txt`). -//! - "should stream reasoning text deltas" (inline chunks). -//! - "should extract reasoning text with signature" / "…without signature". -//! - "should preserve empty text blocks between reasoning blocks". -//! - "should extract redacted reasoning". -//! - "should handle multiple reasoning blocks". -//! - "should transform reasoningConfig to thinking in stream requests". -//! - "merges user additionalModelRequestFields with derived thinking (stream)". -//! - `convert-to-amazon-bedrock-chat-messages.test.ts` — the reasoning -//! `it(...)` cases (prompt-side replay of signed/unsigned reasoning). -//! -//! Tests that require a data model the Rust crate does not expose are marked -//! `#[ignore]` with an inline reason: -//! - **Redacted reasoning replay** — `ContentPart::Reasoning` has no -//! `redactedData` field and `convert_prompt_to_bedrock` has no -//! `redactedReasoning` branch. -//! - **Foreign-provider reasoning replay** — `ContentPart::Reasoning.signature` -//! is provider-agnostic; the converter cannot distinguish an `anthropic` -//! signature (which must be dropped) from a `bedrock` one (which is kept). - -use std::collections::HashMap; - -use futures::StreamExt; -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateContent, StreamResult}; -use aimux_core::stream_part::StreamPart; -use aimux_core::types::FinishReasonUnified; - -use aimux_providers::bedrock::convert::convert_prompt_to_bedrock; -use aimux_providers::bedrock::event_stream; -use aimux_providers::bedrock::{BedrockAuth, BedrockConfig, BedrockModel}; - -// ── Constants ──────────────────────────────────────────────────────────────── - -/// Model id used by the TS reasoning tests (matches the snapshot `modelId`). -const MODEL_ID: &str = "anthropic.claude-3-haiku-20240307-v1:0"; - -/// Signature on the `amazon-bedrock-reasoning.json` fixture's reasoning block. -const REASONING_SIGNATURE: &str = "EvYBCkgICxABGAIqQA9E9VC377UnbjdfXCw4RwQaaIXsqocZKzI3WwWtXT/wBjzAkBOfIiVkP/mhvsPGb5oQW/2j+E1tZcNFoXIb26YSDBTSbDrbHwXJoAl3/hoMA/Qo4rIHvObP/yBrIjAT+l69mh9k7evFO+w+ransSD5YGq4hYrieOyJH3k/zHv8nBJwiz3Nlrb3jNFi2Ib0qXJH7BIK0hR85yYIoccReoJUpeLEMxeWbsO6f0EpOSMSi3YDLd1U9NAw7Itjeay1fEBPHwvawt2M4e/rn52SsuEz3p2DfOZm+N3bnL63rg0Cb9lFcp3k5kuU1h6hcGAE="; - -/// Signature carried by the streaming reasoning fixture's final delta. -const STREAM_SIGNATURE: &str = "Ep0CCkgICxABGAIqQOWPB6/PmA5SW9jC6FvaNq3E+U9ev4FMWcFWuAho+VGLCtazKc5WDjQ5i0MuxsY0o5pKDSVWVKii8KJDusXH4eASDK7jyzuk8iij7fJNihoMxHO9haYzt48R36HVIjCb/EmIFrJLIXShqN6DN//T6vZBtO9qj1QhNWJa3CGm8VZoq80S2/Ok4U0aVaIDiZcqggHC2b8BHuv8BHZrmsR0wjU1ynansBGMdfjnG+iIv8R5lPpRmYGhSVwNybwP3aQZ6o8Dr48Rau8TJfdsArW+r7bvL7bPs4f5nnlp2vG7WkMzWwABHK3fdM44zZ1GZQaWyECNWR2GfY6dXiklo94vgpFTPuZ97mfiN3LY6uYyBwL8RkDaGAE="; - -// ── Helpers ────────────────────────────────────────────────────────────────── - -/// TS `TEST_PROMPT`: a system message + a user "Hello" message. -fn test_prompt() -> LanguageModelPrompt { - vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("System Prompt")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - ] -} - -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -fn make_model(server: &MockServer) -> BedrockModel { - BedrockModel::new( - MODEL_ID.to_string(), - BedrockConfig { - base_url: server.uri(), - auth: BedrockAuth::BearerToken("test-token".to_string()), - retry_config: aimux_provider_utils::RetryConfig::default(), - api_key_source: None, - }, - ) -} - -/// Wrap a `bedrock`-keyed provider-options value for `CallOptions.provider_options`. -fn bedrock_provider_options(value: Value) -> Option> { - let mut map = HashMap::new(); - map.insert("bedrock".to_string(), value); - Some(map) -} - -/// Mock the non-streaming `/converse` endpoint with a JSON body (HTTP 200). -async fn mock_converse(server: &MockServer, body: Value) { - let model_path = format!("/model/{MODEL_ID}/converse"); - Mock::given(method("POST")) - .and(path(model_path.as_str())) - .respond_with(ResponseTemplate::new(200).set_body_json(body)) - .mount(server) - .await; -} - -/// Encode a sequence of `(message-type, event-type, payload)` events into the -/// AWS event stream binary format used by `/converse-stream`. -fn encode_events(events: &[(&str, &str, Value)]) -> Vec { - let mut buf = Vec::new(); - for (mt, et, payload) in events { - buf.extend_from_slice(&event_stream::encode_message(mt, et, &payload.to_string())); - } - buf -} - -/// Mock the streaming `/converse-stream` endpoint with the given events. -async fn mock_stream(server: &MockServer, events: &[(&str, &str, Value)]) { - let body_bytes = encode_events(events); - let model_path = format!("/model/{MODEL_ID}/converse-stream"); - Mock::given(method("POST")) - .and(path(model_path.as_str())) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "application/vnd.amazon.eventstream") - .set_body_raw(body_bytes, "application/vnd.amazon.eventstream"), - ) - .mount(server) - .await; -} - -async fn collect_stream(result: StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -fn as_text(item: &GenerateContent) -> &str { - match item { - GenerateContent::Text { text, .. } => text, - _ => panic!("expected Text content, got {item:?}"), - } -} - -fn as_reasoning(item: &GenerateContent) -> (&str, &Option) { - match item { - GenerateContent::Reasoning { - text, - provider_metadata, - } => (text.as_str(), provider_metadata), - _ => panic!("expected Reasoning content, got {item:?}"), - } -} - -fn reasoning_deltas(parts: &[StreamPart]) -> Vec { - parts - .iter() - .filter_map(|p| match p { - StreamPart::ReasoningDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect() -} - -/// Reasoning deltas that carry actual text, i.e. excluding the zero-length -/// deltas that only transport a `signature` / `redactedData` in -/// `provider_metadata` (upstream emits those with `delta: ''`). -fn reasoning_text_deltas(parts: &[StreamPart]) -> Vec { - reasoning_deltas(parts) - .into_iter() - .filter(|d| !d.is_empty()) - .collect() -} - -/// The `signature` values carried on reasoning-block ends, in emission order. -/// The signature arrives on a trailing text-less delta and is attached to the -/// concluding `ReasoningEnd`. -fn reasoning_signatures(parts: &[StreamPart]) -> Vec { - parts - .iter() - .filter_map(|p| match p { - StreamPart::ReasoningEnd { - provider_metadata: Some(pm), - .. - } => pm - .get("amazonBedrock") - .and_then(|b| b.get("signature")) - .and_then(|s| s.as_str()) - .map(std::string::ToString::to_string), - _ => None, - }) - .collect() -} - -fn text_deltas(parts: &[StreamPart]) -> Vec { - parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect() -} - -fn has_reasoning_start(parts: &[StreamPart]) -> bool { - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningStart { .. })) -} - -fn has_reasoning_end(parts: &[StreamPart]) -> bool { - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningEnd { .. })) -} - -// ── convert helpers (prompt-side) ──────────────────────────────────────────── - -fn msg(role: Role, content: Vec) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role, - content, - provider_options: None, - } -} - -fn user(content: Vec) -> LanguageModelPromptMessage { - msg(Role::User, content) -} - -fn assistant(content: Vec) -> LanguageModelPromptMessage { - msg(Role::Assistant, content) -} - -fn tool_msg(content: Vec) -> LanguageModelPromptMessage { - msg(Role::Tool, content) -} - -fn reasoning(text: &str, signature: Option<&str>) -> ContentPart { - ContentPart::Reasoning { - text: text.to_string(), - signature: signature.map(std::string::ToString::to_string), - provider_options: None, - } -} - -fn tool_call(id: &str, name: &str, input: Value) -> ContentPart { - ContentPart::tool_call(id.to_string(), name.to_string(), input) -} - -fn tool_result(id: &str, output: Value) -> ContentPart { - ContentPart::tool_result(id.to_string(), output) -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate — reasoning response extraction (TDD red until implemented) -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: `doGenerate > reasoning > should extract reasoning and text response`. -/// -/// Uses the `amazon-bedrock-reasoning.json` fixture: an assistant message with -/// a `reasoningContent.reasoningText` block (text + signature) followed by a -/// `text` block. The model should emit `Reasoning` then `Text`. -#[tokio::test] -async fn bedrock_generate_reasoning_response() { - let server = MockServer::start().await; - mock_converse( - &server, - json!({ - "metrics": { "latencyMs": 2202 }, - "output": { - "message": { - "role": "assistant", - "content": [ - { - "reasoningContent": { - "reasoningText": { - "signature": REASONING_SIGNATURE, - "text": "Let me count the r's in \"strawberry\":\n\ns-t-r-a-w-b-e-r-r-y\n\nThere are 3 r's." - } - } - }, - { - "text": "There are **3** r's in \"strawberry\":\n\n1. st**r**awbe**r****r**y" - } - ] - } - }, - "stopReason": "end_turn", - "usage": { - "cacheReadInputTokenCount": 0, - "cacheReadInputTokens": 0, - "cacheWriteInputTokenCount": 0, - "cacheWriteInputTokens": 0, - "inputTokens": 51, - "outputTokens": 78, - "serverToolUsage": {}, - "totalTokens": 129 - } - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - // content[0] — reasoning with signature under both `amazonBedrock` and - // `bedrock` provider-metadata keys. - assert_eq!(result.content.len(), 2, "expected reasoning + text content"); - let (rtext, rmeta) = as_reasoning(&result.content[0]); - assert_eq!( - rtext, - "Let me count the r's in \"strawberry\":\n\ns-t-r-a-w-b-e-r-r-y\n\nThere are 3 r's." - ); - let rmeta = rmeta - .as_ref() - .expect("reasoning provider_metadata should be set"); - assert_eq!(rmeta["amazonBedrock"]["signature"], REASONING_SIGNATURE); - assert_eq!(rmeta["bedrock"]["signature"], REASONING_SIGNATURE); - - // content[1] — text. - assert_eq!( - as_text(&result.content[1]), - "There are **3** r's in \"strawberry\":\n\n1. st**r**awbe**r****r**y" - ); - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(result.finish_reason.raw.as_deref(), Some("end_turn")); - assert_eq!(result.usage.input_tokens.total, Some(51)); - assert_eq!(result.usage.output_tokens.total, Some(78)); -} - -/// TS: "should extract reasoning text with signature". -#[tokio::test] -async fn bedrock_generate_reasoning_text_with_signature() { - let server = MockServer::start().await; - mock_converse( - &server, - json!({ - "output": { - "message": { - "role": "assistant", - "content": [ - { - "reasoningContent": { - "reasoningText": { - "text": "I need to think about this problem carefully...", - "signature": "abc123signature" - } - } - }, - { "type": "text", "text": "The answer is 42." } - ] - } - }, - "usage": { "inputTokens": 4, "outputTokens": 34, "totalTokens": 38 }, - "stopReason": "stop_sequence" - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 2); - let (rtext, rmeta) = as_reasoning(&result.content[0]); - assert_eq!(rtext, "I need to think about this problem carefully..."); - let rmeta = rmeta.as_ref().expect("provider_metadata should be set"); - assert_eq!(rmeta["amazonBedrock"]["signature"], "abc123signature"); - assert_eq!(rmeta["bedrock"]["signature"], "abc123signature"); - - assert_eq!(as_text(&result.content[1]), "The answer is 42."); -} - -/// TS: "should preserve empty text blocks between reasoning blocks". -/// -/// The empty `{ text: "" }` block sitting between two reasoning blocks must be -/// preserved as a `Text` content item (not dropped). -#[tokio::test] -async fn bedrock_generate_reasoning_preserve_empty_text_between_reasoning() { - let server = MockServer::start().await; - mock_converse( - &server, - json!({ - "output": { - "message": { - "role": "assistant", - "content": [ - { "reasoningContent": { "reasoningText": { "text": "thinking...", "signature": "sig-1" } } }, - { "text": "" }, - { "reasoningContent": { "reasoningText": { "text": "more thinking...", "signature": "sig-2" } } }, - { "text": "The answer is 42." } - ] - } - }, - "usage": { "inputTokens": 4, "outputTokens": 34, "totalTokens": 38 }, - "stopReason": "stop_sequence" - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 4); - let (t0, m0) = as_reasoning(&result.content[0]); - assert_eq!(t0, "thinking..."); - assert_eq!(m0.as_ref().unwrap()["bedrock"]["signature"], "sig-1"); - - assert_eq!(as_text(&result.content[1]), ""); - - let (t2, m2) = as_reasoning(&result.content[2]); - assert_eq!(t2, "more thinking..."); - assert_eq!(m2.as_ref().unwrap()["bedrock"]["signature"], "sig-2"); - - assert_eq!(as_text(&result.content[3]), "The answer is 42."); -} - -/// TS: "should extract reasoning text without signature". -/// -/// A `reasoningText` block with no `signature` yields a `Reasoning` content item -/// with no provider metadata. -#[tokio::test] -async fn bedrock_generate_reasoning_text_without_signature() { - let server = MockServer::start().await; - mock_converse( - &server, - json!({ - "output": { - "message": { - "role": "assistant", - "content": [ - { "reasoningContent": { "reasoningText": { "text": "I need to think about this problem carefully..." } } }, - { "type": "text", "text": "The answer is 42." } - ] - } - }, - "usage": { "inputTokens": 4, "outputTokens": 34, "totalTokens": 38 }, - "stopReason": "stop_sequence" - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 2); - let (rtext, rmeta) = as_reasoning(&result.content[0]); - assert_eq!(rtext, "I need to think about this problem carefully..."); - assert!( - rmeta.is_none(), - "reasoning without signature should have no provider_metadata" - ); - assert_eq!(as_text(&result.content[1]), "The answer is 42."); -} - -/// TS: "should extract redacted reasoning". -/// -/// A `redactedReasoning` block yields a `Reasoning` content item with empty text -/// and `redactedData` under both `amazonBedrock` and `bedrock` provider metadata. -#[tokio::test] -async fn bedrock_generate_reasoning_redacted() { - let server = MockServer::start().await; - mock_converse( - &server, - json!({ - "output": { - "message": { - "role": "assistant", - "content": [ - { "reasoningContent": { "redactedReasoning": { "data": "redacted-reasoning-data" } } }, - { "type": "text", "text": "The answer is 42." } - ] - } - }, - "usage": { "inputTokens": 4, "outputTokens": 34, "totalTokens": 38 }, - "stopReason": "stop_sequence" - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 2); - let (rtext, rmeta) = as_reasoning(&result.content[0]); - assert_eq!(rtext, ""); - let rmeta = rmeta.as_ref().expect("provider_metadata should be set"); - assert_eq!( - rmeta["amazonBedrock"]["redactedData"], - "redacted-reasoning-data" - ); - assert_eq!(rmeta["bedrock"]["redactedData"], "redacted-reasoning-data"); - assert_eq!(as_text(&result.content[1]), "The answer is 42."); -} - -/// TS: "should handle multiple reasoning blocks". -/// -/// A signed `reasoningText` block followed by a `redactedReasoning` block then -/// text — three content items in order. -#[tokio::test] -async fn bedrock_generate_reasoning_multiple_blocks() { - let server = MockServer::start().await; - mock_converse( - &server, - json!({ - "output": { - "message": { - "role": "assistant", - "content": [ - { "reasoningContent": { "reasoningText": { "text": "First reasoning block", "signature": "sig1" } } }, - { "reasoningContent": { "redactedReasoning": { "data": "redacted-data" } } }, - { "type": "text", "text": "The answer is 42." } - ] - } - }, - "usage": { "inputTokens": 4, "outputTokens": 34, "totalTokens": 38 }, - "stopReason": "stop_sequence" - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 3); - - let (t0, m0) = as_reasoning(&result.content[0]); - assert_eq!(t0, "First reasoning block"); - assert_eq!(m0.as_ref().unwrap()["bedrock"]["signature"], "sig1"); - - let (t1, m1) = as_reasoning(&result.content[1]); - assert_eq!(t1, ""); - assert_eq!( - m1.as_ref().unwrap()["bedrock"]["redactedData"], - "redacted-data" - ); - - assert_eq!(as_text(&result.content[2]), "The answer is 42."); -} - -// ════════════════════════════════════════════════════════════════════════════ -// doStream — reasoning streaming (TDD red until implemented) -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: `doStream > reasoning > should stream reasoning and text parts`. -/// -/// Uses the `amazon-bedrock-reasoning.chunks.txt` fixture: a reasoning block -/// (index 0) streamed as `reasoningContent.text` deltas + a final -/// `reasoningContent.signature` delta, then a text block (index 1). -#[tokio::test] -async fn bedrock_stream_reasoning_and_text() { - let server = MockServer::start().await; - - let events: Vec<(&str, &str, Value)> = vec![ - ("event", "messageStart", json!({ "role": "assistant" })), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 0, "delta": { "reasoningContent": { "text": "Let me count the r" } } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 0, "delta": { "reasoningContent": { "text": "'s in \"" } } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 0, "delta": { "reasoningContent": { "text": "strawberry\":" } } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 0, "delta": { "reasoningContent": { "text": "\n\ns-t-r-a" } } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 0, "delta": { "reasoningContent": { "text": "-w-b-e-r" } } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 0, "delta": { "reasoningContent": { "text": "-r-y\n\nr" } } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 0, "delta": { "reasoningContent": { "text": " appears at positions " } } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 0, "delta": { "reasoningContent": { "text": "3, 8" } } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 0, "delta": { "reasoningContent": { "text": ", and 9.\n\nSo there" } } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 0, "delta": { "reasoningContent": { "text": " are 3 r's." } } }), - ), - // Empty-text delta — TS skips emitting a reasoning-delta for this. - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 0, "delta": { "reasoningContent": { "text": "" } } }), - ), - // Signature-only delta — emitted as a reasoning-delta with delta="" - // carrying providerMetadata.signature, matching TS. - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 0, "delta": { "reasoningContent": { "signature": STREAM_SIGNATURE } } }), - ), - ( - "event", - "contentBlockStop", - json!({ "contentBlockIndex": 0 }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 1, "delta": { "text": "There" } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 1, "delta": { "text": " are **3** r's in \"" } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 1, "delta": { "text": "strawberry\":" } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 1, "delta": { "text": "\n\n1" } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 1, "delta": { "text": ". st" } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 1, "delta": { "text": "**" } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 1, "delta": { "text": "r**awbe" } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 1, "delta": { "text": "**r****" } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 1, "delta": { "text": "r**y" } }), - ), - ( - "event", - "contentBlockStop", - json!({ "contentBlockIndex": 1 }), - ), - ( - "event", - "messageStop", - json!({ "additionalModelResponseFields": { "delta": { "stop_sequence": null } }, "stopReason": "end_turn" }), - ), - ( - "event", - "metadata", - json!({ "metrics": { "latencyMs": 2281 }, "usage": { "inputTokens": 51, "outputTokens": 94, "serverToolUsage": {}, "totalTokens": 145 } }), - ), - ]; - mock_stream(&server, &events).await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - // Reasoning block (id "0"): start, non-empty text deltas, end. - assert!(has_reasoning_start(&parts), "expected a ReasoningStart"); - assert_eq!( - reasoning_text_deltas(&parts), - vec![ - "Let me count the r", - "'s in \"", - "strawberry\":", - "\n\ns-t-r-a", - "-w-b-e-r", - "-r-y\n\nr", - " appears at positions ", - "3, 8", - ", and 9.\n\nSo there", - " are 3 r's.", - ] - ); - assert!(has_reasoning_end(&parts), "expected a ReasoningEnd"); - // The signature on the final (text-less) reasoning delta is attached to - // the concluding ReasoningEnd via provider_metadata, so extended-thinking - // multi-turn can echo it back (#113). - let reasoning_end_meta = parts.iter().find_map(|p| match p { - StreamPart::ReasoningEnd { - provider_metadata, .. - } => provider_metadata.clone(), - _ => None, - }); - let reasoning_end_meta = reasoning_end_meta.expect("ReasoningEnd with provider_metadata"); - assert_eq!( - reasoning_end_meta["bedrock"]["signature"].as_str(), - Some(STREAM_SIGNATURE) - ); - // Dual-key shape matches the non-streaming path. - assert_eq!( - reasoning_end_meta["amazonBedrock"]["signature"].as_str(), - Some(STREAM_SIGNATURE) - ); - - // Text block (id "1"). - assert_eq!( - text_deltas(&parts), - vec![ - "There", - " are **3** r's in \"", - "strawberry\":", - "\n\n1", - ". st", - "**", - "r**awbe", - "**r****", - "r**y", - ] - ); - - // Finish. - let finish = parts.iter().find_map(|p| match p { - StreamPart::Finish { finish_reason, .. } => Some(finish_reason.clone()), - _ => None, - }); - let finish = finish.expect("expected a Finish part"); - assert_eq!(finish.unified, FinishReasonUnified::Stop); - assert_eq!(finish.raw.as_deref(), Some("end_turn")); -} - -/// TS: "should stream reasoning text deltas". -/// -/// Inline chunks: two reasoning text deltas, a signature-only delta, then a -/// text delta and `messageStop`. -#[tokio::test] -async fn bedrock_stream_reasoning_text_deltas() { - let server = MockServer::start().await; - let events: Vec<(&str, &str, Value)> = vec![ - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 0, "delta": { "reasoningContent": { "text": "I am thinking" } } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 0, "delta": { "reasoningContent": { "text": " about this problem..." } } }), - ), - // Signature-only delta — emitted as a reasoning-delta with delta="" - // carrying providerMetadata.signature, matching TS. - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 0, "delta": { "reasoningContent": { "signature": "abc123signature" } } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 1, "delta": { "text": "Based on my reasoning, the answer is 42." } }), - ), - ( - "event", - "messageStop", - json!({ "stopReason": "stop_sequence" }), - ), - ]; - mock_stream(&server, &events).await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - assert!(has_reasoning_start(&parts), "expected a ReasoningStart"); - assert_eq!( - reasoning_text_deltas(&parts), - vec!["I am thinking", " about this problem..."] - ); - assert_eq!( - reasoning_signatures(&parts), - vec!["abc123signature"], - "the thinking-block signature must reach the caller" - ); - assert!(has_reasoning_end(&parts), "expected a ReasoningEnd"); - - assert_eq!( - text_deltas(&parts), - vec!["Based on my reasoning, the answer is 42."] - ); - - let finish = parts.iter().find_map(|p| match p { - StreamPart::Finish { finish_reason, .. } => Some(finish_reason.clone()), - _ => None, - }); - let finish = finish.expect("expected a Finish part"); - assert_eq!(finish.unified, FinishReasonUnified::Stop); - assert_eq!(finish.raw.as_deref(), Some("stop_sequence")); -} - -// ════════════════════════════════════════════════════════════════════════════ -// doStream — request-side reasoningConfig → thinking (TDD red until implemented) -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should transform reasoningConfig to thinking in stream requests". -/// -/// `providerOptions.bedrock.reasoningConfig = { type: 'enabled', budgetTokens: -/// 2000 }` plus `maxOutputTokens: 100` must land in the request body as -/// `additionalModelRequestFields.thinking = { type: 'enabled', budget_tokens: -/// 2000 }` and `inferenceConfig.maxTokens = 2100` (100 + 2000), with no -/// top-level `reasoningConfig`. -#[tokio::test] -async fn bedrock_stream_transform_reasoning_config_to_thinking() { - let server = MockServer::start().await; - mock_stream( - &server, - &[( - "event", - "messageStop", - json!({ "stopReason": "stop_sequence" }), - )], - ) - .await; - - let opts = CallOptions { - max_output_tokens: Some(100), - provider_options: bedrock_provider_options(json!({ - "reasoningConfig": { "type": "enabled", "budgetTokens": 2000 } - })), - ..default_options(test_prompt()) - }; - - let model = make_model(&server); - let result = model - .do_stream(&opts) - .await - .expect("do_stream should succeed"); - let body = result.request_body.expect("request body should be present"); - - // Thinking config derived from reasoningConfig. - assert_eq!( - body["additionalModelRequestFields"]["thinking"], - json!({ "type": "enabled", "budget_tokens": 2000 }) - ); - // maxTokens is bumped by budgetTokens (100 + 2000). - assert_eq!(body["inferenceConfig"]["maxTokens"], 2100); - // reasoningConfig must not leak to the top level. - assert!( - body.get("reasoningConfig").is_none(), - "reasoningConfig should not appear at the top level" - ); -} - -/// TS: "merges user additionalModelRequestFields with derived thinking (stream)". -/// -/// User-supplied `additionalModelRequestFields` (`foo`, `custom`) must be -/// merged with the derived `thinking` field, and `reasoningConfig` must not -/// appear at the top level. -#[tokio::test] -async fn bedrock_stream_merge_additional_model_request_fields_with_thinking() { - let server = MockServer::start().await; - mock_stream( - &server, - &[( - "event", - "messageStop", - json!({ "stopReason": "stop_sequence" }), - )], - ) - .await; - - let opts = CallOptions { - provider_options: bedrock_provider_options(json!({ - "reasoningConfig": { "type": "enabled", "budgetTokens": 500 }, - "additionalModelRequestFields": { "foo": "bar", "custom": 42 } - })), - ..default_options(test_prompt()) - }; - - let model = make_model(&server); - let result = model - .do_stream(&opts) - .await - .expect("do_stream should succeed"); - let body = result.request_body.expect("request body should be present"); - - assert!( - body.get("reasoningConfig").is_none(), - "reasoningConfig should not appear at the top level" - ); - assert_eq!(body["additionalModelRequestFields"]["foo"], "bar"); - assert_eq!(body["additionalModelRequestFields"]["custom"], 42); - assert_eq!( - body["additionalModelRequestFields"]["thinking"], - json!({ "type": "enabled", "budget_tokens": 500 }) - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// convert_prompt_to_bedrock — prompt-side reasoning replay -// (green for signed/unsigned reasoning; #[ignore] where the data model cannot -// express redacted / foreign-provider reasoning) -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should properly convert reasoning content type". -/// -/// An assistant reasoning part carrying a `bedrock` signature is replayed as a -/// `reasoningContent.reasoningText { text, signature }` block. -#[test] -fn convert_reasoning_content_type() { - let prompt = vec![ - user(vec![ContentPart::text("Explain your reasoning")]), - assistant(vec![reasoning( - "This is my step-by-step reasoning process", - Some("test-signature"), - )]), - ]; - let (system, messages) = convert_prompt_to_bedrock(&prompt); - - assert_eq!(Value::Array(system), json!([])); - assert_eq!( - Value::Array(messages), - json!([ - { "role": "user", "content": [{ "text": "Explain your reasoning" }] }, - { - "role": "assistant", - "content": [ - { - "reasoningContent": { - "reasoningText": { - "text": "This is my step-by-step reasoning process", - "signature": "test-signature" - } - } - } - ] - } - ]) - ); -} - -/// TS: "should properly convert redacted-reasoning content type". -/// -/// SKIPPED: `ContentPart::Reasoning` has no `redactedData` field and -/// `convert_prompt_to_bedrock` has no `redactedReasoning` branch, so redacted -/// reasoning cannot be replayed on the Bedrock side. The test body documents -/// the intended behaviour (a `reasoningContent.redactedReasoning { data }` -/// block) for when the data model gains support. -#[test] -#[ignore = "redacted reasoning is not modelled on ContentPart::Reasoning / convert"] -fn convert_reasoning_redacted_content_type() { - let prompt = vec![ - user(vec![ContentPart::text("Explain your reasoning")]), - assistant(vec![ContentPart::Reasoning { - text: String::new(), - signature: None, - provider_options: Some( - json!({ "bedrock": { "redactedData": "Redacted reasoning information" } }), - ), - }]), - ]; - let (system, messages) = convert_prompt_to_bedrock(&prompt); - - assert_eq!(Value::Array(system), json!([])); - assert_eq!( - Value::Array(messages), - json!([ - { "role": "user", "content": [{ "text": "Explain your reasoning" }] }, - { - "role": "assistant", - "content": [ - { - "reasoningContent": { - "redactedReasoning": { "data": "Redacted reasoning information" } - } - } - ] - } - ]) - ); -} - -/// TS: "should omit assistant message reasoning parts signed by a foreign -/// provider". -/// -/// SKIPPED: `ContentPart::Reasoning.signature` is provider-agnostic, so the -/// converter cannot distinguish a foreign-provider signature (which must be -/// dropped) from a Bedrock one (which is kept). The intended behaviour is that -/// the reasoning part is omitted and only the trailing text remains. -#[test] -#[ignore = "ContentPart::Reasoning cannot express provider distinction (foreign vs bedrock signature)"] -fn convert_reasoning_foreign_provider_omitted() { - let prompt = vec![ - user(vec![ContentPart::text("Explain your reasoning")]), - assistant(vec![ - // In TS this carries `providerOptions.anthropic.signature` and is - // dropped because it is not a Bedrock signature. - reasoning( - "Anthropic-signed reasoning replayed to Bedrock", - Some("anthropic-signature"), - ), - ContentPart::text("final answer"), - ]), - ]; - let (system, messages) = convert_prompt_to_bedrock(&prompt); - - assert_eq!(Value::Array(system), json!([])); - assert_eq!( - Value::Array(messages), - json!([ - { "role": "user", "content": [{ "text": "Explain your reasoning" }] }, - { "role": "assistant", "content": [{ "text": "final answer" }] } - ]) - ); -} - -/// TS: "should preserve assistant message reasoning parts with amazonBedrock -/// providerOptions". -/// -/// A reasoning part with a Bedrock signature is preserved. (The Rust data model -/// does not distinguish `amazonBedrock` from `bedrock` provider options — the -/// signature is a direct field — but the replay behaviour matches.) -#[test] -fn convert_reasoning_amazon_bedrock_provider_options_preserved() { - let prompt = vec![ - user(vec![ContentPart::text("Explain your reasoning")]), - assistant(vec![ - reasoning( - "Bedrock-signed reasoning round-tripped to Bedrock", - Some("bedrock-signature"), - ), - ContentPart::text("final answer"), - ]), - ]; - let (system, messages) = convert_prompt_to_bedrock(&prompt); - - assert_eq!(Value::Array(system), json!([])); - assert_eq!( - Value::Array(messages), - json!([ - { "role": "user", "content": [{ "text": "Explain your reasoning" }] }, - { - "role": "assistant", - "content": [ - { - "reasoningContent": { - "reasoningText": { - "text": "Bedrock-signed reasoning round-tripped to Bedrock", - "signature": "bedrock-signature" - } - } - }, - { "text": "final answer" } - ] - } - ]) - ); -} - -/// TS: "should not trim reasoning text when a signature is present". -/// -/// Trailing whitespace on reasoning text must be preserved verbatim (only the -/// last *text* part of the last block is trimmed, never reasoning). -#[test] -fn convert_reasoning_no_trim_with_signature() { - let prompt = vec![ - user(vec![ContentPart::text("Explain your reasoning")]), - assistant(vec![reasoning( - "This is my reasoning with trailing space ", - Some("test-signature"), - )]), - ]; - let (system, messages) = convert_prompt_to_bedrock(&prompt); - - assert_eq!(Value::Array(system), json!([])); - assert_eq!( - Value::Array(messages), - json!([ - { "role": "user", "content": [{ "text": "Explain your reasoning" }] }, - { - "role": "assistant", - "content": [ - { - "reasoningContent": { - "reasoningText": { - "text": "This is my reasoning with trailing space ", - "signature": "test-signature" - } - } - } - ] - } - ]) - ); -} - -/// TS: "should omit reasoning content without signature". -/// -/// Unsigned reasoning is dropped; the trailing text is kept. -#[test] -fn convert_reasoning_omit_without_signature() { - let prompt = vec![ - user(vec![ContentPart::text("Explain your reasoning")]), - assistant(vec![ - reasoning("This is my reasoning with trailing space ", None), - ContentPart::text("final answer"), - ]), - ]; - let (system, messages) = convert_prompt_to_bedrock(&prompt); - - assert_eq!(Value::Array(system), json!([])); - assert_eq!( - Value::Array(messages), - json!([ - { "role": "user", "content": [{ "text": "Explain your reasoning" }] }, - { "role": "assistant", "content": [{ "text": "final answer" }] } - ]) - ); -} - -/// TS: "should omit multiple reasoning parts without signatures". -#[test] -fn convert_reasoning_omit_multiple_without_signatures() { - let prompt = vec![ - user(vec![ContentPart::text("Explain your reasoning")]), - assistant(vec![ - reasoning("First reasoning with trailing space ", None), - reasoning("Second reasoning with trailing space ", None), - ContentPart::text("final answer"), - ]), - ]; - let (system, messages) = convert_prompt_to_bedrock(&prompt); - - assert_eq!(Value::Array(system), json!([])); - assert_eq!( - Value::Array(messages), - json!([ - { "role": "user", "content": [{ "text": "Explain your reasoning" }] }, - { "role": "assistant", "content": [{ "text": "final answer" }] } - ]) - ); -} - -/// TS: "should omit unsigned reasoning while preserving tool calls in -/// multi-turn tool use". -#[test] -fn convert_reasoning_omit_unsigned_preserve_tool_calls() { - let prompt = vec![ - user(vec![ContentPart::text("What is the weather?")]), - assistant(vec![ - reasoning("I should call the weather tool.", None), - tool_call("call-1", "getWeather", json!({ "city": "SF" })), - ]), - tool_msg(vec![tool_result( - "call-1", - json!({ "type": "text", "value": "Sunny, 72F" }), - )]), - ]; - let (system, messages) = convert_prompt_to_bedrock(&prompt); - - assert_eq!(Value::Array(system), json!([])); - assert_eq!( - Value::Array(messages), - json!([ - { "role": "user", "content": [{ "text": "What is the weather?" }] }, - { - "role": "assistant", - "content": [ - { - "toolUse": { - "input": { "city": "SF" }, - "name": "getWeather", - "toolUseId": "call-1" - } - } - ] - }, - { - "role": "user", - "content": [ - { - "toolResult": { - "content": [{ "text": "Sunny, 72F" }], - "toolUseId": "call-1" - } - } - ] - } - ]) - ); -} - -/// TS: "should preserve reasoning text with signature in multi-turn tool use". -#[test] -fn convert_reasoning_preserve_signed_in_multi_turn_tool_use() { - let prompt = vec![ - user(vec![ContentPart::text("What is the weather?")]), - assistant(vec![ - reasoning("Let me check the weather API.\n", Some("sig-abc123")), - tool_call("call-1", "getWeather", json!({ "city": "SF" })), - ]), - tool_msg(vec![tool_result( - "call-1", - json!({ "type": "text", "value": "Sunny, 72F" }), - )]), - assistant(vec![ - reasoning("The weather is sunny and warm.\n", Some("sig-def456")), - ContentPart::text("It is sunny and 72F in SF."), - ]), - ]; - let (system, messages) = convert_prompt_to_bedrock(&prompt); - - assert_eq!(Value::Array(system), json!([])); - assert_eq!( - Value::Array(messages), - json!([ - { "role": "user", "content": [{ "text": "What is the weather?" }] }, - { - "role": "assistant", - "content": [ - { - "reasoningContent": { - "reasoningText": { - "text": "Let me check the weather API.\n", - "signature": "sig-abc123" - } - } - }, - { - "toolUse": { - "input": { "city": "SF" }, - "name": "getWeather", - "toolUseId": "call-1" - } - } - ] - }, - { - "role": "user", - "content": [ - { - "toolResult": { - "content": [{ "text": "Sunny, 72F" }], - "toolUseId": "call-1" - } - } - ] - }, - { - "role": "assistant", - "content": [ - { - "reasoningContent": { - "reasoningText": { - "text": "The weather is sunny and warm.\n", - "signature": "sig-def456" - } - } - }, - { "text": "It is sunny and 72F in SF." } - ] - } - ]) - ); -} - -/// TS: "should handle a mix of text and reasoning content types". -#[test] -fn convert_reasoning_mix_text_and_reasoning() { - let prompt = vec![ - user(vec![ContentPart::text("Explain your reasoning")]), - assistant(vec![ - ContentPart::text("My answer is 42."), - reasoning( - "I calculated this by analyzing the meaning of life", - Some("reasoning-process"), - ), - ]), - ]; - let (system, messages) = convert_prompt_to_bedrock(&prompt); - - assert_eq!(Value::Array(system), json!([])); - assert_eq!( - Value::Array(messages), - json!([ - { "role": "user", "content": [{ "text": "Explain your reasoning" }] }, - { - "role": "assistant", - "content": [ - { "text": "My answer is 42." }, - { - "reasoningContent": { - "reasoningText": { - "text": "I calculated this by analyzing the meaning of life", - "signature": "reasoning-process" - } - } - } - ] - } - ]) - ); -} diff --git a/aimux-providers/tests/bedrock_remaining_test.rs b/aimux-providers/tests/bedrock_remaining_test.rs deleted file mode 100644 index c17fbb90c..000000000 --- a/aimux-providers/tests/bedrock_remaining_test.rs +++ /dev/null @@ -1,1361 +0,0 @@ -//! Remaining (not-yet-covered) Amazon Bedrock TS test cases, translated to Rust. -//! -//! Translation sources (under `reference/ai/packages/amazon-bedrock/src/`): -//! - `normalize-tool-call-id.test.ts` — `isMistralModel` / `normalizeToolCallId` -//! (helpers not ported to Rust → `#[ignore]`). -//! - `convert-amazon-bedrock-usage.test.ts` — the two `raw`-echo cases (the -//! Rust `Usage` type has no `raw` field → `#[ignore]`). -//! - `amazon-bedrock-provider.test.ts` — `from_env` auth variants + provider -//! plumbing (green). -//! - `convert-to-amazon-bedrock-chat-messages.test.ts` — Mistral tool-call-id -//! normalization cases (the Rust converter has no `isMistral` parameter → -//! `#[ignore]`). -//! - `amazon-bedrock-chat-language-model.test.ts` — ARN URL encoding, -//! `additionalModelResponseFieldPaths`, temperature clamping, guardrails, -//! `trace` / `stop_sequence` extraction into `providerMetadata`, -//! `supportedUrls`, tool calls with empty input, tool-content filtering. -//! -//! Tests that fail because the Rust implementation does not yet support the -//! feature are marked `#[ignore = "TODO: implementation gap"]`. The test body -//! documents the expected behaviour so it can be flipped on once the gap lands. - -use std::collections::HashMap; - -use futures::StreamExt; -use serde_json::{Value, json}; -use serial_test::serial; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::{CallOptions, Tool, ToolChoice}; -use aimux_core::provider::Provider; -use aimux_core::result::GenerateContent; -use aimux_core::stream_part::StreamPart; -use aimux_core::tool::FunctionTool; -use aimux_core::types::FinishReasonUnified; - -use aimux_providers::bedrock::{ - BedrockAuth, BedrockConfig, BedrockModel, BedrockProvider, BedrockProviderConfig, -}; - -// ── Shared helpers ─────────────────────────────────────────────────────────── - -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -fn make_model(server: &MockServer) -> BedrockModel { - BedrockModel::new( - "anthropic.claude-3-5-sonnet-20240620-v1:0".to_string(), - BedrockConfig { - base_url: server.uri(), - auth: BedrockAuth::BearerToken("test-token".to_string()), - retry_config: aimux_provider_utils::RetryConfig::default(), - api_key_source: None, - }, - ) -} - -async fn mock_converse_json(server: &MockServer, status: u16, body: Value) { - let model_path = "/model/anthropic.claude-3-5-sonnet-20240620-v1:0/converse"; - Mock::given(method("POST")) - .and(path(model_path)) - .respond_with(ResponseTemplate::new(status).set_body_json(body)) - .mount(server) - .await; -} - -fn ok_converse_body() -> Value { - json!({ - "output": { - "message": { - "role": "assistant", - "content": [{ "text": "ok" }] - } - }, - "stopReason": "end_turn", - "usage": { "inputTokens": 4, "outputTokens": 7, "totalTokens": 11 } - }) -} - -fn as_text(item: &GenerateContent) -> &str { - match item { - GenerateContent::Text { text, .. } => text, - _ => panic!("expected Text content, got {item:?}"), - } -} - -fn as_tool_call(item: &GenerateContent) -> (&str, &str, &str) { - match item { - GenerateContent::ToolCall { - tool_call_id, - tool_name, - input, - .. - } => (tool_call_id, tool_name, input), - _ => panic!("expected ToolCall content, got {item:?}"), - } -} - -async fn collect_stream(result: aimux_core::result::StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -/// Remove every env var that `BedrockProviderConfig::from_env` consults, so each -/// `#[serial]` test starts from a clean slate. -fn clear_bedrock_env() { - unsafe { - std::env::remove_var("AWS_BEARER_TOKEN_BEDROCK"); - std::env::remove_var("AWS_ACCESS_KEY_ID"); - std::env::remove_var("AWS_SECRET_ACCESS_KEY"); - std::env::remove_var("AWS_REGION"); - std::env::remove_var("AWS_SESSION_TOKEN"); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// normalize-tool-call-id.test.ts -// ════════════════════════════════════════════════════════════════════════════ -// -// The TS SDK exposes `isMistralModel(modelId)` and -// `normalizeToolCallId(toolCallId, isMistral)`. Neither helper is ported to the -// Rust bedrock module — `convert_prompt_to_bedrock` has no `isMistral` -// parameter and never normalizes tool-call IDs. The tests below document the -// expected behaviour; they are `#[ignore]` until the helpers land. - -/// TS: "should return true for mistral models" -#[test] -#[ignore = "TODO: implementation gap — is_mistral_model not implemented in bedrock module"] -fn is_mistral_model_true_for_mistral() { - // Expected: is_mistral_model returns true for: - // "mistral.mistral-7b-instruct-v0:2" - // "mistral.mixtral-8x7b-instruct-v0:1" - // "mistral.mistral-large-2402-v1:0" - // "mistral.mistral-small-2402-v1:0" - // "mistral.mistral-large-2407-v1:0" - // "mistral.ministral-3-14b-instruct" - // "mistral.ministral-3-8b-instruct" - todo!("is_mistral_model is not implemented"); -} - -/// TS: "should return true for region-prefixed mistral models" -#[test] -#[ignore = "TODO: implementation gap — is_mistral_model not implemented in bedrock module"] -fn is_mistral_model_region_prefixed() { - // Expected: is_mistral_model("us.mistral.pixtral-large-2502-v1:0") == true - // Expected: is_mistral_model("eu.mistral.mistral-large-2407-v1:0") == true - todo!("is_mistral_model is not implemented"); -} - -/// TS: "should return false for non-mistral models" -#[test] -#[ignore = "TODO: implementation gap — is_mistral_model not implemented in bedrock module"] -fn is_mistral_model_false_for_non_mistral() { - // Expected: is_mistral_model returns false for: - // "anthropic.claude-3-5-sonnet-20241022-v2:0" - // "amazon.nova-pro-v1:0" - // "openai.gpt-4o" - // "meta.llama3-70b-instruct-v1:0" - todo!("is_mistral_model is not implemented"); -} - -/// TS: "should return the original ID when not a Mistral model" -#[test] -#[ignore = "TODO: implementation gap — normalize_tool_call_id not implemented in bedrock module"] -fn normalize_tool_call_id_passthrough_when_not_mistral() { - // Expected: normalize_tool_call_id("tooluse_bpe71yCfRu2b5i-nKGDr5g", false) - // == "tooluse_bpe71yCfRu2b5i-nKGDr5g" - todo!("normalize_tool_call_id is not implemented"); -} - -/// TS: "should extract first 9 alphanumeric characters for Mistral models" -#[test] -#[ignore = "TODO: implementation gap — normalize_tool_call_id not implemented in bedrock module"] -fn normalize_tool_call_id_first_9_alphanumeric() { - // Expected: normalize_tool_call_id("tooluse_bpe71yCfRu2b5i-nKGDr5g", true) - // == "toolusebp" - todo!("normalize_tool_call_id is not implemented"); -} - -/// TS: "should handle IDs with various special characters" -#[test] -#[ignore = "TODO: implementation gap — normalize_tool_call_id not implemented in bedrock module"] -fn normalize_tool_call_id_special_chars() { - // Expected: normalize_tool_call_id("tool-use_123ABC456", true) == "tooluse12" - // Expected: normalize_tool_call_id("___abc123DEF___", true) == "abc123DEF" - todo!("normalize_tool_call_id is not implemented"); -} - -/// TS: "should handle IDs that are already alphanumeric" -#[test] -#[ignore = "TODO: implementation gap — normalize_tool_call_id not implemented in bedrock module"] -fn normalize_tool_call_id_already_alphanumeric() { - // Expected: normalize_tool_call_id("abcdefghi", true) == "abcdefghi" - // Expected: normalize_tool_call_id("abc123XYZ", true) == "abc123XYZ" - todo!("normalize_tool_call_id is not implemented"); -} - -/// TS: "should handle short IDs" -#[test] -#[ignore = "TODO: implementation gap — normalize_tool_call_id not implemented in bedrock module"] -fn normalize_tool_call_id_short_ids() { - // Expected: normalize_tool_call_id("abc", true) == "abc" - // Expected: normalize_tool_call_id("12345", true) == "12345" - todo!("normalize_tool_call_id is not implemented"); -} - -/// TS: "should handle IDs with only special characters" -#[test] -#[ignore = "TODO: implementation gap — normalize_tool_call_id not implemented in bedrock module"] -fn normalize_tool_call_id_only_special_chars() { - // Expected: normalize_tool_call_id("___---___", true) == "" - todo!("normalize_tool_call_id is not implemented"); -} - -/// TS: "should produce valid Mistral tool call IDs (9 alphanumeric chars)" -#[test] -#[ignore = "TODO: implementation gap — normalize_tool_call_id not implemented in bedrock module"] -fn normalize_tool_call_id_valid_mistral_format() { - // Expected: normalize_tool_call_id("tooluse_bpe71yCfRu2b5i-nKGDr5g", true) - // matches ^[a-zA-Z0-9]{1,9}$ - todo!("normalize_tool_call_id is not implemented"); -} - -// ════════════════════════════════════════════════════════════════════════════ -// convert-amazon-bedrock-usage.test.ts — `raw` echo cases -// ════════════════════════════════════════════════════════════════════════════ -// -// The non-raw cases are already covered in bedrock_convert_test.rs. The two -// `raw`-only cases below require a `raw` echo field on `Usage`, which the Rust -// type does not model. - -use aimux_providers::bedrock::convert::{BedrockUsage, convert_usage}; - -fn bedrock_usage_with_total( - input: u32, - output: u32, - total: Option, - cache_read: Option, - cache_write: Option, -) -> BedrockUsage { - BedrockUsage { - input_tokens: Some(input), - output_tokens: Some(output), - total_tokens: total, - cache_read_input_tokens: cache_read, - cache_write_input_tokens: cache_write, - } -} - -/// TS: "should include totalTokens in raw when provided" -#[test] -#[ignore = "TODO: implementation gap — Usage type has no raw echo field"] -fn usage_raw_includes_total_tokens() { - let u = convert_usage(Some(&bedrock_usage_with_total( - 100, - 50, - Some(150), - None, - None, - ))); - // TS asserts result.raw == { inputTokens: 100, outputTokens: 50, totalTokens: 150 } - // The Rust Usage has no raw field; only the token breakdown is available. - assert_eq!(u.input_tokens.total, Some(100)); - assert_eq!(u.output_tokens.total, Some(50)); - todo!("Usage.raw echo is not modelled"); -} - -/// TS: "should preserve raw usage data" -#[test] -#[ignore = "TODO: implementation gap — Usage type has no raw echo field"] -fn usage_raw_preserved() { - let raw = bedrock_usage_with_total(100, 50, Some(150), Some(80), Some(60)); - let u = convert_usage(Some(&raw)); - // TS asserts result.raw == the full input object. - assert_eq!(u.input_tokens.total, Some(240)); - todo!("Usage.raw echo is not modelled"); -} - -// ════════════════════════════════════════════════════════════════════════════ -// amazon-bedrock-provider.test.ts — provider configuration / auth -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should create a provider instance with default options" — default -/// region is `us-east-1` and the base URL points at bedrock-runtime. -#[test] -#[serial] -fn provider_default_region_and_base_url() { - clear_bedrock_env(); - let config = BedrockProviderConfig::new("ak", "sk", "us-east-1"); - assert_eq!( - config.base_url, - "https://bedrock-runtime.us-east-1.amazonaws.com" - ); - assert!(matches!(config.auth, BedrockAuth::SigV4(_))); -} - -/// TS: "should create a provider instance with custom options" — custom region -/// flows into the base URL. -#[test] -fn provider_custom_region_base_url() { - let config = BedrockProviderConfig::new("ak", "sk", "eu-west-1"); - assert_eq!( - config.base_url, - "https://bedrock-runtime.eu-west-1.amazonaws.com" - ); -} - -/// TS: "should accept a credentialProvider in options" — the Rust port models -/// only static SigV4 creds + bearer; the `with_base_url` override mirrors the -/// custom baseURL option. -#[test] -fn provider_with_base_url_override() { - let config = BedrockProviderConfig::with_bearer_token("tok", "us-east-1") - .with_base_url("https://custom.url/"); - assert_eq!(config.base_url, "https://custom.url"); -} - -/// TS: "should use API key when provided in options" — bearer-token auth path. -#[test] -fn provider_bearer_token_auth() { - let config = BedrockProviderConfig::with_bearer_token("test-api-key", "us-east-1"); - match config.auth { - BedrockAuth::BearerToken(t) => assert_eq!(t, "test-api-key"), - _ => panic!("expected BearerToken auth"), - } -} - -/// TS: "should use API key from environment variable" — `AWS_BEARER_TOKEN_BEDROCK` -/// takes precedence over SigV4 env vars in `from_env`. -#[test] -#[serial] -fn provider_from_env_bearer_token_precedence() { - clear_bedrock_env(); - unsafe { - std::env::set_var("AWS_BEARER_TOKEN_BEDROCK", "env-bearer"); - std::env::set_var("AWS_ACCESS_KEY_ID", "should-not-be-used"); - std::env::set_var("AWS_SECRET_ACCESS_KEY", "should-not-be-used"); - std::env::set_var("AWS_REGION", "us-west-2"); - } - - let config = BedrockProviderConfig::from_env().expect("from_env should succeed"); - match config.auth { - BedrockAuth::BearerToken(t) => assert_eq!(t, "env-bearer"), - _ => panic!("expected BearerToken auth (bearer takes precedence)"), - } - assert!(config.base_url.contains("us-west-2")); - - clear_bedrock_env(); -} - -/// TS: "should fall back to SigV4 when no API key provided" — `from_env` uses -/// SigV4 when `AWS_BEARER_TOKEN_BEDROCK` is absent. -#[test] -#[serial] -fn provider_from_env_sigv4_fallback() { - clear_bedrock_env(); - unsafe { - std::env::set_var("AWS_ACCESS_KEY_ID", "test-ak"); - std::env::set_var("AWS_SECRET_ACCESS_KEY", "test-sk"); - std::env::set_var("AWS_REGION", "eu-west-1"); - } - - let config = BedrockProviderConfig::from_env().expect("from_env should succeed"); - match config.auth { - BedrockAuth::SigV4(creds) => { - assert_eq!(creds.access_key_id, "test-ak"); - assert_eq!(creds.secret_access_key, "test-sk"); - assert_eq!(creds.region, "eu-west-1"); - assert!(creds.session_token.is_none()); - } - _ => panic!("expected SigV4 auth"), - } - - clear_bedrock_env(); -} - -/// TS: "should maintain backward compatibility with existing SigV4 -/// authentication" — `AWS_SESSION_TOKEN` from env is picked up by `from_env`. -#[test] -#[serial] -fn provider_from_env_session_token() { - clear_bedrock_env(); - unsafe { - std::env::set_var("AWS_ACCESS_KEY_ID", "ak"); - std::env::set_var("AWS_SECRET_ACCESS_KEY", "sk"); - std::env::set_var("AWS_REGION", "us-east-1"); - std::env::set_var("AWS_SESSION_TOKEN", "sts-token"); - } - - let config = BedrockProviderConfig::from_env().expect("from_env should succeed"); - match config.auth { - BedrockAuth::SigV4(creds) => { - assert_eq!(creds.session_token.as_deref(), Some("sts-token")); - } - _ => panic!("expected SigV4 auth"), - } - - clear_bedrock_env(); -} - -/// TS: default region falls back to `us-east-1` when `AWS_REGION` is unset. -#[test] -#[serial] -fn provider_from_env_default_region() { - clear_bedrock_env(); - unsafe { - std::env::set_var("AWS_ACCESS_KEY_ID", "ak"); - std::env::set_var("AWS_SECRET_ACCESS_KEY", "sk"); - } - - let config = BedrockProviderConfig::from_env().expect("from_env should succeed"); - assert!(config.base_url.contains("us-east-1")); - - clear_bedrock_env(); -} - -/// TS: `from_env` errors when neither bearer token nor access key is present. -#[test] -#[serial] -fn provider_from_env_missing_credentials_errors() { - clear_bedrock_env(); - let result = BedrockProviderConfig::from_env(); - assert!( - result.is_err(), - "from_env should error without any credentials" - ); - clear_bedrock_env(); -} - -/// The `BedrockProvider` exposes its name and can vend language models. -#[test] -fn provider_name_and_language_model() { - let provider = - BedrockProvider::new(BedrockProviderConfig::with_bearer_token("tok", "us-east-1")); - assert_eq!(provider.name(), "amazon-bedrock"); - - let model = provider.model("anthropic.claude-3-5-sonnet-20240620-v1:0"); - assert_eq!( - model.model_id(), - "anthropic.claude-3-5-sonnet-20240620-v1:0" - ); - assert_eq!(model.provider(), "amazon-bedrock"); -} - -/// TS: "should prioritize options.apiKey over environment variable" — explicit -/// bearer token construction does not consult env vars. -#[test] -#[serial] -fn provider_explicit_bearer_over_env() { - clear_bedrock_env(); - unsafe { - std::env::set_var("AWS_BEARER_TOKEN_BEDROCK", "env-bearer"); - } - // Explicit construction with a different token should win over env. - let config = BedrockProviderConfig::with_bearer_token("explicit-tok", "us-east-1"); - match config.auth { - BedrockAuth::BearerToken(t) => assert_eq!(t, "explicit-tok"), - _ => panic!("expected BearerToken auth"), - } - clear_bedrock_env(); -} - -// ════════════════════════════════════════════════════════════════════════════ -// convert-to-amazon-bedrock-chat-messages.test.ts — Mistral normalization -// ════════════════════════════════════════════════════════════════════════════ -// -// The non-Mistral cases are covered in bedrock_convert_test.rs. The two -// `isMistral: true` cases below require the Rust converter to accept an -// `is_mistral` flag (or auto-detect via model id) and normalize tool-call IDs -// to the first 9 alphanumeric characters. Neither is implemented. - -use aimux_providers::bedrock::convert::convert_prompt_to_bedrock; -fn tool_msg(content: Vec) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role: Role::Tool, - content, - provider_options: None, - } -} - -fn assistant_msg(content: Vec) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role: Role::Assistant, - content, - provider_options: None, - } -} - -/// TS: "should normalize tool call IDs in tool results when isMistral is true" -#[test] -#[ignore = "TODO: implementation gap — convert_prompt_to_bedrock has no isMistral parameter"] -fn convert_mistral_normalize_tool_result_id() { - let original_id = "tooluse_bpe71yCfRu2b5i-nKGDr5g"; - let prompt = vec![tool_msg(vec![ContentPart::tool_result( - original_id.to_string(), - json!({ "type": "text", "value": "The result is 42" }), - )])]; - let (_, messages) = convert_prompt_to_bedrock(&prompt); - // TS (isMistral: true) expects toolUseId == "toolusebp" (first 9 alphanum). - // The Rust converter has no isMistral flag, so the ID is passed through - // unchanged — this assertion fails until the gap is closed. - assert_eq!( - messages[0]["content"][0]["toolResult"]["toolUseId"], - json!("toolusebp") - ); -} - -/// TS: "should normalize tool call IDs in tool calls when isMistral is true" -#[test] -#[ignore = "TODO: implementation gap — convert_prompt_to_bedrock has no isMistral parameter"] -fn convert_mistral_normalize_tool_call_id() { - let original_id = "tooluse_bpe71yCfRu2b5i-nKGDr5g"; - let prompt = vec![assistant_msg(vec![ContentPart::tool_call( - original_id.to_string(), - "getWeather".to_string(), - json!({ "city": "SF" }), - )])]; - let (_, messages) = convert_prompt_to_bedrock(&prompt); - // TS (isMistral: true) expects toolUseId == "toolusebp". - assert_eq!( - messages[0]["content"][0]["toolUse"]["toolUseId"], - json!("toolusebp") - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// amazon-bedrock-chat-language-model.test.ts — request URL / body / response -// ════════════════════════════════════════════════════════════════════════════ - -// ── supportedUrls ──────────────────────────────────────────────────────────── - -/// TS: "should support S3 URLs for image parts" — the TS model exposes a -/// `supportedUrls` map (`{ 'image/*': [/^s3:\/\//] }`). The Rust `BedrockModel` -/// has no `supported_urls` concept, so this test is `#[ignore]`. -#[test] -#[ignore = "TODO: implementation gap — BedrockModel has no supported_urls field"] -fn model_supported_urls_s3() { - // TS: model.supportedUrls == { 'image/*': [/^s3:\/\//] } - todo!("supportedUrls is not modelled on BedrockModel"); -} - -// ── ARN model IDs containing a slash ───────────────────────────────────────── - -/// TS: "should generate text through the encoded Converse route". -/// -/// ARN inference-profile IDs contain a `/` which must be percent-encoded in the -/// URL path. The TS SDK uses `encodeURIComponent(modelId)`. The Rust -/// `BedrockModel::endpoint` does **not** encode the model id, so the slash is -/// sent raw and the request misses the mocked (encoded) route. -#[tokio::test] -#[ignore = "TODO: implementation gap — endpoint() does not percent-encode the model id"] -async fn arn_model_id_encoded_generate_route() { - let server = MockServer::start().await; - let arn = "arn:aws:bedrock:eu-west-1:474668406012:inference-profile/eu.amazon.nova-lite-v1:0"; - let encoded = - percent_encoding::utf8_percent_encode(arn, percent_encoding::NON_ALPHANUMERIC).to_string(); - let encoded_path = format!("/model/{encoded}/converse"); - - Mock::given(method("POST")) - .and(path(&encoded_path)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "output": { "message": { "role": "assistant", "content": [{ "text": "Hello!" }] } }, - "stopReason": "end_turn", - "usage": { "inputTokens": 1, "outputTokens": 1 } - }))) - .mount(&server) - .await; - - let model = BedrockModel::new( - arn.to_string(), - BedrockConfig { - base_url: server.uri(), - auth: BedrockAuth::BearerToken("tok".to_string()), - retry_config: aimux_provider_utils::RetryConfig::default(), - api_key_source: None, - }, - ); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed via the encoded route"); - assert_eq!(as_text(&result.content[0]), "Hello!"); -} - -/// TS: "should stream text through the encoded Converse route". -#[tokio::test] -#[ignore = "TODO: implementation gap — endpoint() does not percent-encode the model id"] -async fn arn_model_id_encoded_stream_route() { - let server = MockServer::start().await; - let arn = "arn:aws:bedrock:eu-west-1:474668406012:inference-profile/eu.amazon.nova-lite-v1:0"; - let encoded = - percent_encoding::utf8_percent_encode(arn, percent_encoding::NON_ALPHANUMERIC).to_string(); - let encoded_path = format!("/model/{encoded}/converse-stream"); - - let events: Vec<(&str, &str, &str)> = vec![ - ("event", "messageStart", r#"{"role":"assistant"}"#), - ( - "event", - "contentBlockDelta", - r#"{"contentBlockIndex":0,"delta":{"text":"Hello!"}}"#, - ), - ("event", "contentBlockStop", r#"{"contentBlockIndex":0}"#), - ("event", "messageStop", r#"{"stopReason":"end_turn"}"#), - ( - "event", - "metadata", - r#"{"usage":{"inputTokens":1,"outputTokens":1}}"#, - ), - ]; - let body_bytes = aimux_providers::bedrock::event_stream::encode_messages(&events); - - Mock::given(method("POST")) - .and(path(&encoded_path)) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "application/vnd.amazon.eventstream") - .set_body_raw(body_bytes, "application/vnd.amazon.eventstream"), - ) - .mount(&server) - .await; - - let model = BedrockModel::new( - arn.to_string(), - BedrockConfig { - base_url: server.uri(), - auth: BedrockAuth::BearerToken("tok".to_string()), - retry_config: aimux_provider_utils::RetryConfig::default(), - api_key_source: None, - }, - ); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("should succeed via the encoded stream route"); - let parts = collect_stream(result).await; - - let text: String = parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect(); - assert_eq!(text, "Hello!"); -} - -// ── additionalModelResponseFieldPaths ──────────────────────────────────────── - -/// TS: "should return the request body" — the default request body includes -/// `additionalModelResponseFieldPaths: ["/delta/stop_sequence"]` so that the -/// stop_sequence value is returned in `additionalModelResponseFields`. The Rust -/// `build_request_body` does not emit this field. -#[tokio::test] -#[ignore = "TODO: implementation gap — build_request_body does not emit additionalModelResponseFieldPaths"] -async fn request_body_includes_additional_model_response_field_paths() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let body = result.request_body.expect("request body"); - assert_eq!( - body["additionalModelResponseFieldPaths"], - json!(["/delta/stop_sequence"]) - ); -} - -// ── temperature clamping ───────────────────────────────────────────────────── - -/// TS: "should clamp temperature above 1 to 1 and add warning". -#[tokio::test] -#[ignore = "TODO: implementation gap — temperature is not clamped to [0, 1]"] -async fn temperature_clamped_above_1() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let mut opts = default_options(test_prompt()); - opts.temperature = Some(1.5); - - let result = model.do_generate(&opts).await.expect("should succeed"); - - let body = result.request_body.expect("request body"); - assert_eq!( - body["inferenceConfig"]["temperature"].as_f64().unwrap(), - 1.0 - ); - assert!( - result.warnings.iter().any( - |w| matches!(w, aimux_core::types::Warning::Unsupported { feature, .. } - if feature == "temperature") - ), - "expected an unsupported-temperature warning, got {:?}", - result.warnings - ); -} - -/// TS: "should clamp temperature below 0 to 0 and add warning". -#[tokio::test] -#[ignore = "TODO: implementation gap — temperature is not clamped to [0, 1]"] -async fn temperature_clamped_below_0() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let mut opts = default_options(test_prompt()); - opts.temperature = Some(-0.5); - - let result = model.do_generate(&opts).await.expect("should succeed"); - - let body = result.request_body.expect("request body"); - assert_eq!( - body["inferenceConfig"]["temperature"].as_f64().unwrap(), - 0.0 - ); - assert!( - result.warnings.iter().any( - |w| matches!(w, aimux_core::types::Warning::Unsupported { feature, .. } - if feature == "temperature") - ), - "expected an unsupported-temperature warning" - ); -} - -/// TS: "should not clamp valid temperature between 0 and 1". -#[tokio::test] -async fn temperature_not_clamped_in_range() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let mut opts = default_options(test_prompt()); - opts.temperature = Some(0.7); - - let result = model.do_generate(&opts).await.expect("should succeed"); - - let body = result.request_body.expect("request body"); - let temp = body["inferenceConfig"]["temperature"].as_f64().unwrap(); - assert!( - (temp - 0.7).abs() < 1e-6, - "temperature should be 0.7, got {temp}" - ); - assert!(result.warnings.is_empty(), "no warnings expected"); -} - -// ── guardrails ─────────────────────────────────────────────────────────────── - -/// TS: "should support guardrails" — `providerOptions.bedrock.guardrailConfig` -/// is forwarded as a top-level `guardrailConfig` in the request body. -#[tokio::test] -#[ignore = "TODO: implementation gap — build_request_body does not emit guardrailConfig"] -async fn guardrail_config_in_request_body() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let mut opts = default_options(test_prompt()); - let mut po = HashMap::new(); - po.insert( - "bedrock".to_string(), - json!({ - "guardrailConfig": { - "guardrailIdentifier": "-1", - "guardrailVersion": "1", - "trace": "enabled" - } - }), - ); - opts.provider_options = Some(po); - - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.expect("request body"); - assert_eq!( - body["guardrailConfig"], - json!({ - "guardrailIdentifier": "-1", - "guardrailVersion": "1", - "trace": "enabled" - }) - ); -} - -// ── trace in providerMetadata ──────────────────────────────────────────────── - -/// TS: "should include trace information in providerMetadata" — the response -/// `trace` field is surfaced as `providerMetadata.bedrock.trace`. -#[tokio::test] -#[ignore = "TODO: implementation gap — do_generate does not extract trace into providerMetadata"] -async fn trace_in_provider_metadata() { - let server = MockServer::start().await; - let trace = json!({ - "guardrail": { - "inputAssessment": { - "1abcd2ef34gh": { - "contentPolicy": { - "filters": [{ - "action": "BLOCKED", - "confidence": "LOW", - "type": "INSULTS" - }] - } - } - } - } - }); - Mock::given(method("POST")) - .and(path("/model/anthropic.claude-3-5-sonnet-20240620-v1:0/converse")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "output": { "message": { "role": "assistant", "content": [{ "text": "Hello, World!" }] } }, - "usage": { "inputTokens": 4, "outputTokens": 34, "totalTokens": 38 }, - "stopReason": "stop_sequence", - "trace": trace - }))) - .mount(&server) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let pm = result - .provider_metadata - .as_ref() - .expect("provider_metadata should be Some"); - assert_eq!(pm["bedrock"]["trace"], trace); -} - -// ── stop_sequence in providerMetadata ──────────────────────────────────────── - -/// TS: "should include stop_sequence in provider metadata" — when the response -/// carries `additionalModelResponseFields.delta.stop_sequence`, it is surfaced -/// as `providerMetadata.bedrock.stopSequence` (and `amazonBedrock.stopSequence`). -#[tokio::test] -#[ignore = "TODO: implementation gap — do_generate does not extract stop_sequence from additionalModelResponseFields"] -async fn stop_sequence_in_provider_metadata() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/model/anthropic.claude-3-5-sonnet-20240620-v1:0/converse")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "output": { "message": { "role": "assistant", "content": [{ "text": "Hello, World!" }] } }, - "stopReason": "stop_sequence", - "additionalModelResponseFields": { "delta": { "stop_sequence": "STOP" } }, - "usage": { "inputTokens": 4, "outputTokens": 30, "totalTokens": 34 } - }))) - .mount(&server) - .await; - - let model = make_model(&server); - let mut opts = default_options(test_prompt()); - opts.stop_sequences = Some(vec!["STOP".to_string()]); - - let result = model.do_generate(&opts).await.expect("should succeed"); - - let pm = result - .provider_metadata - .as_ref() - .expect("provider_metadata should be Some"); - assert_eq!(pm["bedrock"]["stopSequence"], json!("STOP")); - assert_eq!(pm["amazonBedrock"]["stopSequence"], json!("STOP")); -} - -// ── tool calls with empty input ────────────────────────────────────────────── - -/// TS: "should support tool calls with empty input (no arguments)" (stream). -/// -/// When a `toolUse` block carries no input deltas, the stream event loop -/// accumulates an empty string and defaults the parsed input to `{}` — matching -/// the TS behaviour. (The non-streaming path does **not** handle this; see -/// `generate_tool_call_empty_input` below.) -#[tokio::test] -async fn stream_tool_call_empty_input() { - let server = MockServer::start().await; - - let events: Vec<(&str, &str, &str)> = vec![ - ("event", "messageStart", r#"{"role":"assistant"}"#), - ( - "event", - "contentBlockStart", - r#"{"contentBlockIndex":0,"start":{"toolUse":{"name":"updateIssueList","toolUseId":"tool_1"}}}"#, - ), - // No input deltas — the tool call has no arguments. - ("event", "contentBlockStop", r#"{"contentBlockIndex":0}"#), - ("event", "messageStop", r#"{"stopReason":"tool_use"}"#), - ]; - let body_bytes = aimux_providers::bedrock::event_stream::encode_messages(&events); - - Mock::given(method("POST")) - .and(path( - "/model/anthropic.claude-3-5-sonnet-20240620-v1:0/converse-stream", - )) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "application/vnd.amazon.eventstream") - .set_body_raw(body_bytes, "application/vnd.amazon.eventstream"), - ) - .mount(&server) - .await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - let tool_calls: Vec<_> = parts - .iter() - .filter_map(|p| match p { - StreamPart::ToolCall { - tool_call_id, - tool_name, - input, - .. - } => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), - _ => None, - }) - .collect(); - assert_eq!(tool_calls.len(), 1); - assert_eq!(tool_calls[0].0, "tool_1"); - assert_eq!(tool_calls[0].1, "updateIssueList"); - assert_eq!(tool_calls[0].2, Value::String("{}".into())); -} - -// ── omit toolConfig ────────────────────────────────────────────────────────── - -/// TS: "should omit toolConfig when conversation has tool calls but toolChoice -/// is none" — with `toolChoice: none`, no `toolConfig` is sent even when tools -/// are provided. -#[tokio::test] -async fn omit_tool_config_when_tool_choice_none() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let opts = CallOptions { - tools: Some(vec![Tool::Function(FunctionTool::new( - "weather".to_string(), - json!({ "type": "object", "properties": { "city": { "type": "string" } } }), - ))]), - tool_choice: ToolChoice::None, - ..default_options(test_prompt()) - }; - - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.expect("request body"); - assert!( - body.get("toolConfig").is_none(), - "toolConfig should be absent when toolChoice is none, got {}", - body["toolConfig"] - ); -} - -/// TS: "should omit toolConfig and filter tool content when conversation has -/// tool calls but no active tools". -/// -/// The `toolConfig` omission (tools = `[]`) works in Rust. The *content -/// filtering* — dropping the assistant's `toolUse` block from the messages when -/// there are no active tools — is **not** implemented, so only the toolConfig -/// assertion is live; the filtering assertion is `#[ignore]` below. -#[tokio::test] -async fn omit_tool_config_when_no_active_tools() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("What is the weather in Toronto?")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "tool-call-1".to_string(), - "weather".to_string(), - json!({ "city": "Toronto" }), - )], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "tool-call-1".to_string(), - json!({ "type": "text", "value": "The weather in Toronto is 20°C." }), - )], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Now give me a summary.")], - ..Default::default() - }, - ]; - - let model = make_model(&server); - let opts = CallOptions { - tools: Some(vec![]), - ..default_options(prompt) - }; - - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.expect("request body"); - assert!( - body.get("toolConfig").is_none(), - "toolConfig should be absent when tools list is empty" - ); -} - -/// TS: (same test) the assistant's `toolUse` block should be filtered out of -/// the messages when there are no active tools. The Rust converter always -/// emits `toolUse` blocks regardless of active tools. -#[tokio::test] -#[ignore = "TODO: implementation gap — convert_prompt_to_bedrock does not filter toolUse blocks when no active tools"] -async fn filter_tool_content_when_no_active_tools() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("What is the weather in Toronto?")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "tool-call-1".to_string(), - "weather".to_string(), - json!({ "city": "Toronto" }), - )], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "tool-call-1".to_string(), - json!({ "type": "text", "value": "The weather in Toronto is 20°C." }), - )], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Now give me a summary.")], - ..Default::default() - }, - ]; - - let model = make_model(&server); - let opts = CallOptions { - tools: Some(vec![]), - ..default_options(prompt) - }; - - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.expect("request body"); - // The assistant message should NOT contain a toolUse block. - let assistant_msg = &body["messages"][1]; - assert_eq!(assistant_msg["role"], "assistant"); - let has_tool_use = assistant_msg["content"] - .as_array() - .map(|arr| arr.iter().any(|b| b.get("toolUse").is_some())) - .unwrap_or(false); - assert!( - !has_tool_use, - "toolUse should be filtered out when no active tools" - ); -} - -// ── doGenerate: tool call with empty input (non-streaming) ─────────────────── - -/// TS variant: a non-streaming `toolUse` with no `input` field should yield -/// `input: {}`. The Rust `BedrockToolUse.input` serde-defaults to `Null`. -#[tokio::test] -#[ignore = "TODO: implementation gap — empty toolUse input deserializes as Null, not {}"] -async fn generate_tool_call_empty_input() { - let server = MockServer::start().await; - mock_converse_json( - &server, - 200, - json!({ - "output": { - "message": { - "role": "assistant", - "content": [ - { - "toolUse": { - "toolUseId": "tool_1", - "name": "updateIssueList" - // no "input" field - } - } - ] - } - }, - "stopReason": "tool_use", - "usage": { "inputTokens": 5, "outputTokens": 5 } - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.content.len(), 1); - let (id, name, input) = as_tool_call(&result.content[0]); - assert_eq!(id, "tool_1"); - assert_eq!(name, "updateIssueList"); - // TS expects input == {} (empty object), not null. - assert_eq!(input, &json!("{}")); -} - -// ── doGenerate: basic text + finish reason (sanity, already covered but -// exercised here against the remaining-test helper set) ──────────────────── - -/// TS: "should pass the model and the messages" — the request body carries the -/// model id implicitly via the URL path and the messages array. -#[tokio::test] -async fn request_body_messages_shape() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("System Prompt")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - ]; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(prompt)) - .await - .expect("ok"); - let body = result.request_body.expect("request body"); - assert_eq!(body["system"], json!([{ "text": "System Prompt" }])); - assert_eq!(body["messages"][0]["role"], "user"); - assert_eq!(body["messages"][0]["content"][0]["text"], "Hello"); -} - -/// TS: "should extract finish reason" — `guardrail_intervened` → ContentFilter. -#[tokio::test] -async fn finish_reason_guardrail_intervened() { - let server = MockServer::start().await; - mock_converse_json( - &server, - 200, - json!({ - "output": { "message": { "role": "assistant", "content": [{ "text": "" }] } }, - "stopReason": "guardrail_intervened", - "usage": { "inputTokens": 4, "outputTokens": 1 } - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!( - result.finish_reason.unified, - FinishReasonUnified::ContentFilter - ); - assert_eq!( - result.finish_reason.raw.as_deref(), - Some("guardrail_intervened") - ); -} - -/// TS: "should send all tools when toolChoice is auto" — all provided tools -/// appear in the toolConfig, not just a filtered subset. -#[tokio::test] -async fn tool_choice_auto_sends_all_tools() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let opts = CallOptions { - tools: Some(vec![ - Tool::Function( - FunctionTool::new("get-weather".to_string(), json!({ "type": "object" })) - .with_description("Get weather".to_string()), - ), - Tool::Function( - FunctionTool::new("get-time".to_string(), json!({ "type": "object" })) - .with_description("Get time".to_string()), - ), - ]), - tool_choice: ToolChoice::Auto, - ..default_options(test_prompt()) - }; - - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.expect("request body"); - assert_eq!(body["toolConfig"]["toolChoice"], json!({ "auto": {} })); - let tools = body["toolConfig"]["tools"].as_array().unwrap(); - assert_eq!( - tools.len(), - 2, - "both tools should be sent with toolChoice: auto" - ); - assert_eq!(tools[0]["toolSpec"]["name"], "get-weather"); - assert_eq!(tools[1]["toolSpec"]["name"], "get-time"); -} - -/// TS: "should omit empty tool descriptions to avoid Bedrock validation errors". -/// -/// A function tool with an empty-string description must not emit a -/// `description` field in the `toolSpec`. -#[tokio::test] -async fn tool_empty_description_omitted() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let opts = CallOptions { - tools: Some(vec![Tool::Function( - FunctionTool::new("get-weather".to_string(), json!({ "type": "object" })) - .with_description("".to_string()), - )]), - tool_choice: ToolChoice::Auto, - ..default_options(test_prompt()) - }; - - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.expect("request body"); - let spec = &body["toolConfig"]["tools"][0]["toolSpec"]; - assert!( - spec.get("description").is_none(), - "empty description should be omitted" - ); -} - -// ── doStream: error handling ───────────────────────────────────────────────── - -/// TS: "should handle throttlingException error" (stream) — a 429 stream -/// response maps to `AiMuxError::ApiCall` (429 in `status_code`). -#[tokio::test] -async fn stream_throttling_error() { - let server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path( - "/model/anthropic.claude-3-5-sonnet-20240620-v1:0/converse-stream", - )) - .respond_with(ResponseTemplate::new(429).set_body_json(json!({ - "message": "throttlingException", - "type": "TooManyRequestsException" - }))) - .mount(&server) - .await; - - let model = make_model(&server); - let result = model.do_stream(&default_options(test_prompt())).await; - assert!( - matches!(result, Err(ref e) if e.status_code() == Some(429)), - "expected RateLimited, got {result:?}" - ); -} - -/// TS: "should handle validationException error" (stream) — a 400 stream -/// response surfaces as an error (not a panic). -#[tokio::test] -async fn stream_validation_error() { - let server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path( - "/model/anthropic.claude-3-5-sonnet-20240620-v1:0/converse-stream", - )) - .respond_with(ResponseTemplate::new(400).set_body_json(json!({ - "message": "The model ID is invalid", - "type": "ValidationException" - }))) - .mount(&server) - .await; - - let model = make_model(&server); - let result = model.do_stream(&default_options(test_prompt())).await; - assert!(result.is_err(), "expected error for 400 stream response"); -} - -// ── doStream: request body parity ──────────────────────────────────────────── - -/// TS: "should return the request body" (stream) — the stream result carries -/// the request body for debugging. -#[tokio::test] -async fn stream_request_body_available() { - let server = MockServer::start().await; - - let events: Vec<(&str, &str, &str)> = vec![ - ("event", "messageStart", r#"{"role":"assistant"}"#), - ( - "event", - "contentBlockDelta", - r#"{"contentBlockIndex":0,"delta":{"text":"hi"}}"#, - ), - ("event", "contentBlockStop", r#"{"contentBlockIndex":0}"#), - ("event", "messageStop", r#"{"stopReason":"end_turn"}"#), - ]; - let body_bytes = aimux_providers::bedrock::event_stream::encode_messages(&events); - - Mock::given(method("POST")) - .and(path( - "/model/anthropic.claude-3-5-sonnet-20240620-v1:0/converse-stream", - )) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "application/vnd.amazon.eventstream") - .set_body_raw(body_bytes, "application/vnd.amazon.eventstream"), - ) - .mount(&server) - .await; - - let model = make_model(&server); - let mut opts = default_options(test_prompt()); - opts.max_output_tokens = Some(256); - - let result = model.do_stream(&opts).await.expect("should succeed"); - let body = result.request_body.expect("stream request body"); - assert_eq!(body["messages"][0]["role"], "user"); - assert_eq!(body["inferenceConfig"]["maxTokens"], 256); -} diff --git a/aimux-providers/tests/bedrock_reranking_test.rs b/aimux-providers/tests/bedrock_reranking_test.rs deleted file mode 100644 index 8ce074a20..000000000 --- a/aimux-providers/tests/bedrock_reranking_test.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! Rust translations of the Amazon Bedrock Reranking model tests. -//! -//! Source: `reference/ai/packages/amazon-bedrock/src/reranking/amazon-bedrock-reranking-model.test.ts` -//! (13 test cases across "json documents" and "text documents" groups). - -use std::collections::HashMap; - -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::reranking_model::{RerankingCallOptions, RerankingDocuments, RerankingModel}; -use aimux_providers::{BedrockProvider, BedrockProviderConfig}; - -// -- helpers ----------------------------------------------------------------- - -fn rerank_response_body() -> Value { - json!({ - "results": [ - { "index": 0, "relevanceScore": 0.5110583305358887 }, - { "index": 5, "relevanceScore": 0.30241215229034424 } - ] - }) -} - -fn provider(server: &MockServer) -> BedrockProvider { - let config = BedrockProviderConfig::with_bearer_token("test-auth", "us-west-2") - .with_base_url(server.uri()); - BedrockProvider::new(config) -} - -fn bedrock_provider_options() -> HashMap { - let mut po = HashMap::new(); - po.insert( - "bedrock".to_string(), - json!({ - "nextToken": "test-token", - "additionalModelRequestFields": { "test": "test-value" } - }), - ); - po -} - -fn text_docs_opts(query: &str, top_n: u32) -> RerankingCallOptions { - RerankingCallOptions { - documents: RerankingDocuments::Text { - values: vec![ - "sunny day at the beach".to_string(), - "rainy day in the city".to_string(), - ], - }, - query: query.to_string(), - top_n: Some(top_n), - abort_signal: None, - provider_options: Some(bedrock_provider_options()), - headers: None, - max_retries: None, - timeout: None, - } -} - -fn json_docs_opts(query: &str, top_n: u32) -> RerankingCallOptions { - RerankingCallOptions { - documents: RerankingDocuments::Object { - values: vec![ - json!({ "example": "sunny day at the beach" }), - json!({ "example": "rainy day in the city" }), - ], - }, - query: query.to_string(), - top_n: Some(top_n), - abort_signal: None, - provider_options: Some(bedrock_provider_options()), - headers: None, - max_retries: None, - timeout: None, - } -} - -async fn mount_rerank_mock(server: &MockServer) { - Mock::given(method("POST")) - .and(path("/rerank")) - .respond_with(ResponseTemplate::new(200).set_body_json(rerank_response_body())) - .mount(server) - .await; -} - -// -- json documents tests ---------------------------------------------------- - -#[tokio::test] -async fn json_docs_should_send_request_with_stringified_json_documents() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("cohere.rerank-v3-5:0"); - - model - .do_rerank(&json_docs_opts("rainy day", 2)) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["nextToken"], "test-token"); - assert_eq!(body["queries"][0]["textQuery"]["text"], "rainy day"); - assert_eq!(body["queries"][0]["type"], "TEXT"); - assert_eq!( - body["rerankingConfiguration"]["bedrockRerankingConfiguration"]["modelConfiguration"]["modelArn"], - "arn:aws:bedrock:us-west-2::foundation-model/cohere.rerank-v3-5:0" - ); - assert_eq!( - body["rerankingConfiguration"]["bedrockRerankingConfiguration"]["modelConfiguration"]["additionalModelRequestFields"] - ["test"], - "test-value" - ); - assert_eq!( - body["rerankingConfiguration"]["bedrockRerankingConfiguration"]["numberOfResults"], - 2 - ); - assert_eq!( - body["rerankingConfiguration"]["type"], - "BEDROCK_RERANKING_MODEL" - ); - // JSON document sources - assert_eq!(body["sources"][0]["type"], "INLINE"); - assert_eq!(body["sources"][0]["inlineDocumentSource"]["type"], "JSON"); - assert_eq!( - body["sources"][0]["inlineDocumentSource"]["jsonDocument"]["example"], - "sunny day at the beach" - ); - assert_eq!( - body["sources"][1]["inlineDocumentSource"]["jsonDocument"]["example"], - "rainy day in the city" - ); -} - -#[tokio::test] -async fn json_docs_should_send_bedrock_reranking_configuration_wire_key() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("cohere.rerank-v3-5:0"); - - model - .do_rerank(&json_docs_opts("rainy day", 2)) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - // The Bedrock API requires this exact member name. - assert!( - body["rerankingConfiguration"] - .get("bedrockRerankingConfiguration") - .is_some(), - "rerankingConfiguration.bedrockRerankingConfiguration must be present" - ); -} - -#[tokio::test] -async fn json_docs_should_send_request_with_correct_headers() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("cohere.rerank-v3-5:0"); - - model - .do_rerank(&json_docs_opts("rainy day", 2)) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - let headers = &requests[0].headers; - assert_eq!( - headers.get("content-type").and_then(|v| v.to_str().ok()), - Some("application/json") - ); - assert_eq!( - headers.get("authorization").and_then(|v| v.to_str().ok()), - Some("Bearer test-auth") - ); -} - -#[tokio::test] -async fn json_docs_should_return_result_without_warnings() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("cohere.rerank-v3-5:0"); - - let result = model - .do_rerank(&json_docs_opts("rainy day", 2)) - .await - .unwrap(); - - assert!(result.warnings.is_none()); -} - -#[tokio::test] -async fn json_docs_should_return_result_with_correct_ranking() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("cohere.rerank-v3-5:0"); - - let result = model - .do_rerank(&json_docs_opts("rainy day", 2)) - .await - .unwrap(); - - assert_eq!(result.ranking.len(), 2); - assert_eq!(result.ranking[0].index, 0); - assert!((result.ranking[0].relevance_score - 0.5110583305358887).abs() < 1e-8); - assert_eq!(result.ranking[1].index, 5); - assert!((result.ranking[1].relevance_score - 0.30241215229034424).abs() < 1e-8); -} - -#[tokio::test] -async fn json_docs_should_not_return_provider_metadata() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("cohere.rerank-v3-5:0"); - - let result = model - .do_rerank(&json_docs_opts("rainy day", 2)) - .await - .unwrap(); - - assert!(result.provider_metadata.is_none()); -} - -#[tokio::test] -async fn json_docs_should_return_result_with_correct_response() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("cohere.rerank-v3-5:0"); - - let result = model - .do_rerank(&json_docs_opts("rainy day", 2)) - .await - .unwrap(); - - let response = result.response.expect("response"); - let body = response.body.expect("body"); - assert_eq!(body["results"][0]["index"], 0); - assert_eq!(body["results"][0]["relevanceScore"], 0.5110583305358887); - assert_eq!(body["results"][1]["index"], 5); - assert_eq!(body["results"][1]["relevanceScore"], 0.30241215229034424); -} - -// -- text documents tests ---------------------------------------------------- - -#[tokio::test] -async fn text_docs_should_send_request_with_text_documents() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("cohere.rerank-v3-5:0"); - - model - .do_rerank(&text_docs_opts("rainy day", 2)) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["nextToken"], "test-token"); - assert_eq!(body["queries"][0]["textQuery"]["text"], "rainy day"); - assert_eq!( - body["rerankingConfiguration"]["bedrockRerankingConfiguration"]["modelConfiguration"]["modelArn"], - "arn:aws:bedrock:us-west-2::foundation-model/cohere.rerank-v3-5:0" - ); - assert_eq!( - body["rerankingConfiguration"]["bedrockRerankingConfiguration"]["numberOfResults"], - 2 - ); - // Text document sources - assert_eq!(body["sources"][0]["type"], "INLINE"); - assert_eq!(body["sources"][0]["inlineDocumentSource"]["type"], "TEXT"); - assert_eq!( - body["sources"][0]["inlineDocumentSource"]["textDocument"]["text"], - "sunny day at the beach" - ); - assert_eq!( - body["sources"][1]["inlineDocumentSource"]["textDocument"]["text"], - "rainy day in the city" - ); -} - -#[tokio::test] -async fn text_docs_should_send_request_with_correct_headers() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("cohere.rerank-v3-5:0"); - - model - .do_rerank(&text_docs_opts("rainy day", 2)) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - let headers = &requests[0].headers; - assert_eq!( - headers.get("content-type").and_then(|v| v.to_str().ok()), - Some("application/json") - ); - assert_eq!( - headers.get("authorization").and_then(|v| v.to_str().ok()), - Some("Bearer test-auth") - ); -} - -#[tokio::test] -async fn text_docs_should_return_result_without_warnings() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("cohere.rerank-v3-5:0"); - - let result = model - .do_rerank(&text_docs_opts("rainy day", 2)) - .await - .unwrap(); - - assert!(result.warnings.is_none()); -} - -#[tokio::test] -async fn text_docs_should_return_result_with_correct_ranking() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("cohere.rerank-v3-5:0"); - - let result = model - .do_rerank(&text_docs_opts("rainy day", 2)) - .await - .unwrap(); - - assert_eq!(result.ranking.len(), 2); - assert_eq!(result.ranking[0].index, 0); - assert!((result.ranking[0].relevance_score - 0.5110583305358887).abs() < 1e-8); -} - -#[tokio::test] -async fn text_docs_should_not_return_provider_metadata() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("cohere.rerank-v3-5:0"); - - let result = model - .do_rerank(&text_docs_opts("rainy day", 2)) - .await - .unwrap(); - - assert!(result.provider_metadata.is_none()); -} - -#[tokio::test] -async fn text_docs_should_return_result_with_correct_response() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("cohere.rerank-v3-5:0"); - - let result = model - .do_rerank(&text_docs_opts("rainy day", 2)) - .await - .unwrap(); - - let response = result.response.expect("response"); - let body = response.body.expect("body"); - assert_eq!(body["results"][0]["index"], 0); - assert_eq!(body["results"][0]["relevanceScore"], 0.5110583305358887); -} diff --git a/aimux-providers/tests/body_overrides_test.rs b/aimux-providers/tests/body_overrides_test.rs deleted file mode 100644 index f8b392dcb..000000000 --- a/aimux-providers/tests/body_overrides_test.rs +++ /dev/null @@ -1,286 +0,0 @@ -//! Tests for RFC-0017 phase 1: `body_overrides` (JSON deep-merge) and -//! `max_retries` (per-call retry override). -//! -//! `body_overrides` is a per-call JSON object deep-merged into the -//! provider-built request body after built-in vendor overrides. It lets users -//! inject/override arbitrary request fields (e.g. `enable_thinking`, -//! `thinking_budget`) without closure bridging — critical for aimux's -//! multi-language C ABI architecture. `null` values delete keys. -//! -//! stage2-001 (RFC-0017 phase 2) additions: `max_tokens_key` branch and direct -//! `reasoning` → `reasoning_effort` passthrough. The old "reasoning no-mapping" -//! warning block was removed (F6): under v3 direct-passthrough semantics it is -//! unreachable dead code (is_custom_reasoning ⇒ resolved effort is always Some), -//! and the passthrough tests below now guard against re-adding it. - -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::CallOptions; -use aimux_core::types::{ReasoningEffort, Warning}; -use aimux_providers::openai::convert::build_request_body; -use aimux_providers::openai::convert::build_request_body_with_warnings; -use serde_json::{Value, json}; - -fn user_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -fn opts_with_overrides(prompt: LanguageModelPrompt, body_overrides: Value) -> CallOptions { - CallOptions { - prompt, - body_overrides: Some(body_overrides), - ..CallOptions::default() - } -} - -// ── body_overrides: inject ─────────────────────────────────────────────────── - -/// A top-level key in body_overrides is injected into the request body. -#[test] -fn body_overrides_injects_new_field() { - let opts = opts_with_overrides(user_prompt(), json!({ "enable_thinking": false })); - let body = build_request_body("gpt-4o", &opts, false).unwrap(); - assert_eq!(body["enable_thinking"], json!(false)); - assert_eq!(body["model"], json!("gpt-4o")); -} - -/// body_overrides can inject nested objects. -#[test] -fn body_overrides_injects_nested_object() { - let opts = opts_with_overrides(user_prompt(), json!({ "thinking": { "type": "disabled" } })); - let body = build_request_body("gpt-4o", &opts, false).unwrap(); - assert_eq!(body["thinking"], json!({ "type": "disabled" })); -} - -// ── body_overrides: override ───────────────────────────────────────────────── - -/// body_overrides overwrites an existing field (e.g. temperature). -#[test] -fn body_overrides_overwrites_existing_field() { - let mut opts = opts_with_overrides(user_prompt(), json!({ "temperature": 0.1 })); - opts.temperature = Some(0.9); // set by standard option - let body = build_request_body("gpt-4o", &opts, false).unwrap(); - // body_overrides wins over standard option - assert_eq!(body["temperature"], json!(0.1)); -} - -/// body_overrides injects a field into the request body (RFC-0017). -/// stage2-001(RFC-0017 阶段 2)后内置 vendor override 已退役——此前 DeepSeek -/// 会从 `reasoning:none` 注入 `thinking:{type:"disabled"}`;现在 thinking 注入 -/// 完全由 body_overrides 定义(此处直接注入 enabled)。 -#[test] -fn body_overrides_overwrites_vendor_override_field() { - let opts = CallOptions { - prompt: user_prompt(), - reasoning: Some(aimux_core::types::ReasoningEffort::None), - body_overrides: Some(json!({ "thinking": { "type": "enabled" } })), - ..CallOptions::default() - }; - // DeepSeek profile 已回归 full()(特化退役),body_overrides 注入 thinking。 - let body = aimux_providers::openai::convert::build_request_body_with_warnings( - "deepseek-v4-flash", - &opts, - false, - "deepseek", - &aimux_providers::openai::OpenAICompatProfile::deepseek(), - ) - .unwrap() - .body; - assert_eq!(body["thinking"], json!({ "type": "enabled" })); -} - -// ── body_overrides: deep merge ─────────────────────────────────────────────── - -/// Nested objects are merged recursively, not replaced wholesale. -#[test] -fn body_overrides_deep_merges_nested_objects() { - // The standard body has stream_options: { include_usage: true } for streams. - // body_overrides adds another key to stream_options without clobbering - // include_usage. - let opts = opts_with_overrides( - user_prompt(), - json!({ "stream_options": { "include_usage": false } }), - ); - let body = build_request_body("gpt-4o", &opts, true).unwrap(); - assert_eq!(body["stream_options"]["include_usage"], json!(false)); -} - -// ── body_overrides: null = delete ──────────────────────────────────────────── - -/// A `null` value in body_overrides deletes the corresponding key from the -/// request body. -#[test] -fn body_overrides_null_deletes_key() { - let opts = opts_with_overrides( - user_prompt(), - json!({ "stream_options": null, "temperature": null }), - ); - let body = build_request_body("gpt-4o", &opts, true).unwrap(); - assert!( - body.get("stream_options").is_none(), - "stream_options should be deleted by null" - ); - assert!( - body.get("temperature").is_none(), - "temperature should be deleted by null" - ); -} - -/// null in a nested object deletes the nested key. -#[test] -fn body_overrides_null_deletes_nested_key() { - let opts = opts_with_overrides( - user_prompt(), - json!({ "stream_options": { "include_usage": null } }), - ); - let body = build_request_body("gpt-4o", &opts, true).unwrap(); - // stream_options still exists but include_usage is gone - assert!(body.get("stream_options").is_some()); - assert!(body["stream_options"].get("include_usage").is_none()); -} - -// ── body_overrides: no overrides = unchanged ───────────────────────────────── - -/// When body_overrides is None, the request body is identical to the standard -/// build (no merge happens). -#[test] -fn no_body_overrides_leaves_body_unchanged() { - let opts = CallOptions::new(user_prompt()); - let body = build_request_body("gpt-4o", &opts, false).unwrap(); - assert_eq!(body["model"], json!("gpt-4o")); - assert!(body.get("enable_thinking").is_none()); -} - -// ── max_retries ────────────────────────────────────────────────────────────── - -/// max_retries is stored in CallOptions and defaults to None. -#[test] -fn max_retries_defaults_to_none() { - let opts = CallOptions::new(user_prompt()); - assert!(opts.max_retries.is_none()); -} - -/// max_retries can be set (e.g. Some(0) to disable retries). -#[test] -fn max_retries_can_be_set() { - let opts = CallOptions { - prompt: user_prompt(), - max_retries: Some(0), - ..CallOptions::default() - }; - assert_eq!(opts.max_retries, Some(0)); -} - -// ── max_tokens_key (stage2-001, RFC-0017 phase 2 §2.3) ────────────────────── - -/// max_tokens_key=Some("max_tokens") + 推理模型 → 发 `max_tokens`, -/// 不含 `max_completion_tokens`(修推理模型推断 bug)。 -#[test] -fn max_tokens_key_max_tokens_reasoning_model() { - let opts = CallOptions { - prompt: user_prompt(), - max_output_tokens: Some(100), - ..CallOptions::default() - }; - let profile = aimux_providers::openai::OpenAICompatProfile { - max_tokens_key: Some("max_tokens"), - ..aimux_providers::openai::OpenAICompatProfile::full() - }; - let body = build_request_body_with_warnings("o4-mini", &opts, false, "openai", &profile) - .unwrap() - .body; - assert_eq!(body["max_tokens"], json!(100)); - assert!( - body.get("max_completion_tokens").is_none(), - "只认 max_tokens 的厂商不应收到 max_completion_tokens" - ); -} - -/// max_tokens_key=Some("max_completion_tokens") → 非推理分支也发 mct -///(groq/heroku:max_tokens 弃用)。 -#[test] -fn max_tokens_key_max_completion_tokens_non_reasoning() { - let opts = CallOptions { - prompt: user_prompt(), - max_output_tokens: Some(100), - ..CallOptions::default() - }; - let profile = aimux_providers::openai::OpenAICompatProfile { - max_tokens_key: Some("max_completion_tokens"), - ..aimux_providers::openai::OpenAICompatProfile::full() - }; - let body = build_request_body_with_warnings("gpt-4o", &opts, false, "openai", &profile) - .unwrap() - .body; - assert_eq!(body["max_completion_tokens"], json!(100)); - assert!( - body.get("max_tokens").is_none(), - "max_tokens_key=mct 时不应再发 max_tokens" - ); -} - -// ── reasoning 直传: 无 warning(F6: 旧"无映射提示"warning 块已删除)──────────── -// -// v3 直传语义下 `reasoning` 一律映射为 `reasoning_effort`,warning 块不可达已被 -// 删除。以下两个用例断言"无 reasoning warning",作为未来误加 warning 的回归护栏。 - -/// 直传路径: groq 不再特化归一化(`none` 原样透传 'none')→ 已发 effort,不 warning。 -#[test] -fn groq_none_passthrough_no_warning() { - let opts = CallOptions { - prompt: user_prompt(), - reasoning: Some(ReasoningEffort::None), - ..CallOptions::default() - }; - let result = build_request_body_with_warnings( - "llama-3.3-70b-versatile", - &opts, - false, - "groq", - &aimux_providers::openai::OpenAICompatProfile::groq(), - ) - .unwrap(); - assert_eq!(result.body["reasoning_effort"], json!("none")); - let reasoning_warning = result - .warnings - .iter() - .find(|w| matches!(w, Warning::Compatibility { feature, .. } if feature == "reasoning")); - assert!( - reasoning_warning.is_none(), - "已发 effort 时不应 warning(防误报): {:?}", - result.warnings - ); -} - -/// 已发 effort 路径('none' 透传,OpenAI 有效)→ 不 warning(防误报)。 -#[test] -fn no_warning_when_reasoning_translated() { - let opts = CallOptions { - prompt: user_prompt(), - reasoning: Some(ReasoningEffort::None), - ..CallOptions::default() - }; - let result = build_request_body_with_warnings( - "deepseek-reasoner", - &opts, - false, - "openai", - &aimux_providers::openai::OpenAICompatProfile::full(), - ) - .unwrap(); - assert_eq!(result.body["reasoning_effort"], json!("none")); - let reasoning_warning = result - .warnings - .iter() - .find(|w| matches!(w, Warning::Compatibility { feature, .. } if feature == "reasoning")); - assert!( - reasoning_warning.is_none(), - "已发 effort 时不应 warning(防误报): {:?}", - result.warnings - ); -} diff --git a/aimux-providers/tests/cohere_model_test.rs b/aimux-providers/tests/cohere_model_test.rs deleted file mode 100644 index c61f75f5a..000000000 --- a/aimux-providers/tests/cohere_model_test.rs +++ /dev/null @@ -1,1596 +0,0 @@ -//! Wiremock tests for the Cohere provider. -//! -//! Translated from `packages/cohere/src/cohere-chat-language-model.test.ts`, -//! focusing on the cases that the Rust data model can express: -//! - doGenerate: text extraction, MAX_TOKENS finish reason, tool calls, request body -//! - doStream: text streaming, tool call streaming, reasoning streaming - -use futures::StreamExt; -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::error::AiMuxError; -use aimux_core::generate::{GenerateTextOptions, stream_text}; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::{CallOptions, Tool, ToolChoice}; -use aimux_core::result::{GenerateContent, StreamResult}; -use aimux_core::stream_part::StreamPart; -use aimux_core::tool::FunctionTool; -use aimux_core::types::FinishReasonUnified; - -use aimux_providers::{CohereConfig, CohereProvider}; - -// ── helpers ───────────────────────────────────────────────────────────────── - -/// The TS `TEST_PROMPT`: a system message + a user text message. -fn test_prompt() -> LanguageModelPrompt { - vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("you are a friendly bot!")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - ] -} - -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -fn test_tool() -> Tool { - Tool::Function(FunctionTool { - name: "test-tool".to_string(), - description: None, - input_schema: json!({ - "type": "object", - "properties": { "value": { "type": "string" } }, - "required": ["value"], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - }), - strict: None, - provider_options: None, - input_examples: None, - }) -} - -async fn mock_json_response(server: &MockServer, body: Value) { - Mock::given(method("POST")) - .and(path("/chat")) - .respond_with(ResponseTemplate::new(200).set_body_json(body)) - .mount(server) - .await; -} - -async fn mock_sse_response(server: &MockServer, sse_body: &str) { - Mock::given(method("POST")) - .and(path("/chat")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body.to_string()), - ) - .mount(server) - .await; -} - -/// Build a Cohere SSE event from a JSON value (with `event:` field). -fn cohere_event(json_str: &str) -> String { - // Parse to get the type, then format as `event: type\ndata: json\n\n`. - if let Ok(val) = serde_json::from_str::(json_str) { - let event_type = val - .get("type") - .and_then(|t| t.as_str()) - .unwrap_or("message"); - format!("event: {event_type}\ndata: {json_str}\n\n") - } else { - format!("event: unknown\ndata: {json_str}\n\n") - } -} - -/// Concatenate Cohere SSE events. -fn cohere_sse_body(events: &[&str]) -> String { - let mut body = String::new(); - for event in events { - body.push_str(&cohere_event(event)); - } - body -} - -async fn collect_stream(result: StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -fn text_deltas(parts: &[StreamPart]) -> Vec { - parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect() -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should extract text response" -#[tokio::test] -async fn should_extract_text_response() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "e7592632-1e3d-424f-b129-bd5f9f980f7b", - "message": { - "role": "assistant", - "content": [ - { "type": "text", "text": "The capital of France is Paris." } - ] - }, - "finish_reason": "COMPLETE", - "usage": { - "billed_units": { "input_tokens": 12, "output_tokens": 7 }, - "tokens": { "input_tokens": 507, "output_tokens": 10 } - } - }), - ) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::Text { text, .. } => { - assert_eq!(text, "The capital of France is Paris."); - } - other => panic!("expected Text, got {other:?}"), - } - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(result.finish_reason.raw.as_deref(), Some("COMPLETE")); -} - -/// TS: "should map MAX_TOKENS finish reason to length" -#[tokio::test] -async fn should_map_max_tokens_finish_reason() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "max-tokens-id", - "message": { - "role": "assistant", - "content": [{ "type": "text", "text": "truncated" }] - }, - "finish_reason": "MAX_TOKENS", - "usage": { - "billed_units": { "input_tokens": 12, "output_tokens": 7 }, - "tokens": { "input_tokens": 507, "output_tokens": 10 } - } - }), - ) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Length); - assert_eq!(result.finish_reason.raw.as_deref(), Some("MAX_TOKENS")); -} - -/// TS: "should extract tool calls" -#[tokio::test] -async fn should_extract_tool_calls() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "f201af17-e24a-4396-8f6a-98e8bf9c3432", - "message": { - "role": "assistant", - "tool_plan": "I will use the weather tool.", - "tool_calls": [ - { - "id": "weather_dqgshstja6p9", - "type": "function", - "function": { - "name": "weather", - "arguments": "{\"location\":\"San Francisco\"}" - } - }, - { - "id": "cityAttractions_dcxfx4myvx68", - "type": "function", - "function": { - "name": "cityAttractions", - "arguments": "{\"city\":\"San Francisco\"}" - } - } - ] - }, - "finish_reason": "TOOL_CALL", - "usage": { - "billed_units": { "input_tokens": 119, "output_tokens": 52 }, - "tokens": { "input_tokens": 1549, "output_tokens": 103 } - } - }), - ) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let options = CallOptions { - tools: Some(vec![test_tool()]), - ..default_options(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - - assert_eq!(result.content.len(), 2); - match &result.content[0] { - GenerateContent::ToolCall { - tool_name, input, .. - } => { - assert_eq!(tool_name, "weather"); - assert_eq!( - input, - &Value::String(r#"{"location":"San Francisco"}"#.into()) - ); - } - other => panic!("expected ToolCall, got {other:?}"), - } - match &result.content[1] { - GenerateContent::ToolCall { - tool_name, input, .. - } => { - assert_eq!(tool_name, "cityAttractions"); - assert_eq!(input, &Value::String(r#"{"city":"San Francisco"}"#.into())); - } - other => panic!("expected ToolCall, got {other:?}"), - } - assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); -} - -/// TS: "should handle string 'null' tool call arguments" -#[tokio::test] -async fn should_handle_null_tool_call_arguments() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "null-args-id", - "message": { - "role": "assistant", - "tool_calls": [{ - "id": "currentTime_abc", - "type": "function", - "function": { - "name": "currentTime", - "arguments": "null" - } - }] - }, - "finish_reason": "TOOL_CALL", - "usage": { - "billed_units": { "input_tokens": 10, "output_tokens": 5 }, - "tokens": { "input_tokens": 20, "output_tokens": 10 } - } - }), - ) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::ToolCall { input, .. } => { - // "null" should be replaced with "{}". - assert_eq!(input, &Value::String("{}".into())); - } - other => panic!("expected ToolCall, got {other:?}"), - } -} - -/// TS: "should extract usage" -#[tokio::test] -async fn should_extract_usage() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "message": { - "role": "assistant", - "content": [{ "type": "text", "text": "Hello" }] - }, - "finish_reason": "COMPLETE", - "usage": { - "billed_units": { "input_tokens": 12, "output_tokens": 7 }, - "tokens": { "input_tokens": 507, "output_tokens": 10 } - } - }), - ) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - // Cohere uses `tokens` (not `billed_units`) for usage. - assert_eq!(result.usage.input_tokens.total, Some(507)); - assert_eq!(result.usage.input_tokens.no_cache, Some(507)); - assert_eq!(result.usage.output_tokens.total, Some(10)); -} - -/// TS: "should pass model and messages" -#[tokio::test] -async fn should_send_correct_request_body() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "message": { - "role": "assistant", - "content": [{ "type": "text", "text": "Hi" }] - }, - "finish_reason": "COMPLETE", - "usage": { - "billed_units": { "input_tokens": 12, "output_tokens": 7 }, - "tokens": { "input_tokens": 12, "output_tokens": 7 } - } - }), - ) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let body = result.request_body.expect("body"); - assert_eq!(body["model"], "command-r-plus"); - assert_eq!( - body["messages"], - json!([ - { "role": "system", "content": "you are a friendly bot!" }, - { "role": "user", "content": "Hello" } - ]) - ); - // No extra keys when options are unset. - assert!(body.get("max_tokens").is_none()); - assert!(body.get("temperature").is_none()); - assert!(body.get("tools").is_none()); - assert!(body.get("documents").is_none()); -} - -/// TS: "should pass tools" with tool_choice none → "NONE" -#[tokio::test] -async fn should_pass_tools_with_tool_choice_none() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "message": { - "role": "assistant", - "content": [{ "type": "text", "text": "ok" }] - }, - "finish_reason": "COMPLETE", - "usage": { - "billed_units": { "input_tokens": 12, "output_tokens": 7 }, - "tokens": { "input_tokens": 12, "output_tokens": 7 } - } - }), - ) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let options = CallOptions { - tools: Some(vec![test_tool()]), - tool_choice: ToolChoice::None, - ..default_options(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["tool_choice"], json!("NONE")); - assert_eq!(body["tools"][0]["function"]["name"], "test-tool"); -} - -// ════════════════════════════════════════════════════════════════════════════ -// doStream -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should stream text deltas" -#[tokio::test] -async fn should_stream_text_deltas() { - let server = MockServer::start().await; - let sse = cohere_sse_body(&[ - r#"{"id":"321d178c-2c12-44d3-ae42-2f5510f6b1cc","type":"message-start","delta":{"message":{"role":"assistant","content":[],"tool_plan":"","tool_calls":[],"citations":[]}}}"#, - r#"{"type":"content-start","index":0,"delta":{"message":{"content":{"type":"text","text":""}}}}"#, - r#"{"type":"content-delta","index":0,"delta":{"message":{"content":{"text":"The"}}}}"#, - r#"{"type":"content-delta","index":0,"delta":{"message":{"content":{"text":" capital"}}}}"#, - r#"{"type":"content-delta","index":0,"delta":{"message":{"content":{"text":" of"}}}}"#, - r#"{"type":"content-delta","index":0,"delta":{"message":{"content":{"text":" France"}}}}"#, - r#"{"type":"content-delta","index":0,"delta":{"message":{"content":{"text":" is"}}}}"#, - r#"{"type":"content-delta","index":0,"delta":{"message":{"content":{"text":" Paris"}}}}"#, - r#"{"type":"content-delta","index":0,"delta":{"message":{"content":{"text":"."}}}}"#, - r#"{"type":"content-end","index":0}"#, - r#"{"type":"message-end","delta":{"finish_reason":"COMPLETE","usage":{"billed_units":{"input_tokens":12,"output_tokens":7},"tokens":{"input_tokens":507,"output_tokens":10},"cached_tokens":448}}}"#, - ]); - mock_sse_response(&server, &sse).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - - // StreamStart, ResponseMetadata, TextStart, TextDelta x7, TextEnd, Finish. - assert!(matches!( - parts.first(), - Some(StreamPart::StreamStart { .. }) - )); - - let deltas = text_deltas(&parts); - assert_eq!( - deltas, - vec!["The", " capital", " of", " France", " is", " Paris", "."] - ); - - // Check finish. - let finish = parts.last().expect("should have finish"); - match finish { - StreamPart::Finish { - finish_reason, - usage, - .. - } => { - assert_eq!(finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(usage.input_tokens.total, Some(507)); - assert_eq!(usage.output_tokens.total, Some(10)); - } - other => panic!("expected Finish, got {other:?}"), - } -} - -/// TS: "should stream tool deltas" -#[tokio::test] -async fn should_stream_tool_call_deltas() { - let server = MockServer::start().await; - let sse = cohere_sse_body(&[ - r#"{"id":"2941521a","type":"message-start","delta":{"message":{"role":"assistant","content":[],"tool_plan":"","tool_calls":[],"citations":[]}}}"#, - r#"{"type":"tool-call-start","index":0,"delta":{"message":{"tool_calls":{"id":"weather_e8p4pn45zt0t","type":"function","function":{"name":"weather","arguments":""}}}}}"#, - r#"{"type":"tool-call-delta","index":0,"delta":{"message":{"tool_calls":{"function":{"arguments":"{"}}}}}"#, - r#"{"type":"tool-call-delta","index":0,"delta":{"message":{"tool_calls":{"function":{"arguments":"\"location\": \"San Francisco\"}"}}}}}"#, - r#"{"type":"tool-call-end","index":0}"#, - r#"{"type":"message-end","delta":{"finish_reason":"TOOL_CALL","usage":{"billed_units":{"input_tokens":119,"output_tokens":44},"tokens":{"input_tokens":1549,"output_tokens":95},"cached_tokens":1504}}}"#, - ]); - mock_sse_response(&server, &sse).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let options = CallOptions { - tools: Some(vec![test_tool()]), - ..default_options(test_prompt()) - }; - - let result = model.do_stream(&options).await.expect("should succeed"); - let parts = collect_stream(result).await; - - // Should have ToolInputStart, ToolInputDelta(s), ToolInputEnd, ToolCall. - let tool_call = parts.iter().find_map(|p| match p { - StreamPart::ToolCall { - tool_call_id, - tool_name, - input, - .. - } => Some((tool_call_id, tool_name, input)), - _ => None, - }); - let (id, name, input) = tool_call.expect("should have ToolCall"); - assert_eq!(id, "weather_e8p4pn45zt0t"); - assert_eq!(name, "weather"); - // Providers never parse tool input (Core owns that) — the flush forwards - // the accumulated deltas verbatim (only trimmed), so interior whitespace - // from the deltas survives. - assert_eq!( - input, - &Value::String(r#"{"location": "San Francisco"}"#.into()) - ); - - // Verify the accumulated deltas. - let deltas: Vec = parts - .iter() - .filter_map(|p| match p { - StreamPart::ToolInputDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect(); - assert_eq!(deltas, vec!["{", "\"location\": \"San Francisco\"}"]); - - // Finish reason should be ToolCalls. - let finish = parts.last().expect("should have finish"); - match finish { - StreamPart::Finish { finish_reason, .. } => { - assert_eq!(finish_reason.unified, FinishReasonUnified::ToolCalls); - } - other => panic!("expected Finish, got {other:?}"), - } -} - -/// Regression test for PR #165 review finding: Cohere used to parse the -/// accumulated tool-call arguments itself and treat a parse failure as a -/// terminal stream error, unlike every other provider (which forwards raw -/// text and lets Core own parsing/validation/repair) and unlike Cohere's own -/// non-streaming path. Malformed streamed arguments must surface as a -/// retained `invalid: true` tool call, not abort the stream. -#[tokio::test] -async fn malformed_streamed_tool_call_arguments_do_not_error_the_stream() { - let server = MockServer::start().await; - let sse = cohere_sse_body(&[ - r#"{"id":"malformed-1","type":"message-start","delta":{"message":{"role":"assistant","content":[],"tool_plan":"","tool_calls":[],"citations":[]}}}"#, - r#"{"type":"tool-call-start","index":0,"delta":{"message":{"tool_calls":{"id":"tc_malformed","type":"function","function":{"name":"test-tool","arguments":""}}}}}"#, - r#"{"type":"tool-call-delta","index":0,"delta":{"message":{"tool_calls":{"function":{"arguments":"{\"value\":"}}}}}"#, - r#"{"type":"tool-call-end","index":0}"#, - r#"{"type":"message-end","delta":{"finish_reason":"TOOL_CALL","usage":{"billed_units":{"input_tokens":10,"output_tokens":5},"tokens":{"input_tokens":10,"output_tokens":5}}}}"#, - ]); - mock_sse_response(&server, &sse).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - // First, confirm the raw provider stream itself never parses and never - // errors on the malformed arguments — it just forwards the text. - let raw_options = CallOptions { - tools: Some(vec![test_tool()]), - ..default_options(test_prompt()) - }; - let raw_parts = - collect_stream(model.do_stream(&raw_options).await.expect("should succeed")).await; - assert!( - !raw_parts - .iter() - .any(|part| matches!(part, StreamPart::Error { .. })), - "provider must not parse tool input itself: {raw_parts:?}" - ); - let raw_input = raw_parts.iter().find_map(|p| match p { - StreamPart::ToolCall { input, .. } => Some(input.clone()), - _ => None, - }); - assert_eq!( - raw_input, - Some(Value::String(r#"{"value":"#.to_string())), - "provider must forward the malformed text verbatim" - ); - - // Then, through Core's `stream_text` (parse/validate/repair boundary), - // the malformed call surfaces as a retained invalid tool call — the - // stream itself completes normally. - let result = stream_text( - &model, - "test", - GenerateTextOptions { - tools: Some(vec![test_tool()]), - ..GenerateTextOptions::default() - }, - ) - .await - .expect("stream_text should start") - .consume() - .await - .expect("Core parsing keeps an invalid call, not a stream error"); - - let call = result - .tool_calls - .first() - .expect("should have one tool call"); - assert_eq!(call.invalid, Some(true)); - assert!(matches!( - call.error, - Some(aimux_core::error::AiMuxError::InvalidToolInput { .. }) - )); -} - -/// TS: "should stream reasoning deltas" -#[tokio::test] -async fn should_stream_reasoning_deltas() { - let server = MockServer::start().await; - let sse = cohere_sse_body(&[ - r#"{"id":"c9117d7f","type":"message-start","delta":{"message":{"role":"assistant","content":[],"tool_plan":"","tool_calls":[],"citations":[]}}}"#, - r#"{"type":"content-start","index":0,"delta":{"message":{"content":{"type":"thinking","thinking":""}}}}"#, - r#"{"type":"content-delta","index":0,"delta":{"message":{"content":{"thinking":"The user is asking"}}}}"#, - r#"{"type":"content-delta","index":0,"delta":{"message":{"content":{"thinking":" for 2+2."}}}}"#, - r#"{"type":"content-end","index":0}"#, - r#"{"type":"content-start","index":1,"delta":{"message":{"content":{"type":"text","text":""}}}}"#, - r#"{"type":"content-delta","index":1,"delta":{"message":{"content":{"text":"2 + 2 = 4"}}}}"#, - r#"{"type":"content-end","index":1}"#, - r#"{"type":"message-end","delta":{"finish_reason":"COMPLETE","usage":{"billed_units":{"input_tokens":8,"output_tokens":50},"tokens":{"input_tokens":1394,"output_tokens":54},"cached_tokens":1360}}}"#, - ]); - mock_sse_response(&server, &sse).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let parts = collect_stream(result).await; - - // Should have ReasoningStart, ReasoningDelta(s), ReasoningEnd. - let reasoning_deltas: Vec = parts - .iter() - .filter_map(|p| match p { - StreamPart::ReasoningDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect(); - assert_eq!(reasoning_deltas, vec!["The user is asking", " for 2+2."]); - - // Should also have text deltas. - let text_deltas: Vec = parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect(); - assert_eq!(text_deltas, vec!["2 + 2 = 4"]); - - // Should have ReasoningStart and ReasoningEnd. - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningStart { .. })) - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningEnd { .. })) - ); -} - -/// TS: "should pass the messages and the model" (streaming request body) -#[tokio::test] -async fn should_send_streaming_request_body() { - let server = MockServer::start().await; - let sse = cohere_sse_body(&[ - r#"{"id":"test","type":"message-start","delta":{"message":{"role":"assistant","content":[]}}}"#, - r#"{"type":"content-start","index":0,"delta":{"message":{"content":{"type":"text","text":""}}}}"#, - r#"{"type":"content-delta","index":0,"delta":{"message":{"content":{"text":"Hi"}}}"#, - r#"{"type":"content-end","index":0}"#, - r#"{"type":"message-end","delta":{"finish_reason":"COMPLETE","usage":{"billed_units":{"input_tokens":1,"output_tokens":1},"tokens":{"input_tokens":1,"output_tokens":1}}}}"#, - ]); - mock_sse_response(&server, &sse).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let body = result.request_body.expect("body"); - assert_eq!(body["model"], "command-r-plus"); - assert_eq!(body["stream"], json!(true)); - assert_eq!( - body["messages"], - json!([ - { "role": "system", "content": "you are a friendly bot!" }, - { "role": "user", "content": "Hello" } - ]) - ); -} - -/// TS: "should handle unparsable stream parts" -#[tokio::test] -async fn should_handle_unparsable_stream_parts() { - let server = MockServer::start().await; - let sse = cohere_sse_body(&[ - "{unparsable}", - r#"{"type":"content-start","index":0,"delta":{"message":{"content":{"type":"text","text":""}}}}"#, - r#"{"type":"content-delta","index":0,"delta":{"message":{"content":{"text":"after-error"}}}}"#, - r#"{"type":"content-end","index":0}"#, - r#"{"type":"message-end","delta":{"finish_reason":"COMPLETE","usage":{"billed_units":{"input_tokens":1,"output_tokens":1},"tokens":{"input_tokens":1,"output_tokens":1}}}}"#, - ]); - mock_sse_response(&server, &sse).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let outcomes: Vec<_> = result.stream.collect().await; - - assert!(matches!( - outcomes.first(), - Some(Ok(StreamPart::StreamStart { .. })) - )); - let error_index = outcomes - .iter() - .position(|outcome| { - matches!( - outcome, - Err(AiMuxError::JsonParse(_) | AiMuxError::InvalidResponseData(_)) - ) - }) - .expect("malformed frame should surface as a parse error item"); - assert!(outcomes[error_index + 1..].iter().any(|outcome| matches!( - outcome, - Ok(StreamPart::TextDelta { delta, .. }) if delta == "after-error" - ))); - assert!(outcomes.iter().any(|outcome| matches!( - outcome, - Ok(StreamPart::Finish { finish_reason, .. }) - if finish_reason.unified == FinishReasonUnified::Stop - ))); -} - -/// TS: "should handle 401 auth error" -#[tokio::test] -async fn should_handle_auth_error() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat")) - .respond_with( - ResponseTemplate::new(401).set_body_json(json!({ "message": "Invalid API key" })), - ) - .mount(&server) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model.do_generate(&default_options(test_prompt())).await; - - assert!(result.is_err()); - let err = result.unwrap_err(); - assert_eq!(err.status_code(), Some(401), "got {err:?}"); - assert!(err.to_string().contains("Invalid API key")); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Additional doGenerate cases — headers, response format, response metadata, -// tool_choice variants, file→documents, full request body, errors. -// ════════════════════════════════════════════════════════════════════════════ - -/// A minimal Cohere "ok" response reused by request-body assertions. -fn ok_cohere_body() -> Value { - json!({ - "id": "test-id", - "generation_id": "gen-123", - "message": { - "role": "assistant", - "content": [{ "type": "text", "text": "ok" }] - }, - "finish_reason": "COMPLETE", - "usage": { - "billed_units": { "input_tokens": 12, "output_tokens": 7 }, - "tokens": { "input_tokens": 12, "output_tokens": 7 } - } - }) -} - -/// TS: "should pass headers" — request-level custom headers reach the server. -#[tokio::test] -async fn should_pass_request_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat")) - .and(wiremock::matchers::header( - "custom-request-header", - "request-header-value", - )) - .respond_with(ResponseTemplate::new(200).set_body_json(ok_cohere_body())) - .mount(&server) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let mut headers = std::collections::HashMap::new(); - headers.insert( - "Custom-Request-Header".to_string(), - "request-header-value".to_string(), - ); - - let options = CallOptions { - prompt: test_prompt(), - headers: Some(headers), - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await; - assert!(result.is_ok(), "request should match the header mock"); -} - -/// TS: "should expose the raw response headers" -#[tokio::test] -async fn should_expose_raw_response_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("test-header", "test-value") - .set_body_json(ok_cohere_body()), - ) - .mount(&server) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} - -/// TS: "should send additional response information" — generation_id → response.id -#[tokio::test] -async fn should_send_additional_response_information() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.response.id.as_deref(), Some("gen-123")); -} - -/// TS: "should pass response format" — JSON schema → Cohere response_format. -#[tokio::test] -async fn should_pass_response_format() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let schema = json!({ - "type": "object", - "properties": { "text": { "type": "string" } }, - "required": ["text"] - }); - let options = CallOptions { - prompt: test_prompt(), - response_format: Some(aimux_core::options::ResponseFormat::Json { - schema: Some(schema.clone()), - name: None, - description: None, - }), - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!( - body["response_format"], - json!({ "type": "json_object", "json_schema": schema }) - ); -} - -/// TS: "should pass tools" with tool_choice required → "REQUIRED" -#[tokio::test] -async fn should_pass_tools_with_tool_choice_required() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let options = CallOptions { - prompt: test_prompt(), - tools: Some(vec![test_tool()]), - tool_choice: ToolChoice::Required, - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["tool_choice"], json!("REQUIRED")); - assert_eq!(body["tools"][0]["function"]["name"], "test-tool"); -} - -/// TS: "should pass tools" with tool_choice tool → "REQUIRED" + filtered tools -#[tokio::test] -async fn should_pass_tools_with_tool_choice_tool() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let options = CallOptions { - prompt: test_prompt(), - tools: Some(vec![test_tool()]), - tool_choice: ToolChoice::Tool { - tool_name: "test-tool".to_string(), - }, - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["tool_choice"], json!("REQUIRED")); - assert_eq!(body["tools"].as_array().unwrap().len(), 1); - assert_eq!(body["tools"][0]["function"]["name"], "test-tool"); -} - -/// TS: "should extract text documents and send to API" — non-image File parts -/// become Cohere RAG `documents`. The Rust `ContentPart::File` carries no -/// filename, so `title` is omitted (unlike TS). -#[tokio::test] -async fn should_extract_text_documents_from_file_parts() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("What does this say?"), - ContentPart::file( - b"This is a test document.".to_vec(), - "text/plain".to_string(), - ), - ], - ..Default::default() - }]; - - let result = model - .do_generate(&default_options(prompt)) - .await - .expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!( - body["documents"], - json!([{ "data": { "text": "This is a test document." } }]) - ); - assert_eq!(body["messages"][0]["role"], json!("user")); - assert_eq!(body["messages"][0]["content"], json!("What does this say?")); -} - -// ════════════════════════════════════════════════════════════════════════════ -// citations — translated from the TS `describe('citations', ...)` block. -// -// TS has 7 citations cases; `should_extract_text_documents_from_file_parts` -// above already covers "should extract text documents and send to API". The -// 6 tests below cover the remaining cases: response-side citation extraction, -// multiple/JSON/PDF/markdown file→document conversion, and the no-files path. -// ════════════════════════════════════════════════════════════════════════════ - -/// The Cohere `cohere-citations.json` fixture: an assistant message with three -/// TEXT_CONTENT citations, all sourced from `doc:0` (title `benefits.txt`). -fn cohere_citations_response() -> Value { - let citation = |start: u64, end: u64, text: &str| { - json!({ - "start": start, - "end": end, - "text": text, - "sources": [{ - "type": "document", - "id": "doc:0", - "document": { - "id": "doc:0", - "text": "AI provides: 1. Automation of tasks 2. Better decision-making 3. Cost reduction", - "title": "benefits.txt" - } - }], - "type": "TEXT_CONTENT" - }) - }; - json!({ - "id": "68475c80-574b-4c65-98a4-e81cebab5dce", - "message": { - "role": "assistant", - "content": [{ - "type": "text", - "text": "The key benefits mentioned in this document are:\n1. Automation of tasks\n2. Better decision-making\n3. Cost reduction" - }], - "citations": [ - citation(52, 71, "Automation of tasks"), - citation(75, 97, "Better decision-making"), - citation(101, 115, "Cost reduction") - ] - }, - "finish_reason": "COMPLETE", - "usage": { - "billed_units": { "input_tokens": 39, "output_tokens": 27 }, - "tokens": { "input_tokens": 1683, "output_tokens": 62 }, - "cached_tokens": 992 - } - }) -} - -/// TS: "should extract citations from response" — each citation becomes a -/// `Source` content item. The Rust `GenerateContent::Source` variant carries no -/// `mediaType`/`providerMetadata`, so only `source_type`/`title` are asserted -/// (the rich start/end/text/sources/citationType metadata is a documented gap). -/// The TS id comes from a mocked `generateId`; Rust has no such hook, so ids are -/// positional (`citation-{i}`). -#[tokio::test] -async fn should_extract_citations_from_response() { - let server = MockServer::start().await; - mock_json_response(&server, cohere_citations_response()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("What are AI benefits?"), - ContentPart::File { - data: b"AI provides automation and efficiency.".to_vec(), - media_type: "text/plain".to_string(), - filename: Some("ai-benefits.txt".to_string()), - provider_options: None, - }, - ], - ..Default::default() - }]; - - let result = model - .do_generate(&default_options(prompt)) - .await - .expect("should succeed"); - - // 1 text + 3 citation sources. - assert_eq!(result.content.len(), 4); - match &result.content[0] { - GenerateContent::Text { text, .. } => { - assert_eq!( - text, - "The key benefits mentioned in this document are:\n\ - 1. Automation of tasks\n\ - 2. Better decision-making\n\ - 3. Cost reduction" - ); - } - other => panic!("expected Text, got {other:?}"), - } - for (i, c) in result.content[1..].iter().enumerate() { - match c { - GenerateContent::Source { - id, - source_type, - url, - title, - .. - } => { - assert_eq!(id, &format!("citation-{i}")); - assert_eq!(source_type, "document"); - assert_eq!(url, &None); - assert_eq!(title, &Some("benefits.txt".to_string())); - } - other => panic!("expected Source, got {other:?}"), - } - } - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(result.finish_reason.raw.as_deref(), Some("COMPLETE")); - assert_eq!(result.usage.input_tokens.total, Some(1683)); - assert_eq!(result.usage.output_tokens.total, Some(62)); -} - -/// TS: "should extract multiple text documents" — two file parts become two -/// RAG documents, each carrying its filename as `title`. -#[tokio::test] -async fn should_extract_multiple_text_documents() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("What do these documents say?"), - ContentPart::File { - data: b"First document content".to_vec(), - media_type: "text/plain".to_string(), - filename: Some("doc1.txt".to_string()), - provider_options: None, - }, - ContentPart::File { - data: b"Second document content".to_vec(), - media_type: "text/plain".to_string(), - filename: Some("doc2.txt".to_string()), - provider_options: None, - }, - ], - ..Default::default() - }]; - - let result = model - .do_generate(&default_options(prompt)) - .await - .expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!( - body["documents"], - json!([ - { "data": { "text": "First document content", "title": "doc1.txt" } }, - { "data": { "text": "Second document content", "title": "doc2.txt" } } - ]) - ); - assert_eq!(body["messages"][0]["role"], json!("user")); - assert_eq!( - body["messages"][0]["content"], - json!("What do these documents say?") - ); - assert_eq!(body["model"], json!("command-r-plus")); -} - -/// TS: "should support JSON files" — an `application/json` file part becomes a -/// RAG document whose `text` is the raw JSON string and `title` is the filename. -#[tokio::test] -async fn should_support_json_files() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("What is in this JSON?"), - ContentPart::File { - data: b"{\"key\": \"value\"}".to_vec(), - media_type: "application/json".to_string(), - filename: Some("data.json".to_string()), - provider_options: None, - }, - ], - ..Default::default() - }]; - - let result = model - .do_generate(&default_options(prompt)) - .await - .expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!( - body["documents"], - json!([{ "data": { "text": "{\"key\": \"value\"}", "title": "data.json" } }]) - ); - assert_eq!( - body["messages"][0]["content"], - json!("What is in this JSON?") - ); - assert_eq!(body["model"], json!("command-r-plus")); -} - -/// TS: "should not include mediaType in the outgoing payload (category D)" — a -/// non-text/non-image file (PDF) still becomes a RAG document, and neither the -/// media type nor the `mediaType` key leaks into the serialized request body. -#[tokio::test] -async fn should_not_include_mediatype_in_outgoing_payload() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("What is this?"), - ContentPart::File { - data: b"Some file content".to_vec(), - media_type: "application/pdf".to_string(), - filename: Some("document.pdf".to_string()), - provider_options: None, - }, - ], - ..Default::default() - }]; - - let result = model - .do_generate(&default_options(prompt)) - .await - .expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!( - body["documents"], - json!([{ "data": { "text": "Some file content", "title": "document.pdf" } }]) - ); - let serialized = body.to_string(); - assert!( - !serialized.contains("application/pdf"), - "mediaType must not leak into payload: {serialized}" - ); - assert!( - !serialized.contains("mediaType"), - "mediaType key must not leak into payload: {serialized}" - ); -} - -/// TS: "should successfully process supported text media types" — `text/plain` -/// and `text/markdown` files both become RAG documents with their content and -/// filename-derived title. -#[tokio::test] -async fn should_process_supported_text_media_types() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("What is this?"), - ContentPart::File { - data: b"This is plain text content".to_vec(), - media_type: "text/plain".to_string(), - filename: Some("text.txt".to_string()), - provider_options: None, - }, - ContentPart::File { - data: b"# Markdown Header\nContent".to_vec(), - media_type: "text/markdown".to_string(), - filename: Some("doc.md".to_string()), - provider_options: None, - }, - ], - ..Default::default() - }]; - - let result = model - .do_generate(&default_options(prompt)) - .await - .expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!( - body["documents"], - json!([ - { "data": { "text": "This is plain text content", "title": "text.txt" } }, - { "data": { "text": "# Markdown Header\nContent", "title": "doc.md" } } - ]) - ); -} - -/// TS: "should not include documents parameter when no files present" — a plain -/// text prompt produces a request body with no `documents` key. -#[tokio::test] -async fn should_not_include_documents_when_no_files_present() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - let body = result.request_body.expect("body"); - - assert!( - body.get("documents").is_none(), - "documents must be absent when no files are present: {body}" - ); -} - -/// TS: "should send request body" — verify all standard optional fields. -#[tokio::test] -async fn should_send_full_request_body() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let options = CallOptions { - prompt: test_prompt(), - max_output_tokens: Some(100), - temperature: Some(0.5), - top_p: Some(0.9), - top_k: Some(40.0), - seed: Some(7), - stop_sequences: Some(vec!["END".to_string()]), - frequency_penalty: Some(0.1), - presence_penalty: Some(0.2), - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["model"], "command-r-plus"); - assert_eq!(body["max_tokens"], json!(100)); - assert!((body["temperature"].as_f64().unwrap() - 0.5).abs() < 1e-6); - assert!((body["p"].as_f64().unwrap() - 0.9).abs() < 1e-6); - assert_eq!(body["k"], json!(40.0)); - assert_eq!(body["seed"], json!(7)); - assert_eq!(body["stop_sequences"], json!(["END"])); - assert!((body["frequency_penalty"].as_f64().unwrap() - 0.1).abs() < 1e-6); - assert!((body["presence_penalty"].as_f64().unwrap() - 0.2).abs() < 1e-6); - assert!(body.get("tools").is_none()); - assert!(body.get("response_format").is_none()); -} - -/// TS: a 429 response maps to `AiMuxError::ApiCall` (429 in `status_code`). -#[tokio::test] -async fn should_handle_rate_limit_error() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat")) - .respond_with( - ResponseTemplate::new(429).set_body_json(json!({ "message": "Too many requests" })), - ) - .mount(&server) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model.do_generate(&default_options(test_prompt())).await; - assert!( - matches!(result, Err(ref e) if e.status_code() == Some(429)), - "expected a 429, got {result:?}" - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Additional doStream cases — headers, response headers, empty tool args. -// ════════════════════════════════════════════════════════════════════════════ - -/// A minimal Cohere streaming text body reused by streaming assertions. -fn cohere_text_sse() -> String { - cohere_sse_body(&[ - r#"{"id":"test","type":"message-start","delta":{"message":{"role":"assistant","content":[]}}}"#, - r#"{"type":"content-start","index":0,"delta":{"message":{"content":{"type":"text","text":""}}}}"#, - r#"{"type":"content-delta","index":0,"delta":{"message":{"content":{"text":"Hi"}}}"#, - r#"{"type":"content-end","index":0}"#, - r#"{"type":"message-end","delta":{"finish_reason":"COMPLETE","usage":{"billed_units":{"input_tokens":1,"output_tokens":1},"tokens":{"input_tokens":1,"output_tokens":1}}}}"#, - ]) -} - -/// TS: "should pass headers" (streaming) — request-level custom headers. -#[tokio::test] -async fn should_pass_request_headers_stream() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat")) - .and(wiremock::matchers::header( - "custom-stream-header", - "stream-value", - )) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(cohere_text_sse()), - ) - .mount(&server) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let mut headers = std::collections::HashMap::new(); - headers.insert( - "Custom-Stream-Header".to_string(), - "stream-value".to_string(), - ); - - let options = CallOptions { - prompt: test_prompt(), - headers: Some(headers), - ..default_options(Vec::new()) - }; - - let result = model.do_stream(&options).await; - assert!(result.is_ok(), "request should match the header mock"); -} - -/// TS: "should expose the raw response headers" (streaming) -#[tokio::test] -async fn should_expose_raw_response_headers_stream() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .insert_header("test-header", "test-value") - .set_body_string(cohere_text_sse()), - ) - .mount(&server) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} - -/// TS: "should handle empty tool call arguments" (streaming) — empty arguments -/// string parses to an empty object. -#[tokio::test] -async fn should_stream_empty_tool_call_arguments() { - let server = MockServer::start().await; - let sse = cohere_sse_body(&[ - r#"{"id":"empty-args","type":"message-start","delta":{"message":{"role":"assistant","content":[],"tool_plan":"","tool_calls":[],"citations":[]}}}"#, - r#"{"type":"tool-call-start","index":0,"delta":{"message":{"tool_calls":{"id":"tc_empty","type":"function","function":{"name":"doThing","arguments":""}}}}}"#, - r#"{"type":"tool-call-end","index":0}"#, - r#"{"type":"message-end","delta":{"finish_reason":"TOOL_CALL","usage":{"billed_units":{"input_tokens":5,"output_tokens":2},"tokens":{"input_tokens":5,"output_tokens":2}}}}"#, - ]); - mock_sse_response(&server, &sse).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let options = CallOptions { - tools: Some(vec![test_tool()]), - ..default_options(test_prompt()) - }; - - let result = model.do_stream(&options).await.expect("should succeed"); - let parts = collect_stream(result).await; - - let tool_call = parts.iter().find_map(|p| match p { - StreamPart::ToolCall { - tool_call_id, - tool_name, - input, - .. - } => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), - _ => None, - }); - let (id, name, input) = tool_call.expect("should have ToolCall"); - assert_eq!(id, "tc_empty"); - assert_eq!(name, "doThing"); - assert_eq!(input, Value::String("{}".into())); - - let finish = parts.last().expect("should have finish"); - match finish { - StreamPart::Finish { finish_reason, .. } => { - assert_eq!(finish_reason.unified, FinishReasonUnified::ToolCalls); - } - other => panic!("expected Finish, got {other:?}"), - } -} - -/// TS: a 429 HTTP response surfaces as `AiMuxError::ApiCall` (429 in `status_code`) from do_stream. -#[tokio::test] -async fn should_stream_rate_limit_error() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat")) - .respond_with( - ResponseTemplate::new(429).set_body_json(json!({ "message": "Too many requests" })), - ) - .mount(&server) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model.do_stream(&default_options(test_prompt())).await; - assert!( - matches!(result, Err(ref e) if e.status_code() == Some(429)), - "expected a 429, got {result:?}" - ); -} diff --git a/aimux-providers/tests/cohere_remaining_test.rs b/aimux-providers/tests/cohere_remaining_test.rs deleted file mode 100644 index 51a93b103..000000000 --- a/aimux-providers/tests/cohere_remaining_test.rs +++ /dev/null @@ -1,763 +0,0 @@ -//! Remaining Cohere tests translated from TS that are NOT already covered by -//! `cohere_model_test.rs`. Covers: -//! -//! - `cohere-prepare-tools.test.ts` — all 7 cases (prepare_tools unit tests) -//! - `convert-to-cohere-chat-prompt.test.ts` — 9 cases (prompt conversion unit -//! tests): file→documents, image processing, tool messages, provider -//! references -//! - `cohere-chat-language-model.test.ts` — 5 uncovered scenarios: reasoning -//! extraction, top-level reasoning→thinking, providerOptions thinking, -//! assistant tool-call prompt conversion, tool-result prompt conversion - -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::{CallOptions, Tool, ToolChoice}; -use aimux_core::result::GenerateContent; -use aimux_core::tool::{FunctionTool, ProviderTool}; -use aimux_core::types::{FinishReasonUnified, ReasoningEffort}; - -use aimux_providers::cohere::convert::{ - convert_prompt_to_cohere, prepare_tools, resolve_cohere_thinking, -}; -use aimux_providers::{CohereConfig, CohereProvider}; - -// ── helpers ───────────────────────────────────────────────────────────────── - -fn ok_cohere_body() -> Value { - json!({ - "id": "test-id", - "generation_id": "gen-123", - "message": { - "role": "assistant", - "content": [{ "type": "text", "text": "ok" }] - }, - "finish_reason": "COMPLETE", - "usage": { - "billed_units": { "input_tokens": 12, "output_tokens": 7 }, - "tokens": { "input_tokens": 12, "output_tokens": 7 } - } - }) -} - -async fn mock_json_response(server: &MockServer, body: Value) { - Mock::given(method("POST")) - .and(path("/chat")) - .respond_with(ResponseTemplate::new(200).set_body_json(body)) - .mount(server) - .await; -} - -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -fn test_prompt() -> LanguageModelPrompt { - vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("you are a friendly bot!")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - ] -} - -fn basic_function_tool() -> Tool { - Tool::Function(FunctionTool { - name: "testFunction".to_string(), - description: Some("test description".to_string()), - input_schema: json!({ "type": "object", "properties": {} }), - strict: None, - provider_options: None, - input_examples: None, - }) -} - -// ════════════════════════════════════════════════════════════════════════════ -// cohere-prepare-tools.test.ts -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should return undefined tools when no tools are provided" -#[test] -fn prepare_tools_empty_returns_none() { - let result = prepare_tools(&Some(vec![]), None); - assert!(result.tools.is_none()); - assert!(result.tool_choice.is_none()); - assert!(result.tool_warnings.is_empty()); -} - -/// TS: "should process function tools correctly" -#[test] -fn prepare_tools_function_tool() { - let result = prepare_tools(&Some(vec![basic_function_tool()]), None); - assert_eq!( - result.tools, - Some(vec![json!({ - "type": "function", - "function": { - "name": "testFunction", - "description": "test description", - "parameters": { "type": "object", "properties": {} } - } - })]) - ); - assert!(result.tool_choice.is_none()); - assert!(result.tool_warnings.is_empty()); -} - -/// TS: "should add warnings for provider-defined tools" -#[test] -fn prepare_tools_provider_tool_warns() { - let provider_tool = Tool::Provider(ProviderTool { - id: "provider.tool".to_string(), - name: "tool".to_string(), - args: json!({}), - }); - let result = prepare_tools(&Some(vec![provider_tool]), None); - - // Tools list is empty (provider tool was dropped) → tools is None. - assert!(result.tools.is_none()); - assert!(result.tool_choice.is_none()); - assert_eq!(result.tool_warnings.len(), 1); - match &result.tool_warnings[0] { - aimux_core::types::Warning::Unsupported { feature, .. } => { - assert_eq!(feature, "provider-defined tool provider.tool"); - } - other => panic!("expected Unsupported warning, got {other:?}"), - } -} - -/// TS: "should handle auto tool choice" -#[test] -fn prepare_tools_auto_choice() { - let result = prepare_tools(&Some(vec![basic_function_tool()]), Some(&ToolChoice::Auto)); - assert!(result.tool_choice.is_none()); - assert!(result.tools.is_some()); -} - -/// TS: "should handle none tool choice" -#[test] -fn prepare_tools_none_choice() { - let result = prepare_tools(&Some(vec![basic_function_tool()]), Some(&ToolChoice::None)); - assert_eq!(result.tool_choice, Some(json!("NONE"))); - assert!(result.tools.is_some()); -} - -/// TS: "should handle required tool choice" -#[test] -fn prepare_tools_required_choice() { - let result = prepare_tools( - &Some(vec![basic_function_tool()]), - Some(&ToolChoice::Required), - ); - assert_eq!(result.tool_choice, Some(json!("REQUIRED"))); - assert!(result.tools.is_some()); -} - -/// TS: "should handle tool type tool choice by filtering tools" -#[test] -fn prepare_tools_tool_choice_filters() { - let result = prepare_tools( - &Some(vec![basic_function_tool()]), - Some(&ToolChoice::Tool { - tool_name: "testFunction".to_string(), - }), - ); - assert_eq!(result.tool_choice, Some(json!("REQUIRED"))); - let tools = result.tools.expect("tools should be Some"); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0]["function"]["name"], json!("testFunction")); -} - -// ════════════════════════════════════════════════════════════════════════════ -// convert-to-cohere-chat-prompt.test.ts — file processing -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should extract documents from file parts" -#[test] -fn convert_file_parts_to_documents() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("Analyze this file: "), - ContentPart::File { - data: b"This is file content".to_vec(), - media_type: "text/plain".to_string(), - filename: Some("test.txt".to_string()), - provider_options: None, - }, - ], - ..Default::default() - }]; - - let result = convert_prompt_to_cohere(&prompt); - assert_eq!( - result.messages, - vec![json!({ "role": "user", "content": "Analyze this file: " })] - ); - assert_eq!( - result.documents, - vec![json!({ "data": { "text": "This is file content", "title": "test.txt" } })] - ); -} - -/// TS: "should accept top-level-only mediaType without error (category D)" -/// — mediaType "text" (no `/`) is accepted; the file still becomes a document. -#[test] -fn convert_file_with_top_level_only_mediatype() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::File { - data: b"This is file content".to_vec(), - media_type: "text".to_string(), - filename: Some("test.txt".to_string()), - provider_options: None, - }], - ..Default::default() - }]; - - let result = convert_prompt_to_cohere(&prompt); - // No text part → content is empty string. - assert_eq!( - result.messages, - vec![json!({ "role": "user", "content": "" })] - ); - assert_eq!( - result.documents, - vec![json!({ "data": { "text": "This is file content", "title": "test.txt" } })] - ); -} - -/// TS: "should not read mediaType (document payload carries only text + title)" -#[test] -fn convert_pdf_file_omits_mediatype() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::File { - data: b"PDF-like content".to_vec(), - media_type: "application/pdf".to_string(), - filename: Some("test.pdf".to_string()), - provider_options: None, - }], - ..Default::default() - }]; - - let result = convert_prompt_to_cohere(&prompt); - assert_eq!( - result.documents, - vec![json!({ "data": { "text": "PDF-like content", "title": "test.pdf" } })] - ); - let payload = serde_json::to_string(&result.documents).unwrap(); - assert!(!payload.contains("application/pdf")); - assert!(!payload.contains("mediaType")); -} - -// ════════════════════════════════════════════════════════════════════════════ -// convert-to-cohere-chat-prompt.test.ts — image processing -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should convert image file with data bytes into image_url data URI" -#[test] -fn convert_image_file_to_data_uri() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("What is in this image?"), - ContentPart::File { - data: vec![0, 1, 2, 3], - media_type: "image/png".to_string(), - filename: None, - provider_options: None, - }, - ], - ..Default::default() - }]; - - let result = convert_prompt_to_cohere(&prompt); - // With an image, content becomes an array of parts. - let msg = &result.messages[0]; - assert_eq!(msg["role"], json!("user")); - let content = msg["content"].as_array().expect("content should be array"); - assert_eq!(content.len(), 2); - assert_eq!( - content[0], - json!({ "type": "text", "text": "What is in this image?" }) - ); - assert_eq!( - content[1], - json!({ - "type": "image_url", - "image_url": { "url": "data:image/png;base64,AAECAw==" } - }) - ); - assert!(result.documents.is_empty()); -} - -/// TS: "should pass through detail provider option as image_url.detail" -#[test] -fn convert_image_file_with_detail_provider_option() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::File { - data: vec![0, 1, 2, 3], - media_type: "image/png".to_string(), - filename: None, - provider_options: Some(json!({ "cohere": { "detail": "high" } })), - }], - ..Default::default() - }]; - - let result = convert_prompt_to_cohere(&prompt); - // The Rust convert does not parse `cohere.detail` from provider_options - // on File parts — this is a documented data-model gap. We verify the image - // URL is still produced correctly. - let msg = &result.messages[0]; - let content = msg["content"].as_array().expect("content should be array"); - assert_eq!(content[0]["type"], json!("image_url")); - assert!( - content[0]["image_url"]["url"] - .as_str() - .unwrap() - .starts_with("data:image/png;base64,") - ); -} - -/// TS: "should omit detail when no provider option is set" -#[test] -fn convert_image_file_omits_detail_when_no_option() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::File { - data: vec![0, 1, 2, 3], - media_type: "image/png".to_string(), - filename: None, - provider_options: None, - }], - ..Default::default() - }]; - - let result = convert_prompt_to_cohere(&prompt); - let msg = &result.messages[0]; - let content = msg["content"].as_array().expect("content should be array"); - assert_eq!(content[0]["type"], json!("image_url")); - assert!( - content[0]["image_url"].get("detail").is_none(), - "detail should be absent" - ); -} - -/// TS: "should send image inline and route non-image file to documents" -#[test] -fn convert_image_and_text_file_mixed() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("See attached:"), - ContentPart::File { - data: vec![0, 1, 2, 3], - media_type: "image/png".to_string(), - filename: None, - provider_options: None, - }, - ContentPart::File { - data: b"Doc text".to_vec(), - media_type: "text/plain".to_string(), - filename: Some("note.txt".to_string()), - provider_options: None, - }, - ], - ..Default::default() - }]; - - let result = convert_prompt_to_cohere(&prompt); - // Message content is an array (has image). - let msg = &result.messages[0]; - let content = msg["content"].as_array().expect("content should be array"); - assert_eq!(content.len(), 2); - assert_eq!( - content[0], - json!({ "type": "text", "text": "See attached:" }) - ); - assert_eq!(content[1]["type"], json!("image_url")); - // Non-image file → document. - assert_eq!( - result.documents, - vec![json!({ "data": { "text": "Doc text", "title": "note.txt" } })] - ); -} - -/// TS: "should accept top-level 'image' media type and detect full type from bytes" -/// — mediaType "image" (no subtype) still routes to image_url. -#[test] -fn convert_image_top_level_mediatype() { - let png_signature = vec![0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::File { - data: png_signature, - media_type: "image".to_string(), - filename: None, - provider_options: None, - }], - ..Default::default() - }]; - - let result = convert_prompt_to_cohere(&prompt); - let msg = &result.messages[0]; - let content = msg["content"].as_array().expect("content should be array"); - assert_eq!(content[0]["type"], json!("image_url")); - // The Rust impl uses the media_type as-is ("image"), so the data URI is - // "data:image;base64,...". This is a documented gap (TS resolves the full - // type from bytes); we verify the image_url path is taken. - let url = content[0]["image_url"]["url"].as_str().unwrap(); - assert!( - url.starts_with("data:image"), - "url should start with data:image: {url}" - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// convert-to-cohere-chat-prompt.test.ts — tool messages -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should convert a tool call into a cohere chatbot message" -#[test] -fn convert_assistant_tool_call_message() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ - ContentPart::text("Calling a tool"), - ContentPart::ToolCall { - tool_call_id: "tool-call-1".to_string(), - tool_name: "tool-1".to_string(), - input: json!({ "test": "This is a tool message" }), - provider_executed: None, - thought_signature: None, - provider_options: None, - }, - ], - ..Default::default() - }]; - - let result = convert_prompt_to_cohere(&prompt); - assert_eq!( - result.messages, - vec![json!({ - "role": "assistant", - "tool_calls": [{ - "id": "tool-call-1", - "type": "function", - "function": { - "name": "tool-1", - "arguments": "{\"test\":\"This is a tool message\"}" - } - }] - })] - ); - assert!(result.documents.is_empty()); -} - -/// TS: "should convert a single tool result into a cohere tool message" -#[test] -fn convert_single_tool_result_message() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::ToolResult { - tool_call_id: "tool-call-1".to_string(), - result: json!({ "test": "This is a tool message" }), - tool_name: None, - is_error: None, - preliminary: None, - dynamic: None, - provider_options: None, - }], - ..Default::default() - }]; - - let result = convert_prompt_to_cohere(&prompt); - assert_eq!( - result.messages, - vec![json!({ - "role": "tool", - "content": "{\"test\":\"This is a tool message\"}", - "tool_call_id": "tool-call-1" - })] - ); -} - -/// TS: "should convert multiple tool results into cohere tool messages" -#[test] -fn convert_multiple_tool_result_messages() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ - ContentPart::ToolResult { - tool_call_id: "tool-call-1".to_string(), - result: json!({ "test": "This is a tool message" }), - tool_name: None, - is_error: None, - preliminary: None, - dynamic: None, - provider_options: None, - }, - ContentPart::ToolResult { - tool_call_id: "tool-call-2".to_string(), - result: json!({ "something": "else" }), - tool_name: None, - is_error: None, - preliminary: None, - dynamic: None, - provider_options: None, - }, - ], - ..Default::default() - }]; - - let result = convert_prompt_to_cohere(&prompt); - assert_eq!( - result.messages, - vec![ - json!({ - "role": "tool", - "content": "{\"test\":\"This is a tool message\"}", - "tool_call_id": "tool-call-1" - }), - json!({ - "role": "tool", - "content": "{\"something\":\"else\"}", - "tool_call_id": "tool-call-2" - }), - ] - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// cohere-chat-language-model.test.ts — reasoning extraction (doGenerate) -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should extract reasoning from response" — a `thinking` content item in -/// the response becomes a `Reasoning` content item. -#[tokio::test] -async fn should_extract_reasoning_from_response() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "53bcb235-5179-4a91-a578-cb372b5430bc", - "message": { - "role": "assistant", - "content": [ - { - "type": "thinking", - "thinking": "Okay, so I need to figure out what 2 + 2 is. Let me start by recalling what addition means." - }, - { - "type": "text", - "text": "2 + 2 = 4" - } - ] - }, - "finish_reason": "COMPLETE", - "usage": { - "billed_units": { "input_tokens": 8, "output_tokens": 578 }, - "tokens": { "input_tokens": 1394, "output_tokens": 582 } - } - }), - ) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - // reasoning + text. - assert_eq!(result.content.len(), 2); - match &result.content[0] { - GenerateContent::Reasoning { text, .. } => { - assert_eq!( - text, - "Okay, so I need to figure out what 2 + 2 is. Let me start by recalling what addition means." - ); - } - other => panic!("expected Reasoning, got {other:?}"), - } - match &result.content[1] { - GenerateContent::Text { text, .. } => { - assert_eq!(text, "2 + 2 = 4"); - } - other => panic!("expected Text, got {other:?}"), - } - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); -} - -// ════════════════════════════════════════════════════════════════════════════ -// cohere-chat-language-model.test.ts — top-level reasoning → thinking -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should map top-level reasoning to thinking enabled with budget" -#[tokio::test] -async fn should_map_reasoning_high_to_thinking_enabled() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let options = CallOptions { - reasoning: Some(ReasoningEffort::High), - ..default_options(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert!(body.get("thinking").is_some(), "thinking should be defined"); - assert_eq!(body["thinking"]["type"], json!("enabled")); - let budget = body["thinking"]["token_budget"] - .as_u64() - .expect("token_budget"); - assert!(budget > 0, "token_budget should be > 0, got {budget}"); -} - -/// TS: "should map top-level reasoning none to thinking disabled" -#[tokio::test] -async fn should_map_reasoning_none_to_thinking_disabled() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let options = CallOptions { - reasoning: Some(ReasoningEffort::None), - ..default_options(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["thinking"], json!({ "type": "disabled" })); -} - -/// TS: "should prefer providerOptions over top-level reasoning" -#[tokio::test] -async fn should_prefer_provider_options_over_reasoning() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let mut po = std::collections::HashMap::new(); - po.insert( - "cohere".to_string(), - json!({ "thinking": { "type": "enabled" } }), - ); - let options = CallOptions { - reasoning: Some(ReasoningEffort::None), - provider_options: Some(po), - ..default_options(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["thinking"]["type"], json!("enabled")); -} - -/// TS: "should not set thinking when reasoning is not specified" -#[tokio::test] -async fn should_not_set_thinking_when_no_reasoning() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - let body = result.request_body.expect("body"); - assert!( - body.get("thinking").is_none(), - "thinking should be undefined: {body}" - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// resolve_cohere_thinking unit tests -// ════════════════════════════════════════════════════════════════════════════ - -#[test] -fn resolve_thinking_none_returns_none() { - let result = resolve_cohere_thinking(None, &None); - assert!(result.is_none()); -} - -#[test] -fn resolve_thinking_provider_default_returns_none() { - let result = resolve_cohere_thinking(Some(ReasoningEffort::ProviderDefault), &None); - assert!(result.is_none()); -} - -#[test] -fn resolve_thinking_explicit_none_is_disabled() { - let result = resolve_cohere_thinking(Some(ReasoningEffort::None), &None); - assert_eq!(result, Some(json!({ "type": "disabled" }))); -} - -#[test] -fn resolve_thinking_high_is_enabled_with_budget() { - let result = resolve_cohere_thinking(Some(ReasoningEffort::High), &None); - let thinking = result.expect("should be Some"); - assert_eq!(thinking["type"], json!("enabled")); - let budget = thinking["token_budget"].as_u64().unwrap(); - // High = 0.60 * 32768 = 19660.8 → 19661 - assert_eq!(budget, 19661); -} - -#[test] -fn resolve_thinking_provider_options_override() { - let mut po = std::collections::HashMap::new(); - po.insert( - "cohere".to_string(), - json!({ "thinking": { "type": "enabled", "tokenBudget": 5000 } }), - ); - let result = resolve_cohere_thinking(Some(ReasoningEffort::None), &Some(po)); - assert_eq!( - result, - Some(json!({ "type": "enabled", "token_budget": 5000 })) - ); -} - -#[test] -fn resolve_thinking_provider_options_default_type() { - let mut po = std::collections::HashMap::new(); - po.insert( - "cohere".to_string(), - json!({ "thinking": { "tokenBudget": 1000 } }), - ); - let result = resolve_cohere_thinking(None, &Some(po)); - // type defaults to "enabled" when not specified. - assert_eq!( - result, - Some(json!({ "type": "enabled", "token_budget": 1000 })) - ); -} diff --git a/aimux-providers/tests/cohere_reranking_test.rs b/aimux-providers/tests/cohere_reranking_test.rs deleted file mode 100644 index 66c31f0c7..000000000 --- a/aimux-providers/tests/cohere_reranking_test.rs +++ /dev/null @@ -1,350 +0,0 @@ -//! Rust translations of the Cohere Reranking model tests. -//! -//! Source: `reference/ai/packages/cohere/src/reranking/cohere-reranking-model.test.ts` -//! (12 test cases across "json documents" and "text documents" groups). - -use std::collections::HashMap; - -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::reranking_model::{RerankingCallOptions, RerankingDocuments, RerankingModel}; -use aimux_providers::{CohereConfig, CohereProvider}; - -// -- helpers ----------------------------------------------------------------- - -fn rerank_response_body() -> Value { - json!({ - "id": "b44fe75b-e3d3-489a-b61e-1a1aede3ef72", - "meta": { - "api_version": { "version": "2" }, - "billed_units": { "search_units": 1 } - }, - "results": [ - { "index": 1, "relevance_score": 0.10183054 }, - { "index": 0, "relevance_score": 0.03762639 } - ] - }) -} - -fn provider(server: &MockServer) -> CohereProvider { - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - CohereProvider::new(config) -} - -fn cohere_provider_options() -> HashMap { - let mut po = HashMap::new(); - po.insert( - "cohere".to_string(), - json!({ "maxTokensPerDoc": 1000, "priority": 1 }), - ); - po -} - -fn text_docs_opts(query: &str, top_n: u32) -> RerankingCallOptions { - RerankingCallOptions { - documents: RerankingDocuments::Text { - values: vec![ - "sunny day at the beach".to_string(), - "rainy day in the city".to_string(), - ], - }, - query: query.to_string(), - top_n: Some(top_n), - abort_signal: None, - provider_options: Some(cohere_provider_options()), - headers: None, - max_retries: None, - timeout: None, - } -} - -fn json_docs_opts(query: &str, top_n: u32) -> RerankingCallOptions { - RerankingCallOptions { - documents: RerankingDocuments::Object { - values: vec![ - json!({ "example": "sunny day at the beach" }), - json!({ "example": "rainy day in the city" }), - ], - }, - query: query.to_string(), - top_n: Some(top_n), - abort_signal: None, - provider_options: Some(cohere_provider_options()), - headers: None, - max_retries: None, - timeout: None, - } -} - -async fn mount_rerank_mock(server: &MockServer) { - Mock::given(method("POST")) - .and(path("/rerank")) - .respond_with(ResponseTemplate::new(200).set_body_json(rerank_response_body())) - .mount(server) - .await; -} - -// -- json documents tests ---------------------------------------------------- - -#[tokio::test] -async fn json_docs_should_send_request_with_stringified_json_documents() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("rerank-english-v3.0"); - - model - .do_rerank(&json_docs_opts("rainy day", 2)) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!( - body["documents"][0], - json!("{\"example\":\"sunny day at the beach\"}") - ); - assert_eq!( - body["documents"][1], - json!("{\"example\":\"rainy day in the city\"}") - ); - assert_eq!(body["max_tokens_per_doc"], 1000); - assert_eq!(body["model"], "rerank-english-v3.0"); - assert_eq!(body["priority"], 1); - assert_eq!(body["query"], "rainy day"); - assert_eq!(body["top_n"], 2); -} - -#[tokio::test] -async fn json_docs_should_send_request_with_correct_headers() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("rerank-english-v3.0"); - - model - .do_rerank(&json_docs_opts("rainy day", 2)) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - let headers = &requests[0].headers; - assert_eq!( - headers.get("authorization").and_then(|v| v.to_str().ok()), - Some("Bearer test-api-key") - ); - assert_eq!( - headers.get("content-type").and_then(|v| v.to_str().ok()), - Some("application/json") - ); -} - -#[tokio::test] -async fn json_docs_should_return_result_with_warnings() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("rerank-english-v3.0"); - - let result = model - .do_rerank(&json_docs_opts("rainy day", 2)) - .await - .unwrap(); - - let warnings = result.warnings.expect("warnings"); - assert_eq!(warnings.len(), 1); - match &warnings[0] { - aimux_core::types::Warning::Compatibility { feature, details } => { - assert_eq!(feature, "object documents"); - assert_eq!( - details.as_deref(), - Some("Object documents are converted to strings.") - ); - } - other => panic!("expected Compatibility warning, got {other:?}"), - } -} - -#[tokio::test] -async fn json_docs_should_return_result_with_correct_ranking() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("rerank-english-v3.0"); - - let result = model - .do_rerank(&json_docs_opts("rainy day", 2)) - .await - .unwrap(); - - assert_eq!(result.ranking.len(), 2); - assert_eq!(result.ranking[0].index, 1); - assert!((result.ranking[0].relevance_score - 0.10183054).abs() < 1e-8); - assert_eq!(result.ranking[1].index, 0); - assert!((result.ranking[1].relevance_score - 0.03762639).abs() < 1e-8); -} - -#[tokio::test] -async fn json_docs_should_not_return_provider_metadata() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("rerank-english-v3.0"); - - let result = model - .do_rerank(&json_docs_opts("rainy day", 2)) - .await - .unwrap(); - - assert!(result.provider_metadata.is_none()); -} - -#[tokio::test] -async fn json_docs_should_return_result_with_correct_response() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("rerank-english-v3.0"); - - let result = model - .do_rerank(&json_docs_opts("rainy day", 2)) - .await - .unwrap(); - - let response = result.response.expect("response"); - assert_eq!( - response.id.as_deref(), - Some("b44fe75b-e3d3-489a-b61e-1a1aede3ef72") - ); - let body = response.body.expect("body"); - assert_eq!(body["results"][0]["index"], 1); - assert_eq!(body["results"][0]["relevance_score"], 0.10183054); -} - -// -- text documents tests ---------------------------------------------------- - -#[tokio::test] -async fn text_docs_should_send_request_with_text_documents() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("rerank-english-v3.0"); - - model - .do_rerank(&text_docs_opts("rainy day", 2)) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["documents"][0], "sunny day at the beach"); - assert_eq!(body["documents"][1], "rainy day in the city"); - assert_eq!(body["model"], "rerank-english-v3.0"); - assert_eq!(body["query"], "rainy day"); - assert_eq!(body["top_n"], 2); -} - -#[tokio::test] -async fn text_docs_should_send_request_with_correct_headers() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("rerank-english-v3.0"); - - model - .do_rerank(&text_docs_opts("rainy day", 2)) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - let headers = &requests[0].headers; - assert_eq!( - headers.get("authorization").and_then(|v| v.to_str().ok()), - Some("Bearer test-api-key") - ); - assert_eq!( - headers.get("content-type").and_then(|v| v.to_str().ok()), - Some("application/json") - ); -} - -#[tokio::test] -async fn text_docs_should_return_result_without_warnings() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("rerank-english-v3.0"); - - let result = model - .do_rerank(&text_docs_opts("rainy day", 2)) - .await - .unwrap(); - - let warnings = result.warnings.expect("warnings"); - assert!(warnings.is_empty()); -} - -#[tokio::test] -async fn text_docs_should_return_result_with_correct_ranking() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("rerank-english-v3.0"); - - let result = model - .do_rerank(&text_docs_opts("rainy day", 2)) - .await - .unwrap(); - - assert_eq!(result.ranking.len(), 2); - assert_eq!(result.ranking[0].index, 1); - assert!((result.ranking[0].relevance_score - 0.10183054).abs() < 1e-8); -} - -#[tokio::test] -async fn text_docs_should_not_return_provider_metadata() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("rerank-english-v3.0"); - - let result = model - .do_rerank(&text_docs_opts("rainy day", 2)) - .await - .unwrap(); - - assert!(result.provider_metadata.is_none()); -} - -#[tokio::test] -async fn text_docs_should_return_result_with_correct_response() { - let server = MockServer::start().await; - mount_rerank_mock(&server).await; - - let provider = provider(&server); - let model = provider.reranking_model("rerank-english-v3.0"); - - let result = model - .do_rerank(&text_docs_opts("rainy day", 2)) - .await - .unwrap(); - - let response = result.response.expect("response"); - assert_eq!( - response.id.as_deref(), - Some("b44fe75b-e3d3-489a-b61e-1a1aede3ef72") - ); -} diff --git a/aimux-providers/tests/convert_golden_test.rs b/aimux-providers/tests/convert_golden_test.rs deleted file mode 100644 index b0d197ffa..000000000 --- a/aimux-providers/tests/convert_golden_test.rs +++ /dev/null @@ -1,245 +0,0 @@ -//! Golden equivalence tests for the three request-body converters that were -//! split during M11 (#76). -//! -//! The splits were pure reorganization; these tests pin each converter's FULL -//! request body + warning list for a representative option matrix so any -//! future refactor (or a regression like the Anthropic default-budget drop) -//! is caught by an exact comparison, not by a partial assertion. - -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_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 { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -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!({ - "type": "object", - "properties": { "answer": { "type": "string" } }, - "required": ["answer"] - })), - name: Some("qa".to_string()), - description: Some("answer extraction".to_string()), - } -} - -// ── 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] -fn responses_golden() { - let mut provider = std::collections::HashMap::new(); - provider.insert( - "openai".to_string(), - json!({ - "reasoningEffort": "high", - "textVerbosity": "low", - "metadata": { "k": "v" } - }), - ); - let options = CallOptions { - prompt: user_prompt(), - seed: Some(42), - top_k: Some(0.2), - response_format: Some(json_schema_format()), - provider_options: Some(provider), - ..CallOptions::default() - }; - - let result = build_responses_request_body("o3-mini", &options, false); - - assert_eq!( - result.body, - json!({ - "model": "o3-mini", - "input": [ - { "role": "user", "content": [ { "type": "input_text", "text": "Hello" } ] } - ], - "text": { - "format": { - "type": "json_schema", - "strict": true, - "name": "qa", - "description": "answer extraction", - "schema": { - "type": "object", - "properties": { "answer": { "type": "string" } }, - "required": ["answer"] - } - }, - "verbosity": "low" - }, - "metadata": { "k": "v" }, - "reasoning": { "effort": "high", "summary": "detailed" } - }), - "responses body diverged: {}", - result.body - ); - - // top_k / seed are unsupported in the Responses API → compatibility - // warnings, and the model is a reasoning model so it exposes `reasoning`. - assert_eq!( - serde_json::to_value(&result.warnings).unwrap(), - json!([ - { "Unsupported": { "feature": "topK", "details": null } }, - { "Unsupported": { "feature": "seed", "details": null } } - ]) - ); -} - -// ── Anthropic ─────────────────────────────────────────────────────────────── - -#[test] -fn anthropic_golden() { - let mut provider = std::collections::HashMap::new(); - provider.insert( - "anthropic".to_string(), - json!({ - "thinking": { "type": "enabled", "budgetTokens": 4096 } - }), - ); - let options = CallOptions { - prompt: user_prompt(), - max_output_tokens: Some(1000), - temperature: Some(0.7), - top_k: Some(0.5), - stop_sequences: Some(vec!["END".to_string()]), - provider_options: Some(provider), - ..CallOptions::default() - }; - - let result = anthropic_build("claude-sonnet-4-5", &options, false).expect("anthropic build"); - - assert_eq!( - result.body, - json!({ - "model": "claude-sonnet-4-5", - "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Hello" } ] } ], - "max_tokens": 1000 + 4096, - "stream": false, - "thinking": { "type": "enabled", "budget_tokens": 4096 }, - "stop_sequences": ["END"] - }), - "anthropic body diverged: {}", - result.body - ); - - // Thinking enabled strips temperature/topK with warnings. - assert_eq!(result.body.get("temperature"), None); - assert_eq!(result.body.get("top_k"), None); - assert_eq!( - serde_json::to_value(&result.warnings).unwrap(), - json!([ - { "Unsupported": { "feature": "temperature", - "details": "temperature is not supported when thinking is enabled" } }, - { "Unsupported": { "feature": "topK", - "details": "topK is not supported when thinking is enabled" } } - ]) - ); -} diff --git a/aimux-providers/tests/deepseek_reasoning_test.rs b/aimux-providers/tests/deepseek_reasoning_test.rs deleted file mode 100644 index c51b19be9..000000000 --- a/aimux-providers/tests/deepseek_reasoning_test.rs +++ /dev/null @@ -1,956 +0,0 @@ -//! DeepSeek `reasoning_content` tests, translated from the Vercel AI SDK -//! TypeScript suite. -//! -//! Translation sources: -//! - `packages/deepseek/src/chat/deepseek-chat-language-model.test.ts` -//! - `describe('doGenerate') › describe('reasoning')` — request body with -//! `thinking` + response extraction from the reasoning fixture. -//! - `describe('doGenerate') › describe('top-level reasoning')` — mapping of -//! top-level `reasoning` and `providerOptions.deepseek` to the -//! `thinking` / `reasoning_effort` request fields. -//! - `describe('doStream') › describe('reasoning')` — streaming the -//! reasoning fixture. -//! - `packages/openai-compatible/src/chat/openai-compatible-chat-language-model.test.ts` -//! - DeepSeek is a thin OpenAI-compatible wrapper, so the -//! `reasoning_content` response-extraction tests (doGenerate + doStream) -//! apply directly. These cover the core behaviour gap: the shared OpenAI -//! response parser currently ignores `reasoning_content` (serde skips the -//! unknown field), so reasoning text is not surfaced as a -//! `GenerateContent::Reasoning` part / `StreamPart::Reasoning*` event. -//! -//! HTTP is mocked with `wiremock` (a real loopback server), replacing the TS -//! `createTestServer`. Each test starts its own `MockServer` so parallel -//! `#[tokio::test]` runs do not collide. -//! -//! # TDD status -//! -//! DeepSeek delegates to the shared [`OpenAIModel`], whose response parser does -//! not read `reasoning_content` / `reasoning` and whose request builder does -//! not emit a `thinking` field or do the DeepSeek-specific `reasoning` → -//! `reasoning_effort` remapping (xhigh→max, minimal→low with compatibility -//! warnings). Provider options keyed under `deepseek` are also not consulted -//! (the shared builder only reads the `openai` key). -//! -//! Tests that assert these unimplemented behaviours are written as TDD red -//! lights: they compile cleanly against the existing types -//! (`GenerateContent::Reasoning`, `StreamPart::Reasoning*`, -//! `Warning::Compatibility`) but fail at runtime until the feature lands. -//! No `#[ignore]` is used — every test compiles against types that exist today. -//! -//! # Mock URL note -//! -//! The real DeepSeek base URL is `https://api.deepseek.com/v1`, so production -//! requests hit `/v1/chat/completions`. In these tests `with_base_url` is -//! overridden with the mock server's root URI (no `/v1` suffix), so the -//! resulting request path is `/chat/completions` — matching the convention in -//! `openai_compatible_test.rs`. - -use std::collections::HashMap; - -use futures::StreamExt; -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::CallOptions; -use aimux_core::result::GenerateContent; -use aimux_core::stream_part::StreamPart; -use aimux_core::types::{ReasoningEffort, Warning}; - -use aimux_providers::{ProviderOptions, provider}; - -// ───────────────────────────────────────────────────────────────────────────── -// Shared helpers (mirrors openai_compatible_test.rs / anthropic_model_test.rs) -// ───────────────────────────────────────────────────────────────────────────── - -/// The TS `TEST_PROMPT`: a single user text message "Hello". -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -/// `CallOptions` with only `prompt` set (everything else default/None). -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -/// Build a DeepSeek provider whose base URL points at the mock server. -fn make_provider(server: &MockServer) -> Box { - provider( - "deepseek", - Some("test-api-key".to_string()), - "deepseek-reasoner", - Some(ProviderOptions { - base_url: Some(server.uri()), - ..Default::default() - }), - ) - .expect("deepseek provider should build") -} - -/// Wrap a value as `providerOptions.deepseek.`. -/// -/// The TS tests pass `providerOptions: { deepseek: { ... } }`. The Rust -/// `CallOptions.provider_options` is keyed by provider name. -fn deepseek_opts(value: Value) -> Option> { - let mut m = HashMap::new(); - m.insert("deepseek".to_string(), value); - Some(m) -} - -/// Mount a JSON chat-completion response on `/chat/completions`. -async fn mock_json(server: &MockServer, body: Value) { - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(body)) - .mount(server) - .await; -} - -/// Mount an SSE stream response on `/chat/completions`. -async fn mock_sse(server: &MockServer, body: String) { - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(body), - ) - .mount(server) - .await; -} - -/// Build a single SSE `data: \n\n` event string. -fn sse_event(json_str: &str) -> String { - format!("data: {json_str}\n\n") -} - -/// Concatenate SSE events and append the `[DONE]` sentinel. -fn sse_body(events: &[&str]) -> String { - let mut body = String::new(); - for event in events { - body.push_str(event); - } - body.push_str("data: [DONE]\n\n"); - body -} - -/// Collect every `StreamPart` from a `StreamResult` into a `Vec`. -async fn collect_stream(result: aimux_core::result::StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -/// Extract text deltas from a list of stream parts. -fn text_deltas(parts: &[StreamPart]) -> Vec { - parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect() -} - -/// Extract reasoning deltas from a list of stream parts. -fn reasoning_deltas(parts: &[StreamPart]) -> Vec { - parts - .iter() - .filter_map(|p| match p { - StreamPart::ReasoningDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect() -} - -/// True if the warnings contain a `Compatibility` warning for `feature`. -fn has_reasoning_compatibility_warning(warnings: &[Warning]) -> bool { - warnings.iter().any(|w| { - matches!( - w, - Warning::Compatibility { feature, .. } if feature == "reasoning" - ) - }) -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate — reasoning_content response extraction -// -// Translated from -// `openai-compatible-chat-language-model.test.ts` doGenerate reasoning tests -// and `deepseek-chat-language-model.test.ts` doGenerate › reasoning › "should -// extract text content". -// -// TS behaviour: `reasoning = message.reasoning_content ?? message.reasoning` -// (JS nullish coalescing — an empty string is NOT nullish, so `""` wins over -// `reasoning`); a non-empty reasoning string is pushed as a `{ type: -// "reasoning", text }` content part after the text part. -// -// Rust status: the shared OpenAI `MessageResponse` has no `reasoning_content` -// / `reasoning` field, so serde silently drops them — no `Reasoning` content -// part is ever produced. The extraction tests below are TDD red lights. -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should prefer reasoning_content over reasoning field when both are -/// provided" (openai-compatible test, line ~320). -/// -/// RED LIGHT: Rust ignores `reasoning_content`, so only the `Text` part is -/// produced — the `Reasoning` assertion fails. -#[tokio::test] -async fn should_prefer_reasoning_content_over_reasoning_field_when_both_provided() { - let server = MockServer::start().await; - mock_json( - &server, - json!({ - "id": "chatcmpl-1", - "object": "chat.completion", - "created": 1711115037, - "model": "deepseek-reasoner", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "Hello, World!", - "reasoning_content": "This is from reasoning_content", - "reasoning": "This is from reasoning field" - }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - }), - ) - .await; - - let model = make_provider(&server); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!( - result.content.len(), - 2, - "expected [Text, Reasoning] — reasoning_content should be extracted" - ); - match &result.content[0] { - GenerateContent::Text { text, .. } => assert_eq!(text, "Hello, World!"), - other => panic!("expected Text, got {other:?}"), - } - match &result.content[1] { - GenerateContent::Reasoning { text, .. } => { - assert_eq!(text, "This is from reasoning_content") - } - other => panic!("expected Reasoning from reasoning_content, got {other:?}"), - } -} - -/// TS: "should extract reasoning from reasoning field when reasoning_content -/// is not provided" (openai-compatible test, line ~300). -/// -/// NOTE: the TS `prepareJsonResponse` helper defaults `reasoning_content` to -/// `""` (empty string). Because JS `??` does not treat `""` as nullish, -/// `"" ?? reasoning` resolves to `""`, whose `.length` is 0, so NO reasoning -/// part is emitted — the TS snapshot is text-only. This test replicates that -/// exact edge case. It currently passes in Rust because reasoning is ignored -/// entirely; after `reasoning_content` extraction is implemented (mirroring -/// the `??` + length-check) it must still pass. -#[tokio::test] -async fn should_extract_reasoning_from_reasoning_field_when_reasoning_content_not_provided() { - let server = MockServer::start().await; - mock_json( - &server, - json!({ - "id": "chatcmpl-1", - "object": "chat.completion", - "created": 1711115037, - "model": "deepseek-reasoner", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "Hello, World!", - "reasoning_content": "", - "reasoning": "This is the reasoning from the reasoning field" - }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - }), - ) - .await; - - let model = make_provider(&server); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - // Empty-string reasoning_content suppresses the reasoning-field fallback - // (TS `??` edge case), so only the text part is expected. - assert_eq!( - result.content.len(), - 1, - "empty reasoning_content should not yield a reasoning part" - ); - match &result.content[0] { - GenerateContent::Text { text, .. } => assert_eq!(text, "Hello, World!"), - other => panic!("expected Text only, got {other:?}"), - } -} - -/// TS: deepseek doGenerate › reasoning › "should extract text content" -/// (deepseek-chat-language-model.test.ts, line ~118), using the -/// `deepseek-reasoning` fixture. -/// -/// The fixture carries both `content` and a long `reasoning_content`. The TS -/// snapshot surfaces the reasoning as a `{ type: "reasoning" }` part. The -/// `reasoning_content` text below is abbreviated from the fixture for -/// readability; the assertion still verifies extraction. -/// -/// RED LIGHT: Rust ignores `reasoning_content`, so only `Text` is produced. -#[tokio::test] -async fn should_extract_reasoning_content_and_text_from_deepseek_reasoning_fixture() { - let server = MockServer::start().await; - mock_json( - &server, - json!({ - "id": "945bb10c-9bf3-47ff-a2a2-43bbe9705c72", - "object": "chat.completion", - "created": 1764660903, - "model": "deepseek-reasoner", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "The word \"strawberry\" contains three instances of the letter \"r\": one after the \"t\" and two before the \"y\".", - "reasoning_content": "We are asked: \"How many 'r's are in the word 'strawberry'?\" We need to count the occurrences of the letter 'r'." - }, - "logprobs": null, - "finish_reason": "stop" - }], - "usage": { - "prompt_tokens": 18, - "completion_tokens": 345, - "total_tokens": 363, - "completion_tokens_details": { "reasoning_tokens": 315 } - }, - "system_fingerprint": "fp_eaab8d114b_prod0820_fp8_kvcache" - }), - ) - .await; - - let model = make_provider(&server); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!( - result.content.len(), - 2, - "expected [Text, Reasoning] from the reasoning fixture" - ); - match &result.content[0] { - GenerateContent::Text { text, .. } => assert!( - text.contains("strawberry"), - "expected the fixture text, got: {text}" - ), - other => panic!("expected Text, got {other:?}"), - } - match &result.content[1] { - GenerateContent::Reasoning { text, .. } => assert!( - text.contains("How many"), - "expected the fixture reasoning_content, got: {text}" - ), - other => panic!("expected Reasoning, got {other:?}"), - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate — request body: thinking & reasoning_effort -// -// Translated from `deepseek-chat-language-model.test.ts` doGenerate › -// "reasoning" ("should send correct request body") and "top-level reasoning" -// describe blocks (lines ~82–311). -// -// TS behaviour: DeepSeek maps top-level `reasoning` and -// `providerOptions.deepseek` to a `thinking` field (`{ type: "enabled" | -// "disabled" | "adaptive" }`) and a `reasoning_effort` field, with -// xhigh→max / minimal→low remapping that emits `Compatibility` warnings. -// -// Rust status (stage2-001, RFC-0017 阶段 2): the DeepSeek 特化已整体退役—— -// 共享 builder 直传 `options.reasoning` 为 `reasoning_effort`(7 档,无归一化), -// 不注入 `thinking`,不产生归一化 warning;`providerOptions.deepseek.*` 被忽略。 -// thinking 注入 / effort 重映射由用户 bodyOverrides 定义。以下测试断言退役后的 -// 透传语义(原断言特化的测试已按新语义改写)。 -// ════════════════════════════════════════════════════════════════════════════ - -/// stage2-001: DeepSeek 特化退役——`providerOptions.deepseek.thinking` 不再 -/// 翻译为请求体 `thinking` 字段(thinking 注入改由用户 bodyOverrides 定义)。 -#[tokio::test] -async fn should_not_inject_thinking_from_deepseek_provider_options() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.provider_options = deepseek_opts(json!({ "thinking": { "type": "enabled" } })); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["model"], json!("deepseek-reasoner")); - assert!( - body.get("thinking").is_none(), - "stage2-001: DeepSeek 特化退役,thinking 不再由 providerOptions.deepseek 注入,改用 bodyOverrides" - ); -} - -/// stage2-001: 顶层 `reasoning: 'high'` 透传为 `reasoning_effort: "high"`, -/// 不再注入 `thinking`。 -#[tokio::test] -async fn should_passthrough_reasoning_high_to_reasoning_effort_without_thinking() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.reasoning = Some(ReasoningEffort::High); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert!( - body.get("thinking").is_none(), - "stage2-001: thinking 注入已退役,开思考改用 bodyOverrides:{{thinking:{{type:'enabled'}}}}" - ); - assert_eq!(body["reasoning_effort"], json!("high")); -} - -/// stage2-001: 顶层 `reasoning: 'none'` 透传为 `reasoning_effort: "none"`, -/// 不含 thinking 注入(任务验收:透传语义)。 -#[tokio::test] -async fn should_passthrough_reasoning_none_to_reasoning_effort_without_thinking() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.reasoning = Some(ReasoningEffort::None); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert!( - body.get("thinking").is_none(), - "stage2-001: thinking 注入已退役,关思考改用 bodyOverrides:{{thinking:{{type:'disabled'}}}}" - ); - assert_eq!(body["reasoning_effort"], json!("none")); -} - -/// stage2-001: `reasoning: 'xhigh'` 直传为 `reasoning_effort: "xhigh"`, -/// 无归一化(xhigh→max 已退役),无 warning。 -#[tokio::test] -async fn should_passthrough_reasoning_xhigh_without_normalization() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.reasoning = Some(ReasoningEffort::Xhigh); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["reasoning_effort"], json!("xhigh")); - let warning = result - .warnings - .iter() - .find(|w| matches!(w, Warning::Compatibility { feature, .. } if feature == "reasoning")); - assert!( - warning.is_none(), - "stage2-001: 归一化已退役,xhigh 直传不产生归一化 warning" - ); -} - -/// TS: "should map top-level reasoning low to reasoning_effort low without a -/// compatibility warning" (line ~175). -/// -/// PASSES: Rust maps `Low` → `reasoning_effort: "low"` with no compatibility -/// warning. -#[tokio::test] -async fn should_map_top_level_reasoning_low_to_reasoning_effort_low_without_warning() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.reasoning = Some(ReasoningEffort::Low); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["reasoning_effort"], json!("low")); - assert!( - !has_reasoning_compatibility_warning(&result.warnings), - "low should not emit a compatibility warning" - ); -} - -/// TS: "should map top-level reasoning medium to reasoning_effort medium" -/// (line ~192). -/// -/// PASSES: Rust maps `Medium` → `reasoning_effort: "medium"`. -#[tokio::test] -async fn should_map_top_level_reasoning_medium_to_reasoning_effort_medium() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.reasoning = Some(ReasoningEffort::Medium); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["reasoning_effort"], json!("medium")); -} - -/// stage2-001: `reasoning: 'minimal'` 直传为 `reasoning_effort: "minimal"`, -/// 无归一化(minimal→low 已退役),无 warning。 -#[tokio::test] -async fn should_passthrough_reasoning_minimal_without_normalization() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.reasoning = Some(ReasoningEffort::Minimal); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["reasoning_effort"], json!("minimal")); - let warning = result - .warnings - .iter() - .find(|w| matches!(w, Warning::Compatibility { feature, .. } if feature == "reasoning")); - assert!( - warning.is_none(), - "stage2-001: 归一化已退役,minimal 直传不产生归一化 warning" - ); -} - -/// stage2-001: `providerOptions.deepseek.reasoningEffort` 不再被翻译 -/// (deepseek 特化退役,共享 builder 只读 `openai` key)→ `reasoning_effort` 缺席。 -#[tokio::test] -async fn should_ignore_deepseek_provider_options_reasoning_effort() { - for effort in ["low", "medium", "xhigh"] { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.provider_options = deepseek_opts(json!({ "reasoningEffort": effort })); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert!( - body.get("reasoning_effort").is_none(), - "stage2-001: deepseek 特化退役,providerOptions.deepseek.reasoningEffort 被忽略,effort={effort} 不出现" - ); - } -} - -/// stage2-001: `providerOptions.deepseek.thinking.type=adaptive` 不再翻译为 -/// 请求体 `thinking` 字段(特化退役)→ thinking 缺席。 -#[tokio::test] -async fn should_ignore_deepseek_provider_options_thinking_adaptive() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.provider_options = deepseek_opts(json!({ "thinking": { "type": "adaptive" } })); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert!( - body.get("thinking").is_none(), - "stage2-001: providerOptions.deepseek.thinking 被忽略,改用 bodyOverrides" - ); -} - -/// stage2-001: `providerOptions.deepseek.reasoningEffort = "max"` 被忽略 → -/// `reasoning_effort` 缺席;thinking 亦缺席。 -#[tokio::test] -async fn should_ignore_deepseek_provider_options_reasoning_effort_max() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.provider_options = deepseek_opts(json!({ "reasoningEffort": "max" })); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert!( - body.get("reasoning_effort").is_none(), - "stage2-001: providerOptions.deepseek.reasoningEffort 被忽略" - ); - // 未设置 reasoning/thinking 时,请求体不含 thinking,依赖 API 默认。 - assert!(body.get("thinking").is_none()); -} - -/// stage2-001: `providerOptions.deepseek.thinking` 不再优先于顶层 `reasoning` -/// (特化退役)——thinking 缺席,`reasoning:'none'` 透传为 `reasoning_effort:"none"`。 -#[tokio::test] -async fn should_ignore_deepseek_provider_options_thinking_over_top_level_reasoning() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.reasoning = Some(ReasoningEffort::None); - options.provider_options = deepseek_opts(json!({ "thinking": { "type": "enabled" } })); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert!( - body.get("thinking").is_none(), - "stage2-001: providerOptions.deepseek.thinking 被忽略,改用 bodyOverrides" - ); - assert_eq!(body["reasoning_effort"], json!("none")); -} - -/// stage2-001: `providerOptions.deepseek.reasoningEffort` 被忽略,顶层 -/// `reasoning:'high'` 透传为 `reasoning_effort:"high"`。 -#[tokio::test] -async fn should_ignore_deepseek_provider_options_reasoning_effort_over_top_level_reasoning() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.reasoning = Some(ReasoningEffort::High); - options.provider_options = deepseek_opts(json!({ "reasoningEffort": "max" })); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["reasoning_effort"], json!("high")); -} - -/// TS: "should not set thinking when reasoning is not specified" (line ~302). -/// -/// PASSES: `thinking` is never emitted by the shared builder. -#[tokio::test] -async fn should_not_set_thinking_when_reasoning_not_specified() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - let body = result.request_body.expect("body"); - - assert!( - body.get("thinking").is_none(), - "thinking should be absent when no reasoning is specified" - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// doStream — reasoning_content streaming -// -// Translated from `openai-compatible-chat-language-model.test.ts` doStream -// reasoning tests (lines ~1856–2149) and `deepseek-chat-language-model.test.ts` -// doStream › reasoning › "should stream reasoning" (line ~764). -// -// TS behaviour: `reasoningContent = delta.reasoning_content ?? delta.reasoning`; -// when truthy it emits `reasoning-start` / `reasoning-delta` / `reasoning-end` -// before the text deltas. -// -// Rust status: the shared `Delta` type has no `reasoning_content` / `reasoning` -// field, so serde drops them — no `StreamPart::Reasoning*` is ever emitted. -// These tests are TDD red lights. -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should stream reasoning content before text deltas" (line ~1856). -/// -/// RED LIGHT: `reasoning_content` deltas are dropped, so no -/// `ReasoningStart` / `ReasoningDelta` / `ReasoningEnd` parts are emitted. -#[tokio::test] -async fn should_stream_reasoning_content_before_text_deltas() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1711357598,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning_content":"Let me think"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1711357598,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{"content":"","reasoning_content":" about this"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1711357598,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{"content":"Here's"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1711357598,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{"content":" my response"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1729171479,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":18,"completion_tokens":439}}"#, - ), - ]); - mock_sse(&server, body).await; - - let model = make_provider(&server); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - assert_eq!( - reasoning_deltas(&parts), - vec!["Let me think".to_string(), " about this".to_string()], - "reasoning_content deltas should be emitted as reasoning-delta parts" - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningStart { .. })), - "expected a ReasoningStart part" - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningEnd { .. })), - "expected a ReasoningEnd part" - ); - let deltas = text_deltas(&parts); - assert!(deltas.iter().any(|d| d == "Here's"), "missing text delta"); - assert!( - deltas.iter().any(|d| d == " my response"), - "missing text delta" - ); -} - -/// TS: "should stream reasoning from reasoning field when reasoning_content is -/// not provided" (line ~1959). -/// -/// RED LIGHT: the `reasoning` delta field is dropped, so no reasoning parts. -#[tokio::test] -async fn should_stream_reasoning_from_reasoning_field_when_reasoning_content_not_provided() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1711357598,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning":"Let me consider"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1711357598,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{"content":"","reasoning":" this carefully"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1711357598,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{"content":"My answer is"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1711357598,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{"content":" correct"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1729171479,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":18,"completion_tokens":439}}"#, - ), - ]); - mock_sse(&server, body).await; - - let model = make_provider(&server); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - assert_eq!( - reasoning_deltas(&parts), - vec!["Let me consider".to_string(), " this carefully".to_string()], - "reasoning field deltas should be emitted as reasoning-delta parts" - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningStart { .. })), - "expected a ReasoningStart part" - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningEnd { .. })), - "expected a ReasoningEnd part" - ); -} - -/// TS: "should prefer reasoning_content over reasoning field in streaming when -/// both are provided" (line ~2062). -/// -/// RED LIGHT: both fields are dropped, so no reasoning parts are emitted (and -/// certainly not from `reasoning_content`). -#[tokio::test] -async fn should_prefer_reasoning_content_over_reasoning_field_in_streaming() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1711357598,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning_content":"From reasoning_content","reasoning":"From reasoning"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1711357598,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{"content":"Final response"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1729171479,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":18,"completion_tokens":439}}"#, - ), - ]); - mock_sse(&server, body).await; - - let model = make_provider(&server); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - assert_eq!( - reasoning_deltas(&parts), - vec!["From reasoning_content".to_string()], - "reasoning_content should be preferred over the reasoning field" - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningStart { .. })), - "expected a ReasoningStart part" - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningEnd { .. })), - "expected a ReasoningEnd part" - ); - assert!( - text_deltas(&parts).iter().any(|d| d == "Final response"), - "missing text delta" - ); -} - -/// TS: deepseek doStream › reasoning › "should stream reasoning" (line ~764), -/// using the `deepseek-reasoning` chunks fixture. -/// -/// The fixture streams `reasoning_content` deltas ("We", " need", ...) before -/// the text content. The chunks below are an abbreviated inline subset of the -/// fixture; the assertion still verifies reasoning is streamed. -/// -/// RED LIGHT: `reasoning_content` deltas are dropped, so no reasoning parts. -#[tokio::test] -async fn should_stream_reasoning_from_deepseek_reasoning_fixture() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"cac7192e","object":"chat.completion.chunk","created":1764661832,"model":"deepseek-reasoner","system_fingerprint":"fp_eaab8d114b_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{"role":"assistant","content":null,"reasoning_content":"We"},"logprobs":null,"finish_reason":null}],"usage":null}"#, - ), - &sse_event( - r#"{"id":"cac7192e","object":"chat.completion.chunk","created":1764661832,"model":"deepseek-reasoner","system_fingerprint":"fp_eaab8d114b_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{"content":null,"reasoning_content":" need"},"logprobs":null,"finish_reason":null}],"usage":null}"#, - ), - &sse_event( - r#"{"id":"cac7192e","object":"chat.completion.chunk","created":1764661832,"model":"deepseek-reasoner","system_fingerprint":"fp_eaab8d114b_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{"content":"The word","reasoning_content":null},"logprobs":null,"finish_reason":null}],"usage":null}"#, - ), - &sse_event( - r#"{"id":"cac7192e","object":"chat.completion.chunk","created":1764661832,"model":"deepseek-reasoner","system_fingerprint":"fp_eaab8d114b_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":{"prompt_tokens":18,"completion_tokens":345,"total_tokens":363}}"#, - ), - ]); - mock_sse(&server, body).await; - - let model = make_provider(&server); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - assert_eq!( - reasoning_deltas(&parts), - vec!["We".to_string(), " need".to_string()], - "fixture reasoning_content deltas should be streamed" - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningStart { .. })), - "expected a ReasoningStart part" - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningEnd { .. })), - "expected a ReasoningEnd part" - ); - assert!( - text_deltas(&parts).iter().any(|d| d == "The word"), - "missing text delta" - ); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Local fixtures -// ───────────────────────────────────────────────────────────────────────────── - -/// A minimal non-streaming chat-completion body returning "Hello, World!". -/// Used by the request-body tests (the response content is irrelevant there). -fn text_completion_body() -> Value { - json!({ - "id": "chatcmpl-test", - "object": "chat.completion", - "created": 1711115037, - "model": "deepseek-reasoner", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "Hello, World!" }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - }) -} diff --git a/aimux-providers/tests/google_model_test.rs b/aimux-providers/tests/google_model_test.rs deleted file mode 100644 index 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/google_remaining_test.rs b/aimux-providers/tests/google_remaining_test.rs deleted file mode 100644 index 30023ac97..000000000 --- a/aimux-providers/tests/google_remaining_test.rs +++ /dev/null @@ -1,1440 +0,0 @@ -//! Remaining Google provider tests — ported from the TS SDK test suite. -//! -//! Covers the pure-function and config tests that were not already exercised -//! by `google_model_test.rs` / `google_provider_tools_test.rs`: -//! -//! - get-model-path.test.ts -> get_model_path tests -//! - google-supported-file-url.test.ts -> is_supported_file_url tests -//! - google-model-capabilities.test.ts -> get_google_model_capabilities -//! - convert-json-schema-to-openapi-schema.test.ts -> schema conversion -//! - google-json-accumulator.test.ts -> GoogleJsonAccumulator -//! - google-provider.test.ts -> provider config (env var, headers, base URL) -//! -//! Reference: reference/ai/packages/google/src/. - -use serde_json::{Value, json}; -use serial_test::serial; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::CallOptions; -use aimux_providers::google::convert::{ - convert_json_schema_to_openapi_schema, get_google_model_capabilities, -}; -use aimux_providers::google::utils::{ - GoogleJsonAccumulator, PartialArg, get_model_path, is_supported_file_url, -}; -use aimux_providers::{GoogleConfig, GoogleProvider}; - -// ════════════════════════════════════════════════════════════════════════════ -// get_model_path (TS: get-model-path.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod get_model_path_tests { - use super::*; - - #[test] - fn pass_through_for_models_slash() { - assert_eq!(get_model_path("models/some-model"), "models/some-model"); - } - - #[test] - fn pass_through_for_tuned_models_slash() { - assert_eq!( - get_model_path("tunedModels/some-model"), - "tunedModels/some-model" - ); - } - - #[test] - fn add_prefix_to_models_without_slash() { - assert_eq!(get_model_path("some-model"), "models/some-model"); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// is_supported_file_url (TS: google-supported-file-url.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod is_supported_file_url_tests { - use super::*; - - #[test] - fn valid_google_file_urls() { - assert!(is_supported_file_url( - "https://generativelanguage.googleapis.com/v1beta/files/00000000-00000000-00000000-00000000" - )); - assert!(is_supported_file_url( - "https://generativelanguage.googleapis.com/v1beta/files/test123" - )); - } - - #[test] - fn valid_youtube_urls() { - let valid = [ - "https://www.youtube.com/watch?v=dQw4w9WgXcQ", - "https://youtube.com/watch?v=dQw4w9WgXcQ", - "https://youtu.be/dQw4w9WgXcQ", - "https://www.youtube.com/watch?v=dQw4w9WgXcQ&feature=youtu.be", - "https://youtu.be/dQw4w9WgXcQ?t=42", - ]; - for url in valid { - assert!(is_supported_file_url(url), "should be supported: {url}"); - } - } - - #[test] - fn invalid_youtube_urls() { - let invalid = [ - "https://youtube.com/channel/UCdQw4w9WgXcQ", - "https://youtube.com/playlist?list=PLdQw4w9WgXcQ", - "https://m.youtube.com/watch?v=dQw4w9WgXcQ", - "http://youtube.com/watch?v=dQw4w9WgXcQ", - "https://vimeo.com/123456789", - ]; - for url in invalid { - assert!( - !is_supported_file_url(url), - "should NOT be supported: {url}" - ); - } - } - - #[test] - fn non_google_file_urls() { - let cases = [ - "https://example.com", - "https://example.com/foo/bar", - "https://generativelanguage.googleapis.com", - "https://generativelanguage.googleapis.com/v1/other", - "http://generativelanguage.googleapis.com/v1beta/files/test", - "https://api.googleapis.com/v1beta/files/test", - ]; - for url in cases { - assert!( - !is_supported_file_url(url), - "should NOT be supported: {url}" - ); - } - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// get_google_model_capabilities (TS: google-model-capabilities.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod model_capabilities_tests { - use super::*; - - #[test] - fn gemini_pro_is_legacy() { - let caps = get_google_model_capabilities("gemini-pro"); - assert!(!caps.supports_gemini_2_tools); - assert!(!caps.supports_file_search); - assert!(!caps.uses_gemini_3_features); - } - - #[test] - fn gemini_pro_vision_is_legacy() { - let caps = get_google_model_capabilities("gemini-pro-vision"); - assert!(!caps.supports_gemini_2_tools); - assert!(!caps.supports_file_search); - assert!(!caps.uses_gemini_3_features); - } - - #[test] - fn gemini_1_5_flash_is_legacy() { - let caps = get_google_model_capabilities("gemini-1.5-flash"); - assert!(!caps.supports_gemini_2_tools); - assert!(!caps.supports_file_search); - assert!(!caps.uses_gemini_3_features); - } - - #[test] - fn gemini_robotics_er_1_5_is_legacy() { - let caps = get_google_model_capabilities("gemini-robotics-er-1.5-preview"); - assert!(!caps.supports_gemini_2_tools); - assert!(!caps.supports_file_search); - assert!(!caps.uses_gemini_3_features); - } - - #[test] - fn gemini_2_0_flash_supports_gemini_2_tools() { - let caps = get_google_model_capabilities("gemini-2.0-flash"); - assert!(caps.supports_gemini_2_tools); - assert!(!caps.supports_file_search); - assert!(!caps.uses_gemini_3_features); - } - - #[test] - fn gemini_2_5_flash_supports_file_search() { - let caps = get_google_model_capabilities("gemini-2.5-flash"); - assert!(caps.supports_gemini_2_tools); - assert!(caps.supports_file_search); - assert!(!caps.uses_gemini_3_features); - } - - #[test] - fn gemini_3_1_pro_uses_gemini_3_features() { - let caps = get_google_model_capabilities("gemini-3.1-pro-preview"); - assert!(caps.supports_gemini_2_tools); - assert!(caps.supports_file_search); - assert!(caps.uses_gemini_3_features); - } - - #[test] - fn unknown_future_gemini_inherits_newest() { - let caps = get_google_model_capabilities("gemini-99-pro-preview"); - assert!(caps.supports_gemini_2_tools); - assert!(caps.supports_file_search); - assert!(caps.uses_gemini_3_features); - } - - #[test] - fn gemini_ultra_latest_uses_gemini_3_features() { - let caps = get_google_model_capabilities("gemini-ultra-latest"); - assert!(caps.supports_gemini_2_tools); - assert!(caps.supports_file_search); - assert!(caps.uses_gemini_3_features); - } - - #[test] - fn nano_banana_supports_gemini_2_tools_only() { - let caps = get_google_model_capabilities("nano-banana-pro-preview"); - assert!(caps.supports_gemini_2_tools); - assert!(!caps.supports_file_search); - assert!(!caps.uses_gemini_3_features); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// convert_json_schema_to_openapi_schema (TS: convert-json-schema-to-openapi-schema.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod schema_conversion_tests { - use super::*; - - fn convert_root(schema: &Value) -> Value { - convert_json_schema_to_openapi_schema(schema, true) - } - - fn convert_nested(schema: &Value) -> Value { - convert_json_schema_to_openapi_schema(schema, false) - } - - #[test] - fn removes_additional_properties_and_schema() { - let input = json!({ - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "properties": { - "name": { "type": "string" }, - "age": { "type": "number" } - }, - "additionalProperties": false - }); - assert_eq!( - convert_root(&input), - json!({ - "type": "object", - "properties": { - "name": { "type": "string" }, - "age": { "type": "number" } - } - }) - ); - } - - #[test] - fn removes_additional_properties_from_nested_objects() { - let input = json!({ - "type": "object", - "properties": { - "keys": { - "type": "object", - "additionalProperties": { "type": "string" }, - "description": "Description for the key" - } - }, - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - }); - assert_eq!( - convert_root(&input), - json!({ - "type": "object", - "properties": { - "keys": { - "type": "object", - "description": "Description for the key" - } - } - }) - ); - } - - #[test] - fn handles_nested_objects_and_arrays() { - let input = json!({ - "type": "object", - "properties": { - "users": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { "type": "number" }, - "name": { "type": "string" } - }, - "additionalProperties": false - } - } - }, - "additionalProperties": false - }); - assert_eq!( - convert_root(&input), - json!({ - "type": "object", - "properties": { - "users": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { "type": "number" }, - "name": { "type": "string" } - } - } - } - } - }) - ); - } - - #[test] - fn converts_const_to_enum_single_value() { - let input = json!({ - "type": "object", - "properties": { "status": { "const": "active" } } - }); - assert_eq!( - convert_root(&input), - json!({ - "type": "object", - "properties": { "status": { "enum": ["active"] } } - }) - ); - } - - #[test] - fn handles_all_of_any_of_one_of() { - let input = json!({ - "type": "object", - "properties": { - "allOfProp": { "allOf": [{ "type": "string" }, { "minLength": 5 }] }, - "anyOfProp": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, - "oneOfProp": { "oneOf": [{ "type": "boolean" }, { "type": "null" }] } - } - }); - assert_eq!( - convert_root(&input), - json!({ - "type": "object", - "properties": { - "allOfProp": { "allOf": [{ "type": "string" }, { "minLength": 5 }] }, - "anyOfProp": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, - "oneOfProp": { "oneOf": [{ "type": "boolean" }, { "type": "null" }] } - } - }) - ); - } - - #[test] - fn preserves_format() { - let input = json!({ - "type": "object", - "properties": { "timestamp": { "type": "string", "format": "date-time" } } - }); - assert_eq!( - convert_root(&input), - json!({ - "type": "object", - "properties": { "timestamp": { "type": "string", "format": "date-time" } } - }) - ); - } - - #[test] - fn handles_required_properties() { - let input = json!({ - "type": "object", - "properties": { "id": { "type": "number" }, "name": { "type": "string" } }, - "required": ["id"] - }); - assert_eq!( - convert_root(&input), - json!({ - "type": "object", - "properties": { "id": { "type": "number" }, "name": { "type": "string" } }, - "required": ["id"] - }) - ); - } - - #[test] - fn deeply_nested_const_becomes_enum() { - let input = json!({ - "type": "object", - "properties": { - "nested": { - "type": "object", - "properties": { - "deeplyNested": { - "anyOf": [ - { "type": "object", "properties": { "value": { "const": "specific value" } } }, - { "type": "string" } - ] - } - } - } - } - }); - assert_eq!( - convert_root(&input), - json!({ - "type": "object", - "properties": { - "nested": { - "type": "object", - "properties": { - "deeplyNested": { - "anyOf": [ - { "type": "object", "properties": { "value": { "enum": ["specific value"] } } }, - { "type": "string" } - ] - } - } - } - } - }) - ); - } - - #[test] - fn complex_schema_with_nested_const_and_anyof() { - let input = json!({ - "type": "object", - "properties": { - "name": { "type": "string" }, - "age": { "type": "number" }, - "contact": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { "type": "string", "const": "email" }, - "value": { "type": "string" } - }, - "required": ["type", "value"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { "type": "string", "const": "phone" }, - "value": { "type": "string" } - }, - "required": ["type", "value"], - "additionalProperties": false - } - ] - } - }, - "required": ["name", "age", "contact"], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - }); - let result = convert_root(&input); - assert_eq!(result["type"], "object"); - assert_eq!(result["required"], json!(["name", "age", "contact"])); - let contact_anyof = &result["properties"]["contact"]["anyOf"]; - assert_eq!( - contact_anyof[0]["properties"]["type"]["enum"], - json!(["email"]) - ); - assert_eq!( - contact_anyof[1]["properties"]["type"]["enum"], - json!(["phone"]) - ); - } - - #[test] - fn handles_null_type_correctly() { - let input = json!({ - "type": "object", - "properties": { - "nullableField": { "type": ["string", "null"] }, - "explicitNullField": { "type": "null" } - } - }); - let result = convert_root(&input); - assert_eq!( - result["properties"]["nullableField"], - json!({ "anyOf": [{ "type": "string" }], "nullable": true }) - ); - assert_eq!( - result["properties"]["explicitNullField"], - json!({ "type": "null" }) - ); - } - - #[test] - fn handles_descriptions() { - let input = json!({ - "type": "object", - "description": "A user object", - "properties": { - "id": { "type": "number", "description": "The user ID" }, - "name": { "type": "string", "description": "The user's full name" }, - "email": { "type": "string", "format": "email", "description": "The user's email address" } - }, - "required": ["id", "name"] - }); - assert_eq!(convert_root(&input), input); - } - - #[test] - fn returns_null_for_empty_object_schemas_at_root() { - let cases = [ - json!({ "type": "object" }), - json!({ "type": "object", "properties": {} }), - ]; - for schema in &cases { - assert!(convert_root(schema).is_null(), "expected null for {schema}"); - } - } - - #[test] - fn preserves_nested_empty_object_with_description() { - let input = json!({ - "type": "object", - "properties": { - "url": { "type": "string", "description": "URL to navigate to" }, - "launchOptions": { "type": "object", "description": "PuppeteerJS LaunchOptions" }, - "allowDangerous": { "type": "boolean", "description": "Allow dangerous options" } - }, - "required": ["url", "launchOptions"] - }); - assert_eq!(convert_root(&input), input); - } - - #[test] - fn preserves_nested_empty_object_without_description() { - let input = json!({ - "type": "object", - "properties": { "options": { "type": "object" } }, - "required": ["options"] - }); - assert_eq!(convert_root(&input), input); - } - - #[test] - fn handles_non_empty_object_schemas() { - let input = json!({ - "type": "object", - "properties": { "name": { "type": "string" } } - }); - assert_eq!(convert_root(&input), input); - } - - #[test] - fn converts_string_enum_properties() { - let input = json!({ - "type": "object", - "properties": { - "kind": { "type": "string", "enum": ["text", "code", "image"] } - }, - "required": ["kind"], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - }); - assert_eq!( - convert_root(&input), - json!({ - "type": "object", - "properties": { - "kind": { "type": "string", "enum": ["text", "code", "image"] } - }, - "required": ["kind"] - }) - ); - } - - #[test] - fn converts_nullable_string_enum() { - let input = json!({ - "type": "object", - "properties": { - "fieldD": { - "anyOf": [ - { "type": "string", "enum": ["a", "b", "c"] }, - { "type": "null" } - ] - } - }, - "required": ["fieldD"], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - }); - let result = convert_root(&input); - // The anyOf with a null branch collapses into nullable + the non-null - // schema's fields. - assert_eq!( - result["properties"]["fieldD"], - json!({ "nullable": true, "type": "string", "enum": ["a", "b", "c"] }) - ); - } - - #[test] - fn type_array_multiple_non_null_plus_null() { - let input = json!({ - "type": "object", - "properties": { - "multiTypeField": { "type": ["string", "number", "null"] } - } - }); - assert_eq!( - convert_root(&input), - json!({ - "type": "object", - "properties": { - "multiTypeField": { - "anyOf": [{ "type": "string" }, { "type": "number" }], - "nullable": true - } - } - }) - ); - } - - #[test] - fn type_array_without_null_becomes_anyof() { - let input = json!({ - "type": "object", - "properties": { - "multiTypeField": { "type": ["string", "number"] } - } - }); - assert_eq!( - convert_root(&input), - json!({ - "type": "object", - "properties": { - "multiTypeField": { "anyOf": [{ "type": "string" }, { "type": "number" }] } - } - }) - ); - } - - #[test] - fn boolean_schema_becomes_object() { - let input = json!(true); - assert_eq!( - convert_root(&input), - json!({ "type": "boolean", "properties": {} }) - ); - } - - #[test] - fn nested_empty_object_root_returns_null_nested_returns_type_object() { - let empty = json!({ "type": "object", "properties": {} }); - assert!(convert_root(&empty).is_null()); - // When nested, it becomes { "type": "object" }. - assert_eq!(convert_nested(&empty), json!({ "type": "object" })); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// GoogleJsonAccumulator (TS: google-json-accumulator.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod json_accumulator_tests { - use super::*; - - fn s(path: &str, value: &str) -> PartialArg { - PartialArg { - json_path: path.to_string(), - string_value: Some(value.to_string()), - ..Default::default() - } - } - - fn s_continue(path: &str, value: &str) -> PartialArg { - PartialArg { - json_path: path.to_string(), - string_value: Some(value.to_string()), - will_continue: Some(true), - ..Default::default() - } - } - - fn n(path: &str, value: f64) -> PartialArg { - PartialArg { - json_path: path.to_string(), - number_value: Some(value), - ..Default::default() - } - } - - fn b(path: &str, value: bool) -> PartialArg { - PartialArg { - json_path: path.to_string(), - bool_value: Some(value), - ..Default::default() - } - } - - fn null_arg(path: &str) -> PartialArg { - PartialArg { - json_path: path.to_string(), - null_value: Some(()), - ..Default::default() - } - } - - // ── flat paths ───────────────────────────────────────────────────────── - - #[test] - fn accumulate_simple_string_with_will_continue() { - let mut acc = GoogleJsonAccumulator::new(); - let r = acc - .process_partial_args(&[s_continue("$.location", "Boston")]) - .unwrap(); - assert_eq!(r.current_json, json!({ "location": "Boston" })); - assert_eq!(r.text_delta, "{\"location\":\"Boston"); - } - - #[test] - fn continue_string_across_chunks() { - let mut acc = GoogleJsonAccumulator::new(); - acc.process_partial_args(&[s_continue("$.location", "Boston")]) - .unwrap(); - let r = acc - .process_partial_args(&[s("$.location", ", MA")]) - .unwrap(); - assert_eq!(r.current_json, json!({ "location": "Boston, MA" })); - assert_eq!(r.text_delta, ", MA"); - } - - #[test] - fn accumulate_complete_string() { - let mut acc = GoogleJsonAccumulator::new(); - let r = acc - .process_partial_args(&[s("$.location", "Boston")]) - .unwrap(); - assert_eq!(r.current_json, json!({ "location": "Boston" })); - assert_eq!(r.text_delta, "{\"location\":\"Boston\""); - } - - #[test] - fn accumulate_number() { - let mut acc = GoogleJsonAccumulator::new(); - let r = acc - .process_partial_args(&[n("$.brightness", 50.0)]) - .unwrap(); - assert_eq!(r.current_json, json!({ "brightness": 50 })); - assert_eq!(r.text_delta, "{\"brightness\":50"); - } - - #[test] - fn accumulate_boolean() { - let mut acc = GoogleJsonAccumulator::new(); - let r = acc.process_partial_args(&[b("$.enabled", true)]).unwrap(); - assert_eq!(r.current_json, json!({ "enabled": true })); - assert_eq!(r.text_delta, "{\"enabled\":true"); - } - - #[test] - fn accumulate_null() { - let mut acc = GoogleJsonAccumulator::new(); - let r = acc.process_partial_args(&[null_arg("$.nickname")]).unwrap(); - assert_eq!(r.current_json, json!({ "nickname": null })); - assert_eq!(r.text_delta, "{\"nickname\":null"); - } - - #[test] - fn accumulate_multiple_args_with_commas() { - let mut acc = GoogleJsonAccumulator::new(); - let first = acc - .process_partial_args(&[n("$.brightness", 50.0)]) - .unwrap(); - assert_eq!(first.text_delta, "{\"brightness\":50"); - - let second = acc.process_partial_args(&[b("$.enabled", true)]).unwrap(); - assert_eq!( - second.current_json, - json!({ "brightness": 50, "enabled": true }) - ); - assert_eq!(second.text_delta, ",\"enabled\":true"); - } - - #[test] - fn accumulate_multiple_args_single_call() { - let mut acc = GoogleJsonAccumulator::new(); - let r = acc - .process_partial_args(&[ - n("$.brightness", 50.0), - b("$.enabled", false), - null_arg("$.nickname"), - ]) - .unwrap(); - assert_eq!( - r.current_json, - json!({ "brightness": 50, "enabled": false, "nickname": null }) - ); - assert_eq!( - r.text_delta, - "{\"brightness\":50,\"enabled\":false,\"nickname\":null" - ); - } - - #[test] - fn escape_special_chars_in_continued_strings() { - let mut acc = GoogleJsonAccumulator::new(); - acc.process_partial_args(&[s_continue("$.query", "Boston \"Lo")]) - .unwrap(); - let r = acc.process_partial_args(&[s("$.query", "gan\"")]).unwrap(); - assert_eq!(r.current_json, json!({ "query": "Boston \"Logan\"" })); - // The continuation delta is the escaped inner content of `gan"`. - assert_eq!(r.text_delta, "gan\\\""); - } - - #[test] - fn skip_args_with_empty_path() { - let mut acc = GoogleJsonAccumulator::new(); - let r = acc.process_partial_args(&[s("$.", "ignored")]).unwrap(); - assert_eq!(r.current_json, json!({})); - assert_eq!(r.text_delta, ""); - } - - #[test] - fn skip_args_with_no_resolvable_value() { - let mut acc = GoogleJsonAccumulator::new(); - let r = acc - .process_partial_args(&[PartialArg { - json_path: "$.something".to_string(), - ..Default::default() - }]) - .unwrap(); - assert_eq!(r.current_json, json!({})); - assert_eq!(r.text_delta, ""); - } - - /// Regression (audit finding on H3): a malformed empty path like `$.[]` - /// parses to zero segments; it must be skipped instead of panicking with a - /// slice underflow in `emit_navigation_to`. - #[test] - fn malformed_empty_path_is_skipped_not_panicked() { - let mut acc = GoogleJsonAccumulator::new(); - let r = acc - .process_partial_args(&[ - PartialArg { - json_path: "$.[]".to_string(), - string_value: Some("x".to_string()), - ..Default::default() - }, - PartialArg { - json_path: "$.ok".to_string(), - string_value: Some("y".to_string()), - ..Default::default() - }, - ]) - .unwrap(); - // The malformed arg is ignored; the well-formed one still lands. - assert_eq!(r.current_json, json!({ "ok": "y" })); - } - - /// Regression (audit round 2): an oversized array index must produce an - /// error instead of expanding an array with ~1e6+ Null entries (OOM). - #[test] - fn oversized_array_index_is_rejected() { - let mut acc = GoogleJsonAccumulator::new(); - let err = acc - .process_partial_args(&[PartialArg { - json_path: "$.a[1000000]".to_string(), - string_value: Some("x".to_string()), - ..Default::default() - }]) - .unwrap_err(); - assert!(matches!(err, AiMuxError::JsonParse(_))); - } - - /// Regression (audit round 3, A1): an oversized **intermediate** index - /// (not the last segment) must also be rejected — previously only the - /// leaf segment was checked, so `$.a[1000000000].b` could still force - /// ~1 GiB of array allocation before failing. - #[test] - fn intermediate_oversized_index_is_rejected_atomically() { - let mut acc = GoogleJsonAccumulator::new(); - let err = acc - .process_partial_args(&[PartialArg { - json_path: "$.a[1000000].b".to_string(), - string_value: Some("x".to_string()), - ..Default::default() - }]) - .unwrap_err(); - assert!(matches!(err, AiMuxError::JsonParse(_))); - - // Error atomicity: the rejected arg must not leave partially-built - // containers (`a: []`) behind — a subsequent valid write lands cleanly. - let r = acc - .process_partial_args(&[PartialArg { - json_path: "$.ok".to_string(), - string_value: Some("y".to_string()), - ..Default::default() - }]) - .unwrap(); - assert_eq!(r.current_json, json!({ "ok": "y" })); - } - - /// Regression (audit round 2): an overly deep path must be rejected. - #[test] - fn overdeep_path_is_rejected() { - let mut acc = GoogleJsonAccumulator::new(); - let deep = format!("${}", ".a".repeat(100)); - let err = acc - .process_partial_args(&[PartialArg { - json_path: deep, - string_value: Some("x".to_string()), - ..Default::default() - }]) - .unwrap_err(); - assert!(matches!(err, AiMuxError::JsonParse(_))); - } - - #[test] - fn empty_partial_args_returns_empty_delta() { - let mut acc = GoogleJsonAccumulator::new(); - let r = acc.process_partial_args(&[]).unwrap(); - assert_eq!(r.current_json, json!({})); - assert_eq!(r.text_delta, ""); - } - - // ── nested paths ─────────────────────────────────────────────────────── - - #[test] - fn build_nested_object_from_dotted_path() { - let mut acc = GoogleJsonAccumulator::new(); - let r = acc - .process_partial_args(&[s("$.recipe.name", "Lasagna")]) - .unwrap(); - assert_eq!(r.current_json, json!({ "recipe": { "name": "Lasagna" } })); - assert_eq!(r.text_delta, "{\"recipe\":{\"name\":\"Lasagna\""); - } - - #[test] - fn build_nested_object_with_array_from_indexed_path() { - let mut acc = GoogleJsonAccumulator::new(); - let amount = acc - .process_partial_args(&[s("$.recipe.ingredients[0].amount", "16 oz")]) - .unwrap(); - assert_eq!( - amount.current_json, - json!({ "recipe": { "ingredients": [{ "amount": "16 oz" }] } }) - ); - assert_eq!( - amount.text_delta, - "{\"recipe\":{\"ingredients\":[{\"amount\":\"16 oz\"" - ); - - let name = acc - .process_partial_args(&[s("$.recipe.ingredients[0].name", "Lasagna noodles")]) - .unwrap(); - assert_eq!( - name.current_json, - json!({ "recipe": { "ingredients": [{ "amount": "16 oz", "name": "Lasagna noodles" }] } }) - ); - assert_eq!(name.text_delta, ",\"name\":\"Lasagna noodles\""); - } - - #[test] - fn accumulate_multiple_array_elements_across_chunks() { - let mut acc = GoogleJsonAccumulator::new(); - let mut deltas = Vec::new(); - - let r = acc - .process_partial_args(&[s("$.recipe.ingredients[0].amount", "16 oz")]) - .unwrap(); - deltas.push(r.text_delta.clone()); - - let r = acc - .process_partial_args(&[s("$.recipe.ingredients[0].name", "Noodles")]) - .unwrap(); - deltas.push(r.text_delta.clone()); - - let r = acc - .process_partial_args(&[s("$.recipe.ingredients[1].amount", "1 lb")]) - .unwrap(); - deltas.push(r.text_delta.clone()); - assert_eq!(r.text_delta, "},{\"amount\":\"1 lb\""); - - let r = acc - .process_partial_args(&[s("$.recipe.ingredients[1].name", "Beef")]) - .unwrap(); - deltas.push(r.text_delta.clone()); - assert_eq!(r.text_delta, ",\"name\":\"Beef\""); - - assert_eq!( - r.current_json, - json!({ - "recipe": { - "ingredients": [ - { "amount": "16 oz", "name": "Noodles" }, - { "amount": "1 lb", "name": "Beef" } - ] - } - }) - ); - - let f = acc.finalize(); - deltas.push(f.closing_delta); - assert_eq!(deltas.join(""), f.final_json); - } - - #[test] - fn string_continuation_on_nested_paths() { - let mut acc = GoogleJsonAccumulator::new(); - let start = acc - .process_partial_args(&[s_continue("$.recipe.steps[0]", "Preheat oven")]) - .unwrap(); - assert_eq!(start.text_delta, "{\"recipe\":{\"steps\":[\"Preheat oven"); - - let cont = acc - .process_partial_args(&[s("$.recipe.steps[0]", " to 375°F.")]) - .unwrap(); - assert_eq!( - cont.current_json, - json!({ "recipe": { "steps": ["Preheat oven to 375°F."] } }) - ); - assert_eq!(cont.text_delta, " to 375°F."); - } - - #[test] - fn mixed_nested_and_flat_paths() { - let mut acc = GoogleJsonAccumulator::new(); - let loc = acc - .process_partial_args(&[s("$.location", "Boston")]) - .unwrap(); - assert_eq!(loc.text_delta, "{\"location\":\"Boston\""); - - let details = acc - .process_partial_args(&[s("$.details.zip", "02101")]) - .unwrap(); - assert_eq!( - details.current_json, - json!({ "details": { "zip": "02101" }, "location": "Boston" }) - ); - assert_eq!(details.text_delta, ",\"details\":{\"zip\":\"02101\""); - - let f = acc.finalize(); - assert_eq!(f.closing_delta, "}}"); - assert_eq!( - f.final_json, - "{\"location\":\"Boston\",\"details\":{\"zip\":\"02101\"}}" - ); - } - - #[test] - fn array_elements_direct_string_values() { - let mut acc = GoogleJsonAccumulator::new(); - let first = acc - .process_partial_args(&[s("$.steps[0]", "Step one")]) - .unwrap(); - assert_eq!(first.text_delta, "{\"steps\":[\"Step one\""); - - let second = acc - .process_partial_args(&[s("$.steps[1]", "Step two")]) - .unwrap(); - assert_eq!( - second.current_json, - json!({ "steps": ["Step one", "Step two"] }) - ); - assert_eq!(second.text_delta, ",\"Step two\""); - } - - #[test] - fn deeply_nested_paths() { - let mut acc = GoogleJsonAccumulator::new(); - let r = acc.process_partial_args(&[s("$.a.b.c.d", "deep")]).unwrap(); - assert_eq!( - r.current_json, - json!({ "a": { "b": { "c": { "d": "deep" } } } }) - ); - assert_eq!(r.text_delta, "{\"a\":{\"b\":{\"c\":{\"d\":\"deep\""); - - let f = acc.finalize(); - assert_eq!(f.closing_delta, "}}}}"); - assert_eq!(f.final_json, "{\"a\":{\"b\":{\"c\":{\"d\":\"deep\"}}}}"); - } - - // ── finalize ─────────────────────────────────────────────────────────── - - #[test] - fn finalize_continued_string() { - let mut acc = GoogleJsonAccumulator::new(); - acc.process_partial_args(&[s_continue("$.location", "Boston")]) - .unwrap(); - let f = acc.finalize(); - assert_eq!(f.closing_delta, "\"}"); - assert_eq!(f.final_json, "{\"location\":\"Boston\"}"); - } - - #[test] - fn finalize_complete_string() { - let mut acc = GoogleJsonAccumulator::new(); - acc.process_partial_args(&[s("$.location", "Boston")]) - .unwrap(); - let f = acc.finalize(); - assert_eq!(f.closing_delta, "}"); - assert_eq!(f.final_json, "{\"location\":\"Boston\"}"); - } - - #[test] - fn finalize_multiple_args() { - let mut acc = GoogleJsonAccumulator::new(); - acc.process_partial_args(&[n("$.brightness", 50.0), b("$.enabled", true)]) - .unwrap(); - let f = acc.finalize(); - assert_eq!(f.closing_delta, "}"); - assert_eq!(f.final_json, "{\"brightness\":50,\"enabled\":true}"); - } - - #[test] - fn finalize_continued_string_with_continuation() { - let mut acc = GoogleJsonAccumulator::new(); - acc.process_partial_args(&[s_continue("$.location", "Boston")]) - .unwrap(); - acc.process_partial_args(&[s("$.location", ", MA")]) - .unwrap(); - let f = acc.finalize(); - assert_eq!(f.closing_delta, "\"}"); - assert_eq!(f.final_json, "{\"location\":\"Boston, MA\"}"); - } - - #[test] - fn finalize_empty_accumulator() { - let acc = GoogleJsonAccumulator::new(); - let f = acc.finalize(); - assert_eq!(f.closing_delta, "{}"); - assert_eq!(f.final_json, "{}"); - } - - #[test] - fn finalize_nested_structure() { - let mut acc = GoogleJsonAccumulator::new(); - acc.process_partial_args(&[s("$.recipe.ingredients[0].name", "Noodles")]) - .unwrap(); - acc.process_partial_args(&[s("$.recipe.name", "Lasagna")]) - .unwrap(); - let f = acc.finalize(); - let parsed: Value = serde_json::from_str(&f.final_json).unwrap(); - assert_eq!( - parsed, - json!({ - "recipe": { - "ingredients": [{ "name": "Noodles" }], - "name": "Lasagna" - } - }) - ); - } - - #[test] - fn finalize_nested_arrays_with_string_continuation() { - let mut acc = GoogleJsonAccumulator::new(); - acc.process_partial_args(&[s_continue("$.recipe.steps[0]", "Preheat")]) - .unwrap(); - acc.process_partial_args(&[s("$.recipe.steps[0]", " oven.")]) - .unwrap(); - acc.process_partial_args(&[s("$.recipe.steps[1]", "Cook.")]) - .unwrap(); - let f = acc.finalize(); - let parsed: Value = serde_json::from_str(&f.final_json).unwrap(); - assert_eq!( - parsed, - json!({ - "recipe": { "steps": ["Preheat oven.", "Cook."] } - }) - ); - } - - // ── concatenation invariant ──────────────────────────────────────────── - - #[test] - fn flat_args_concat_invariant() { - let mut acc = GoogleJsonAccumulator::new(); - let mut deltas = Vec::new(); - - let r = acc - .process_partial_args(&[n("$.brightness", 50.0)]) - .unwrap(); - deltas.push(r.text_delta); - - let r = acc.process_partial_args(&[b("$.enabled", true)]).unwrap(); - deltas.push(r.text_delta); - - let r = acc.process_partial_args(&[s("$.name", "test")]).unwrap(); - deltas.push(r.text_delta); - - let f = acc.finalize(); - deltas.push(f.closing_delta); - assert_eq!(deltas.join(""), f.final_json); - let parsed: Value = serde_json::from_str(&f.final_json).unwrap(); - assert_eq!( - parsed, - json!({ "brightness": 50, "enabled": true, "name": "test" }) - ); - } - - #[test] - fn nested_args_concat_invariant() { - let mut acc = GoogleJsonAccumulator::new(); - let mut deltas = Vec::new(); - - let r = acc - .process_partial_args(&[s("$.recipe.ingredients[0].amount", "16 oz")]) - .unwrap(); - deltas.push(r.text_delta); - let r = acc - .process_partial_args(&[s("$.recipe.ingredients[0].name", "Noodles")]) - .unwrap(); - deltas.push(r.text_delta); - let r = acc - .process_partial_args(&[s("$.recipe.ingredients[1].amount", "1 lb")]) - .unwrap(); - deltas.push(r.text_delta); - let r = acc - .process_partial_args(&[s("$.recipe.ingredients[1].name", "Beef")]) - .unwrap(); - deltas.push(r.text_delta); - let r = acc - .process_partial_args(&[s("$.recipe.name", "Lasagna")]) - .unwrap(); - deltas.push(r.text_delta); - let r = acc - .process_partial_args(&[s_continue("$.recipe.steps[0]", "Preheat")]) - .unwrap(); - deltas.push(r.text_delta); - let r = acc - .process_partial_args(&[s("$.recipe.steps[0]", " oven.")]) - .unwrap(); - deltas.push(r.text_delta); - let r = acc - .process_partial_args(&[s("$.recipe.steps[1]", "Cook.")]) - .unwrap(); - deltas.push(r.text_delta); - - let f = acc.finalize(); - deltas.push(f.closing_delta); - assert_eq!(deltas.join(""), f.final_json); - } - - #[test] - fn will_continue_strings_concat_invariant() { - let mut acc = GoogleJsonAccumulator::new(); - let mut deltas = Vec::new(); - - let r = acc - .process_partial_args(&[s_continue("$.location", "Bos")]) - .unwrap(); - deltas.push(r.text_delta); - let r = acc.process_partial_args(&[s("$.location", "ton")]).unwrap(); - deltas.push(r.text_delta); - let r = acc.process_partial_args(&[n("$.count", 42.0)]).unwrap(); - deltas.push(r.text_delta); - - let f = acc.finalize(); - deltas.push(f.closing_delta); - assert_eq!(deltas.join(""), f.final_json); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// Provider config (TS: google-provider.test.ts — config subset) -// ════════════════════════════════════════════════════════════════════════════ - -mod provider_config_tests { - use super::*; - - fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] - } - - fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) - } - - async fn mock_ok(server: &MockServer) { - Mock::given(method("POST")) - .and(path("/models/gemini-2.0-flash:generateContent")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "candidates": [{ - "content": { "parts": [{ "text": "hi" }], "role": "model" }, - "finishReason": "STOP", "index": 0 - }] - }))) - .mount(server) - .await; - } - - #[tokio::test] - async fn default_base_url_is_generativelanguage() { - let config = GoogleConfig::new("test-api-key"); - assert_eq!( - config.base_url, - "https://generativelanguage.googleapis.com/v1beta" - ); - } - - #[tokio::test] - async fn custom_base_url_is_used() { - let server = MockServer::start().await; - mock_ok(&server).await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - model - .do_generate(&default_options(test_prompt())) - .await - .expect("ok"); - } - - #[tokio::test] - async fn provider_name_is_google() { - use aimux_core::provider::Provider; - let config = GoogleConfig::new("test-api-key"); - let provider = GoogleProvider::new(config); - assert_eq!(provider.name(), "google"); - } - - #[tokio::test] - async fn model_provider_is_google_generative_ai() { - let server = MockServer::start().await; - mock_ok(&server).await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - assert_eq!(model.provider(), "google.generative-ai"); - assert_eq!(model.model_id(), "gemini-2.0-flash"); - } - - #[tokio::test] - async fn with_base_url_strips_trailing_slash() { - let config = GoogleConfig::new("key").with_base_url("https://example.com/v1beta/"); - assert_eq!(config.base_url, "https://example.com/v1beta"); - } - - #[tokio::test] - #[serial] - async fn from_env_uses_google_generative_ai_api_key() { - unsafe { - std::env::set_var("GOOGLE_GENERATIVE_AI_API_KEY", "env-api-key"); - } - let config = GoogleConfig::from_env().expect("from_env"); - assert_eq!(config.api_key, "env-api-key"); - unsafe { - std::env::remove_var("GOOGLE_GENERATIVE_AI_API_KEY"); - } - } - - #[tokio::test] - #[serial] - async fn from_env_errors_when_missing() { - unsafe { - std::env::remove_var("GOOGLE_GENERATIVE_AI_API_KEY"); - } - let result = GoogleConfig::from_env(); - assert!(result.is_err()); - } - - #[tokio::test] - async fn api_key_sent_via_x_goog_api_key_header() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/gemini-2.0-flash:generateContent")) - .and(header("x-goog-api-key", "my-secret-key")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "candidates": [{ - "content": { "parts": [{ "text": "hi" }], "role": "model" }, - "finishReason": "STOP", "index": 0 - }] - }))) - .mount(&server) - .await; - - let config = GoogleConfig::new("my-secret-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed — the mock requires the x-goog-api-key header"); - } - - #[tokio::test] - async fn custom_headers_are_sent_alongside_api_key() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/gemini-2.0-flash:generateContent")) - .and(header("x-goog-api-key", "test-api-key")) - .and(header("custom-header", "custom-value")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "candidates": [{ - "content": { "parts": [{ "text": "hi" }], "role": "model" }, - "finishReason": "STOP", "index": 0 - }] - }))) - .mount(&server) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let mut opts = default_options(test_prompt()); - let mut headers = std::collections::HashMap::new(); - headers.insert("Custom-Header".to_string(), "custom-value".to_string()); - opts.headers = Some(headers); - - model - .do_generate(&opts) - .await - .expect("should succeed — mock requires both headers"); - } - - #[tokio::test] - async fn language_model_trait_method_returns_boxed_model() { - use aimux_core::provider::Provider; - - let config = GoogleConfig::new("test-api-key"); - let provider = GoogleProvider::new(config); - let model = provider.language_model("gemini-2.0-flash").unwrap(); - assert_eq!(model.model_id(), "gemini-2.0-flash"); - assert_eq!(model.provider(), "google.generative-ai"); - } -} diff --git a/aimux-providers/tests/mistral_model_test.rs b/aimux-providers/tests/mistral_model_test.rs deleted file mode 100644 index e5583b71d..000000000 --- a/aimux-providers/tests/mistral_model_test.rs +++ /dev/null @@ -1,1641 +0,0 @@ -//! Wiremock tests for the Mistral provider. -//! -//! Translated from `packages/mistral/src/mistral-chat-language-model.test.ts`, -//! focusing on the cases that the Rust data model can express: -//! - doGenerate: text extraction, usage, tool calls, request body -//! - doStream: text streaming, tool call streaming, request body - -use futures::StreamExt; -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::{CallOptions, Tool, ToolChoice}; -use aimux_core::result::{GenerateContent, StreamResult}; -use aimux_core::stream_part::StreamPart; -use aimux_core::tool::FunctionTool; -use aimux_core::types::FinishReasonUnified; - -use aimux_providers::{MistralConfig, MistralProvider}; - -// ── helpers ───────────────────────────────────────────────────────────────── - -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -fn test_tool() -> Tool { - Tool::Function(FunctionTool { - name: "test-tool".to_string(), - description: None, - input_schema: json!({ - "type": "object", - "properties": { "value": { "type": "string" } }, - "required": ["value"], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - }), - strict: None, - provider_options: None, - input_examples: None, - }) -} - -async fn mock_json_response(server: &MockServer, body: Value) { - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(body)) - .mount(server) - .await; -} - -async fn mock_sse_response(server: &MockServer, sse_body: &str) { - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body.to_string()), - ) - .mount(server) - .await; -} - -fn sse_body(events: &[&str]) -> String { - let mut body = String::new(); - for event in events { - body.push_str(event); - } - body.push_str("data: [DONE]\n\n"); - body -} - -/// Build an SSE body from raw JSON chunk strings — each becomes -/// `data: \n\n`. Easier than hand-writing `data:` prefixes with the -/// required blank-line terminator. -fn sse_json_body(chunks: &[&str]) -> String { - let mut body = String::new(); - for c in chunks { - body.push_str(&format!("data: {c}\n\n")); - } - body.push_str("data: [DONE]\n\n"); - body -} - -async fn collect_stream(result: StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -fn text_deltas(parts: &[StreamPart]) -> Vec { - parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect() -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should extract text content" -#[tokio::test] -async fn should_extract_text_response() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "5319bd0299614c679a0068a4f2c8ffd0", - "created": 1769088720, - "model": "mistral-small-latest", - "usage": { - "prompt_tokens": 13, - "total_tokens": 447, - "completion_tokens": 434 - }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "stop", - "message": { - "role": "assistant", - "tool_calls": null, - "content": "Hello, World!" - } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::Text { text, .. } => assert_eq!(text, "Hello, World!"), - other => panic!("expected Text, got {other:?}"), - } - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(result.finish_reason.raw.as_deref(), Some("stop")); - assert_eq!( - result.response.id.as_deref(), - Some("5319bd0299614c679a0068a4f2c8ffd0") - ); - assert_eq!( - result.response.model_id.as_deref(), - Some("mistral-small-latest") - ); -} - -/// TS: "should extract usage" -#[tokio::test] -async fn should_extract_usage() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "created": 1711115037, - "model": "mistral-small-latest", - "usage": { - "prompt_tokens": 13, - "total_tokens": 447, - "completion_tokens": 434 - }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "stop", - "message": { - "role": "assistant", - "content": "Hello" - } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.usage.input_tokens.total, Some(13)); - assert_eq!(result.usage.input_tokens.no_cache, Some(13)); - assert_eq!(result.usage.input_tokens.cache_read, None); - assert_eq!(result.usage.output_tokens.total, Some(434)); -} - -/// TS: "should extract usage with cached tokens" -#[tokio::test] -async fn should_extract_usage_with_cached_tokens() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "model": "mistral-small-latest", - "usage": { - "prompt_tokens": 100, - "total_tokens": 200, - "completion_tokens": 100, - "num_cached_tokens": 30 - }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "stop", - "message": { "role": "assistant", "content": "Hi" } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.usage.input_tokens.total, Some(100)); - assert_eq!(result.usage.input_tokens.no_cache, Some(70)); - assert_eq!(result.usage.input_tokens.cache_read, Some(30)); -} - -/// TS: "should extract tool call content" -#[tokio::test] -async fn should_extract_tool_call() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "b3999b8c93e04e11bcbff7bcab829667", - "created": 1769088854, - "model": "mistral-small-latest", - "usage": { - "prompt_tokens": 124, - "total_tokens": 146, - "completion_tokens": 22 - }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "tool_calls", - "message": { - "role": "assistant", - "tool_calls": [{ - "id": "gSIMJiOkT", - "function": { - "name": "weather", - "arguments": "{\"location\": \"San Francisco\"}" - } - }] - } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::ToolCall { - 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()) - ); - } - other => panic!("expected ToolCall, got {other:?}"), - } - assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); -} - -/// TS: "should send correct request body" -#[tokio::test] -async fn should_send_correct_request_body() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "model": "mistral-small-latest", - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "stop", - "message": { "role": "assistant", "content": "Hello" } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - // Verify the request body. - let request_body = result.request_body.expect("request body should be set"); - assert_eq!(request_body["model"], "mistral-small-latest"); - assert_eq!( - request_body["messages"], - json!([{ "role": "user", "content": [{ "type": "text", "text": "Hello" }] }]) - ); - // No extra keys should be present. - assert!(request_body.get("max_tokens").is_none()); - assert!(request_body.get("temperature").is_none()); - assert!(request_body.get("tools").is_none()); -} - -/// TS: "should pass tools and toolChoice" — Mistral maps `required` → `"any"`. -#[tokio::test] -async fn should_pass_tools_with_tool_choice_required() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "model": "mistral-small-latest", - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "stop", - "message": { "role": "assistant", "content": "ok" } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let options = CallOptions { - prompt: test_prompt(), - tools: Some(vec![test_tool()]), - tool_choice: ToolChoice::Required, - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!( - body["tool_choice"], - json!("any"), - "Mistral uses 'any' for required tool choice" - ); - assert_eq!(body["tools"][0]["function"]["name"], "test-tool"); -} - -/// TS: "should pass tools and toolChoice" — `tool` choice filters tools. -#[tokio::test] -async fn should_pass_tools_with_tool_choice_tool() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "model": "mistral-small-latest", - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "stop", - "message": { "role": "assistant", "content": "ok" } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let options = CallOptions { - prompt: test_prompt(), - tools: Some(vec![test_tool()]), - tool_choice: ToolChoice::Tool { - tool_name: "test-tool".to_string(), - }, - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["tool_choice"], json!("any")); - assert_eq!(body["tools"].as_array().unwrap().len(), 1); - assert_eq!(body["tools"][0]["function"]["name"], "test-tool"); -} - -/// TS: "should forward stopSequences as the Mistral stop parameter" -#[tokio::test] -async fn should_forward_stop_sequences() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "model": "mistral-small-latest", - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "stop", - "message": { "role": "assistant", "content": "ok" } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let options = CallOptions { - prompt: test_prompt(), - stop_sequences: Some(vec!["foo".to_string(), "bar".to_string()]), - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["stop"], json!(["foo", "bar"])); -} - -/// TS: "should map model_length finish reason to length" -#[tokio::test] -async fn should_map_model_length_finish_reason() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "model": "mistral-small-latest", - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "model_length", - "message": { "role": "assistant", "content": "ok" } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Length); - assert_eq!(result.finish_reason.raw.as_deref(), Some("model_length")); -} - -// ════════════════════════════════════════════════════════════════════════════ -// doStream -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should stream text" -#[tokio::test] -async fn should_stream_text() { - let server = MockServer::start().await; - let sse = sse_body(&[ - r#"data: {"id":"5319bd0299614c679a0068a4f2c8ffd0","object":"chat.completion.chunk","created":1769088720,"model":"mistral-small-latest","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"logprobs":null}]} - -"#, - r#"data: {"id":"5319bd0299614c679a0068a4f2c8ffd0","object":"chat.completion.chunk","created":1769088720,"model":"mistral-small-latest","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null,"logprobs":null}]} - -"#, - r#"data: {"id":"5319bd0299614c679a0068a4f2c8ffd0","object":"chat.completion.chunk","created":1769088720,"model":"mistral-small-latest","choices":[{"index":0,"delta":{"content":", world!"},"finish_reason":null,"logprobs":null}]} - -"#, - r#"data: {"id":"5319bd0299614c679a0068a4f2c8ffd0","object":"chat.completion.chunk","created":1769088720,"model":"mistral-small-latest","choices":[{"index":0,"delta":{"content":""},"finish_reason":"stop","logprobs":null}],"usage":{"prompt_tokens":13,"total_tokens":21,"completion_tokens":8}} - -"#, - ]); - mock_sse_response(&server, &sse).await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - - // StreamStart, ResponseMetadata, TextStart, TextDelta x2, TextEnd, Finish. - assert!(matches!( - parts.first(), - Some(StreamPart::StreamStart { .. }) - )); - - let deltas = text_deltas(&parts); - assert_eq!(deltas, vec!["Hello", ", world!"]); - - // Check finish reason and usage. - let finish = parts.last().expect("should have finish"); - match finish { - StreamPart::Finish { - finish_reason, - usage, - .. - } => { - assert_eq!(finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(usage.input_tokens.total, Some(13)); - assert_eq!(usage.output_tokens.total, Some(8)); - } - other => panic!("expected Finish, got {other:?}"), - } -} - -/// TS: "should stream tool call" -#[tokio::test] -async fn should_stream_tool_call() { - let server = MockServer::start().await; - let sse = sse_body(&[ - r#"data: {"id":"b3999b8c93e04e11bcbff7bcab829667","object":"chat.completion.chunk","created":1769088854,"model":"mistral-small-latest","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"logprobs":null}]} - -"#, - r#"data: {"id":"b3999b8c93e04e11bcbff7bcab829667","object":"chat.completion.chunk","created":1769088854,"model":"mistral-small-latest","choices":[{"index":0,"delta":{"content":null,"tool_calls":[{"id":"gSIMJiOkT","function":{"name":"weather","arguments":"{\"location\": \"San Francisco\"}"}}]},"finish_reason":"tool_calls","logprobs":null}],"usage":{"prompt_tokens":124,"total_tokens":146,"completion_tokens":22}} - -"#, - ]); - mock_sse_response(&server, &sse).await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - - // Find the ToolCall part. - let tool_call = parts.iter().find_map(|p| match p { - StreamPart::ToolCall { - 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()) - ); - - // Should also have ToolInputStart, ToolInputDelta, ToolInputEnd. - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ToolInputStart { .. })) - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ToolInputDelta { .. })) - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ToolInputEnd { .. })) - ); -} - -/// TS: "should pass the messages" (streaming request body). -#[tokio::test] -async fn should_send_streaming_request_body() { - let server = MockServer::start().await; - mock_sse_response( - &server, - &sse_body(&[ - r#"data: {"id":"test","model":"mistral-small-latest","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"total_tokens":2,"completion_tokens":1}} - -"#, - ]), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let body = result.request_body.expect("body"); - assert_eq!(body["model"], "mistral-small-latest"); - assert_eq!(body["stream"], json!(true)); - assert_eq!( - body["messages"], - json!([{ "role": "user", "content": [{ "type": "text", "text": "Hello" }] }]) - ); - // No stream_options (Mistral doesn't use it). - assert!(body.get("stream_options").is_none()); -} - -/// TS: "should stream text with content objects" (array content format) -#[tokio::test] -async fn should_stream_text_with_array_content() { - let server = MockServer::start().await; - let sse = sse_body(&[ - r#"data: {"id":"b9e43f82d6c74a1e9f5b2c8e7a9d4f6b","object":"chat.completion.chunk","created":1750538500,"model":"mistral-small-latest","choices":[{"index":0,"delta":{"role":"assistant","content":[{"type":"text","text":""}]},"finish_reason":null,"logprobs":null}]} - -"#, - r#"data: {"id":"b9e43f82d6c74a1e9f5b2c8e7a9d4f6b","object":"chat.completion.chunk","created":1750538500,"model":"mistral-small-latest","choices":[{"index":0,"delta":{"content":[{"type":"text","text":"Hello"}]},"finish_reason":null,"logprobs":null}]} - -"#, - r#"data: {"id":"b9e43f82d6c74a1e9f5b2c8e7a9d4f6b","object":"chat.completion.chunk","created":1750538500,"model":"mistral-small-latest","choices":[{"index":0,"delta":{"content":[{"type":"text","text":", world!"}]},"finish_reason":"stop","logprobs":null}],"usage":{"prompt_tokens":4,"total_tokens":36,"completion_tokens":32}} - -"#, - ]); - mock_sse_response(&server, &sse).await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - let deltas = text_deltas(&parts); - assert_eq!(deltas, vec!["Hello", ", world!"]); -} - -/// TS: "should handle 401 auth error" -#[tokio::test] -async fn should_handle_auth_error() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(401) - .set_body_json(json!({ "message": "Invalid API key", "type": "auth_error" })), - ) - .mount(&server) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model.do_generate(&default_options(test_prompt())).await; - - assert!(result.is_err()); - let err = result.unwrap_err(); - assert_eq!(err.status_code(), Some(401), "got {err:?}"); - assert!(err.to_string().contains("Invalid API key")); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Additional doGenerate cases — request construction, headers, penalties, -// response_format, parallel_tool_calls, content-shape variants, errors. -// ════════════════════════════════════════════════════════════════════════════ - -/// A minimal "ok" chat-completion body reused by request-body assertions. -fn ok_text_body() -> Value { - json!({ - "id": "test-id", - "model": "mistral-small-latest", - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "stop", - "message": { "role": "assistant", "content": "ok" } - }] - }) -} - -/// TS: "should forward presencePenalty and frequencyPenalty without unsupported warnings" -#[tokio::test] -async fn should_forward_presence_and_frequency_penalty() { - let server = MockServer::start().await; - mock_json_response(&server, ok_text_body()).await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let options = CallOptions { - prompt: test_prompt(), - presence_penalty: Some(0.1), - frequency_penalty: Some(0.2), - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - // f32 round-trip: compare with tolerance rather than exact equality. - let pp = body["presence_penalty"].as_f64().unwrap(); - let fp = body["frequency_penalty"].as_f64().unwrap(); - assert!((pp - 0.1).abs() < 1e-6, "presence_penalty={pp}"); - assert!((fp - 0.2).abs() < 1e-6, "frequency_penalty={fp}"); -} - -/// TS: "should pass headers" — request-level custom headers reach the server. -#[tokio::test] -async fn should_pass_request_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .and(wiremock::matchers::header( - "custom-request-header", - "request-header-value", - )) - .respond_with(ResponseTemplate::new(200).set_body_json(ok_text_body())) - .mount(&server) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let mut headers = std::collections::HashMap::new(); - headers.insert( - "Custom-Request-Header".to_string(), - "request-header-value".to_string(), - ); - - let options = CallOptions { - prompt: test_prompt(), - headers: Some(headers), - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await; - assert!(result.is_ok(), "request should match the header mock"); -} - -/// TS: "should expose the raw response headers" -#[tokio::test] -async fn should_expose_raw_response_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("test-header", "test-value") - .set_body_json(ok_text_body()), - ) - .mount(&server) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} - -/// TS: "should send additional response information" (id, modelId) -#[tokio::test] -async fn should_send_additional_response_information() { - let server = MockServer::start().await; - mock_json_response(&server, ok_text_body()).await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.response.id.as_deref(), Some("test-id")); - assert_eq!( - result.response.model_id.as_deref(), - Some("mistral-small-latest") - ); -} - -/// TS: "should inject JSON instruction for JSON response format" — Rust sets -/// `response_format: { type: "json_object" }` (no schema). The Rust impl does -/// not inject a system message, so we assert only the response_format field. -#[tokio::test] -async fn should_set_json_object_response_format() { - let server = MockServer::start().await; - mock_json_response(&server, ok_text_body()).await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let options = CallOptions { - prompt: test_prompt(), - response_format: Some(aimux_core::options::ResponseFormat::Json { - schema: None, - name: None, - description: None, - }), - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["response_format"], json!({ "type": "json_object" })); -} - -/// TS: "should inject JSON instruction for JSON response format with schema" -#[tokio::test] -async fn should_set_json_schema_response_format() { - let server = MockServer::start().await; - mock_json_response(&server, ok_text_body()).await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let schema = json!({ - "type": "object", - "properties": { "name": { "type": "string" } } - }); - let options = CallOptions { - prompt: test_prompt(), - response_format: Some(aimux_core::options::ResponseFormat::Json { - schema: Some(schema.clone()), - name: None, - description: None, - }), - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["response_format"]["type"], json!("json_schema")); - assert_eq!(body["response_format"]["json_schema"]["schema"], schema); - assert_eq!( - body["response_format"]["json_schema"]["name"], - json!("response") - ); - assert_eq!( - body["response_format"]["json_schema"]["strict"], - json!(false) - ); -} - -/// TS: "should avoid duplication when trailing assistant message" — continuation -/// mode sets `prefix: true` on the trailing assistant message. -#[tokio::test] -async fn should_mark_trailing_assistant_message_as_prefix() { - let server = MockServer::start().await; - mock_json_response(&server, ok_text_body()).await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text("prefix ")], - ..Default::default() - }, - ]; - - let result = model - .do_generate(&default_options(prompt)) - .await - .expect("should succeed"); - let body = result.request_body.expect("body"); - let messages = body["messages"].as_array().expect("messages array"); - let last = messages.last().expect("last message"); - assert_eq!(last["role"], json!("assistant")); - assert_eq!(last["content"], json!("prefix ")); - assert_eq!(last["prefix"], json!(true)); -} - -/// TS: "should extract content when message content is a content object" -#[tokio::test] -async fn should_extract_content_when_message_content_is_object() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "object": "chat.completion", - "id": "object-id", - "created": 1711113008, - "model": "mistral-small-latest", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": [{ "type": "text", "text": "Hello from object" }], - "tool_calls": null - }, - "finish_reason": "stop", - "logprobs": null - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::Text { text, .. } => assert_eq!(text, "Hello from object"), - other => panic!("expected Text, got {other:?}"), - } -} - -/// TS: "should preserve ordering of mixed thinking and text". -/// -/// Thinking parts become a `Reasoning` item, pushed ahead of the text to match -/// the upstream ordering; the text parts are joined as before. -#[tokio::test] -async fn should_extract_text_from_mixed_thinking_and_text() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "mixed-content-test", - "object": "chat.completion", - "created": 1722349660, - "model": "magistral-medium-2507", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": [ - { "type": "thinking", "thinking": [{ "type": "text", "text": "First thought." }] }, - { "type": "text", "text": "Partial answer." }, - { "type": "thinking", "thinking": [{ "type": "text", "text": "Second thought." }] }, - { "type": "text", "text": "Final answer." } - ] - }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 10, "total_tokens": 30, "completion_tokens": 20 } - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("magistral-medium-2507"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - // Reasoning first, then the joined text. - assert_eq!(result.content.len(), 2); - match &result.content[0] { - GenerateContent::Reasoning { text, .. } => { - assert_eq!( - text, "First thought.Second thought.", - "both thinking parts must survive, in order" - ); - } - other => panic!("expected Reasoning at [0], got {other:?}"), - } - match &result.content[1] { - GenerateContent::Text { text, .. } => { - assert_eq!(text, "Partial answer.Final answer."); - } - other => panic!("expected Text at [1], got {other:?}"), - } -} - -/// TS: "should handle empty thinking content" — empty thinking is skipped. -#[tokio::test] -async fn should_handle_empty_thinking_content() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "empty-thinking-test", - "object": "chat.completion", - "created": 1722349660, - "model": "magistral-medium-2507", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": [ - { "type": "thinking", "thinking": [] }, - { "type": "text", "text": "Just the answer." } - ] - }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 10, "total_tokens": 30, "completion_tokens": 20 } - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("magistral-medium-2507"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::Text { text, .. } => assert_eq!(text, "Just the answer."), - other => panic!("expected Text, got {other:?}"), - } -} - -/// TS: "should return raw text with think tags" -#[tokio::test] -async fn should_return_raw_text_with_think_tags() { - let server = MockServer::start().await; - let raw = "Let me think.\n\n\nHello! I'm ready to help."; - mock_json_response( - &server, - json!({ - "object": "chat.completion", - "id": "raw-think-id", - "created": 1711113008, - "model": "magistral-small-2506", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": raw, - "tool_calls": null - }, - "finish_reason": "stop", - "logprobs": null - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("magistral-small-2506"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::Text { text, .. } => assert_eq!(text, raw), - other => panic!("expected Text, got {other:?}"), - } -} - -/// TS: "should map content_filter finish reason" -#[tokio::test] -async fn should_map_content_filter_finish_reason() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "model": "mistral-small-latest", - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "content_filter", - "message": { "role": "assistant", "content": "ok" } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!( - result.finish_reason.unified, - FinishReasonUnified::ContentFilter - ); - assert_eq!(result.finish_reason.raw.as_deref(), Some("content_filter")); -} - -/// TS: a single response with several tool calls yields multiple ToolCall -/// entries in order. -#[tokio::test] -async fn should_extract_multiple_tool_calls() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "multi-tc", - "model": "mistral-small-latest", - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "tool_calls", - "message": { - "role": "assistant", - "tool_calls": [ - { "id": "call-1", "function": { "name": "weather", "arguments": "{\"city\": \"SF\"}" } }, - { "id": "call-2", "function": { "name": "time", "arguments": "{\"zone\": \"PST\"}" } } - ] - } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.content.len(), 2); - match &result.content[0] { - GenerateContent::ToolCall { - 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())); - } - other => panic!("expected 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, "time"); - assert_eq!(input, &Value::String(r#"{"zone": "PST"}"#.into())); - } - other => panic!("expected ToolCall, got {other:?}"), - } - assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); -} - -/// TS: "should send request body" — verify all standard optional fields. -#[tokio::test] -async fn should_send_default_request_body_shape() { - let server = MockServer::start().await; - mock_json_response(&server, ok_text_body()).await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let options = CallOptions { - prompt: test_prompt(), - max_output_tokens: Some(100), - temperature: Some(0.7), - top_p: Some(0.9), - seed: Some(42), - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["model"], "mistral-small-latest"); - assert_eq!(body["max_tokens"], json!(100)); - // f32 round-trip: compare with tolerance. - assert!((body["temperature"].as_f64().unwrap() - 0.7).abs() < 1e-6); - assert!((body["top_p"].as_f64().unwrap() - 0.9).abs() < 1e-6); - assert_eq!(body["random_seed"], json!(42)); - assert_eq!( - body["messages"], - json!([{ "role": "user", "content": [{ "type": "text", "text": "Hello" }] }]) - ); - assert!(body.get("stop").is_none()); - assert!(body.get("tools").is_none()); - assert!(body.get("tool_choice").is_none()); - assert!(body.get("response_format").is_none()); - assert!(body.get("stream").is_none()); -} - -/// TS: a 429 response maps to `AiMuxError::ApiCall` (429 in `status_code`). -#[tokio::test] -async fn should_handle_rate_limit_error() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(429) - .set_body_json(json!({ "message": "Too many requests", "type": "rate_limit" })), - ) - .mount(&server) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model.do_generate(&default_options(test_prompt())).await; - assert!( - matches!(result, Err(ref e) if e.status_code() == Some(429)), - "expected a 429, got {result:?}" - ); -} - -/// TS: a 404 response maps to `AiMuxError::ApiCall` (404 in `status_code`). -#[tokio::test] -async fn should_handle_model_not_found_error() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(404) - .set_body_json(json!({ "message": "Model not found", "type": "not_found" })), - ) - .mount(&server) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model.do_generate(&default_options(test_prompt())).await; - assert!( - matches!(result, Err(ref e) if e.status_code() == Some(404)), - "expected ModelNotFound, got {result:?}" - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Additional doStream cases — reasoning, interleaved thinking, headers, -// response metadata, error-in-chunk, prefix continuation. -// ════════════════════════════════════════════════════════════════════════════ - -/// Extract reasoning deltas from a list of stream parts. -fn reasoning_deltas(parts: &[StreamPart]) -> Vec { - parts - .iter() - .filter_map(|p| match p { - StreamPart::ReasoningDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect() -} - -/// TS: "should stream reasoning" — thinking parts in array content become -/// ReasoningStart/Delta/End stream parts. -#[tokio::test] -async fn should_stream_reasoning() { - let server = MockServer::start().await; - let sse = sse_json_body(&[ - r#"{"id":"reasoning-1","object":"chat.completion.chunk","created":1750538000,"model":"magistral-small-2507","choices":[{"index":0,"delta":{"role":"assistant","content":[{"type":"thinking","thinking":[{"type":"text","text":"Let me think."}]}]},"finish_reason":null}]}"#, - r#"{"id":"reasoning-1","object":"chat.completion.chunk","created":1750538000,"model":"magistral-small-2507","choices":[{"index":0,"delta":{"content":[{"type":"text","text":"Answer."}]},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"total_tokens":15,"completion_tokens":10}}"#, - ]); - mock_sse_response(&server, &sse).await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("magistral-small-2507"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - assert_eq!(reasoning_deltas(&parts), vec!["Let me think.".to_string()]); - assert_eq!(text_deltas(&parts), vec!["Answer.".to_string()]); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningStart { .. })) - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningEnd { .. })) - ); -} - -/// TS: "should handle interleaved thinking and text" -#[tokio::test] -async fn should_stream_interleaved_thinking_and_text() { - let server = MockServer::start().await; - let sse = sse_json_body(&[ - r#"{"id":"interleaved-test","object":"chat.completion.chunk","created":1750538000,"model":"magistral-small-2507","choices":[{"index":0,"delta":{"role":"assistant","content":[{"type":"thinking","thinking":[{"type":"text","text":"First thought."}]}]},"finish_reason":null}]}"#, - r#"{"id":"interleaved-test","object":"chat.completion.chunk","created":1750538000,"model":"magistral-small-2507","choices":[{"index":0,"delta":{"role":"assistant","content":[{"type":"text","text":"Partial answer."}]},"finish_reason":null}]}"#, - r#"{"id":"interleaved-test","object":"chat.completion.chunk","created":1750538000,"model":"magistral-small-2507","choices":[{"index":0,"delta":{"role":"assistant","content":[{"type":"thinking","thinking":[{"type":"text","text":"Second thought."}]}]},"finish_reason":null}]}"#, - r#"{"id":"interleaved-test","object":"chat.completion.chunk","created":1750538000,"model":"magistral-small-2507","choices":[{"index":0,"delta":{"role":"assistant","content":[{"type":"text","text":"Final answer."}]},"finish_reason":null}]}"#, - r#"{"id":"interleaved-test","object":"chat.completion.chunk","created":1750538000,"model":"magistral-small-2507","choices":[{"index":0,"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"total_tokens":40,"completion_tokens":30}}"#, - ]); - mock_sse_response(&server, &sse).await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("magistral-small-2507"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - assert_eq!( - reasoning_deltas(&parts), - vec!["First thought.".to_string(), "Second thought.".to_string()] - ); - assert_eq!( - text_deltas(&parts), - vec!["Partial answer.".to_string(), "Final answer.".to_string()] - ); - assert_eq!( - parts - .iter() - .filter(|p| matches!(p, StreamPart::ReasoningStart { .. })) - .count(), - 2 - ); - assert_eq!( - parts - .iter() - .filter(|p| matches!(p, StreamPart::ReasoningEnd { .. })) - .count(), - 2 - ); -} - -/// TS: "should expose the raw response headers" (streaming) -#[tokio::test] -async fn should_expose_raw_response_headers_stream() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .insert_header("test-header", "test-value") - .set_body_string(sse_json_body(&[ - r#"{"id":"test","model":"mistral-small-latest","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"total_tokens":2,"completion_tokens":1}}"#, - ])), - ) - .mount(&server) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} - -/// TS: "should pass headers" (streaming) — request-level custom headers. -#[tokio::test] -async fn should_pass_request_headers_stream() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .and(wiremock::matchers::header("custom-stream-header", "stream-value")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_json_body(&[ - r#"{"id":"test","model":"mistral-small-latest","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"total_tokens":2,"completion_tokens":1}}"#, - ])), - ) - .mount(&server) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let mut headers = std::collections::HashMap::new(); - headers.insert( - "Custom-Stream-Header".to_string(), - "stream-value".to_string(), - ); - - let options = CallOptions { - prompt: test_prompt(), - headers: Some(headers), - ..default_options(Vec::new()) - }; - - let result = model.do_stream(&options).await; - assert!(result.is_ok(), "request should match the header mock"); -} - -/// TS: streaming ResponseMetadata carries the chunk id and model id. -#[tokio::test] -async fn should_stream_response_metadata() { - let server = MockServer::start().await; - mock_sse_response( - &server, - &sse_json_body(&[ - r#"{"id":"meta-id","object":"chat.completion.chunk","created":1750538000,"model":"mistral-small-latest","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}"#, - r#"{"id":"meta-id","object":"chat.completion.chunk","created":1750538000,"model":"mistral-small-latest","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"total_tokens":2,"completion_tokens":1}}"#, - ]), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - let meta = parts.iter().find_map(|p| match p { - StreamPart::ResponseMetadata { id, model_id, .. } => Some((id.clone(), model_id.clone())), - _ => None, - }); - let (id, model_id) = meta.expect("should have ResponseMetadata"); - assert_eq!(id.as_deref(), Some("meta-id")); - assert_eq!(model_id.as_deref(), Some("mistral-small-latest")); -} - -/// TS: a 429 HTTP response surfaces as `AiMuxError::ApiCall` (429 in `status_code`) from -/// `do_stream` (the stream is never opened). -#[tokio::test] -async fn should_stream_rate_limit_error() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(429) - .set_body_json(json!({ "message": "Too many requests", "type": "rate_limit" })), - ) - .mount(&server) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model.do_stream(&default_options(test_prompt())).await; - assert!( - matches!(result, Err(ref e) if e.status_code() == Some(429)), - "expected a 429, got {result:?}" - ); -} - -/// TS: an `error` object embedded in a non-first SSE chunk surfaces as a -/// stream `Error` part. -#[tokio::test] -async fn should_stream_error_in_chunk() { - let server = MockServer::start().await; - let sse = sse_json_body(&[ - r#"{"id":"ok","object":"chat.completion.chunk","created":1750538000,"model":"mistral-small-latest","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}"#, - r#"{"error":{"message":"rate limited","code":429}}"#, - ]); - mock_sse_response(&server, &sse).await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - assert!( - parts.iter().any(|p| matches!( - p, - StreamPart::Error { error } if error.status_code() == Some(429) - )), - "expected a 429 stream error, got {parts:?}" - ); -} - -/// TS: "should avoid duplication when trailing assistant message" (streaming) -/// — the trailing assistant message is sent with `prefix: true`. -#[tokio::test] -async fn should_stream_with_trailing_assistant_prefix() { - let server = MockServer::start().await; - mock_sse_response( - &server, - &sse_json_body(&[ - r#"{"id":"53ff663126294946a6b7a4747b70597e","object":"chat.completion.chunk","created":1750537996,"model":"mistral-small-latest","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"logprobs":null}]}"#, - r#"{"id":"53ff663126294946a6b7a4747b70597e","object":"chat.completion.chunk","created":1750537996,"model":"mistral-small-latest","choices":[{"index":0,"delta":{"role":"assistant","content":"prefix"},"finish_reason":null,"logprobs":null}]}"#, - r#"{"id":"53ff663126294946a6b7a4747b70597e","object":"chat.completion.chunk","created":1750537996,"model":"mistral-small-latest","choices":[{"index":0,"delta":{"content":" and more content"},"finish_reason":"stop","logprobs":null}],"usage":{"prompt_tokens":4,"total_tokens":36,"completion_tokens":32}}"#, - ]), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text("prefix ")], - ..Default::default() - }, - ]; - - let result = model - .do_stream(&default_options(prompt)) - .await - .expect("do_stream should succeed"); - - let body = result.request_body.clone().expect("body"); - let messages = body["messages"].as_array().expect("messages array"); - let last = messages.last().expect("last message"); - assert_eq!(last["role"], json!("assistant")); - assert_eq!(last["prefix"], json!(true)); - - let parts = collect_stream(result).await; - assert_eq!( - text_deltas(&parts), - vec!["prefix".to_string(), " and more content".to_string()] - ); -} diff --git a/aimux-providers/tests/mistral_remaining_test.rs b/aimux-providers/tests/mistral_remaining_test.rs deleted file mode 100644 index b06ba2332..000000000 --- a/aimux-providers/tests/mistral_remaining_test.rs +++ /dev/null @@ -1,860 +0,0 @@ -//! Remaining Mistral tests translated from TS that are NOT already covered by -//! `mistral_model_test.rs`. Covers: -//! -//! - `mistral-prepare-tools.test.ts` — all 8 cases (prepare_tools unit tests) -//! - `convert-to-mistral-chat-messages.test.ts` — 12 cases (message conversion -//! unit tests): system, user text/image/file, assistant text/tool-call, -//! prefix continuation, tool results -//! - `convert-mistral-usage.test.ts` — 4 cases (usage conversion via doGenerate): -//! alternate cached-token spellings, zero cached, missing tokens -//! - `mistral-chat-language-model.test.ts` — uncovered scenarios: -//! parse_finish_reason unit tests (6), build_request_body unit tests (7): -//! stream flag, provider tool silently dropped, tool with description/strict, -//! json_schema with name+description - -use base64::Engine; -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::{CallOptions, ResponseFormat, Tool, ToolChoice}; -use aimux_core::tool::{FunctionTool, ProviderTool}; -use aimux_core::types::FinishReasonUnified; - -use aimux_providers::mistral::convert::{ - build_request_body, convert_prompt_to_mistral_messages, parse_finish_reason, prepare_tools, -}; -use aimux_providers::{MistralConfig, MistralProvider}; - -// ── helpers ───────────────────────────────────────────────────────────────── - -fn b64(data: &[u8]) -> String { - base64::engine::general_purpose::STANDARD.encode(data) -} - -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -fn basic_function_tool() -> FunctionTool { - FunctionTool::new( - "testFunction", - json!({ "type": "object", "properties": {} }), - ) - .with_description("test description") -} - -async fn mock_json_response(server: &MockServer, body: Value) { - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(body)) - .mount(server) - .await; -} - -fn ok_mistral_body() -> Value { - json!({ - "id": "test-id", - "model": "mistral-small-latest", - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "stop", - "message": { "role": "assistant", "content": "ok" } - }] - }) -} - -// ════════════════════════════════════════════════════════════════════════════ -// mistral-prepare-tools.test.ts -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should return undefined tools when no tools are provided" -#[test] -fn prepare_tools_empty_returns_none() { - let result = prepare_tools(&Some(vec![]), None); - assert!(result.tools.is_none()); - assert!(result.tool_choice.is_none()); - assert!(result.tool_warnings.is_empty()); -} - -/// TS: "should process function tools correctly" -#[test] -fn prepare_tools_function_tool() { - let result = prepare_tools(&Some(vec![basic_function_tool()]), None); - assert_eq!( - result.tools, - Some(vec![json!({ - "type": "function", - "function": { - "name": "testFunction", - "description": "test description", - "parameters": { "type": "object", "properties": {} } - } - })]) - ); - assert!(result.tool_choice.is_none()); - assert!(result.tool_warnings.is_empty()); -} - -/// TS: "should include strict field when set" -#[test] -fn prepare_tools_function_tool_with_strict() { - let tool = FunctionTool::new("strictTool", json!({ "type": "object", "properties": {} })) - .with_strict(true); - let result = prepare_tools(&Some(vec![tool]), None); - let tools = result.tools.expect("tools should be Some"); - assert_eq!(tools[0]["function"]["strict"], json!(true)); -} - -/// TS: "should handle auto tool choice" -#[test] -fn prepare_tools_auto_choice() { - let result = prepare_tools(&Some(vec![basic_function_tool()]), Some(&ToolChoice::Auto)); - assert_eq!(result.tool_choice, Some(json!("auto"))); - assert!(result.tools.is_some()); -} - -/// TS: "should handle none tool choice" -#[test] -fn prepare_tools_none_choice() { - let result = prepare_tools(&Some(vec![basic_function_tool()]), Some(&ToolChoice::None)); - assert_eq!(result.tool_choice, Some(json!("none"))); - assert!(result.tools.is_some()); -} - -/// TS: "should handle required tool choice" — Mistral maps Required → "any". -#[test] -fn prepare_tools_required_choice() { - let result = prepare_tools( - &Some(vec![basic_function_tool()]), - Some(&ToolChoice::Required), - ); - assert_eq!(result.tool_choice, Some(json!("any"))); - assert!(result.tools.is_some()); -} - -/// TS: "should handle tool type tool choice by filtering tools" — uses "any". -#[test] -fn prepare_tools_tool_choice_filters() { - let result = prepare_tools( - &Some(vec![basic_function_tool()]), - Some(&ToolChoice::Tool { - tool_name: "testFunction".to_string(), - }), - ); - assert_eq!(result.tool_choice, Some(json!("any"))); - let tools = result.tools.expect("tools should be Some"); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0]["function"]["name"], json!("testFunction")); -} - -/// TS: "should return no tools when tool_choice references a non-existent tool" -#[test] -fn prepare_tools_tool_choice_filters_to_empty() { - let result = prepare_tools( - &Some(vec![basic_function_tool()]), - Some(&ToolChoice::Tool { - tool_name: "nonExistent".to_string(), - }), - ); - assert_eq!(result.tool_choice, Some(json!("any"))); - let tools = result.tools.expect("tools should be Some"); - assert!(tools.is_empty()); -} - -// ════════════════════════════════════════════════════════════════════════════ -// convert-to-mistral-chat-messages.test.ts — text messages -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should convert a system message to a string content" -#[test] -fn convert_system_message() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a helpful assistant.")], - ..Default::default() - }]; - let messages = convert_prompt_to_mistral_messages(&prompt); - assert_eq!( - messages, - vec![json!({ "role": "system", "content": "You are a helpful assistant." })] - ); -} - -/// TS: "should convert a user message to an array content" -#[test] -fn convert_user_text_message() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }]; - let messages = convert_prompt_to_mistral_messages(&prompt); - assert_eq!( - messages, - vec![json!({ - "role": "user", - "content": [{ "type": "text", "text": "Hello" }] - })] - ); -} - -/// TS: "should convert an assistant message to a string content" -#[test] -fn convert_assistant_message() { - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hi")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text("Hello!")], - ..Default::default() - }, - ]; - let messages = convert_prompt_to_mistral_messages(&prompt); - // The assistant message is last → prefix: true. - let last = messages.last().expect("last message"); - assert_eq!(last["role"], json!("assistant")); - assert_eq!(last["content"], json!("Hello!")); - assert_eq!(last["prefix"], json!(true)); -} - -/// TS: "should not set prefix on a non-last assistant message" -#[test] -fn convert_assistant_message_no_prefix_when_not_last() { - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text("I am thinking")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Continue")], - ..Default::default() - }, - ]; - let messages = convert_prompt_to_mistral_messages(&prompt); - let assistant_msg = &messages[0]; - assert_eq!(assistant_msg["role"], json!("assistant")); - assert_eq!(assistant_msg["content"], json!("I am thinking")); - assert!( - assistant_msg.get("prefix").is_none(), - "prefix should be absent on non-last assistant" - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// convert-to-mistral-chat-messages.test.ts — image / file processing -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should convert an image part to an image_url data URI" -#[test] -fn convert_user_image_message() { - let data = vec![0, 1, 2, 3]; - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("What is this?"), - ContentPart::Image { - image: data.clone(), - media_type: "image/png".to_string(), - provider_options: None, - }, - ], - ..Default::default() - }]; - let messages = convert_prompt_to_mistral_messages(&prompt); - let expected_url = format!("data:image/png;base64,{}", b64(&data)); - assert_eq!( - messages, - vec![json!({ - "role": "user", - "content": [ - { "type": "text", "text": "What is this?" }, - { "type": "image_url", "image_url": expected_url } - ] - })] - ); -} - -/// TS: "should convert an image file to an image_url data URI" -#[test] -fn convert_user_image_file_message() { - let data = vec![0, 1, 2, 3]; - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::File { - data: data.clone(), - media_type: "image/png".to_string(), - filename: None, - provider_options: None, - }], - ..Default::default() - }]; - let messages = convert_prompt_to_mistral_messages(&prompt); - let expected_url = format!("data:image/png;base64,{}", b64(&data)); - assert_eq!( - messages, - vec![json!({ - "role": "user", - "content": [{ "type": "image_url", "image_url": expected_url }] - })] - ); -} - -/// TS: "should convert a non-image file to a document_url data URI" -#[test] -fn convert_user_document_file_message() { - let data = b"Document content".to_vec(); - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::File { - data: data.clone(), - media_type: "text/plain".to_string(), - filename: Some("doc.txt".to_string()), - provider_options: None, - }], - ..Default::default() - }]; - let messages = convert_prompt_to_mistral_messages(&prompt); - let expected_url = format!("data:text/plain;base64,{}", b64(&data)); - assert_eq!( - messages, - vec![json!({ - "role": "user", - "content": [{ "type": "document_url", "document_url": expected_url }] - })] - ); -} - -/// TS: "should accept top-level 'image' media type and still route to image_url" -#[test] -fn convert_user_image_file_top_level_mediatype() { - let data = vec![0x89, 0x50, 0x4e, 0x47]; - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::File { - data: data.clone(), - media_type: "image".to_string(), - filename: None, - provider_options: None, - }], - ..Default::default() - }]; - let messages = convert_prompt_to_mistral_messages(&prompt); - let content = messages[0]["content"].as_array().expect("content array"); - assert_eq!(content[0]["type"], json!("image_url")); - let url = content[0]["image_url"].as_str().unwrap(); - assert!( - url.starts_with("data:image;base64,"), - "url should start with data:image: {url}" - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// convert-to-mistral-chat-messages.test.ts — tool messages -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should convert an assistant tool call message" -#[test] -fn convert_assistant_tool_call_message() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ - ContentPart::text("Calling a tool"), - ContentPart::ToolCall { - tool_call_id: "tool-call-1".to_string(), - tool_name: "tool-1".to_string(), - input: json!({ "test": "This is a tool message" }), - provider_executed: None, - thought_signature: None, - provider_options: None, - }, - ], - ..Default::default() - }]; - let messages = convert_prompt_to_mistral_messages(&prompt); - // The assistant message is last → prefix: true. - assert_eq!( - messages, - vec![json!({ - "role": "assistant", - "content": "Calling a tool", - "tool_calls": [{ - "id": "tool-call-1", - "type": "function", - "function": { - "name": "tool-1", - "arguments": "{\"test\":\"This is a tool message\"}" - } - }], - "prefix": true - })] - ); -} - -/// TS: "should convert a tool result message" -#[test] -fn convert_tool_result_message() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::ToolResult { - tool_call_id: "tool-call-1".to_string(), - result: json!({ "test": "This is a tool message" }), - tool_name: None, - is_error: None, - preliminary: None, - dynamic: None, - provider_options: None, - }], - ..Default::default() - }]; - let messages = convert_prompt_to_mistral_messages(&prompt); - assert_eq!( - messages, - vec![json!({ - "role": "tool", - "tool_call_id": "tool-call-1", - "content": "{\"test\":\"This is a tool message\"}" - })] - ); -} - -/// TS: "should convert a tool result with a string output" -#[test] -fn convert_tool_result_with_string_output() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::ToolResult { - tool_call_id: "tool-call-1".to_string(), - result: json!("plain string result"), - tool_name: None, - is_error: None, - preliminary: None, - dynamic: None, - provider_options: None, - }], - ..Default::default() - }]; - let messages = convert_prompt_to_mistral_messages(&prompt); - assert_eq!( - messages, - vec![json!({ - "role": "tool", - "tool_call_id": "tool-call-1", - "content": "plain string result" - })] - ); -} - -/// TS: "should convert multiple tool results into separate tool messages" -#[test] -fn convert_multiple_tool_results() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ - ContentPart::ToolResult { - tool_call_id: "tool-call-1".to_string(), - result: json!({ "test": "result 1" }), - tool_name: None, - is_error: None, - preliminary: None, - dynamic: None, - provider_options: None, - }, - ContentPart::ToolResult { - tool_call_id: "tool-call-2".to_string(), - result: json!({ "test": "result 2" }), - tool_name: None, - is_error: None, - preliminary: None, - dynamic: None, - provider_options: None, - }, - ], - ..Default::default() - }]; - let messages = convert_prompt_to_mistral_messages(&prompt); - assert_eq!(messages.len(), 2); - assert_eq!(messages[0]["role"], json!("tool")); - assert_eq!(messages[0]["tool_call_id"], json!("tool-call-1")); - assert_eq!(messages[0]["content"], json!("{\"test\":\"result 1\"}")); - assert_eq!(messages[1]["role"], json!("tool")); - assert_eq!(messages[1]["tool_call_id"], json!("tool-call-2")); - assert_eq!(messages[1]["content"], json!("{\"test\":\"result 2\"}")); -} - -// ════════════════════════════════════════════════════════════════════════════ -// convert-mistral-usage.test.ts — usage conversion via doGenerate -// -// `convert_usage` is private in model.rs, so these tests exercise it through -// `do_generate` and assert on `result.usage`. -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should extract usage with prompt_tokens_details.cached_tokens" -#[tokio::test] -async fn usage_with_prompt_tokens_details_cached() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "model": "mistral-small-latest", - "usage": { - "prompt_tokens": 100, - "total_tokens": 200, - "completion_tokens": 100, - "prompt_tokens_details": { "cached_tokens": 25 } - }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "stop", - "message": { "role": "assistant", "content": "ok" } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.usage.input_tokens.total, Some(100)); - assert_eq!(result.usage.input_tokens.no_cache, Some(75)); - assert_eq!(result.usage.input_tokens.cache_read, Some(25)); - assert_eq!(result.usage.output_tokens.total, Some(100)); -} - -/// TS: "should extract usage with prompt_token_details.cached_tokens" — the -/// alternate (singular "token") spelling used by Mistral at different times. -#[tokio::test] -async fn usage_with_prompt_token_details_cached() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "model": "mistral-small-latest", - "usage": { - "prompt_tokens": 100, - "total_tokens": 200, - "completion_tokens": 100, - "prompt_token_details": { "cached_tokens": 40 } - }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "stop", - "message": { "role": "assistant", "content": "ok" } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.usage.input_tokens.total, Some(100)); - assert_eq!(result.usage.input_tokens.no_cache, Some(60)); - assert_eq!(result.usage.input_tokens.cache_read, Some(40)); -} - -/// TS: "should not set cache_read when cached_tokens is 0" — the impl omits -/// `cache_read` when the value is 0 (uses `if cache_read > 0`). -#[tokio::test] -async fn usage_with_zero_cached_tokens() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "model": "mistral-small-latest", - "usage": { - "prompt_tokens": 50, - "total_tokens": 100, - "completion_tokens": 50, - "num_cached_tokens": 0 - }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "stop", - "message": { "role": "assistant", "content": "ok" } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.usage.input_tokens.total, Some(50)); - assert_eq!(result.usage.input_tokens.no_cache, Some(50)); - assert_eq!(result.usage.input_tokens.cache_read, None); -} - -/// TS: "should default missing token counts to 0" -#[tokio::test] -async fn usage_with_missing_tokens_defaults_to_zero() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "model": "mistral-small-latest", - "usage": {}, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "stop", - "message": { "role": "assistant", "content": "ok" } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.usage.input_tokens.total, Some(0)); - assert_eq!(result.usage.input_tokens.no_cache, Some(0)); - assert_eq!(result.usage.input_tokens.cache_read, None); - assert_eq!(result.usage.output_tokens.total, Some(0)); -} - -// ════════════════════════════════════════════════════════════════════════════ -// mistral-chat-language-model.test.ts — parse_finish_reason unit tests -// ════════════════════════════════════════════════════════════════════════════ - -#[test] -fn parse_finish_reason_stop() { - let fr = parse_finish_reason("stop"); - assert_eq!(fr.unified, FinishReasonUnified::Stop); - assert_eq!(fr.raw.as_deref(), Some("stop")); -} - -#[test] -fn parse_finish_reason_length() { - let fr = parse_finish_reason("length"); - assert_eq!(fr.unified, FinishReasonUnified::Length); - assert_eq!(fr.raw.as_deref(), Some("length")); -} - -/// TS: "model_length" is a Mistral-specific finish reason also mapped to Length. -#[test] -fn parse_finish_reason_model_length() { - let fr = parse_finish_reason("model_length"); - assert_eq!(fr.unified, FinishReasonUnified::Length); - assert_eq!(fr.raw.as_deref(), Some("model_length")); -} - -#[test] -fn parse_finish_reason_tool_calls() { - let fr = parse_finish_reason("tool_calls"); - assert_eq!(fr.unified, FinishReasonUnified::ToolCalls); -} - -#[test] -fn parse_finish_reason_content_filter() { - let fr = parse_finish_reason("content_filter"); - assert_eq!(fr.unified, FinishReasonUnified::ContentFilter); -} - -#[test] -fn parse_finish_reason_unknown() { - let fr = parse_finish_reason("something_unexpected"); - assert_eq!(fr.unified, FinishReasonUnified::Other); - assert_eq!(fr.raw.as_deref(), Some("something_unexpected")); -} - -// ════════════════════════════════════════════════════════════════════════════ -// build_request_body unit tests — stream flag, provider tools, tool fields, -// response format with name + description. -// ════════════════════════════════════════════════════════════════════════════ - -#[test] -fn build_request_body_stream_flag() { - let options = default_options(test_prompt()); - let body = build_request_body("mistral-small-latest", &options, true); - assert_eq!(body["stream"], json!(true)); -} - -#[test] -fn build_request_body_no_stream_flag() { - let options = default_options(test_prompt()); - let body = build_request_body("mistral-small-latest", &options, false); - assert!(body.get("stream").is_none()); -} - -/// TS: provider-defined tools are silently dropped (Mistral filters them out -/// before calling prepare_tools — no warning is emitted). -#[test] -fn build_request_body_provider_tool_dropped() { - let provider_tool = Tool::Provider(ProviderTool { - id: "mistral.web_search".to_string(), - name: "web_search".to_string(), - args: json!({}), - }); - let options = CallOptions { - prompt: test_prompt(), - tools: Some(vec![provider_tool]), - ..default_options(Vec::new()) - }; - let body = build_request_body("mistral-small-latest", &options, false); - assert!( - body.get("tools").is_none(), - "provider-only tools should produce no tools field" - ); -} - -/// TS: a function tool with a description includes it in the request body. -#[test] -fn build_request_body_tool_with_description() { - let tool = FunctionTool::new("weather", json!({ "type": "object", "properties": {} })) - .with_description("Get the weather"); - let options = CallOptions { - prompt: test_prompt(), - tools: Some(vec![Tool::Function(tool)]), - ..default_options(Vec::new()) - }; - let body = build_request_body("mistral-small-latest", &options, false); - assert_eq!(body["tools"][0]["function"]["name"], json!("weather")); - assert_eq!( - body["tools"][0]["function"]["description"], - json!("Get the weather") - ); -} - -/// TS: a function tool with strict=true includes it in the request body. -#[test] -fn build_request_body_tool_with_strict() { - let tool = FunctionTool::new("weather", json!({ "type": "object", "properties": {} })) - .with_strict(true); - let options = CallOptions { - prompt: test_prompt(), - tools: Some(vec![Tool::Function(tool)]), - ..default_options(Vec::new()) - }; - let body = build_request_body("mistral-small-latest", &options, false); - assert_eq!(body["tools"][0]["function"]["strict"], json!(true)); -} - -/// TS: json_schema response format with name + description + strict=false. -#[test] -fn build_request_body_json_schema_with_name_and_description() { - let schema = json!({ "type": "object", "properties": { "name": { "type": "string" } } }); - let options = CallOptions { - prompt: test_prompt(), - response_format: Some(ResponseFormat::Json { - schema: Some(schema.clone()), - name: Some("person".to_string()), - description: Some("A person object".to_string()), - }), - ..default_options(Vec::new()) - }; - let body = build_request_body("mistral-small-latest", &options, false); - assert_eq!(body["response_format"]["type"], json!("json_schema")); - assert_eq!(body["response_format"]["json_schema"]["schema"], schema); - assert_eq!( - body["response_format"]["json_schema"]["name"], - json!("person") - ); - assert_eq!( - body["response_format"]["json_schema"]["description"], - json!("A person object") - ); - assert_eq!( - body["response_format"]["json_schema"]["strict"], - json!(false) - ); -} - -/// TS: json_object response format when schema is None. -#[test] -fn build_request_body_json_object_when_no_schema() { - let options = CallOptions { - prompt: test_prompt(), - response_format: Some(ResponseFormat::Json { - schema: None, - name: Some("ignored".to_string()), - description: None, - }), - ..default_options(Vec::new()) - }; - let body = build_request_body("mistral-small-latest", &options, false); - assert_eq!(body["response_format"], json!({ "type": "json_object" })); -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate — request body via the model (verifies build_request_body wiring) -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should send correct request body" — the model wires build_request_body -/// into the HTTP request and exposes it via `result.request_body`. -#[tokio::test] -async fn should_expose_request_body_from_model() { - let server = MockServer::start().await; - mock_json_response(&server, ok_mistral_body()).await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let body = result.request_body.expect("request body"); - assert_eq!(body["model"], "mistral-small-latest"); - assert_eq!( - body["messages"], - json!([{ "role": "user", "content": [{ "type": "text", "text": "Hello" }] }]) - ); - // No optional fields set → absent. - assert!(body.get("max_tokens").is_none()); - assert!(body.get("tools").is_none()); - assert!(body.get("stream").is_none()); -} diff --git a/aimux-providers/tests/openai_convert_extended_test.rs b/aimux-providers/tests/openai_convert_extended_test.rs deleted file mode 100644 index 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 deleted file mode 100644 index 5f1222740..000000000 --- a/aimux-providers/tests/openai_convert_test.rs +++ /dev/null @@ -1,827 +0,0 @@ -// Panic convert wrappers are #[deprecated]; these tests still use them. -#![allow(deprecated)] -//! Rust translations of the AI SDK OpenAI provider pure-function tests. -//! -//! Sources (TS → Rust): -//! - `packages/openai/src/chat/convert-to-openai-chat-messages.test.ts` -//! → `convert_prompt_to_openai_messages` -//! - `packages/openai/src/chat/openai-chat-prepare-tools.test.ts` -//! → `prepare_tools` -//! - `packages/openai/src/chat/openai-chat-language-model.test.ts` -//! (`doGenerate` `requestBodyJson` assertions) → `build_request_body` -//! -//! Tests that depend on features absent from the Rust data model -//! (`providerOptions`, `systemMessageMode`, file URL/reference/audio/PDF -//! parts, reasoning models, etc.) are documented at the bottom of each -//! module rather than translated — see "Remaining untranslated cases". - -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::{CallOptions, ResponseFormat, Tool, ToolChoice}; -use aimux_core::tool::FunctionTool; -use aimux_providers::openai::convert::{ - build_request_body, convert_prompt_to_openai_messages, prepare_tools, -}; -use serde_json::{Value, json}; - -// ── helpers ───────────────────────────────────────────────────────────────── - -/// The TS `TEST_PROMPT`: a single user text message "Hello". -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -/// Build `CallOptions` with everything unset except `prompt`. -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -/// The JSON schema used across the TS `requestBodyJson` tool tests. -fn value_schema() -> Value { - json!({ - "type": "object", - "properties": { "value": { "type": "string" } }, - "required": ["value"], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - }) -} - -fn sys(content: &str) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text(content)], - provider_options: None, - } -} - -fn user_parts(parts: Vec) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role: Role::User, - content: parts, - provider_options: None, - } -} - -fn assistant_text(content: &str) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text(content)], - provider_options: None, - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// convert_prompt_to_openai_messages -// (convert-to-openai-chat-messages.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod convert_messages { - use super::*; - use serde_json::json; - - // ── system messages ────────────────────────────────────────────────────── - - /// TS: "should forward system messages" - #[test] - fn forwards_system_messages() { - let prompt = vec![sys("You are a helpful assistant.")]; - let result = convert_prompt_to_openai_messages(&prompt); - assert_eq!( - Value::Array(result), - json!([{ "role": "system", "content": "You are a helpful assistant." }]) - ); - } - - // ── user messages ──────────────────────────────────────────────────────── - - /// TS: "should convert messages with only a text part to a string content" - #[test] - fn text_only_user_message_becomes_string_content() { - let prompt = vec![user_parts(vec![ContentPart::text("Hello")])]; - let result = convert_prompt_to_openai_messages(&prompt); - assert_eq!( - Value::Array(result), - json!([{ "role": "user", "content": "Hello" }]) - ); - } - - /// TS: "should convert messages with image parts" - #[test] - fn converts_image_parts_to_data_url() { - let prompt = vec![user_parts(vec![ - ContentPart::text("Hello"), - ContentPart::image(vec![0, 1, 2, 3], "image/png".to_string()), - ])]; - let result = convert_prompt_to_openai_messages(&prompt); - assert_eq!( - Value::Array(result), - json!([{ - "role": "user", - "content": [ - { "type": "text", "text": "Hello" }, - { - "type": "image_url", - "image_url": { "url": "data:image/png;base64,AAECAw==" } - } - ] - }]) - ); - } - - /// TS: "should convert messages with Uint8Array image parts to data URLs" - #[test] - fn converts_uint8array_image_parts_to_data_urls() { - let prompt = vec![user_parts(vec![ContentPart::image( - vec![0xff, 0xd8, 0xff, 0xe0], - "image/jpeg".to_string(), - )])]; - let result = convert_prompt_to_openai_messages(&prompt); - assert_eq!( - Value::Array(result), - json!([{ - "role": "user", - "content": [{ - "type": "image_url", - "image_url": { "url": "data:image/jpeg;base64,/9j/4A==" } - }] - }]) - ); - } - - // ── tool calls ─────────────────────────────────────────────────────────── - - /// TS: "should stringify arguments to tool calls" - #[test] - fn stringifies_tool_call_arguments() { - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "quux".to_string(), - "thwomp".to_string(), - json!({ "foo": "bar123" }), - )], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "quux".to_string(), - json!({ "oof": "321rab" }), - )], - ..Default::default() - }, - ]; - let result = convert_prompt_to_openai_messages(&prompt); - assert_eq!( - Value::Array(result), - json!([ - { - "role": "assistant", - "content": null, - "tool_calls": [{ - "type": "function", - "id": "quux", - "function": { - "name": "thwomp", - "arguments": "{\"foo\":\"bar123\"}" - } - }] - }, - { - "role": "tool", - "content": "{\"oof\":\"321rab\"}", - "tool_call_id": "quux" - } - ]) - ); - } - - /// TS: "should send empty string content for assistant messages with no - /// tool calls" - #[test] - fn assistant_with_empty_text_sends_empty_string_content() { - let prompt = vec![assistant_text("")]; - let result = convert_prompt_to_openai_messages(&prompt); - assert_eq!( - Value::Array(result), - json!([{ "role": "assistant", "content": "" }]) - ); - } - - /// TS: "should default missing tool call input to an empty object" - #[test] - fn missing_tool_call_input_defaults_to_empty_object() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "quux".to_string(), - "thwomp".to_string(), - Value::Null, - )], - ..Default::default() - }]; - let result = convert_prompt_to_openai_messages(&prompt); - assert_eq!( - Value::Array(result), - json!([{ - "role": "assistant", - "content": null, - "tool_calls": [{ - "type": "function", - "id": "quux", - "function": { "name": "thwomp", "arguments": "{}" } - }] - }]) - ); - } - - /// TS: "should handle different tool output types" - /// - /// A single tool-role message with two tool-result parts expands into two - /// separate OpenAI `tool` messages. - #[test] - fn handles_different_tool_output_types() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ - ContentPart::tool_result( - "text-tool".to_string(), - Value::String("Hello world".to_string()), - ), - ContentPart::tool_result( - "error-tool".to_string(), - Value::String("Something went wrong".to_string()), - ), - ], - ..Default::default() - }]; - let result = convert_prompt_to_openai_messages(&prompt); - assert_eq!( - Value::Array(result), - json!([ - { - "role": "tool", - "content": "Hello world", - "tool_call_id": "text-tool" - }, - { - "role": "tool", - "content": "Something went wrong", - "tool_call_id": "error-tool" - } - ]) - ); - } - - // ── Extended tests in `convert_messages_extended` module below ─────────── -} - -// ════════════════════════════════════════════════════════════════════════════ -// prepare_tools -// (openai-chat-prepare-tools.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod prepare_tools_tests { - use super::*; - use serde_json::json; - - /// A single function tool reused across the tool-choice / strict tests. - fn function_tool(name: &str, desc: &str, strict: Option) -> FunctionTool { - FunctionTool { - name: name.to_string(), - description: Some(desc.to_string()), - input_schema: json!({ "type": "object", "properties": {} }), - strict, - provider_options: None, - input_examples: None, - } - } - - /// TS: "should return undefined tools and toolChoice when tools are null" - #[test] - fn null_tools_returns_none() { - let result = prepare_tools(&None, None); - assert_eq!(result.tools, None); - assert_eq!(result.tool_choice, None); - assert!(result.tool_warnings.is_empty()); - } - - /// TS: "should return undefined tools and toolChoice when tools are empty" - #[test] - fn empty_tools_returns_none() { - let result = prepare_tools(&Some(vec![]), None); - assert_eq!(result.tools, None); - assert_eq!(result.tool_choice, None); - assert!(result.tool_warnings.is_empty()); - } - - /// TS: "should correctly prepare function tools" - #[test] - fn prepares_function_tools() { - let tools = Some(vec![FunctionTool { - name: "testFunction".to_string(), - description: Some("A test function".to_string()), - input_schema: json!({ "type": "object", "properties": {} }), - strict: None, - provider_options: None, - input_examples: None, - }]); - let result = prepare_tools(&tools, None); - - assert_eq!(result.tool_choice, None); - assert!(result.tool_warnings.is_empty()); - assert_eq!( - result.tools, - Some(vec![json!({ - "type": "function", - "function": { - "name": "testFunction", - "description": "A test function", - "parameters": { "type": "object", "properties": {} } - } - })]) - ); - } - - /// TS: "should handle tool choice 'auto'" - #[test] - fn tool_choice_auto() { - let tools = Some(vec![function_tool("testFunction", "Test", None)]); - let result = prepare_tools(&tools, Some(&ToolChoice::Auto)); - assert_eq!(result.tool_choice, Some(json!("auto"))); - } - - /// TS: "should handle tool choice 'required'" - #[test] - fn tool_choice_required() { - let tools = Some(vec![function_tool("testFunction", "Test", None)]); - let result = prepare_tools(&tools, Some(&ToolChoice::Required)); - assert_eq!(result.tool_choice, Some(json!("required"))); - } - - /// TS: "should handle tool choice 'none'" - #[test] - fn tool_choice_none() { - let tools = Some(vec![function_tool("testFunction", "Test", None)]); - let result = prepare_tools(&tools, Some(&ToolChoice::None)); - assert_eq!(result.tool_choice, Some(json!("none"))); - } - - /// TS: "should handle tool choice 'tool'" - #[test] - fn tool_choice_tool() { - let tools = Some(vec![function_tool("testFunction", "Test", None)]); - let result = prepare_tools( - &tools, - Some(&ToolChoice::Tool { - tool_name: "testFunction".to_string(), - }), - ); - assert_eq!( - result.tool_choice, - Some(json!({ "type": "function", "function": { "name": "testFunction" } })) - ); - } - - /// TS: "should pass through strict mode when strict is true" - #[test] - fn strict_true_is_passed_through() { - let tools = Some(vec![function_tool( - "testFunction", - "A test function", - Some(true), - )]); - let result = prepare_tools(&tools, None); - assert_eq!( - result.tools, - Some(vec![json!({ - "type": "function", - "function": { - "name": "testFunction", - "description": "A test function", - "parameters": { "type": "object", "properties": {} }, - "strict": true - } - })]) - ); - } - - /// TS: "should pass through strict mode when strict is false" - #[test] - fn strict_false_is_passed_through() { - let tools = Some(vec![function_tool( - "testFunction", - "A test function", - Some(false), - )]); - let result = prepare_tools(&tools, None); - assert_eq!( - result.tools, - Some(vec![json!({ - "type": "function", - "function": { - "name": "testFunction", - "description": "A test function", - "parameters": { "type": "object", "properties": {} }, - "strict": false - } - })]) - ); - } - - /// TS: "should not include strict mode when strict is undefined" - #[test] - fn strict_undefined_is_omitted() { - let tools = Some(vec![function_tool("testFunction", "A test function", None)]); - let result = prepare_tools(&tools, None); - assert_eq!( - result.tools, - Some(vec![json!({ - "type": "function", - "function": { - "name": "testFunction", - "description": "A test function", - "parameters": { "type": "object", "properties": {} } - } - })]) - ); - } - - /// TS: "should pass through strict mode for multiple tools with different - /// strict settings" - #[test] - fn multiple_tools_with_different_strict_settings() { - let tools = Some(vec![ - function_tool("strictTool", "A strict tool", Some(true)), - function_tool("nonStrictTool", "A non-strict tool", Some(false)), - function_tool("defaultTool", "A tool without strict setting", None), - ]); - let result = prepare_tools(&tools, None); - assert_eq!( - result.tools, - Some(vec![ - json!({ - "type": "function", - "function": { - "name": "strictTool", - "description": "A strict tool", - "parameters": { "type": "object", "properties": {} }, - "strict": true - } - }), - json!({ - "type": "function", - "function": { - "name": "nonStrictTool", - "description": "A non-strict tool", - "parameters": { "type": "object", "properties": {} }, - "strict": false - } - }), - json!({ - "type": "function", - "function": { - "name": "defaultTool", - "description": "A tool without strict setting", - "parameters": { "type": "object", "properties": {} } - } - }), - ]) - ); - } - - // ── Remaining untranslated cases ───────────────────────────────────────── - // - // * "should add warnings for unsupported tools" — the Rust `FunctionTool` - // has no `type` discriminator (it is always a function tool), so there - // is no `provider`-type tool to reject and warn about. -} - -// ════════════════════════════════════════════════════════════════════════════ -// build_request_body -// (openai-chat-language-model.test.ts — doGenerate requestBodyJson assertions) -// ════════════════════════════════════════════════════════════════════════════ - -mod build_request_body_tests { - use super::*; - use serde_json::json; - - /// TS: "should pass the model and the messages" - #[test] - fn passes_model_and_messages() { - let options = default_options(test_prompt()); - let body = build_request_body("gpt-3.5-turbo", &options, false).unwrap(); - assert_eq!( - body, - json!({ - "model": "gpt-3.5-turbo", - "messages": [{ "role": "user", "content": "Hello" }] - }) - ); - } - - /// RFC-0016 M3: `logprobs` / `topLogprobs` provider options reach the - /// request body — previously they were silently dropped by the - /// provider_options whitelist (the only "quietly no-op" option). - #[test] - fn passes_logprobs_and_top_logprobs() { - let mut provider_options = std::collections::HashMap::new(); - provider_options.insert( - "openai".to_string(), - json!({ "logprobs": true, "topLogprobs": 3 }), - ); - let options = CallOptions { - provider_options: Some(provider_options), - ..default_options(test_prompt()) - }; - let body = build_request_body("gpt-3.5-turbo", &options, false).unwrap(); - assert_eq!(body["logprobs"], json!(true)); - assert_eq!(body["top_logprobs"], json!(3)); - } - - /// TS: "should pass tools and toolChoice" - #[test] - fn passes_tools_and_tool_choice() { - let options = CallOptions { - tools: Some(vec![Tool::Function(FunctionTool { - name: "test-tool".to_string(), - description: None, - input_schema: value_schema(), - strict: None, - provider_options: None, - input_examples: None, - })]), - tool_choice: ToolChoice::Tool { - tool_name: "test-tool".to_string(), - }, - ..default_options(test_prompt()) - }; - let body = build_request_body("gpt-3.5-turbo", &options, false).unwrap(); - assert_eq!( - body, - json!({ - "model": "gpt-3.5-turbo", - "messages": [{ "role": "user", "content": "Hello" }], - "tool_choice": { - "type": "function", - "function": { "name": "test-tool" } - }, - "tools": [{ - "type": "function", - "function": { - "name": "test-tool", - "parameters": value_schema() - } - }] - }) - ); - } - - /// TS: "should not send a response_format when response format is text" - #[test] - fn text_response_format_is_omitted() { - let options = CallOptions { - response_format: Some(ResponseFormat::Text), - ..default_options(test_prompt()) - }; - let body = build_request_body("gpt-4o-2024-08-06", &options, false).unwrap(); - assert_eq!( - body, - json!({ - "model": "gpt-4o-2024-08-06", - "messages": [{ "role": "user", "content": "Hello" }] - }) - ); - } - - /// TS: "should forward json response format as 'json_object' without schema" - #[test] - fn json_without_schema_becomes_json_object() { - let options = CallOptions { - response_format: Some(ResponseFormat::Json { - schema: None, - name: None, - description: None, - }), - ..default_options(test_prompt()) - }; - let body = build_request_body("gpt-4o-2024-08-06", &options, false).unwrap(); - assert_eq!( - body, - json!({ - "model": "gpt-4o-2024-08-06", - "messages": [{ "role": "user", "content": "Hello" }], - "response_format": { "type": "json_object" } - }) - ); - } - - /// TS: "should forward json response format as 'json_object' and include - /// schema" - #[test] - fn json_with_schema_uses_json_schema() { - let schema = value_schema(); - let options = CallOptions { - response_format: Some(ResponseFormat::Json { - schema: Some(schema.clone()), - name: None, - description: None, - }), - ..default_options(test_prompt()) - }; - let body = build_request_body("gpt-4o-2024-08-06", &options, false).unwrap(); - assert_eq!( - body, - json!({ - "model": "gpt-4o-2024-08-06", - "messages": [{ "role": "user", "content": "Hello" }], - "response_format": { - "type": "json_schema", - "json_schema": { - "schema": schema, - "name": "response", - "strict": true - } - } - }) - ); - } - - /// TS: "should use json_schema & strict with responseFormat json" - /// - /// Same shape as above; the TS repeats the assertion to confirm strict - /// mode is used even without an explicit name/description. - #[test] - fn json_schema_strict_without_name() { - let schema = value_schema(); - let options = CallOptions { - response_format: Some(ResponseFormat::Json { - schema: Some(schema.clone()), - name: None, - description: None, - }), - ..default_options(test_prompt()) - }; - let body = build_request_body("gpt-4o-2024-08-06", &options, false).unwrap(); - assert_eq!( - body["response_format"], - json!({ - "type": "json_schema", - "json_schema": { - "schema": schema, - "name": "response", - "strict": true - } - }) - ); - } - - /// TS: "should set name & description with responseFormat json" - #[test] - fn json_schema_sets_name_and_description() { - let schema = value_schema(); - let options = CallOptions { - response_format: Some(ResponseFormat::Json { - schema: Some(schema.clone()), - name: Some("test-name".to_string()), - description: Some("test description".to_string()), - }), - ..default_options(test_prompt()) - }; - let body = build_request_body("gpt-4o-2024-08-06", &options, false).unwrap(); - assert_eq!( - body, - json!({ - "model": "gpt-4o-2024-08-06", - "messages": [{ "role": "user", "content": "Hello" }], - "response_format": { - "type": "json_schema", - "json_schema": { - "schema": schema, - "name": "test-name", - "description": "test description", - "strict": true - } - } - }) - ); - } - - /// TS: "should allow for undefined schema with responseFormat json when - /// structuredOutputs are enabled" - #[test] - fn json_without_schema_even_with_name_and_description() { - let options = CallOptions { - response_format: Some(ResponseFormat::Json { - schema: None, - name: Some("test-name".to_string()), - description: Some("test description".to_string()), - }), - ..default_options(test_prompt()) - }; - let body = build_request_body("gpt-4o-2024-08-06", &options, false).unwrap(); - assert_eq!( - body, - json!({ - "model": "gpt-4o-2024-08-06", - "messages": [{ "role": "user", "content": "Hello" }], - "response_format": { "type": "json_object" } - }) - ); - } - - /// TS: "should set strict with tool call" - /// - /// Tools with `toolChoice: required` and a description. - #[test] - fn strict_with_tool_call_required() { - let options = CallOptions { - tools: Some(vec![Tool::Function(FunctionTool { - name: "test-tool".to_string(), - description: Some("test description".to_string()), - input_schema: value_schema(), - strict: None, - provider_options: None, - input_examples: None, - })]), - tool_choice: ToolChoice::Required, - ..default_options(test_prompt()) - }; - let body = build_request_body("gpt-4o-2024-08-06", &options, false).unwrap(); - assert_eq!( - body, - json!({ - "model": "gpt-4o-2024-08-06", - "messages": [{ "role": "user", "content": "Hello" }], - "tool_choice": "required", - "tools": [{ - "type": "function", - "function": { - "name": "test-tool", - "description": "test description", - "parameters": value_schema() - } - }] - }) - ); - } - - /// TS: "should set strict for tool usage" - /// - /// Tools with `toolChoice: tool` (no description). - #[test] - fn strict_for_tool_usage() { - let options = CallOptions { - tools: Some(vec![Tool::Function(FunctionTool { - name: "test-tool".to_string(), - description: None, - input_schema: value_schema(), - strict: None, - provider_options: None, - input_examples: None, - })]), - tool_choice: ToolChoice::Tool { - tool_name: "test-tool".to_string(), - }, - ..default_options(test_prompt()) - }; - let body = build_request_body("gpt-4o-2024-08-06", &options, false).unwrap(); - assert_eq!( - body, - json!({ - "model": "gpt-4o-2024-08-06", - "messages": [{ "role": "user", "content": "Hello" }], - "tool_choice": { - "type": "function", - "function": { "name": "test-tool" } - }, - "tools": [{ - "type": "function", - "function": { - "name": "test-tool", - "parameters": value_schema() - } - }] - }) - ); - } - - // ── Remaining untranslated requestBodyJson cases ──────────────────────── - // - // * "should pass settings" (logitBias, parallelToolCalls, user) — these - // come from `providerOptions.openai`, which the Rust `CallOptions` does - // not surface as individual fields. - // * reasoning_effort / reasoning model tests — the Rust `CallOptions` has - // no `reasoning` field and `build_request_body` has no reasoning-model - // detection (which would remap `max_tokens` → `max_completion_tokens` - // and strip temperature/topP/frequencyPenalty/presencePenalty). - // * textVerbosity — `providerOptions.openai.textVerbosity`. - // * systemMessageMode 'developer' for o1 / forceReasoning models. -} diff --git a/aimux-providers/tests/openai_files_test.rs b/aimux-providers/tests/openai_files_test.rs deleted file mode 100644 index ab503b3ba..000000000 --- a/aimux-providers/tests/openai_files_test.rs +++ /dev/null @@ -1,321 +0,0 @@ -//! Rust translations of the OpenAI Files provider tests. -//! -//! Source: `reference/ai/packages/openai/src/files/openai-files.test.ts` -//! (8 test cases). -//! -//! Each test uses `wiremock` to stub the OpenAI `/v1/files` endpoint, -//! uploads a file via `OpenAIFiles::upload_file`, and asserts on the -//! request or the returned result. - -use std::collections::HashMap; - -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::files_model::{Files, UploadFileCallOptions, UploadFileData}; -use aimux_core::shared::{FileBytes, SharedProviderOptions}; -use aimux_providers::{OpenAIConfig, OpenAIProvider}; - -// ── helpers ───────────────────────────────────────────────────────────────── - -/// The standard successful file response body used by most tests. -fn file_response_body(id: &str) -> Value { - json!({ - "id": id, - "object": "file", - "bytes": 1024, - "created_at": 1700000000, - "filename": "test.csv", - "purpose": "assistants", - "status": "processed", - "expires_at": null - }) -} - -/// Build an `OpenAIProvider` pointing at the mock server. -fn provider(server: &MockServer) -> OpenAIProvider { - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - OpenAIProvider::new(config) -} - -/// Build `UploadFileCallOptions` with binary data and the given provider options. -fn upload_options(provider_options: Option) -> UploadFileCallOptions { - UploadFileCallOptions { - data: UploadFileData::Data { - data: FileBytes::Binary(vec![1, 2, 3]), - }, - media_type: "application/octet-stream".to_string(), - filename: None, - provider_options, - abort_signal: None, - } -} - -// ── tests ─────────────────────────────────────────────────────────────────── - -#[tokio::test] -async fn should_send_multipart_request_with_purpose() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/files")) - .respond_with(ResponseTemplate::new(200).set_body_json(file_response_body("file-abc123"))) - .mount(&server) - .await; - - let provider = provider(&server); - let files = provider.files(); - - let mut opts = upload_options(None); - let mut po = HashMap::new(); - po.insert("openai".to_string(), json!({ "purpose": "assistants" })); - opts.provider_options = Some(po); - - files.upload_file(&opts).await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - assert_eq!(requests.len(), 1); - let body_str = String::from_utf8_lossy(&requests[0].body); - // The purpose field should be present in the multipart body. - assert!( - body_str.contains("assistants"), - "multipart body should contain 'assistants': {body_str}" - ); - // The file field should be present. - assert!( - body_str.contains("name=\"file\""), - "multipart body should contain a 'file' part: {body_str}" - ); -} - -#[tokio::test] -async fn should_return_provider_reference_with_openai_key() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/files")) - .respond_with(ResponseTemplate::new(200).set_body_json(file_response_body("file-xyz789"))) - .mount(&server) - .await; - - let provider = provider(&server); - let files = provider.files(); - - let mut po = HashMap::new(); - po.insert("openai".to_string(), json!({ "purpose": "assistants" })); - let opts = upload_options(Some(po)); - - let result = files.upload_file(&opts).await.unwrap(); - - assert_eq!( - result.provider_reference.get("openai"), - Some(&"file-xyz789".to_string()) - ); -} - -#[tokio::test] -async fn should_return_provider_metadata_from_response() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/files")) - .respond_with(ResponseTemplate::new(200).set_body_json(file_response_body("file-abc123"))) - .mount(&server) - .await; - - let provider = provider(&server); - let files = provider.files(); - - let mut po = HashMap::new(); - po.insert("openai".to_string(), json!({ "purpose": "assistants" })); - let opts = upload_options(Some(po)); - - let result = files.upload_file(&opts).await.unwrap(); - - let metadata = result.provider_metadata.expect("metadata"); - let openai = metadata.get("openai").expect("openai metadata"); - assert_eq!(openai["filename"], "test.csv"); - assert_eq!(openai["purpose"], "assistants"); - assert_eq!(openai["bytes"], 1024); - assert_eq!(openai["createdAt"], 1700000000); - assert_eq!(openai["status"], "processed"); -} - -#[tokio::test] -async fn should_default_purpose_to_assistants() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/files")) - .respond_with(ResponseTemplate::new(200).set_body_json(file_response_body("file-abc123"))) - .mount(&server) - .await; - - let provider = provider(&server); - let files = provider.files(); - - // No provider options — purpose should default to "assistants". - let opts = upload_options(None); - files.upload_file(&opts).await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body_str = String::from_utf8_lossy(&requests[0].body); - assert!( - body_str.contains("assistants"), - "default purpose should be 'assistants': {body_str}" - ); -} - -#[tokio::test] -async fn should_pass_expires_after_when_provided() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/files")) - .respond_with(ResponseTemplate::new(200).set_body_json(file_response_body("file-abc123"))) - .mount(&server) - .await; - - let provider = provider(&server); - let files = provider.files(); - - let mut po = HashMap::new(); - po.insert( - "openai".to_string(), - json!({ "purpose": "assistants", "expiresAfter": 3600 }), - ); - let opts = upload_options(Some(po)); - - files.upload_file(&opts).await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body_str = String::from_utf8_lossy(&requests[0].body); - assert!( - body_str.contains("3600"), - "expires_after should be '3600' in multipart body: {body_str}" - ); -} - -#[tokio::test] -async fn should_pass_auth_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/files")) - .respond_with(ResponseTemplate::new(200).set_body_json(file_response_body("file-abc123"))) - .mount(&server) - .await; - - let mut extra_headers = HashMap::new(); - extra_headers.insert("Custom-Header".to_string(), "custom-value".to_string()); - - let config = OpenAIConfig::new("test-api-key") - .with_base_url(server.uri()) - .with_org_id("test-org") - .with_project("test-project") - .with_headers(extra_headers); - let provider = OpenAIProvider::new(config); - let files = provider.files(); - - let mut po = HashMap::new(); - po.insert("openai".to_string(), json!({ "purpose": "assistants" })); - let opts = upload_options(Some(po)); - - files.upload_file(&opts).await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - let headers = &requests[0].headers; - assert_eq!( - headers.get("authorization").and_then(|v| v.to_str().ok()), - Some("Bearer test-api-key") - ); - assert_eq!( - headers - .get("openai-organization") - .and_then(|v| v.to_str().ok()), - Some("test-org") - ); - assert_eq!( - headers.get("openai-project").and_then(|v| v.to_str().ok()), - Some("test-project") - ); - assert_eq!( - headers.get("custom-header").and_then(|v| v.to_str().ok()), - Some("custom-value") - ); -} - -#[tokio::test] -async fn should_handle_base64_string_data() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/files")) - .respond_with(ResponseTemplate::new(200).set_body_json(file_response_body("file-abc123"))) - .mount(&server) - .await; - - let provider = provider(&server); - let files = provider.files(); - - // "hello world" encoded as base64. - let opts = UploadFileCallOptions { - data: UploadFileData::Data { - data: FileBytes::Base64("aGVsbG8gd29ybGQ=".to_string()), - }, - media_type: "application/octet-stream".to_string(), - filename: None, - provider_options: None, - abort_signal: None, - }; - - let result = files.upload_file(&opts).await.unwrap(); - - assert_eq!( - result.provider_reference.get("openai"), - Some(&"file-abc123".to_string()) - ); -} - -#[tokio::test] -async fn should_set_specification_version_and_provider() { - let provider = OpenAIProvider::new(OpenAIConfig::new("test-api-key")); - let files = provider.files(); - - assert_eq!(files.specification_version(), "v4"); - assert_eq!(files.provider(), "openai.files"); -} - -/// A transient 503 followed by 200 must succeed: `upload_file` retries the -/// upload exchange using the provider's configured retry settings, the same -/// way `list_models` already does. -#[tokio::test] -async fn transient_failure_is_retried_and_succeeds() { - let server = MockServer::start().await; - let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let responder_attempts = std::sync::Arc::clone(&attempts); - Mock::given(method("POST")) - .and(path("/files")) - .respond_with(move |_: &wiremock::Request| { - if responder_attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 { - ResponseTemplate::new(503) - .insert_header("retry-after-ms", "0") - .set_body_json(json!({"error": {"message": "try again"}})) - } else { - ResponseTemplate::new(200).set_body_json(file_response_body("file-retried")) - } - }) - .mount(&server) - .await; - - let config = OpenAIConfig::new("test-api-key") - .with_base_url(server.uri()) - .with_retry_config(aimux_core::retry::RetryConfig { - max_retries: 1, - ..Default::default() - }); - let provider = OpenAIProvider::new(config); - let files = provider.files(); - - let result = files.upload_file(&upload_options(None)).await.unwrap(); - - assert_eq!( - result.provider_reference.get("openai"), - Some(&"file-retried".to_string()) - ); - assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 2); -} 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_provider_test.rs b/aimux-providers/tests/openai_provider_test.rs deleted file mode 100644 index 4dcfe790b..000000000 --- a/aimux-providers/tests/openai_provider_test.rs +++ /dev/null @@ -1,282 +0,0 @@ -//! OpenAI provider configuration and forward-compatible defaults tests. -//! -//! Translates the chat-completions-relevant parts of: -//! - `packages/openai/src/openai-provider.test.ts` — baseURL config, chat routing -//! - `packages/openai/src/openai-forward-compatible-defaults.test.ts` — reasoning-safe defaults -//! -//! Responses API / embedding / image parts are excluded (covered by B1/C1/C2). - -use serde_json::{Value, json}; -use serial_test::serial; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::CallOptions; -use aimux_core::provider::Provider; - -use aimux_providers::{OpenAIConfig, OpenAIProvider}; - -// ── helpers ─────────────────────────────────────────────────────────────────── - -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -fn text_completion_body() -> Value { - json!({ - "id": "chatcmpl-test", - "object": "chat.completion", - "created": 1711115037, - "model": "test-model", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "ok" }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2 } - }) -} - -// ════════════════════════════════════════════════════════════════════════════ -// Provider configuration (openai-provider.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod provider_config { - use super::*; - - /// TS: `createOpenAI()` provider name is "openai". - #[test] - fn provider_name_is_openai() { - let config = OpenAIConfig::new("test-key"); - let provider = OpenAIProvider::new(config); - assert_eq!(provider.name(), "openai"); - } - - /// TS: default base URL is `https://api.openai.com/v1`. - #[test] - fn default_base_url() { - let config = OpenAIConfig::new("test-key"); - assert_eq!(config.base_url, "https://api.openai.com/v1"); - } - - /// TS: custom base URL overrides the default. - #[test] - fn custom_base_url_override() { - let config = - OpenAIConfig::new("test-key").with_base_url("https://proxy.openai.example/v1/"); - // `with_base_url` strips trailing slash. - assert_eq!(config.base_url, "https://proxy.openai.example/v1"); - } - - /// TS: `from_env` loads `OPENAI_API_KEY`. - #[serial] - #[test] - fn from_env_loads_openai_api_key() { - let saved = std::env::var("OPENAI_API_KEY").ok(); - unsafe { std::env::set_var("OPENAI_API_KEY", "env-test-key") }; - - let config = OpenAIConfig::from_env(); - assert!(config.is_ok(), "from_env should succeed with env var set"); - - unsafe { - match saved { - Some(v) => std::env::set_var("OPENAI_API_KEY", v), - None => std::env::remove_var("OPENAI_API_KEY"), - } - } - } - - /// TS: `from_env` fails without the env var. - #[serial] - #[test] - fn from_env_fails_without_env_var() { - let saved = std::env::var("OPENAI_API_KEY").ok(); - unsafe { std::env::remove_var("OPENAI_API_KEY") }; - - let config = OpenAIConfig::from_env(); - assert!(config.is_err(), "from_env should fail without env var"); - - unsafe { - if let Some(v) = saved { - std::env::set_var("OPENAI_API_KEY", v); - } - } - } - - /// TS: chat completions API routes to `/chat/completions`. - #[tokio::test] - async fn chat_routes_to_chat_completions() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-4o-mini"); - - model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - } - - /// TS: custom API key is sent in Authorization header. - #[tokio::test] - async fn custom_api_key_in_auth_header() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let config = OpenAIConfig::new("my-custom-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-4o"); - - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - assert_eq!( - requests[0] - .headers - .get("authorization") - .and_then(|v| v.to_str().ok()), - Some("Bearer my-custom-key"), - ); - } - - /// TS: `languageModel` via Provider trait creates a working model. - #[tokio::test] - async fn language_model_via_trait() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider - .language_model("gpt-4o") - .expect("language_model should succeed"); - - model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - } - - /// TS: org ID is sent in the OpenAI-Organization header. - #[tokio::test] - async fn org_id_in_header() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let config = OpenAIConfig::new("test-key") - .with_base_url(server.uri()) - .with_org_id("org-123"); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-4o"); - - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - assert_eq!( - requests[0] - .headers - .get("openai-organization") - .and_then(|v| v.to_str().ok()), - Some("org-123"), - ); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// Forward-compatible defaults (openai-forward-compatible-defaults.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod forward_compatible_defaults { - use super::*; - - /// TS: gpt-99 (reasoning model) should use reasoning-safe Chat Completions - /// defaults: system→developer, max_completion_tokens instead of max_tokens, - /// temperature/top_p/penalties/logit_bias/logprobs stripped. - /// - /// We verify by checking the request body sent to the mock server. - #[tokio::test] - async fn reasoning_model_uses_safe_defaults() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("o1"); - - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("Follow the instructions.")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Say ok.")], - ..Default::default() - }, - ]; - let mut options = CallOptions::new(prompt); - options.max_output_tokens = Some(64); - options.temperature = Some(0.2); - options.top_p = Some(0.8); - - let _ = model.do_generate(&options).await.expect("should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - - // Reasoning models use `max_completion_tokens` not `max_tokens`. - assert!( - body.get("max_completion_tokens").is_some(), - "should use max_completion_tokens for reasoning models" - ); - // System message should be converted to developer for reasoning models. - assert_eq!( - body["messages"][0]["role"], "developer", - "system should become developer for reasoning models" - ); - } -} diff --git a/aimux-providers/tests/openai_responses_test.rs b/aimux-providers/tests/openai_responses_test.rs deleted file mode 100644 index 07c01a1c5..000000000 --- a/aimux-providers/tests/openai_responses_test.rs +++ /dev/null @@ -1,1523 +0,0 @@ -//! Rust translations of the AI SDK OpenAI Responses API tests. -//! -//! Sources (TS -> Rust): -//! - `packages/openai/src/responses/openai-responses-language-model.test.ts` -//! `describe('doGenerate')` request-body + response-parsing cases -//! - `packages/openai/src/responses/openai-responses-language-model.test.ts` -//! `describe('doStream')` streaming cases -//! -//! Covers the core paths: -//! 1. Request building: input array, instructions, store, previous_response_id, -//! reasoning (effort/summary), response_format (json_schema/json_object) -//! 2. Stream parsing: response.created -> output_item.added -> output_text.delta -//! -> output_text.done -> output_item.done -> response.completed -//! 3. function tool calls (function_call_arguments.delta/done) -//! 4. reasoning summary streaming -//! -//! Each test uses `wiremock` to spin up a mock HTTP server, configures a JSON -//! or SSE response, creates an `OpenAIResponsesModel` pointing at the mock, -//! calls `do_generate` / `do_stream`, and asserts on the result. - -use futures::StreamExt; -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::{CallOptions, Tool, ToolChoice}; -use aimux_core::result::{GenerateContent, StreamResult}; -use aimux_core::stream_part::StreamPart; -use aimux_core::tool::FunctionTool; -use aimux_core::types::FinishReasonUnified; - -use aimux_providers::{OpenAIConfig, OpenAIProvider}; - -// -- helpers ----------------------------------------------------------------- - -/// The TS `TEST_PROMPT`: a single user text message "Hello". -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -/// Build `CallOptions` with everything unset except `prompt`. -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -/// A simple function tool named `weather`. -fn weather_tool() -> FunctionTool { - FunctionTool { - name: "weather".to_string(), - description: None, - input_schema: json!({ - "type": "object", - "properties": { "location": { "type": "string" } }, - "required": ["location"], - "additionalProperties": false, - }), - strict: None, - provider_options: None, - input_examples: None, - } -} - -/// Standard mock for a JSON responses-api response. -async fn mock_json_response(server: &MockServer, body: Value) { - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(body)) - .mount(server) - .await; -} - -/// Standard mock for an SSE streaming response. -async fn mock_sse_response(server: &MockServer, sse_body: &str) { - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body.to_string()), - ) - .mount(server) - .await; -} - -/// Build an SSE event string from a JSON value. -fn sse_event(json_str: &str) -> String { - format!("data: {json_str}\n\n") -} - -/// Concatenate SSE events and append `[DONE]`. -fn sse_body(events: &[&str]) -> String { - let mut body = String::new(); - for event in events { - body.push_str(event); - } - body.push_str("data: [DONE]\n\n"); - body -} - -/// Collect all `StreamPart`s from a `StreamResult` into a `Vec`. -async fn collect_stream(result: StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -/// Extract the first recorded request body as a JSON value. -async fn first_request_body(server: &MockServer) -> Value { - let requests = server - .received_requests() - .await - .expect("no requests received"); - serde_json::from_slice(&requests[0].body).expect("invalid JSON body") -} - -/// A basic text response body (single message output item). -fn text_response_body() -> Value { - json!({ - "id": "resp_67c97c0203188190a025beb4a75242bc", - "object": "response", - "created_at": 1741257730, - "status": "completed", - "error": null, - "incomplete_details": null, - "model": "gpt-4o-2024-07-18", - "output": [ - { - "id": "msg_67c97c02656c81908e080dfdf4a03cd1", - "type": "message", - "status": "completed", - "role": "assistant", - "content": [ - { - "type": "output_text", - "text": "answer text", - "annotations": [] - } - ] - } - ], - "usage": { - "input_tokens": 345, - "input_tokens_details": { - "cached_tokens": 234, - "cache_write_tokens": 45 - }, - "output_tokens": 538, - "output_tokens_details": { - "reasoning_tokens": 123 - } - }, - "reasoning": { "effort": null, "summary": null, "context": "current_turn" } - }) -} - -// ============================================================================ -// doGenerate -- request body construction -// (openai-responses-language-model.test.ts -> describe('doGenerate')) -// ============================================================================ - -mod do_generate_request { - use super::*; - - // -- should send model id, settings, and input -- - - /// TS: "should send model id, settings, and input" - #[tokio::test] - async fn should_send_model_id_settings_and_input() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a helpful assistant.")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - ]; - let options = CallOptions { - temperature: Some(0.5), - top_p: Some(0.3), - provider_options: Some({ - let mut m = std::collections::HashMap::new(); - m.insert("openai".to_string(), json!({ "maxToolCalls": 10 })); - m - }), - ..CallOptions::new(prompt) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["model"], "gpt-4o"); - assert_eq!(body["input"][0]["role"], "system"); - assert_eq!(body["input"][0]["content"], "You are a helpful assistant."); - assert_eq!(body["input"][1]["role"], "user"); - assert_eq!(body["input"][1]["content"][0]["type"], "input_text"); - assert_eq!(body["input"][1]["content"][0]["text"], "Hello"); - assert_eq!(body["temperature"], 0.5); - assert_eq!(body["top_p"], 0.3); - assert_eq!(body["max_tool_calls"], 10); - } - - // -- should send response format json schema -- - - /// TS: "should send response format json schema" - #[tokio::test] - async fn should_send_response_format_json_schema() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let options = CallOptions { - response_format: Some(aimux_core::options::ResponseFormat::Json { - schema: Some(json!({ - "type": "object", - "properties": { "value": { "type": "string" } }, - "required": ["value"], - "additionalProperties": false, - })), - name: Some("response".to_string()), - description: Some("A response".to_string()), - }), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["text"]["format"]["type"], "json_schema"); - assert_eq!(body["text"]["format"]["strict"], true); - assert_eq!(body["text"]["format"]["name"], "response"); - assert_eq!(body["text"]["format"]["description"], "A response"); - assert_eq!(body["text"]["format"]["schema"]["type"], "object"); - } - - // -- should send response format json object -- - - /// TS: "should send response format json object" - #[tokio::test] - async fn should_send_response_format_json_object() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let options = CallOptions { - response_format: Some(aimux_core::options::ResponseFormat::Json { - schema: None, - name: None, - description: None, - }), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["text"]["format"]["type"], "json_object"); - } - - // -- should remove unsupported settings for o1 -- - - /// TS: "should remove unsupported settings for o1" - #[tokio::test] - async fn should_remove_unsupported_settings_for_o1() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("o1"); - - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a helpful assistant.")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - ]; - let options = CallOptions { - temperature: Some(0.5), - top_p: Some(0.3), - ..CallOptions::new(prompt) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - - let body = first_request_body(&server).await; - assert_eq!(body["model"], "o1"); - // system message uses "developer" role for reasoning models - assert_eq!(body["input"][0]["role"], "developer"); - assert_eq!(body["input"][0]["content"], "You are a helpful assistant."); - assert_eq!(body["input"][1]["role"], "user"); - assert_eq!(body["input"][1]["content"][0]["type"], "input_text"); - // temperature and top_p should be absent - assert!(body.get("temperature").is_none()); - assert!(body.get("top_p").is_none()); - - // warnings for temperature and topP - assert_eq!(result.warnings.len(), 2); - } - - // -- should send store = false for reasoning model -- - - /// TS: "should send store = false provider option and opt into - /// reasoning.encrypted_content for reasoning models" - #[tokio::test] - async fn should_send_store_false_reasoning_model() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-5-mini"); - - let options = CallOptions { - provider_options: Some({ - let mut m = std::collections::HashMap::new(); - m.insert("openai".to_string(), json!({ "store": false })); - m - }), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["model"], "gpt-5-mini"); - assert_eq!(body["store"], false); - assert_eq!(body["include"][0], "reasoning.encrypted_content"); - } - - // -- should send store = false for non-reasoning model -- - - /// TS: "should send store = false provider option and not opt into - /// reasoning.encrypted_content for non-reasoning models" - #[tokio::test] - async fn should_send_store_false_non_reasoning_model() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let options = CallOptions { - provider_options: Some({ - let mut m = std::collections::HashMap::new(); - m.insert("openai".to_string(), json!({ "store": false })); - m - }), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["store"], false); - assert!(body.get("include").is_none()); - } - - // -- should send store = true -- - - /// TS: "should send store = true provider option without - /// reasoning.encrypted_content" - #[tokio::test] - async fn should_send_store_true() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let options = CallOptions { - provider_options: Some({ - let mut m = std::collections::HashMap::new(); - m.insert("openai".to_string(), json!({ "store": true })); - m - }), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["store"], true); - assert!(body.get("include").is_none()); - } - - // -- should send previous response id -- - - /// TS: "should send previous response id provider option" - #[tokio::test] - async fn should_send_previous_response_id() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let options = CallOptions { - provider_options: Some({ - let mut m = std::collections::HashMap::new(); - m.insert( - "openai".to_string(), - json!({ "previousResponseId": "resp_123" }), - ); - m - }), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["previous_response_id"], "resp_123"); - } - - // -- should warn when both conversation and previousResponseId -- - - /// TS: "should warn when both conversation and previousResponseId are - /// provided" - #[tokio::test] - async fn should_warn_when_conversation_and_previous_response_id() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let options = CallOptions { - provider_options: Some({ - let mut m = std::collections::HashMap::new(); - m.insert( - "openai".to_string(), - json!({ "conversation": "conv_123", "previousResponseId": "resp_123" }), - ); - m - }), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - - let body = first_request_body(&server).await; - assert_eq!(body["conversation"], "conv_123"); - assert_eq!(body["previous_response_id"], "resp_123"); - - assert_eq!(result.warnings.len(), 1); - match &result.warnings[0] { - aimux_core::types::Warning::Unsupported { feature, .. } => { - assert_eq!(feature, "conversation"); - } - other => panic!("expected Unsupported warning, got {other:?}"), - } - } - - // -- should send reasoningEffort and reasoningSummary -- - - /// TS: "should send reasoningEffort and reasoningSummary provider options" - #[tokio::test] - async fn should_send_reasoning_effort_and_summary() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("o3-mini"); - - let options = CallOptions { - provider_options: Some({ - let mut m = std::collections::HashMap::new(); - m.insert( - "openai".to_string(), - json!({ "reasoningEffort": "low", "reasoningSummary": "auto" }), - ); - m - }), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["model"], "o3-mini"); - assert_eq!(body["reasoning"]["effort"], "low"); - assert_eq!(body["reasoning"]["summary"], "auto"); - } - - // -- should send reasoning with detailed summary when only effort is set -- - - /// TS: "should send xhigh reasoningEffort for codex-max model" (verifies - /// that `summary` defaults to "detailed" when effort is non-"none") - #[tokio::test] - async fn should_default_reasoning_summary_to_detailed() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-5.1-codex-max"); - - let options = CallOptions { - provider_options: Some({ - let mut m = std::collections::HashMap::new(); - m.insert("openai".to_string(), json!({ "reasoningEffort": "xhigh" })); - m - }), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["reasoning"]["effort"], "xhigh"); - assert_eq!(body["reasoning"]["summary"], "detailed"); - } - - // -- should send parallelToolCalls -- - - /// TS: "should send parallelToolCalls provider option" - #[tokio::test] - async fn should_send_parallel_tool_calls() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let options = CallOptions { - provider_options: Some({ - let mut m = std::collections::HashMap::new(); - m.insert("openai".to_string(), json!({ "parallelToolCalls": false })); - m - }), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["parallel_tool_calls"], false); - } - - // -- should send user -- - - /// TS: "should send user provider option" - #[tokio::test] - async fn should_send_user_option() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let options = CallOptions { - provider_options: Some({ - let mut m = std::collections::HashMap::new(); - m.insert("openai".to_string(), json!({ "user": "user_123" })); - m - }), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["user"], "user_123"); - } - - // -- should send conversation -- - - /// TS: "should send conversation provider option" - #[tokio::test] - async fn should_send_conversation() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let options = CallOptions { - provider_options: Some({ - let mut m = std::collections::HashMap::new(); - m.insert("openai".to_string(), json!({ "conversation": "conv_123" })); - m - }), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["conversation"], "conv_123"); - } - - // -- should send function tools -- - - /// TS: tools are prepared into the Responses `tools` array. - #[tokio::test] - async fn should_send_function_tools() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let options = CallOptions { - tools: Some(vec![Tool::from(weather_tool())]), - tool_choice: ToolChoice::Auto, - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["tools"][0]["type"], "function"); - assert_eq!(body["tools"][0]["name"], "weather"); - assert_eq!(body["tools"][0]["parameters"]["type"], "object"); - } - - // -- should warn about unsupported topK -- - - /// TS: topK is unsupported for the Responses API. - #[tokio::test] - async fn should_warn_about_topk() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let options = CallOptions { - top_k: Some(40.0), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.iter().any(|w| match w { - aimux_core::types::Warning::Unsupported { feature, .. } => feature == "topK", - _ => false, - })); - } -} - -// ============================================================================ -// doGenerate -- response parsing -// ============================================================================ - -mod do_generate_response { - use super::*; - - // -- should generate text -- - - /// TS: "should generate text" - #[tokio::test] - async fn should_generate_text() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::Text { text, .. } => assert_eq!(text, "answer text"), - other => panic!("expected Text, got {other:?}"), - } - } - - // -- should extract usage -- - - /// TS: "should extract usage" - #[tokio::test] - async fn should_extract_usage() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.usage.input_tokens.total, Some(345)); - assert_eq!(result.usage.input_tokens.cache_read, Some(234)); - assert_eq!(result.usage.input_tokens.cache_write, Some(45)); - assert_eq!(result.usage.input_tokens.no_cache, Some(66)); - assert_eq!(result.usage.output_tokens.total, Some(538)); - assert_eq!(result.usage.output_tokens.reasoning, Some(123)); - assert_eq!(result.usage.output_tokens.text, Some(415)); - } - - // -- should extract response id metadata -- - - /// TS: "should extract response id metadata" - #[tokio::test] - async fn should_extract_response_id_metadata() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!( - result.response.id.as_deref(), - Some("resp_67c97c0203188190a025beb4a75242bc") - ); - assert_eq!( - result.response.model_id.as_deref(), - Some("gpt-4o-2024-07-18") - ); - - // providerMetadata should contain responseId and reasoningContext. - let pm = result - .provider_metadata - .as_ref() - .and_then(|v| v.get("openai")) - .expect("provider_metadata.openai should exist"); - assert_eq!(pm["responseId"], "resp_67c97c0203188190a025beb4a75242bc"); - assert_eq!(pm["reasoningContext"], "current_turn"); - } - - // -- should throw error when no output -- - - /// TS: "should throw a descriptive error when the response has no output" - #[tokio::test] - async fn should_throw_when_no_output() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "resp_no_output", - "object": "response", - "created_at": 1741257730, - "status": "incomplete", - "error": null, - "incomplete_details": { "reason": "content_filter" }, - "model": "gpt-4o-2024-07-18", - "usage": { - "input_tokens": 10, - "output_tokens": 0 - } - }), - ) - .await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let err = model - .do_generate(&default_options(test_prompt())) - .await - .expect_err("should error"); - - let msg = err.to_string(); - assert!( - msg.contains("no output"), - "error should mention 'no output', got: {msg}" - ); - assert!( - msg.contains("content_filter"), - "error should mention 'content_filter', got: {msg}" - ); - } - - // -- should generate tool-call (function_call) -- - - /// TS: function_call output items map to tool-call content. - #[tokio::test] - async fn should_generate_tool_call() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "resp_fc", - "object": "response", - "created_at": 1741257730, - "status": "completed", - "error": null, - "incomplete_details": null, - "model": "gpt-4o-2024-07-18", - "output": [ - { - "type": "function_call", - "id": "fc_123", - "call_id": "call_abc", - "name": "weather", - "arguments": "{\"location\":\"San Francisco\"}" - } - ], - "usage": { - "input_tokens": 50, - "output_tokens": 10 - } - }), - ) - .await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let options = CallOptions { - tools: Some(vec![Tool::from(weather_tool())]), - tool_choice: ToolChoice::Auto, - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::ToolCall { - 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. - #[tokio::test] - async fn should_generate_reasoning() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "resp_rs", - "object": "response", - "created_at": 1741257730, - "status": "completed", - "error": null, - "incomplete_details": null, - "model": "o3-mini", - "output": [ - { - "type": "reasoning", - "id": "rs_123", - "summary": [ - { "type": "summary_text", "text": "thinking..." } - ] - }, - { - "id": "msg_1", - "type": "message", - "status": "completed", - "role": "assistant", - "content": [ - { "type": "output_text", "text": "answer", "annotations": [] } - ] - } - ], - "usage": { - "input_tokens": 10, - "output_tokens": 20, - "output_tokens_details": { "reasoning_tokens": 15 } - } - }), - ) - .await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("o3-mini"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.content.len(), 2); - match &result.content[0] { - GenerateContent::Reasoning { text, .. } => assert_eq!(text, "thinking..."), - other => panic!("expected Reasoning, got {other:?}"), - } - match &result.content[1] { - GenerateContent::Text { text, .. } => assert_eq!(text, "answer"), - other => panic!("expected Text, got {other:?}"), - } - assert_eq!(result.usage.output_tokens.reasoning, Some(15)); - assert_eq!(result.usage.output_tokens.text, Some(5)); - } -} - -// ============================================================================ -// doStream -- streaming -// (openai-responses-language-model.test.ts -> describe('doStream')) -// ============================================================================ - -mod do_stream { - use super::*; - - // -- should stream text deltas -- - - /// TS: "should stream text deltas" - /// - /// Verifies the main streaming path: - /// response.created -> output_item.added (message) -> output_text.delta -> - /// output_item.done (message) -> response.completed. - #[tokio::test] - async fn should_stream_text_deltas() { - let server = MockServer::start().await; - let chunks = sse_body(&[ - &sse_event( - r#"{"type":"response.created","response":{"id":"resp_1","created_at":1741269019,"model":"gpt-4o-2024-07-18"}}"#, - ), - &sse_event( - r#"{"type":"response.output_item.added","output_index":0,"item":{"id":"msg_1","type":"message","status":"in_progress","role":"assistant","content":[]}}"#, - ), - &sse_event( - r#"{"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"delta":"Hello,"}"#, - ), - &sse_event( - r#"{"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"delta":" World!"}"#, - ), - &sse_event( - r#"{"type":"response.output_item.done","output_index":0,"item":{"id":"msg_1","type":"message","status":"completed","role":"assistant","content":[]}}"#, - ), - &sse_event( - r#"{"type":"response.completed","response":{"id":"resp_1","created_at":1741269019,"model":"gpt-4o-2024-07-18","incomplete_details":null,"usage":{"input_tokens":543,"input_tokens_details":{"cached_tokens":234},"output_tokens":478,"output_tokens_details":{"reasoning_tokens":123}}}}"#, - ), - ]); - mock_sse_response(&server, &chunks).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("should succeed"); - let parts = collect_stream(result).await; - - // Expected sequence: - // StreamStart, ResponseMetadata, TextStart, TextDelta("Hello,"), - // TextDelta(" World!"), TextEnd, Finish - assert_eq!(parts.len(), 7); - - assert!(matches!( - &parts[0], - StreamPart::StreamStart { warnings } if warnings.is_empty() - )); - - match &parts[1] { - StreamPart::ResponseMetadata { id, model_id, .. } => { - assert_eq!(id.as_deref(), Some("resp_1")); - assert_eq!(model_id.as_deref(), Some("gpt-4o-2024-07-18")); - } - other => panic!("expected ResponseMetadata, got {other:?}"), - } - - match &parts[2] { - StreamPart::TextStart { id, .. } => assert_eq!(id, "msg_1"), - other => panic!("expected TextStart, got {other:?}"), - } - - match &parts[3] { - StreamPart::TextDelta { id, delta, .. } => { - assert_eq!(id, "msg_1"); - assert_eq!(delta, "Hello,"); - } - other => panic!("expected TextDelta, got {other:?}"), - } - - match &parts[4] { - StreamPart::TextDelta { id, delta, .. } => { - assert_eq!(id, "msg_1"); - assert_eq!(delta, " World!"); - } - other => panic!("expected TextDelta, got {other:?}"), - } - - match &parts[5] { - StreamPart::TextEnd { id, .. } => assert_eq!(id, "msg_1"), - other => panic!("expected TextEnd, got {other:?}"), - } - - match &parts[6] { - StreamPart::Finish { - finish_reason, - usage, - .. - } => { - assert_eq!(finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(usage.input_tokens.total, Some(543)); - assert_eq!(usage.input_tokens.cache_read, Some(234)); - assert_eq!(usage.output_tokens.total, Some(478)); - assert_eq!(usage.output_tokens.reasoning, Some(123)); - assert_eq!(usage.output_tokens.text, Some(355)); - } - other => panic!("expected Finish, got {other:?}"), - } - } - - // -- should stream tool calls -- - - /// TS: "should send streaming tool calls" - /// - /// Verifies the function-call streaming path: - /// output_item.added (function_call) -> function_call_arguments.delta -> - /// function_call_arguments.done -> output_item.done (function_call). - #[tokio::test] - async fn should_stream_tool_calls() { - let server = MockServer::start().await; - let chunks = sse_body(&[ - &sse_event( - r#"{"type":"response.created","response":{"id":"resp_tc","created_at":1741362087,"model":"gpt-4o-2024-07-18"}}"#, - ), - &sse_event( - r#"{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_added","name":"weather","arguments":"","status":"completed"}}"#, - ), - &sse_event( - r#"{"type":"response.function_call_arguments.delta","item_id":"fc_1","output_index":0,"delta":"{\"location\":"}"#, - ), - &sse_event( - r#"{"type":"response.function_call_arguments.delta","item_id":"fc_1","output_index":0,"delta":"\"Rome\"}"}"#, - ), - &sse_event( - r#"{"type":"response.function_call_arguments.done","item_id":"fc_1","output_index":0,"arguments":"{\"location\":\"Rome\"}"}"#, - ), - &sse_event( - r#"{"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_done","name":"weather","arguments":"{\"location\":\"Rome\"}","status":"completed"}}"#, - ), - &sse_event( - r#"{"type":"response.completed","response":{"id":"resp_tc","created_at":1741362087,"model":"gpt-4o-2024-07-18","incomplete_details":null,"usage":{"input_tokens":0,"input_tokens_details":{"cached_tokens":0},"output_tokens":0,"output_tokens_details":{"reasoning_tokens":0}}}}"#, - ), - ]); - mock_sse_response(&server, &chunks).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let options = CallOptions { - tools: Some(vec![Tool::from(weather_tool())]), - tool_choice: ToolChoice::Auto, - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_stream(&options).await.expect("should succeed"); - let parts = collect_stream(result).await; - - // StreamStart, ResponseMetadata, ToolInputStart, ToolInputDelta x2, - // ToolInputEnd, ToolCall, Finish - assert_eq!(parts.len(), 8); - - // ToolInputStart uses the call_id from the added item. - match &parts[2] { - StreamPart::ToolInputStart { id, tool_name, .. } => { - assert_eq!(id, "call_added"); - assert_eq!(tool_name, "weather"); - } - other => panic!("expected ToolInputStart, got {other:?}"), - } - - // ToolInputDelta uses the ongoing tool call's id (from added item). - match &parts[3] { - StreamPart::ToolInputDelta { id, delta, .. } => { - assert_eq!(id, "call_added"); - assert!(delta.contains("location")); - } - other => panic!("expected ToolInputDelta, got {other:?}"), - } - match &parts[4] { - StreamPart::ToolInputDelta { id, delta, .. } => { - assert_eq!(id, "call_added"); - assert!(delta.contains("Rome")); - } - other => panic!("expected ToolInputDelta, got {other:?}"), - } - - // ToolInputEnd uses the call_id from the done item. - match &parts[5] { - StreamPart::ToolInputEnd { id, .. } => assert_eq!(id, "call_done"), - other => panic!("expected ToolInputEnd, got {other:?}"), - } - - // ToolCall uses the call_id and arguments from the done item. - match &parts[6] { - StreamPart::ToolCall { - 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. - /// - /// Verifies the reasoning streaming path: - /// output_item.added (reasoning) -> reasoning_summary_part.added -> - /// reasoning_summary_text.delta -> reasoning_summary_part.done -> - /// output_item.done (reasoning). - #[tokio::test] - async fn should_stream_reasoning_summary() { - let server = MockServer::start().await; - let chunks = sse_body(&[ - &sse_event( - r#"{"type":"response.created","response":{"id":"resp_rs","created_at":1741269019,"model":"o3-mini-2025-01-31"}}"#, - ), - &sse_event( - r#"{"type":"response.output_item.added","output_index":0,"item":{"id":"rs_1","type":"reasoning"}}"#, - ), - &sse_event( - r#"{"type":"response.reasoning_summary_part.added","item_id":"rs_1","summary_index":0}"#, - ), - &sse_event( - r#"{"type":"response.reasoning_summary_text.delta","item_id":"rs_1","summary_index":0,"delta":"thinking through the steps"}"#, - ), - &sse_event( - r#"{"type":"response.reasoning_summary_part.done","item_id":"rs_1","summary_index":0}"#, - ), - &sse_event( - r#"{"type":"response.output_item.done","output_index":0,"item":{"id":"rs_1","type":"reasoning","summary":[{"type":"summary_text","text":"thinking through the steps"}]}}"#, - ), - &sse_event( - r#"{"type":"response.completed","response":{"id":"resp_rs","created_at":1741269019,"model":"o3-mini-2025-01-31","incomplete_details":null,"usage":{"input_tokens":10,"input_tokens_details":{"cached_tokens":0},"output_tokens":20,"output_tokens_details":{"reasoning_tokens":20}}}}"#, - ), - ]); - mock_sse_response(&server, &chunks).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("o3-mini"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("should succeed"); - let parts = collect_stream(result).await; - - // StreamStart, ResponseMetadata, ReasoningStart, ReasoningDelta, - // ReasoningEnd, Finish - // (reasoning_summary_part.done with store=false defaults to - // can-conclude; the ReasoningEnd is emitted at output_item.done) - assert_eq!(parts.len(), 6); - - match &parts[2] { - StreamPart::ReasoningStart { id, .. } => { - assert_eq!(id, "rs_1:0"); - } - other => panic!("expected ReasoningStart, got {other:?}"), - } - - match &parts[3] { - StreamPart::ReasoningDelta { id, delta, .. } => { - assert_eq!(id, "rs_1:0"); - assert_eq!(delta, "thinking through the steps"); - } - other => panic!("expected ReasoningDelta, got {other:?}"), - } - - match &parts[4] { - StreamPart::ReasoningEnd { id, .. } => assert_eq!(id, "rs_1:0"), - other => panic!("expected ReasoningEnd, got {other:?}"), - } - - match &parts[5] { - StreamPart::Finish { finish_reason, .. } => { - assert_eq!(finish_reason.unified, FinishReasonUnified::Stop); - } - other => panic!("expected Finish, got {other:?}"), - } - } - - // -- should carry encrypted reasoning content in stream metadata -- - - /// Regression: the streaming reducer captured `encrypted_content` into - /// state and then discarded it (`let _ = encrypted;`) — every - /// ReasoningEnd carried `provider_metadata: None`, so encrypted - /// reasoning could not cross a turn with store=false. - #[tokio::test] - async fn should_stream_reasoning_encrypted_content_in_metadata() { - let server = MockServer::start().await; - let chunks = sse_body(&[ - &sse_event( - r#"{"type":"response.created","response":{"id":"resp_enc","created_at":1741269019,"model":"o3-mini-2025-01-31"}}"#, - ), - &sse_event( - r#"{"type":"response.output_item.added","output_index":0,"item":{"id":"rs_1","type":"reasoning","encrypted_content":"enc-blob-123"}}"#, - ), - &sse_event( - r#"{"type":"response.reasoning_summary_text.delta","item_id":"rs_1","summary_index":0,"delta":"thinking"}"#, - ), - &sse_event( - r#"{"type":"response.output_item.done","output_index":0,"item":{"id":"rs_1","type":"reasoning","encrypted_content":"enc-blob-123","summary":[{"type":"summary_text","text":"thinking"}]}}"#, - ), - &sse_event( - r#"{"type":"response.completed","response":{"id":"resp_enc","created_at":1741269019,"model":"o3-mini-2025-01-31","incomplete_details":null,"usage":{"input_tokens":10,"input_tokens_details":{"cached_tokens":0},"output_tokens":20,"output_tokens_details":{"reasoning_tokens":20}}}}"#, - ), - ]); - mock_sse_response(&server, &chunks).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("o3-mini"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("should succeed"); - let parts = collect_stream(result).await; - - let start = parts - .iter() - .find(|p| matches!(p, StreamPart::ReasoningStart { .. })) - .expect("ReasoningStart"); - match start { - StreamPart::ReasoningStart { - provider_metadata: Some(m), - .. - } => { - assert_eq!(m["openai"]["itemId"], json!("rs_1")); - assert_eq!( - m["openai"]["reasoningEncryptedContent"], - json!("enc-blob-123") - ); - } - other => panic!("ReasoningStart must carry metadata, got {other:?}"), - } - - let end = parts - .iter() - .find(|p| matches!(p, StreamPart::ReasoningEnd { .. })) - .expect("ReasoningEnd"); - match end { - StreamPart::ReasoningEnd { - id, - provider_metadata: Some(m), - } => { - assert_eq!(id, "rs_1:0"); - assert_eq!(m["openai"]["itemId"], json!("rs_1")); - assert_eq!( - m["openai"]["reasoningEncryptedContent"], - json!("enc-blob-123"), - "encrypted_content must ride ReasoningEnd so consume() can round-trip it" - ); - } - other => panic!("ReasoningEnd must carry metadata, got {other:?}"), - } - } - - /// Same path with no summary text at all: the ReasoningEnd must still - /// carry the metadata so `consume()` can keep the part in - /// response_messages. - #[tokio::test] - async fn should_stream_reasoning_encrypted_content_without_summary() { - let server = MockServer::start().await; - let chunks = sse_body(&[ - &sse_event( - r#"{"type":"response.created","response":{"id":"resp_enc2","created_at":1741269019,"model":"o3-mini-2025-01-31"}}"#, - ), - &sse_event( - r#"{"type":"response.output_item.added","output_index":0,"item":{"id":"rs_9","type":"reasoning","encrypted_content":"enc-no-summary"}}"#, - ), - &sse_event( - r#"{"type":"response.output_item.done","output_index":0,"item":{"id":"rs_9","type":"reasoning","encrypted_content":"enc-no-summary","summary":[]}}"#, - ), - &sse_event( - r#"{"type":"response.completed","response":{"id":"resp_enc2","created_at":1741269019,"model":"o3-mini-2025-01-31","incomplete_details":null,"usage":{"input_tokens":10,"input_tokens_details":{"cached_tokens":0},"output_tokens":20,"output_tokens_details":{"reasoning_tokens":20}}}}"#, - ), - ]); - mock_sse_response(&server, &chunks).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("o3-mini"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("should succeed"); - let parts = collect_stream(result).await; - - let end = parts - .iter() - .find(|p| matches!(p, StreamPart::ReasoningEnd { .. })) - .expect("ReasoningEnd even without any summary text"); - match end { - StreamPart::ReasoningEnd { - provider_metadata: Some(m), - .. - } => { - assert_eq!(m["openai"]["itemId"], json!("rs_9")); - assert_eq!( - m["openai"]["reasoningEncryptedContent"], - json!("enc-no-summary") - ); - } - other => panic!("ReasoningEnd must carry metadata even without summary, got {other:?}"), - } - } - - // -- should send finish reason for incomplete response -- - - /// TS: "should send finish reason for incomplete response" - /// - /// Verifies that `response.incomplete` with `incomplete_details.reason = - /// "max_output_tokens"` maps to finish reason `length`. - #[tokio::test] - async fn should_send_finish_reason_for_incomplete() { - let server = MockServer::start().await; - let chunks = sse_body(&[ - &sse_event( - r#"{"type":"response.created","response":{"id":"resp_inc","created_at":1741269019,"model":"gpt-4o-2024-07-18"}}"#, - ), - &sse_event( - r#"{"type":"response.output_item.added","output_index":0,"item":{"id":"msg_1","type":"message","status":"in_progress","role":"assistant","content":[]}}"#, - ), - &sse_event( - r#"{"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"delta":"Hello,"}"#, - ), - &sse_event( - r#"{"type":"response.output_item.done","output_index":0,"item":{"id":"msg_1","type":"message","status":"incomplete","role":"assistant","content":[]}}"#, - ), - &sse_event( - r#"{"type":"response.incomplete","response":{"id":"resp_inc","created_at":1741347648,"model":"gpt-4o-2024-07-18","incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":0,"input_tokens_details":{"cached_tokens":0},"output_tokens":0,"output_tokens_details":{"reasoning_tokens":0}}}}"#, - ), - ]); - mock_sse_response(&server, &chunks).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("should succeed"); - let parts = collect_stream(result).await; - - let finish = parts - .iter() - .find(|p| matches!(p, StreamPart::Finish { .. })) - .expect("should have a Finish part"); - match finish { - StreamPart::Finish { finish_reason, .. } => { - assert_eq!(finish_reason.unified, FinishReasonUnified::Length); - assert_eq!(finish_reason.raw.as_deref(), Some("max_output_tokens")); - } - _ => unreachable!(), - } - } - - // -- should stream text with store=true (reasoning-end at summary_part.done) -- - - /// Verifies that when `store=true`, reasoning summary parts are concluded - /// immediately at `reasoning_summary_part.done` (not deferred to - /// `output_item.done`). - #[tokio::test] - async fn should_conclude_reasoning_at_summary_done_when_store_true() { - let server = MockServer::start().await; - let chunks = sse_body(&[ - &sse_event( - r#"{"type":"response.created","response":{"id":"resp_rs","created_at":1741269019,"model":"o3-mini-2025-01-31"}}"#, - ), - &sse_event( - r#"{"type":"response.output_item.added","output_index":0,"item":{"id":"rs_1","type":"reasoning"}}"#, - ), - &sse_event( - r#"{"type":"response.reasoning_summary_part.added","item_id":"rs_1","summary_index":0}"#, - ), - &sse_event( - r#"{"type":"response.reasoning_summary_text.delta","item_id":"rs_1","summary_index":0,"delta":"thinking"}"#, - ), - &sse_event( - r#"{"type":"response.reasoning_summary_part.done","item_id":"rs_1","summary_index":0}"#, - ), - &sse_event( - r#"{"type":"response.output_item.done","output_index":0,"item":{"id":"rs_1","type":"reasoning","summary":[{"type":"summary_text","text":"thinking"}]}}"#, - ), - &sse_event( - r#"{"type":"response.completed","response":{"id":"resp_rs","created_at":1741269019,"model":"o3-mini-2025-01-31","incomplete_details":null,"usage":{"input_tokens":10,"input_tokens_details":{"cached_tokens":0},"output_tokens":20,"output_tokens_details":{"reasoning_tokens":20}}}}"#, - ), - ]); - mock_sse_response(&server, &chunks).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("o3-mini"); - - let options = CallOptions { - provider_options: Some({ - let mut m = std::collections::HashMap::new(); - m.insert("openai".to_string(), json!({ "store": true })); - m - }), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_stream(&options).await.expect("should succeed"); - let parts = collect_stream(result).await; - - // With store=true, ReasoningEnd should be emitted at - // reasoning_summary_part.done (before output_item.done), so no - // duplicate ReasoningEnd at output_item.done. - let reasoning_ends: Vec<&StreamPart> = parts - .iter() - .filter(|p| matches!(p, StreamPart::ReasoningEnd { .. })) - .collect(); - assert_eq!( - reasoning_ends.len(), - 1, - "should have exactly one ReasoningEnd with store=true" - ); - } - - // -- should emit provider metadata with responseId in finish -- - - /// TS: finish event should carry providerMetadata.openai.responseId. - #[tokio::test] - async fn should_emit_response_id_in_finish() { - let server = MockServer::start().await; - let chunks = sse_body(&[ - &sse_event( - r#"{"type":"response.created","response":{"id":"resp_meta","created_at":1741269019,"model":"gpt-4o-2024-07-18"}}"#, - ), - &sse_event( - r#"{"type":"response.completed","response":{"id":"resp_meta","created_at":1741269019,"model":"gpt-4o-2024-07-18","incomplete_details":null,"usage":{"input_tokens":0,"input_tokens_details":{"cached_tokens":0},"output_tokens":0,"output_tokens_details":{"reasoning_tokens":0}}}}"#, - ), - ]); - mock_sse_response(&server, &chunks).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("should succeed"); - let parts = collect_stream(result).await; - - let finish = parts - .iter() - .find(|p| matches!(p, StreamPart::Finish { .. })) - .expect("should have a Finish part"); - match finish { - StreamPart::Finish { - provider_metadata, .. - } => { - let pm = provider_metadata - .as_ref() - .and_then(|v| v.get("openai")) - .expect("provider_metadata.openai should exist"); - assert_eq!(pm["responseId"], "resp_meta"); - } - _ => unreachable!(), - } - } -} diff --git a/aimux-providers/tests/stream_error_semantics_test.rs b/aimux-providers/tests/stream_error_semantics_test.rs deleted file mode 100644 index 734ef9df2..000000000 --- a/aimux-providers/tests/stream_error_semantics_test.rs +++ /dev/null @@ -1,139 +0,0 @@ -//! Stream-error classification and termination semantics (review follow-ups). -//! -//! Locks three behaviors: -//! - no fabricated HTTP 500 for non-numeric provider error codes (the "M3 -//! bug"): such errors carry no status and are not retryable; -//! - payload `retry_after_ms` hints surface through `retry_after_hint()`; -//! - the shared Responses reducer ends its stream right after a terminal -//! error event instead of waiting on a source that stays open. - -use std::collections::HashMap; - -use futures::StreamExt; -use serde_json::json; - -use aimux_core::AiMuxError; -use aimux_core::stream_part::StreamPart; -use aimux_core::types::FinishReasonUnified; -use aimux_provider_utils::stream_error_api_call; -use aimux_providers::openai::responses::responses_convert::build_responses_event_stream; - -fn as_api_call(error: &AiMuxError) -> &aimux_core::ApiCallError { - match error { - AiMuxError::ApiCall(detail) => detail, - other => panic!("expected ApiCall, got {other:?}"), - } -} - -#[test] -fn string_error_code_is_not_a_retryable_500() { - let payload = json!({"message": "Incorrect API key", "code": "invalid_api_key"}); - let error = stream_error_api_call( - "Incorrect API key", - Some("invalid_api_key".into()), - None, - &payload, - "https://example.test", - json!({}), - HashMap::new(), - ); - let detail = as_api_call(&error); - assert_eq!(detail.status_code, None); - assert!(!error.is_retryable()); -} - -#[test] -fn numeric_status_keeps_status_based_retryability() { - let payload = json!({"message": "rate limited", "code": 429}); - let error = stream_error_api_call( - "rate limited", - Some("429".into()), - Some(429), - &payload, - "https://example.test", - json!({}), - HashMap::new(), - ); - assert_eq!(as_api_call(&error).status_code, Some(429)); - assert!(error.is_retryable()); -} - -#[test] -fn payload_retry_after_ms_surfaces_as_hint() { - let payload = json!({"message": "rate limited", "code": 429, "retry_after_ms": 15000}); - let error = stream_error_api_call( - "rate limited", - None, - Some(429), - &payload, - "https://example.test", - json!({}), - HashMap::new(), - ); - assert_eq!(error.retry_after_hint(), Some(15000)); -} - -#[test] -fn real_retry_after_header_wins_over_payload() { - let payload = json!({"message": "rate limited", "retry_after_ms": 15000}); - let headers = HashMap::from([("retry-after-ms".to_string(), "7".to_string())]); - let error = stream_error_api_call( - "rate limited", - None, - Some(429), - &payload, - "https://example.test", - json!({}), - HashMap::new(), - ); - assert_eq!(error.retry_after_hint(), Some(15000)); - let error = stream_error_api_call( - "rate limited", - None, - Some(429), - &payload, - "https://example.test", - json!({}), - headers, - ); - assert_eq!(error.retry_after_hint(), Some(7)); -} - -#[tokio::test] -async fn responses_stream_ends_after_terminal_error_event() { - // First event is benign so the peek passes; the mid-stream error event is - // followed by a source that never closes (heartbeat-style server). - let first = json!({"type": "response.created", "response": {"id": "resp_1"}}); - let error_event = - json!({"type": "error", "error": {"message": "boom", "type": "server_error"}}); - let source = futures::stream::iter(vec![Ok(error_event)]).chain(futures::stream::pending()); - - let stream = build_responses_event_stream( - Some(Ok(first)), - source, - "openai".to_string(), - Vec::new(), - false, - "https://example.test".to_string(), - json!({}), - HashMap::new(), - ) - .expect("stream setup"); - - let parts: Vec<_> = stream.collect().await; - assert!( - parts - .iter() - .any(|part| matches!(part, Ok(StreamPart::Error { .. }))), - "error part yielded" - ); - let last = parts.last().expect("stream not empty"); - assert!( - matches!( - last, - Ok(StreamPart::Finish { finish_reason, .. }) - if finish_reason.unified == FinishReasonUnified::Error - ), - "stream ends with an error Finish, got {last:?}" - ); -} diff --git a/aimux-providers/tests/tool_result_and_reasoning_fix_test.rs b/aimux-providers/tests/tool_result_and_reasoning_fix_test.rs deleted file mode 100644 index f9807c573..000000000 --- a/aimux-providers/tests/tool_result_and_reasoning_fix_test.rs +++ /dev/null @@ -1,315 +0,0 @@ -// Panic convert wrappers are #[deprecated]; these tests still use them. -#![allow(deprecated)] -//! Regression tests for two issues reported against aimux 0.1.1 when driving -//! OpenAI-compatible thinking models (e.g. DeepSeek `deepseek-v4-flash`) in -//! multi-turn tool-call conversations: -//! -//! 1. **`tool` role with `ContentPart[]` was rejected.** `ModelPrompt` -//! deserialization of a `tool_result` part built with the legacy `output` -//! field (the shape emitted by the Vercel AI SDK and the 0.1.0 TypeScript -//! bindings) failed with "data did not match any variant of untagged enum -//! ModelPrompt". `ContentPart::ToolResult.result` now accepts `output` as a -//! deserialization alias (`#[serde(alias = "output")]`). -//! -//! 2. **`reasoning` ContentPart was dropped on the request side.** Thinking -//! models require prior assistant `reasoning_content` to be replayed on -//! later turns, including tool-call turns. The OpenAI message converter -//! now lifts `ContentPart::Reasoning` parts to a top-level -//! `reasoning_content` string on assistant messages (mirroring the Vercel -//! AI SDK `openai-compatible` assistant conversion). -//! -//! These are pure-function tests against -//! `convert_prompt_to_openai_messages` (no network). - -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::{ModelMessage, ModelPrompt, Role}; -use aimux_providers::openai::convert::convert_prompt_to_openai_messages; -use serde_json::{Value, json}; - -// ── Issue 1: tool role ContentPart[] / `output` alias ─────────────────────── - -/// A `tool` message whose `tool_result` part uses the legacy `output` field -/// (Vercel AI SDK / 0.1.0 TS bindings shape) round-trips through `ModelPrompt`. -#[test] -fn tool_message_with_tool_result_output_field_deserializes() { - let json_str = r#"[ - {"role":"tool","content":[{"type":"tool_result","tool_call_id":"tc1","output":"ok"}]} - ]"#; - let prompt: ModelPrompt = - serde_json::from_str(json_str).expect("output-field tool_result must deserialize"); - let msgs = match prompt { - ModelPrompt::Messages(m) => m, - other => panic!("expected Messages, got {other:?}"), - }; - assert_eq!(msgs.len(), 1); - assert_eq!(msgs[0].role, Role::Tool); - // The alias only affects deserialization; the in-memory field is `result`. - match &msgs[0].content { - aimux_core::message::MessageContent::Parts(parts) => { - assert_eq!(parts.len(), 1); - match &parts[0] { - ContentPart::ToolResult { - tool_call_id, - result, - .. - } => { - assert_eq!(tool_call_id, "tc1"); - assert_eq!(result, &json!("ok")); - } - other => panic!("expected ToolResult, got {other:?}"), - } - } - other => panic!("expected Parts, got {other:?}"), - } -} - -/// The current `result` field name still deserializes (no regression). -#[test] -fn tool_message_with_tool_result_result_field_deserializes() { - let json_str = r#"[ - {"role":"tool","content":[{"type":"tool_result","tool_call_id":"tc1","result":"ok"}]} - ]"#; - let prompt: ModelPrompt = - serde_json::from_str(json_str).expect("result-field tool_result must deserialize"); - assert!(matches!(prompt, ModelPrompt::Messages(_))); -} - -/// A full tool round-trip (assistant tool_call → tool result → …) built from -/// raw JSON reproducing the user's failing case now converts to OpenAI -/// messages with `tool_call_id` on the tool message. -#[test] -fn full_tool_round_trip_from_json_converts_with_tool_call_id() { - let json_str = r#"[ - {"role":"user","content":"write a file"}, - {"role":"assistant","content":[ - {"type":"tool_call","tool_call_id":"tc1","tool_name":"write_file","input":{"path":"/tmp/test.txt"}} - ]}, - {"role":"tool","content":[ - {"type":"tool_result","tool_call_id":"tc1","output":"Successfully wrote to /tmp/test.txt"} - ]} - ]"#; - let prompt: ModelPrompt = serde_json::from_str(json_str).expect("must deserialize"); - let msgs = match prompt { - ModelPrompt::Messages(m) => m, - _ => unreachable!(), - }; - // Convert to provider-facing prompt then to OpenAI messages. - let provider_prompt: LanguageModelPrompt = msgs - .iter() - .map(|m| LanguageModelPromptMessage { - role: m.role, - content: match &m.content { - aimux_core::message::MessageContent::Text(t) => vec![ContentPart::text(t)], - aimux_core::message::MessageContent::Parts(p) => p.clone(), - }, - provider_options: None, - }) - .collect(); - let out = convert_prompt_to_openai_messages(&provider_prompt); - assert_eq!(out.len(), 3); - // The tool message must carry tool_call_id (the core of issue 1). - assert_eq!(out[2]["role"], json!("tool")); - assert_eq!(out[2]["tool_call_id"], json!("tc1")); - assert_eq!( - out[2]["content"], - json!("Successfully wrote to /tmp/test.txt") - ); -} - -// ── Issue 2: reasoning_content replay on the request side ─────────────────── - -/// An assistant message with reasoning + tool_call lifts the reasoning to a -/// top-level `reasoning_content` field (the DeepSeek V4 thinking-mode -/// requirement). This is the exact scenario from the user's table: assistant -/// `[{reasoning},{tool_call}]` + tool `string`. -#[test] -fn assistant_reasoning_with_tool_call_emits_reasoning_content() { - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("inspect the repo")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ - ContentPart::reasoning("I need to inspect files before answering."), - ContentPart::tool_call( - "call_1".to_string(), - "read_file".to_string(), - json!({ "path": "README.md" }), - ), - ], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "call_1".to_string(), - json!("contents"), - )], - ..Default::default() - }, - ]; - let out = convert_prompt_to_openai_messages(&prompt); - assert_eq!(out.len(), 3); - - // Assistant message: reasoning_content present, content null, tool_calls set. - let assistant = &out[1]; - assert_eq!(assistant["role"], json!("assistant")); - assert_eq!( - assistant["reasoning_content"], - json!("I need to inspect files before answering.") - ); - assert_eq!(assistant["content"], Value::Null); - assert_eq!( - assistant["tool_calls"], - json!([{ - "type": "function", - "id": "call_1", - "function": { - "name": "read_file", - "arguments": "{\"path\":\"README.md\"}" - } - }]) - ); -} - -/// An assistant message with reasoning + text (no tool calls) lifts reasoning -/// to `reasoning_content` and keeps the text as `content`. -#[test] -fn assistant_reasoning_with_text_emits_reasoning_content() { - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("What is 2+2?")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ - ContentPart::reasoning("2 plus 2 equals 4."), - ContentPart::text("4"), - ], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("thanks")], - ..Default::default() - }, - ]; - let out = convert_prompt_to_openai_messages(&prompt); - let assistant = &out[1]; - assert_eq!(assistant["role"], json!("assistant")); - assert_eq!(assistant["content"], json!("4")); - assert_eq!(assistant["reasoning_content"], json!("2 plus 2 equals 4.")); - // No tool_calls key should be emitted. - assert!( - assistant.get("tool_calls").is_none(), - "tool_calls must not be emitted when there are none" - ); -} - -/// An assistant message with no reasoning part emits no `reasoning_content` -/// field (no spurious empty string — matches the Vercel `openai-compatible` -/// `reasoning.length > 0` guard). -#[test] -fn assistant_without_reasoning_omits_reasoning_content() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "call_1".to_string(), - "write_file".to_string(), - json!({ "path": "/tmp/x" }), - )], - ..Default::default() - }]; - let out = convert_prompt_to_openai_messages(&prompt); - let assistant = &out[0]; - assert!( - assistant.get("reasoning_content").is_none(), - "reasoning_content must be omitted when no reasoning part is present" - ); -} - -/// Multiple reasoning parts are concatenated (mirrors the SDK join behaviour). -#[test] -fn multiple_reasoning_parts_are_concatenated() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ - ContentPart::reasoning("part one. "), - ContentPart::reasoning("part two."), - ContentPart::text("answer"), - ], - ..Default::default() - }]; - let out = convert_prompt_to_openai_messages(&prompt); - let assistant = &out[0]; - assert_eq!(assistant["content"], json!("answer")); - assert_eq!(assistant["reasoning_content"], json!("part one. part two.")); -} - -/// A user message never carries reasoning, so `reasoning_content` is never -/// emitted for non-assistant roles even if a reasoning part somehow appears. -/// The reasoning part is still dropped from the content shape, and the -/// remaining text part collapses to a string (matching the plain-text path). -#[test] -fn non_assistant_role_never_emits_reasoning_content() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::reasoning("should be ignored"), - ContentPart::text("hello"), - ], - ..Default::default() - }]; - let out = convert_prompt_to_openai_messages(&prompt); - let user = &out[0]; - assert_eq!(user["role"], json!("user")); - assert!( - user.get("reasoning_content").is_none(), - "user messages must not carry reasoning_content" - ); - // The text part collapses to a plain string; reasoning is dropped entirely. - assert_eq!(user["content"], json!("hello")); -} - -/// The user-facing `ModelMessage` + `ModelPrompt` path (not just the -/// provider-facing prompt) also surfaces reasoning_content, exercising the -/// `convert_to_language_model_prompt` → `convert_prompt_to_openai_messages` -/// pipeline end to end. -#[test] -fn model_prompt_path_emits_reasoning_content() { - let prompt = ModelPrompt::Messages(vec![ - ModelMessage::user("hi"), - ModelMessage { - role: Role::Assistant, - content: aimux_core::message::MessageContent::Parts(vec![ - ContentPart::reasoning("thinking about hi"), - ContentPart::text("hello!"), - ]), - }, - ]); - let msgs = match prompt { - ModelPrompt::Messages(m) => m, - _ => unreachable!(), - }; - let provider_prompt: LanguageModelPrompt = msgs - .iter() - .map(|m| LanguageModelPromptMessage { - role: m.role, - content: match &m.content { - aimux_core::message::MessageContent::Text(t) => vec![ContentPart::text(t)], - aimux_core::message::MessageContent::Parts(p) => p.clone(), - }, - provider_options: None, - }) - .collect(); - let out = convert_prompt_to_openai_messages(&provider_prompt); - let assistant = &out[1]; - assert_eq!(assistant["reasoning_content"], json!("thinking about hi")); - assert_eq!(assistant["content"], json!("hello!")); -} diff --git a/aimux-providers/tests/vertex_anthropic_test.rs b/aimux-providers/tests/vertex_anthropic_test.rs deleted file mode 100644 index 65e4798ef..000000000 --- a/aimux-providers/tests/vertex_anthropic_test.rs +++ /dev/null @@ -1,564 +0,0 @@ -//! Wiremock tests for Anthropic partner models on Vertex AI (`rawPredict`). -//! -//! Claude models on Vertex AI are reached via the -//! `publishers/anthropic/models/{model}:rawPredict` (and `:streamRawPredict`) -//! endpoints. The request body is a standard Anthropic Messages request -//! wrapped in an `anthropic_version` envelope (model identity in the URL, not -//! the body); the response is a verbatim Anthropic Messages response. These -//! tests mock the Vertex AI endpoints and never touch the public network or -//! real credentials. - -use futures::StreamExt; -use serde_json::{Value, json}; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::{CallOptions, ProviderTool, Tool}; -use aimux_core::result::{GenerateContent, StreamResult}; -use aimux_core::stream_part::StreamPart; -use aimux_core::types::FinishReasonUnified; - -use aimux_providers::vertex::{ - VertexAnthropicModel, VertexAuth, VertexProvider, VertexProviderConfig, -}; - -// ── Constants ───────────────────────────────────────────────────────────────── - -const MODEL_ID: &str = "claude-sonnet-4-20250514"; -/// Endpoint path once the `/publishers/google` suffix has been swapped for -/// `/publishers/anthropic`. -const RAW_PREDICT_PATH: &str = "/publishers/anthropic/models/claude-sonnet-4-20250514:rawPredict"; -const STREAM_RAW_PREDICT_PATH: &str = - "/publishers/anthropic/models/claude-sonnet-4-20250514:streamRawPredict"; - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -/// Build a `VertexProvider` whose base URL ends in `/publishers/google` and -/// points at the mock server, then return the Anthropic partner model. This -/// exercises the `/publishers/google` → `/publishers/anthropic` base-URL -/// rewrite performed by [`VertexProvider::anthropic_model`]. -fn make_model(server: &MockServer) -> VertexAnthropicModel { - let config = VertexProviderConfig { - base_url: format!("{}/publishers/google", server.uri()), - project: Some("test-project".to_string()), - location: Some("us-central1".to_string()), - auth: VertexAuth::BearerToken("test-token".to_string()), - api_key_source: None, - retry_config: aimux_provider_utils::RetryConfig::default(), - }; - VertexProvider::new(config) - .anthropic_model(MODEL_ID) - .expect("anthropic_model should succeed") -} - -async fn mock_raw_predict_json(server: &MockServer, status: u16, body: Value) { - Mock::given(method("POST")) - .and(path(RAW_PREDICT_PATH)) - .respond_with(ResponseTemplate::new(status).set_body_json(body)) - .mount(server) - .await; -} - -async fn mock_stream_raw_predict_sse(server: &MockServer, sse_body: &str) { - Mock::given(method("POST")) - .and(path(STREAM_RAW_PREDICT_PATH)) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body.to_string()), - ) - .mount(server) - .await; -} - -fn sse(data: &Value) -> String { - format!("data: {data}\n\n") -} - -fn sse_stream(events: &[Value]) -> String { - events.iter().map(sse).collect() -} - -fn as_text(item: &GenerateContent) -> &str { - match item { - GenerateContent::Text { text, .. } => text, - _ => panic!("expected Text content, got {item:?}"), - } -} - -async fn collect_stream(result: StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -fn text_response(text: &str) -> Value { - json!({ - "id": "msg_017TfcQ4AgGxKyBduUpqYPZn", - "type": "message", - "role": "assistant", - "content": [{ "type": "text", "text": text }], - "model": "claude-sonnet-4-20250514", - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": { "input_tokens": 4, "output_tokens": 30 } - }) -} - -// ═════════════════════════════════════════════════════════════════════════════ -// doGenerate tests -// ═════════════════════════════════════════════════════════════════════════════ - -/// Test: non-streaming text generation via rawPredict extracts text, usage, -/// and finish reason from the standard Anthropic response body. -#[tokio::test] -async fn vertex_anthropic_generate_text_response() { - let server = MockServer::start().await; - mock_raw_predict_json(&server, 200, text_response("Hello from Claude on Vertex!")).await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - assert_eq!(as_text(&result.content[0]), "Hello from Claude on Vertex!"); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(result.usage.input_tokens.total, Some(4)); - assert_eq!(result.usage.output_tokens.total, Some(30)); - assert_eq!( - result.response.id.as_deref(), - Some("msg_017TfcQ4AgGxKyBduUpqYPZn") - ); -} - -#[tokio::test] -async fn vertex_anthropic_generate_keeps_direct_caller_metadata() { - let server = MockServer::start().await; - mock_raw_predict_json( - &server, - 200, - json!({ - "id": "msg_direct", - "type": "message", - "role": "assistant", - "content": [{ - "type": "tool_use", - "id": "toolu_direct", - "name": "get_weather", - "input": { "city": "Tokyo" }, - "caller": { "type": "direct" }, - }], - "model": MODEL_ID, - "stop_reason": "tool_use", - "usage": { "input_tokens": 100, "output_tokens": 50 }, - }), - ) - .await; - - let result = make_model(&server) - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - let metadata = result.content.iter().find_map(|part| match part { - GenerateContent::ToolCall { - provider_metadata, .. - } => provider_metadata.as_ref(), - _ => None, - }); - assert_eq!( - metadata.expect("caller metadata")["anthropic"]["caller"], - json!({ "type": "direct" }) - ); -} - -/// Test: the request hits the `/publishers/anthropic/models/{model}:rawPredict` -/// path (i.e. the `/publishers/google` suffix is replaced) and carries the -/// Bearer token Authorization header. -#[tokio::test] -async fn vertex_anthropic_url_and_auth() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path(RAW_PREDICT_PATH)) - .and(header("Authorization", "Bearer test-token")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_response("OK"))) - .mount(&server) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(as_text(&result.content[0]), "OK"); - - // The single recorded request hit the Anthropic publisher path. - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - assert_eq!(requests[0].url.path(), RAW_PREDICT_PATH); - assert_eq!( - requests[0].headers.get("authorization").unwrap(), - "Bearer test-token" - ); -} - -/// Test: the rawPredict request body is wrapped in the `anthropic_version` -/// envelope and drops the `model` field (the model identity lives in the URL). -#[tokio::test] -async fn vertex_anthropic_request_body_envelope() { - let server = MockServer::start().await; - mock_raw_predict_json(&server, 200, text_response("OK")).await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - // The reported request body is the envelope actually sent. - let reported = result.request_body.expect("should have request body"); - assert_eq!(reported["anthropic_version"], "vertex-2023-10-16"); - assert_eq!(reported["messages"][0]["role"], "user"); - assert_eq!(reported["messages"][0]["content"][0]["text"], "Hello"); - assert!( - reported.get("model").is_none(), - "model must not be in the body" - ); - - // The body sent over the wire matches (anthropic_version present, no model). - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - let wire: Value = serde_json::from_slice(&requests[0].body).expect("valid json body"); - assert_eq!(wire["anthropic_version"], "vertex-2023-10-16"); - assert_eq!(wire["messages"][0]["content"][0]["text"], "Hello"); - assert!( - wire.get("model").is_none(), - "model must not be in the wire body" - ); -} - -// ═════════════════════════════════════════════════════════════════════════════ -// doStream tests -// ═════════════════════════════════════════════════════════════════════════════ - -/// Test: streaming text via the Anthropic SSE events returned by -/// streamRawPredict. -#[tokio::test] -async fn vertex_anthropic_stream_text() { - let server = MockServer::start().await; - - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_001", - "model": "claude-sonnet-4-20250514", - "usage": { "input_tokens": 3 } - } - }), - json!({ - "type": "content_block_start", - "index": 0, - "content_block": { "type": "text", "text": "" } - }), - json!({ - "type": "content_block_delta", - "index": 0, - "delta": { "type": "text_delta", "text": "Hello" } - }), - json!({ - "type": "content_block_delta", - "index": 0, - "delta": { "type": "text_delta", "text": " streamed!" } - }), - json!({ - "type": "content_block_stop", - "index": 0 - }), - json!({ - "type": "message_delta", - "delta": { "stop_reason": "end_turn" }, - "usage": { "output_tokens": 5 } - }), - json!({ "type": "message_stop" }), - ]); - - mock_stream_raw_predict_sse(&server, &sse_body).await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - - let text_deltas: Vec = parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect(); - assert_eq!(text_deltas, vec!["Hello", " streamed!"]); - - let finish = parts.iter().find_map(|p| match p { - StreamPart::Finish { finish_reason, .. } => Some(finish_reason.clone()), - _ => None, - }); - let finish = finish.expect("should have a Finish part"); - assert_eq!(finish.unified, FinishReasonUnified::Stop); - - // The stream request hit the streamRawPredict path. - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - assert_eq!(requests[0].url.path(), STREAM_RAW_PREDICT_PATH); - let wire: Value = serde_json::from_slice(&requests[0].body).expect("valid json body"); - assert_eq!(wire["anthropic_version"], "vertex-2023-10-16"); - assert_eq!(wire["stream"], true); -} - -// ═════════════════════════════════════════════════════════════════════════════ -#[tokio::test] -async fn vertex_anthropic_streamed_tool_search_results_follow_their_call_ids() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_tool_search", - "model": MODEL_ID, - "usage": { "input_tokens": 10 }, - }, - }), - json!({ - "type": "content_block_start", - "index": 0, - "content_block": { - "type": "server_tool_use", - "id": "bm25_call", - "name": "tool_search_tool_bm25", - "input": { "query": "weather" }, - }, - }), - json!({ "type": "content_block_stop", "index": 0 }), - json!({ - "type": "content_block_start", - "index": 1, - "content_block": { - "type": "tool_search_tool_result", - "tool_use_id": "bm25_call", - "content": { - "type": "tool_search_tool_search_result", - "tool_references": [{ - "type": "tool_reference", - "tool_name": "get_weather", - }], - }, - }, - }), - json!({ "type": "content_block_stop", "index": 1 }), - json!({ - "type": "content_block_start", - "index": 2, - "content_block": { - "type": "server_tool_use", - "id": "regex_call", - "name": "tool_search_tool_regex", - "input": { "pattern": "forecast.*" }, - }, - }), - json!({ "type": "content_block_stop", "index": 2 }), - json!({ - "type": "content_block_start", - "index": 3, - "content_block": { - "type": "tool_search_tool_result", - "tool_use_id": "regex_call", - "content": { - "type": "tool_search_tool_search_result", - "tool_references": [{ - "type": "tool_reference", - "tool_name": "get_forecast", - }], - }, - }, - }), - json!({ "type": "content_block_stop", "index": 3 }), - json!({ - "type": "message_delta", - "delta": { "stop_reason": "end_turn" }, - "usage": { "output_tokens": 20 }, - }), - json!({ "type": "message_stop" }), - ]); - mock_stream_raw_predict_sse(&server, &sse_body).await; - let model = make_model(&server); - let options = CallOptions { - tools: Some(vec![ - Tool::Provider(ProviderTool { - id: "anthropic.tool_search_regex_20251119".to_string(), - name: "regexSearch".to_string(), - args: json!({}), - }), - Tool::Provider(ProviderTool { - id: "anthropic.tool_search_bm25_20251119".to_string(), - name: "semanticSearch".to_string(), - args: json!({}), - }), - ]), - ..default_options(test_prompt()) - }; - - let parts = collect_stream(model.do_stream(&options).await.unwrap()).await; - let result_names: std::collections::HashMap<&str, &str> = parts - .iter() - .filter_map(|part| match part { - StreamPart::ToolResult { - tool_call_id, - tool_name, - .. - } => Some((tool_call_id.as_str(), tool_name.as_str())), - _ => None, - }) - .collect(); - assert_eq!(result_names.get("bm25_call"), Some(&"semanticSearch")); - assert_eq!(result_names.get("regex_call"), Some(&"regexSearch")); -} - -#[tokio::test] -async fn vertex_anthropic_stream_keeps_programmatic_caller_metadata() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_programmatic", - "model": MODEL_ID, - "usage": { "input_tokens": 100 }, - }, - }), - json!({ - "type": "content_block_start", - "index": 0, - "content_block": { - "type": "tool_use", - "id": "toolu_programmatic", - "name": "query_database", - "input": { "sql": "SELECT 1" }, - "caller": { - "type": "code_execution_20250825", - "tool_id": "srvtoolu_code", - }, - }, - }), - json!({ "type": "content_block_stop", "index": 0 }), - json!({ - "type": "message_delta", - "delta": { "stop_reason": "tool_use" }, - "usage": { "output_tokens": 50 }, - }), - json!({ "type": "message_stop" }), - ]); - mock_stream_raw_predict_sse(&server, &sse_body).await; - let model = make_model(&server); - - let parts = collect_stream( - model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(), - ) - .await; - let metadata = parts.iter().find_map(|part| match part { - StreamPart::ToolCall { - provider_metadata, .. - } => provider_metadata.as_ref(), - _ => None, - }); - assert_eq!( - metadata.expect("caller metadata")["anthropic"]["caller"], - json!({ - "type": "code_execution_20250825", - "toolId": "srvtoolu_code", - }) - ); -} - -// Error handling -// ═════════════════════════════════════════════════════════════════════════════ - -/// Test: a 401 response maps to `AiMuxError::ApiCall` (401 in `status_code`). -#[tokio::test] -async fn vertex_anthropic_generate_auth_error() { - let server = MockServer::start().await; - mock_raw_predict_json( - &server, - 401, - json!({ - "error": { - "code": 401, - "message": "Request had invalid authentication credentials.", - "status": "UNAUTHENTICATED" - } - }), - ) - .await; - - let model = make_model(&server); - let result = model.do_generate(&default_options(test_prompt())).await; - assert!( - matches!(result, Err(ref e) if e.status_code() == Some(401)), - "expected Auth, got {result:?}" - ); -} - -/// Test: a generic HTTP error (500) maps to `AiMuxError::ApiCall`. -#[tokio::test] -async fn vertex_anthropic_generate_provider_error() { - let server = MockServer::start().await; - mock_raw_predict_json( - &server, - 500, - json!({ - "error": { - "code": 500, - "message": "Internal error.", - "status": "INTERNAL" - } - }), - ) - .await; - - let model = make_model(&server); - let result = model.do_generate(&default_options(test_prompt())).await; - assert!( - matches!(result, Err(AiMuxError::ApiCall(_))), - "expected Provider, got {result:?}" - ); -} diff --git a/aimux-providers/tests/vertex_embedding_test.rs b/aimux-providers/tests/vertex_embedding_test.rs deleted file mode 100644 index 856c7dd54..000000000 --- a/aimux-providers/tests/vertex_embedding_test.rs +++ /dev/null @@ -1,335 +0,0 @@ -//! Rust translations of the AI SDK Google Vertex embedding model tests. -//! -//! Source: `reference/ai/packages/google-vertex/src/google-vertex-embedding-model.test.ts` -//! (460 lines, 12 cases). - -use std::collections::HashMap; - -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::embedding_model::{EmbeddingCallOptions, EmbeddingModel}; -use aimux_providers::{VertexAuth, VertexProvider, VertexProviderConfig}; - -const TEST_VALUES: &[&str] = &["test text one", "test text two"]; - -fn mock_provider_options() -> HashMap { - let mut opts = HashMap::new(); - opts.insert( - "google".to_string(), - json!({ - "outputDimensionality": 768, - "taskType": "SEMANTIC_SIMILARITY", - "title": "test title", - "autoTruncate": false - }), - ); - opts -} - -/// The fixture response body from `__fixtures__/google-vertex-embedding.json`. -fn predict_response_body() -> Value { - json!({ - "predictions": [ - { - "embeddings": { - "values": [-0.017999587580561638, -0.006893285550177097, -0.036766719073057175, -0.017558680847287178, -0.019938766956329346], - "statistics": { "token_count": 6 } - } - }, - { - "embeddings": { - "values": [-0.06007182598114014, 0.004907649010419846, -0.00690646655857563, -0.007314121350646019, -0.048464205116033554], - "statistics": { "token_count": 5 } - } - } - ] - }) -} - -fn embed_content_response_body() -> Value { - json!({ - "embedding": { "values": [0.1, 0.2, 0.3] }, - "usageMetadata": { "promptTokenCount": 4 } - }) -} - -fn test_values() -> Vec { - TEST_VALUES - .iter() - .map(std::string::ToString::to_string) - .collect() -} - -fn default_options(values: Vec) -> EmbeddingCallOptions { - EmbeddingCallOptions { - values, - abort_signal: None, - provider_options: Some(mock_provider_options()), - headers: None, - max_retries: None, - timeout: None, - } -} - -fn test_provider(base_url: String) -> VertexProvider { - let config = VertexProviderConfig { - base_url, - project: Some("test-project".to_string()), - location: Some("us-central1".to_string()), - auth: VertexAuth::BearerToken("test-token".to_string()), - api_key_source: None, - retry_config: aimux_provider_utils::RetryConfig::default(), - }; - VertexProvider::new(config) -} - -/// TS: "should extract embeddings" -// f32 test fixtures mirror provider output verbatim -#[allow(clippy::excessive_precision)] -#[tokio::test] -async fn should_extract_embeddings() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/textembedding-gecko@001:predict")) - .respond_with(ResponseTemplate::new(200).set_body_json(predict_response_body())) - .mount(&server) - .await; - - let provider = test_provider(server.uri()); - let model = provider.embedding_model("textembedding-gecko@001"); - - let result = model - .do_embed(&default_options(test_values())) - .await - .expect("should succeed"); - assert_eq!(result.embeddings.len(), 2); - assert_eq!( - result.embeddings[0], - vec![ - -0.017999587580561638, - -0.006893285550177097, - -0.036766719073057175, - -0.017558680847287178, - -0.019938766956329346 - ] - ); - assert_eq!( - result.embeddings[1], - vec![ - -0.06007182598114014, - 0.004907649010419846, - -0.00690646655857563, - -0.007314121350646019, - -0.048464205116033554 - ] - ); -} - -/// TS: "should extract usage" -#[tokio::test] -async fn should_extract_usage() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/textembedding-gecko@001:predict")) - .respond_with(ResponseTemplate::new(200).set_body_json(predict_response_body())) - .mount(&server) - .await; - - let provider = test_provider(server.uri()); - let model = provider.embedding_model("textembedding-gecko@001"); - - let result = model - .do_embed(&default_options(test_values())) - .await - .expect("should succeed"); - let usage = result.usage.expect("usage should be present"); - // token_count: 6 + 5 = 11 - assert_eq!(usage.tokens, 11); -} - -/// TS: "should pass the model parameters correctly" -#[tokio::test] -async fn should_pass_model_parameters() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/textembedding-gecko@001:predict")) - .respond_with(ResponseTemplate::new(200).set_body_json(predict_response_body())) - .mount(&server) - .await; - - let provider = test_provider(server.uri()); - let model = provider.embedding_model("textembedding-gecko@001"); - - let _ = model - .do_embed(&default_options(test_values())) - .await - .expect("should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["instances"].as_array().unwrap().len(), 2); - assert_eq!(body["instances"][0]["content"], "test text one"); - assert_eq!(body["instances"][0]["task_type"], "SEMANTIC_SIMILARITY"); - assert_eq!(body["instances"][0]["title"], "test title"); - assert_eq!(body["parameters"]["autoTruncate"], false); - assert_eq!(body["parameters"]["outputDimensionality"], 768); -} - -/// TS: "should accept googleVertex as provider options key" -#[tokio::test] -async fn should_accept_google_vertex_key() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/textembedding-gecko@001:predict")) - .respond_with(ResponseTemplate::new(200).set_body_json(predict_response_body())) - .mount(&server) - .await; - - let provider = test_provider(server.uri()); - let model = provider.embedding_model("textembedding-gecko@001"); - - let mut provider_options = HashMap::new(); - provider_options.insert( - "googleVertex".to_string(), - json!({ - "outputDimensionality": 768, - "taskType": "SEMANTIC_SIMILARITY", - "title": "test title", - "autoTruncate": false - }), - ); - let options = EmbeddingCallOptions { - values: test_values(), - abort_signal: None, - provider_options: Some(provider_options), - headers: None, - max_retries: None, - timeout: None, - }; - - let _ = model.do_embed(&options).await.expect("should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["instances"][0]["task_type"], "SEMANTIC_SIMILARITY"); - assert_eq!(body["parameters"]["outputDimensionality"], 768); -} - -/// TS: "should pass the taskType setting in instances" -#[tokio::test] -async fn should_pass_task_type_only() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/textembedding-gecko@001:predict")) - .respond_with(ResponseTemplate::new(200).set_body_json(predict_response_body())) - .mount(&server) - .await; - - let provider = test_provider(server.uri()); - let model = provider.embedding_model("textembedding-gecko@001"); - - let mut provider_options = HashMap::new(); - provider_options.insert( - "google".to_string(), - json!({"taskType": "SEMANTIC_SIMILARITY"}), - ); - let options = EmbeddingCallOptions { - values: test_values(), - abort_signal: None, - provider_options: Some(provider_options), - headers: None, - max_retries: None, - timeout: None, - }; - - let _ = model.do_embed(&options).await.expect("should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["instances"][0]["task_type"], "SEMANTIC_SIMILARITY"); - // parameters should be empty object when no outputDimensionality/autoTruncate - assert_eq!(body["parameters"].as_object().unwrap().len(), 0); -} - -/// TS: "should use embedContent for gemini-embedding-2" -#[tokio::test] -async fn should_use_embed_content_for_gemini_2() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/gemini-embedding-2:embedContent")) - .respond_with(ResponseTemplate::new(200).set_body_json(embed_content_response_body())) - .mount(&server) - .await; - - let provider = test_provider(server.uri()); - let model = provider.embedding_model("gemini-embedding-2"); - - let result = model - .do_embed(&default_options(vec![TEST_VALUES[0].to_string()])) - .await - .expect("should succeed"); - assert_eq!(result.embeddings, vec![vec![0.1, 0.2, 0.3]]); - let usage = result.usage.expect("usage present"); - assert_eq!(usage.tokens, 4); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["content"]["parts"][0]["text"], "test text one"); - assert_eq!(body["embedContentConfig"]["autoTruncate"], false); - assert_eq!(body["embedContentConfig"]["outputDimensionality"], 768); - assert_eq!( - body["embedContentConfig"]["taskType"], - "SEMANTIC_SIMILARITY" - ); - assert_eq!(body["embedContentConfig"]["title"], "test title"); -} - -/// TS: "should limit gemini-embedding-2 to one value per call" -#[test] -fn gemini_embedding_2_max_per_call() { - let config = VertexProviderConfig { - base_url: "https://example.com".to_string(), - project: Some("test-project".to_string()), - location: Some("us-central1".to_string()), - auth: VertexAuth::BearerToken("test".to_string()), - api_key_source: None, - retry_config: aimux_provider_utils::RetryConfig::default(), - }; - let provider = VertexProvider::new(config); - let model = provider.embedding_model("gemini-embedding-2"); - assert_eq!(model.max_embeddings_per_call(), Some(1)); -} - -/// TS: "should expose response headers" -#[tokio::test] -async fn should_expose_response_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/textembedding-gecko@001:predict")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("test-header", "test-value") - .set_body_json(predict_response_body()), - ) - .mount(&server) - .await; - - let provider = test_provider(server.uri()); - let model = provider.embedding_model("textembedding-gecko@001"); - - let result = model - .do_embed(&default_options(test_values())) - .await - .expect("should succeed"); - let headers = result - .response - .as_ref() - .and_then(|r| r.headers.as_ref()) - .expect("headers present"); - assert_eq!( - headers.get("test-header").map(std::string::String::as_str), - Some("test-value") - ); -} diff --git a/aimux-providers/tests/vertex_model_test.rs b/aimux-providers/tests/vertex_model_test.rs deleted file mode 100644 index 295562172..000000000 --- a/aimux-providers/tests/vertex_model_test.rs +++ /dev/null @@ -1,1425 +0,0 @@ -//! Wiremock tests for the Google Vertex AI provider. -//! -//! Tests cover: -//! - Non-streaming text generation (generateContent JSON response) -//! - Non-streaming tool call extraction -//! - Streaming via SSE (streamGenerateContent) -//! - Error handling -//! -//! Vertex AI uses the same request/response format as the public Google Gemini -//! API, so the response fixtures mirror the Gemini API shape. - -use 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::FinishReasonUnified; - -use aimux_providers::vertex::{VertexAuth, VertexConfig, VertexModel, VertexProviderConfig}; - -// ── Helpers ────────────────────────────────────────────────────────────────── - -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -fn make_model(server: &MockServer) -> VertexModel { - VertexModel::new( - "gemini-2.0-flash".to_string(), - VertexConfig { - base_url: server.uri(), - auth: VertexAuth::BearerToken("test-token".to_string()), - api_key_source: None, - retry_config: aimux_provider_utils::RetryConfig::default(), - }, - ) -} - -async fn mock_generate_content(server: &MockServer, body: Value) { - Mock::given(method("POST")) - .and(path("/models/gemini-2.0-flash:generateContent")) - .respond_with(ResponseTemplate::new(200).set_body_json(body)) - .mount(server) - .await; -} - -async fn mock_generate_error(server: &MockServer, status: u16, body: Value) { - Mock::given(method("POST")) - .and(path("/models/gemini-2.0-flash:generateContent")) - .respond_with(ResponseTemplate::new(status).set_body_json(body)) - .mount(server) - .await; -} - -async fn mock_stream_content(server: &MockServer, sse_body: &str) { - Mock::given(method("POST")) - .and(path("/models/gemini-2.0-flash:streamGenerateContent")) - .and(query_param("alt", "sse")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body.to_string()), - ) - .mount(server) - .await; -} - -fn sse(data: &Value) -> String { - format!("data: {data}\n\n") -} - -fn sse_stream(events: &[Value]) -> String { - events.iter().map(sse).collect() -} - -fn as_text(item: &GenerateContent) -> &str { - match item { - GenerateContent::Text { text, .. } => text, - _ => panic!("expected Text content, got {item:?}"), - } -} - -fn as_tool_call(item: &GenerateContent) -> (&str, &str, &str) { - match item { - GenerateContent::ToolCall { - tool_call_id, - tool_name, - input, - .. - } => (tool_call_id, tool_name, input), - _ => panic!("expected ToolCall content, got {item:?}"), - } -} - -async fn collect_stream(result: StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -// ═════════════════════════════════════════════════════════════════════════════ -// doGenerate tests -// ═════════════════════════════════════════════════════════════════════════════ - -/// Test: non-streaming text generation with usage and finish reason. -#[tokio::test] -async fn vertex_generate_text_response() { - let server = MockServer::start().await; - mock_generate_content( - &server, - json!({ - "candidates": [{ - "content": { - "role": "model", - "parts": [{ "text": "Hello from Vertex!" }] - }, - "finishReason": "STOP" - }], - "usageMetadata": { - "promptTokenCount": 5, - "candidatesTokenCount": 10, - "totalTokenCount": 15 - } - }), - ) - .await; - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - assert_eq!(as_text(&result.content[0]), "Hello from Vertex!"); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(result.usage.input_tokens.total, Some(5)); - assert_eq!(result.usage.output_tokens.total, Some(10)); - let metadata = result - .provider_metadata - .as_ref() - .expect("provider metadata"); - assert_eq!(metadata["vertex"], metadata["googleVertex"]); - assert!(metadata.get("google").is_none()); -} - -/// Test: non-streaming tool call extraction. -#[tokio::test] -async fn vertex_generate_tool_call() { - let server = MockServer::start().await; - mock_generate_content( - &server, - json!({ - "candidates": [{ - "content": { - "role": "model", - "parts": [{ - "functionCall": { - "name": "getWeather", - "id": "call_1", - "args": { "location": "Tokyo" } - } - }] - }, - "finishReason": "STOP" - }], - "usageMetadata": { - "promptTokenCount": 5, - "candidatesTokenCount": 5 - } - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - let (id, name, input) = as_tool_call(&result.content[0]); - assert_eq!(id, "call_1"); - assert_eq!(name, "getWeather"); - assert_eq!(input, &json!(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; - mock_generate_content( - &server, - json!({ - "candidates": [{ - "content": { - "role": "model", - "parts": [ - { "toolCall": { - "toolType": "GOOGLE_SEARCH_WEB", - "args": { "query": "Singapore weather" }, - "id": "server-call-1" - }, "thoughtSignature": "call-signature" }, - { "toolResponse": { - "toolType": "GOOGLE_SEARCH_WEB", - "response": { "results": [{ "title": "Sunny" }] }, - "id": "server-call-1" - }, "thoughtSignature": "result-signature" } - ] - }, - "finishReason": "STOP" - }] - }), - ) - .await; - - let model = make_model(&server); - let result = generate_text(&model, "Search the web", GenerateTextOptions::default()) - .await - .expect("dynamic Vertex server tool should pass Core validation"); - - let call = result.tool_calls.first().expect("server tool call"); - assert_eq!(call.tool_call_id, "server-call-1"); - assert_eq!(call.tool_name, "server:GOOGLE_SEARCH_WEB"); - assert_eq!(call.input, json!({ "query": "Singapore weather" })); - assert_eq!(call.provider_executed, Some(true)); - assert_eq!(call.dynamic, Some(true)); - assert_eq!(call.invalid, None); - assert_eq!( - call.provider_metadata.as_ref().expect("call metadata")["googleVertex"], - json!({ - "serverToolCallId": "server-call-1", - "serverToolType": "GOOGLE_SEARCH_WEB", - "thoughtSignature": "call-signature" - }) - ); - assert_eq!( - call.provider_metadata.as_ref().unwrap()["vertex"], - call.provider_metadata.as_ref().unwrap()["googleVertex"] - ); - assert!( - call.provider_metadata - .as_ref() - .unwrap() - .get("google") - .is_none() - ); - assert!(result.raw.content.iter().any(|content| matches!( - content, - GenerateContent::ToolResult { - tool_call_id, - tool_name, - result, - dynamic: None, - provider_metadata: Some(metadata), - .. - } if tool_call_id == "server-call-1" - && tool_name == "server:GOOGLE_SEARCH_WEB" - && *result == json!({ "results": [{ "title": "Sunny" }] }) - && metadata["googleVertex"] == json!({ - "serverToolCallId": "server-call-1", - "serverToolType": "GOOGLE_SEARCH_WEB", - "thoughtSignature": "result-signature" - }) - && metadata["vertex"] == metadata["googleVertex"] - && metadata.get("google").is_none() - ))); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); -} - -/// Vertex Gemini thinking models echo `thoughtSignature` on `functionCall` -/// parts the same way the public Gemini API does — it must be preserved. -#[tokio::test] -async fn vertex_generate_tool_call_with_thought_signature() { - let server = MockServer::start().await; - mock_generate_content( - &server, - json!({ - "candidates": [{ - "content": { - "role": "model", - "parts": [{ - "functionCall": { - "name": "getWeather", - "id": "call_1", - "args": { "location": "Tokyo" } - }, - "thoughtSignature": "EuIDCt8DARFNMg/aRDRK3THWhBjzltCEy5/VM6ImWLJU8oHmnC75abdcZBMH" - }] - }, - "finishReason": "STOP" - }], - "usageMetadata": { - "promptTokenCount": 5, - "candidatesTokenCount": 5 - } - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::ToolCall { - 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") - ); - let metadata = provider_metadata.as_ref().expect("thought metadata"); - assert_eq!( - metadata["googleVertex"]["thoughtSignature"], - json!("EuIDCt8DARFNMg/aRDRK3THWhBjzltCEy5/VM6ImWLJU8oHmnC75abdcZBMH") - ); - assert_eq!(metadata["vertex"], metadata["googleVertex"]); - assert!(metadata.get("google").is_none()); - } - other => panic!("expected ToolCall, got {other:?}"), - } - assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); -} - -/// Test: HTTP error status is propagated. -#[tokio::test] -async fn vertex_generate_error() { - let server = MockServer::start().await; - mock_generate_error( - &server, - 400, - json!({ - "error": { - "code": 400, - "message": "Invalid model name", - "status": "INVALID_ARGUMENT" - } - }), - ) - .await; - - let model = make_model(&server); - let result = model.do_generate(&default_options(test_prompt())).await; - - assert!(result.is_err(), "should return error for 400 status"); -} - -// ═════════════════════════════════════════════════════════════════════════════ -// doStream tests -// ═════════════════════════════════════════════════════════════════════════════ - -/// Test: streaming text via SSE. -#[tokio::test] -async fn vertex_stream_text() { - let server = MockServer::start().await; - - let sse_body = sse_stream(&[ - json!({ - "candidates": [{ - "content": { "role": "model", "parts": [{ "text": "Hello" }] } - }] - }), - json!({ - "candidates": [{ - "content": { "role": "model", "parts": [{ "text": " world" }] }, - "finishReason": "STOP" - }], - "usageMetadata": { - "promptTokenCount": 3, - "candidatesTokenCount": 5 - } - }), - ]); - - mock_stream_content(&server, &sse_body).await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - - let text_deltas: Vec = parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect(); - assert_eq!(text_deltas, vec!["Hello", " world"]); - - let finish = parts.iter().find_map(|p| match p { - StreamPart::Finish { finish_reason, .. } => Some(finish_reason.clone()), - _ => None, - }); - assert!(finish.is_some()); - assert_eq!(finish.unwrap().unified, FinishReasonUnified::Stop); -} - -/// Test: API key authentication uses x-goog-api-key header. -#[tokio::test] -async fn vertex_api_key_auth() { - let server = MockServer::start().await; - mock_generate_content( - &server, - json!({ - "candidates": [{ - "content": { "parts": [{ "text": "Express!" }] }, - "finishReason": "STOP" - }] - }), - ) - .await; - - let model = VertexModel::new( - "gemini-2.0-flash".to_string(), - VertexConfig { - base_url: server.uri(), - auth: VertexAuth::ApiKey("test-api-key".to_string()), - api_key_source: None, - retry_config: aimux_provider_utils::RetryConfig::default(), - }, - ); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed with API key"); - - assert_eq!(as_text(&result.content[0]), "Express!"); -} - -/// Test: provider config construction and base URL. -#[tokio::test] -async fn vertex_provider_config() { - let config = VertexProviderConfig::new("test-token", "my-project", "us-central1"); - assert!( - config - .base_url - .contains("us-central1-aiplatform.googleapis.com") - ); - assert!(config.base_url.contains("my-project")); - - let config_express = VertexProviderConfig::with_api_key("test-key"); - assert!( - config_express - .base_url - .contains("aiplatform.googleapis.com") - ); - match config_express.auth { - VertexAuth::ApiKey(k) => assert_eq!(k, "test-key"), - _ => panic!("expected ApiKey auth"), - } -} - -// ═════════════════════════════════════════════════════════════════════════════ -// Additional cases — finish reasons, settings, headers, stream tool calls. -// ═════════════════════════════════════════════════════════════════════════════ - -/// A minimal Vertex generateContent "ok" body. -fn ok_vertex_body() -> Value { - json!({ - "candidates": [{ - "content": { "role": "model", "parts": [{ "text": "ok" }] }, - "finishReason": "STOP" - }], - "usageMetadata": { "promptTokenCount": 4, "candidatesTokenCount": 7 } - }) -} - -/// TS: MAX_TOKENS → Length -#[tokio::test] -async fn vertex_generate_finish_reason_max_tokens() { - let server = MockServer::start().await; - mock_generate_content( - &server, - json!({ - "candidates": [{ - "content": { "parts": [{ "text": "truncated" }] }, - "finishReason": "MAX_TOKENS" - }], - "usageMetadata": { "promptTokenCount": 4, "candidatesTokenCount": 34 } - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Length); - assert_eq!(result.finish_reason.raw.as_deref(), Some("MAX_TOKENS")); -} - -/// TS: SAFETY → ContentFilter -#[tokio::test] -async fn vertex_generate_finish_reason_safety() { - let server = MockServer::start().await; - mock_generate_content( - &server, - json!({ - "candidates": [{ - "content": { "parts": [{ "text": "" }] }, - "finishReason": "SAFETY" - }], - "usageMetadata": { "promptTokenCount": 4, "candidatesTokenCount": 1 } - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!( - result.finish_reason.unified, - FinishReasonUnified::ContentFilter - ); -} - -/// TS: settings land in `generationConfig`. -#[tokio::test] -async fn vertex_generate_settings() { - let server = MockServer::start().await; - mock_generate_content(&server, ok_vertex_body()).await; - - let model = make_model(&server); - let mut opts = default_options(test_prompt()); - opts.max_output_tokens = Some(256); - opts.temperature = Some(0.5); - opts.top_p = Some(0.9); - opts.top_k = Some(40.0); - - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["generationConfig"]["maxOutputTokens"], json!(256)); - assert!((body["generationConfig"]["temperature"].as_f64().unwrap() - 0.5).abs() < 1e-6); - assert!((body["generationConfig"]["topP"].as_f64().unwrap() - 0.9).abs() < 1e-6); - assert_eq!(body["generationConfig"]["topK"], json!(40.0)); -} - -/// TS: request body carries the user message as `contents`. -#[tokio::test] -async fn vertex_generate_request_body() { - let server = MockServer::start().await; - mock_generate_content(&server, ok_vertex_body()).await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let body = result.request_body.expect("body"); - assert_eq!(body["contents"][0]["role"], json!("user")); - assert_eq!(body["contents"][0]["parts"][0]["text"], json!("Hello")); -} - -/// TS: response headers are exposed on the generate result. -#[tokio::test] -async fn vertex_generate_response_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/gemini-2.0-flash:generateContent")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("test-header", "test-value") - .set_body_json(ok_vertex_body()), - ) - .mount(&server) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} - -/// TS: streaming tool calls via SSE. -#[tokio::test] -async fn vertex_stream_tool_call() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[json!({ - "candidates": [{ - "content": { - "role": "model", - "parts": [{ - "functionCall": { - "name": "getWeather", - "id": "call_1", - "args": { "location": "Tokyo" } - } - }] - }, - "finishReason": "STOP" - }] - })]); - mock_stream_content(&server, &sse_body).await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - let tool_call = parts.iter().find_map(|p| match p { - StreamPart::ToolCall { - tool_call_id, - tool_name, - input, - .. - } => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), - _ => None, - }); - let (id, name, input) = tool_call.expect("should have ToolCall"); - assert_eq!(id, "call_1"); - assert_eq!(name, "getWeather"); - assert_eq!(input, json!(r#"{"location":"Tokyo"}"#)); -} - -/// TS: response headers are exposed on the stream result. -#[tokio::test] -async fn vertex_stream_response_headers() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[json!({ - "candidates": [{ - "content": { "parts": [{ "text": "Hi" }] }, - "finishReason": "STOP" - }] - })]); - Mock::given(method("POST")) - .and(path("/models/gemini-2.0-flash:streamGenerateContent")) - .and(query_param("alt", "sse")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .insert_header("test-header", "test-value") - .set_body_string(sse_body), - ) - .mount(&server) - .await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} - -/// TS: a 429 response maps to `AiMuxError::ApiCall` (429 in `status_code`). -#[tokio::test] -async fn vertex_generate_rate_limit() { - let server = MockServer::start().await; - mock_generate_error( - &server, - 429, - json!({ "error": { "code": 429, "message": "Quota exceeded", "status": "RESOURCE_EXHAUSTED" } }), - ) - .await; - - let model = make_model(&server); - let result = model.do_generate(&default_options(test_prompt())).await; - assert!( - matches!(result, Err(ref e) if e.status_code() == Some(429)), - "expected RateLimited, got {result:?}" - ); -} - -// ═════════════════════════════════════════════════════════════════════════════ -// doStream — provider tool results & metadata (mirrors the google provider's -// do_stream tests in google_provider_tools_test.rs; issue #141). -// ═════════════════════════════════════════════════════════════════════════════ - -/// A single SSE chunk carrying `content.parts: [{ text }]`, optional -/// `finishReason`, and optional `groundingMetadata` / `urlContextMetadata`. -fn stream_chunk( - text: &str, - finish_reason: Option<&str>, - grounding: Option, - url_context: Option, -) -> Value { - let mut candidate = json!({ - "content": { "parts": [{ "text": text }], "role": "model" } - }); - if let Some(r) = finish_reason { - candidate["finishReason"] = json!(r); - } - if let Some(g) = grounding { - candidate["groundingMetadata"] = g; - } - if let Some(u) = url_context { - candidate["urlContextMetadata"] = u; - } - json!({ "candidates": [candidate] }) -} - -/// Extract the `Finish` part's provider metadata from a collected stream. -fn finish_provider_metadata(parts: &[StreamPart]) -> Option { - parts.iter().find_map(|p| match p { - StreamPart::Finish { - provider_metadata, .. - } => provider_metadata.clone(), - _ => None, - }) -} - -/// Collect `(tool_call_id, tool_name, input)` from streamed `ToolCall` parts. -fn stream_tool_calls(parts: &[StreamPart]) -> Vec<(String, String, 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() -} - -/// Collect `(tool_call_id, result)` from streamed `ToolResult` parts. -fn stream_tool_results(parts: &[StreamPart]) -> Vec<(String, Value)> { - parts - .iter() - .filter_map(|p| match p { - StreamPart::ToolResult { - tool_call_id, - result, - .. - } => Some((tool_call_id.clone(), result.clone())), - _ => None, - }) - .collect() -} - -/// Collect `(id, source_type, url, title)` from streamed `Source` parts. -fn stream_sources(parts: &[StreamPart]) -> Vec<(String, String, Option, Option)> { - parts - .iter() - .filter_map(|p| match p { - StreamPart::Source { - id, - source_type, - url, - title, - .. - } => Some((id.clone(), source_type.clone(), url.clone(), title.clone())), - _ => None, - }) - .collect() -} - -/// TS: "should stream code execution tool calls and results" — the Vertex -/// stream must not silently drop provider-executed code results (#141). -#[tokio::test] -async fn vertex_stream_code_execution_tool_calls_and_results() { - let server = MockServer::start().await; - mock_stream_content( - &server, - &sse_stream(&[ - json!({ - "candidates": [{ - "content": { - "parts": [{ "executableCode": { "language": "PYTHON", "code": "print(\"hello\")" } }] - } - }] - }), - json!({ - "candidates": [{ - "content": { - "parts": [ - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "hello\n" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "second result\n" } } - ] - }, - "finishReason": "STOP" - }] - }), - ]), - ) - .await; - mock_generate_content( - &server, - json!({ - "candidates": [{ - "content": { "parts": [{ "text": "ok" }] }, - "finishReason": "STOP" - }] - }), - ) - .await; - - let model = make_model(&server); - let tool = Tool::Provider(ProviderTool { - id: "google.code_execution".to_string(), - name: "runCode".to_string(), - args: json!({}), - }); - let options = CallOptions { - tools: Some(vec![tool.clone()]), - ..default_options(test_prompt()) - }; - let result = model - .do_stream(&options) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - let calls = stream_tool_calls(&parts); - let has_call = calls.iter().any(|(_, name, input)| { - name == "runCode" && *input == 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() { - let server = MockServer::start().await; - mock_stream_content( - &server, - &sse_stream(&[ - json!({ - "candidates": [{ - "content": { - "parts": [{ "executableCode": { - "language": "PYTHON", - "code": "img = PIL.Image.open('input.png')\nimg.save('output.png')\n" - } }] - } - }] - }), - json!({ - "candidates": [{ - "content": { - "parts": [{ "codeExecutionResult": { "outcome": "OUTCOME_OK" } }] - }, - "finishReason": "STOP" - }] - }), - ]), - ) - .await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - assert!( - stream_tool_calls(&parts) - .iter() - .any(|(_, name, _)| name == "code_execution"), - "expected a code_execution tool-call" - ); - // Missing output defaults to "". - let results = stream_tool_results(&parts); - let has_empty = results - .iter() - .any(|(_, output)| *output == json!({ "outcome": "OUTCOME_OK", "output": "" })); - assert!( - has_empty, - "expected a tool-result with empty output, got {results:?}" - ); -} - -/// TS: "should stream server-side toolCall and toolResponse parts (tool -/// combination)" — server tools are provider-executed and must be streamed. -#[tokio::test] -async fn vertex_stream_server_tool_call_and_response() { - let server = MockServer::start().await; - mock_stream_content( - &server, - &sse_stream(&[ - json!({ - "candidates": [{ - "content": { - "parts": [{ "toolCall": { - "toolType": "GOOGLE_SEARCH_WEB", - "args": { "query": "San Francisco weather" }, - "id": "server-call-1" - }, "thoughtSignature": "sig-abc" }] - } - }] - }), - json!({ - "candidates": [{ - "content": { - "parts": [ - { "toolResponse": { - "toolType": "GOOGLE_SEARCH_WEB", - "response": { "results": [{ "title": "Weather in SF" }] }, - "id": "server-call-1" - }, "thoughtSignature": "sig-def" }, - { "text": "The weather in San Francisco is sunny." } - ] - }, - "finishReason": "STOP" - }] - }), - ]), - ) - .await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - let calls = stream_tool_calls(&parts); - assert!( - calls - .iter() - .any(|(_, name, _)| name == "server:GOOGLE_SEARCH_WEB"), - "expected a server-side tool-call in the stream, got {calls:?}" - ); - - let results = stream_tool_results(&parts); - assert!( - results.iter().any(|(id, output)| *id == "server-call-1" - && *output == json!({ "results": [{ "title": "Weather in SF" }] })), - "expected a server tool-response keyed to server-call-1, got {results:?}" - ); - - // Provider-executed server tools → stop, not tool-calls. - let finish = parts.iter().find_map(|p| match p { - StreamPart::Finish { finish_reason, .. } => Some(finish_reason.clone()), - _ => None, - }); - assert_eq!( - finish.expect("finish part").unified, - FinishReasonUnified::Stop - ); - - let result = stream_text(&model, "Search the web", GenerateTextOptions::default()) - .await - .expect("stream_text should start"); - let mut core_stream = result.stream; - let mut core_parts = Vec::new(); - while let Some(part) = core_stream.next().await { - core_parts.push(part.expect("Core stream part should succeed")); - } - - assert!(core_parts.iter().any(|part| matches!( - part, - StreamPart::ToolCall { - tool_call_id, - tool_name, - input, - provider_executed: Some(true), - dynamic: Some(true), - invalid: None, - provider_metadata: Some(metadata), - .. - } if tool_call_id == "server-call-1" - && tool_name == "server:GOOGLE_SEARCH_WEB" - && *input == json!({ "query": "San Francisco weather" }) - && metadata["googleVertex"]["serverToolCallId"] == "server-call-1" - && metadata["vertex"] == metadata["googleVertex"] - && metadata.get("google").is_none() - ))); - assert!(core_parts.iter().any(|part| matches!( - part, - StreamPart::ToolResult { - tool_call_id, - tool_name, - result, - dynamic: None, - provider_metadata: Some(metadata), - .. - } if tool_call_id == "server-call-1" - && tool_name == "server:GOOGLE_SEARCH_WEB" - && *result == json!({ "results": [{ "title": "Weather in SF" }] }) - && metadata["googleVertex"]["serverToolType"] == "GOOGLE_SEARCH_WEB" - && metadata["vertex"] == metadata["googleVertex"] - && metadata.get("google").is_none() - ))); -} - -/// TS: "should stream source events" + "should deduplicate sources across -/// chunks" — groundingMetadata is processed, not dropped. -#[tokio::test] -async fn vertex_stream_grounding_metadata_sources() { - let server = MockServer::start().await; - mock_stream_content( - &server, - &sse_stream(&[ - stream_chunk( - "first chunk", - None, - Some(json!({ - "groundingChunks": [ - { "web": { "uri": "https://example.com", "title": "Example" } }, - { "web": { "uri": "https://unique.com", "title": "Unique" } } - ] - })), - None, - ), - stream_chunk( - "second chunk", - None, - Some(json!({ - "groundingChunks": [ - { "web": { "uri": "https://example.com", "title": "Example Duplicate" } }, - { "web": { "uri": "https://another.com", "title": "Another" } } - ] - })), - None, - ), - stream_chunk("final chunk", Some("STOP"), None, None), - ]), - ) - .await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - let sources = stream_sources(&parts); - // The duplicate https://example.com appears in two chunks but should be - // emitted only once (keeping the first title). - let example: Vec<_> = sources - .iter() - .filter(|(_, _, url, _)| url.as_deref() == Some("https://example.com")) - .collect(); - assert_eq!( - example.len(), - 1, - "duplicate source should be emitted once, got {sources:?}" - ); - assert_eq!(example[0].1, "url"); - assert_eq!(example[0].3.as_deref(), Some("Example")); - assert_eq!( - sources.len(), - 3, - "expected 3 deduplicated sources, got {sources:?}" - ); -} - -/// TS: "should preserve grounding/url context metadata when it arrives before -/// the finishReason chunk" — Finish provider_metadata is non-empty with all -/// six fields under the `googleVertex` key. -#[tokio::test] -async fn vertex_stream_finish_provider_metadata() { - let server = MockServer::start().await; - mock_stream_content( - &server, - &sse_stream(&[ - { - let mut c = stream_chunk( - "hello", - None, - Some(json!({ - "webSearchQueries": ["super bowl 2026 halftime show"], - "groundingChunks": [{ - "web": { "uri": "https://example.com/superbowl", "title": "Super Bowl 2026" } - }] - })), - Some(json!({ - "urlMetadata": [{ - "retrievedUrl": "https://example.com/page", - "urlRetrievalStatus": "URL_RETRIEVAL_STATUS_SUCCESS" - }] - })), - ); - // Carry a valued promptFeedback so the assertion below locks - // the captured value, not just key presence. - c["promptFeedback"] = json!({ "promptTokenCount": 12 }); - c - }, - { - let mut c = stream_chunk(" world", Some("STOP"), None, None); - c["candidates"][0]["safetyRatings"] = json!([ - { "category": "HARM_CATEGORY_HARASSMENT", "probability": "NEGLIGIBLE" } - ]); - c["candidates"][0]["finishMessage"] = json!("natural stop"); - c["usageMetadata"] = json!({ - "promptTokenCount": 38, - "candidatesTokenCount": 1335, - "totalTokenCount": 1890 - }); - c - }, - ]), - ) - .await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - let pm = finish_provider_metadata(&parts).expect("finish part"); - let vertex = &pm["googleVertex"]; - assert_eq!(&pm["vertex"], vertex); - assert!(pm.get("google").is_none()); - assert!( - !vertex.is_null() && vertex.as_object().map(|o| !o.is_empty()).unwrap_or(false), - "googleVertex provider metadata should be non-empty, got {pm}" - ); - assert_eq!( - vertex["groundingMetadata"], - json!({ - "webSearchQueries": ["super bowl 2026 halftime show"], - "groundingChunks": [{ - "web": { "uri": "https://example.com/superbowl", "title": "Super Bowl 2026" } - }] - }) - ); - assert_eq!( - vertex["urlContextMetadata"], - json!({ - "urlMetadata": [{ - "retrievedUrl": "https://example.com/page", - "urlRetrievalStatus": "URL_RETRIEVAL_STATUS_SUCCESS" - }] - }) - ); - // usageMetadata is the serialized GoogleUsageMetadata (includes null - // optional fields, mirroring the google provider). - assert_eq!(vertex["usageMetadata"]["promptTokenCount"], json!(38)); - assert_eq!(vertex["usageMetadata"]["candidatesTokenCount"], json!(1335)); - assert_eq!(vertex["usageMetadata"]["totalTokenCount"], json!(1890)); - // All six keys are always present (null when absent), matching google. - for key in [ - "promptFeedback", - "groundingMetadata", - "urlContextMetadata", - "safetyRatings", - "usageMetadata", - "finishMessage", - ] { - assert!( - vertex.get(key).is_some(), - "expected key `{key}` in googleVertex metadata, got {pm}" - ); - } - // …and the three snapshot fields carry their captured values, not just - // key presence (locks the capture code, not the key scaffold). - assert_eq!(vertex["promptFeedback"], json!({ "promptTokenCount": 12 })); - assert_eq!( - vertex["safetyRatings"], - json!([{ "category": "HARM_CATEGORY_HARASSMENT", "probability": "NEGLIGIBLE" }]) - ); - assert_eq!(vertex["finishMessage"], json!("natural stop")); - - // Usage also lands on the Finish part itself. - let finish_usage = parts.iter().find_map(|p| match p { - StreamPart::Finish { usage, .. } => Some(usage.clone()), - _ => None, - }); - assert_eq!( - finish_usage.expect("finish usage").input_tokens.total, - Some(38) - ); -} diff --git a/aimux-providers/tests/vertex_remaining_test.rs b/aimux-providers/tests/vertex_remaining_test.rs deleted file mode 100644 index 18eaf7874..000000000 --- a/aimux-providers/tests/vertex_remaining_test.rs +++ /dev/null @@ -1,504 +0,0 @@ -//! Remaining Google Vertex AI provider tests — ported from the TS SDK suite. -//! -//! Mirrors `reference/ai/packages/google-vertex/src/google-vertex-provider.test.ts` -//! (provider configuration: auth headers, base URL, env-var resolution, project -//! handling, Express-mode API key, tuned-model restrictions). -//! -//! The TS tests mock `createAuthTokenGenerator` / `createGoogleVertex` and assert -//! on the options passed to the base provider. The Rust provider has no lazy -//! token generator — a bearer token (or API key) is supplied directly to -//! `VertexProviderConfig` — so each TS scenario is translated to the equivalent -//! observable Rust behaviour: which auth variant is selected, which headers are -//! sent, and how the project/location shape the base URL. -//! -//! Model-level generate/stream behaviour is already covered by -//! `vertex_model_test.rs`; this file focuses on provider configuration only. - -use serde_json::json; -use serial_test::serial; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::CallOptions; -use aimux_core::provider::Provider; -use aimux_providers::vertex::{VertexAuth, VertexProvider, VertexProviderConfig}; - -// ── Shared helpers ─────────────────────────────────────────────────────────── - -/// All environment variables consulted by `VertexProviderConfig::from_env`. -const ENV_VARS: &[&str] = &[ - "GOOGLE_VERTEX_API_KEY", - "GOOGLE_VERTEX_ACCESS_TOKEN", - "GOOGLE_VERTEX_PROJECT", - "GOOGLE_VERTEX_LOCATION", -]; - -/// Remove every Vertex env var (test isolation for `#[serial]` env tests). -fn clear_vertex_env() { - for var in ENV_VARS { - unsafe { - std::env::remove_var(var); - } - } -} - -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -/// Mount a minimal 200 generateContent mock on the standard model path. -async fn mock_ok(server: &MockServer) { - Mock::given(method("POST")) - .and(path("/models/gemini-2.0-flash:generateContent")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "candidates": [{ - "content": { "parts": [{ "text": "hi" }], "role": "model" }, - "finishReason": "STOP", "index": 0 - }] - }))) - .mount(server) - .await; -} - -// ════════════════════════════════════════════════════════════════════════════ -// Provider identity (TS: google-vertex-provider.test.ts — base wiring) -// ════════════════════════════════════════════════════════════════════════════ - -#[tokio::test] -async fn provider_name_is_google_vertex() { - let config = VertexProviderConfig::new("test-token", "my-project", "us-central1"); - let provider = VertexProvider::new(config); - assert_eq!(provider.name(), "google.vertex"); -} - -#[tokio::test] -async fn language_model_trait_method_returns_boxed_model() { - let config = VertexProviderConfig::new("test-token", "my-project", "us-central1"); - let provider = VertexProvider::new(config); - let model = provider.language_model("gemini-2.0-flash").expect("model"); - assert_eq!(model.model_id(), "gemini-2.0-flash"); - assert_eq!(model.provider(), "google.vertex"); -} - -#[tokio::test] -async fn model_factory_returns_vertex_model() { - let config = VertexProviderConfig::new("test-token", "my-project", "us-central1"); - let provider = VertexProvider::new(config); - let model = provider.model("gemini-2.0-flash").expect("model"); - assert_eq!(model.model_id(), "gemini-2.0-flash"); - assert_eq!(model.provider(), "google.vertex"); -} - -/// TS: "creates the auth token generator once per provider instance" — a single -/// provider instance can mint multiple models that all share its config. -#[tokio::test] -async fn one_provider_instance_creates_multiple_models() { - let config = VertexProviderConfig::new("test-token", "my-project", "us-central1"); - let provider = VertexProvider::new(config); - - let m1 = provider.model("gemini-2.0-flash").expect("model 1"); - let m2 = provider.model("gemini-2.5-pro").expect("model 2"); - - assert_eq!(m1.model_id(), "gemini-2.0-flash"); - assert_eq!(m2.model_id(), "gemini-2.5-pro"); - assert_eq!(m1.provider(), "google.vertex"); - assert_eq!(m2.provider(), "google.vertex"); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Auth headers (TS: "default headers function should return auth token", -// "should use custom headers in addition to auth token", -// "should pass options through to base provider when apiKey -// is provided") -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: bearer-token auth resolves to an `Authorization: Bearer {token}` header. -#[tokio::test] -async fn bearer_token_sent_via_authorization_header() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/gemini-2.0-flash:generateContent")) - .and(header("authorization", "Bearer my-bearer-token")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "candidates": [{ - "content": { "parts": [{ "text": "hi" }], "role": "model" }, - "finishReason": "STOP", "index": 0 - }] - }))) - .mount(&server) - .await; - - let config = VertexProviderConfig::new("my-bearer-token", "my-project", "us-central1") - .with_base_url(server.uri()); - let provider = VertexProvider::new(config); - let model = provider.model("gemini-2.0-flash").expect("model"); - - model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed — mock requires the Authorization: Bearer header"); -} - -/// TS: custom headers are sent alongside the auth token. -#[tokio::test] -async fn custom_headers_sent_alongside_bearer_token() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/gemini-2.0-flash:generateContent")) - .and(header("authorization", "Bearer my-bearer-token")) - .and(header("custom-header", "custom-value")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "candidates": [{ - "content": { "parts": [{ "text": "hi" }], "role": "model" }, - "finishReason": "STOP", "index": 0 - }] - }))) - .mount(&server) - .await; - - let config = VertexProviderConfig::new("my-bearer-token", "my-project", "us-central1") - .with_base_url(server.uri()); - let provider = VertexProvider::new(config); - let model = provider.model("gemini-2.0-flash").expect("model"); - - let mut opts = default_options(test_prompt()); - let mut headers = std::collections::HashMap::new(); - headers.insert("Custom-Header".to_string(), "custom-value".to_string()); - opts.headers = Some(headers); - - model - .do_generate(&opts) - .await - .expect("should succeed — mock requires both headers"); -} - -/// TS: "should pass options through to base provider when apiKey is provided" — -/// Express-mode API key uses `x-goog-api-key` (and does NOT send an -/// `Authorization: Bearer` header, i.e. the token generator is not invoked). -#[tokio::test] -async fn api_key_uses_x_goog_api_key_header() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/gemini-2.0-flash:generateContent")) - .and(header("x-goog-api-key", "express-api-key")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "candidates": [{ - "content": { "parts": [{ "text": "hi" }], "role": "model" }, - "finishReason": "STOP", "index": 0 - }] - }))) - .mount(&server) - .await; - - let config = VertexProviderConfig::with_api_key("express-api-key").with_base_url(server.uri()); - let provider = VertexProvider::new(config); - let model = provider.model("gemini-2.0-flash").expect("model"); - - model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed — mock requires the x-goog-api-key header"); -} - -/// Express-mode config selects the `ApiKey` auth variant (no bearer token). -#[tokio::test] -async fn with_api_key_selects_apikey_auth_variant() { - let config = VertexProviderConfig::with_api_key("express-key"); - match &config.auth { - VertexAuth::ApiKey(k) => assert_eq!(k, "express-key"), - other => panic!("expected ApiKey auth, got {other:?}"), - } -} - -/// Bearer-token config selects the `BearerToken` auth variant. -#[tokio::test] -async fn new_selects_bearer_token_auth_variant() { - let config = VertexProviderConfig::new("the-token", "proj", "us-central1"); - match &config.auth { - VertexAuth::BearerToken(t) => assert_eq!(t, "the-token"), - other => panic!("expected BearerToken auth, got {other:?}"), - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// Base URL construction (TS: project / location handling) -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: the project is threaded into the base URL (projectId → URL path). -#[tokio::test] -async fn new_embeds_project_in_base_url() { - let config = VertexProviderConfig::new("token", "my-gcp-project", "us-central1"); - assert!( - config.base_url.contains("/projects/my-gcp-project/"), - "base_url should embed the project: {}", - config.base_url - ); -} - -/// TS: the location is threaded into the base URL. -#[tokio::test] -async fn new_embeds_location_in_base_url() { - let config = VertexProviderConfig::new("token", "proj", "us-central1"); - assert!( - config.base_url.contains("/locations/us-central1/"), - "base_url should embed the location: {}", - config.base_url - ); -} - -/// Location `us-central1` → `{location}-aiplatform.googleapis.com` host. -#[tokio::test] -async fn base_url_for_regional_location() { - let config = VertexProviderConfig::new("token", "proj", "us-central1"); - assert_eq!( - config.base_url, - "https://us-central1-aiplatform.googleapis.com/v1beta1/projects/proj/locations/us-central1/publishers/google" - ); -} - -/// Location `global` → bare `aiplatform.googleapis.com` host. -#[tokio::test] -async fn base_url_for_global_location() { - let config = VertexProviderConfig::new("token", "proj", "global"); - assert_eq!( - config.base_url, - "https://aiplatform.googleapis.com/v1beta1/projects/proj/locations/global/publishers/google" - ); -} - -/// Location `eu`/`us` → multi-region `aiplatform.{region}.rep.googleapis.com`. -#[tokio::test] -async fn base_url_for_eu_multi_region() { - let config = VertexProviderConfig::new("token", "proj", "eu"); - assert_eq!( - config.base_url, - "https://aiplatform.eu.rep.googleapis.com/v1beta1/projects/proj/locations/eu/publishers/google" - ); -} - -#[tokio::test] -async fn base_url_for_us_multi_region() { - let config = VertexProviderConfig::new("token", "proj", "us"); - assert_eq!( - config.base_url, - "https://aiplatform.us.rep.googleapis.com/v1beta1/projects/proj/locations/us/publishers/google" - ); -} - -/// Express-mode (API key) uses the `/v1/publishers/google` endpoint (no -/// project/location scoping). -#[tokio::test] -async fn express_mode_base_url() { - let config = VertexProviderConfig::with_api_key("key"); - assert_eq!( - config.base_url, - "https://aiplatform.googleapis.com/v1/publishers/google" - ); -} - -/// `with_base_url` overrides the constructed URL and strips a trailing slash. -#[tokio::test] -async fn with_base_url_strips_trailing_slash() { - let config = VertexProviderConfig::new("token", "proj", "us-central1") - .with_base_url("https://example.com/v1beta1/"); - assert_eq!(config.base_url, "https://example.com/v1beta1"); -} - -/// `with_base_url` is honoured end-to-end (request hits the override host). -#[tokio::test] -async fn with_base_url_is_used_for_requests() { - let server = MockServer::start().await; - mock_ok(&server).await; - - let config = - VertexProviderConfig::new("token", "proj", "us-central1").with_base_url(server.uri()); - let provider = VertexProvider::new(config); - let model = provider.model("gemini-2.0-flash").expect("model"); - - model - .do_generate(&default_options(test_prompt())) - .await - .expect("request should hit the mock server"); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Tuned-model restriction (TS: Express mode cannot address tuned endpoints) -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: a tuned model (`endpoints/…`) cannot be used with Express-mode API key -/// auth — the provider rejects it up front. -#[tokio::test] -async fn tuned_model_rejected_with_api_key_auth() { - let config = VertexProviderConfig::with_api_key("express-key"); - let provider = VertexProvider::new(config); - let result = provider.model("endpoints/1234567890"); - assert!( - result.is_err(), - "tuned models should be rejected under Express-mode API key auth" - ); -} - -/// Tuned models are allowed with standard (bearer-token) auth. -#[tokio::test] -async fn tuned_model_allowed_with_bearer_token_auth() { - let config = VertexProviderConfig::new("token", "proj", "us-central1"); - let provider = VertexProvider::new(config); - let model = provider - .model("endpoints/1234567890") - .expect("tuned models should be allowed with bearer-token auth"); - assert_eq!(model.model_id(), "endpoints/1234567890"); -} - -// ════════════════════════════════════════════════════════════════════════════ -// from_env (TS: env-var driven provider creation) -// ════════════════════════════════════════════════════════════════════════════ - -/// `GOOGLE_VERTEX_API_KEY` selects Express mode (API key auth). -#[tokio::test] -#[serial] -async fn from_env_uses_api_key_when_set() { - clear_vertex_env(); - unsafe { - std::env::set_var("GOOGLE_VERTEX_API_KEY", "env-api-key"); - } - let config = VertexProviderConfig::from_env().expect("from_env"); - match config.auth { - VertexAuth::ApiKey(k) => assert_eq!(k, "env-api-key"), - other => panic!("expected ApiKey auth, got {other:?}"), - } - clear_vertex_env(); -} - -/// `GOOGLE_VERTEX_API_KEY` takes precedence over the access-token vars. -#[tokio::test] -#[serial] -async fn from_env_prefers_api_key_over_access_token() { - clear_vertex_env(); - unsafe { - std::env::set_var("GOOGLE_VERTEX_API_KEY", "env-api-key"); - std::env::set_var("GOOGLE_VERTEX_ACCESS_TOKEN", "env-token"); - std::env::set_var("GOOGLE_VERTEX_PROJECT", "env-project"); - } - let config = VertexProviderConfig::from_env().expect("from_env"); - // API key wins → Express mode base URL (no project scoping). - assert!( - config.base_url.contains("/v1/publishers/google"), - "API key should select Express mode: {}", - config.base_url - ); - match config.auth { - VertexAuth::ApiKey(k) => assert_eq!(k, "env-api-key"), - other => panic!("expected ApiKey auth, got {other:?}"), - } - clear_vertex_env(); -} - -/// Access token + project + location → bearer-token auth with scoped base URL. -#[tokio::test] -#[serial] -async fn from_env_uses_access_token_project_location() { - clear_vertex_env(); - unsafe { - std::env::set_var("GOOGLE_VERTEX_ACCESS_TOKEN", "env-token"); - std::env::set_var("GOOGLE_VERTEX_PROJECT", "env-project"); - std::env::set_var("GOOGLE_VERTEX_LOCATION", "europe-west1"); - } - let config = VertexProviderConfig::from_env().expect("from_env"); - match config.auth { - VertexAuth::BearerToken(t) => assert_eq!(t, "env-token"), - other => panic!("expected BearerToken auth, got {other:?}"), - } - assert!(config.base_url.contains("/projects/env-project/")); - assert!(config.base_url.contains("/locations/europe-west1/")); - clear_vertex_env(); -} - -/// `GOOGLE_VERTEX_LOCATION` defaults to `us-central1` when unset. -#[tokio::test] -#[serial] -async fn from_env_defaults_location_to_us_central1() { - clear_vertex_env(); - unsafe { - std::env::set_var("GOOGLE_VERTEX_ACCESS_TOKEN", "env-token"); - std::env::set_var("GOOGLE_VERTEX_PROJECT", "env-project"); - } - let config = VertexProviderConfig::from_env().expect("from_env"); - assert!( - config.base_url.contains("/locations/us-central1/"), - "location should default to us-central1: {}", - config.base_url - ); - clear_vertex_env(); -} - -/// Missing every var → error. -#[tokio::test] -#[serial] -async fn from_env_errors_when_all_missing() { - clear_vertex_env(); - let result = VertexProviderConfig::from_env(); - assert!(result.is_err(), "from_env should error with no credentials"); - clear_vertex_env(); -} - -/// Access token present but project missing → error. -#[tokio::test] -#[serial] -async fn from_env_errors_when_project_missing() { - clear_vertex_env(); - unsafe { - std::env::set_var("GOOGLE_VERTEX_ACCESS_TOKEN", "env-token"); - } - let result = VertexProviderConfig::from_env(); - assert!( - result.is_err(), - "from_env should error when GOOGLE_VERTEX_PROJECT is missing" - ); - clear_vertex_env(); -} - -/// Project present but access token missing → error. -#[tokio::test] -#[serial] -async fn from_env_errors_when_access_token_missing() { - clear_vertex_env(); - unsafe { - std::env::set_var("GOOGLE_VERTEX_PROJECT", "env-project"); - } - let result = VertexProviderConfig::from_env(); - assert!( - result.is_err(), - "from_env should error when GOOGLE_VERTEX_ACCESS_TOKEN is missing" - ); - clear_vertex_env(); -} - -/// An empty `GOOGLE_VERTEX_API_KEY` falls through to access-token auth. -#[tokio::test] -#[serial] -async fn from_env_empty_api_key_falls_through_to_access_token() { - clear_vertex_env(); - unsafe { - std::env::set_var("GOOGLE_VERTEX_API_KEY", " "); - std::env::set_var("GOOGLE_VERTEX_ACCESS_TOKEN", "env-token"); - std::env::set_var("GOOGLE_VERTEX_PROJECT", "env-project"); - } - let config = VertexProviderConfig::from_env().expect("from_env"); - match config.auth { - VertexAuth::BearerToken(t) => assert_eq!(t, "env-token"), - other => panic!("expected BearerToken auth, got {other:?}"), - } - clear_vertex_env(); -} diff --git a/aimux-providers/tests/xai_responses_test.rs b/aimux-providers/tests/xai_responses_test.rs deleted file mode 100644 index 24f936a74..000000000 --- a/aimux-providers/tests/xai_responses_test.rs +++ /dev/null @@ -1,3030 +0,0 @@ -//! Rust translations of the AI SDK xAI Responses API tests. -//! -//! Sources (TS → Rust): -//! - `xai-responses-language-model.test.ts` → `do_generate` / `do_stream` mods -//! - `xai-responses-prepare-tools.test.ts` → `prepare_tools` mod -//! - `convert-xai-responses-usage.test.ts` → `convert_usage` mod -//! - `convert-to-xai-responses-input.test.ts` → `convert_input` mod -//! -//! Each test uses `wiremock` to spin up a mock HTTP server, configures a JSON -//! or SSE response, creates an `XaiResponsesModel` via `XAIProvider`, calls -//! `do_generate` / `do_stream`, and asserts on the result. - -use std::collections::HashMap; - -use futures::StreamExt; -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::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::types::ReasoningEffort; - -use aimux_providers::{XAIConfig, XAIProvider}; - -// ── shared helpers ─────────────────────────────────────────────────────────── - -fn test_prompt() -> Vec { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("hello")], - ..Default::default() - }] -} - -fn default_options(prompt: Vec) -> CallOptions { - CallOptions::new(prompt) -} - -fn sse_event(json_str: &str) -> String { - format!("data: {json_str}\n\n") -} - -fn sse_body(events: &[&str]) -> String { - let mut body = String::new(); - for event in events { - body.push_str(event); - } - body.push_str("data: [DONE]\n\n"); - body -} - -async fn collect_stream(result: aimux_core::result::StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -fn make_provider(server: &MockServer) -> XAIProvider { - let config = XAIConfig::new("test-api-key").with_base_url(server.uri()); - XAIProvider::new(config) -} - -fn xai_provider_options(opts: Value) -> Option> { - let mut m = HashMap::new(); - m.insert("xai".to_string(), opts); - Some(m) -} - -/// A standard Responses API JSON body returning a text message. -fn text_response_body() -> Value { - json!({ - "id": "resp_123", - "object": "response", - "created_at": 1700000000, - "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [{ - "type": "message", - "id": "msg_123", - "status": "completed", - "role": "assistant", - "content": [{ - "type": "output_text", - "text": "hello world", - "annotations": [] - }] - }], - "usage": { "input_tokens": 10, "output_tokens": 5, "total_tokens": 15 } - }) -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate — basic text generation -// ════════════════════════════════════════════════════════════════════════════ - -mod do_generate { - use super::*; - - /// TS: should generate text content - #[tokio::test] - async fn generate_text_content() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_response_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::Text { text, .. } => assert_eq!(text, "hello world"), - other => panic!("expected Text, got {other:?}"), - } - } - - /// TS: should extract usage correctly - #[tokio::test] - async fn extract_usage() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", - "object": "response", - "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [], - "usage": { - "input_tokens": 345, - "output_tokens": 538, - "total_tokens": 883, - "output_tokens_details": { "reasoning_tokens": 123 } - } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert_eq!(result.usage.input_tokens.total, Some(345)); - assert_eq!(result.usage.input_tokens.no_cache, Some(345)); - assert_eq!(result.usage.input_tokens.cache_read, Some(0)); - assert_eq!(result.usage.output_tokens.total, Some(538)); - assert_eq!(result.usage.output_tokens.text, Some(415)); // 538 - 123 - assert_eq!(result.usage.output_tokens.reasoning, Some(123)); - } - - /// TS: should expose cost_in_usd_ticks in providerMetadata - #[tokio::test] - async fn cost_in_usd_ticks() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", - "object": "response", - "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5, "cost_in_usd_ticks": 113500 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert_eq!( - result.provider_metadata, - Some(json!({ "xai": { "costInUsdTicks": 113500 } })) - ); - } - - /// TS: should not include providerMetadata when cost_in_usd_ticks is missing - #[tokio::test] - async fn no_cost_no_metadata() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", - "object": "response", - "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert!(result.provider_metadata.is_none()); - } - - /// TS: should extract finish reason from status - #[tokio::test] - async fn finish_reason_from_status() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", - "object": "response", - "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert_eq!(result.finish_reason.raw.as_deref(), Some("completed")); - assert_eq!( - result.finish_reason.unified, - aimux_core::types::FinishReasonUnified::Stop - ); - } - - /// TS: should return tool-calls finish reason when function_call is present - #[tokio::test] - async fn tool_calls_finish_reason() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", - "object": "response", - "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [{ - "type": "function_call", - "id": "fc_123", - "name": "weather", - "arguments": "{\"location\":\"sf\"}", - "call_id": "call_123" - }], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - tools: Some(vec![ - FunctionTool::new( - "weather", - json!({ - "type": "object", - "properties": { "location": { "type": "string" } } - }), - ) - .into(), - ]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!( - result.finish_reason.unified, - aimux_core::types::FinishReasonUnified::ToolCalls - ); - assert_eq!(result.finish_reason.raw.as_deref(), Some("completed")); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate — reasoning content -// ════════════════════════════════════════════════════════════════════════════ - -mod reasoning { - use super::*; - - /// TS: should extract reasoning with encrypted content when store=false - #[tokio::test] - async fn reasoning_with_encrypted_content() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", - "object": "response", - "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [ - { - "type": "reasoning", - "id": "rs_456", - "status": "completed", - "summary": [{ "type": "summary_text", "text": "First, analyze the question carefully." }], - "encrypted_content": "abc123encryptedcontent" - }, - { - "type": "message", - "id": "msg_123", - "status": "completed", - "role": "assistant", - "content": [{ "type": "output_text", "text": "The answer is 42.", "annotations": [] }] - } - ], - "usage": { "input_tokens": 10, "output_tokens": 20, "output_tokens_details": { "reasoning_tokens": 15 } } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert_eq!(result.content.len(), 2); - match &result.content[0] { - GenerateContent::Reasoning { - text, - provider_metadata, - } => { - assert_eq!(text, "First, analyze the question carefully."); - assert_eq!( - provider_metadata, - &Some( - json!({ "xai": { "itemId": "rs_456", "reasoningEncryptedContent": "abc123encryptedcontent" } }) - ) - ); - } - other => panic!("expected Reasoning, got {other:?}"), - } - match &result.content[1] { - GenerateContent::Text { text, .. } => assert_eq!(text, "The answer is 42."), - other => panic!("expected Text, got {other:?}"), - } - } - - /// TS: should handle reasoning without encrypted content - #[tokio::test] - async fn reasoning_without_encrypted_content() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", - "object": "response", - "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [ - { - "type": "reasoning", - "id": "rs_456", - "status": "completed", - "summary": [{ "type": "summary_text", "text": "Thinking through the problem." }] - }, - { - "type": "message", - "id": "msg_123", - "status": "completed", - "role": "assistant", - "content": [{ "type": "output_text", "text": "Solution found.", "annotations": [] }] - } - ], - "usage": { "input_tokens": 10, "output_tokens": 15 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - match &result.content[0] { - GenerateContent::Reasoning { - text, - provider_metadata, - } => { - assert_eq!(text, "Thinking through the problem."); - assert_eq!( - provider_metadata, - &Some(json!({ "xai": { "itemId": "rs_456" } })) - ); - } - other => panic!("expected Reasoning, got {other:?}"), - } - } - - /// TS: should extract reasoning from content when summary is empty - #[tokio::test] - async fn reasoning_from_content() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", - "object": "response", - "status": "completed", - "model": "grok-3-mini", - "output": [ - { - "type": "reasoning", - "id": "rs_456", - "status": "completed", - "summary": [], - "content": [{ "type": "reasoning_text", "text": "Let me think step by step." }] - }, - { - "type": "message", - "id": "msg_123", - "status": "completed", - "role": "assistant", - "content": [{ "type": "output_text", "text": "The answer is 444.", "annotations": [] }] - } - ], - "usage": { "input_tokens": 10, "output_tokens": 15 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-3-mini"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - match &result.content[0] { - GenerateContent::Reasoning { text, .. } => { - assert_eq!(text, "Let me think step by step."); - } - other => panic!("expected Reasoning, got {other:?}"), - } - } - - /// TS: should extract reasoning with encrypted content but empty summary text - #[tokio::test] - async fn reasoning_empty_summary_with_encrypted() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", - "object": "response", - "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [ - { - "type": "reasoning", - "id": "rs_789", - "status": "completed", - "summary": [], - "encrypted_content": "encrypted_zdr_content_xyz" - }, - { - "type": "message", - "id": "msg_123", - "status": "completed", - "role": "assistant", - "content": [{ "type": "output_text", "text": "Here is my response.", "annotations": [] }] - } - ], - "usage": { "input_tokens": 10, "output_tokens": 20, "output_tokens_details": { "reasoning_tokens": 15 } } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - match &result.content[0] { - GenerateContent::Reasoning { - text, - provider_metadata, - } => { - assert_eq!(text, ""); - assert_eq!( - provider_metadata, - &Some( - json!({ "xai": { "itemId": "rs_789", "reasoningEncryptedContent": "encrypted_zdr_content_xyz" } }) - ) - ); - } - other => panic!("expected Reasoning, got {other:?}"), - } - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate — settings and provider options -// ════════════════════════════════════════════════════════════════════════════ - -mod settings { - use super::*; - - /// TS: should send model id and settings - #[tokio::test] - async fn send_model_id_and_settings() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", - "object": "response", - "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - prompt: vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("you are helpful")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("hello")], - ..Default::default() - }, - ], - temperature: Some(0.5), - top_p: Some(0.9), - max_output_tokens: Some(100), - ..CallOptions::new(vec![]) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["model"], "grok-4-fast-non-reasoning"); - assert_eq!(body["temperature"], 0.5); - assert_eq!(body["top_p"], 0.9); - assert_eq!(body["max_output_tokens"], 100); - assert_eq!( - body["input"], - json!([ - { "role": "system", "content": "you are helpful" }, - { "role": "user", "content": [{ "type": "input_text", "text": "hello" }] } - ]) - ); - } - - /// TS: reasoningEffort provider option - #[tokio::test] - async fn reasoning_effort_option() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - provider_options: xai_provider_options(json!({ "reasoningEffort": "high" })), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!(result.request_body.unwrap()["reasoning"]["effort"], "high"); - } - - /// TS: reasoningSummary provider option - #[tokio::test] - async fn reasoning_summary_option() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - provider_options: xai_provider_options(json!({ "reasoningSummary": "concise" })), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!( - result.request_body.unwrap()["reasoning"]["summary"], - "concise" - ); - } - - /// TS: reasoningEffort and reasoningSummary together - #[tokio::test] - async fn reasoning_effort_and_summary() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - provider_options: xai_provider_options( - json!({ "reasoningEffort": "high", "reasoningSummary": "detailed" }), - ), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!( - result.request_body.unwrap()["reasoning"], - json!({ "effort": "high", "summary": "detailed" }) - ); - } - - /// TS: store:false - #[tokio::test] - async fn store_false() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - provider_options: xai_provider_options(json!({ "store": false })), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["store"], false); - assert_eq!(body["include"], json!(["reasoning.encrypted_content"])); - } - - /// TS: store:true (should not set store or include) - #[tokio::test] - async fn store_true() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - provider_options: xai_provider_options(json!({ "store": true })), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert!(body.get("store").is_none()); - assert!(body.get("include").is_none()); - } - - /// TS: previousResponseId - #[tokio::test] - async fn previous_response_id() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - provider_options: xai_provider_options(json!({ "previousResponseId": "resp_456" })), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!( - result.request_body.unwrap()["previous_response_id"], - "resp_456" - ); - } - - /// TS: include with file_search_call.results - #[tokio::test] - async fn include_option() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - provider_options: xai_provider_options( - json!({ "include": ["file_search_call.results"] }), - ), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!( - result.request_body.unwrap()["include"], - json!(["file_search_call.results"]) - ); - } - - /// TS: include with file_search_call.results and store:false - #[tokio::test] - async fn include_with_store_false() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - provider_options: xai_provider_options( - json!({ "include": ["file_search_call.results"], "store": false }), - ), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["store"], false); - assert_eq!( - body["include"], - json!(["file_search_call.results", "reasoning.encrypted_content"]) - ); - } - - /// TS: should map top-level reasoning to reasoning effort - #[tokio::test] - async fn top_level_reasoning() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4.3", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4.3"); - let options = CallOptions { - reasoning: Some(ReasoningEffort::High), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!(result.request_body.unwrap()["reasoning"]["effort"], "high"); - } - - /// TS: should map top-level reasoning none to "none" - #[tokio::test] - async fn top_level_reasoning_none() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4.3", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4.3"); - let options = CallOptions { - reasoning: Some(ReasoningEffort::None), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!(result.request_body.unwrap()["reasoning"]["effort"], "none"); - } - - /// TS: should prefer providerOptions reasoningEffort over top-level reasoning - #[tokio::test] - async fn prefer_provider_reasoning_effort() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4.3", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4.3"); - let options = CallOptions { - reasoning: Some(ReasoningEffort::None), - provider_options: xai_provider_options(json!({ "reasoningEffort": "high" })), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!(result.request_body.unwrap()["reasoning"]["effort"], "high"); - } - - /// TS: should omit reasoning effort and warn for models that do not support it - #[tokio::test] - async fn unsupported_reasoning_model() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4.20-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4.20-reasoning"); - let options = CallOptions { - reasoning: Some(ReasoningEffort::None), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert!(body.get("reasoning").is_none()); - assert!(result.warnings.iter().any(|w| match w { - aimux_core::types::Warning::Unsupported { feature, details } => { - feature == "reasoning" - && details.as_deref() - == Some("reasoning \"none\" is not supported by this model.") - } - _ => false, - })); - } - - /// TS: should warn about unsupported stopSequences - #[tokio::test] - async fn warn_stop_sequences() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - stop_sequences: Some(vec!["\n\n".to_string(), "STOP".to_string()]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert!(result.warnings.iter().any(|w| match w { - aimux_core::types::Warning::Unsupported { feature, .. } => feature == "stopSequences", - _ => false, - })); - } - - /// TS: should send response format json schema - #[tokio::test] - async fn response_format_json_schema() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - response_format: Some(ResponseFormat::Json { - schema: Some(json!({ - "type": "object", - "properties": { "name": { "type": "string" } }, - "required": ["name"], - "additionalProperties": false - })), - name: Some("recipe".to_string()), - description: Some("A recipe object".to_string()), - }), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["text"]["format"]["type"], "json_schema"); - assert_eq!(body["text"]["format"]["strict"], true); - assert_eq!(body["text"]["format"]["name"], "recipe"); - assert_eq!(body["text"]["format"]["description"], "A recipe object"); - } - - /// TS: should send response format json object when no schema provided - #[tokio::test] - async fn response_format_json_object() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - response_format: Some(ResponseFormat::Json { - schema: None, - name: None, - description: None, - }), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!( - result.request_body.unwrap()["text"]["format"]["type"], - "json_object" - ); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate — xAI-specific tools -// ════════════════════════════════════════════════════════════════════════════ - -mod tools { - use super::*; - - #[tokio::test] - async fn renamed_view_tools_pass_core_validation() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", - "object": "response", - "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [ - { - "type": "view_image_call", - "id": "image_1", - "name": "view_image", - "arguments": "{\"image_url\":\"https://example.com/image.png\"}", - "status": "completed" - }, - { - "type": "view_x_video_call", - "id": "video_1", - "name": "view_x_video", - "arguments": "{\"video_url\":\"https://x.com/i/status/1\"}", - "status": "completed" - } - ], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = generate_text( - &model, - "Inspect the media", - GenerateTextOptions { - tools: Some(vec![ - Tool::Provider(ProviderTool { - id: "xai.view_image".to_string(), - name: "inspectImage".to_string(), - args: json!({}), - }), - Tool::Provider(ProviderTool { - id: "xai.view_x_video".to_string(), - name: "inspectVideo".to_string(), - args: json!({}), - }), - ]), - ..GenerateTextOptions::default() - }, - ) - .await - .expect("renamed xAI view tools should pass Core validation"); - - assert_eq!(result.tool_calls.len(), 2); - assert_eq!(result.tool_calls[0].tool_name, "inspectImage"); - assert_eq!(result.tool_calls[1].tool_name, "inspectVideo"); - assert!( - result - .tool_calls - .iter() - .all(|call| { call.provider_executed == Some(true) && call.invalid.is_none() }) - ); - - let next_turn_prompt = aimux_core::language_model_message::convert_to_language_model_prompt( - &result.response_messages, - None, - ); - let (next_turn_input, warnings) = - aimux_providers::xai::responses::convert::convert_to_xai_responses_input( - &next_turn_prompt, - ) - .expect("Core response messages should convert for the next xAI turn"); - assert!(warnings.is_empty()); - assert!( - next_turn_input.is_empty(), - "provider-executed calls must not be replayed as client function calls" - ); - } - - /// TS: should send web_search tool with args in request - #[tokio::test] - async fn web_search_tool_request() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - tools: Some(vec![Tool::Provider(ProviderTool { - id: "xai.web_search".to_string(), - name: "web_search".to_string(), - args: json!({ - "allowedDomains": ["wikipedia.org"], - "enableImageSearch": true, - "enableImageUnderstanding": true - }), - })]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!( - body["tools"], - json!([{ - "type": "web_search", - "allowed_domains": ["wikipedia.org"], - "enable_image_search": true, - "enable_image_understanding": true - }]) - ); - } - - /// TS: should include web_search tool call - #[tokio::test] - async fn web_search_tool_call() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [{ - "type": "web_search_call", - "id": "ws_123", - "name": "web_search", - "arguments": "{\"query\":\"test\"}", - "call_id": "", - "status": "completed" - }], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - tools: Some(vec![Tool::Provider(ProviderTool { - id: "xai.web_search".to_string(), - name: "web_search".to_string(), - args: json!({}), - })]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::ToolCall { - 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() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [{ - "type": "function_call", - "id": "fc_123", - "name": "weather", - "arguments": "{\"location\":\"sf\"}", - "call_id": "call_123" - }], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - tools: Some(vec![ - FunctionTool::new( - "weather", - json!({ - "type": "object", - "properties": { "location": { "type": "string" } } - }), - ) - .into(), - ]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - match &result.content[0] { - GenerateContent::ToolCall { - 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())); - } - other => panic!("expected ToolCall, got {other:?}"), - } - } - - /// TS: should omit additionalProperties from serialized function tool schemas - #[tokio::test] - async fn omit_additional_properties() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - tools: Some(vec![ - FunctionTool::new( - "saveContact", - json!({ - "type": "object", - "properties": { - "address": { - "type": "object", - "properties": { "city": { "type": "string" } }, - "required": ["city"], - "additionalProperties": false - } - }, - "required": ["address"], - "additionalProperties": false - }), - ) - .into(), - ]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let tools = result.request_body.unwrap()["tools"] - .as_array() - .unwrap() - .clone(); - let params = &tools[0]["parameters"]; - // additionalProperties: false should be removed at all levels. - assert!(params.get("additionalProperties").is_none()); - assert!( - params["properties"]["address"] - .get("additionalProperties") - .is_none() - ); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate — citations -// ════════════════════════════════════════════════════════════════════════════ - -mod citations { - use super::*; - - /// TS: should extract citations from annotations - #[tokio::test] - async fn extract_citations() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [{ - "type": "message", - "id": "msg_123", - "status": "completed", - "role": "assistant", - "content": [{ - "type": "output_text", - "text": "based on research", - "annotations": [ - { "type": "url_citation", "url": "https://example.com", "title": "example title" }, - { "type": "url_citation", "url": "https://test.com" } - ] - }] - }], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert_eq!(result.content.len(), 3); - // Text - match &result.content[0] { - GenerateContent::Text { text, .. } => assert_eq!(text, "based on research"), - other => panic!("expected Text, got {other:?}"), - } - // Source 1 - match &result.content[1] { - GenerateContent::Source { url, title, .. } => { - assert_eq!(url.as_deref(), Some("https://example.com")); - assert_eq!(title.as_deref(), Some("example title")); - } - other => panic!("expected Source, got {other:?}"), - } - // Source 2 (title falls back to url) - match &result.content[2] { - GenerateContent::Source { url, title, .. } => { - assert_eq!(url.as_deref(), Some("https://test.com")); - assert_eq!(title.as_deref(), Some("https://test.com")); - } - other => panic!("expected Source, got {other:?}"), - } - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// doStream — text streaming -// ════════════════════════════════════════════════════════════════════════════ - -mod do_stream { - use super::*; - - /// TS: should stream text deltas - #[tokio::test] - async fn stream_text_deltas() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body(&[ - &sse_event(&json!({ - "type": "response.created", - "response": { "id": "resp_123", "object": "response", "model": "grok-4-fast-non-reasoning", "output": [] } - }).to_string()), - &sse_event(&json!({ - "type": "response.output_text.delta", - "item_id": "msg_123", - "output_index": 0, - "content_index": 0, - "delta": "Hello" - }).to_string()), - &sse_event(&json!({ - "type": "response.output_text.delta", - "item_id": "msg_123", - "output_index": 0, - "content_index": 0, - "delta": " world" - }).to_string()), - &sse_event(&json!({ - "type": "response.completed", - "response": { - "id": "resp_123", "object": "response", "model": "grok-4-fast-non-reasoning", - "status": "completed", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - } - }).to_string()), - ])), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(); - let parts = collect_stream(result).await; - - let text_deltas: Vec<&str> = parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.as_str()), - _ => None, - }) - .collect(); - assert_eq!(text_deltas, vec!["Hello", " world"]); - - // Should have StreamStart, ResponseMetadata, TextStart, TextDelta x2, TextEnd, Finish - let has_start = parts - .iter() - .any(|p| matches!(p, StreamPart::StreamStart { .. })); - let has_finish = parts.iter().any(|p| matches!(p, StreamPart::Finish { .. })); - assert!(has_start); - assert!(has_finish); - } - - /// TS: should not emit duplicate text-delta from response.output_item.done after streaming - #[tokio::test] - async fn no_duplicate_text_delta() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body(&[ - &sse_event(&json!({ - "type": "response.created", - "response": { "id": "resp_123", "object": "response", "model": "grok-4-fast-non-reasoning", "status": "in_progress", "output": [] } - }).to_string()), - &sse_event(&json!({ - "type": "response.output_item.added", - "item": { "type": "message", "id": "msg_123", "status": "in_progress", "role": "assistant", "content": [] }, - "output_index": 0 - }).to_string()), - &sse_event(&json!({ "type": "response.output_text.delta", "item_id": "msg_123", "output_index": 0, "content_index": 0, "delta": "Hello" }).to_string()), - &sse_event(&json!({ "type": "response.output_text.delta", "item_id": "msg_123", "output_index": 0, "content_index": 0, "delta": " " }).to_string()), - &sse_event(&json!({ "type": "response.output_text.delta", "item_id": "msg_123", "output_index": 0, "content_index": 0, "delta": "world" }).to_string()), - &sse_event(&json!({ - "type": "response.output_item.done", - "item": { "type": "message", "id": "msg_123", "status": "completed", "role": "assistant", "content": [{ "type": "output_text", "text": "Hello world", "annotations": [] }] }, - "output_index": 0 - }).to_string()), - &sse_event(&json!({ - "type": "response.done", - "response": { "id": "resp_123", "object": "response", "status": "completed", "output": [], "usage": { "input_tokens": 10, "output_tokens": 5 } } - }).to_string()), - ])), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(); - let parts = collect_stream(result).await; - - let text_deltas: Vec<&str> = parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.as_str()), - _ => None, - }) - .collect(); - // Should only have 3 text-deltas, NOT 4 (with duplicate full text) - assert_eq!(text_deltas, vec!["Hello", " ", "world"]); - } - - /// TS: should stream reasoning text deltas (response.reasoning_text.delta) - #[tokio::test] - async fn stream_reasoning_text_deltas() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body(&[ - &sse_event(&json!({ "type": "response.created", "response": { "id": "resp_123", "object": "response", "model": "grok-code-fast-1", "output": [] } }).to_string()), - &sse_event(&json!({ "type": "response.output_item.added", "item": { "type": "reasoning", "id": "rs_456", "status": "in_progress", "summary": [] }, "output_index": 0 }).to_string()), - &sse_event(&json!({ "type": "response.reasoning_text.delta", "item_id": "rs_456", "output_index": 0, "content_index": 0, "delta": "First" }).to_string()), - &sse_event(&json!({ "type": "response.reasoning_text.delta", "item_id": "rs_456", "output_index": 0, "content_index": 0, "delta": ", analyze the question." }).to_string()), - &sse_event(&json!({ "type": "response.reasoning_text.done", "item_id": "rs_456", "output_index": 0, "content_index": 0, "text": "First, analyze the question." }).to_string()), - &sse_event(&json!({ "type": "response.output_item.done", "item": { "type": "reasoning", "id": "rs_456", "status": "completed", "summary": [{ "type": "summary_text", "text": "First, analyze the question." }] }, "output_index": 0 }).to_string()), - &sse_event(&json!({ "type": "response.output_item.added", "item": { "type": "message", "id": "msg_789", "role": "assistant", "status": "in_progress", "content": [] }, "output_index": 1 }).to_string()), - &sse_event(&json!({ "type": "response.output_text.delta", "item_id": "msg_789", "output_index": 1, "content_index": 0, "delta": "The answer." }).to_string()), - &sse_event(&json!({ "type": "response.done", "response": { "id": "resp_123", "object": "response", "model": "grok-code-fast-1", "status": "completed", "output": [], "usage": { "input_tokens": 10, "output_tokens": 20, "output_tokens_details": { "reasoning_tokens": 15 } } } }).to_string()), - ])), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-code-fast-1"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(); - let parts = collect_stream(result).await; - - // Verify reasoning start/delta/end ordering. - let start_idx = parts - .iter() - .position(|p| matches!(p, StreamPart::ReasoningStart { .. })); - let first_delta_idx = parts - .iter() - .position(|p| matches!(p, StreamPart::ReasoningDelta { .. })); - let end_idx = parts - .iter() - .position(|p| matches!(p, StreamPart::ReasoningEnd { .. })); - let text_idx = parts - .iter() - .position(|p| matches!(p, StreamPart::TextDelta { .. })); - - assert!(start_idx.is_some()); - assert!(first_delta_idx.is_some()); - assert!(end_idx.is_some()); - assert!(text_idx.is_some()); - assert!(start_idx.unwrap() < first_delta_idx.unwrap()); - assert!(first_delta_idx.unwrap() < end_idx.unwrap()); - assert!(end_idx.unwrap() < text_idx.unwrap()); - - // Verify reasoning deltas. - let reasoning_deltas: Vec<&str> = parts - .iter() - .filter_map(|p| match p { - StreamPart::ReasoningDelta { delta, .. } => Some(delta.as_str()), - _ => None, - }) - .collect(); - assert_eq!(reasoning_deltas, vec!["First", ", analyze the question."]); - } - - /// TS: should include encrypted content in reasoning-end providerMetadata - #[tokio::test] - async fn reasoning_end_encrypted_content() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body(&[ - &sse_event(&json!({ "type": "response.created", "response": { "id": "resp_123", "object": "response", "model": "grok-4-fast-non-reasoning", "output": [] } }).to_string()), - &sse_event(&json!({ "type": "response.output_item.added", "item": { "type": "reasoning", "id": "rs_456", "status": "in_progress", "summary": [] }, "output_index": 0 }).to_string()), - &sse_event(&json!({ "type": "response.reasoning_summary_part.added", "item_id": "rs_456", "output_index": 0, "summary_index": 0, "part": { "type": "summary_text", "text": "" } }).to_string()), - &sse_event(&json!({ "type": "response.reasoning_summary_text.delta", "item_id": "rs_456", "output_index": 0, "summary_index": 0, "delta": "Analyzing..." }).to_string()), - &sse_event(&json!({ "type": "response.reasoning_summary_text.done", "item_id": "rs_456", "output_index": 0, "summary_index": 0, "text": "Analyzing..." }).to_string()), - &sse_event(&json!({ "type": "response.output_item.done", "item": { "type": "reasoning", "id": "rs_456", "status": "completed", "summary": [{ "type": "summary_text", "text": "Analyzing..." }], "encrypted_content": "encrypted_data_abc123" }, "output_index": 0 }).to_string()), - &sse_event(&json!({ "type": "response.output_item.added", "item": { "type": "message", "id": "msg_789", "role": "assistant", "status": "in_progress", "content": [] }, "output_index": 1 }).to_string()), - &sse_event(&json!({ "type": "response.output_text.delta", "item_id": "msg_789", "output_index": 1, "content_index": 0, "delta": "Result." }).to_string()), - &sse_event(&json!({ "type": "response.done", "response": { "id": "resp_123", "object": "response", "model": "grok-4-fast-non-reasoning", "status": "completed", "output": [], "usage": { "input_tokens": 10, "output_tokens": 20 } } }).to_string()), - ])), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(); - let parts = collect_stream(result).await; - - let reasoning_end = parts - .iter() - .find(|p| matches!(p, StreamPart::ReasoningEnd { .. })); - assert!(reasoning_end.is_some()); - if let Some(StreamPart::ReasoningEnd { - id, - provider_metadata, - }) = reasoning_end - { - assert_eq!(id, "reasoning-rs_456"); - assert_eq!( - provider_metadata, - &Some( - json!({ "xai": { "itemId": "rs_456", "reasoningEncryptedContent": "encrypted_data_abc123" } }) - ) - ); - } - } - - /// TS: should stream web_search tool calls - #[tokio::test] - async fn stream_web_search_tool() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body(&[ - &sse_event(&json!({ "type": "response.created", "response": { "id": "resp_123", "object": "response", "model": "grok-4-fast-non-reasoning", "status": "in_progress", "output": [] } }).to_string()), - &sse_event(&json!({ "type": "response.output_item.added", "item": { "type": "web_search_call", "id": "ws_123", "name": "web_search", "arguments": "{\"query\":\"test\"}", "call_id": "", "status": "completed" }, "output_index": 0 }).to_string()), - &sse_event(&json!({ "type": "response.done", "response": { "id": "resp_123", "object": "response", "status": "completed", "output": [], "usage": { "input_tokens": 10, "output_tokens": 5 } } }).to_string()), - ])), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - tools: Some(vec![Tool::Provider(ProviderTool { - id: "xai.web_search".to_string(), - name: "mySearch".to_string(), - args: json!({}), - })]), - ..default_options(test_prompt()) - }; - let result = model.do_stream(&options).await.unwrap(); - let parts = collect_stream(result).await; - - let tool_call = parts - .iter() - .find(|p| matches!(p, StreamPart::ToolCall { .. })); - assert!(tool_call.is_some()); - if let Some(StreamPart::ToolCall { - tool_call_id, - tool_name, - input, - .. - }) = tool_call - { - assert_eq!(tool_call_id, "ws_123"); - assert_eq!(tool_name, "mySearch"); - assert_eq!(input, "{\"query\":\"test\"}"); - } - - assert!(parts.iter().any(|part| matches!( - part, - StreamPart::ToolInputStart { - tool_name, - provider_executed: Some(true), - .. - } if tool_name == "mySearch" - ))); - - let result = stream_text( - &model, - "Search", - GenerateTextOptions { - tools: options.tools.clone(), - ..GenerateTextOptions::default() - }, - ) - .await - .expect("stream_text should start") - .consume() - .await - .expect("renamed provider tool should pass Core validation"); - let call = result.tool_calls.first().expect("web search call"); - assert_eq!(call.tool_name, "mySearch"); - assert_eq!(call.provider_executed, Some(true)); - assert_eq!(call.invalid, None); - } - - /// TS: should stream function tool call arguments - #[tokio::test] - async fn stream_function_tool_arguments() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body(&[ - &sse_event(&json!({ "type": "response.created", "response": { "id": "resp_123", "object": "response", "model": "grok-4-fast-non-reasoning", "status": "in_progress", "output": [] } }).to_string()), - &sse_event(&json!({ "type": "response.output_item.added", "output_index": 0, "item": { "type": "function_call", "id": "fc_123", "call_id": "call_123", "name": "weather", "arguments": "", "status": "in_progress" } }).to_string()), - &sse_event(&json!({ "type": "response.function_call_arguments.delta", "item_id": "fc_123", "output_index": 0, "delta": "{\"location\"" }).to_string()), - &sse_event(&json!({ "type": "response.function_call_arguments.delta", "item_id": "fc_123", "output_index": 0, "delta": ":\"sf\"}" }).to_string()), - &sse_event(&json!({ "type": "response.function_call_arguments.done", "item_id": "fc_123", "output_index": 0, "arguments": "{\"location\":\"sf\"}" }).to_string()), - &sse_event(&json!({ "type": "response.output_item.done", "output_index": 0, "item": { "type": "function_call", "id": "fc_123", "call_id": "call_123", "name": "weather", "arguments": "{\"location\":\"sf\"}", "status": "completed" } }).to_string()), - &sse_event(&json!({ "type": "response.done", "response": { "id": "resp_123", "object": "response", "status": "completed", "output": [], "usage": { "input_tokens": 10, "output_tokens": 5 } } }).to_string()), - ])), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - tools: Some(vec![ - FunctionTool::new( - "weather", - json!({ - "type": "object", - "properties": { "location": { "type": "string" } } - }), - ) - .into(), - ]), - ..default_options(test_prompt()) - }; - let result = model.do_stream(&options).await.unwrap(); - let parts = collect_stream(result).await; - - // Should have tool-input-start, tool-input-delta x2, tool-input-end, tool-call - let has_start = parts.iter().any(|p| matches!(p, StreamPart::ToolInputStart { id, tool_name, .. } if id == "call_123" && tool_name == "weather")); - assert!(has_start); - - let deltas: Vec<&str> = parts - .iter() - .filter_map(|p| match p { - StreamPart::ToolInputDelta { id, delta, .. } if id == "call_123" => { - Some(delta.as_str()) - } - _ => None, - }) - .collect(); - assert_eq!(deltas, vec!["{\"location\"", ":\"sf\"}"]); - - let has_end = parts - .iter() - .any(|p| matches!(p, StreamPart::ToolInputEnd { id, .. } if id == "call_123")); - assert!(has_end); - - let tool_call = parts - .iter() - .find(|p| matches!(p, StreamPart::ToolCall { .. })); - assert!(tool_call.is_some()); - if let Some(StreamPart::ToolCall { - tool_call_id, - tool_name, - input, - .. - }) = tool_call - { - assert_eq!(tool_call_id, "call_123"); - assert_eq!(tool_name, "weather"); - assert_eq!(input, &Value::String(r#"{"location":"sf"}"#.into())); - } - - // Finish reason should be tool-calls. - let finish = parts - .iter() - .find(|p| matches!(p, StreamPart::Finish { .. })); - if let Some(StreamPart::Finish { finish_reason, .. }) = finish { - assert_eq!( - finish_reason.unified, - aimux_core::types::FinishReasonUnified::ToolCalls - ); - } - } - - /// TS: should expose cost_in_usd_ticks in finish providerMetadata - #[tokio::test] - async fn stream_cost_in_usd_ticks() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body(&[ - &sse_event(&json!({ "type": "response.created", "response": { "id": "resp_123", "object": "response", "model": "grok-4-fast-non-reasoning", "output": [] } }).to_string()), - &sse_event(&json!({ "type": "response.output_text.delta", "output_index": 0, "content_index": 0, "delta": "Hello" }).to_string()), - &sse_event(&json!({ - "type": "response.completed", - "response": { - "id": "resp_123", "object": "response", "model": "grok-4-fast-non-reasoning", - "status": "completed", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5, "cost_in_usd_ticks": 113500 } - } - }).to_string()), - ])), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(); - let parts = collect_stream(result).await; - - let finish = parts - .iter() - .find(|p| matches!(p, StreamPart::Finish { .. })); - if let Some(StreamPart::Finish { - provider_metadata, .. - }) = finish - { - assert_eq!( - provider_metadata, - &Some(json!({ "xai": { "costInUsdTicks": 113500 } })) - ); - } else { - panic!("no finish part found"); - } - } - - /// TS: should stream citations as sources - #[tokio::test] - async fn stream_citations() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body(&[ - &sse_event(&json!({ "type": "response.created", "response": { "id": "resp_123", "object": "response", "model": "grok-4-fast-non-reasoning", "status": "in_progress", "output": [] } }).to_string()), - &sse_event(&json!({ - "type": "response.output_text.annotation.added", - "item_id": "msg_123", "output_index": 0, "content_index": 0, "annotation_index": 0, - "annotation": { "type": "url_citation", "url": "https://example.com", "title": "example" } - }).to_string()), - &sse_event(&json!({ "type": "response.done", "response": { "id": "resp_123", "object": "response", "status": "completed", "output": [], "usage": { "input_tokens": 10, "output_tokens": 5 } } }).to_string()), - ])), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(); - let parts = collect_stream(result).await; - - let source = parts - .iter() - .find(|p| matches!(p, StreamPart::Source { .. })); - assert!(source.is_some()); - if let Some(StreamPart::Source { url, title, .. }) = source { - assert_eq!(url.as_deref(), Some("https://example.com")); - assert_eq!(title.as_deref(), Some("example")); - } - } - - /// TS: should set finish reason to length for max_output_tokens (incomplete) - #[tokio::test] - async fn incomplete_max_output_tokens() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body(&[ - &sse_event(&json!({ "type": "response.created", "response": { "id": "resp_123", "object": "response", "model": "grok-4-fast-non-reasoning", "output": [] } }).to_string()), - &sse_event(&json!({ "type": "response.output_text.delta", "item_id": "msg_456", "output_index": 0, "content_index": 0, "delta": "partial output" }).to_string()), - &sse_event(&json!({ - "type": "response.incomplete", - "response": { "incomplete_details": { "reason": "max_output_tokens" }, "usage": { "input_tokens": 100, "output_tokens": 4096 } } - }).to_string()), - ])), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(); - let parts = collect_stream(result).await; - - let finish = parts - .iter() - .find(|p| matches!(p, StreamPart::Finish { .. })); - if let Some(StreamPart::Finish { - finish_reason, - usage, - .. - }) = finish - { - assert_eq!( - finish_reason.unified, - aimux_core::types::FinishReasonUnified::Length - ); - assert_eq!(finish_reason.raw.as_deref(), Some("max_output_tokens")); - assert_eq!(usage.input_tokens.total, Some(100)); - assert_eq!(usage.output_tokens.total, Some(4096)); - } else { - panic!("no finish part found"); - } - } - - /// TS: should set finish reason to error on response.failed - #[tokio::test] - async fn failed_response() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body(&[ - &sse_event(&json!({ "type": "response.created", "response": { "id": "resp_123", "object": "response", "model": "grok-4-fast-non-reasoning", "output": [] } }).to_string()), - &sse_event(&json!({ - "type": "response.failed", - "response": { "error": { "code": "server_error", "message": "Internal server error" }, "usage": { "input_tokens": 50, "output_tokens": 0 } } - }).to_string()), - ])), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(); - let parts = collect_stream(result).await; - - let finish = parts - .iter() - .find(|p| matches!(p, StreamPart::Finish { .. })); - if let Some(StreamPart::Finish { finish_reason, .. }) = finish { - assert_eq!( - finish_reason.unified, - aimux_core::types::FinishReasonUnified::Error - ); - assert_eq!(finish_reason.raw.as_deref(), Some("error")); - } else { - panic!("no finish part found"); - } - } - - /// TS: should handle missing usage in streaming response - #[tokio::test] - async fn missing_usage() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body(&[ - &sse_event(&json!({ "type": "response.created", "response": { "id": "resp_123", "object": "response", "model": "grok-4-fast-non-reasoning", "status": "in_progress", "output": [] } }).to_string()), - &sse_event(&json!({ "type": "response.output_text.delta", "output_index": 0, "content_index": 0, "delta": "Hello" }).to_string()), - &sse_event(&json!({ - "type": "response.completed", - "response": { "id": "resp_123", "object": "response", "model": "grok-4-fast-non-reasoning", "status": "completed", "output": [] } - }).to_string()), - ])), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(); - let parts = collect_stream(result).await; - - let finish = parts - .iter() - .find(|p| matches!(p, StreamPart::Finish { .. })); - if let Some(StreamPart::Finish { - finish_reason, - usage, - .. - }) = finish - { - assert_eq!( - finish_reason.unified, - aimux_core::types::FinishReasonUnified::Stop - ); - assert_eq!(finish_reason.raw.as_deref(), Some("completed")); - assert_eq!(usage.input_tokens.total, Some(0)); - assert_eq!(usage.output_tokens.total, Some(0)); - } else { - panic!("no finish part found"); - } - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// convertXaiResponsesUsage — direct function tests -// (convert-xai-responses-usage.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod convert_usage { - use aimux_providers::xai::responses::convert::convert_xai_responses_usage; - use aimux_providers::xai::responses::types::XaiResponsesUsage; - - fn make_usage(input: u64, output: u64) -> XaiResponsesUsage { - XaiResponsesUsage { - input_tokens: input, - output_tokens: output, - ..Default::default() - } - } - - /// TS: should convert basic usage without caching or reasoning - #[test] - fn basic_usage() { - let result = convert_xai_responses_usage(&make_usage(100, 50)); - assert_eq!(result.input_tokens.total, Some(100)); - assert_eq!(result.input_tokens.no_cache, Some(100)); - assert_eq!(result.input_tokens.cache_read, Some(0)); - assert_eq!(result.output_tokens.total, Some(50)); - assert_eq!(result.output_tokens.text, Some(50)); - assert_eq!(result.output_tokens.reasoning, Some(0)); - } - - /// TS: should convert usage with reasoning tokens - #[test] - fn usage_with_reasoning() { - let mut usage = make_usage(1941, 583); - usage.output_tokens_details = Some( - aimux_providers::xai::responses::types::XaiResponsesOutputTokensDetails { - reasoning_tokens: Some(380), - }, - ); - let result = convert_xai_responses_usage(&usage); - assert_eq!(result.output_tokens.total, Some(583)); - assert_eq!(result.output_tokens.text, Some(203)); // 583 - 380 - assert_eq!(result.output_tokens.reasoning, Some(380)); - } - - /// TS: should convert usage with cached input tokens - #[test] - fn usage_with_cached_tokens() { - let mut usage = make_usage(200, 50); - usage.input_tokens_details = Some( - aimux_providers::xai::responses::types::XaiResponsesInputTokensDetails { - cached_tokens: Some(150), - }, - ); - let result = convert_xai_responses_usage(&usage); - assert_eq!(result.input_tokens.cache_read, Some(150)); - assert_eq!(result.input_tokens.no_cache, Some(50)); // 200 - 150 - assert_eq!(result.input_tokens.total, Some(200)); - } - - /// TS: should handle cached_tokens exceeding input_tokens (non-inclusive reporting) - #[test] - fn cached_exceeds_input() { - let mut usage = make_usage(4142, 254); - usage.input_tokens_details = Some( - aimux_providers::xai::responses::types::XaiResponsesInputTokensDetails { - cached_tokens: Some(4328), - }, - ); - let result = convert_xai_responses_usage(&usage); - assert_eq!(result.input_tokens.cache_read, Some(4328)); - assert_eq!(result.input_tokens.no_cache, Some(4142)); - assert_eq!(result.input_tokens.total, Some(8470)); // 4142 + 4328 - } - - /// TS: should convert usage with both cached input and reasoning - #[test] - fn both_cached_and_reasoning() { - let mut usage = make_usage(200, 583); - usage.input_tokens_details = Some( - aimux_providers::xai::responses::types::XaiResponsesInputTokensDetails { - cached_tokens: Some(150), - }, - ); - usage.output_tokens_details = Some( - aimux_providers::xai::responses::types::XaiResponsesOutputTokensDetails { - reasoning_tokens: Some(380), - }, - ); - let result = convert_xai_responses_usage(&usage); - assert_eq!(result.input_tokens.cache_read, Some(150)); - assert_eq!(result.input_tokens.no_cache, Some(50)); - assert_eq!(result.input_tokens.total, Some(200)); - assert_eq!(result.output_tokens.reasoning, Some(380)); - assert_eq!(result.output_tokens.text, Some(203)); - assert_eq!(result.output_tokens.total, Some(583)); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// prepareResponsesTools — direct function tests -// (xai-responses-prepare-tools.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod prepare_tools { - use super::*; - use aimux_providers::xai::responses::convert::prepare_responses_tools; - - /// TS: should return undefined tools when tools are undefined - #[test] - fn no_tools() { - let result = prepare_responses_tools(&None, None); - assert!(result.tools.is_none()); - assert!(result.tool_choice.is_none()); - } - - /// TS: should return undefined tools when tools are empty - #[test] - fn empty_tools() { - let result = prepare_responses_tools(&Some(vec![]), None); - assert!(result.tools.is_none()); - } - - /// TS: should prepare web_search tool with no args - #[test] - fn web_search_no_args() { - let result = prepare_responses_tools( - &Some(vec![Tool::Provider(ProviderTool { - id: "xai.web_search".to_string(), - name: "web_search".to_string(), - args: json!({}), - })]), - None, - ); - let tools = result.tools.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0]["type"], "web_search"); - } - - /// TS: should prepare web_search tool with allowed domains - #[test] - fn web_search_with_domains() { - let result = prepare_responses_tools( - &Some(vec![Tool::Provider(ProviderTool { - id: "xai.web_search".to_string(), - name: "web_search".to_string(), - args: json!({ "allowedDomains": ["wikipedia.org", "example.com"] }), - })]), - None, - ); - let tools = result.tools.unwrap(); - assert_eq!( - tools[0]["allowed_domains"], - json!(["wikipedia.org", "example.com"]) - ); - } - - /// TS: should prepare x_search tool with no args - #[test] - fn x_search_no_args() { - let result = prepare_responses_tools( - &Some(vec![Tool::Provider(ProviderTool { - id: "xai.x_search".to_string(), - name: "x_search".to_string(), - args: json!({}), - })]), - None, - ); - let tools = result.tools.unwrap(); - assert_eq!(tools[0]["type"], "x_search"); - } - - /// TS: should prepare code_execution tool as code_interpreter - #[test] - fn code_execution() { - let result = prepare_responses_tools( - &Some(vec![Tool::Provider(ProviderTool { - id: "xai.code_execution".to_string(), - name: "code_execution".to_string(), - args: json!({}), - })]), - None, - ); - let tools = result.tools.unwrap(); - assert_eq!(tools[0]["type"], "code_interpreter"); - } - - /// TS: should prepare view_image tool - #[test] - fn view_image() { - let result = prepare_responses_tools( - &Some(vec![Tool::Provider(ProviderTool { - id: "xai.view_image".to_string(), - name: "view_image".to_string(), - args: json!({}), - })]), - None, - ); - let tools = result.tools.unwrap(); - assert_eq!(tools[0]["type"], "view_image"); - } - - /// TS: should prepare view_x_video tool - #[test] - fn view_x_video() { - let result = prepare_responses_tools( - &Some(vec![Tool::Provider(ProviderTool { - id: "xai.view_x_video".to_string(), - name: "view_x_video".to_string(), - args: json!({}), - })]), - None, - ); - let tools = result.tools.unwrap(); - assert_eq!(tools[0]["type"], "view_x_video"); - } - - /// TS: should prepare file_search tool with vector store IDs - #[test] - fn file_search() { - let result = prepare_responses_tools( - &Some(vec![Tool::Provider(ProviderTool { - id: "xai.file_search".to_string(), - name: "file_search".to_string(), - args: json!({ "vectorStoreIds": ["collection_1", "collection_2"] }), - })]), - None, - ); - let tools = result.tools.unwrap(); - assert_eq!(tools[0]["type"], "file_search"); - assert_eq!( - tools[0]["vector_store_ids"], - json!(["collection_1", "collection_2"]) - ); - } - - /// TS: should prepare file_search tool with max num results - #[test] - fn file_search_max_results() { - let result = prepare_responses_tools( - &Some(vec![Tool::Provider(ProviderTool { - id: "xai.file_search".to_string(), - name: "file_search".to_string(), - args: json!({ "vectorStoreIds": ["collection_1"], "maxNumResults": 10 }), - })]), - None, - ); - let tools = result.tools.unwrap(); - assert_eq!(tools[0]["max_num_results"], 10); - } - - /// TS: should prepare mcp tool with required args only - #[test] - fn mcp_required_args() { - let result = prepare_responses_tools( - &Some(vec![Tool::Provider(ProviderTool { - id: "xai.mcp".to_string(), - name: "mcp".to_string(), - args: json!({ "serverUrl": "https://example.com/mcp", "serverLabel": "test-server" }), - })]), - None, - ); - let tools = result.tools.unwrap(); - assert_eq!(tools[0]["type"], "mcp"); - assert_eq!(tools[0]["server_url"], "https://example.com/mcp"); - assert_eq!(tools[0]["server_label"], "test-server"); - } - - /// TS: should prepare mcp tool with all optional args - #[test] - fn mcp_all_args() { - let result = prepare_responses_tools( - &Some(vec![Tool::Provider(ProviderTool { - id: "xai.mcp".to_string(), - name: "mcp".to_string(), - args: json!({ - "serverUrl": "https://example.com/mcp", - "serverLabel": "test-server", - "serverDescription": "A test MCP server", - "allowedTools": ["tool1", "tool2"], - "headers": { "X-Custom": "value" }, - "authorization": "Bearer token123" - }), - })]), - None, - ); - let tools = result.tools.unwrap(); - assert_eq!(tools[0]["server_description"], "A test MCP server"); - assert_eq!(tools[0]["allowed_tools"], json!(["tool1", "tool2"])); - assert_eq!(tools[0]["headers"]["X-Custom"], "value"); - assert_eq!(tools[0]["authorization"], "Bearer token123"); - } - - /// TS: should prepare function tools - #[test] - fn function_tools() { - let result = prepare_responses_tools( - &Some(vec![ - FunctionTool::new( - "weather", - json!({ - "type": "object", - "properties": { "location": { "type": "string" } }, - "required": ["location"] - }), - ) - .with_description("get weather information") - .into(), - ]), - None, - ); - let tools = result.tools.unwrap(); - assert_eq!(tools[0]["type"], "function"); - assert_eq!(tools[0]["name"], "weather"); - assert_eq!(tools[0]["description"], "get weather information"); - assert_eq!( - tools[0]["parameters"]["properties"]["location"]["type"], - "string" - ); - } - - /// TS: should pass through strict mode when strict is true - #[test] - fn strict_true() { - let result = prepare_responses_tools( - &Some(vec![ - FunctionTool::new( - "testFunction", - json!({ - "type": "object", "properties": {} - }), - ) - .with_description("A test function") - .with_strict(true) - .into(), - ]), - None, - ); - let tools = result.tools.unwrap(); - assert_eq!(tools[0]["strict"], true); - } - - /// TS: should not include strict when strict is undefined - #[test] - fn strict_undefined() { - let result = prepare_responses_tools( - &Some(vec![ - FunctionTool::new( - "testFunction", - json!({ - "type": "object", "properties": {} - }), - ) - .with_description("A test function") - .into(), - ]), - None, - ); - let tools = result.tools.unwrap(); - assert!(tools[0].get("strict").is_none()); - } - - /// TS: should handle tool choice auto - #[test] - fn tool_choice_auto() { - let result = prepare_responses_tools( - &Some(vec![Tool::Provider(ProviderTool { - id: "xai.web_search".to_string(), - name: "web_search".to_string(), - args: json!({}), - })]), - Some(&ToolChoice::Auto), - ); - assert_eq!(result.tool_choice, Some(json!("auto"))); - } - - /// TS: should handle tool choice required - #[test] - fn tool_choice_required() { - let result = prepare_responses_tools( - &Some(vec![Tool::Provider(ProviderTool { - id: "xai.web_search".to_string(), - name: "web_search".to_string(), - args: json!({}), - })]), - Some(&ToolChoice::Required), - ); - assert_eq!(result.tool_choice, Some(json!("required"))); - } - - /// TS: should handle tool choice none - #[test] - fn tool_choice_none() { - let result = prepare_responses_tools( - &Some(vec![Tool::Provider(ProviderTool { - id: "xai.web_search".to_string(), - name: "web_search".to_string(), - args: json!({}), - })]), - Some(&ToolChoice::None), - ); - assert_eq!(result.tool_choice, Some(json!("none"))); - } - - /// TS: should handle specific tool choice (function) - #[test] - fn tool_choice_specific_function() { - let result = prepare_responses_tools( - &Some(vec![ - FunctionTool::new( - "weather", - json!({ - "type": "object", "properties": {} - }), - ) - .into(), - ]), - Some(&ToolChoice::Tool { - tool_name: "weather".to_string(), - }), - ); - assert_eq!( - result.tool_choice, - Some(json!({ "type": "function", "name": "weather" })) - ); - } - - /// TS: should warn when trying to force server-side tool via toolChoice - #[test] - fn tool_choice_server_side_warning() { - let result = prepare_responses_tools( - &Some(vec![Tool::Provider(ProviderTool { - id: "xai.web_search".to_string(), - name: "web_search".to_string(), - args: json!({}), - })]), - Some(&ToolChoice::Tool { - tool_name: "web_search".to_string(), - }), - ); - assert!(result.tool_choice.is_none()); - assert!(result.tool_warnings.iter().any(|w| match w { - aimux_core::types::Warning::Unsupported { feature, .. } => { - feature == "toolChoice for server-side tool \"web_search\"" - } - _ => false, - })); - } - - /// TS: should warn about unsupported provider-defined tools - #[test] - fn unsupported_provider_tool() { - let result = prepare_responses_tools( - &Some(vec![Tool::Provider(ProviderTool { - id: "unsupported.tool".to_string(), - name: "unsupported".to_string(), - args: json!({}), - })]), - None, - ); - assert!(result.tools.is_none()); - assert!(result.tool_warnings.iter().any(|w| match w { - aimux_core::types::Warning::Unsupported { feature, .. } => { - feature == "provider-defined tool unsupported" - } - _ => false, - })); - } - - /// TS: should handle multiple tools including provider-defined and functions - #[test] - fn multiple_tools() { - let result = prepare_responses_tools( - &Some(vec![ - FunctionTool::new("calculator", json!({ "type": "object", "properties": {} })) - .into(), - Tool::Provider(ProviderTool { - id: "xai.web_search".to_string(), - name: "web_search".to_string(), - args: json!({}), - }), - Tool::Provider(ProviderTool { - id: "xai.x_search".to_string(), - name: "x_search".to_string(), - args: json!({}), - }), - ]), - None, - ); - let tools = result.tools.unwrap(); - assert_eq!(tools.len(), 3); - assert_eq!(tools[0]["type"], "function"); - assert_eq!(tools[1]["type"], "web_search"); - assert_eq!(tools[2]["type"], "x_search"); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// convertToXaiResponsesInput — direct function tests -// (convert-to-xai-responses-input.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod convert_input { - use super::*; - use aimux_providers::xai::responses::convert::convert_to_xai_responses_input; - - /// TS: should convert system messages - #[test] - fn system_message() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("you are a helpful assistant")], - ..Default::default() - }]; - let (input, warnings) = convert_to_xai_responses_input(&prompt).unwrap(); - assert_eq!(warnings.len(), 0); - assert_eq!( - input, - vec![json!({ "role": "system", "content": "you are a helpful assistant" })] - ); - } - - /// TS: should convert single text part - #[test] - fn user_text() { - let (input, warnings) = convert_to_xai_responses_input(&test_prompt()).unwrap(); - assert_eq!(warnings.len(), 0); - assert_eq!( - input, - vec![json!({ - "role": "user", - "content": [{ "type": "input_text", "text": "hello" }] - })] - ); - } - - /// TS: should convert image file parts with URL - #[test] - fn user_image_url() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("what is in this image"), - ContentPart::file_url("https://example.com/image.jpg", "image/jpeg"), - ], - ..Default::default() - }]; - let (input, _) = convert_to_xai_responses_input(&prompt).unwrap(); - assert_eq!( - input[0]["content"][1], - json!({ "type": "input_image", "image_url": "https://example.com/image.jpg" }) - ); - } - - /// TS: should convert non-image file parts with URL to input_file with file_url - #[test] - fn user_pdf_url() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("summarize this PDF"), - ContentPart::file_url("https://example.com/document.pdf", "application/pdf"), - ], - ..Default::default() - }]; - let (input, _) = convert_to_xai_responses_input(&prompt).unwrap(); - assert_eq!( - input[0]["content"][1], - json!({ "type": "input_file", "file_url": "https://example.com/document.pdf" }) - ); - } - - /// TS: should convert assistant text - #[test] - fn assistant_text() { - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("hi")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text("hello back")], - ..Default::default() - }, - ]; - let (input, _) = convert_to_xai_responses_input(&prompt).unwrap(); - assert_eq!( - input[1], - json!({ "role": "assistant", "content": "hello back" }) - ); - } - - /// AI SDK: provider-executed calls are not replayed as client function calls. - #[test] - fn skips_provider_executed_tool_call_without_provider_options() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::ToolCall { - tool_call_id: "server_call_123".to_string(), - tool_name: "web_search".to_string(), - input: json!({ "query": "weather" }), - provider_executed: Some(true), - thought_signature: None, - provider_options: None, - }], - ..Default::default() - }]; - - let (input, warnings) = convert_to_xai_responses_input(&prompt).unwrap(); - assert!(warnings.is_empty()); - assert!(input.is_empty()); - } - - /// An explicit false value must override stale legacy provider metadata. - #[test] - fn explicit_client_execution_overrides_legacy_provider_metadata() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::ToolCall { - tool_call_id: "call_123".to_string(), - tool_name: "weather".to_string(), - input: json!({ "city": "Singapore" }), - provider_executed: Some(false), - thought_signature: None, - provider_options: Some(json!({ - "xai": { "providerExecuted": true } - })), - }], - ..Default::default() - }]; - - let (input, warnings) = convert_to_xai_responses_input(&prompt).unwrap(); - assert!(warnings.is_empty()); - assert_eq!(input.len(), 1); - assert_eq!(input[0]["type"], "function_call"); - assert_eq!(input[0]["call_id"], "call_123"); - } - - /// TS: should convert tool results - #[test] - fn tool_result() { - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("weather?")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call("call_123", "weather", json!({}))], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result("call_123", json!("sunny"))], - ..Default::default() - }, - ]; - let (input, _) = convert_to_xai_responses_input(&prompt).unwrap(); - // Should have: user message, function_call, function_call_output - assert_eq!(input.len(), 3); - assert_eq!(input[1]["type"], "function_call"); - assert_eq!(input[1]["call_id"], "call_123"); - assert_eq!(input[2]["type"], "function_call_output"); - assert_eq!(input[2]["call_id"], "call_123"); - assert_eq!(input[2]["output"], "sunny"); - } -} diff --git a/aimux-providers/tests/xai_test.rs b/aimux-providers/tests/xai_test.rs deleted file mode 100644 index 71a4dfb4e..000000000 --- a/aimux-providers/tests/xai_test.rs +++ /dev/null @@ -1,2822 +0,0 @@ -//! Rust translations of the AI SDK xAI provider chat-level tests. -//! -//! Sources (TS → Rust): -//! - `packages/xai/src/supports-reasoning-effort.test.ts` → `supports_reasoning_effort` mod -//! - `packages/xai/src/convert-xai-chat-usage.test.ts` → `convert_usage` mod -//! - `packages/xai/src/xai-prepare-tools.test.ts` → `prepare_tools` mod -//! - `packages/xai/src/convert-to-xai-chat-messages.test.ts` → `convert_messages` mod -//! - `packages/xai/src/xai-error.test.ts` → `error_handling` mod -//! - `packages/xai/src/xai-chat-language-model.test.ts` → `do_generate` / `do_stream` / `reasoning` mods -//! - `packages/xai/src/xai-provider.test.ts` → `provider` mod -//! -//! Each test uses `wiremock` to spin up a mock HTTP server, configures a JSON -//! or SSE response, creates an `XaiModel` via `XAIProvider`, calls -//! `do_generate` / `do_stream`, and asserts on the result. - -use std::collections::HashMap; - -use futures::StreamExt; -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::LanguageModelPromptMessage; -use aimux_core::message::Role; -use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; -use aimux_core::result::GenerateContent; -use aimux_core::stream_part::StreamPart; -use aimux_core::tool::{FunctionTool, ProviderTool, Tool}; -use aimux_core::types::{FinishReasonUnified, ReasoningEffort}; - -use aimux_providers::xai::convert::supports_reasoning_effort; -use aimux_providers::{XAIConfig, XAIProvider}; - -// ── shared helpers ─────────────────────────────────────────────────────────── - -/// The TS `TEST_PROMPT`: a single user text message "Hello". -fn test_prompt() -> Vec { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -/// `CallOptions` with only `prompt` set. -fn default_options(prompt: Vec) -> CallOptions { - CallOptions::new(prompt) -} - -/// A standard non-streaming chat-completion JSON body returning "Hello". -fn text_completion_body() -> Value { - json!({ - "id": "chatcmpl-test", - "object": "chat.completion", - "created": 1711115037, - "model": "test-model", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "Hello, World!" }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - }) -} - -/// The xai-text fixture response (with reasoning_content and detailed usage). -fn xai_text_fixture() -> Value { - json!({ - "id": "2af5c888-e886-6dcb-7844-95f8fe010b00", - "object": "chat.completion", - "created": 1770774046, - "model": "grok-3-mini", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "Hello", - "reasoning_content": "First, the user said: \"Say a single word.\" That's straightforward. They want me to respond with just one word.\n\nResponse: I'll go with \"Hello\" as it's a common greeting and keeps it simple.", - "refusal": null - }, - "finish_reason": "stop" - }], - "usage": { - "prompt_tokens": 12, - "completion_tokens": 1, - "total_tokens": 241, - "prompt_tokens_details": { - "text_tokens": 12, - "audio_tokens": 0, - "image_tokens": 0, - "cached_tokens": 2 - }, - "completion_tokens_details": { - "reasoning_tokens": 228, - "audio_tokens": 0, - "accepted_prediction_tokens": 0, - "rejected_prediction_tokens": 0 - } - }, - "system_fingerprint": "fp_2a885414fb" - }) -} - -/// Build a single SSE `data: \n\n` event string. -fn sse_event(json_str: &str) -> String { - format!("data: {json_str}\n\n") -} - -/// Concatenate SSE events and append the `[DONE]` sentinel. -fn sse_body(events: &[&str]) -> String { - let mut body = String::new(); - for event in events { - body.push_str(event); - } - body.push_str("data: [DONE]\n\n"); - body -} - -/// Collect every `StreamPart` from a `StreamResult` into a `Vec`. -async fn collect_stream(result: aimux_core::result::StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -/// Extract text deltas from a list of stream parts. -fn text_deltas(parts: &[StreamPart]) -> Vec { - parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect() -} - -/// Extract reasoning deltas from a list of stream parts. -fn reasoning_deltas(parts: &[StreamPart]) -> Vec { - parts - .iter() - .filter_map(|p| match p { - StreamPart::ReasoningDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect() -} - -/// Build a provider pointed at the mock server. -fn make_provider(server: &MockServer) -> XAIProvider { - let config = XAIConfig::new("test-api-key").with_base_url(server.uri()); - XAIProvider::new(config) -} - -/// Provider options with a single xai key. -fn xai_options(key: &str, value: Value) -> Option> { - let mut m = HashMap::new(); - m.insert("xai".to_string(), json!({ key: value })); - Some(m) -} - -/// Build provider options from a JSON object. -fn xai_provider_options(opts: Value) -> Option> { - let mut m = HashMap::new(); - m.insert("xai".to_string(), opts); - Some(m) -} - -// ════════════════════════════════════════════════════════════════════════════ -// supportsReasoningEffort — direct function tests -// (supports-reasoning-effort.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod supports_reasoning_effort_tests { - use super::*; - - /// TS: should return true for grok-4.3 - #[test] - fn true_for_grok_4_3() { - assert!(supports_reasoning_effort("grok-4.3")); - } - - /// TS: should return true for grok-latest - #[test] - fn true_for_grok_latest() { - assert!(supports_reasoning_effort("grok-latest")); - } - - /// TS: should return true for grok-4.20-multi-agent - #[test] - fn true_for_grok_4_20_multi_agent() { - assert!(supports_reasoning_effort("grok-4.20-multi-agent")); - } - - /// TS: should return true for grok-4.20-multi-agent-0309 - #[test] - fn true_for_grok_4_20_multi_agent_0309() { - assert!(supports_reasoning_effort("grok-4.20-multi-agent-0309")); - } - - /// TS: should return true for grok-3-mini - #[test] - fn true_for_grok_3_mini() { - assert!(supports_reasoning_effort("grok-3-mini")); - } - - /// TS: should return false for grok-4.20-reasoning - #[test] - fn false_for_grok_4_20_reasoning() { - assert!(!supports_reasoning_effort("grok-4.20-reasoning")); - } - - /// TS: should return false for grok-4.20-non-reasoning - #[test] - fn false_for_grok_4_20_non_reasoning() { - assert!(!supports_reasoning_effort("grok-4.20-non-reasoning")); - } - - /// TS: should return false for grok-4.20-0309-reasoning - #[test] - fn false_for_grok_4_20_0309_reasoning() { - assert!(!supports_reasoning_effort("grok-4.20-0309-reasoning")); - } - - /// TS: should return false for grok-4.20-0309-non-reasoning - #[test] - fn false_for_grok_4_20_0309_non_reasoning() { - assert!(!supports_reasoning_effort("grok-4.20-0309-non-reasoning")); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// convertXaiChatUsage — tested through do_generate -// (convert-xai-chat-usage.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod convert_usage { - use super::*; - - /// TS: should convert basic usage without reasoning tokens - #[tokio::test] - async fn basic_usage_without_reasoning() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "test", - "object": "chat.completion", - "created": 1699472111, - "model": "grok-3", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "Hi" }, - "finish_reason": "stop" - }], - "usage": { - "prompt_tokens": 12, - "completion_tokens": 1, - "total_tokens": 13 - } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert_eq!(result.usage.input_tokens.total, Some(12)); - assert_eq!(result.usage.input_tokens.no_cache, Some(12)); - assert_eq!(result.usage.input_tokens.cache_read, Some(0)); - assert_eq!(result.usage.output_tokens.total, Some(1)); - assert_eq!(result.usage.output_tokens.text, Some(1)); - assert_eq!(result.usage.output_tokens.reasoning, Some(0)); - } - - /// TS: should convert usage with reasoning tokens (xai reports separately) - #[tokio::test] - async fn usage_with_reasoning_tokens() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "test", - "object": "chat.completion", - "created": 1699472111, - "model": "grok-3", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "Hi" }, - "finish_reason": "stop" - }], - "usage": { - "prompt_tokens": 12, - "completion_tokens": 1, - "total_tokens": 241, - "completion_tokens_details": { - "reasoning_tokens": 228 - } - } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - // xAI reports reasoning separately: total = completion + reasoning - assert_eq!(result.usage.output_tokens.total, Some(229)); - assert_eq!(result.usage.output_tokens.text, Some(1)); - assert_eq!(result.usage.output_tokens.reasoning, Some(228)); - assert_eq!(result.usage.input_tokens.total, Some(12)); - } - - /// TS: should convert usage with cached input tokens - #[tokio::test] - async fn usage_with_cached_tokens() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "test", - "object": "chat.completion", - "created": 1699472111, - "model": "grok-3", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "Hi" }, - "finish_reason": "stop" - }], - "usage": { - "prompt_tokens": 12, - "completion_tokens": 2, - "total_tokens": 438, - "prompt_tokens_details": { - "text_tokens": 12, - "audio_tokens": 0, - "image_tokens": 0, - "cached_tokens": 3 - }, - "completion_tokens_details": { - "reasoning_tokens": 424 - } - } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert_eq!(result.usage.input_tokens.cache_read, Some(3)); - assert_eq!(result.usage.input_tokens.no_cache, Some(9)); // 12 - 3 - assert_eq!(result.usage.input_tokens.total, Some(12)); - assert_eq!(result.usage.output_tokens.reasoning, Some(424)); - assert_eq!(result.usage.output_tokens.text, Some(2)); - assert_eq!(result.usage.output_tokens.total, Some(426)); // 2 + 424 - } - - /// TS: should handle cached_tokens exceeding prompt_tokens (non-inclusive) - #[tokio::test] - async fn cached_tokens_exceeding_prompt_tokens() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "test", - "object": "chat.completion", - "created": 1699472111, - "model": "grok-3", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "Hi" }, - "finish_reason": "stop" - }], - "usage": { - "prompt_tokens": 4142, - "completion_tokens": 254, - "total_tokens": 8724, - "prompt_tokens_details": { - "cached_tokens": 4328 - } - } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - // Non-inclusive: total = prompt + cached, noCache = prompt - assert_eq!(result.usage.input_tokens.cache_read, Some(4328)); - assert_eq!(result.usage.input_tokens.no_cache, Some(4142)); - assert_eq!(result.usage.input_tokens.total, Some(8470)); // 4142 + 4328 - } - - /// TS: should handle null token details - #[tokio::test] - async fn null_token_details() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "test", - "object": "chat.completion", - "created": 1699472111, - "model": "grok-3", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "Hi" }, - "finish_reason": "stop" - }], - "usage": { - "prompt_tokens": 100, - "completion_tokens": 200, - "total_tokens": 300, - "prompt_tokens_details": null, - "completion_tokens_details": null - } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert_eq!(result.usage.input_tokens.cache_read, Some(0)); - assert_eq!(result.usage.input_tokens.no_cache, Some(100)); - assert_eq!(result.usage.output_tokens.reasoning, Some(0)); - assert_eq!(result.usage.output_tokens.text, Some(200)); - assert_eq!(result.usage.output_tokens.total, Some(200)); - } - - /// TS: should handle zero reasoning tokens - #[tokio::test] - async fn zero_reasoning_tokens() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "test", - "object": "chat.completion", - "created": 1699472111, - "model": "grok-3", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "Hi" }, - "finish_reason": "stop" - }], - "usage": { - "prompt_tokens": 50, - "completion_tokens": 100, - "total_tokens": 150, - "completion_tokens_details": { - "reasoning_tokens": 0 - } - } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert_eq!(result.usage.output_tokens.reasoning, Some(0)); - assert_eq!(result.usage.output_tokens.text, Some(100)); - assert_eq!(result.usage.output_tokens.total, Some(100)); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// prepareTools — tested through do_generate request body and warnings -// (xai-prepare-tools.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod prepare_tools { - use super::*; - - /// TS: should return undefined tools and toolChoice when tools are undefined - #[tokio::test] - async fn no_tools() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - let body = result.request_body.unwrap(); - assert!(body.get("tools").is_none()); - assert!(body.get("tool_choice").is_none()); - } - - /// TS: should return undefined tools and toolChoice when tools are empty - #[tokio::test] - async fn empty_tools() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert!(body.get("tools").is_none()); - assert!(body.get("tool_choice").is_none()); - } - - /// TS: should correctly prepare function tools - #[tokio::test] - async fn function_tools() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![Tool::from(FunctionTool { - name: "testFunction".to_string(), - description: Some("A test function".to_string()), - input_schema: json!({"type": "object", "properties": {}}), - strict: None, - provider_options: None, - input_examples: None, - })]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - let tools = body["tools"].as_array().expect("tools should be array"); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0]["type"], "function"); - assert_eq!(tools[0]["function"]["name"], "testFunction"); - assert_eq!(tools[0]["function"]["description"], "A test function"); - assert_eq!(tools[0]["function"]["parameters"]["type"], "object"); - assert!(tools[0]["function"].get("strict").is_none()); - } - - /// TS: should add warnings for provider-defined tools - #[tokio::test] - async fn provider_defined_tools_warning() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![Tool::Provider(ProviderTool { - id: "xai.unsupported_tool".to_string(), - name: "unsupported_tool".to_string(), - args: json!({}), - })]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - // No tools in body (all were provider-defined) - let tools = body.get("tools"); - assert!( - tools.is_none() - || tools - .unwrap() - .as_array() - .map(std::vec::Vec::is_empty) - .unwrap_or(true) - ); - - // Should have a warning - let has_warning = result.warnings.iter().any(|w| { - matches!(w, aimux_core::types::Warning::Unsupported { feature, .. } - if feature == "provider-defined tool unsupported_tool") - }); - assert!( - has_warning, - "should have unsupported warning for provider-defined tool" - ); - } - - /// TS: should handle multiple tools including provider-defined and function tools - #[tokio::test] - async fn mixed_tools() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![ - Tool::from(FunctionTool { - name: "calculator".to_string(), - description: Some("calculate numbers".to_string()), - input_schema: json!({"type": "object", "properties": {}}), - strict: None, - provider_options: None, - input_examples: None, - }), - Tool::Provider(ProviderTool { - id: "xai.some_tool".to_string(), - name: "some_tool".to_string(), - args: json!({}), - }), - Tool::from(FunctionTool { - name: "weather".to_string(), - description: Some("get weather".to_string()), - input_schema: json!({"type": "object", "properties": {}}), - strict: None, - provider_options: None, - input_examples: None, - }), - ]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - let tools = body["tools"].as_array().expect("tools should be array"); - assert_eq!(tools.len(), 2); - assert_eq!(tools[0]["function"]["name"], "calculator"); - assert_eq!(tools[1]["function"]["name"], "weather"); - - let has_warning = result.warnings.iter().any(|w| { - matches!(w, aimux_core::types::Warning::Unsupported { feature, .. } - if feature == "provider-defined tool some_tool") - }); - assert!(has_warning); - } - - /// TS: should handle tool choice "auto" - #[tokio::test] - async fn tool_choice_auto() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![Tool::from(FunctionTool::new( - "testFunction", - json!({}), - ))]), - tool_choice: ToolChoice::Auto, - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["tool_choice"], "auto"); - } - - /// TS: should handle tool choice "none" - #[tokio::test] - async fn tool_choice_none() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![Tool::from(FunctionTool::new( - "testFunction", - json!({}), - ))]), - tool_choice: ToolChoice::None, - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["tool_choice"], "none"); - } - - /// TS: should handle tool choice "required" - #[tokio::test] - async fn tool_choice_required() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![Tool::from(FunctionTool::new( - "testFunction", - json!({}), - ))]), - tool_choice: ToolChoice::Required, - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["tool_choice"], "required"); - } - - /// TS: should handle tool choice "tool" - #[tokio::test] - async fn tool_choice_tool() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![Tool::from(FunctionTool::new( - "testFunction", - json!({}), - ))]), - tool_choice: ToolChoice::Tool { - tool_name: "testFunction".to_string(), - }, - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["tool_choice"]["type"], "function"); - assert_eq!(body["tool_choice"]["function"]["name"], "testFunction"); - } - - /// TS: should pass through strict mode when strict is true - #[tokio::test] - async fn strict_true() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![Tool::from(FunctionTool { - name: "testFunction".to_string(), - description: Some("A test function".to_string()), - input_schema: json!({"type": "object", "properties": {}}), - strict: Some(true), - provider_options: None, - input_examples: None, - })]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["tools"][0]["function"]["strict"], true); - } - - /// TS: should pass through strict mode when strict is false - #[tokio::test] - async fn strict_false() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![Tool::from(FunctionTool { - name: "testFunction".to_string(), - description: Some("A test function".to_string()), - input_schema: json!({"type": "object", "properties": {}}), - strict: Some(false), - provider_options: None, - input_examples: None, - })]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["tools"][0]["function"]["strict"], false); - } - - /// TS: should not include strict when strict is undefined - #[tokio::test] - async fn strict_undefined() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![Tool::from(FunctionTool { - name: "testFunction".to_string(), - description: Some("A test function".to_string()), - input_schema: json!({"type": "object", "properties": {}}), - strict: None, - provider_options: None, - input_examples: None, - })]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert!(body["tools"][0]["function"].get("strict").is_none()); - } - - /// TS: should remove additionalProperties: false from tool schemas - #[tokio::test] - async fn removes_additional_properties_false() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![Tool::from(FunctionTool { - name: "test-tool".to_string(), - description: None, - input_schema: json!({ - "type": "object", - "properties": { "value": { "type": "string" } }, - "required": ["value"], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - }), - strict: None, - provider_options: None, - input_examples: None, - })]), - tool_choice: ToolChoice::Tool { - tool_name: "test-tool".to_string(), - }, - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - let params = &body["tools"][0]["function"]["parameters"]; - assert!(params.get("additionalProperties").is_none()); - assert_eq!(params["type"], "object"); - assert_eq!(params["properties"]["value"]["type"], "string"); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// convertToXaiChatMessages — tested through do_generate request body -// (convert-to-xai-chat-messages.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod convert_messages { - use super::*; - - /// TS: should convert simple text messages - #[tokio::test] - async fn simple_text() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["messages"][0]["role"], "user"); - assert_eq!(body["messages"][0]["content"], "Hello"); - } - - /// TS: should convert system messages - #[tokio::test] - async fn system_message() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a helpful assistant.")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - ]; - let result = model.do_generate(&default_options(prompt)).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["messages"][0]["role"], "system"); - assert_eq!( - body["messages"][0]["content"], - "You are a helpful assistant." - ); - } - - /// TS: should convert assistant messages - #[tokio::test] - async fn assistant_message() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text("Hello there!")], - ..Default::default() - }, - ]; - let result = model.do_generate(&default_options(prompt)).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["messages"][1]["role"], "assistant"); - assert_eq!(body["messages"][1]["content"], "Hello there!"); - } - - /// TS: should convert messages with image parts (data) - #[tokio::test] - async fn image_data_part() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("What is in this image?"), - ContentPart::file(vec![0, 1, 2, 3], "image/png"), - ], - ..Default::default() - }]; - let result = model.do_generate(&default_options(prompt)).await.unwrap(); - - let body = result.request_body.unwrap(); - let content = body["messages"][0]["content"].as_array().unwrap(); - assert_eq!(content[0]["type"], "text"); - assert_eq!(content[0]["text"], "What is in this image?"); - assert_eq!(content[1]["type"], "image_url"); - assert_eq!( - content[1]["image_url"]["url"], - "data:image/png;base64,AAECAw==" - ); - } - - /// TS: should convert image URLs - #[tokio::test] - async fn image_url_part() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::file_url( - "https://example.com/image.jpg", - "image/jpeg", - )], - ..Default::default() - }]; - let result = model.do_generate(&default_options(prompt)).await.unwrap(); - - let body = result.request_body.unwrap(); - let content = body["messages"][0]["content"].as_array().unwrap(); - assert_eq!(content[0]["type"], "image_url"); - assert_eq!( - content[0]["image_url"]["url"], - "https://example.com/image.jpg" - ); - } - - /// TS: should convert image file parts with provider reference (xai key) - #[tokio::test] - async fn image_reference() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::file_reference( - "image/png", - json!({"xai": "file-abc123", "openai": "file-xyz789"}), - )], - ..Default::default() - }]; - let result = model.do_generate(&default_options(prompt)).await.unwrap(); - - let body = result.request_body.unwrap(); - let content = body["messages"][0]["content"].as_array().unwrap(); - assert_eq!(content[0]["type"], "file"); - assert_eq!(content[0]["file"]["file_id"], "file-abc123"); - } - - /// TS: should throw error when provider reference is missing xai key - #[tokio::test] - async fn missing_xai_reference_panics() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::file_reference( - "image/png", - json!({"openai": "file-xyz789"}), - )], - ..Default::default() - }]; - let result = model.do_generate(&default_options(prompt)).await; - assert!( - result.is_err(), - "should error when xai reference is missing" - ); - } - - /// TS: should convert tool calls and tool responses - #[tokio::test] - async fn tool_calls_and_responses() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "call_123", - "weather", - json!({"location": "Paris"}), - )], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "call_123", - json!({"temperature": 20}), - )], - ..Default::default() - }, - ]; - let result = model.do_generate(&default_options(prompt)).await.unwrap(); - - let body = result.request_body.unwrap(); - // Assistant message with tool_calls - assert_eq!(body["messages"][0]["role"], "assistant"); - assert_eq!(body["messages"][0]["content"], ""); - let tool_calls = body["messages"][0]["tool_calls"].as_array().unwrap(); - assert_eq!(tool_calls[0]["id"], "call_123"); - assert_eq!(tool_calls[0]["type"], "function"); - assert_eq!(tool_calls[0]["function"]["name"], "weather"); - assert_eq!( - tool_calls[0]["function"]["arguments"], - "{\"location\":\"Paris\"}" - ); - - // Tool message - assert_eq!(body["messages"][1]["role"], "tool"); - assert_eq!(body["messages"][1]["tool_call_id"], "call_123"); - assert_eq!(body["messages"][1]["content"], "{\"temperature\":20}"); - } - - /// TS: should handle multiple tool calls in one message - #[tokio::test] - async fn multiple_tool_calls() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ - ContentPart::tool_call("call_123", "weather", json!({"location": "Paris"})), - ContentPart::tool_call("call_456", "time", json!({"timezone": "UTC"})), - ], - ..Default::default() - }]; - let result = model.do_generate(&default_options(prompt)).await.unwrap(); - - let body = result.request_body.unwrap(); - let tool_calls = body["messages"][0]["tool_calls"].as_array().unwrap(); - assert_eq!(tool_calls.len(), 2); - assert_eq!(tool_calls[0]["function"]["name"], "weather"); - assert_eq!(tool_calls[1]["function"]["name"], "time"); - } - - /// TS: should handle mixed content with text and tool calls - #[tokio::test] - async fn mixed_text_and_tool_calls() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ - ContentPart::text("Let me check the weather for you."), - ContentPart::tool_call("call_123", "weather", json!({"location": "Paris"})), - ], - ..Default::default() - }]; - let result = model.do_generate(&default_options(prompt)).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!( - body["messages"][0]["content"], - "Let me check the weather for you." - ); - let tool_calls = body["messages"][0]["tool_calls"].as_array().unwrap(); - assert_eq!(tool_calls.len(), 1); - assert_eq!(tool_calls[0]["function"]["name"], "weather"); - } - - /// TS: should pass imageDetail from xai provider options on image parts - #[tokio::test] - async fn image_detail_provider_option() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("What is in this image?"), - ContentPart::File { - data: vec![0, 1, 2, 3], - media_type: "image/png".to_string(), - filename: None, - provider_options: Some(json!({"xai": {"imageDetail": "low"}})), - }, - ], - ..Default::default() - }]; - let result = model.do_generate(&default_options(prompt)).await.unwrap(); - - let body = result.request_body.unwrap(); - let content = body["messages"][0]["content"].as_array().unwrap(); - assert_eq!(content[1]["image_url"]["detail"], "low"); - } - - /// TS: should not set detail when imageDetail is not set - #[tokio::test] - async fn no_image_detail() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("What is in this image?"), - ContentPart::file(vec![0, 1, 2, 3], "image/png"), - ], - ..Default::default() - }]; - let result = model.do_generate(&default_options(prompt)).await.unwrap(); - - let body = result.request_body.unwrap(); - let content = body["messages"][0]["content"].as_array().unwrap(); - assert!(content[1]["image_url"].get("detail").is_none()); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate — response parsing and request body -// (xai-chat-language-model.test.ts → describe('doGenerate')) -// ════════════════════════════════════════════════════════════════════════════ - -mod do_generate { - use super::*; - - /// TS: should be instantiated correctly - #[tokio::test] - async fn instantiated_correctly() { - let provider = make_provider(&MockServer::start().await); - let model = provider.model("grok-3"); - assert_eq!(model.model_id(), "grok-3"); - assert_eq!(model.provider(), "xai.chat"); - } - - /// TS: should extract text content - #[tokio::test] - async fn extract_text_content() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "test", - "object": "chat.completion", - "created": 1699472111, - "model": "grok-3", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "Hello from object" }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::Text { text, .. } => assert_eq!(text, "Hello from object"), - other => panic!("expected Text, got {other:?}"), - } - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - } - - /// TS: should extract tool call content - #[tokio::test] - async fn extract_tool_call() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "test", - "object": "chat.completion", - "created": 1699472111, - "model": "grok-3", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "", - "tool_calls": [{ - "id": "call_93562515", - "function": { - "name": "weather", - "arguments": "{\"location\":\"San Francisco\"}" - }, - "type": "function" - }] - }, - "finish_reason": "tool_calls" - }], - "usage": { "prompt_tokens": 291, "total_tokens": 506, "completion_tokens": 26 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - let tool_call = result.content.iter().find_map(|c| match c { - GenerateContent::ToolCall { - tool_call_id, - tool_name, - input, - .. - } => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), - _ => None, - }); - let (id, name, input) = tool_call.expect("should have ToolCall"); - assert_eq!(id, "call_93562515"); - assert_eq!(name, "weather"); - assert_eq!( - input, - Value::String(r#"{"location":"San Francisco"}"#.into()) - ); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); - } - - /// TS: should extract usage (from xai-text fixture) - #[tokio::test] - async fn extract_usage() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(xai_text_fixture())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert_eq!(result.usage.input_tokens.cache_read, Some(2)); - assert_eq!(result.usage.input_tokens.no_cache, Some(10)); - assert_eq!(result.usage.input_tokens.total, Some(12)); - assert_eq!(result.usage.output_tokens.reasoning, Some(228)); - assert_eq!(result.usage.output_tokens.text, Some(1)); - assert_eq!(result.usage.output_tokens.total, Some(229)); - } - - /// TS: should send additional response information (id, modelId, timestamp) - #[tokio::test] - async fn response_metadata() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(xai_text_fixture())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert_eq!( - result.response.id.as_deref(), - Some("2af5c888-e886-6dcb-7844-95f8fe010b00") - ); - assert_eq!(result.response.model_id.as_deref(), Some("grok-3-mini")); - assert!(result.response.timestamp.is_some()); - } - - /// TS: should expose the raw response headers - #[tokio::test] - async fn response_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("test-header", "test-value") - .set_body_json(text_completion_body()), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - let headers = result.response_headers.as_ref().expect("headers"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); - } - - /// TS: should pass the model and the messages - #[tokio::test] - async fn pass_model_and_messages() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["model"], "grok-3"); - assert_eq!(body["messages"][0]["content"], "Hello"); - assert_eq!(body["messages"][0]["role"], "user"); - } - - /// TS: should pass tools and toolChoice - #[tokio::test] - async fn pass_tools_and_tool_choice() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![Tool::from(FunctionTool { - name: "test-tool".to_string(), - description: None, - input_schema: json!({ - "type": "object", - "properties": { "value": { "type": "string" } }, - "required": ["value"], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - }), - strict: None, - provider_options: None, - input_examples: None, - })]), - tool_choice: ToolChoice::Tool { - tool_name: "test-tool".to_string(), - }, - ..default_options(test_prompt()) - }; - let _ = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["tool_choice"]["function"]["name"], "test-tool"); - assert_eq!(body["tools"][0]["function"]["name"], "test-tool"); - // additionalProperties should be removed - assert!( - body["tools"][0]["function"]["parameters"] - .get("additionalProperties") - .is_none() - ); - } - - /// TS: should pass parallel_function_calling provider option - #[tokio::test] - async fn pass_parallel_function_calling() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - provider_options: xai_options("parallel_function_calling", json!(false)), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["parallel_function_calling"], false); - } - - /// TS: should pass logprobs provider options - #[tokio::test] - async fn pass_logprobs() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - provider_options: xai_provider_options(json!({ - "logprobs": true, - "topLogprobs": 5 - })), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["logprobs"], true); - assert_eq!(body["top_logprobs"], 5); - } - - /// TS: should enable logprobs when topLogprobs is set - #[tokio::test] - async fn enable_logprobs_when_top_logprobs_set() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - provider_options: xai_options("topLogprobs", json!(3)), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["logprobs"], true); - assert_eq!(body["top_logprobs"], 3); - } - - /// TS: should pass search parameters - #[tokio::test] - async fn pass_search_parameters() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - provider_options: xai_provider_options(json!({ - "searchParameters": { - "mode": "auto", - "returnCitations": true, - "fromDate": "2024-01-01", - "toDate": "2024-12-31", - "maxSearchResults": 10 - } - })), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["search_parameters"]["mode"], "auto"); - assert_eq!(body["search_parameters"]["return_citations"], true); - assert_eq!(body["search_parameters"]["from_date"], "2024-01-01"); - assert_eq!(body["search_parameters"]["to_date"], "2024-12-31"); - assert_eq!(body["search_parameters"]["max_search_results"], 10); - } - - /// TS: should pass search parameters with sources array - #[tokio::test] - async fn pass_search_parameters_with_sources() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - provider_options: xai_provider_options(json!({ - "searchParameters": { - "mode": "on", - "sources": [ - {"type": "web", "country": "US", "excludedWebsites": ["example.com"], "safeSearch": false}, - {"type": "x", "includedXHandles": ["grok"], "excludedXHandles": ["openai"], "postFavoriteCount": 5, "postViewCount": 50}, - {"type": "news", "country": "GB"}, - {"type": "rss", "links": ["https://status.x.ai/feed.xml"]} - ] - } - })), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - let sp = &body["search_parameters"]; - assert_eq!(sp["mode"], "on"); - let sources = sp["sources"].as_array().unwrap(); - assert_eq!(sources.len(), 4); - assert_eq!(sources[0]["type"], "web"); - assert_eq!(sources[0]["country"], "US"); - assert_eq!(sources[0]["excluded_websites"][0], "example.com"); - assert_eq!(sources[0]["safe_search"], false); - assert_eq!(sources[1]["type"], "x"); - assert_eq!(sources[1]["included_x_handles"][0], "grok"); - assert_eq!(sources[1]["excluded_x_handles"][0], "openai"); - assert_eq!(sources[1]["post_favorite_count"], 5); - assert_eq!(sources[1]["post_view_count"], 50); - assert_eq!(sources[2]["type"], "news"); - assert_eq!(sources[2]["country"], "GB"); - assert_eq!(sources[3]["type"], "rss"); - assert_eq!(sources[3]["links"][0], "https://status.x.ai/feed.xml"); - } - - /// TS: should support json schema response format without warnings - #[tokio::test] - async fn json_schema_response_format() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - response_format: Some(ResponseFormat::Json { - schema: Some(json!({ - "type": "object", - "properties": { "name": { "type": "string" } }, - "required": ["name"] - })), - name: Some("person".to_string()), - description: None, - }), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["response_format"]["type"], "json_schema"); - assert_eq!(body["response_format"]["json_schema"]["name"], "person"); - assert_eq!(body["response_format"]["json_schema"]["strict"], true); - assert_eq!( - body["response_format"]["json_schema"]["schema"]["type"], - "object" - ); - assert!(result.warnings.is_empty()); - } - - /// TS: should handle missing usage in response - #[tokio::test] - async fn missing_usage() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "no-usage-test", - "object": "chat.completion", - "created": 1699472111, - "model": "grok-3", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "Hello", "tool_calls": null }, - "finish_reason": "stop" - }] - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert_eq!(result.usage.input_tokens.total, Some(0)); - assert_eq!(result.usage.input_tokens.no_cache, Some(0)); - assert_eq!(result.usage.input_tokens.cache_read, Some(0)); - assert_eq!(result.usage.output_tokens.total, Some(0)); - assert_eq!(result.usage.output_tokens.text, Some(0)); - assert_eq!(result.usage.output_tokens.reasoning, Some(0)); - } - - /// TS: should extract citations as sources - #[tokio::test] - async fn extract_citations() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "citations-test", - "object": "chat.completion", - "created": 1699472111, - "model": "grok-3", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "Here are the latest developments in AI.", "tool_calls": null }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "citations": [ - "https://example.com/article1", - "https://example.com/article2" - ] - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - provider_options: xai_provider_options(json!({ - "searchParameters": { "mode": "auto", "returnCitations": true } - })), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let sources: Vec<_> = result - .content - .iter() - .filter_map(|c| match c { - GenerateContent::Source { - url, source_type, .. - } => Some((url.clone(), source_type.clone())), - _ => None, - }) - .collect(); - assert_eq!(sources.len(), 2); - assert_eq!( - sources[0].0.as_deref(), - Some("https://example.com/article1") - ); - assert_eq!(sources[0].1, "url"); - assert_eq!( - sources[1].0.as_deref(), - Some("https://example.com/article2") - ); - } - - /// TS: should avoid duplication when there is a trailing assistant message - #[tokio::test] - async fn avoid_duplication_trailing_assistant() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(xai_text_fixture())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text("prefix ")], - ..Default::default() - }, - ]; - let result = model.do_generate(&default_options(prompt)).await.unwrap(); - - // Should have text "Hello" (not "prefix ") and reasoning content - let text = result.content.iter().find_map(|c| match c { - GenerateContent::Text { text, .. } => Some(text.clone()), - _ => None, - }); - assert_eq!(text.as_deref(), Some("Hello")); - - let has_reasoning = result - .content - .iter() - .any(|c| matches!(c, GenerateContent::Reasoning { .. })); - assert!(has_reasoning, "should have reasoning content"); - } - - /// TS: should send request body with correct fields - #[tokio::test] - async fn request_body_fields() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["model"], "grok-3"); - assert_eq!(body["messages"][0]["content"], "Hello"); - // Should NOT have stream fields - assert!(body.get("stream").is_none()); - assert!(body.get("stream_options").is_none()); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// doStream — streaming response parsing -// (xai-chat-language-model.test.ts → describe('doStream')) -// ════════════════════════════════════════════════════════════════════════════ - -mod do_stream { - use super::*; - - /// TS: should stream text content - #[tokio::test] - async fn stream_text() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-1","model":"grok-3","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-1","model":"grok-3","choices":[{"index":0,"delta":{"content":", World!"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-1","model":"grok-3","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":4,"completion_tokens":30,"total_tokens":34}}"#, - ), - ]); - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(body), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(); - let parts = collect_stream(result).await; - - assert_eq!( - text_deltas(&parts), - vec!["Hello".to_string(), ", World!".to_string()] - ); - - let finish = parts - .iter() - .find(|p| matches!(p, StreamPart::Finish { .. })); - match finish { - Some(StreamPart::Finish { finish_reason, .. }) => { - assert_eq!(finish_reason.unified, FinishReasonUnified::Stop); - } - other => panic!("expected Finish, got {other:?}"), - } - } - - /// TS: should stream tool call content - #[tokio::test] - async fn stream_tool_call() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"de9d896d","model":"grok-3-mini","choices":[{"index":0,"delta":{"tool_calls":[{"id":"call_55117580","function":{"name":"weather","arguments":"{\"location\":\"San Francisco\"}"}}]}}]}"#, - ), - &sse_event( - r#"{"id":"de9d896d","model":"grok-3-mini","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}"#, - ), - ]); - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(body), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(); - let parts = collect_stream(result).await; - - let tool_call = parts.iter().find_map(|p| match p { - StreamPart::ToolCall { - tool_call_id, - tool_name, - input, - .. - } => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), - _ => None, - }); - let (id, name, input) = tool_call.expect("should have ToolCall"); - assert_eq!(id, "call_55117580"); - assert_eq!(name, "weather"); - assert_eq!( - input, - Value::String(r#"{"location":"San Francisco"}"#.into()) - ); - } - - /// TS: should pass the messages (stream request body) - #[tokio::test] - async fn stream_request_body() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-1","model":"grok-3","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-1","model":"grok-3","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":4,"completion_tokens":30,"total_tokens":34}}"#, - ), - ]); - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(body), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(); - let _ = collect_stream(result).await; - - let requests = server.received_requests().await.unwrap(); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["stream"], true); - assert_eq!(body["stream_options"]["include_usage"], true); - assert_eq!(body["model"], "grok-3"); - assert_eq!(body["messages"][0]["content"], "Hello"); - } - - /// TS: should handle missing usage in streaming response - #[tokio::test] - async fn stream_missing_usage() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"no-usage","model":"grok-3","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"no-usage","model":"grok-3","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"no-usage","model":"grok-3","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#, - ), - ]); - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(body), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(); - let parts = collect_stream(result).await; - - let finish = parts.iter().find_map(|p| match p { - StreamPart::Finish { usage, .. } => Some(usage.clone()), - _ => None, - }); - let usage = finish.expect("should have Finish"); - assert_eq!(usage.input_tokens.total, Some(0)); - assert_eq!(usage.output_tokens.total, Some(0)); - } - - /// TS: should stream citations as sources - #[tokio::test] - async fn stream_citations() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"c8e45f92","model":"grok-3","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"c8e45f92","model":"grok-3","choices":[{"index":0,"delta":{"content":"Latest AI news"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"c8e45f92","model":"grok-3","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":4,"completion_tokens":30,"total_tokens":34},"citations":["https://example.com/source1","https://example.com/source2"]}"#, - ), - ]); - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(body), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - provider_options: xai_provider_options(json!({ - "searchParameters": { "mode": "auto", "returnCitations": true } - })), - ..default_options(test_prompt()) - }; - let result = model.do_stream(&options).await.unwrap(); - let parts = collect_stream(result).await; - - let sources: Vec<_> = parts - .iter() - .filter_map(|p| match p { - StreamPart::Source { url, .. } => url.clone(), - _ => None, - }) - .collect(); - assert_eq!(sources.len(), 2); - assert_eq!(sources[0], "https://example.com/source1"); - assert_eq!(sources[1], "https://example.com/source2"); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// Reasoning models — reasoning_effort, reasoning content, streaming -// (xai-chat-language-model.test.ts → describe('reasoning models')) -// ════════════════════════════════════════════════════════════════════════════ - -mod reasoning { - use super::*; - - /// TS: should pass reasoning_effort parameter - #[tokio::test] - async fn pass_reasoning_effort() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3-mini"); - let options = CallOptions { - provider_options: xai_options("reasoningEffort", json!("high")), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["reasoning_effort"], "high"); - } - - /// TS: should pass reasoning_effort: "none" via providerOptions - #[tokio::test] - async fn pass_reasoning_effort_none() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3-mini"); - let options = CallOptions { - provider_options: xai_options("reasoningEffort", json!("none")), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["reasoning_effort"], "none"); - } - - /// TS: should map top-level reasoning to reasoning_effort - #[tokio::test] - async fn map_top_level_reasoning() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3-mini"); - let options = CallOptions { - reasoning: Some(ReasoningEffort::High), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["reasoning_effort"], "high"); - } - - /// TS: should map top-level reasoning medium to reasoning_effort: "medium" - #[tokio::test] - async fn map_top_level_reasoning_medium() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3-mini"); - let options = CallOptions { - reasoning: Some(ReasoningEffort::Medium), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["reasoning_effort"], "medium"); - } - - /// TS: should coerce top-level reasoning xhigh to high - #[tokio::test] - async fn coerce_xhigh_to_high() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3-mini"); - let options = CallOptions { - reasoning: Some(ReasoningEffort::Xhigh), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["reasoning_effort"], "high"); - } - - /// TS: should map top-level reasoning none to reasoning_effort: "none" - #[tokio::test] - async fn map_top_level_reasoning_none() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3-mini"); - let options = CallOptions { - reasoning: Some(ReasoningEffort::None), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["reasoning_effort"], "none"); - } - - /// TS: should prefer providerOptions reasoningEffort over top-level reasoning - #[tokio::test] - async fn prefer_provider_options_over_top_level() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3-mini"); - let options = CallOptions { - reasoning: Some(ReasoningEffort::Medium), - provider_options: xai_options("reasoningEffort", json!("high")), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["reasoning_effort"], "high"); - } - - /// TS: should omit reasoning_effort and warn for models that do not support it - #[tokio::test] - async fn omit_for_unsupported_model() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-4.20-reasoning"); - let options = CallOptions { - reasoning: Some(ReasoningEffort::None), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert!(body.get("reasoning_effort").is_none()); - - let has_warning = result.warnings.iter().any(|w| { - matches!(w, aimux_core::types::Warning::Unsupported { feature, details } - if feature == "reasoning" - && details.as_deref() == Some("reasoning \"none\" is not supported by this model.")) - }); - assert!(has_warning, "should have reasoning unsupported warning"); - } - - /// TS: should still pass providerOptions reasoningEffort for models that do not support top-level reasoning - #[tokio::test] - async fn still_pass_provider_options_for_unsupported_model() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-4.20-reasoning"); - let options = CallOptions { - provider_options: xai_options("reasoningEffort", json!("none")), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["reasoning_effort"], "none"); - } - - /// TS: should extract reasoning content - #[tokio::test] - async fn extract_reasoning_content() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(xai_text_fixture())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3-mini"); - let options = CallOptions { - provider_options: xai_options("reasoningEffort", json!("low")), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - // Should have text content and reasoning content - let has_text = result - .content - .iter() - .any(|c| matches!(c, GenerateContent::Text { text, .. } if text == "Hello")); - assert!(has_text, "should have text 'Hello'"); - - let reasoning = result.content.iter().find_map(|c| match c { - GenerateContent::Reasoning { text, .. } => Some(text.clone()), - _ => None, - }); - assert!(reasoning.is_some(), "should have reasoning content"); - assert!(reasoning.unwrap().starts_with("First, the user said")); - } - - /// TS: should extract reasoning tokens from usage - #[tokio::test] - async fn extract_reasoning_tokens_from_usage() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(xai_text_fixture())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3-mini"); - let options = CallOptions { - provider_options: xai_options("reasoningEffort", json!("high")), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!(result.usage.output_tokens.reasoning, Some(228)); - assert_eq!(result.usage.output_tokens.text, Some(1)); - assert_eq!(result.usage.output_tokens.total, Some(229)); - } - - /// TS: should handle reasoning streaming - #[tokio::test] - async fn reasoning_streaming() { - let server = MockServer::start().await; - let chunks = [ - r#"{"id":"7327b9f5","model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"First","role":"assistant"}}]}"#, - r#"{"id":"7327b9f5","model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}]}"#, - r#"{"id":"7327b9f5","model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" the"}}]}"#, - r#"{"id":"7327b9f5","model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" user"}}]}"#, - r#"{"id":"7327b9f5","model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" said"}}]}"#, - r#"{"id":"7327b9f5","model":"grok-3-mini","choices":[{"index":0,"delta":{"content":"Hello"}}]}"#, - r#"{"id":"7327b9f5","model":"grok-3-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#, - ]; - let body = sse_body( - &chunks - .iter() - .map(|c| sse_event(c)) - .collect::>() - .iter() - .map(std::string::String::as_str) - .collect::>(), - ); - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(body), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3-mini"); - let options = CallOptions { - provider_options: xai_options("reasoningEffort", json!("low")), - ..default_options(test_prompt()) - }; - let result = model.do_stream(&options).await.unwrap(); - let parts = collect_stream(result).await; - - let deltas = reasoning_deltas(&parts); - assert_eq!(deltas, vec!["First", ",", " the", " user", " said"]); - - // Should also have text delta - let text = text_deltas(&parts); - assert_eq!(text, vec!["Hello"]); - } - - /// TS: should deduplicate repetitive reasoning deltas - #[tokio::test] - async fn dedup_reasoning_deltas() { - let server = MockServer::start().await; - let chunks = [ - r#"{"id":"grok-4-test","model":"grok-4-0709","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}"#, - r#"{"id":"grok-4-test","model":"grok-4-0709","choices":[{"index":0,"delta":{"reasoning_content":"Thinking... "},"finish_reason":null}]}"#, - r#"{"id":"grok-4-test","model":"grok-4-0709","choices":[{"index":0,"delta":{"reasoning_content":"Thinking... "},"finish_reason":null}]}"#, - r#"{"id":"grok-4-test","model":"grok-4-0709","choices":[{"index":0,"delta":{"reasoning_content":"Thinking... "},"finish_reason":null}]}"#, - r#"{"id":"grok-4-test","model":"grok-4-0709","choices":[{"index":0,"delta":{"reasoning_content":"Actually calculating now..."},"finish_reason":null}]}"#, - r#"{"id":"grok-4-test","model":"grok-4-0709","choices":[{"index":0,"delta":{"content":"The answer is 42."},"finish_reason":null}]}"#, - r#"{"id":"grok-4-test","model":"grok-4-0709","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":15,"completion_tokens":20,"total_tokens":35,"completion_tokens_details":{"reasoning_tokens":10}}}"#, - ]; - let body = sse_body( - &chunks - .iter() - .map(|c| sse_event(c)) - .collect::>() - .iter() - .map(std::string::String::as_str) - .collect::>(), - ); - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(body), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3-mini"); - let options = CallOptions { - provider_options: xai_options("reasoningEffort", json!("low")), - ..default_options(test_prompt()) - }; - let result = model.do_stream(&options).await.unwrap(); - let parts = collect_stream(result).await; - - // "Thinking... " should only appear once (dedup) - let deltas = reasoning_deltas(&parts); - assert_eq!(deltas, vec!["Thinking... ", "Actually calculating now..."]); - - // Should have text delta - let text = text_deltas(&parts); - assert_eq!(text, vec!["The answer is 42."]); - - // Should have reasoning tokens in finish usage - let finish = parts.iter().find_map(|p| match p { - StreamPart::Finish { usage, .. } => Some(usage.clone()), - _ => None, - }); - let usage = finish.expect("should have Finish"); - assert_eq!(usage.output_tokens.reasoning, Some(10)); - assert_eq!(usage.output_tokens.text, Some(20)); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// Error handling — 200-status errors, chat completions error shape -// (xai-error.test.ts, xai-chat-language-model.test.ts → error handling) -// ════════════════════════════════════════════════════════════════════════════ - -mod error_handling { - use super::*; - - /// TS: extracts message from chat completions error shape (400) - #[tokio::test] - async fn chat_completions_error_400() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(400).set_body_json(json!({ - "error": { - "message": "Invalid value: temperature must be between 0 and 2", - "type": "invalid_request_error", - "code": "invalid_value" - } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model.do_generate(&default_options(test_prompt())).await; - - assert!(result.is_err()); - let err = result.unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("Invalid value: temperature must be between 0 and 2"), - "got: {msg}" - ); - } - - /// TS: extracts message and code from responses api error shape - #[tokio::test] - async fn responses_error_shape() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(400).set_body_json(json!({ - "code": "Client specified an invalid argument", - "error": "Invalid request content: Each message must have at least one content element." - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model.do_generate(&default_options(test_prompt())).await; - - assert!(result.is_err()); - let err = result.unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("Client specified an invalid argument"), - "got: {msg}" - ); - assert!(msg.contains("Invalid request content"), "got: {msg}"); - } - - /// TS: should throw APICallError when xai returns error with 200 status (doGenerate) - #[tokio::test] - async fn error_with_200_status_do_generate() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "code": "The service is currently unavailable", - "error": "Timed out waiting for first token" - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model.do_generate(&default_options(test_prompt())).await; - - assert!(result.is_err()); - let err = result.unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("Timed out waiting for first token"), - "got: {msg}" - ); - } - - /// TS: should throw APICallError when xai returns error with 200 status (doStream) - #[tokio::test] - async fn error_with_200_status_do_stream() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "code": "The service is currently unavailable", - "error": "Timed out waiting for first token" - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model.do_stream(&default_options(test_prompt())).await; - - assert!(result.is_err()); - let err = result.unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("Timed out waiting for first token"), - "got: {msg}" - ); - } - - /// TS: 401 maps to `ApiCall` (401) - #[tokio::test] - async fn status_401_maps_to_auth_error() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(401).set_body_json(json!({ - "error": { - "message": "Incorrect API key provided", - "type": "invalid_request_error" - } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model.do_generate(&default_options(test_prompt())).await; - - assert!( - matches!(result, Err(AiMuxError::ApiCall(ref m)) if m.status_code == Some(401) && m.message == "Incorrect API key provided") - ); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// Provider configuration — base URL, headers, auth -// (xai-provider.test.ts → chat-related tests) -// ════════════════════════════════════════════════════════════════════════════ - -mod provider { - use super::*; - - /// TS: should construct a chat model with correct configuration - #[tokio::test] - async fn chat_model_config() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - - assert_eq!(model.model_id(), "grok-3"); - assert_eq!(model.provider(), "xai.chat"); - } - - /// TS: should use custom baseURL - #[tokio::test] - async fn custom_base_url() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let config = XAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = XAIProvider::new(config); - let model = provider.model("grok-3"); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - assert_eq!(requests.len(), 1); - assert_eq!(requests[0].url.path(), "/chat/completions"); - } - - /// TS: should pass headers (provider + request) - #[tokio::test] - async fn pass_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let mut headers = HashMap::new(); - headers.insert( - "Custom-Request-Header".to_string(), - "request-header-value".to_string(), - ); - let options = CallOptions { - headers: Some(headers), - ..default_options(test_prompt()) - }; - let _ = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - assert_eq!( - requests[0] - .headers - .get("authorization") - .and_then(|v| v.to_str().ok()), - Some("Bearer test-api-key") - ); - assert_eq!( - requests[0] - .headers - .get("custom-request-header") - .and_then(|v| v.to_str().ok()), - Some("request-header-value") - ); - } - - /// TS: request uses correct URL and auth header - #[tokio::test] - async fn request_url_and_auth() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-2"); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - assert_eq!(requests.len(), 1); - assert_eq!(requests[0].url.path(), "/chat/completions"); - assert_eq!( - requests[0] - .headers - .get("authorization") - .and_then(|v| v.to_str().ok()), - Some("Bearer test-api-key") - ); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["model"], "grok-2"); - } - - /// TS: should warn for unsupported parameters (topK, frequencyPenalty, etc.) - #[tokio::test] - async fn warn_for_unsupported_params() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - top_k: Some(40.0), - frequency_penalty: Some(0.5), - presence_penalty: Some(0.3), - stop_sequences: Some(vec!["stop".to_string()]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let features: Vec<_> = result - .warnings - .iter() - .filter_map(|w| match w { - aimux_core::types::Warning::Unsupported { feature, .. } => Some(feature.clone()), - _ => None, - }) - .collect(); - assert!(features.contains(&"topK".to_string())); - assert!(features.contains(&"frequencyPenalty".to_string())); - assert!(features.contains(&"presencePenalty".to_string())); - assert!(features.contains(&"stopSequences".to_string())); - } -} 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/provider_name.dart b/bindings/flutter/lib/provider_name.dart deleted file mode 100644 index c3a7d9743..000000000 --- a/bindings/flutter/lib/provider_name.dart +++ /dev/null @@ -1,258 +0,0 @@ -// ProviderName — GENERATED by `scripts/gen_provider_names.py`. -// Do not edit manually; single source of truth is `provider-registry.json`. - -/// Names of all built-in OpenAI-compatible providers. -class ProviderName { - ProviderName._(); - static const Abacus = 'abacus'; - static const AbliterationAi = 'abliteration_ai'; - static const Ai21 = 'ai21'; - static const Ai302 = 'ai302'; - static const AiRouter = 'ai_router'; - static const Aiand = 'aiand'; - static const Aibadgr = 'aibadgr'; - static const Aigc2d = 'aigc2d'; - static const Aihubmix = 'aihubmix'; - static const Ails = 'ails'; - static const Aiml = 'aiml'; - static const AkiIo = 'aki_io'; - static const Albert = 'albert'; - static const Alibaba = 'alibaba'; - static const AlibabaCodingPlan = 'alibaba_coding_plan'; - static const AlibabaCodingPlanCn = 'alibaba_coding_plan_cn'; - static const AlibabaTokenPlan = 'alibaba_token_plan'; - static const AlibabaTokenPlanCn = 'alibaba_token_plan_cn'; - static const Ambient = 'ambient'; - static const Anyapi = 'anyapi'; - static const Anyscale = 'anyscale'; - static const Apertis = 'apertis'; - static const Api2d = 'api2d'; - static const Api2gpt = 'api2gpt'; - static const Apiserpent = 'apiserpent'; - static const Atlascloud = 'atlascloud'; - static const AtomicChat = 'atomic_chat'; - static const Auriko = 'auriko'; - static const AzureAi = 'azure_ai'; - static const Baichuan = 'baichuan'; - static const Baidu = 'baidu'; - static const BaiduV2 = 'baidu_v2'; - static const Bailing = 'bailing'; - static const Baseten = 'baseten'; - static const Berget = 'berget'; - static const Bigmodel = 'bigmodel'; - static const Blueclaw = 'blueclaw'; - static const Bytedance = 'bytedance'; - static const Byteplus = 'byteplus'; - static const Bytez = 'bytez'; - static const Canopywave = 'canopywave'; - static const Cerebras = 'cerebras'; - static const Chatgpt = 'chatgpt'; - static const Cherryin = 'cherryin'; - static const Chutes = 'chutes'; - static const Clarifai = 'clarifai'; - static const Claudinio = 'claudinio'; - static const ClinePass = 'cline_pass'; - static const Closeai = 'closeai'; - static const CloudferroSherlock = 'cloudferro_sherlock'; - static const Cloudflare = 'cloudflare'; - static const CloudflareWorkersAi = 'cloudflare_workers_ai'; - static const Codestral = 'codestral'; - static const Cometapi = 'cometapi'; - static const Commandcode = 'commandcode'; - static const Compactifai = 'compactifai'; - static const Copilot = 'copilot'; - static const Cortecs = 'cortecs'; - static const Coze = 'coze'; - static const Crof = 'crof'; - static const Crossmodel = 'crossmodel'; - static const Crusoe = 'crusoe'; - static const Daoxe = 'daoxe'; - static const Darkbloom = 'darkbloom'; - static const Databricks = 'databricks'; - static const Datarobot = 'datarobot'; - static const Deepbricks = 'deepbricks'; - static const Deepinfra = 'deepinfra'; - static const Deepseek = 'deepseek'; - static const Digitalocean = 'digitalocean'; - static const Dinference = 'dinference'; - static const Doubao = 'doubao'; - static const Doubleword = 'doubleword'; - static const Drun = 'drun'; - static const Ebcloud = 'ebcloud'; - static const Embercloud = 'embercloud'; - static const Empiriolabs = 'empiriolabs'; - static const Evroc = 'evroc'; - static const Fastcrw = 'fastcrw'; - static const Fastgpt = 'fastgpt'; - static const Fastrouter = 'fastrouter'; - static const FeatherlessAi = 'featherless_ai'; - static const Firepass = 'firepass'; - static const Fireworks = 'fireworks'; - static const Freemodel = 'freemodel'; - static const Friendliai = 'friendliai'; - static const Frogbot = 'frogbot'; - static const Galadriel = 'galadriel'; - static const Gdc = 'gdc'; - static const Gigachat = 'gigachat'; - static const Github = 'github'; - static const Gmi = 'gmi'; - static const Gmicloud = 'gmicloud'; - static const Gonka24 = 'gonka24'; - static const GradientAi = 'gradient_ai'; - static const Groq = 'groq'; - static const Helicone = 'helicone'; - static const Heroku = 'heroku'; - static const Hetzner = 'hetzner'; - static const HostedVllm = 'hosted_vllm'; - static const HpcAi = 'hpc_ai'; - static const Hyperbolic = 'hyperbolic'; - static const Iflowcn = 'iflowcn'; - static const Inception = 'inception'; - static const Inceptron = 'inceptron'; - static const InferenceNet = 'inference_net'; - static const Inferencehub = 'inferencehub'; - static const Inferx = 'inferx'; - static const Infinity = 'infinity'; - static const IoNet = 'io_net'; - static const Jiekou = 'jiekou'; - static const Kenari = 'kenari'; - static const Kilo = 'kilo'; - static const Kimi = 'kimi'; - static const KimiForCoding = 'kimi_for_coding'; - static const Kiro = 'kiro'; - static const KlusterAi = 'kluster_ai'; - static const Krutrim = 'krutrim'; - static const KuaeCloudCodingPlan = 'kuae_cloud_coding_plan'; - static const LambdaAi = 'lambda_ai'; - static const Lemonade = 'lemonade'; - static const LemonfoxAi = 'lemonfox_ai'; - static const Libertai = 'libertai'; - static const Lilac = 'lilac'; - static const Lingyiwanwu = 'lingyiwanwu'; - static const Llama = 'llama'; - static const Llamagate = 'llamagate'; - static const Llmgateway = 'llmgateway'; - static const Llmtr = 'llmtr'; - static const Longcat = 'longcat'; - static const Lucidquery = 'lucidquery'; - static const Lynkr = 'lynkr'; - static const Matterai = 'matterai'; - static const Meganova = 'meganova'; - static const MergeGateway = 'merge_gateway'; - static const Meta = 'meta'; - static const MetaLlama = 'meta_llama'; - static const Mimo = 'mimo'; - static const Minimax = 'minimax'; - static const MinimaxCn = 'minimax_cn'; - static const MinimaxCnCodingPlan = 'minimax_cn_coding_plan'; - static const MinimaxCodingPlan = 'minimax_coding_plan'; - static const Mira = 'mira'; - static const Mixlayer = 'mixlayer'; - static const Moark = 'moark'; - static const Modal = 'modal'; - static const ModelOracleAi = 'model_oracle_ai'; - static const Modelscope = 'modelscope'; - static const Moonshotai = 'moonshotai'; - static const MoonshotaiCn = 'moonshotai_cn'; - static const Morph = 'morph'; - static const Nanogpt = 'nanogpt'; - static const Ncompass = 'ncompass'; - static const Nearai = 'nearai'; - static const Nebius = 'nebius'; - static const Neon = 'neon'; - static const Neuralwatt = 'neuralwatt'; - static const Nextbit = 'nextbit'; - static const NlpCloud = 'nlp_cloud'; - static const NousResearch = 'nous_research'; - static const Novita = 'novita'; - static const Nscale = 'nscale'; - static const NvidiaNim = 'nvidia_nim'; - static const Oci = 'oci'; - static const Ofox = 'ofox'; - static const Ohmygpt = 'ohmygpt'; - static const OllamaCloud = 'ollama_cloud'; - static const Openaimax = 'openaimax'; - static const Openaisb = 'openaisb'; - static const Opencode = 'opencode'; - static const OpencodeGo = 'opencode_go'; - static const OpencodeZen = 'opencode_zen'; - static const Orcarouter = 'orcarouter'; - static const Ovhcloud = 'ovhcloud'; - static const Parasail = 'parasail'; - static const Perfxcloud = 'perfxcloud'; - static const Perplexity = 'perplexity'; - static const PerplexityAgent = 'perplexity_agent'; - static const Petals = 'petals'; - static const Pinstripes = 'pinstripes'; - static const Pioneer = 'pioneer'; - static const Poe = 'poe'; - static const Poolside = 'poolside'; - static const Portkey = 'portkey'; - static const Ppinfra = 'ppinfra'; - static const Predibase = 'predibase'; - static const PrivatemodeAi = 'privatemode_ai'; - static const Publicai = 'publicai'; - static const QihangAi = 'qihang_ai'; - static const Qihoo360 = 'qihoo360'; - static const QiniuAi = 'qiniu_ai'; - static const RegoloAi = 'regolo_ai'; - static const RekaAi = 'reka_ai'; - static const Requesty = 'requesty'; - static const Reve = 'reve'; - static const RoutingRun = 'routing_run'; - static const Sakana = 'sakana'; - static const Sambanova = 'sambanova'; - static const Sarvam = 'sarvam'; - static const Scaleway = 'scaleway'; - static const ScxAi = 'scx_ai'; - static const Siliconflow = 'siliconflow'; - static const Snowflake = 'snowflake'; - static const SnowflakeCortex = 'snowflake_cortex'; - static const Stackit = 'stackit'; - static const Stepfun = 'stepfun'; - static const StepfunAiStepPlan = 'stepfun_ai_step_plan'; - static const StepfunStepPlan = 'stepfun_step_plan'; - static const Subconscious = 'subconscious'; - static const Submodel = 'submodel'; - static const Synthetic = 'synthetic'; - static const Tencent = 'tencent'; - static const TencentCodingPlan = 'tencent_coding_plan'; - static const TencentTokenPlan = 'tencent_token_plan'; - static const TencentTokenPlanEnterpriseAuto = 'tencent_token_plan_enterprise_auto'; - static const TencentTokenPlanEnterprisePro = 'tencent_token_plan_enterprise_pro'; - static const TencentTokenPlanGeneralPersonal = 'tencent_token_plan_general_personal'; - static const TencentTokenPlanHyPersonal = 'tencent_token_plan_hy_personal'; - static const TencentTokenhub = 'tencent_tokenhub'; - static const Tensormesh = 'tensormesh'; - static const TheGridAi = 'the_grid_ai'; - static const Thinkingmachines = 'thinkingmachines'; - static const Tinfoil = 'tinfoil'; - static const Togetherai = 'togetherai'; - static const Tokenflux = 'tokenflux'; - static const Tokenpony = 'tokenpony'; - static const Trustedrouter = 'trustedrouter'; - static const Tundra = 'tundra'; - static const UmansAi = 'umans_ai'; - static const Unorouter = 'unorouter'; - static const Upstage = 'upstage'; - static const V0 = 'v0'; - static const Venice = 'venice'; - static const Vercel = 'vercel'; - static const Vivgrid = 'vivgrid'; - static const VolcEngine = 'volc_engine'; - static const Vultr = 'vultr'; - static const Wafer = 'wafer'; - static const Wandb = 'wandb'; - static const XiaomiTokenPlanAms = 'xiaomi_token_plan_ams'; - static const XiaomiTokenPlanCn = 'xiaomi_token_plan_cn'; - static const XiaomiTokenPlanSgp = 'xiaomi_token_plan_sgp'; - static const Xiaomimimo = 'xiaomimimo'; - static const Xpersona = 'xpersona'; - static const Xunfei = 'xunfei'; - static const Zai = 'zai'; - static const ZaiCodingPlan = 'zai_coding_plan'; - static const Zeldoc = 'zeldoc'; - static const Zenmux = 'zenmux'; - static const ZhipuV4 = 'zhipu_v4'; - static const ZhipuaiCodingPlan = 'zhipuai_coding_plan'; -} 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/go/provider_name.go b/bindings/go/provider_name.go deleted file mode 100644 index 9c4fae0a0..000000000 --- a/bindings/go/provider_name.go +++ /dev/null @@ -1,260 +0,0 @@ -// ProviderName — GENERATED by `scripts/gen_provider_names.py`. -// Do not edit manually; single source of truth is `provider-registry.json`. -package aimux - -// ProviderName is the registry name of a built-in OpenAI-compatible provider. -type ProviderName string - -const ( - Abacus ProviderName = "abacus" - AbliterationAi ProviderName = "abliteration_ai" - Ai21 ProviderName = "ai21" - Ai302 ProviderName = "ai302" - AiRouter ProviderName = "ai_router" - Aiand ProviderName = "aiand" - Aibadgr ProviderName = "aibadgr" - Aigc2d ProviderName = "aigc2d" - Aihubmix ProviderName = "aihubmix" - Ails ProviderName = "ails" - Aiml ProviderName = "aiml" - AkiIo ProviderName = "aki_io" - Albert ProviderName = "albert" - Alibaba ProviderName = "alibaba" - AlibabaCodingPlan ProviderName = "alibaba_coding_plan" - AlibabaCodingPlanCn ProviderName = "alibaba_coding_plan_cn" - AlibabaTokenPlan ProviderName = "alibaba_token_plan" - AlibabaTokenPlanCn ProviderName = "alibaba_token_plan_cn" - Ambient ProviderName = "ambient" - Anyapi ProviderName = "anyapi" - Anyscale ProviderName = "anyscale" - Apertis ProviderName = "apertis" - Api2d ProviderName = "api2d" - Api2gpt ProviderName = "api2gpt" - Apiserpent ProviderName = "apiserpent" - Atlascloud ProviderName = "atlascloud" - AtomicChat ProviderName = "atomic_chat" - Auriko ProviderName = "auriko" - AzureAi ProviderName = "azure_ai" - Baichuan ProviderName = "baichuan" - Baidu ProviderName = "baidu" - BaiduV2 ProviderName = "baidu_v2" - Bailing ProviderName = "bailing" - Baseten ProviderName = "baseten" - Berget ProviderName = "berget" - Bigmodel ProviderName = "bigmodel" - Blueclaw ProviderName = "blueclaw" - Bytedance ProviderName = "bytedance" - Byteplus ProviderName = "byteplus" - Bytez ProviderName = "bytez" - Canopywave ProviderName = "canopywave" - Cerebras ProviderName = "cerebras" - Chatgpt ProviderName = "chatgpt" - Cherryin ProviderName = "cherryin" - Chutes ProviderName = "chutes" - Clarifai ProviderName = "clarifai" - Claudinio ProviderName = "claudinio" - ClinePass ProviderName = "cline_pass" - Closeai ProviderName = "closeai" - CloudferroSherlock ProviderName = "cloudferro_sherlock" - Cloudflare ProviderName = "cloudflare" - CloudflareWorkersAi ProviderName = "cloudflare_workers_ai" - Codestral ProviderName = "codestral" - Cometapi ProviderName = "cometapi" - Commandcode ProviderName = "commandcode" - Compactifai ProviderName = "compactifai" - Copilot ProviderName = "copilot" - Cortecs ProviderName = "cortecs" - Coze ProviderName = "coze" - Crof ProviderName = "crof" - Crossmodel ProviderName = "crossmodel" - Crusoe ProviderName = "crusoe" - Daoxe ProviderName = "daoxe" - Darkbloom ProviderName = "darkbloom" - Databricks ProviderName = "databricks" - Datarobot ProviderName = "datarobot" - Deepbricks ProviderName = "deepbricks" - Deepinfra ProviderName = "deepinfra" - Deepseek ProviderName = "deepseek" - Digitalocean ProviderName = "digitalocean" - Dinference ProviderName = "dinference" - Doubao ProviderName = "doubao" - Doubleword ProviderName = "doubleword" - Drun ProviderName = "drun" - Ebcloud ProviderName = "ebcloud" - Embercloud ProviderName = "embercloud" - Empiriolabs ProviderName = "empiriolabs" - Evroc ProviderName = "evroc" - Fastcrw ProviderName = "fastcrw" - Fastgpt ProviderName = "fastgpt" - Fastrouter ProviderName = "fastrouter" - FeatherlessAi ProviderName = "featherless_ai" - Firepass ProviderName = "firepass" - Fireworks ProviderName = "fireworks" - Freemodel ProviderName = "freemodel" - Friendliai ProviderName = "friendliai" - Frogbot ProviderName = "frogbot" - Galadriel ProviderName = "galadriel" - Gdc ProviderName = "gdc" - Gigachat ProviderName = "gigachat" - Github ProviderName = "github" - Gmi ProviderName = "gmi" - Gmicloud ProviderName = "gmicloud" - Gonka24 ProviderName = "gonka24" - GradientAi ProviderName = "gradient_ai" - Groq ProviderName = "groq" - Helicone ProviderName = "helicone" - Heroku ProviderName = "heroku" - Hetzner ProviderName = "hetzner" - HostedVllm ProviderName = "hosted_vllm" - HpcAi ProviderName = "hpc_ai" - Hyperbolic ProviderName = "hyperbolic" - Iflowcn ProviderName = "iflowcn" - Inception ProviderName = "inception" - Inceptron ProviderName = "inceptron" - InferenceNet ProviderName = "inference_net" - Inferencehub ProviderName = "inferencehub" - Inferx ProviderName = "inferx" - Infinity ProviderName = "infinity" - IoNet ProviderName = "io_net" - Jiekou ProviderName = "jiekou" - Kenari ProviderName = "kenari" - Kilo ProviderName = "kilo" - Kimi ProviderName = "kimi" - KimiForCoding ProviderName = "kimi_for_coding" - Kiro ProviderName = "kiro" - KlusterAi ProviderName = "kluster_ai" - Krutrim ProviderName = "krutrim" - KuaeCloudCodingPlan ProviderName = "kuae_cloud_coding_plan" - LambdaAi ProviderName = "lambda_ai" - Lemonade ProviderName = "lemonade" - LemonfoxAi ProviderName = "lemonfox_ai" - Libertai ProviderName = "libertai" - Lilac ProviderName = "lilac" - Lingyiwanwu ProviderName = "lingyiwanwu" - Llama ProviderName = "llama" - Llamagate ProviderName = "llamagate" - Llmgateway ProviderName = "llmgateway" - Llmtr ProviderName = "llmtr" - Longcat ProviderName = "longcat" - Lucidquery ProviderName = "lucidquery" - Lynkr ProviderName = "lynkr" - Matterai ProviderName = "matterai" - Meganova ProviderName = "meganova" - MergeGateway ProviderName = "merge_gateway" - Meta ProviderName = "meta" - MetaLlama ProviderName = "meta_llama" - Mimo ProviderName = "mimo" - Minimax ProviderName = "minimax" - MinimaxCn ProviderName = "minimax_cn" - MinimaxCnCodingPlan ProviderName = "minimax_cn_coding_plan" - MinimaxCodingPlan ProviderName = "minimax_coding_plan" - Mira ProviderName = "mira" - Mixlayer ProviderName = "mixlayer" - Moark ProviderName = "moark" - Modal ProviderName = "modal" - ModelOracleAi ProviderName = "model_oracle_ai" - Modelscope ProviderName = "modelscope" - Moonshotai ProviderName = "moonshotai" - MoonshotaiCn ProviderName = "moonshotai_cn" - Morph ProviderName = "morph" - Nanogpt ProviderName = "nanogpt" - Ncompass ProviderName = "ncompass" - Nearai ProviderName = "nearai" - Nebius ProviderName = "nebius" - Neon ProviderName = "neon" - Neuralwatt ProviderName = "neuralwatt" - Nextbit ProviderName = "nextbit" - NlpCloud ProviderName = "nlp_cloud" - NousResearch ProviderName = "nous_research" - Novita ProviderName = "novita" - Nscale ProviderName = "nscale" - NvidiaNim ProviderName = "nvidia_nim" - Oci ProviderName = "oci" - Ofox ProviderName = "ofox" - Ohmygpt ProviderName = "ohmygpt" - OllamaCloud ProviderName = "ollama_cloud" - Openaimax ProviderName = "openaimax" - Openaisb ProviderName = "openaisb" - Opencode ProviderName = "opencode" - OpencodeGo ProviderName = "opencode_go" - OpencodeZen ProviderName = "opencode_zen" - Orcarouter ProviderName = "orcarouter" - Ovhcloud ProviderName = "ovhcloud" - Parasail ProviderName = "parasail" - Perfxcloud ProviderName = "perfxcloud" - Perplexity ProviderName = "perplexity" - PerplexityAgent ProviderName = "perplexity_agent" - Petals ProviderName = "petals" - Pinstripes ProviderName = "pinstripes" - Pioneer ProviderName = "pioneer" - Poe ProviderName = "poe" - Poolside ProviderName = "poolside" - Portkey ProviderName = "portkey" - Ppinfra ProviderName = "ppinfra" - Predibase ProviderName = "predibase" - PrivatemodeAi ProviderName = "privatemode_ai" - Publicai ProviderName = "publicai" - QihangAi ProviderName = "qihang_ai" - Qihoo360 ProviderName = "qihoo360" - QiniuAi ProviderName = "qiniu_ai" - RegoloAi ProviderName = "regolo_ai" - RekaAi ProviderName = "reka_ai" - Requesty ProviderName = "requesty" - Reve ProviderName = "reve" - RoutingRun ProviderName = "routing_run" - Sakana ProviderName = "sakana" - Sambanova ProviderName = "sambanova" - Sarvam ProviderName = "sarvam" - Scaleway ProviderName = "scaleway" - ScxAi ProviderName = "scx_ai" - Siliconflow ProviderName = "siliconflow" - Snowflake ProviderName = "snowflake" - SnowflakeCortex ProviderName = "snowflake_cortex" - Stackit ProviderName = "stackit" - Stepfun ProviderName = "stepfun" - StepfunAiStepPlan ProviderName = "stepfun_ai_step_plan" - StepfunStepPlan ProviderName = "stepfun_step_plan" - Subconscious ProviderName = "subconscious" - Submodel ProviderName = "submodel" - Synthetic ProviderName = "synthetic" - Tencent ProviderName = "tencent" - TencentCodingPlan ProviderName = "tencent_coding_plan" - TencentTokenPlan ProviderName = "tencent_token_plan" - TencentTokenPlanEnterpriseAuto ProviderName = "tencent_token_plan_enterprise_auto" - TencentTokenPlanEnterprisePro ProviderName = "tencent_token_plan_enterprise_pro" - TencentTokenPlanGeneralPersonal ProviderName = "tencent_token_plan_general_personal" - TencentTokenPlanHyPersonal ProviderName = "tencent_token_plan_hy_personal" - TencentTokenhub ProviderName = "tencent_tokenhub" - Tensormesh ProviderName = "tensormesh" - TheGridAi ProviderName = "the_grid_ai" - Thinkingmachines ProviderName = "thinkingmachines" - Tinfoil ProviderName = "tinfoil" - Togetherai ProviderName = "togetherai" - Tokenflux ProviderName = "tokenflux" - Tokenpony ProviderName = "tokenpony" - Trustedrouter ProviderName = "trustedrouter" - Tundra ProviderName = "tundra" - UmansAi ProviderName = "umans_ai" - Unorouter ProviderName = "unorouter" - Upstage ProviderName = "upstage" - V0 ProviderName = "v0" - Venice ProviderName = "venice" - Vercel ProviderName = "vercel" - Vivgrid ProviderName = "vivgrid" - VolcEngine ProviderName = "volc_engine" - Vultr ProviderName = "vultr" - Wafer ProviderName = "wafer" - Wandb ProviderName = "wandb" - XiaomiTokenPlanAms ProviderName = "xiaomi_token_plan_ams" - XiaomiTokenPlanCn ProviderName = "xiaomi_token_plan_cn" - XiaomiTokenPlanSgp ProviderName = "xiaomi_token_plan_sgp" - Xiaomimimo ProviderName = "xiaomimimo" - Xpersona ProviderName = "xpersona" - Xunfei ProviderName = "xunfei" - Zai ProviderName = "zai" - ZaiCodingPlan ProviderName = "zai_coding_plan" - Zeldoc ProviderName = "zeldoc" - Zenmux ProviderName = "zenmux" - ZhipuV4 ProviderName = "zhipu_v4" - ZhipuaiCodingPlan ProviderName = "zhipuai_coding_plan" -) 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/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/ProviderName.java b/bindings/java/src/main/java/ai/arcships/aimux/ProviderName.java deleted file mode 100644 index 0c3ffc1fb..000000000 --- a/bindings/java/src/main/java/ai/arcships/aimux/ProviderName.java +++ /dev/null @@ -1,260 +0,0 @@ -// ProviderName — GENERATED by `scripts/gen_provider_names.py`. -// Do not edit manually; single source of truth is `provider-registry.json`. -package ai.arcships.aimux; - -/** Names of all built-in OpenAI-compatible providers. */ -public final class ProviderName { - private ProviderName() {} - - public static final String ABACUS = "abacus"; - public static final String ABLITERATIONAI = "abliteration_ai"; - public static final String AI21 = "ai21"; - public static final String AI302 = "ai302"; - public static final String AIROUTER = "ai_router"; - public static final String AIAND = "aiand"; - public static final String AIBADGR = "aibadgr"; - public static final String AIGC2D = "aigc2d"; - public static final String AIHUBMIX = "aihubmix"; - public static final String AILS = "ails"; - public static final String AIML = "aiml"; - public static final String AKIIO = "aki_io"; - public static final String ALBERT = "albert"; - public static final String ALIBABA = "alibaba"; - public static final String ALIBABACODINGPLAN = "alibaba_coding_plan"; - public static final String ALIBABACODINGPLANCN = "alibaba_coding_plan_cn"; - public static final String ALIBABATOKENPLAN = "alibaba_token_plan"; - public static final String ALIBABATOKENPLANCN = "alibaba_token_plan_cn"; - public static final String AMBIENT = "ambient"; - public static final String ANYAPI = "anyapi"; - public static final String ANYSCALE = "anyscale"; - public static final String APERTIS = "apertis"; - public static final String API2D = "api2d"; - public static final String API2GPT = "api2gpt"; - public static final String APISERPENT = "apiserpent"; - public static final String ATLASCLOUD = "atlascloud"; - public static final String ATOMICCHAT = "atomic_chat"; - public static final String AURIKO = "auriko"; - public static final String AZUREAI = "azure_ai"; - public static final String BAICHUAN = "baichuan"; - public static final String BAIDU = "baidu"; - public static final String BAIDUV2 = "baidu_v2"; - public static final String BAILING = "bailing"; - public static final String BASETEN = "baseten"; - public static final String BERGET = "berget"; - public static final String BIGMODEL = "bigmodel"; - public static final String BLUECLAW = "blueclaw"; - public static final String BYTEDANCE = "bytedance"; - public static final String BYTEPLUS = "byteplus"; - public static final String BYTEZ = "bytez"; - public static final String CANOPYWAVE = "canopywave"; - public static final String CEREBRAS = "cerebras"; - public static final String CHATGPT = "chatgpt"; - public static final String CHERRYIN = "cherryin"; - public static final String CHUTES = "chutes"; - public static final String CLARIFAI = "clarifai"; - public static final String CLAUDINIO = "claudinio"; - public static final String CLINEPASS = "cline_pass"; - public static final String CLOSEAI = "closeai"; - public static final String CLOUDFERROSHERLOCK = "cloudferro_sherlock"; - public static final String CLOUDFLARE = "cloudflare"; - public static final String CLOUDFLAREWORKERSAI = "cloudflare_workers_ai"; - public static final String CODESTRAL = "codestral"; - public static final String COMETAPI = "cometapi"; - public static final String COMMANDCODE = "commandcode"; - public static final String COMPACTIFAI = "compactifai"; - public static final String COPILOT = "copilot"; - public static final String CORTECS = "cortecs"; - public static final String COZE = "coze"; - public static final String CROF = "crof"; - public static final String CROSSMODEL = "crossmodel"; - public static final String CRUSOE = "crusoe"; - public static final String DAOXE = "daoxe"; - public static final String DARKBLOOM = "darkbloom"; - public static final String DATABRICKS = "databricks"; - public static final String DATAROBOT = "datarobot"; - public static final String DEEPBRICKS = "deepbricks"; - public static final String DEEPINFRA = "deepinfra"; - public static final String DEEPSEEK = "deepseek"; - public static final String DIGITALOCEAN = "digitalocean"; - public static final String DINFERENCE = "dinference"; - public static final String DOUBAO = "doubao"; - public static final String DOUBLEWORD = "doubleword"; - public static final String DRUN = "drun"; - public static final String EBCLOUD = "ebcloud"; - public static final String EMBERCLOUD = "embercloud"; - public static final String EMPIRIOLABS = "empiriolabs"; - public static final String EVROC = "evroc"; - public static final String FASTCRW = "fastcrw"; - public static final String FASTGPT = "fastgpt"; - public static final String FASTROUTER = "fastrouter"; - public static final String FEATHERLESSAI = "featherless_ai"; - public static final String FIREPASS = "firepass"; - public static final String FIREWORKS = "fireworks"; - public static final String FREEMODEL = "freemodel"; - public static final String FRIENDLIAI = "friendliai"; - public static final String FROGBOT = "frogbot"; - public static final String GALADRIEL = "galadriel"; - public static final String GDC = "gdc"; - public static final String GIGACHAT = "gigachat"; - public static final String GITHUB = "github"; - public static final String GMI = "gmi"; - public static final String GMICLOUD = "gmicloud"; - public static final String GONKA24 = "gonka24"; - public static final String GRADIENTAI = "gradient_ai"; - public static final String GROQ = "groq"; - public static final String HELICONE = "helicone"; - public static final String HEROKU = "heroku"; - public static final String HETZNER = "hetzner"; - public static final String HOSTEDVLLM = "hosted_vllm"; - public static final String HPCAI = "hpc_ai"; - public static final String HYPERBOLIC = "hyperbolic"; - public static final String IFLOWCN = "iflowcn"; - public static final String INCEPTION = "inception"; - public static final String INCEPTRON = "inceptron"; - public static final String INFERENCENET = "inference_net"; - public static final String INFERENCEHUB = "inferencehub"; - public static final String INFERX = "inferx"; - public static final String INFINITY = "infinity"; - public static final String IONET = "io_net"; - public static final String JIEKOU = "jiekou"; - public static final String KENARI = "kenari"; - public static final String KILO = "kilo"; - public static final String KIMI = "kimi"; - public static final String KIMIFORCODING = "kimi_for_coding"; - public static final String KIRO = "kiro"; - public static final String KLUSTERAI = "kluster_ai"; - public static final String KRUTRIM = "krutrim"; - public static final String KUAECLOUDCODINGPLAN = "kuae_cloud_coding_plan"; - public static final String LAMBDAAI = "lambda_ai"; - public static final String LEMONADE = "lemonade"; - public static final String LEMONFOXAI = "lemonfox_ai"; - public static final String LIBERTAI = "libertai"; - public static final String LILAC = "lilac"; - public static final String LINGYIWANWU = "lingyiwanwu"; - public static final String LLAMA = "llama"; - public static final String LLAMAGATE = "llamagate"; - public static final String LLMGATEWAY = "llmgateway"; - public static final String LLMTR = "llmtr"; - public static final String LONGCAT = "longcat"; - public static final String LUCIDQUERY = "lucidquery"; - public static final String LYNKR = "lynkr"; - public static final String MATTERAI = "matterai"; - public static final String MEGANOVA = "meganova"; - public static final String MERGEGATEWAY = "merge_gateway"; - public static final String META = "meta"; - public static final String METALLAMA = "meta_llama"; - public static final String MIMO = "mimo"; - public static final String MINIMAX = "minimax"; - public static final String MINIMAXCN = "minimax_cn"; - public static final String MINIMAXCNCODINGPLAN = "minimax_cn_coding_plan"; - public static final String MINIMAXCODINGPLAN = "minimax_coding_plan"; - public static final String MIRA = "mira"; - public static final String MIXLAYER = "mixlayer"; - public static final String MOARK = "moark"; - public static final String MODAL = "modal"; - public static final String MODELORACLEAI = "model_oracle_ai"; - public static final String MODELSCOPE = "modelscope"; - public static final String MOONSHOTAI = "moonshotai"; - public static final String MOONSHOTAICN = "moonshotai_cn"; - public static final String MORPH = "morph"; - public static final String NANOGPT = "nanogpt"; - public static final String NCOMPASS = "ncompass"; - public static final String NEARAI = "nearai"; - public static final String NEBIUS = "nebius"; - public static final String NEON = "neon"; - public static final String NEURALWATT = "neuralwatt"; - public static final String NEXTBIT = "nextbit"; - public static final String NLPCLOUD = "nlp_cloud"; - public static final String NOUSRESEARCH = "nous_research"; - public static final String NOVITA = "novita"; - public static final String NSCALE = "nscale"; - public static final String NVIDIANIM = "nvidia_nim"; - public static final String OCI = "oci"; - public static final String OFOX = "ofox"; - public static final String OHMYGPT = "ohmygpt"; - public static final String OLLAMACLOUD = "ollama_cloud"; - public static final String OPENAIMAX = "openaimax"; - public static final String OPENAISB = "openaisb"; - public static final String OPENCODE = "opencode"; - public static final String OPENCODEGO = "opencode_go"; - public static final String OPENCODEZEN = "opencode_zen"; - public static final String ORCAROUTER = "orcarouter"; - public static final String OVHCLOUD = "ovhcloud"; - public static final String PARASAIL = "parasail"; - public static final String PERFXCLOUD = "perfxcloud"; - public static final String PERPLEXITY = "perplexity"; - public static final String PERPLEXITYAGENT = "perplexity_agent"; - public static final String PETALS = "petals"; - public static final String PINSTRIPES = "pinstripes"; - public static final String PIONEER = "pioneer"; - public static final String POE = "poe"; - public static final String POOLSIDE = "poolside"; - public static final String PORTKEY = "portkey"; - public static final String PPINFRA = "ppinfra"; - public static final String PREDIBASE = "predibase"; - public static final String PRIVATEMODEAI = "privatemode_ai"; - public static final String PUBLICAI = "publicai"; - public static final String QIHANGAI = "qihang_ai"; - public static final String QIHOO360 = "qihoo360"; - public static final String QINIUAI = "qiniu_ai"; - public static final String REGOLOAI = "regolo_ai"; - public static final String REKAAI = "reka_ai"; - public static final String REQUESTY = "requesty"; - public static final String REVE = "reve"; - public static final String ROUTINGRUN = "routing_run"; - public static final String SAKANA = "sakana"; - public static final String SAMBANOVA = "sambanova"; - public static final String SARVAM = "sarvam"; - public static final String SCALEWAY = "scaleway"; - public static final String SCXAI = "scx_ai"; - public static final String SILICONFLOW = "siliconflow"; - public static final String SNOWFLAKE = "snowflake"; - public static final String SNOWFLAKECORTEX = "snowflake_cortex"; - public static final String STACKIT = "stackit"; - public static final String STEPFUN = "stepfun"; - public static final String STEPFUNAISTEPPLAN = "stepfun_ai_step_plan"; - public static final String STEPFUNSTEPPLAN = "stepfun_step_plan"; - public static final String SUBCONSCIOUS = "subconscious"; - public static final String SUBMODEL = "submodel"; - public static final String SYNTHETIC = "synthetic"; - public static final String TENCENT = "tencent"; - public static final String TENCENTCODINGPLAN = "tencent_coding_plan"; - public static final String TENCENTTOKENPLAN = "tencent_token_plan"; - public static final String TENCENTTOKENPLANENTERPRISEAUTO = "tencent_token_plan_enterprise_auto"; - public static final String TENCENTTOKENPLANENTERPRISEPRO = "tencent_token_plan_enterprise_pro"; - public static final String TENCENTTOKENPLANGENERALPERSONAL = "tencent_token_plan_general_personal"; - public static final String TENCENTTOKENPLANHYPERSONAL = "tencent_token_plan_hy_personal"; - public static final String TENCENTTOKENHUB = "tencent_tokenhub"; - public static final String TENSORMESH = "tensormesh"; - public static final String THEGRIDAI = "the_grid_ai"; - public static final String THINKINGMACHINES = "thinkingmachines"; - public static final String TINFOIL = "tinfoil"; - public static final String TOGETHERAI = "togetherai"; - public static final String TOKENFLUX = "tokenflux"; - public static final String TOKENPONY = "tokenpony"; - public static final String TRUSTEDROUTER = "trustedrouter"; - public static final String TUNDRA = "tundra"; - public static final String UMANSAI = "umans_ai"; - public static final String UNOROUTER = "unorouter"; - public static final String UPSTAGE = "upstage"; - public static final String V0 = "v0"; - public static final String VENICE = "venice"; - public static final String VERCEL = "vercel"; - public static final String VIVGRID = "vivgrid"; - public static final String VOLCENGINE = "volc_engine"; - public static final String VULTR = "vultr"; - public static final String WAFER = "wafer"; - public static final String WANDB = "wandb"; - public static final String XIAOMITOKENPLANAMS = "xiaomi_token_plan_ams"; - public static final String XIAOMITOKENPLANCN = "xiaomi_token_plan_cn"; - public static final String XIAOMITOKENPLANSGP = "xiaomi_token_plan_sgp"; - public static final String XIAOMIMIMO = "xiaomimimo"; - public static final String XPERSONA = "xpersona"; - public static final String XUNFEI = "xunfei"; - public static final String ZAI = "zai"; - public static final String ZAICODINGPLAN = "zai_coding_plan"; - public static final String ZELDOC = "zeldoc"; - public static final String ZENMUX = "zenmux"; - public static final String ZHIPUV4 = "zhipu_v4"; - public static final String ZHIPUAICODINGPLAN = "zhipuai_coding_plan"; -} 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/ModelTest.java b/bindings/java/src/test/java/ai/arcships/aimux/ModelTest.java index 470189df2..0edddeec5 100644 --- a/bindings/java/src/test/java/ai/arcships/aimux/ModelTest.java +++ b/bindings/java/src/test/java/ai/arcships/aimux/ModelTest.java @@ -60,15 +60,6 @@ void providerCreatesModelInstanceFromRegistry() { } } - @Test - void providerCreatesModelWithProviderNameConstant() { - // Recommended typed spelling: ProviderName.GROQ constant (key is - // validated on the first API call, not construction). - try (Model model = Model.provider(ProviderName.GROQ, "sk-test-fake-key", "llama-3.3-70b", null)) { - assertThat(model).isNotNull(); - } - } - // ── generation ────────────────────────────────────────────────────────── @Test 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/ProviderName.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/ProviderName.kt deleted file mode 100644 index ad55711e8..000000000 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/ProviderName.kt +++ /dev/null @@ -1,258 +0,0 @@ -// ProviderName — GENERATED by `scripts/gen_provider_names.py`. -// Do not edit manually; single source of truth is `provider-registry.json`. -package ai.arcships.aimux - -/** Names of all built-in OpenAI-compatible providers. */ -object ProviderName { - const val ABACUS = "abacus" - const val ABLITERATIONAI = "abliteration_ai" - const val AI21 = "ai21" - const val AI302 = "ai302" - const val AIROUTER = "ai_router" - const val AIAND = "aiand" - const val AIBADGR = "aibadgr" - const val AIGC2D = "aigc2d" - const val AIHUBMIX = "aihubmix" - const val AILS = "ails" - const val AIML = "aiml" - const val AKIIO = "aki_io" - const val ALBERT = "albert" - const val ALIBABA = "alibaba" - const val ALIBABACODINGPLAN = "alibaba_coding_plan" - const val ALIBABACODINGPLANCN = "alibaba_coding_plan_cn" - const val ALIBABATOKENPLAN = "alibaba_token_plan" - const val ALIBABATOKENPLANCN = "alibaba_token_plan_cn" - const val AMBIENT = "ambient" - const val ANYAPI = "anyapi" - const val ANYSCALE = "anyscale" - const val APERTIS = "apertis" - const val API2D = "api2d" - const val API2GPT = "api2gpt" - const val APISERPENT = "apiserpent" - const val ATLASCLOUD = "atlascloud" - const val ATOMICCHAT = "atomic_chat" - const val AURIKO = "auriko" - const val AZUREAI = "azure_ai" - const val BAICHUAN = "baichuan" - const val BAIDU = "baidu" - const val BAIDUV2 = "baidu_v2" - const val BAILING = "bailing" - const val BASETEN = "baseten" - const val BERGET = "berget" - const val BIGMODEL = "bigmodel" - const val BLUECLAW = "blueclaw" - const val BYTEDANCE = "bytedance" - const val BYTEPLUS = "byteplus" - const val BYTEZ = "bytez" - const val CANOPYWAVE = "canopywave" - const val CEREBRAS = "cerebras" - const val CHATGPT = "chatgpt" - const val CHERRYIN = "cherryin" - const val CHUTES = "chutes" - const val CLARIFAI = "clarifai" - const val CLAUDINIO = "claudinio" - const val CLINEPASS = "cline_pass" - const val CLOSEAI = "closeai" - const val CLOUDFERROSHERLOCK = "cloudferro_sherlock" - const val CLOUDFLARE = "cloudflare" - const val CLOUDFLAREWORKERSAI = "cloudflare_workers_ai" - const val CODESTRAL = "codestral" - const val COMETAPI = "cometapi" - const val COMMANDCODE = "commandcode" - const val COMPACTIFAI = "compactifai" - const val COPILOT = "copilot" - const val CORTECS = "cortecs" - const val COZE = "coze" - const val CROF = "crof" - const val CROSSMODEL = "crossmodel" - const val CRUSOE = "crusoe" - const val DAOXE = "daoxe" - const val DARKBLOOM = "darkbloom" - const val DATABRICKS = "databricks" - const val DATAROBOT = "datarobot" - const val DEEPBRICKS = "deepbricks" - const val DEEPINFRA = "deepinfra" - const val DEEPSEEK = "deepseek" - const val DIGITALOCEAN = "digitalocean" - const val DINFERENCE = "dinference" - const val DOUBAO = "doubao" - const val DOUBLEWORD = "doubleword" - const val DRUN = "drun" - const val EBCLOUD = "ebcloud" - const val EMBERCLOUD = "embercloud" - const val EMPIRIOLABS = "empiriolabs" - const val EVROC = "evroc" - const val FASTCRW = "fastcrw" - const val FASTGPT = "fastgpt" - const val FASTROUTER = "fastrouter" - const val FEATHERLESSAI = "featherless_ai" - const val FIREPASS = "firepass" - const val FIREWORKS = "fireworks" - const val FREEMODEL = "freemodel" - const val FRIENDLIAI = "friendliai" - const val FROGBOT = "frogbot" - const val GALADRIEL = "galadriel" - const val GDC = "gdc" - const val GIGACHAT = "gigachat" - const val GITHUB = "github" - const val GMI = "gmi" - const val GMICLOUD = "gmicloud" - const val GONKA24 = "gonka24" - const val GRADIENTAI = "gradient_ai" - const val GROQ = "groq" - const val HELICONE = "helicone" - const val HEROKU = "heroku" - const val HETZNER = "hetzner" - const val HOSTEDVLLM = "hosted_vllm" - const val HPCAI = "hpc_ai" - const val HYPERBOLIC = "hyperbolic" - const val IFLOWCN = "iflowcn" - const val INCEPTION = "inception" - const val INCEPTRON = "inceptron" - const val INFERENCENET = "inference_net" - const val INFERENCEHUB = "inferencehub" - const val INFERX = "inferx" - const val INFINITY = "infinity" - const val IONET = "io_net" - const val JIEKOU = "jiekou" - const val KENARI = "kenari" - const val KILO = "kilo" - const val KIMI = "kimi" - const val KIMIFORCODING = "kimi_for_coding" - const val KIRO = "kiro" - const val KLUSTERAI = "kluster_ai" - const val KRUTRIM = "krutrim" - const val KUAECLOUDCODINGPLAN = "kuae_cloud_coding_plan" - const val LAMBDAAI = "lambda_ai" - const val LEMONADE = "lemonade" - const val LEMONFOXAI = "lemonfox_ai" - const val LIBERTAI = "libertai" - const val LILAC = "lilac" - const val LINGYIWANWU = "lingyiwanwu" - const val LLAMA = "llama" - const val LLAMAGATE = "llamagate" - const val LLMGATEWAY = "llmgateway" - const val LLMTR = "llmtr" - const val LONGCAT = "longcat" - const val LUCIDQUERY = "lucidquery" - const val LYNKR = "lynkr" - const val MATTERAI = "matterai" - const val MEGANOVA = "meganova" - const val MERGEGATEWAY = "merge_gateway" - const val META = "meta" - const val METALLAMA = "meta_llama" - const val MIMO = "mimo" - const val MINIMAX = "minimax" - const val MINIMAXCN = "minimax_cn" - const val MINIMAXCNCODINGPLAN = "minimax_cn_coding_plan" - const val MINIMAXCODINGPLAN = "minimax_coding_plan" - const val MIRA = "mira" - const val MIXLAYER = "mixlayer" - const val MOARK = "moark" - const val MODAL = "modal" - const val MODELORACLEAI = "model_oracle_ai" - const val MODELSCOPE = "modelscope" - const val MOONSHOTAI = "moonshotai" - const val MOONSHOTAICN = "moonshotai_cn" - const val MORPH = "morph" - const val NANOGPT = "nanogpt" - const val NCOMPASS = "ncompass" - const val NEARAI = "nearai" - const val NEBIUS = "nebius" - const val NEON = "neon" - const val NEURALWATT = "neuralwatt" - const val NEXTBIT = "nextbit" - const val NLPCLOUD = "nlp_cloud" - const val NOUSRESEARCH = "nous_research" - const val NOVITA = "novita" - const val NSCALE = "nscale" - const val NVIDIANIM = "nvidia_nim" - const val OCI = "oci" - const val OFOX = "ofox" - const val OHMYGPT = "ohmygpt" - const val OLLAMACLOUD = "ollama_cloud" - const val OPENAIMAX = "openaimax" - const val OPENAISB = "openaisb" - const val OPENCODE = "opencode" - const val OPENCODEGO = "opencode_go" - const val OPENCODEZEN = "opencode_zen" - const val ORCAROUTER = "orcarouter" - const val OVHCLOUD = "ovhcloud" - const val PARASAIL = "parasail" - const val PERFXCLOUD = "perfxcloud" - const val PERPLEXITY = "perplexity" - const val PERPLEXITYAGENT = "perplexity_agent" - const val PETALS = "petals" - const val PINSTRIPES = "pinstripes" - const val PIONEER = "pioneer" - const val POE = "poe" - const val POOLSIDE = "poolside" - const val PORTKEY = "portkey" - const val PPINFRA = "ppinfra" - const val PREDIBASE = "predibase" - const val PRIVATEMODEAI = "privatemode_ai" - const val PUBLICAI = "publicai" - const val QIHANGAI = "qihang_ai" - const val QIHOO360 = "qihoo360" - const val QINIUAI = "qiniu_ai" - const val REGOLOAI = "regolo_ai" - const val REKAAI = "reka_ai" - const val REQUESTY = "requesty" - const val REVE = "reve" - const val ROUTINGRUN = "routing_run" - const val SAKANA = "sakana" - const val SAMBANOVA = "sambanova" - const val SARVAM = "sarvam" - const val SCALEWAY = "scaleway" - const val SCXAI = "scx_ai" - const val SILICONFLOW = "siliconflow" - const val SNOWFLAKE = "snowflake" - const val SNOWFLAKECORTEX = "snowflake_cortex" - const val STACKIT = "stackit" - const val STEPFUN = "stepfun" - const val STEPFUNAISTEPPLAN = "stepfun_ai_step_plan" - const val STEPFUNSTEPPLAN = "stepfun_step_plan" - const val SUBCONSCIOUS = "subconscious" - const val SUBMODEL = "submodel" - const val SYNTHETIC = "synthetic" - const val TENCENT = "tencent" - const val TENCENTCODINGPLAN = "tencent_coding_plan" - const val TENCENTTOKENPLAN = "tencent_token_plan" - const val TENCENTTOKENPLANENTERPRISEAUTO = "tencent_token_plan_enterprise_auto" - const val TENCENTTOKENPLANENTERPRISEPRO = "tencent_token_plan_enterprise_pro" - const val TENCENTTOKENPLANGENERALPERSONAL = "tencent_token_plan_general_personal" - const val TENCENTTOKENPLANHYPERSONAL = "tencent_token_plan_hy_personal" - const val TENCENTTOKENHUB = "tencent_tokenhub" - const val TENSORMESH = "tensormesh" - const val THEGRIDAI = "the_grid_ai" - const val THINKINGMACHINES = "thinkingmachines" - const val TINFOIL = "tinfoil" - const val TOGETHERAI = "togetherai" - const val TOKENFLUX = "tokenflux" - const val TOKENPONY = "tokenpony" - const val TRUSTEDROUTER = "trustedrouter" - const val TUNDRA = "tundra" - const val UMANSAI = "umans_ai" - const val UNOROUTER = "unorouter" - const val UPSTAGE = "upstage" - const val V0 = "v0" - const val VENICE = "venice" - const val VERCEL = "vercel" - const val VIVGRID = "vivgrid" - const val VOLCENGINE = "volc_engine" - const val VULTR = "vultr" - const val WAFER = "wafer" - const val WANDB = "wandb" - const val XIAOMITOKENPLANAMS = "xiaomi_token_plan_ams" - const val XIAOMITOKENPLANCN = "xiaomi_token_plan_cn" - const val XIAOMITOKENPLANSGP = "xiaomi_token_plan_sgp" - const val XIAOMIMIMO = "xiaomimimo" - const val XPERSONA = "xpersona" - const val XUNFEI = "xunfei" - const val ZAI = "zai" - const val ZAICODINGPLAN = "zai_coding_plan" - const val ZELDOC = "zeldoc" - const val ZENMUX = "zenmux" - const val ZHIPUV4 = "zhipu_v4" - const val ZHIPUAICODINGPLAN = "zhipuai_coding_plan" -} 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/ModelTest.kt b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ModelTest.kt index 33bfe2e85..61aa7d1d3 100644 --- a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ModelTest.kt +++ b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ModelTest.kt @@ -36,15 +36,6 @@ class ModelTest { } } - @Test - fun `provider creates model with ProviderName constant (recommended)`() { - // Recommended typed spelling: ProviderName.GROQ constant (key is - // validated on the first API call, not construction). - Model.provider(name = ProviderName.GROQ, apiKey = "sk-test-fake-key", modelId = "llama-3.3-70b").use { model -> - assertThat(model).isNotNull - } - } - @Test fun `generateText rejects malformed prompt JSON with IllegalArgumentException`() { Model.openai("sk-test-fake-key", "gpt-4o-mini").use { model -> 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/package.json b/bindings/node/package.json index 6df016099..30ce4df5f 100644 --- a/bindings/node/package.json +++ b/bindings/node/package.json @@ -40,7 +40,7 @@ "scripts": { "build": "napi build --platform --release", "build:debug": "napi build --platform", - "build:typed": "tsc -p tsconfig.json", + "build:typed": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json", "test": "ava", "prepublishOnly": "npm run build:typed && napi prepublish -t npm --no-gh-release" }, diff --git a/bindings/node/src/index.ts b/bindings/node/src/index.ts index 17694d3ea..38505815e 100644 --- a/bindings/node/src/index.ts +++ b/bindings/node/src/index.ts @@ -131,11 +131,6 @@ export { // `AimuxResult` implementation type. export const recordingTryFlush: () => void = native.recordingTryFlush export const initRecording: (dir: string) => void = native.initRecording -// Both meanings: the `ProviderName` const object (runtime, for `ProviderName.groq`) -// and the derived string-union type. A value export resolves at runtime, so the -// specifier needs the real `.ts` extension for Node's type-stripping test runs; -// tsc rewrites it to `.js` on emit (rewriteRelativeImportExtensions). -export { ProviderName } from './types/ProviderName.ts' // Public type surface — typed objects, no `any`. export type { diff --git a/bindings/node/src/types/ProviderName.ts b/bindings/node/src/types/ProviderName.ts deleted file mode 100644 index 34fccb295..000000000 --- a/bindings/node/src/types/ProviderName.ts +++ /dev/null @@ -1,262 +0,0 @@ -// ProviderName — GENERATED by `scripts/gen_provider_names.py`. -// Do not edit manually; single source of truth is `provider-registry.json`. - -/** - * Names of all built-in OpenAI-compatible providers. - * Use `ProviderName.groq` etc. as the `name` argument of `provider()`. - */ -export const ProviderName = { - abacus: 'abacus', - abliteration_ai: 'abliteration_ai', - ai21: 'ai21', - ai302: 'ai302', - ai_router: 'ai_router', - aiand: 'aiand', - aibadgr: 'aibadgr', - aigc2d: 'aigc2d', - aihubmix: 'aihubmix', - ails: 'ails', - aiml: 'aiml', - aki_io: 'aki_io', - albert: 'albert', - alibaba: 'alibaba', - alibaba_coding_plan: 'alibaba_coding_plan', - alibaba_coding_plan_cn: 'alibaba_coding_plan_cn', - alibaba_token_plan: 'alibaba_token_plan', - alibaba_token_plan_cn: 'alibaba_token_plan_cn', - ambient: 'ambient', - anyapi: 'anyapi', - anyscale: 'anyscale', - apertis: 'apertis', - api2d: 'api2d', - api2gpt: 'api2gpt', - apiserpent: 'apiserpent', - atlascloud: 'atlascloud', - atomic_chat: 'atomic_chat', - auriko: 'auriko', - azure_ai: 'azure_ai', - baichuan: 'baichuan', - baidu: 'baidu', - baidu_v2: 'baidu_v2', - bailing: 'bailing', - baseten: 'baseten', - berget: 'berget', - bigmodel: 'bigmodel', - blueclaw: 'blueclaw', - bytedance: 'bytedance', - byteplus: 'byteplus', - bytez: 'bytez', - canopywave: 'canopywave', - cerebras: 'cerebras', - chatgpt: 'chatgpt', - cherryin: 'cherryin', - chutes: 'chutes', - clarifai: 'clarifai', - claudinio: 'claudinio', - cline_pass: 'cline_pass', - closeai: 'closeai', - cloudferro_sherlock: 'cloudferro_sherlock', - cloudflare: 'cloudflare', - cloudflare_workers_ai: 'cloudflare_workers_ai', - codestral: 'codestral', - cometapi: 'cometapi', - commandcode: 'commandcode', - compactifai: 'compactifai', - copilot: 'copilot', - cortecs: 'cortecs', - coze: 'coze', - crof: 'crof', - crossmodel: 'crossmodel', - crusoe: 'crusoe', - daoxe: 'daoxe', - darkbloom: 'darkbloom', - databricks: 'databricks', - datarobot: 'datarobot', - deepbricks: 'deepbricks', - deepinfra: 'deepinfra', - deepseek: 'deepseek', - digitalocean: 'digitalocean', - dinference: 'dinference', - doubao: 'doubao', - doubleword: 'doubleword', - drun: 'drun', - ebcloud: 'ebcloud', - embercloud: 'embercloud', - empiriolabs: 'empiriolabs', - evroc: 'evroc', - fastcrw: 'fastcrw', - fastgpt: 'fastgpt', - fastrouter: 'fastrouter', - featherless_ai: 'featherless_ai', - firepass: 'firepass', - fireworks: 'fireworks', - freemodel: 'freemodel', - friendliai: 'friendliai', - frogbot: 'frogbot', - galadriel: 'galadriel', - gdc: 'gdc', - gigachat: 'gigachat', - github: 'github', - gmi: 'gmi', - gmicloud: 'gmicloud', - gonka24: 'gonka24', - gradient_ai: 'gradient_ai', - groq: 'groq', - helicone: 'helicone', - heroku: 'heroku', - hetzner: 'hetzner', - hosted_vllm: 'hosted_vllm', - hpc_ai: 'hpc_ai', - hyperbolic: 'hyperbolic', - iflowcn: 'iflowcn', - inception: 'inception', - inceptron: 'inceptron', - inference_net: 'inference_net', - inferencehub: 'inferencehub', - inferx: 'inferx', - infinity: 'infinity', - io_net: 'io_net', - jiekou: 'jiekou', - kenari: 'kenari', - kilo: 'kilo', - kimi: 'kimi', - kimi_for_coding: 'kimi_for_coding', - kiro: 'kiro', - kluster_ai: 'kluster_ai', - krutrim: 'krutrim', - kuae_cloud_coding_plan: 'kuae_cloud_coding_plan', - lambda_ai: 'lambda_ai', - lemonade: 'lemonade', - lemonfox_ai: 'lemonfox_ai', - libertai: 'libertai', - lilac: 'lilac', - lingyiwanwu: 'lingyiwanwu', - llama: 'llama', - llamagate: 'llamagate', - llmgateway: 'llmgateway', - llmtr: 'llmtr', - longcat: 'longcat', - lucidquery: 'lucidquery', - lynkr: 'lynkr', - matterai: 'matterai', - meganova: 'meganova', - merge_gateway: 'merge_gateway', - meta: 'meta', - meta_llama: 'meta_llama', - mimo: 'mimo', - minimax: 'minimax', - minimax_cn: 'minimax_cn', - minimax_cn_coding_plan: 'minimax_cn_coding_plan', - minimax_coding_plan: 'minimax_coding_plan', - mira: 'mira', - mixlayer: 'mixlayer', - moark: 'moark', - modal: 'modal', - model_oracle_ai: 'model_oracle_ai', - modelscope: 'modelscope', - moonshotai: 'moonshotai', - moonshotai_cn: 'moonshotai_cn', - morph: 'morph', - nanogpt: 'nanogpt', - ncompass: 'ncompass', - nearai: 'nearai', - nebius: 'nebius', - neon: 'neon', - neuralwatt: 'neuralwatt', - nextbit: 'nextbit', - nlp_cloud: 'nlp_cloud', - nous_research: 'nous_research', - novita: 'novita', - nscale: 'nscale', - nvidia_nim: 'nvidia_nim', - oci: 'oci', - ofox: 'ofox', - ohmygpt: 'ohmygpt', - ollama_cloud: 'ollama_cloud', - openaimax: 'openaimax', - openaisb: 'openaisb', - opencode: 'opencode', - opencode_go: 'opencode_go', - opencode_zen: 'opencode_zen', - orcarouter: 'orcarouter', - ovhcloud: 'ovhcloud', - parasail: 'parasail', - perfxcloud: 'perfxcloud', - perplexity: 'perplexity', - perplexity_agent: 'perplexity_agent', - petals: 'petals', - pinstripes: 'pinstripes', - pioneer: 'pioneer', - poe: 'poe', - poolside: 'poolside', - portkey: 'portkey', - ppinfra: 'ppinfra', - predibase: 'predibase', - privatemode_ai: 'privatemode_ai', - publicai: 'publicai', - qihang_ai: 'qihang_ai', - qihoo360: 'qihoo360', - qiniu_ai: 'qiniu_ai', - regolo_ai: 'regolo_ai', - reka_ai: 'reka_ai', - requesty: 'requesty', - reve: 'reve', - routing_run: 'routing_run', - sakana: 'sakana', - sambanova: 'sambanova', - sarvam: 'sarvam', - scaleway: 'scaleway', - scx_ai: 'scx_ai', - siliconflow: 'siliconflow', - snowflake: 'snowflake', - snowflake_cortex: 'snowflake_cortex', - stackit: 'stackit', - stepfun: 'stepfun', - stepfun_ai_step_plan: 'stepfun_ai_step_plan', - stepfun_step_plan: 'stepfun_step_plan', - subconscious: 'subconscious', - submodel: 'submodel', - synthetic: 'synthetic', - tencent: 'tencent', - tencent_coding_plan: 'tencent_coding_plan', - tencent_token_plan: 'tencent_token_plan', - tencent_token_plan_enterprise_auto: 'tencent_token_plan_enterprise_auto', - tencent_token_plan_enterprise_pro: 'tencent_token_plan_enterprise_pro', - tencent_token_plan_general_personal: 'tencent_token_plan_general_personal', - tencent_token_plan_hy_personal: 'tencent_token_plan_hy_personal', - tencent_tokenhub: 'tencent_tokenhub', - tensormesh: 'tensormesh', - the_grid_ai: 'the_grid_ai', - thinkingmachines: 'thinkingmachines', - tinfoil: 'tinfoil', - togetherai: 'togetherai', - tokenflux: 'tokenflux', - tokenpony: 'tokenpony', - trustedrouter: 'trustedrouter', - tundra: 'tundra', - umans_ai: 'umans_ai', - unorouter: 'unorouter', - upstage: 'upstage', - v0: 'v0', - venice: 'venice', - vercel: 'vercel', - vivgrid: 'vivgrid', - volc_engine: 'volc_engine', - vultr: 'vultr', - wafer: 'wafer', - wandb: 'wandb', - xiaomi_token_plan_ams: 'xiaomi_token_plan_ams', - xiaomi_token_plan_cn: 'xiaomi_token_plan_cn', - xiaomi_token_plan_sgp: 'xiaomi_token_plan_sgp', - xiaomimimo: 'xiaomimimo', - xpersona: 'xpersona', - xunfei: 'xunfei', - zai: 'zai', - zai_coding_plan: 'zai_coding_plan', - zeldoc: 'zeldoc', - zenmux: 'zenmux', - zhipu_v4: 'zhipu_v4', - zhipuai_coding_plan: 'zhipuai_coding_plan', -} as const - -export type ProviderName = typeof ProviderName[keyof typeof ProviderName] diff --git a/bindings/python/python/aimux/provider_name.py b/bindings/python/python/aimux/provider_name.py deleted file mode 100644 index ed4e18356..000000000 --- a/bindings/python/python/aimux/provider_name.py +++ /dev/null @@ -1,8 +0,0 @@ -"""ProviderName — GENERATED by `scripts/gen_provider_names.py`. - -Do not edit manually; single source of truth is `provider-registry.json`. -""" - -from typing import Literal - -ProviderName = Literal['abacus', 'abliteration_ai', 'ai21', 'ai302', 'ai_router', 'aiand', 'aibadgr', 'aigc2d', 'aihubmix', 'ails', 'aiml', 'aki_io', 'albert', 'alibaba', 'alibaba_coding_plan', 'alibaba_coding_plan_cn', 'alibaba_token_plan', 'alibaba_token_plan_cn', 'ambient', 'anyapi', 'anyscale', 'apertis', 'api2d', 'api2gpt', 'apiserpent', 'atlascloud', 'atomic_chat', 'auriko', 'azure_ai', 'baichuan', 'baidu', 'baidu_v2', 'bailing', 'baseten', 'berget', 'bigmodel', 'blueclaw', 'bytedance', 'byteplus', 'bytez', 'canopywave', 'cerebras', 'chatgpt', 'cherryin', 'chutes', 'clarifai', 'claudinio', 'cline_pass', 'closeai', 'cloudferro_sherlock', 'cloudflare', 'cloudflare_workers_ai', 'codestral', 'cometapi', 'commandcode', 'compactifai', 'copilot', 'cortecs', 'coze', 'crof', 'crossmodel', 'crusoe', 'daoxe', 'darkbloom', 'databricks', 'datarobot', 'deepbricks', 'deepinfra', 'deepseek', 'digitalocean', 'dinference', 'doubao', 'doubleword', 'drun', 'ebcloud', 'embercloud', 'empiriolabs', 'evroc', 'fastcrw', 'fastgpt', 'fastrouter', 'featherless_ai', 'firepass', 'fireworks', 'freemodel', 'friendliai', 'frogbot', 'galadriel', 'gdc', 'gigachat', 'github', 'gmi', 'gmicloud', 'gonka24', 'gradient_ai', 'groq', 'helicone', 'heroku', 'hetzner', 'hosted_vllm', 'hpc_ai', 'hyperbolic', 'iflowcn', 'inception', 'inceptron', 'inference_net', 'inferencehub', 'inferx', 'infinity', 'io_net', 'jiekou', 'kenari', 'kilo', 'kimi', 'kimi_for_coding', 'kiro', 'kluster_ai', 'krutrim', 'kuae_cloud_coding_plan', 'lambda_ai', 'lemonade', 'lemonfox_ai', 'libertai', 'lilac', 'lingyiwanwu', 'llama', 'llamagate', 'llmgateway', 'llmtr', 'longcat', 'lucidquery', 'lynkr', 'matterai', 'meganova', 'merge_gateway', 'meta', 'meta_llama', 'mimo', 'minimax', 'minimax_cn', 'minimax_cn_coding_plan', 'minimax_coding_plan', 'mira', 'mixlayer', 'moark', 'modal', 'model_oracle_ai', 'modelscope', 'moonshotai', 'moonshotai_cn', 'morph', 'nanogpt', 'ncompass', 'nearai', 'nebius', 'neon', 'neuralwatt', 'nextbit', 'nlp_cloud', 'nous_research', 'novita', 'nscale', 'nvidia_nim', 'oci', 'ofox', 'ohmygpt', 'ollama_cloud', 'openaimax', 'openaisb', 'opencode', 'opencode_go', 'opencode_zen', 'orcarouter', 'ovhcloud', 'parasail', 'perfxcloud', 'perplexity', 'perplexity_agent', 'petals', 'pinstripes', 'pioneer', 'poe', 'poolside', 'portkey', 'ppinfra', 'predibase', 'privatemode_ai', 'publicai', 'qihang_ai', 'qihoo360', 'qiniu_ai', 'regolo_ai', 'reka_ai', 'requesty', 'reve', 'routing_run', 'sakana', 'sambanova', 'sarvam', 'scaleway', 'scx_ai', 'siliconflow', 'snowflake', 'snowflake_cortex', 'stackit', 'stepfun', 'stepfun_ai_step_plan', 'stepfun_step_plan', 'subconscious', 'submodel', 'synthetic', 'tencent', 'tencent_coding_plan', 'tencent_token_plan', 'tencent_token_plan_enterprise_auto', 'tencent_token_plan_enterprise_pro', 'tencent_token_plan_general_personal', 'tencent_token_plan_hy_personal', 'tencent_tokenhub', 'tensormesh', 'the_grid_ai', 'thinkingmachines', 'tinfoil', 'togetherai', 'tokenflux', 'tokenpony', 'trustedrouter', 'tundra', 'umans_ai', 'unorouter', 'upstage', 'v0', 'venice', 'vercel', 'vivgrid', 'volc_engine', 'vultr', 'wafer', 'wandb', 'xiaomi_token_plan_ams', 'xiaomi_token_plan_cn', 'xiaomi_token_plan_sgp', 'xiaomimimo', 'xpersona', 'xunfei', 'zai', 'zai_coding_plan', 'zeldoc', 'zenmux', 'zhipu_v4', 'zhipuai_coding_plan'] 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/ProviderName.swift b/bindings/swift/Sources/Aimux/ProviderName.swift deleted file mode 100644 index 5ac314b03..000000000 --- a/bindings/swift/Sources/Aimux/ProviderName.swift +++ /dev/null @@ -1,257 +0,0 @@ -// ProviderName — GENERATED by `scripts/gen_provider_names.py`. -// Do not edit manually; single source of truth is `provider-registry.json`. - -/// Names of all built-in OpenAI-compatible providers. -public enum ProviderName: String, CaseIterable, Sendable { - case Abacus = "abacus" - case AbliterationAi = "abliteration_ai" - case Ai21 = "ai21" - case Ai302 = "ai302" - case AiRouter = "ai_router" - case Aiand = "aiand" - case Aibadgr = "aibadgr" - case Aigc2d = "aigc2d" - case Aihubmix = "aihubmix" - case Ails = "ails" - case Aiml = "aiml" - case AkiIo = "aki_io" - case Albert = "albert" - case Alibaba = "alibaba" - case AlibabaCodingPlan = "alibaba_coding_plan" - case AlibabaCodingPlanCn = "alibaba_coding_plan_cn" - case AlibabaTokenPlan = "alibaba_token_plan" - case AlibabaTokenPlanCn = "alibaba_token_plan_cn" - case Ambient = "ambient" - case Anyapi = "anyapi" - case Anyscale = "anyscale" - case Apertis = "apertis" - case Api2d = "api2d" - case Api2gpt = "api2gpt" - case Apiserpent = "apiserpent" - case Atlascloud = "atlascloud" - case AtomicChat = "atomic_chat" - case Auriko = "auriko" - case AzureAi = "azure_ai" - case Baichuan = "baichuan" - case Baidu = "baidu" - case BaiduV2 = "baidu_v2" - case Bailing = "bailing" - case Baseten = "baseten" - case Berget = "berget" - case Bigmodel = "bigmodel" - case Blueclaw = "blueclaw" - case Bytedance = "bytedance" - case Byteplus = "byteplus" - case Bytez = "bytez" - case Canopywave = "canopywave" - case Cerebras = "cerebras" - case Chatgpt = "chatgpt" - case Cherryin = "cherryin" - case Chutes = "chutes" - case Clarifai = "clarifai" - case Claudinio = "claudinio" - case ClinePass = "cline_pass" - case Closeai = "closeai" - case CloudferroSherlock = "cloudferro_sherlock" - case Cloudflare = "cloudflare" - case CloudflareWorkersAi = "cloudflare_workers_ai" - case Codestral = "codestral" - case Cometapi = "cometapi" - case Commandcode = "commandcode" - case Compactifai = "compactifai" - case Copilot = "copilot" - case Cortecs = "cortecs" - case Coze = "coze" - case Crof = "crof" - case Crossmodel = "crossmodel" - case Crusoe = "crusoe" - case Daoxe = "daoxe" - case Darkbloom = "darkbloom" - case Databricks = "databricks" - case Datarobot = "datarobot" - case Deepbricks = "deepbricks" - case Deepinfra = "deepinfra" - case Deepseek = "deepseek" - case Digitalocean = "digitalocean" - case Dinference = "dinference" - case Doubao = "doubao" - case Doubleword = "doubleword" - case Drun = "drun" - case Ebcloud = "ebcloud" - case Embercloud = "embercloud" - case Empiriolabs = "empiriolabs" - case Evroc = "evroc" - case Fastcrw = "fastcrw" - case Fastgpt = "fastgpt" - case Fastrouter = "fastrouter" - case FeatherlessAi = "featherless_ai" - case Firepass = "firepass" - case Fireworks = "fireworks" - case Freemodel = "freemodel" - case Friendliai = "friendliai" - case Frogbot = "frogbot" - case Galadriel = "galadriel" - case Gdc = "gdc" - case Gigachat = "gigachat" - case Github = "github" - case Gmi = "gmi" - case Gmicloud = "gmicloud" - case Gonka24 = "gonka24" - case GradientAi = "gradient_ai" - case Groq = "groq" - case Helicone = "helicone" - case Heroku = "heroku" - case Hetzner = "hetzner" - case HostedVllm = "hosted_vllm" - case HpcAi = "hpc_ai" - case Hyperbolic = "hyperbolic" - case Iflowcn = "iflowcn" - case Inception = "inception" - case Inceptron = "inceptron" - case InferenceNet = "inference_net" - case Inferencehub = "inferencehub" - case Inferx = "inferx" - case Infinity = "infinity" - case IoNet = "io_net" - case Jiekou = "jiekou" - case Kenari = "kenari" - case Kilo = "kilo" - case Kimi = "kimi" - case KimiForCoding = "kimi_for_coding" - case Kiro = "kiro" - case KlusterAi = "kluster_ai" - case Krutrim = "krutrim" - case KuaeCloudCodingPlan = "kuae_cloud_coding_plan" - case LambdaAi = "lambda_ai" - case Lemonade = "lemonade" - case LemonfoxAi = "lemonfox_ai" - case Libertai = "libertai" - case Lilac = "lilac" - case Lingyiwanwu = "lingyiwanwu" - case Llama = "llama" - case Llamagate = "llamagate" - case Llmgateway = "llmgateway" - case Llmtr = "llmtr" - case Longcat = "longcat" - case Lucidquery = "lucidquery" - case Lynkr = "lynkr" - case Matterai = "matterai" - case Meganova = "meganova" - case MergeGateway = "merge_gateway" - case Meta = "meta" - case MetaLlama = "meta_llama" - case Mimo = "mimo" - case Minimax = "minimax" - case MinimaxCn = "minimax_cn" - case MinimaxCnCodingPlan = "minimax_cn_coding_plan" - case MinimaxCodingPlan = "minimax_coding_plan" - case Mira = "mira" - case Mixlayer = "mixlayer" - case Moark = "moark" - case Modal = "modal" - case ModelOracleAi = "model_oracle_ai" - case Modelscope = "modelscope" - case Moonshotai = "moonshotai" - case MoonshotaiCn = "moonshotai_cn" - case Morph = "morph" - case Nanogpt = "nanogpt" - case Ncompass = "ncompass" - case Nearai = "nearai" - case Nebius = "nebius" - case Neon = "neon" - case Neuralwatt = "neuralwatt" - case Nextbit = "nextbit" - case NlpCloud = "nlp_cloud" - case NousResearch = "nous_research" - case Novita = "novita" - case Nscale = "nscale" - case NvidiaNim = "nvidia_nim" - case Oci = "oci" - case Ofox = "ofox" - case Ohmygpt = "ohmygpt" - case OllamaCloud = "ollama_cloud" - case Openaimax = "openaimax" - case Openaisb = "openaisb" - case Opencode = "opencode" - case OpencodeGo = "opencode_go" - case OpencodeZen = "opencode_zen" - case Orcarouter = "orcarouter" - case Ovhcloud = "ovhcloud" - case Parasail = "parasail" - case Perfxcloud = "perfxcloud" - case Perplexity = "perplexity" - case PerplexityAgent = "perplexity_agent" - case Petals = "petals" - case Pinstripes = "pinstripes" - case Pioneer = "pioneer" - case Poe = "poe" - case Poolside = "poolside" - case Portkey = "portkey" - case Ppinfra = "ppinfra" - case Predibase = "predibase" - case PrivatemodeAi = "privatemode_ai" - case Publicai = "publicai" - case QihangAi = "qihang_ai" - case Qihoo360 = "qihoo360" - case QiniuAi = "qiniu_ai" - case RegoloAi = "regolo_ai" - case RekaAi = "reka_ai" - case Requesty = "requesty" - case Reve = "reve" - case RoutingRun = "routing_run" - case Sakana = "sakana" - case Sambanova = "sambanova" - case Sarvam = "sarvam" - case Scaleway = "scaleway" - case ScxAi = "scx_ai" - case Siliconflow = "siliconflow" - case Snowflake = "snowflake" - case SnowflakeCortex = "snowflake_cortex" - case Stackit = "stackit" - case Stepfun = "stepfun" - case StepfunAiStepPlan = "stepfun_ai_step_plan" - case StepfunStepPlan = "stepfun_step_plan" - case Subconscious = "subconscious" - case Submodel = "submodel" - case Synthetic = "synthetic" - case Tencent = "tencent" - case TencentCodingPlan = "tencent_coding_plan" - case TencentTokenPlan = "tencent_token_plan" - case TencentTokenPlanEnterpriseAuto = "tencent_token_plan_enterprise_auto" - case TencentTokenPlanEnterprisePro = "tencent_token_plan_enterprise_pro" - case TencentTokenPlanGeneralPersonal = "tencent_token_plan_general_personal" - case TencentTokenPlanHyPersonal = "tencent_token_plan_hy_personal" - case TencentTokenhub = "tencent_tokenhub" - case Tensormesh = "tensormesh" - case TheGridAi = "the_grid_ai" - case Thinkingmachines = "thinkingmachines" - case Tinfoil = "tinfoil" - case Togetherai = "togetherai" - case Tokenflux = "tokenflux" - case Tokenpony = "tokenpony" - case Trustedrouter = "trustedrouter" - case Tundra = "tundra" - case UmansAi = "umans_ai" - case Unorouter = "unorouter" - case Upstage = "upstage" - case V0 = "v0" - case Venice = "venice" - case Vercel = "vercel" - case Vivgrid = "vivgrid" - case VolcEngine = "volc_engine" - case Vultr = "vultr" - case Wafer = "wafer" - case Wandb = "wandb" - case XiaomiTokenPlanAms = "xiaomi_token_plan_ams" - case XiaomiTokenPlanCn = "xiaomi_token_plan_cn" - case XiaomiTokenPlanSgp = "xiaomi_token_plan_sgp" - case Xiaomimimo = "xiaomimimo" - case Xpersona = "xpersona" - case Xunfei = "xunfei" - case Zai = "zai" - case ZaiCodingPlan = "zai_coding_plan" - case Zeldoc = "zeldoc" - case Zenmux = "zenmux" - case ZhipuV4 = "zhipu_v4" - case ZhipuaiCodingPlan = "zhipuai_coding_plan" -} diff --git a/bindings/swift/Tests/AimuxTests/AimuxTests.swift b/bindings/swift/Tests/AimuxTests/AimuxTests.swift index 325d48225..1753dce86 100644 --- a/bindings/swift/Tests/AimuxTests/AimuxTests.swift +++ b/bindings/swift/Tests/AimuxTests/AimuxTests.swift @@ -28,15 +28,6 @@ final class AimuxTests: XCTestCase { XCTAssertNotNil(model) } - func testProviderModelCreationWithProviderName() throws { - // Recommended typed spelling: ProviderName enum case (key is - // validated on the first API call, not construction). - let model = try Model.provider( - name: ProviderName.Groq.rawValue, apiKey: "sk-test-fake-key", modelId: "llama-3.3-70b" - ) - XCTAssertNotNil(model) - } - func testInvalidPromptThrows() throws { let model = try Model.openai(apiKey: "sk-test-fake-key", modelId: "gpt-4o-mini") XCTAssertThrowsError(try model.generateText(prompt: "{invalid json}")) 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/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.md b/docs/API.md index 596590771..eda312d76 100644 --- a/docs/API.md +++ b/docs/API.md @@ -70,22 +70,11 @@ One function in every binding: ```text provider(name, api_key?, model_id, config?) // all languages - name — provider name; 推荐使用类型化 ProviderName(见下),字符串同样可用 + name — registry provider name as a string api_key — optional; omitted/None reads the provider's env var config — optional overrides (base_url / headers / maxRetries / body_overrides) ``` -- **ProviderName** (Rust enum, TS const object, Go/Java/Kotlin consts, Swift - enum, Dart consts) — IDE-completable and typo-proof: - - ```text - Rust: provider(ProviderName::Groq, ...) TS: provider(ProviderName.groq, ...) - Go: Provider(string(ProviderName.Groq), ...) Java: Model.provider(ProviderName.GROQ, ...) - Swift: Aimux.provider(name: ProviderName.groq.rawValue, ...) - Dart: Model.provider(ProviderName.groq, ...) - ``` - - 字符串形式(`provider("groq", ...)`)在全部语言中同样可用——两种写法等价。 - Full list (name / env var / base URL): [providers.md](api/providers.md) - Custom endpoint: registry name + `base_url` override, or the OpenAI constructor with a base URL diff --git a/docs/PROJECT-OVERVIEW.md b/docs/PROJECT-OVERVIEW.md index a53a672eb..3207e2521 100644 --- a/docs/PROJECT-OVERVIEW.md +++ b/docs/PROJECT-OVERVIEW.md @@ -29,8 +29,8 @@ aimux does not do agent loops, RAG, or orchestration — it focuses solely on un > **Provider access (RFC-0017 phase 4)**: all OpenAI-compatible providers are > registry-backed — `provider-registry.json` is the single source of truth, and -> every binding exposes `provider(name, ...)` with a typed `ProviderName` -> (enum/union/consts). The per-provider shell types (`XxxConfig`/`XxxProvider`) +> every binding exposes the string-keyed `provider(name, ...)`. The +> per-provider shell types (`XxxConfig`/`XxxProvider`) > were retired in phase 4. --- diff --git a/docs/api/flutter.md b/docs/api/flutter.md index 3ad62229a..39d70832b 100644 --- a/docs/api/flutter.md +++ b/docs/api/flutter.md @@ -47,8 +47,7 @@ model.close(); ## Providers -All 251 registry-backed OpenAI-compatible providers are reachable by name; -`ProviderName` holds the constants: +All 251 registry-backed OpenAI-compatible providers are reachable by string name: > **Scope:** `provider(name)` covers only the 251 registry OpenAI-compatible > providers; Anthropic/Google/multimodal/local → typed factories @@ -56,18 +55,16 @@ All 251 registry-backed OpenAI-compatible providers are reachable by name; > Full list: [providers.md](providers.md). ```dart -// 推荐:ProviderName.groq 常量(补全 + 防拼写错误) -final model = Model.provider(ProviderName.groq, 'llama-3.3-70b'); +final model = Model.provider('groq', 'llama-3.3-70b'); final result = model.generateText('Hello'); model.close(); -// 字符串形式同样可用 + 可选 config JSON ({"base_url": "..."}): +// Optional config JSON ({"base_url": "..."}): final model2 = Model.provider('groq', 'llama-3.3-70b', apiKey: 'sk-...'); model2.close(); ``` -Unknown names throw `NoSuchProviderError` (payload: the provider id); valid -names come from the generated `ProviderName` constants. +Unknown names throw `NoSuchProviderError` (payload: the provider id). ## Errors diff --git a/docs/api/go.md b/docs/api/go.md index e9319bc3e..da8ef02af 100644 --- a/docs/api/go.md +++ b/docs/api/go.md @@ -155,8 +155,7 @@ The producer closes `Parts()` after the blocking native call returns. ## Providers -All 251 registry-backed OpenAI-compatible providers are reachable by name; -`aimux.ProviderName` holds typed constants: +All 251 registry-backed OpenAI-compatible providers are reachable by string name: > **Scope:** `provider(name)` covers only the 251 registry OpenAI-compatible > providers; Anthropic/Google/multimodal/local → typed constructors @@ -164,12 +163,11 @@ All 251 registry-backed OpenAI-compatible providers are reachable by name; > Full list: [providers.md](providers.md). ```go -// 推荐:aimux.Groq 类型常量(类型检查 + 补全) -model, err := aimux.Provider(string(aimux.Groq), "", "llama-3.3-70b") +model, err := aimux.Provider("groq", "", "llama-3.3-70b") if err != nil { log.Fatal(err) } defer model.Close() -// 字符串形式同样可用 + base URL 覆盖: +// Base URL override: model2, err := aimux.ProviderWithBase("groq", "sk-...", "llama-3.3-70b", "https://relay.example/v1") defer model2.Close() diff --git a/docs/api/java.md b/docs/api/java.md index 4e340f1ec..c1314c719 100644 --- a/docs/api/java.md +++ b/docs/api/java.md @@ -33,8 +33,7 @@ try (Model model = Model.openaiWithBase("sk-...", "gpt-4o", "http://localhost:30 ## Providers -All 251 registry-backed OpenAI-compatible providers are reachable by name; -`ProviderName` holds the constants: +All 251 registry-backed OpenAI-compatible providers are reachable by string name: > **Scope:** `provider(name)` covers only the 251 registry OpenAI-compatible > providers; Anthropic/Google/multimodal/local → typed factories @@ -43,22 +42,19 @@ All 251 registry-backed OpenAI-compatible providers are reachable by name; ```java import ai.arcships.aimux.Model; -import ai.arcships.aimux.ProviderName; -// 推荐:ProviderName.GROQ 常量(类型检查 + 补全) -try (Model model = Model.providerFromEnv(ProviderName.GROQ, "llama-3.3-70b")) { +try (Model model = Model.providerFromEnv("groq", "llama-3.3-70b")) { String result = model.generateText("\"Hello\""); } -// 字符串形式同样可用 + 可选 config JSON ({"base_url": "..."}): +// Optional config JSON ({"base_url": "..."}): try (Model model = Model.provider("groq", "sk-...", "llama-3.3-70b", null)) { String result = model.generateText("\"Hello\""); } ``` `deepseek(apiKey, modelId)` remains as a shortcut (registry-backed). -Unknown names throw `NoSuchProviderError` naming the requested provider -(valid names come from the generated `ProviderName` constants). +Unknown names throw `NoSuchProviderError` naming the requested provider. ## Errors diff --git a/docs/api/kotlin.md b/docs/api/kotlin.md index eba623342..154ce3aa8 100644 --- a/docs/api/kotlin.md +++ b/docs/api/kotlin.md @@ -27,8 +27,7 @@ Model.openai("sk-...", "gpt-4o", "http://localhost:3000").use { model -> ## Providers -All 251 registry-backed OpenAI-compatible providers are reachable by name; -`ai.arcships.aimux.ProviderName` holds the constants: +All 251 registry-backed OpenAI-compatible providers are reachable by string name: > **Scope:** `provider(name)` covers only the 251 registry OpenAI-compatible > providers; Anthropic/Google/multimodal/local → typed factories @@ -36,19 +35,17 @@ All 251 registry-backed OpenAI-compatible providers are reachable by name; > Full list: [providers.md](providers.md). ```kotlin -// 推荐:ProviderName.GROQ 常量(类型检查 + 补全) -Model.provider(name = ProviderName.GROQ, modelId = "llama-3.3-70b").use { model -> +Model.provider(name = "groq", modelId = "llama-3.3-70b").use { model -> val result = model.generateText("\"Hello\"") } -// 字符串形式同样可用 + 可选 config JSON ({"base_url": "..."}): +// Optional config JSON ({"base_url": "..."}): Model.provider(name = "groq", apiKey = "sk-...", modelId = "llama-3.3-70b").use { model -> val result = model.generateText("\"Hello\"") } ``` -Unknown names throw `NoSuchProviderError` naming the requested provider -(valid names come from the generated `ProviderName` constants). +Unknown names throw `NoSuchProviderError` naming the requested provider. ## Errors diff --git a/docs/api/node.md b/docs/api/node.md index a6dd4f771..6895ccdea 100644 --- a/docs/api/node.md +++ b/docs/api/node.md @@ -22,16 +22,13 @@ console.log(result.text) ## Providers All 251 built-in OpenAI-compatible providers are registry-backed. Look them up -by name; the `ProviderName` type is a string-literal union generated from -`provider-registry.json`, so your IDE autocompletes and typo'd names fail -type-checking: +by string name; the current list is generated from `provider_registry.json` in +[providers.md](providers.md): ```typescript -import { provider, generateText, ProviderName } from '@arcships/aimux' +import { provider, generateText } from '@arcships/aimux' -// 推荐:ProviderName.groq 写法(IDE 补全 + 类型检查) -const model = await provider(ProviderName.groq, undefined, 'llama-3.3-70b') -// 字符串形式同样可用: +const model = await provider('groq', undefined, 'llama-3.3-70b') const relay = await provider('groq', 'sk-...', 'llama-3.3-70b', { baseUrl: 'https://relay.example/v1', maxRetries: 0, diff --git a/docs/api/providers.md b/docs/api/providers.md index 0b7133baf..c970a359e 100644 --- a/docs/api/providers.md +++ b/docs/api/providers.md @@ -9,7 +9,7 @@ | category | count | |----------|-------| -| Registry-backed OpenAI-compatible (`provider(name, ...)` / `ProviderName`) | 251 | +| Registry-backed OpenAI-compatible (`provider(name, ...)`) | 251 | | Non-registry: Native protocol providers | 14 | | Non-registry: OpenAI-compatible thin wrappers (second batch) | 5 | | Non-registry: Speech-only providers (TTS) | 4 | @@ -28,7 +28,7 @@ | Non-registry: Search-only providers (web search modality) | 11 | | **Total providers** | **328** | -**251 registry-backed OpenAI-compatible providers** (construct via `provider(name, ...)` / `ProviderName`) + **77 non-registry providers** (construct via the typed factories listed below). +**251 registry-backed OpenAI-compatible providers** (construct via `provider(name, ...)`) + **77 non-registry providers** (construct via the typed factories listed below). ## Registry-backed (OpenAI-compatible) — 251 diff --git a/docs/api/reference.md b/docs/api/reference.md index 3e4133a34..a1796fd3b 100644 --- a/docs/api/reference.md +++ b/docs/api/reference.md @@ -16,7 +16,6 @@ | `ReasoningEffort` | [types.rs](../../aimux-core/src/types.rs) | [ReasoningEffort.ts](../../bindings/node/src/types/ReasoningEffort.ts) | 7 levels, passed through verbatim | | `AbortSignal` | [abort_signal.rs](../../aimux-core/src/abort_signal.rs) | — (runtime handle) | Rust: options field; Node: `AbortBridge` + JS `AbortSignal` | | `ProviderOptions` | [provider.rs](../../aimux-providers/src/provider.rs) | `ProviderConfig` (per binding) | `base_url` / `headers` / `organization` / `project` / `max_retries` / `body_overrides` | -| `ProviderName` | [provider_name.rs](../../aimux-providers/src/provider_name.rs) | [ProviderName.ts](../../bindings/node/src/types/ProviderName.ts) | one constant per registry provider (count: [providers.md](providers.md)); also generated for Go / Python / Java / Kotlin / Swift / Dart (`scripts/gen_provider_names.py`) | | `GenerateTextResult` | [generate.rs](../../aimux-core/src/generate.rs) | [GenerateTextResult.ts](../../bindings/node/src/types/GenerateTextResult.ts) | `text`, `tool_calls`, `usage`, `warnings`, `raw` | | `StreamTextResult` | [generate.rs](../../aimux-core/src/generate.rs) | — | use `StreamPart` while iterating | | `StreamPart` | [stream_part.rs](../../aimux-core/src/stream_part.rs) | [StreamPart.ts](../../bindings/node/src/types/StreamPart.ts) | TextDelta / ToolCallDelta / Finish / … | @@ -86,7 +85,7 @@ Per-modality entry points are trait methods on the model types |----------|-------| | `generateText(model, prompt, options?, signal?)` / `streamText(...)` | typed wrappers; `signal` = `AbortSignal` | | `openai()` / `anthropic()` / `deepseek()` / `google()` | native typed path; `deepseek()` is registry-backed | -| `provider(name, apiKey?, modelId, config?)` | registry-backed (rows in [providers.md](providers.md)); typed `ProviderName` | +| `provider(name, apiKey?, modelId, config?)` | registry-backed; names listed in [providers.md](providers.md) | | `openaiEmbedding` / `cohereEmbedding` / `googleEmbedding` | embeddings | | `openaiSpeech` / `openaiTranscription` | speech / STT | | `openaiImage` / `googleImage` / `googleVideo` | image / video | @@ -108,7 +107,7 @@ Per-modality entry points are trait methods on the model types | Function | Notes | |----------|-------| -| `Provider(name, apiKey?, modelId, config?)` | registry-backed; typed `ProviderName` | +| `Provider(name, apiKey?, modelId, config?)` | registry-backed; names listed in [providers.md](providers.md) | | `NewOpenAI` / `NewAnthropic` / `NewDeepSeek` | native typed path (+ `WithBase` variants) | | `NewOpenAIEmbedding` / `NewCohereEmbedding` / `NewGoogleEmbedding` | embeddings | | `NewOpenAISpeech` / `NewOpenAITranscription` | speech / STT | diff --git a/docs/api/rust.md b/docs/api/rust.md index fbdc04ba5..4614f3c25 100644 --- a/docs/api/rust.md +++ b/docs/api/rust.md @@ -25,7 +25,7 @@ async fn main() -> Result<(), AiMuxError> { ## Providers All 251 built-in OpenAI-compatible providers are registry-backed: no per-provider -`XxxConfig`/`XxxProvider` types. Look them up by name (or by the `ProviderName` enum): +`XxxConfig`/`XxxProvider` types. Look them up by string name: > **Scope:** `provider(name)` covers only the 251 registry OpenAI-compatible > providers; Anthropic/Google/multimodal/local → typed factories @@ -34,7 +34,7 @@ All 251 built-in OpenAI-compatible providers are registry-backed: no per-provide > Full list: [providers.md](providers.md). ```rust -use aimux_providers::{provider, provider_from_env, ProviderName, ProviderOptions}; +use aimux_providers::{provider, provider_from_env, ProviderOptions}; // Key from the provider's env var (GROQ_API_KEY etc.), base URL & profile from // the registry — replaces the retired `XxxConfig::from_env()`. @@ -48,14 +48,11 @@ let model = provider( Some(ProviderOptions { base_url: Some("https://relay.example/v1".into()), ..Default::default() }), )?; -// Typed name (推荐——IDE-completable, compile-checked): -let model = provider(ProviderName::Groq, None, "llama-3.3-70b", None)?; -// 字符串形式同样可用(弱类型兼容): -let model = provider_from_env("groq", "llama-3.3-70b", None)?; ``` -`ProviderName` is generated from `provider_registry.json` (251 variants, `as_str`/ -`from_str`/`ALL`). Unknown names fail with `AiMuxError::NoSuchProvider { provider_id }`. +Names come from `provider_registry.json`; the current list is in +[providers.md](providers.md). Unknown names fail with +`AiMuxError::NoSuchProvider { provider_id }`. ## Text Generation diff --git a/docs/api/swift.md b/docs/api/swift.md index 568cbe13e..e87e054d6 100644 --- a/docs/api/swift.md +++ b/docs/api/swift.md @@ -31,8 +31,7 @@ print(result) ## Providers -All 251 registry-backed OpenAI-compatible providers are reachable by name; -`ProviderName` is an enum with one case per provider: +All 251 registry-backed OpenAI-compatible providers are reachable by string name: > **Scope:** `provider(name)` covers only the 251 registry OpenAI-compatible > providers; Anthropic/Google/multimodal/local → typed factories @@ -40,16 +39,14 @@ All 251 registry-backed OpenAI-compatible providers are reachable by name; > Full list: [providers.md](providers.md). ```swift -// 推荐:ProviderName enum case(类型检查 + 补全) -let model = try Model.provider(name: ProviderName.Groq.rawValue, modelId: "llama-3.3-70b") +let model = try Model.provider(name: "groq", modelId: "llama-3.3-70b") let result = try model.generateText(prompt: "\"Hello\"") -// 字符串形式同样可用 + 可选 config JSON ({"base_url": "..."}): +// Optional config JSON ({"base_url": "..."}): let model2 = try Model.provider(name: "groq", apiKey: "sk-...", modelId: "llama-3.3-70b") ``` -Unknown names throw `.noSuchProvider` (payload: the provider id); valid names -come from the generated `ProviderName` enum. +Unknown names throw `.noSuchProvider` (payload: the provider id). ## Text Generation diff --git a/docs/contributing/adding-a-provider.md b/docs/contributing/adding-a-provider.md index da09fb1ec..080eb4793 100644 --- a/docs/contributing/adding-a-provider.md +++ b/docs/contributing/adding-a-provider.md @@ -37,14 +37,13 @@ case 1. flags rides in per-call `body_overrides` (RFC-0017); if the quirk changes auth or error shapes, it is case 2. -2. Run both generators and commit their output: +2. Regenerate the provider documentation and commit its output: ```sh - python scripts/gen_provider_names.py # ProviderName in 8 languages python scripts/gen_providers_doc.py # docs/api/providers.md (totals + list) ``` - CI runs both with `--check` in the `contract-tests` job and fails if the + CI runs it with `--check` in the `contract-tests` job and fails if the committed output is stale. 3. Derive replay cassettes: add a tuple to `PROVIDERS` in @@ -123,8 +122,8 @@ case 1. - **A generator may stay in `scripts/` only if its output carries a "GENERATED — do not edit" header and CI runs it with `--check`.** Today - that is `gen_provider_names.py` and `gen_providers_doc.py` (the - `contract-tests` job). `gen_ts_types.py` regenerates the ts-rs TypeScript + that is `gen_providers_doc.py` (the `contract-tests` job). + `gen_ts_types.py` regenerates the ts-rs TypeScript types through `cargo test -p aimux-core --lib export` (the export tests are the gate). - `docs/api/providers.md` is the single source of truth for provider counts diff --git a/docs/external-provider-config-research.md b/docs/external-provider-config-research.md index 5d4eb2350..6676c5855 100644 --- a/docs/external-provider-config-research.md +++ b/docs/external-provider-config-research.md @@ -134,8 +134,8 @@ a/b/d 三者**共用同一个运行时覆盖层**(见 §4),实现上是同一机 - 新增一个**运行时覆盖层** `OVERLAYS: RwLock>`(或 `OnceLock<...>` 包 `RwLock`)。 - `provider()` 查找顺序改为:**覆盖层 → 内置 registry**;找到条目后,后端组装管线(`OpenAIConfig` 构造 + `ProviderOptions` 叠加)完全复用,零改动。 -- 内置 registry 仍是 `include_str!` 编译期嵌入(保留 RFC-0017 的"单一数据源 + 类型派生"不变);覆盖层只承载外部新增/覆盖条目。 -- **这是纯增量改动**:不动 `provider_registry.json`、不动 `gen_provider_names.py`、不动 `ProviderName` 派生(外部名字不属于编译期枚举,走字符串路径即可)。 +- 内置 registry 仍是 `include_str!` 编译期嵌入;覆盖层只承载外部新增/覆盖条目。 +- **这是纯增量改动**:不动 `provider_registry.json`;内置与外部名字都走字符串路径。 ### 4.2 Provider / LanguageModel trait 是否 object-safe(能否动态注册)? diff --git a/docs/internal/phase4-audit.md b/docs/internal/phase4-audit.md index a6d6ce119..d21b84b6c 100644 --- a/docs/internal/phase4-audit.md +++ b/docs/internal/phase4-audit.md @@ -5,6 +5,7 @@ > 审计方式:只读代码/测试核查 + `cargo test -p aimux-providers --tests` 实测(EXIT=0,2769 绿) > 范围:RFC-0017 阶段 4(registry JSON 唯一数据源 + `provider(name,...)` 统一入口 + ProviderName 派生类型 + C ABI 符号)在 8 语言绑定 + C ABI 的覆盖一致性与测试落库情况 > 说明:审计 prompt 写"8 个绑定"但枚举了 9 项(Rust/Node/Python/Go/Java/Kotlin/Swift/Flutter/C ABI),本报告按 9 行呈现。 +> **后续变更**: 2026-09-29 删除生成式 `ProviderName` 及 `scripts/gen_provider_names.py`;本报告中关于 ProviderName 派生类型与其防漂移测试的条目(b / d / P2)已不再适用,provider 名字统一为字符串,现行列表见 [docs/api/providers.md](../api/providers.md)。 --- diff --git a/docs/plan/structured-error-business-semantics.md b/docs/plan/structured-error-business-semantics.md index 8fa770b99..706117df3 100644 --- a/docs/plan/structured-error-business-semantics.md +++ b/docs/plan/structured-error-business-semantics.md @@ -36,7 +36,7 @@ disagree. | RFC-0016 §7.1 | Request deadlines surface as `AiMuxError::Timeout` and are not retried. | | RFC-0016 §7.6 R2/R4 and §7.7 S1/S2 | User cancellation surfaces as `AiMuxError::Aborted`; abort must remain responsive during body reads, retry backoff, and provider polling waits. | | RFC-0016 alignment goal | Retryability follows the AI SDK response policy: 408, 409, 429, and 5xx are retryable; ordinary 4xx are not. | -| RFC-0017 §3 stage 4 | Unknown registry names must identify the requested provider and direct callers to the generated `ProviderName` surface. The runtime error must not carry all 250 automatically registered names. | +| RFC-0017 §3 stage 4 | Unknown registry names must identify the requested provider. The runtime error must not carry all automatically registered names; discovery uses the generated provider documentation. | | RFC-0018 §3.2 | Codex subscription 401 remains the specialized `TokenExpired` action signal. Do not fold it back into a generic 401 `ApiCall`. | ### 2.2 Final error classification table @@ -98,11 +98,11 @@ disagree. 2. Remove both `available` and stored `message`. Display text must be derived from `provider_id`, so the payload cannot contain contradictory facts. 3. In `provider_handle`, construct only `provider_id`. -4. Remove the now-unused `ProviderName` import and fix the stale `provider()` - doc comment that still promises the full provider list. +4. Fix the stale `provider()` doc comment that still promises the full + provider list in the error. 5. Update RFC-0017 stage-4 wording and acceptance item. The accepted behavior is: the error names the unknown provider; discoverable valid names come from - generated `ProviderName`. The error payload does not contain 250 names. + generated provider documentation. The error payload does not contain the registry. 6. Regenerate or mechanically update the checked-in TS wire type. Do not add a compatibility union for the old payload. @@ -387,7 +387,7 @@ Clean only residue directly invalidated by this work: - `scripts/gen_responses_convert.py` if it still emits a removed `AiMuxError::Provider` constructor; - stale Rustdoc references to `AiMuxError::Unsupported` / `AiMuxError::Json`; -- the unused `ProviderName` import created by removing the available list. +- stale provider-name discovery references after removing the available list. Do not turn this into a general documentation sweep. @@ -425,4 +425,3 @@ boundaries are: - [ ] Codex subscription 401 still maps to `TokenExpired`. - [ ] No binding compatibility work or unrelated API expansion was added. - [ ] Focused checks in §9 pass and `git diff --check` is clean. - diff --git a/rfc/0017-provider-config-dx.md b/rfc/0017-provider-config-dx.md index 6a64c3a7d..2dfc6d04a 100644 --- a/rfc/0017-provider-config-dx.md +++ b/rfc/0017-provider-config-dx.md @@ -344,18 +344,15 @@ await generateText(model, prompt, { **目标**:把"新增/修正 provider"从库行为变成纯用户行为;补上绑定层(除 Rust 外)对 250 家 registry 的暴露缺口(现状:Node/Python/Go 等只有 openai/anthropic/deepseek 三个工厂)。 -**架构定稿(2026-08-02, v2)**:`provider-registry.json` 是**唯一数据源**——内置 provider 的构造数据(base_url/env_var/profile)全部从 JSON 查条目获得。Rust 250 个编译期类型壳(`XxxConfig`/`XxxProvider`)**退役**(crates.io 发布仅一天,破坏零成本,0.x 内处理)——统一改为名字驱动入口 + 各语言 `ProviderName` 派生类型。 +**架构定稿(2026-09-29, v3)**:`provider-registry.json` 是**唯一数据源**——内置 provider 的构造数据(base_url/env_var/profile)全部从 JSON 查条目获得。Rust 250 个编译期类型壳(`XxxConfig`/`XxxProvider`)与各语言的生成式名字枚举均已退役——统一改为字符串名字驱动入口。 **改动**: 1. `provider-registry.json` — 唯一数据源(250 家:name/display/base_url/env_var/profile 全字段),由阶段 3 调研数据生成;Rust `include_str!` embed,各绑定打包同一份 -2. **名字类型声明**(checked-in 派生文件,脚本 `scripts/gen_provider_names.py` 从 JSON 生成,无 build.rs): - - TS: `type ProviderName = 'groq' | 'deepseek' | ...`(字面量 union → IDE 补全 + 编译期检查) - - Rust: `pub enum ProviderName { Groq, DeepSeek, ... }`(250 变体 + `as_str()`/`FromStr`/`Display`) - - Go/Python/Swift/Kotlin/Java: 各语言常量/枚举/Literal 形态 +2. **名字发现**:不再生成 8 语言静态副本;Rust/web 可通过 `provider_names()` 遍历内置 registry,用户文档由 `gen_providers_doc.py` 从同一 JSON 生成 3. **统一入口**(各语言一致): - - `provider(name: ProviderName, api_key: Option, model_id, config?)` — 名字 → 查 JSON → 构造;`api_key=None` 时自动读 JSON 条目的 env_var(替代旧 `from_env()`) + - `provider(name: string, api_key: Option, model_id, config?)` — 名字 → 查 JSON → 构造;`api_key=None` 时自动读 JSON 条目的 env_var(替代旧 `from_env()`) - config 可覆盖 JSON 条目字段(base_url/headers/maxRetries/bodyOverrides 等,替代旧 `with_base_url`) - - 未知名字 → 明确错误:`NoSuchProvider { provider_id }`,显示 "No such provider: xxx";可用名字通过生成的 `ProviderName` 发现,错误载荷不携带 250 个名字 + - 未知名字 → 明确错误:`NoSuchProvider { provider_id }`,显示 "No such provider: xxx";错误载荷不携带 250 个名字 4. **`createProvider(config)`**(各语言):用户用基础类(`OpenAIConfig`/`OpenAIProvider`)手工构造自己的薄封装——**与内置表无关**的纯用户侧 API;用于自定义 relay / 内置表外厂商 5. 边界特例(§2.6):认证流程特殊者(GigaChat/copilot)标记 unsupported 或后续 auth 钩子;伪兼容(coze/zai_coding_plan)修正分类 6. 校验:启动时对 registry JSON 做 schema 校验(必填字段/base_url 合法性) @@ -373,14 +370,14 @@ await generateText(model, prompt, { | 基础类构造(createProvider 地基) | `OpenAIConfig`/`OpenAIProvider` 保留 | **验收**: -- [x] 任意语言 `provider(ProviderName::Groq, key, "llama-3.3-70b")` 可用,profile 差异生效(Rust/Node/Python 冒烟 ✅;Go/Java/Kotlin/Swift/Flutter 代码就绪,环境受限未运行) -- [x] `ProviderName` 类型生成正确且被测试锁定(250 名字与 JSON 一致,`provider_name_roundtrip` 测试) +- [x] 任意语言 `provider("groq", key, "llama-3.3-70b")` 可用,profile 差异生效 +- [x] `provider_names()` 与生成的 provider 文档均直接读取 registry,不维护静态名字副本 - [x] 用户覆盖内置条目生效(`ProviderOptions.base_url` 等;Rust `base_url_override_is_applied` + Node/Python/Go/C 的 config 参数) - [x] `createProvider` 等价能力:基础类 `OpenAIConfig`/`OpenAIProvider` 保留 + 各绑定 config 参数覆盖(Rust/Node/Python/Go/C 均实现) -- [x] 未知名字报错指明所请求的 provider(`NoSuchProvider { provider_id }`);可用列表由生成的 `ProviderName` 提供,不进错误载荷(Node/Python 冒烟验证) +- [x] 未知名字报错指明所请求的 provider(`NoSuchProvider { provider_id }`),不把可用名字列表放进错误载荷 - [x] 7 处 registry base_url 错误通过数据修正落地(stage2-004 完成) -**实施状态(2026-08-02)**:✅ 完成——registry JSON 唯一数据源;250 壳类型退役(发布一天零成本);`provider(name)` 入口 + `ProviderName` 派生类型(Rust enum/TS union);C ABI `aimux_provider_new`/`aimux_provider_from_env`;8 语言绑定统一入口;全量测试 2769 绿;Node/Python E2E 冒烟通过。 +**实施状态(2026-09-29)**:✅ 完成——registry JSON 唯一数据源;250 壳类型与 8 语言名字派生退役;字符串 `provider(name)` 入口、C ABI `aimux_provider_new`/`aimux_provider_from_env` 与 8 语言绑定统一入口保留。 --- @@ -436,4 +433,4 @@ await generateText(model, prompt, { 6. **退役 DeepSeek 特化的迁移**:0.x minor bump + 用户手册迁移说明(reasoning:'none' → bodyOverrides)。 7. **registry JSON 化的校验时机**:启动时 schema 校验替代编译期校验,错误发现延后——是否可接受?校验规则集(必填字段/base_url 合法/profile 字段范围)需定义。 8. **认证流程特殊厂商**(GigaChat OAuth/copilot device-flow)的落地形态:auth 钩子(代码扩展点)还是暂不支持仅文档化。 -9. ~~**Rust 编译期类型与 JSON 的单一事实来源**~~:**已解决(2026-08-02)**——JSON 为唯一源;类型壳**退役**(发布一天零成本),统一 `provider(name)` 入口 + `ProviderName` 派生类型,无宏生成、无双源、无 build.rs。 +9. ~~**Rust 编译期类型与 JSON 的单一事实来源**~~:**已解决(2026-09-29)**——JSON 为唯一源;类型壳与静态名字枚举均退役,统一字符串 `provider(name)` 入口,无宏生成、无双源、无 build.rs。 diff --git a/rfc/0020-external-provider-config.md b/rfc/0020-external-provider-config.md index a0a7819b2..5a2397a97 100644 --- a/rfc/0020-external-provider-config.md +++ b/rfc/0020-external-provider-config.md @@ -4,6 +4,7 @@ > **Date**: 2026-08-05 > **Scope**: `aimux-providers` 新增运行时覆盖层,允许外部(配置文件 / 编程式 API)注册、覆盖 OpenAI 兼容 provider 条目;各 binding 薄透传 > **Related**: [RFC-0017](0017-provider-config-dx.md) 配置 DX(本 RFC 落地其 §363 预留的"用户覆盖内置条目"语义)、[RFC-0019](0019-session-affinity.md) 会话亲和、[调研报告](../docs/external-provider-config-research.md) +> **后续变更**: 2026-09-29 删除生成式 `ProviderName`;内置与外部名字现在都走字符串入口,不改变本 RFC 的 overlay 语义。 --- @@ -23,7 +24,7 @@ RFC-0017 阶段 4 已预留语义([0017-provider-config-dx.md:363](0017-provider ## 2. Design Goals -1. **零破坏性**:不动 `provider_registry.json`、不动 `gen_provider_names.py`、不动 `ProviderName` 派生、不动 `provider()` 签名(新参数全 `Option`)。 +1. **零破坏性**:不动 `provider_registry.json`、不动 `provider()` 签名(新参数全 `Option`)。 2. **后端管线零改动**:外部配置只影响"查条目"一步;查到后 `OpenAIConfig` 构造 + `ProviderOptions` 叠加 + `OpenAIProvider::new(config).language_model(id)` 完全复用。 3. **8 语言同步受益**:Rust core 一处落地,各 binding 薄透传(RFC-0017 阶段 4 已验证此分发模式)。 4. **仅 OpenAI 兼容协议**:原生协议(anthropic/google/bedrock…)是代码实现,无法用配置数据描述(RFC-0017 §2.6 分层定论)。 @@ -50,7 +51,7 @@ static OVERLAYS: Lazy>> = // 3. 未命中 → NoSuchProvider ``` -内置 registry 仍是 `include_str!` 编译期嵌入(`OnceLock>` 不变);覆盖层只承载外部新增/覆盖条目。外部名字不属于编译期 `ProviderName` 枚举,走字符串路径——`provider()` 本就接受 `&str`,无需改签名。 +内置 registry 仍是 `include_str!` 编译期嵌入(`OnceLock>` 不变);覆盖层只承载外部新增/覆盖条目。内置与外部名字都走字符串路径——`provider()` 接受 `&str`,无需改签名。 ### 3.2 配置条目 schema @@ -261,7 +262,7 @@ const char* aimux_register_providers(const char* config_json); 2. **不做动态发现 / 网关路由**。增删 provider 可行(覆盖层可变),但"动态发现"(运行时探测可用 provider)属网关职责,aimux 定位是接入层不做(RFC-0019 §1.4)。 3. **不做 deep-merge 覆盖语义**。整体替换,简单可预期。 4. **不做配置热重载 / 文件监听**。MVP 是启动期加载注册,进程生命周期内固定。 -5. **不改 `ProviderName` 枚举**。外部名字走字符串路径,不属于编译期派生。 +5. **不维护编译期名字枚举**。内置与外部名字统一走字符串路径。 --- diff --git a/scripts/gen_provider_names.py b/scripts/gen_provider_names.py deleted file mode 100644 index b47c27ea2..000000000 --- a/scripts/gen_provider_names.py +++ /dev/null @@ -1,250 +0,0 @@ -"""Generate ProviderName derived types from provider-registry.json (RFC-0017 phase 4). - -Produces (all checked-in, one per language): -- aimux-providers/src/provider_name.rs (Rust enum) -- bindings/node/src/types/ProviderName.ts (TS string-literal union) -- bindings/go/provider_name.go (Go const group) -- bindings/python/python/aimux/provider_name.py (Python Literal) -- bindings/java/src/main/java/ai/arcships/aimux/ProviderName.java (Java String consts) -- bindings/kotlin/src/main/kotlin/ai/arcships/aimux/ProviderName.kt (Kotlin const object) -- bindings/swift/Sources/Aimux/ProviderName.swift (Swift enum) -- bindings/flutter/lib/provider_name.dart (Dart const class) - -Usage: uv run python scripts/gen_provider_names.py -Idempotent: overwrites outputs. -""" - -import json -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parent.parent -JSON = ROOT / "aimux-providers" / "src" / "provider_registry.json" - - -def to_variant(name: str) -> str: - """snake_case provider name -> PascalCase (abacus->Abacus, reka_ai->RekaAi).""" - parts = name.split("_") - out = [] - for p in parts: - if not p: - continue - if p[0].isdigit(): - out.append(p) - else: - out.append(p[0].upper() + p[1:]) - return "".join(out) - - -def gen_rs(names): - variants = "\n".join(f" {to_variant(n)}," for n in names) - arms_str = "\n".join(f' Self::{to_variant(n)} => "{n}",' for n in names) - arms_from = "\n".join(f' "{n}" => Ok(Self::{to_variant(n)}),' for n in names) - all_list = "\n".join(f" Self::{to_variant(n)}," for n in names) - return f"""//! Provider name enum — GENERATED by `scripts/gen_provider_names.py`. -//! Do not edit manually; single source of truth is `provider_registry.json`. - -/// Names of all built-in OpenAI-compatible providers (registry-backed). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -#[non_exhaustive] -pub enum ProviderName {{ -{variants} -}} - -impl ProviderName {{ - /// All provider names, sorted. - pub const ALL: &'static [ProviderName] = &[ -{all_list} - ]; - - /// The registry name (snake_case), e.g. `ProviderName::Groq -> "groq"`. - #[must_use] - pub fn as_str(self) -> &'static str {{ - match self {{ -{arms_str} - }} - }} - - /// All registry names (for error messages / docs). - #[must_use] - pub const fn all_names() -> &'static str {{ - "{','.join(names)}" - }} -}} - -impl std::str::FromStr for ProviderName {{ - type Err = (); - - fn from_str(name: &str) -> Result {{ - match name {{ -{arms_from} - _ => Err(()), - }} - }} -}} - -impl AsRef for ProviderName {{ - fn as_ref(&self) -> &str {{ - self.as_str() - }} -}} - -impl std::fmt::Display for ProviderName {{ - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {{ - f.write_str(self.as_str()) - }} -}} -""" - - -def gen_ts(names): - props = "\n".join(f" {n}: '{n}'," for n in names) - return f"""// ProviderName — GENERATED by `scripts/gen_provider_names.py`. -// Do not edit manually; single source of truth is `provider-registry.json`. - -/** - * Names of all built-in OpenAI-compatible providers. - * Use `ProviderName.groq` etc. as the `name` argument of `provider()`. - */ -export const ProviderName = {{ -{props} -}} as const - -export type ProviderName = typeof ProviderName[keyof typeof ProviderName] -""" - - -def gen_go(names): - # gofmt-aligned const block: pad variant names to the widest one. - width = max(len(to_variant(n)) for n in names) - consts = "\n".join( - f'\t{to_variant(n):<{width}} ProviderName = "{n}"' for n in names - ) - return f"""// ProviderName — GENERATED by `scripts/gen_provider_names.py`. -// Do not edit manually; single source of truth is `provider-registry.json`. -package aimux - -// ProviderName is the registry name of a built-in OpenAI-compatible provider. -type ProviderName string - -const ( -{consts} -) -""" - - -def gen_py(names): - lit = ", ".join(f"{n!r}" for n in names) - return f'''"""ProviderName — GENERATED by `scripts/gen_provider_names.py`. - -Do not edit manually; single source of truth is `provider-registry.json`. -""" - -from typing import Literal - -ProviderName = Literal[{lit}] -''' - - -def gen_java(names): - consts = "\n".join( - f' public static final String {to_variant(n).upper()} = "{n}";' - for n in names - ) - return f"""// ProviderName — GENERATED by `scripts/gen_provider_names.py`. -// Do not edit manually; single source of truth is `provider-registry.json`. -package ai.arcships.aimux; - -/** Names of all built-in OpenAI-compatible providers. */ -public final class ProviderName {{ - private ProviderName() {{}} - -{consts} -}} -""" - - -def gen_kotlin(names): - consts = "\n".join( - f' const val {to_variant(n).upper()} = "{n}"' for n in names - ) - return f"""// ProviderName — GENERATED by `scripts/gen_provider_names.py`. -// Do not edit manually; single source of truth is `provider-registry.json`. -package ai.arcships.aimux - -/** Names of all built-in OpenAI-compatible providers. */ -object ProviderName {{ -{consts} -}} -""" - - -def gen_swift(names): - cases = "\n".join(f' case {to_variant(n)} = "{n}"' for n in names) - return f"""// ProviderName — GENERATED by `scripts/gen_provider_names.py`. -// Do not edit manually; single source of truth is `provider-registry.json`. - -/// Names of all built-in OpenAI-compatible providers. -public enum ProviderName: String, CaseIterable, Sendable {{ -{cases} -}} -""" - - -def gen_dart(names): - consts = "\n".join(f" static const {to_variant(n)} = '{n}';" for n in names) - return f"""// ProviderName — GENERATED by `scripts/gen_provider_names.py`. -// Do not edit manually; single source of truth is `provider-registry.json`. - -/// Names of all built-in OpenAI-compatible providers. -class ProviderName {{ - ProviderName._(); -{consts} -}} -""" - - -OUTPUTS = { - "aimux-providers/src/provider_name.rs": gen_rs, - "bindings/node/src/types/ProviderName.ts": gen_ts, - "bindings/go/provider_name.go": gen_go, - "bindings/python/python/aimux/provider_name.py": gen_py, - "bindings/java/src/main/java/ai/arcships/aimux/ProviderName.java": gen_java, - "bindings/kotlin/src/main/kotlin/ai/arcships/aimux/ProviderName.kt": gen_kotlin, - "bindings/swift/Sources/Aimux/ProviderName.swift": gen_swift, - "bindings/flutter/lib/provider_name.dart": gen_dart, -} - - -def main() -> int: - check = "--check" in sys.argv[1:] - entries = json.loads(JSON.read_text(encoding="utf-8")) - names = sorted(e["name"] for e in entries) - stale = [] - for rel, gen in OUTPUTS.items(): - out = ROOT / rel - text = gen(names) - if check: - on_disk = out.read_text(encoding="utf-8") if out.is_file() else None - if on_disk != text: - stale.append(rel) - continue - out.parent.mkdir(parents=True, exist_ok=True) - out.write_text(text, encoding="utf-8") - print(f"{len(names)} names -> {out.relative_to(ROOT)}") - if stale: - for rel in stale: - print(f"STALE: {rel}", file=sys.stderr) - print( - "generated ProviderName files are out of sync — " - "run scripts/gen_provider_names.py and commit the result", - file=sys.stderr, - ) - return 1 - if check: - print(f"{len(OUTPUTS)} files up to date ({len(names)} names)") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/gen_providers_doc.py b/scripts/gen_providers_doc.py index ad4db61b8..e62626cad 100644 --- a/scripts/gen_providers_doc.py +++ b/scripts/gen_providers_doc.py @@ -7,8 +7,8 @@ A `pub mod` in lib.rs counts as a provider module iff its `pub use` re-exports a typed surface (`*Provider` / `*Config` / `*Model`). Registry machinery -(`provider`, `provider_name`), `replay` and `catalogue` export none of these -and are excluded without a hand-kept list. +(`provider`), `replay` and `catalogue` export none of these and are excluded +without a hand-kept list. The generated page carries the provider totals (registry rows, per-category typed providers, grand total); other docs reference it instead of repeating @@ -64,10 +64,10 @@ def parse_lib_rs(): while k < len(lines) and lines[k].strip() == "": k += 1 if k < len(lines) and lines[k].strip().startswith("pub mod "): - # registry machinery (provider / provider_name) has its own - # doc comment block — never treat it as a section header + # registry machinery (provider) has its own doc comment + # block — never treat it as a section header first_mod = lines[k].strip().split()[2].rstrip(";") - if first_mod in ("provider", "provider_name"): + if first_mod == "provider": i = j continue if pending: @@ -131,9 +131,8 @@ def is_provider_module(module): """True when the module re-exports a typed provider surface. A provider module exposes `*Provider` / `*Config` / `*Model` types. - Registry machinery (`provider`, `provider_name`), `replay` and - `catalogue` re-export none of these, so they are excluded without a - hand-kept deny-list (#177). + Registry machinery (`provider`), `replay` and `catalogue` re-export + none of these, so they are excluded without a hand-kept deny-list (#177). """ return any(e.endswith(("Provider", "Config", "Model")) for e in module_exports(module)) @@ -180,7 +179,7 @@ def build_page(): w("") w("| category | count |") w("|----------|-------|") - w(f"| Registry-backed OpenAI-compatible (`provider(name, ...)` / `ProviderName`) | {len(registry)} |") + w(f"| Registry-backed OpenAI-compatible (`provider(name, ...)`) | {len(registry)} |") for title, mods in sections: # First sentence only — some lib.rs section comments run on; the full # title stays on the section heading below. The lookahead keeps @@ -191,7 +190,7 @@ def build_page(): w("") w( f"**{len(registry)} registry-backed OpenAI-compatible providers** " - f"(construct via `provider(name, ...)` / `ProviderName`) + " + f"(construct via `provider(name, ...)`) + " f"**{non_registry} non-registry providers** " "(construct via the typed factories listed below)." ) diff --git a/scripts/gen_ts_types.py b/scripts/gen_ts_types.py index 26a0da907..8f0c89b51 100755 --- a/scripts/gen_ts_types.py +++ b/scripts/gen_ts_types.py @@ -1,12 +1,17 @@ #!/usr/bin/env python3 -"""Regenerate ts-rs TypeScript bindings from Rust core types. - -The Rust types in aimux-core carry #[derive(TS)] + #[ts(export)]; running the -export tests (cargo test -p aimux-core --lib export) writes the generated .ts -files to TS_RS_EXPORT_DIR. This script always exports into a temporary -directory first — the repo is never written by cargo directly — then either -syncs that output into bindings/node/src/types/ (default) or compares the two -trees and fails on any difference (--check). Comparing full file sets means a +"""Regenerate ts-rs TypeScript bindings from Rust types. + +Two crates carry #[derive(TS)] + #[ts(export)]: + +- aimux-core -> bindings/node/src/types/ (+ the types.ts aggregate) +- aimux-web -> tools/aimux-web/web/src/types/generated/ (the console's wire + schema; kept out of the node bindings because it belongs to the frontend) + +Running a crate's export tests writes the generated .ts files to +TS_RS_EXPORT_DIR. This script always exports into a temporary directory +first — the repo is never written by cargo directly — then either syncs that +output into the target directory (default) or compares the two trees and +fails on any difference (--check). Comparing full file sets means a deleted Rust type shows up as a stale .ts, and a forgotten `git add` of a new type shows up as a missing one; a failed cargo run leaves the repo untouched. @@ -18,6 +23,8 @@ python3 scripts/gen_ts_types.py --check # fail if committed output differs # (CI; read-only, repo untouched) """ +from __future__ import annotations + import os import subprocess import sys @@ -27,11 +34,14 @@ ROOT = Path(__file__).resolve().parent.parent TYPES_DIR = ROOT / "bindings" / "node" / "src" / "types" TYPES_TS = ROOT / "bindings" / "node" / "src" / "types.ts" -# ProviderName.ts is generated by scripts/gen_provider_names.py and re-exported -# from index.ts directly as a value + type; it is not a ts-rs product, and this -# aggregate is type-only (it would erase the const), so it lives outside the -# generated set: never compared, never deleted, never re-exported here. -EXCLUDED = {"ProviderName.ts"} +WEB_TYPES_DIR = ROOT / "tools" / "aimux-web" / "web" / "src" / "types" / "generated" + +# (cargo test args selecting the crate's export tests, output directory, +# aggregate re-export file or None) +TARGETS = [ + (["-p", "aimux-core", "--lib", "export"], TYPES_DIR, TYPES_TS), + (["-p", "aimux-web", "--bins", "--features", "ts-export", "export"], WEB_TYPES_DIR, None), +] def normalize_generated(content: str) -> str: @@ -39,8 +49,8 @@ def normalize_generated(content: str) -> str: return "\n".join(line.rstrip() for line in content.splitlines()) + "\n" -def regenerate(export_dir: Path) -> None: - """Run the ts-rs export tests, writing the .ts files into export_dir. +def regenerate(test_args: list[str], export_dir: Path) -> None: + """Run a crate's ts-rs export tests, writing the .ts files into export_dir. The external TS_RS_EXPORT_DIR overrides the repo-relative default in .cargo/config.toml ([env] without force = true). Do not add force = true @@ -49,7 +59,7 @@ def regenerate(export_dir: Path) -> None: """ env = {**os.environ, "TS_RS_EXPORT_DIR": str(export_dir)} subprocess.run( - ["cargo", "test", "-p", "aimux-core", "--lib", "export"], + ["cargo", "test", *test_args], cwd=ROOT, env=env, check=True, @@ -78,67 +88,88 @@ def aggregate(files: dict[str, str]) -> str: return "\n".join(lines) + "\n" -def sync(expected: dict[str, str]) -> None: - """Make TYPES_DIR match expected exactly (EXCLUDED files untouched).""" - for rel in manifest(TYPES_DIR, EXCLUDED).keys() - expected.keys(): - (TYPES_DIR / rel).unlink() +def sync(types_dir: Path, expected: dict[str, str]) -> None: + """Make types_dir match expected exactly.""" + if types_dir.is_dir(): + for rel in manifest(types_dir).keys() - expected.keys(): + (types_dir / rel).unlink() for rel, content in expected.items(): - path = TYPES_DIR / rel + path = types_dir / rel path.parent.mkdir(parents=True, exist_ok=True) - # `content` is canonicalized by manifest(); compare the raw file so a - # normal regeneration also removes ts-rs's incidental EOL spaces. - if not path.exists() or path.read_text(encoding="utf-8") != content: + # `content` is canonicalized by manifest(); compare against the + # canonicalized file too, so committed ts-rs output that only differs + # by incidental EOL spaces is left alone (no whitespace-only churn). + if not path.exists() or normalize_generated(path.read_text(encoding="utf-8")) != content: path.write_text(content, encoding="utf-8") -def main() -> int: - check = "--check" in sys.argv[1:] - +def export(test_args: list[str]) -> dict[str, str]: + """Run one crate's export tests into a temp dir and return its manifest.""" with tempfile.TemporaryDirectory(prefix="ts-rs-export-") as tmp: - regenerate(Path(tmp)) + regenerate(test_args, Path(tmp)) expected = manifest(Path(tmp)) - if not expected: - print( - "ERROR: the export tests wrote nothing to the temporary " - "TS_RS_EXPORT_DIR — is it overridden with force = true in " - ".cargo/config.toml?", - file=sys.stderr, - ) - return 2 - - if not check: - sync(expected) - TYPES_TS.write_text(aggregate(expected), encoding="utf-8") - print(f"{len(expected)} files written to {TYPES_DIR.relative_to(ROOT)}") - return 0 - - actual = manifest(TYPES_DIR, EXCLUDED) if TYPES_DIR.is_dir() else {} + if not expected: + print( + "ERROR: the export tests wrote nothing to the temporary " + "TS_RS_EXPORT_DIR — is it overridden with force = true in " + ".cargo/config.toml?", + file=sys.stderr, + ) + sys.exit(2) + return expected + + +def check_target( + expected: dict[str, str], types_dir: Path, types_ts: Path | None +) -> bool: + actual = manifest(types_dir) if types_dir.is_dir() else {} missing = sorted(expected.keys() - actual.keys()) stale = sorted(actual.keys() - expected.keys()) changed = sorted(k for k in expected.keys() & actual.keys() if expected[k] != actual[k]) - try: - aggregate_ok = TYPES_TS.read_text(encoding="utf-8") == aggregate(expected) - except FileNotFoundError: - aggregate_ok = False - + aggregate_ok = True + if types_ts is not None: + try: + aggregate_ok = types_ts.read_text(encoding="utf-8") == aggregate(expected) + except FileNotFoundError: + aggregate_ok = False + + rel = types_dir.relative_to(ROOT) if missing or stale or changed or not aggregate_ok: - rel = TYPES_DIR.relative_to(ROOT) for f in missing: print(f"MISSING: {rel}/{f} (generated, but not committed)", file=sys.stderr) for f in stale: print(f"STALE: {rel}/{f} (committed, but no longer generated)", file=sys.stderr) for f in changed: print(f"CHANGED: {rel}/{f}", file=sys.stderr) - if not aggregate_ok: - print(f"CHANGED: {TYPES_TS.relative_to(ROOT)}", file=sys.stderr) + if not aggregate_ok and types_ts is not None: + print(f"CHANGED: {types_ts.relative_to(ROOT)}", file=sys.stderr) + return False + + print(f"{rel}: {len(expected)} files up to date") + return True + + +def main() -> int: + check = "--check" in sys.argv[1:] + ok = True + + for test_args, types_dir, types_ts in TARGETS: + expected = export(test_args) + if not check: + sync(types_dir, expected) + if types_ts is not None: + types_ts.write_text(aggregate(expected), encoding="utf-8") + print(f"{len(expected)} files written to {types_dir.relative_to(ROOT)}") + else: + ok = check_target(expected, types_dir, types_ts) and ok + + if not ok: print( "generated ts-rs types are out of sync — " "run scripts/gen_ts_types.py and commit the result", file=sys.stderr, ) return 1 - - print(f"{len(expected)} files up to date") return 0 diff --git a/scripts/local-ci.sh b/scripts/local-ci.sh index 11bc206c8..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 ProviderName drift + 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,18 +69,6 @@ if want rust; then fi fi -# ── contract ─────────────────────────────────────────────────────────────── -if want contract; then - section "contract: ProviderName drift + Rust/Node contract tests" - if python3 scripts/gen_provider_names.py --check \ - && 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/Cargo.toml b/tools/aimux-web/Cargo.toml index 361ce8832..cf9411e19 100644 --- a/tools/aimux-web/Cargo.toml +++ b/tools/aimux-web/Cargo.toml @@ -18,6 +18,9 @@ path = "src/main.rs" # `web/dist` to exist at compile time (run `npm run build` first). Dev builds # (default) keep serving from disk so the frontend can hot-reload. embed-frontend = ["dep:rust-embed"] +# Emit the wire types' ts-rs export tests. Enabled only by +# scripts/gen_ts_types.py, which points TS_RS_EXPORT_DIR at a temp directory. +ts-export = [] [dependencies] aimux-core = { workspace = true } @@ -25,6 +28,7 @@ aimux-providers = { workspace = true } tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +ts-rs = { workspace = true } anyhow = { workspace = true } futures = { workspace = true } async-stream = { workspace = true } diff --git a/tools/aimux-web/README.md b/tools/aimux-web/README.md index 06541c334..039694cec 100644 --- a/tools/aimux-web/README.md +++ b/tools/aimux-web/README.md @@ -126,15 +126,17 @@ tools/aimux-web/ └── web/ Vue 3 + shadcn 风格 + Tailwind + markstream-vue ├── src/agent/engine.ts 前端 agent loop 引擎 ├── src/api/client.ts SSE/端点封装 - └── src/types/ d.ts(core 类型来自 ts-rs,见下) + └── src/types/generated/ wire 类型(ts-rs 生成,勿手改) ``` ## 前端类型来源 - **core 类型**(`Recording`/`StreamPart`/`TraceRecord`/`SessionView` 等):ts-rs 从 `aimux-core` 生成到 `bindings/node/src/types/`(`scripts/gen_ts_types.py` 管线), - **拷贝**到 `web/src/types/` 供前端使用。改 core 类型后:跑 `scripts/gen_ts_types.py` - 并同步拷贝。 -- **wire 类型**(`Wire*.ts`):是 aimux-web 的 API 契约,**手动维护**在 - `web/src/types/`(不 ts-rs 导出,避免污染 node bindings 的导出目录)。 - 改 `src/wire.rs` 时同步更新对应的 `web/src/types/Wire*.ts`。 + 前端通过 `tsconfig.json` 的 `@aimux/types/*` 别名(`import type { Recording } from '@aimux/types/Recording'`) + 直接使用该受 CI 漂移检查保护的生成目录,不拷贝。别名只服务类型导入(构建时擦除), + 不要用它做值导入。改 core 类型后只需运行 `scripts/gen_ts_types.py`。 +- **wire 类型**(`Wire*.ts`):aimux-web 的 API 契约,由 ts-rs 从 `src/wire.rs` 生成到 + `web/src/types/generated/`(同样由 `scripts/gen_ts_types.py` 生成、CI `--check` 防漂移)。 + 不导出到 `bindings/node`,因为它们属于前端而非 node 绑定。改 `src/wire.rs` 后运行 + `scripts/gen_ts_types.py` 并一起提交,**不要手改** `generated/` 下的文件。 diff --git a/tools/aimux-web/src/api/providers.rs b/tools/aimux-web/src/api/providers.rs index 907cb9675..5393794c7 100644 --- a/tools/aimux-web/src/api/providers.rs +++ b/tools/aimux-web/src/api/providers.rs @@ -4,17 +4,15 @@ use axum::Json; use axum::response::{IntoResponse, Response}; use serde_json::{Value, json}; -use aimux_providers::provider_name::ProviderName; - /// Provider names: the six native single-key protocols plus every -/// registry-backed OpenAI-compatible provider (generated enum). +/// registry-backed OpenAI-compatible provider. pub fn provider_names() -> Vec { const NATIVE: [&str; 6] = ["openai", "anthropic", "google", "mistral", "xai", "cohere"]; let mut names: Vec = NATIVE .iter() .map(std::string::ToString::to_string) .collect(); - names.extend(ProviderName::ALL.iter().map(|p| p.as_str().to_string())); + names.extend(aimux_providers::provider_names().map(str::to_owned)); names } diff --git a/tools/aimux-web/src/wire.rs b/tools/aimux-web/src/wire.rs index 27c646919..0c97f2ad6 100644 --- a/tools/aimux-web/src/wire.rs +++ b/tools/aimux-web/src/wire.rs @@ -6,6 +6,7 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; use serde_json::Value; +use ts_rs::TS; use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; @@ -16,15 +17,19 @@ use aimux_core::tool::{FunctionTool, Tool}; // ───────────────────────────────────────────────────────────────────────────── // Wire request types — the console's API surface. // -// The corresponding TypeScript declarations live in `web/src/types/Wire*.ts` -// (committed). They are NOT ts-rs `#[ts(export)]`ed here: the repo's global -// `TS_RS_EXPORT_DIR` points at `bindings/node/src/types`, and these types -// belong to the web frontend, not the node bindings. Keep the committed -// `web/src/types/Wire*.ts` in sync when changing these structs. +// The corresponding TypeScript declarations are generated by ts-rs into +// `web/src/types/generated/` by `scripts/gen_ts_types.py` (CI runs `--check`). +// They are exported to that directory, not `bindings/node/src/types`, because +// these types belong to the web frontend, not the node bindings. The export +// tests are gated behind the `ts-export` feature so a plain `cargo test` (which +// would write to the workspace-wide `TS_RS_EXPORT_DIR`, i.e. the node bindings) +// never emits them; only the generator enables it. Never edit the generated +// files by hand. // ───────────────────────────────────────────────────────────────────────────── /// One model call from the console (RFC-0029 §5.3). -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[cfg_attr(feature = "ts-export", ts(export))] pub struct WireCallRequest { /// Registry name or native protocol name ("openai", "deepseek", …). pub provider: String, @@ -59,7 +64,8 @@ fn default_true() -> bool { true } -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[cfg_attr(feature = "ts-export", ts(export))] pub struct WireOptions { #[serde(default, skip_serializing_if = "Option::is_none")] pub temperature: Option, @@ -85,7 +91,8 @@ pub struct WireOptions { } /// A function tool definition (JSON Schema parameters). -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[cfg_attr(feature = "ts-export", ts(export))] pub struct WireTool { pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -95,7 +102,8 @@ pub struct WireTool { } /// A chat message in the wire format. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[cfg_attr(feature = "ts-export", ts(export))] pub struct WireMessage { /// "system" | "user" | "assistant" | "tool". pub role: String, @@ -103,7 +111,8 @@ pub struct WireMessage { } /// A content part in the wire format. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[cfg_attr(feature = "ts-export", ts(export))] #[serde(tag = "type", rename_all = "snake_case")] pub enum WireContentPart { Text { @@ -125,7 +134,8 @@ pub enum WireContentPart { } /// Response for non-streaming calls. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[cfg_attr(feature = "ts-export", ts(export))] pub struct WireCallResponse { pub text: String, pub finish_reason: Value, @@ -137,7 +147,8 @@ pub struct WireCallResponse { } /// SSE `meta` event payload — the frontend trace anchor (RFC-0029 §5.2). -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[cfg_attr(feature = "ts-export", ts(export))] pub struct WireMeta { pub call_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/tools/aimux-web/web/src/agent/engine.ts b/tools/aimux-web/web/src/agent/engine.ts index 3065e8d45..41f7170c0 100644 --- a/tools/aimux-web/web/src/agent/engine.ts +++ b/tools/aimux-web/web/src/agent/engine.ts @@ -7,8 +7,8 @@ // so the Traces page groups the whole run into a waterfall. import { api, callStream, parseStreamPart } from '../api/client' -import type { WireCallRequest } from '../types/WireCallRequest' -import type { StreamPart } from '../types/StreamPart' +import type { WireCallRequest } from '@/types/generated/WireCallRequest' +import type { StreamPart } from '@aimux/types/StreamPart' /** A chat message as sent to the backend (loose typing: the backend wire * schema's `JsonValue` is a recursive union that trips TS2589 inside diff --git a/tools/aimux-web/web/src/api/client.ts b/tools/aimux-web/web/src/api/client.ts index dba199ea3..97bc7a2ca 100644 --- a/tools/aimux-web/web/src/api/client.ts +++ b/tools/aimux-web/web/src/api/client.ts @@ -1,11 +1,11 @@ // API client — typed wrappers for the console endpoints (RFC-0029 §5). -import type { WireCallRequest } from '../types/WireCallRequest' -import type { WireCallResponse } from '../types/WireCallResponse' -import type { Recording } from '../types/Recording' -import type { TraceRecord } from '../types/TraceRecord' -import type { SessionView } from '../types/SessionView' -import type { StreamPart } from '../types/StreamPart' +import type { WireCallRequest } from '@/types/generated/WireCallRequest' +import type { WireCallResponse } from '@/types/generated/WireCallResponse' +import type { Recording } from '@aimux/types/Recording' +import type { TraceRecord } from '@aimux/types/TraceRecord' +import type { SessionView } from '@aimux/types/SessionView' +import type { StreamPart } from '@aimux/types/StreamPart' const API = '/api' diff --git a/tools/aimux-web/web/src/types/AiMuxError.ts b/tools/aimux-web/web/src/types/AiMuxError.ts deleted file mode 100644 index 72b4c339d..000000000 --- a/tools/aimux-web/web/src/types/AiMuxError.ts +++ /dev/null @@ -1,27 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ApiCallError } from "./ApiCallError"; - -/** - * Unified error type for all aimux operations. - * - * Variants are cut by *what the caller does about it*, not by where the - * failure came from. `ApiCall` carries the [`ApiCallError`] detail inline - * (unboxed, like async-openai's `ApiError(ApiError)`); the size guard in - * `error_value_golden_test` keeps the enum under clippy's - * `result_large_err` threshold. - * - * Constructed as a named struct literal everywhere - * (`ApiCallError { status_code: .., ..Default::default() }`), the same - * shape as the AI SDK's named-options constructor. - */ -export type AiMuxError = { "ApiCall": ApiCallError } | { "JsonParse": string } | { "InvalidResponseData": string } | { "NoSuchTool": { tool_name: string, available_tools?: Array | null, -/** - * Original argument text, when supplied by the provider. Absent in - * older serialized errors and errors constructed without a call. - */ -tool_input?: string | null, } } | { "InvalidToolInput": { tool_name: string, tool_input: string, cause: string, } } | { "ToolCallRepair": { original_error: AiMuxError, cause: AiMuxError, } } | { "InvalidArgument": string } | { "InvalidPrompt": string } | { "TokenExpired": string } | { "UnsupportedFunctionality": string } | { "NoSuchModel": { model_id: string, -/** - * What kind of model was requested (`"languageModel"`, - * `"imageModel"`, …), the AI SDK's `modelType`. - */ -model_type: string, } } | { "NoSuchProvider": { provider_id: string, } } | { "Timeout": string } | "Aborted" | { "Other": string }; diff --git a/tools/aimux-web/web/src/types/ApiCallError.ts b/tools/aimux-web/web/src/types/ApiCallError.ts deleted file mode 100644 index dfb248300..000000000 --- a/tools/aimux-web/web/src/types/ApiCallError.ts +++ /dev/null @@ -1,66 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * What a failed API call observed — the field set of the AI SDK's - * `APICallError` (`statusCode` / `responseBody`), which this project's error - * taxonomy mirrors (docs/internal/vercel-research/07-kernel-infrastructure.md). - * - * Carried by [`AiMuxError::ApiCall`] alone. There are no per-status - * variants: a 401, 404 or 429 is an `ApiCall` error whose classification is - * read from `status_code`, exactly as the AI SDK reads - * `APICallError.statusCode`. Transport failures (connection reset, DNS, - * body read) are `ApiCall` errors too — no response arrived, so - * `status_code` is `None` and `is_retryable` is `true`, exactly as the AI - * SDK's `handleFetchError` builds an `APICallError` with no `statusCode` - * and `isRetryable: true`. `message` holds the provider's text **only** — - * the status is a field, never baked into the string; `Display` composes - * the human-readable form from the fields at print time, so nothing - * downstream has to parse it back out. - * - * `APICallError` fields not carried in this round: `url` / - * `requestBodyValues` / `responseHeaders` (the request context is not - * available at the error-construction sites today; all fields are - * `#[serde(default)]`, so adding them later is not a breaking change). - */ -export type ApiCallError = { -/** - * HTTP status of the response, when it came from one - * (`APICallError.statusCode`). Always the *observed* status: the HTTP - * layer fills it for every response-derived error; errors built without - * an HTTP exchange leave it `None`. - */ -status_code: number | null, -/** - * Provider's machine-readable code (OpenAI's `code`/`type`, - * e.g. `"rate_limit_exceeded"` vs `"insufficient_quota"`). Never an HTTP - * status. Our normalized take on `APICallError.data`. - */ -provider_code: string | null, -/** - * The provider's message, verbatim. No status prefix. - */ -message: string, -/** - * The raw response body, verbatim (`APICallError.responseBody`) — the - * lossless evidence when `message`/`provider_code` are extractions. - * `None` when the error did not come from a response body. - */ -response_body: string | null, -/** - * Provider-assigned request id, from the `x-request-id` / `request-id` - * response header when one was sent. Filled by the shared HTTP layer. - */ -request_id: string | null, -/** - * Retry hint in milliseconds, distilled from the `retry-after-ms` / - * `retry-after` response headers on a 429. The classification lives in - * `status_code`; this is the matching action input. - */ -retry_after_ms: number | null, -/** - * Whether retrying can help (`APICallError.isRetryable`) — stored at - * construction, exactly like the AI SDK: the response path computes it - * from the status (408/409/429 or 5xx), and the transport path (no response, so - * no status to compute from) sets it to `true` explicitly. - */ -is_retryable: boolean, }; diff --git a/tools/aimux-web/web/src/types/AspectRatio.ts b/tools/aimux-web/web/src/types/AspectRatio.ts deleted file mode 100644 index c466b9b65..000000000 --- a/tools/aimux-web/web/src/types/AspectRatio.ts +++ /dev/null @@ -1,10 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Aspect ratio in `{width}:{height}` format (e.g. `"16:9"`). - * - * Newtype that enforces the `W:H` format used by V4 image and video - * `aspectRatio` options. Aligned with the TS template-literal type - * `` `${number}:${number}` ``. - */ -export type AspectRatio = { width: number, height: number, }; diff --git a/tools/aimux-web/web/src/types/AudioChunk.ts b/tools/aimux-web/web/src/types/AudioChunk.ts deleted file mode 100644 index 062104028..000000000 --- a/tools/aimux-web/web/src/types/AudioChunk.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * A chunk of audio in a streaming transcription request. - */ -export type AudioChunk = { "Binary": Array } | { "Base64": string }; diff --git a/tools/aimux-web/web/src/types/AudioData.ts b/tools/aimux-web/web/src/types/AudioData.ts deleted file mode 100644 index b2ae98665..000000000 --- a/tools/aimux-web/web/src/types/AudioData.ts +++ /dev/null @@ -1,9 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Generated audio: a base64-encoded string or raw binary bytes. - * - * The TS result type is `string | Uint8Array`. Providers should return data - * without unnecessary conversion. - */ -export type AudioData = { "Base64": string } | { "Binary": Array }; diff --git a/tools/aimux-web/web/src/types/AudioInput.ts b/tools/aimux-web/web/src/types/AudioInput.ts deleted file mode 100644 index e513ffda3..000000000 --- a/tools/aimux-web/web/src/types/AudioInput.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Audio input: raw bytes or a base64-encoded string. - */ -export type AudioInput = { "Binary": Array } | { "Base64": string }; diff --git a/tools/aimux-web/web/src/types/BreakKind.ts b/tools/aimux-web/web/src/types/BreakKind.ts deleted file mode 100644 index 43aad850d..000000000 --- a/tools/aimux-web/web/src/types/BreakKind.ts +++ /dev/null @@ -1,3 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type BreakKind = "SystemChanged" | "ToolsChanged" | "ConversationReset" | "Unknown"; diff --git a/tools/aimux-web/web/src/types/CallOptions.ts b/tools/aimux-web/web/src/types/CallOptions.ts deleted file mode 100644 index 5e9056a60..000000000 --- a/tools/aimux-web/web/src/types/CallOptions.ts +++ /dev/null @@ -1,114 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { LanguageModelPromptMessage } from "./LanguageModelPromptMessage"; -import type { ReasoningEffort } from "./ReasoningEffort"; -import type { ResponseFormat } from "./ResponseFormat"; -import type { TimeoutConfiguration } from "./TimeoutConfiguration"; -import type { Tool } from "./Tool"; -import type { ToolChoice } from "./ToolChoice"; -import type { JsonValue } from "./serde_json/JsonValue"; - -/** - * Options passed to `LanguageModel::do_generate` / `do_stream`. - * - * This is the **provider-facing** options struct. Users interact with - * `GenerateTextOptions` (user-facing) which is converted to `CallOptions` - * by the `generate_text` / `stream_text` functions. - */ -export type CallOptions = { -/** - * The standardized prompt (message array). Required. - */ -prompt: Array, -/** - * Maximum tokens to generate. - */ -max_output_tokens: number | null, -/** - * Sampling temperature. - */ -temperature: number | null, -/** - * Stop sequences. - */ -stop_sequences: Array | null, -/** - * Nucleus sampling `top_p`. - */ -top_p: number | null, -/** - * Top-k sampling. - */ -top_k: number | null, -/** - * Presence penalty. - */ -presence_penalty: number | null, -/** - * Frequency penalty. - */ -frequency_penalty: number | null, -/** - * Response format (text or JSON). - */ -response_format: ResponseFormat | null, -/** - * Seed for reproducibility. - */ -seed: number | null, -/** - * Tools available to the model (function tools and/or provider-defined tools). - */ -tools: Array | null, -/** - * How the model should choose tools. - */ -tool_choice: ToolChoice, -/** - * Extra HTTP headers. - */ -headers: { [key in string]: string } | null, -/** - * Provider-specific options (keyed by provider name). - */ -provider_options: { [key in string]: JsonValue } | null, -/** - * Top-level reasoning effort. Maps to OpenAI `reasoning_effort` and - * Anthropic `thinking` config. - */ -reasoning: ReasoningEffort | null, -/** - * Per-call request body overrides. Deep-merged into the provider-built - * request body (after any built-in vendor override) before sending. - * `null` values delete the corresponding key. See RFC-0017. - */ -body_overrides: JsonValue | null, -/** - * Per-call retry count override. `None` uses the provider's configured - * `RetryConfig.max_retries`. `Some(0)` disables retries. - */ -max_retries: number | null, -/** - * Per-call timeout configuration (total / first-chunk / chunk idle). - * `None` = no timeouts (provider defaults still apply at the HTTP layer). - */ -timeout: TimeoutConfiguration | null, -/** - * Session identifier, for grouping consecutive calls into a session - * (observability, see RFC-0024). Explicit values take precedence; when - * `None` and the optional session inferer is enabled, one may be inferred. - * Orthogonal to RFC-0019 session-affinity headers: this field is for - * local grouping, while session headers in `headers` are for upstream - * routing — both may share an id value but travel different paths. - */ -session_id: string | null, -/** - * Emit raw provider stream chunks as `StreamPart::Raw` (debugging aid). - * When `Some(true)`, streaming providers yield one `Raw` part per JSON - * SSE event, carrying the **parsed JSON payload** of the event, emitted - * before the parsed parts. Excludes the `[DONE]` sentinel; unparsable - * chunks emit only `Error`. `null`/`Some(false)` = off. - * Currently honored by the OpenAI-compatible family (openai / azure / - * openai-compatible registry providers); other provider families ignore - * it for now (RFC-0016 M2). - */ -include_raw_chunks: boolean | null, }; diff --git a/tools/aimux-web/web/src/types/CatalogueSource.ts b/tools/aimux-web/web/src/types/CatalogueSource.ts deleted file mode 100644 index 5fa0754bc..000000000 --- a/tools/aimux-web/web/src/types/CatalogueSource.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Where a [`ModelSpec`] came from. - */ -export type CatalogueSource = "Anya2a" | "ModelsDev" | "Manual"; diff --git a/tools/aimux-web/web/src/types/ChatCompletion.ts b/tools/aimux-web/web/src/types/ChatCompletion.ts deleted file mode 100644 index 62b7f7f97..000000000 --- a/tools/aimux-web/web/src/types/ChatCompletion.ts +++ /dev/null @@ -1,10 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ChatCompletionChoice } from "./ChatCompletionChoice"; -import type { ChatCompletionUsage } from "./ChatCompletionUsage"; - -/** - * A complete Chat Completion response (non-streaming). - * - * Mirrors the OpenAI `chat.completion` object. - */ -export type ChatCompletion = { id: string, object: string, created: bigint, model: string, choices: Array, usage: ChatCompletionUsage, system_fingerprint: string | null, }; diff --git a/tools/aimux-web/web/src/types/ChatCompletionChoice.ts b/tools/aimux-web/web/src/types/ChatCompletionChoice.ts deleted file mode 100644 index c9f24fde1..000000000 --- a/tools/aimux-web/web/src/types/ChatCompletionChoice.ts +++ /dev/null @@ -1,5 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ChatCompletionMessage } from "./ChatCompletionMessage"; -import type { JsonValue } from "./serde_json/JsonValue"; - -export type ChatCompletionChoice = { index: number, message: ChatCompletionMessage, finish_reason: string | null, logprobs: JsonValue | null, }; diff --git a/tools/aimux-web/web/src/types/ChatCompletionChunk.ts b/tools/aimux-web/web/src/types/ChatCompletionChunk.ts deleted file mode 100644 index 97bd66a31..000000000 --- a/tools/aimux-web/web/src/types/ChatCompletionChunk.ts +++ /dev/null @@ -1,10 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ChatCompletionChunkChoice } from "./ChatCompletionChunkChoice"; -import type { ChatCompletionUsage } from "./ChatCompletionUsage"; - -/** - * A single Chat Completion chunk (streaming). - * - * Mirrors the OpenAI `chat.completion.chunk` object. - */ -export type ChatCompletionChunk = { id: string, object: string, created: bigint, model: string, choices: Array, usage: ChatCompletionUsage | null, }; diff --git a/tools/aimux-web/web/src/types/ChatCompletionChunkChoice.ts b/tools/aimux-web/web/src/types/ChatCompletionChunkChoice.ts deleted file mode 100644 index e56df7c79..000000000 --- a/tools/aimux-web/web/src/types/ChatCompletionChunkChoice.ts +++ /dev/null @@ -1,5 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ChatCompletionDelta } from "./ChatCompletionDelta"; -import type { JsonValue } from "./serde_json/JsonValue"; - -export type ChatCompletionChunkChoice = { index: number, delta: ChatCompletionDelta, finish_reason: string | null, logprobs: JsonValue | null, }; diff --git a/tools/aimux-web/web/src/types/ChatCompletionChunkFunction.ts b/tools/aimux-web/web/src/types/ChatCompletionChunkFunction.ts deleted file mode 100644 index b42f91a3d..000000000 --- a/tools/aimux-web/web/src/types/ChatCompletionChunkFunction.ts +++ /dev/null @@ -1,3 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type ChatCompletionChunkFunction = { name: string | null, arguments: string | null, }; diff --git a/tools/aimux-web/web/src/types/ChatCompletionChunkToolCall.ts b/tools/aimux-web/web/src/types/ChatCompletionChunkToolCall.ts deleted file mode 100644 index d54deb19f..000000000 --- a/tools/aimux-web/web/src/types/ChatCompletionChunkToolCall.ts +++ /dev/null @@ -1,4 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ChatCompletionChunkFunction } from "./ChatCompletionChunkFunction"; - -export type ChatCompletionChunkToolCall = { index: number, id: string | null, type: string | null, function: ChatCompletionChunkFunction, }; diff --git a/tools/aimux-web/web/src/types/ChatCompletionDelta.ts b/tools/aimux-web/web/src/types/ChatCompletionDelta.ts deleted file mode 100644 index b668adba8..000000000 --- a/tools/aimux-web/web/src/types/ChatCompletionDelta.ts +++ /dev/null @@ -1,4 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ChatCompletionChunkToolCall } from "./ChatCompletionChunkToolCall"; - -export type ChatCompletionDelta = { role: string | null, content: string | null, reasoning_content: string | null, tool_calls: Array | null, }; diff --git a/tools/aimux-web/web/src/types/ChatCompletionFunction.ts b/tools/aimux-web/web/src/types/ChatCompletionFunction.ts deleted file mode 100644 index 58a9fd050..000000000 --- a/tools/aimux-web/web/src/types/ChatCompletionFunction.ts +++ /dev/null @@ -1,3 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type ChatCompletionFunction = { name: string, arguments: string, }; diff --git a/tools/aimux-web/web/src/types/ChatCompletionMessage.ts b/tools/aimux-web/web/src/types/ChatCompletionMessage.ts deleted file mode 100644 index f9e728dc4..000000000 --- a/tools/aimux-web/web/src/types/ChatCompletionMessage.ts +++ /dev/null @@ -1,5 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ChatCompletionToolCall } from "./ChatCompletionToolCall"; -import type { JsonValue } from "./serde_json/JsonValue"; - -export type ChatCompletionMessage = { role: string, content: string | null, reasoning_content: string | null, tool_calls: Array | null, annotations: Array | null, }; diff --git a/tools/aimux-web/web/src/types/ChatCompletionToolCall.ts b/tools/aimux-web/web/src/types/ChatCompletionToolCall.ts deleted file mode 100644 index e91f0aab5..000000000 --- a/tools/aimux-web/web/src/types/ChatCompletionToolCall.ts +++ /dev/null @@ -1,4 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ChatCompletionFunction } from "./ChatCompletionFunction"; - -export type ChatCompletionToolCall = { id: string, type: string, function: ChatCompletionFunction, }; diff --git a/tools/aimux-web/web/src/types/ChatCompletionUsage.ts b/tools/aimux-web/web/src/types/ChatCompletionUsage.ts deleted file mode 100644 index 3306f7cfd..000000000 --- a/tools/aimux-web/web/src/types/ChatCompletionUsage.ts +++ /dev/null @@ -1,5 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { CompletionTokensDetails } from "./CompletionTokensDetails"; -import type { PromptTokensDetails } from "./PromptTokensDetails"; - -export type ChatCompletionUsage = { prompt_tokens: number, completion_tokens: number, total_tokens: number, prompt_tokens_details: PromptTokensDetails | null, completion_tokens_details: CompletionTokensDetails | null, }; diff --git a/tools/aimux-web/web/src/types/CompletionTokensDetails.ts b/tools/aimux-web/web/src/types/CompletionTokensDetails.ts deleted file mode 100644 index 4b25482aa..000000000 --- a/tools/aimux-web/web/src/types/CompletionTokensDetails.ts +++ /dev/null @@ -1,3 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type CompletionTokensDetails = { reasoning_tokens: number | null, }; diff --git a/tools/aimux-web/web/src/types/ContentPart.ts b/tools/aimux-web/web/src/types/ContentPart.ts deleted file mode 100644 index 56108da87..000000000 --- a/tools/aimux-web/web/src/types/ContentPart.ts +++ /dev/null @@ -1,72 +0,0 @@ -// 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"; - -/** - * A part of a multi-part message. - * - * Shared between `ModelMessage` (user-facing) and `LanguageModelPrompt` (provider-facing). - */ -export type ContentPart = { "type": "text", text: string, -/** - * Provider-specific options for this part (e.g. `openai.promptCacheBreakpoint`). - */ -provider_options?: JsonValue | null, } | { "type": "image", image: Array, media_type: string, provider_options?: JsonValue | null, } | { "type": "file", data: Array, media_type: string, filename?: string | null, provider_options?: JsonValue | null, } | { "type": "file_base64", data: string, media_type: string, filename?: string | null, provider_options?: JsonValue | null, } | { "type": "file_url", url: string, media_type: string, provider_options?: JsonValue | null, } | { "type": "file_reference", media_type: string, reference: JsonValue, filename?: string | null, provider_options?: JsonValue | null, } | { "type": "reasoning", text: string, signature: string | null, -/** - * Provider-specific options for this part (e.g. - * `anthropic.signature`, `anthropic.redactedData`, - * `anthropic.cacheControl`). - */ -provider_options?: JsonValue | null, } | { "type": "tool_call", tool_call_id: string, tool_name: string, -/** - * Arguments as a JSON value (usually an object). - */ -input: JsonValue, -/** - * Whether the tool call is executed by the provider rather than by - * the client. This is part of the prompt contract because providers - * need it to replay server tool calls on a later turn. - */ -provider_executed?: boolean | null, -/** - * Provider-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`). - */ -provider_options?: JsonValue | null, } | { "type": "tool_result", tool_call_id: string, -/** - * The tool's output (usually a JSON value or plain text). - * - * Accepts the legacy `output` field name (used by the Vercel AI SDK - * and the 0.1.0 TypeScript bindings) as an alias during deserialization, - * so multi-part tool messages constructed either way round-trip - * correctly. Serialization always emits `result`. - */ -result: JsonValue, -/** - * The name of the tool that produced this result (optional on the - * user-input side; providers that need it can look it up from the - * preceding tool call). - */ -tool_name?: string | null, -/** - * 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, -/** - * Provider-specific options for this part (e.g. - * `anthropic.cacheControl`). - */ -provider_options?: JsonValue | null, }; diff --git a/tools/aimux-web/web/src/types/EmbeddingCallOptions.ts b/tools/aimux-web/web/src/types/EmbeddingCallOptions.ts deleted file mode 100644 index c78a34769..000000000 --- a/tools/aimux-web/web/src/types/EmbeddingCallOptions.ts +++ /dev/null @@ -1,21 +0,0 @@ -// 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"; - -/** - * Options passed to [`EmbeddingModel::do_embed`]. - * - * Aligned with V4 `EmbeddingModelV4CallOptions`. - */ -export type EmbeddingCallOptions = { -/** - * List of text values to generate embeddings for. - */ -values: Array, -/** - * Additional provider-specific options, keyed by provider name. - */ -provider_options: { [key in string]: JsonValue } | null, -/** - * Additional HTTP headers to send with the request. - */ -headers: { [key in string]: string } | null, }; diff --git a/tools/aimux-web/web/src/types/EmbeddingResponse.ts b/tools/aimux-web/web/src/types/EmbeddingResponse.ts deleted file mode 100644 index c758f7b32..000000000 --- a/tools/aimux-web/web/src/types/EmbeddingResponse.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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"; - -/** - * Debugging response info specific to embedding calls. - */ -export type EmbeddingResponse = { -/** - * Response headers. - */ -headers: { [key in string]: string } | null, -/** - * The response body (opaque JSON). - */ -body: JsonValue | null, }; diff --git a/tools/aimux-web/web/src/types/EmbeddingResult.ts b/tools/aimux-web/web/src/types/EmbeddingResult.ts deleted file mode 100644 index 81b741d23..000000000 --- a/tools/aimux-web/web/src/types/EmbeddingResult.ts +++ /dev/null @@ -1,32 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { EmbeddingResponse } from "./EmbeddingResponse"; -import type { EmbeddingUsage } from "./EmbeddingUsage"; -import type { Warning } from "./Warning"; -import type { JsonValue } from "./serde_json/JsonValue"; - -/** - * The result of [`EmbeddingModel::do_embed`]. - * - * Aligned with V4 `EmbeddingModelV4Result`. - */ -export type EmbeddingResult = { -/** - * Generated embeddings, in the same order as the input `values`. - */ -embeddings: Array>, -/** - * Token usage (input tokens only). - */ -usage: EmbeddingUsage | null, -/** - * Additional provider-specific metadata. - */ -provider_metadata: { [key in string]: JsonValue } | null, -/** - * Optional response information for debugging. - */ -response: EmbeddingResponse | null, -/** - * Warnings for the call, e.g. unsupported settings. - */ -warnings: Array, }; diff --git a/tools/aimux-web/web/src/types/EmbeddingUsage.ts b/tools/aimux-web/web/src/types/EmbeddingUsage.ts deleted file mode 100644 index 08a49377b..000000000 --- a/tools/aimux-web/web/src/types/EmbeddingUsage.ts +++ /dev/null @@ -1,10 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Token usage for an embedding call. Embeddings only report input tokens. - */ -export type EmbeddingUsage = { -/** - * Number of input tokens consumed. - */ -tokens: number, }; diff --git a/tools/aimux-web/web/src/types/FileBytes.ts b/tools/aimux-web/web/src/types/FileBytes.ts deleted file mode 100644 index b06229434..000000000 --- a/tools/aimux-web/web/src/types/FileBytes.ts +++ /dev/null @@ -1,10 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Either raw bytes or a base64-encoded string. - * - * Providers should pass file data through without unnecessary conversion: if - * the upstream API returns base64, return [`FileBytes::Base64`]; if it - * returns binary, return [`FileBytes::Binary`]. - */ -export type FileBytes = { "Binary": Array } | { "Base64": string }; diff --git a/tools/aimux-web/web/src/types/FileData.ts b/tools/aimux-web/web/src/types/FileData.ts deleted file mode 100644 index 1fdfc2444..000000000 --- a/tools/aimux-web/web/src/types/FileData.ts +++ /dev/null @@ -1,9 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { FileBytes } from "./FileBytes"; - -/** - * File data as a tagged discriminated union. - * - * Aligned with V4 `SharedV4FileData`. - */ -export type FileData = { "Data": { data: FileBytes, } } | { "Url": { url: string, } } | { "Reference": { reference: { [key in string]: string }, } } | { "Text": { text: string, } }; diff --git a/tools/aimux-web/web/src/types/FilePart.ts b/tools/aimux-web/web/src/types/FilePart.ts deleted file mode 100644 index b4676404e..000000000 --- a/tools/aimux-web/web/src/types/FilePart.ts +++ /dev/null @@ -1,7 +0,0 @@ -// 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"; - -/** - * A file generated by the model, extracted to the top level (M7). - */ -export type FilePart = { data: FileData, media_type: string, }; diff --git a/tools/aimux-web/web/src/types/Fingerprint.ts b/tools/aimux-web/web/src/types/Fingerprint.ts deleted file mode 100644 index 0849548a0..000000000 --- a/tools/aimux-web/web/src/types/Fingerprint.ts +++ /dev/null @@ -1,20 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Serialized-wire fingerprint stored in a `TraceRecord` (hex strings — - * `u128` is not JSON-serializable). - */ -export type Fingerprint = { -/** - * 128-bit hash of the whole denoised body (hex). - */ -body_hash: string, len_bytes: bigint, block_size: bigint, -/** - * Block-hash chain (hex), one entry per block. - */ -block_hashes: Array, -/** - * Token estimate — byte proxy `len_bytes / 4` when no tokenizer is - * attached (RFC-0015 §4.1: W capped at one block in this mode). - */ -token_estimate: bigint, }; diff --git a/tools/aimux-web/web/src/types/FinishReason.ts b/tools/aimux-web/web/src/types/FinishReason.ts deleted file mode 100644 index 3e557eb11..000000000 --- a/tools/aimux-web/web/src/types/FinishReason.ts +++ /dev/null @@ -1,15 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { FinishReasonUnified } from "./FinishReasonUnified"; - -/** - * Unified finish reason (why the model stopped generating). - */ -export type FinishReason = { -/** - * The unified reason. - */ -unified: FinishReasonUnified, -/** - * The raw provider-specific reason string. - */ -raw: string | null, }; diff --git a/tools/aimux-web/web/src/types/FinishReasonUnified.ts b/tools/aimux-web/web/src/types/FinishReasonUnified.ts deleted file mode 100644 index cf09e862d..000000000 --- a/tools/aimux-web/web/src/types/FinishReasonUnified.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Unified finish reason enum (aligned with V4 `LanguageModelV4FinishReason`). - */ -export type FinishReasonUnified = "stop" | "length" | "content-filter" | "tool-calls" | "error" | "other"; diff --git a/tools/aimux-web/web/src/types/FunctionTool.ts b/tools/aimux-web/web/src/types/FunctionTool.ts deleted file mode 100644 index a79887e0c..000000000 --- a/tools/aimux-web/web/src/types/FunctionTool.ts +++ /dev/null @@ -1,34 +0,0 @@ -// 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"; - -/** - * A tool definition passed to the model in `CallOptions.tools`. - */ -export type FunctionTool = { -/** - * Unique tool name. - */ -name: string, -/** - * Human-readable description. - */ -description: string | null, -/** - * JSON Schema describing the tool's parameters. - */ -input_schema: JsonValue, -/** - * Whether the tool supports strict schema enforcement (OpenAI strict mode). - */ -strict: boolean | null, -/** - * Provider-specific options, keyed by provider name - * (e.g. `{"anthropic": {"eagerInputStreaming": true}}`). Aligned with the - * V4 `providerOptions` field on function tools. - */ -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, }; diff --git a/tools/aimux-web/web/src/types/GenerateContent.ts b/tools/aimux-web/web/src/types/GenerateContent.ts deleted file mode 100644 index a64676c52..000000000 --- a/tools/aimux-web/web/src/types/GenerateContent.ts +++ /dev/null @@ -1,43 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { FileData } from "./FileData"; -import type { JsonValue } from "./serde_json/JsonValue"; - -/** - * 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, input: JsonValue, -/** - * 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, } }; diff --git a/tools/aimux-web/web/src/types/GenerateObjectResult.ts b/tools/aimux-web/web/src/types/GenerateObjectResult.ts deleted file mode 100644 index 8c46e3e1a..000000000 --- a/tools/aimux-web/web/src/types/GenerateObjectResult.ts +++ /dev/null @@ -1,49 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { FinishReason } from "./FinishReason"; -import type { GenerateTextResult } from "./GenerateTextResult"; -import type { ResponseMetadata } from "./ResponseMetadata"; -import type { Usage } from "./Usage"; -import type { Warning } from "./Warning"; -import type { JsonValue } from "./serde_json/JsonValue"; - -/** - * Result of `generate_object` (user-facing, M12). The parsed JSON object plus - * convenience fields from the underlying `generate_text` call. - */ -export type GenerateObjectResult = { -/** - * The parsed JSON object returned by the model. - */ -object: JsonValue, -/** - * Why generation stopped. - */ -finish_reason: FinishReason, -/** - * Raw provider-specific finish reason string. - */ -raw_finish_reason: string | null, -/** - * Token usage. - */ -usage: Usage, -/** - * Warnings from the provider. - */ -warnings: Array, -/** - * Concatenated reasoning text (if the model produced reasoning/thinking). - */ -reasoning: string | null, -/** - * Provider-specific metadata (e.g. Anthropic cache info). - */ -provider_metadata: JsonValue | null, -/** - * Response metadata (id, timestamp, model_id). - */ -response: ResponseMetadata, -/** - * The full `generate_text` result (for advanced use). - */ -raw: GenerateTextResult, }; diff --git a/tools/aimux-web/web/src/types/GenerateResult.ts b/tools/aimux-web/web/src/types/GenerateResult.ts deleted file mode 100644 index 4667bfc23..000000000 --- a/tools/aimux-web/web/src/types/GenerateResult.ts +++ /dev/null @@ -1,44 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { FinishReason } from "./FinishReason"; -import type { GenerateContent } from "./GenerateContent"; -import type { ResponseMetadata } from "./ResponseMetadata"; -import type { Usage } from "./Usage"; -import type { Warning } from "./Warning"; -import type { JsonValue } from "./serde_json/JsonValue"; - -/** - * Result of `LanguageModel::do_generate` (non-streaming). - */ -export type GenerateResult = { -/** - * Generated content items (text, tool calls, etc.). - */ -content: Array, -/** - * Why generation stopped. - */ -finish_reason: FinishReason, -/** - * Token usage. - */ -usage: Usage, -/** - * Warnings issued by the provider. - */ -warnings: Array, -/** - * Provider-specific metadata. - */ -provider_metadata: JsonValue | null, -/** - * Response metadata (id, timestamp, model_id). - */ -response: ResponseMetadata, -/** - * The request body that was sent (for debugging). - */ -request_body: JsonValue | null, -/** - * Response headers. - */ -response_headers: { [key in string]: string } | null, }; diff --git a/tools/aimux-web/web/src/types/GenerateTextOptions.ts b/tools/aimux-web/web/src/types/GenerateTextOptions.ts deleted file mode 100644 index 00b49da24..000000000 --- a/tools/aimux-web/web/src/types/GenerateTextOptions.ts +++ /dev/null @@ -1,46 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ReasoningEffort } from "./ReasoningEffort"; -import type { ResponseFormat } from "./ResponseFormat"; -import type { TimeoutConfiguration } from "./TimeoutConfiguration"; -import type { Tool } from "./Tool"; -import type { ToolChoice } from "./ToolChoice"; -import type { JsonValue } from "./serde_json/JsonValue"; - -/** - * User-facing options for `generate_text` and `stream_text`. - * - * Unlike `CallOptions` (provider-facing), this does not include `prompt` - * (passed separately) and defaults are more ergonomic. - */ -export type GenerateTextOptions = { max_output_tokens: number | null, temperature: number | null, stop_sequences: Array | null, top_p: number | null, top_k: number | null, presence_penalty: number | null, frequency_penalty: number | null, response_format: ResponseFormat | null, seed: number | null, tools: Array | null, tool_choice: ToolChoice | null, headers: { [key in string]: string } | null, provider_options: { [key in string]: JsonValue } | null, -/** - * Top-level reasoning effort. - */ -reasoning: ReasoningEffort | null, -/** - * System instructions prepended to the prompt. - */ -instructions: string | null, -/** - * Per-call request body overrides (deep-merged). See RFC-0017. - */ -body_overrides: JsonValue | null, -/** - * Per-call retry count override. `None` = provider default, `Some(0)` = disable. - */ -max_retries: number | null, -/** - * Per-call timeout configuration (total / first-chunk / chunk idle). - */ -timeout: TimeoutConfiguration | null, -/** - * Session identifier, for grouping consecutive calls into a session - * (observability, see RFC-0024). Orthogonal to RFC-0019 session-affinity - * headers. When `None` and the optional session inferer is enabled, one - * may be inferred from prompt-prefix continuation. - */ -session_id: string | null, -/** - * Emit raw provider stream chunks as `StreamPart::Raw` (debugging aid). - */ -include_raw_chunks: boolean | null, }; diff --git a/tools/aimux-web/web/src/types/GenerateTextResult.ts b/tools/aimux-web/web/src/types/GenerateTextResult.ts deleted file mode 100644 index a677b7004..000000000 --- a/tools/aimux-web/web/src/types/GenerateTextResult.ts +++ /dev/null @@ -1,82 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { FilePart } from "./FilePart"; -import type { FinishReason } from "./FinishReason"; -import type { GenerateResult } from "./GenerateResult"; -import type { ModelMessage } from "./ModelMessage"; -import type { ReasoningPart } from "./ReasoningPart"; -import type { ResponseMetadata } from "./ResponseMetadata"; -import type { SourcePart } from "./SourcePart"; -import type { ToolCall } from "./ToolCall"; -import type { Usage } from "./Usage"; -import type { Warning } from "./Warning"; -import type { JsonValue } from "./serde_json/JsonValue"; - -/** - * Result of `generate_text` (user-facing). - */ -export type GenerateTextResult = { -/** - * The generated text (concatenated from all text content parts). - */ -text: string, -/** - * Tool calls requested by the model. - */ -tool_calls: Array, -/** - * Why generation stopped. - */ -finish_reason: FinishReason, -/** - * Token usage. - */ -usage: Usage, -/** - * Warnings from the provider. - */ -warnings: Array, -/** - * Raw provider result (for advanced use). - */ -raw: GenerateResult, -/** - * Reasoning / thinking segments from the model. - */ -reasoning: Array, -/** - * Concatenated reasoning text (convenience for `reasoning.iter().map(text).join("")`). - */ -reasoning_text: string, -/** - * Sources / citations (search-preview models). - */ -sources: Array, -/** - * Files generated by the model. - */ -files: Array, -/** - * Assistant messages ready to append to the prompt for the next turn - * (solves the multi-turn "manually build assistant message" footgun). - */ -response_messages: Array, -/** - * The raw provider-specific finish reason string (e.g. "stop", - * "end_turn", "safety"). Useful when `finish_reason.unified` is `Other`. - */ -raw_finish_reason: string | null, -/** - * Provider-specific metadata (e.g. Anthropic cache info). Mirrored from - * `raw.provider_metadata` for top-level convenience. - */ -provider_metadata: JsonValue | null, -/** - * Response metadata (id, timestamp, model_id). Mirrored from - * `raw.response` for top-level convenience. - */ -response: ResponseMetadata, -/** - * Total token usage across all steps. In single-step mode (aimux's - * default), `total_usage` equals `usage`. Provided for AI SDK parity. - */ -total_usage: Usage, }; diff --git a/tools/aimux-web/web/src/types/HttpExchange.ts b/tools/aimux-web/web/src/types/HttpExchange.ts deleted file mode 100644 index 2d87f56c0..000000000 --- a/tools/aimux-web/web/src/types/HttpExchange.ts +++ /dev/null @@ -1,21 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { HttpRecord } from "./HttpRecord"; -import type { ResponseRecord } from "./ResponseRecord"; -import type { TimingRecord } from "./TimingRecord"; - -/** - * ③ HTTP 侧:单次 attempt 的 wire 交换。 - */ -export type HttpExchange = { -/** - * 第几次重试(0=首次);per-attempt 递增。 - */ -attempt: number, request: HttpRecord, -/** - * None = 请求失败未获响应。 - */ -response: ResponseRecord | null, timing: TimingRecord, error: string | null, -/** - * 流式:该 exchange 是否已终结(收到 response 补全)。非流式恒 true。 - */ -finalized: boolean, }; diff --git a/tools/aimux-web/web/src/types/HttpRecord.ts b/tools/aimux-web/web/src/types/HttpRecord.ts deleted file mode 100644 index 71c348207..000000000 --- a/tools/aimux-web/web/src/types/HttpRecord.ts +++ /dev/null @@ -1,11 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type HttpRecord = { method: string, url: string, -/** - * 敏感头(authorization/cookie/含 api-key/key/x-amz-security-token 等)已脱敏为 "[REDACTED]"。 - */ -headers: Array<[string, string]>, -/** - * 明文(脱敏后);None = 无 body。 - */ -body: string | null, }; diff --git a/tools/aimux-web/web/src/types/ImageCallOptions.ts b/tools/aimux-web/web/src/types/ImageCallOptions.ts deleted file mode 100644 index d32de9957..000000000 --- a/tools/aimux-web/web/src/types/ImageCallOptions.ts +++ /dev/null @@ -1,51 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AspectRatio } from "./AspectRatio"; -import type { ImageFile } from "./ImageFile"; -import type { Size } from "./Size"; -import type { JsonValue } from "./serde_json/JsonValue"; - -/** - * Options passed to [`ImageModel::do_generate`]. - * - * Aligned with V4 `ImageModelV4CallOptions`. - */ -export type ImageCallOptions = { -/** - * Prompt for the image generation. `None` for operations (e.g. upscaling) - * that do not require a prompt. - */ -prompt: string | null, -/** - * Number of images to generate. - */ -n: number, -/** - * Size of the images, in `{width}x{height}` format. - * `None` uses the provider's default size. - */ -size: Size | null, -/** - * Aspect ratio of the images, in `{width}:{height}` format. - * `None` uses the provider's default aspect ratio. - */ -aspect_ratio: AspectRatio | null, -/** - * Seed for deterministic generation. `None` uses the provider's default. - */ -seed: number | null, -/** - * Images for image editing or variation generation. - */ -files: Array | null, -/** - * Mask image for inpainting operations. - */ -mask: ImageFile | null, -/** - * Additional provider-specific options, keyed by provider name. - */ -provider_options: { [key in string]: JsonValue }, -/** - * Additional HTTP headers to send with the request. - */ -headers: { [key in string]: string } | null, }; diff --git a/tools/aimux-web/web/src/types/ImageFile.ts b/tools/aimux-web/web/src/types/ImageFile.ts deleted file mode 100644 index 545e7cef5..000000000 --- a/tools/aimux-web/web/src/types/ImageFile.ts +++ /dev/null @@ -1,21 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ImageFileData } from "./ImageFileData"; - -/** - * An image file used for image editing or variation generation. - * - * Aligned with V4 `ImageModelV4File`. - */ -export type ImageFile = { "File": { -/** - * IANA media type, e.g. `"image/png"`. - */ -media_type: string, -/** - * File data as base64 string or binary bytes. - */ -data: ImageFileData, } } | { "Url": { -/** - * The URL of the image file. - */ -url: string, } }; diff --git a/tools/aimux-web/web/src/types/ImageFileData.ts b/tools/aimux-web/web/src/types/ImageFileData.ts deleted file mode 100644 index 96be9636f..000000000 --- a/tools/aimux-web/web/src/types/ImageFileData.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * File payload for an [`ImageFile::File`]: base64 string or raw bytes. - */ -export type ImageFileData = { "Base64": string } | { "Binary": Array }; diff --git a/tools/aimux-web/web/src/types/ImageOutputs.ts b/tools/aimux-web/web/src/types/ImageOutputs.ts deleted file mode 100644 index 38be2705b..000000000 --- a/tools/aimux-web/web/src/types/ImageOutputs.ts +++ /dev/null @@ -1,12 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * The generated images, returned as a homogeneous collection. - * - * The TS result type is `Array | Array` — i.e. either all - * base64 strings or all binary blobs, never mixed. This enum models that - * union. Providers should return data without unnecessary conversion: if the - * upstream API returns base64, return [`ImageOutputs::Base64`]; if binary, - * return [`ImageOutputs::Binary`]. - */ -export type ImageOutputs = { "Base64": Array } | { "Binary": Array> }; diff --git a/tools/aimux-web/web/src/types/ImageResponse.ts b/tools/aimux-web/web/src/types/ImageResponse.ts deleted file mode 100644 index 0f5f33dd0..000000000 --- a/tools/aimux-web/web/src/types/ImageResponse.ts +++ /dev/null @@ -1,18 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Response information for an image generation call. - */ -export type ImageResponse = { -/** - * Timestamp for the start of the generated response (ISO 8601 string). - */ -timestamp: string | null, -/** - * The ID of the model that was used to generate the response. - */ -model_id: string | null, -/** - * Response headers. - */ -headers: { [key in string]: string } | null, }; diff --git a/tools/aimux-web/web/src/types/ImageResult.ts b/tools/aimux-web/web/src/types/ImageResult.ts deleted file mode 100644 index fbf5cbdec..000000000 --- a/tools/aimux-web/web/src/types/ImageResult.ts +++ /dev/null @@ -1,33 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ImageOutputs } from "./ImageOutputs"; -import type { ImageResponse } from "./ImageResponse"; -import type { ImageUsage } from "./ImageUsage"; -import type { Warning } from "./Warning"; -import type { JsonValue } from "./serde_json/JsonValue"; - -/** - * The result of [`ImageModel::do_generate`]. - * - * Aligned with V4 `ImageModelV4Result`. - */ -export type ImageResult = { -/** - * Generated images (base64 strings or binary data). - */ -images: ImageOutputs, -/** - * Warnings for the call, e.g. unsupported features. - */ -warnings: Array, -/** - * Additional provider-specific metadata, keyed by provider name. - */ -provider_metadata: { [key in string]: JsonValue } | null, -/** - * Response information for telemetry and debugging. - */ -response: ImageResponse, -/** - * Optional token usage for the call (if the provider reports it). - */ -usage: ImageUsage | null, }; diff --git a/tools/aimux-web/web/src/types/ImageUsage.ts b/tools/aimux-web/web/src/types/ImageUsage.ts deleted file mode 100644 index 86fb55df9..000000000 --- a/tools/aimux-web/web/src/types/ImageUsage.ts +++ /dev/null @@ -1,20 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Token usage for an image generation call (if the provider reports it). - * - * Aligned with V4 `ImageModelV4Usage`. - */ -export type ImageUsage = { -/** - * Number of input (prompt) tokens used. - */ -input_tokens: number | null, -/** - * Number of output tokens used, if reported. - */ -output_tokens: number | null, -/** - * Total tokens as reported by the provider. - */ -total_tokens: number | null, }; diff --git a/tools/aimux-web/web/src/types/InputAudioFormat.ts b/tools/aimux-web/web/src/types/InputAudioFormat.ts deleted file mode 100644 index c45946104..000000000 --- a/tools/aimux-web/web/src/types/InputAudioFormat.ts +++ /dev/null @@ -1,14 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * The input audio format for a streaming transcription request. - */ -export type InputAudioFormat = { -/** - * Audio format type, e.g. `"audio/pcm"`, `"audio/pcmu"`, `"audio/pcma"`. - */ -format_type: string, -/** - * Sample rate in Hz. Only applicable for formats that require a rate. - */ -rate: number | null, }; diff --git a/tools/aimux-web/web/src/types/InputRecord.ts b/tools/aimux-web/web/src/types/InputRecord.ts deleted file mode 100644 index fb203c0ba..000000000 --- a/tools/aimux-web/web/src/types/InputRecord.ts +++ /dev/null @@ -1,17 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { LanguageModelPromptMessage } from "./LanguageModelPromptMessage"; -import type { JsonValue } from "./serde_json/JsonValue"; - -/** - * ① 输入侧:完整调用参数,足以重建 generate_text 调用。 - */ -export type InputRecord = { -/** - * 完整 prompt(消息数组,含 ContentPart::Image 等多模态)。 - */ -prompt: Array, -/** - * 序列化的 CallOptions(abort_signal/call_id 已 serde skip); - * headers/provider_options/body_overrides 已递归脱敏。 - */ -options: JsonValue, }; diff --git a/tools/aimux-web/web/src/types/LanguageModelPromptMessage.ts b/tools/aimux-web/web/src/types/LanguageModelPromptMessage.ts deleted file mode 100644 index b2e3efa50..000000000 --- a/tools/aimux-web/web/src/types/LanguageModelPromptMessage.ts +++ /dev/null @@ -1,19 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ContentPart } from "./ContentPart"; -import type { Role } from "./Role"; -import type { JsonValue } from "./serde_json/JsonValue"; - -/** - * A single provider-facing message. - * - * Unlike `ModelMessage` (which can have string content), this always has - * `content: Vec` — strings are normalized to `ContentPart::Text`. - */ -export type LanguageModelPromptMessage = { role: Role, content: Array, -/** - * Message-level provider-specific options (e.g. - * `anthropic.cacheControl`), applied to the last part of the message when - * converting to a provider prompt. Mirrors the Vercel AI SDK - * `LanguageModelV4Message.providerOptions`. - */ -provider_options: JsonValue | null, }; diff --git a/tools/aimux-web/web/src/types/MessageContent.ts b/tools/aimux-web/web/src/types/MessageContent.ts deleted file mode 100644 index 93a4c9512..000000000 --- a/tools/aimux-web/web/src/types/MessageContent.ts +++ /dev/null @@ -1,7 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ContentPart } from "./ContentPart"; - -/** - * Message body: either a simple string or multi-part content. - */ -export type MessageContent = string | Array; diff --git a/tools/aimux-web/web/src/types/Modality.ts b/tools/aimux-web/web/src/types/Modality.ts deleted file mode 100644 index babed00ac..000000000 --- a/tools/aimux-web/web/src/types/Modality.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * A single input or output modality. - */ -export type Modality = "Text" | "Image" | "Audio" | "Video" | "Pdf" | "Embedding" | "Score" | "Other"; diff --git a/tools/aimux-web/web/src/types/ModelCapabilities.ts b/tools/aimux-web/web/src/types/ModelCapabilities.ts deleted file mode 100644 index e38d3622b..000000000 --- a/tools/aimux-web/web/src/types/ModelCapabilities.ts +++ /dev/null @@ -1,22 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Model-level capability flags (more precise than provider-level `supports_*`). - */ -export type ModelCapabilities = { -/** - * Supports function/tool calling. - */ -tool_call: boolean, -/** - * Supports structured / JSON-schema output. - */ -structured_output: boolean, -/** - * Accepts a `temperature` parameter. - */ -temperature: boolean, -/** - * Accepts multimodal attachments (image/audio/pdf input). - */ -attachment: boolean, }; diff --git a/tools/aimux-web/web/src/types/ModelCost.ts b/tools/aimux-web/web/src/types/ModelCost.ts deleted file mode 100644 index 39e510c55..000000000 --- a/tools/aimux-web/web/src/types/ModelCost.ts +++ /dev/null @@ -1,7 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Pricing (advisory, read-only metadata). Units follow the source catalog - * (anya2a uses USD per 1M tokens). - */ -export type ModelCost = { input?: number | null, output?: number | null, cache_read?: number | null, cache_write?: number | null, }; diff --git a/tools/aimux-web/web/src/types/ModelId.ts b/tools/aimux-web/web/src/types/ModelId.ts deleted file mode 100644 index 3456afef4..000000000 --- a/tools/aimux-web/web/src/types/ModelId.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * A model identifier in `"provider/model-name"` format, e.g. `"openai/gpt-4o"`. - */ -export type ModelId = { provider: string, model: string, }; diff --git a/tools/aimux-web/web/src/types/ModelLimits.ts b/tools/aimux-web/web/src/types/ModelLimits.ts deleted file mode 100644 index 8835a50e6..000000000 --- a/tools/aimux-web/web/src/types/ModelLimits.ts +++ /dev/null @@ -1,18 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Token / request limits for a model. - */ -export type ModelLimits = { -/** - * Maximum total context window (input + output), in tokens. - */ -context?: number | null, -/** - * Maximum output tokens. - */ -output?: number | null, -/** - * Maximum input tokens (when distinct from `context`). - */ -input?: number | null, }; diff --git a/tools/aimux-web/web/src/types/ModelMessage.ts b/tools/aimux-web/web/src/types/ModelMessage.ts deleted file mode 100644 index e971ba523..000000000 --- a/tools/aimux-web/web/src/types/ModelMessage.ts +++ /dev/null @@ -1,8 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { MessageContent } from "./MessageContent"; -import type { Role } from "./Role"; - -/** - * A single user-facing chat message. - */ -export type ModelMessage = { role: Role, content: MessageContent, }; diff --git a/tools/aimux-web/web/src/types/ModelModalities.ts b/tools/aimux-web/web/src/types/ModelModalities.ts deleted file mode 100644 index 6196f1f1d..000000000 --- a/tools/aimux-web/web/src/types/ModelModalities.ts +++ /dev/null @@ -1,7 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { Modality } from "./Modality"; - -/** - * Input/output modality sets. - */ -export type ModelModalities = { input?: Array, output?: Array, }; diff --git a/tools/aimux-web/web/src/types/ModelPrompt.ts b/tools/aimux-web/web/src/types/ModelPrompt.ts deleted file mode 100644 index 1b7668d01..000000000 --- a/tools/aimux-web/web/src/types/ModelPrompt.ts +++ /dev/null @@ -1,9 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ModelMessage } from "./ModelMessage"; - -/** - * What the user passes as `prompt` to `generate_text` / `stream_text`. - * - * Can be a simple string (converted to a single user message) or a list of messages. - */ -export type ModelPrompt = string | Array; diff --git a/tools/aimux-web/web/src/types/ModelSpec.ts b/tools/aimux-web/web/src/types/ModelSpec.ts deleted file mode 100644 index 58eb9bd31..000000000 --- a/tools/aimux-web/web/src/types/ModelSpec.ts +++ /dev/null @@ -1,26 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { CatalogueSource } from "./CatalogueSource"; -import type { ModelCapabilities } from "./ModelCapabilities"; -import type { ModelCost } from "./ModelCost"; -import type { ModelLimits } from "./ModelLimits"; -import type { ModelModalities } from "./ModelModalities"; -import type { ModelType } from "./ModelType"; -import type { ReasoningSpec } from "./ReasoningSpec"; -import type { JsonValue } from "./serde_json/JsonValue"; - -/** - * The full static portrait for a model, supplemented from community knowledge. - * - * All fields are advisory — aimux never auto-applies them in the request path. - * The caller reads them to decide how to configure `GenerateTextOptions` / - * `bodyOverrides`. - */ -export type ModelSpec = { display_name?: string | null, type: ModelType, limits: ModelLimits, modalities: ModelModalities, capabilities: ModelCapabilities, reasoning?: ReasoningSpec | null, cost?: ModelCost | null, source: CatalogueSource, -/** - * Provider name (normalized to aimux registry spelling). - */ -provider?: string | null, -/** - * Verbatim source entry, preserved for forward compatibility / debugging. - */ -raw?: JsonValue | null, }; diff --git a/tools/aimux-web/web/src/types/ModelType.ts b/tools/aimux-web/web/src/types/ModelType.ts deleted file mode 100644 index 00c3284ae..000000000 --- a/tools/aimux-web/web/src/types/ModelType.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Coarse model category, used to route to the right modality surface. - */ -export type ModelType = "Chat" | "Completion" | "Embedding" | "Rerank" | "ImageGen" | "ImageEdit" | "Video" | "Audio" | "Other"; diff --git a/tools/aimux-web/web/src/types/OutcomeRecord.ts b/tools/aimux-web/web/src/types/OutcomeRecord.ts deleted file mode 100644 index f76f2ac96..000000000 --- a/tools/aimux-web/web/src/types/OutcomeRecord.ts +++ /dev/null @@ -1,12 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { OutcomeStatus } from "./OutcomeStatus"; -import type { JsonValue } from "./serde_json/JsonValue"; - -/** - * 最终结果摘要。 - */ -export type OutcomeRecord = { status: OutcomeStatus, finish_reason: string | null, error: string | null, -/** - * 序列化的 Usage。 - */ -usage: JsonValue | null, }; diff --git a/tools/aimux-web/web/src/types/OutcomeStatus.ts b/tools/aimux-web/web/src/types/OutcomeStatus.ts deleted file mode 100644 index ea12e44c2..000000000 --- a/tools/aimux-web/web/src/types/OutcomeStatus.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * 调用终结状态。 - */ -export type OutcomeStatus = "pending" | "success" | "error" | "incomplete" | "cancelled"; diff --git a/tools/aimux-web/web/src/types/PrefixBreak.ts b/tools/aimux-web/web/src/types/PrefixBreak.ts deleted file mode 100644 index 78aa70bdb..000000000 --- a/tools/aimux-web/web/src/types/PrefixBreak.ts +++ /dev/null @@ -1,7 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { BreakKind } from "./BreakKind"; - -/** - * A prefix break between two consecutive records of a session. - */ -export type PrefixBreak = { at_record_id: string, prev_record_id: string, lcp_bytes: bigint, expected_break: boolean, kind: BreakKind, }; diff --git a/tools/aimux-web/web/src/types/PromptTokensDetails.ts b/tools/aimux-web/web/src/types/PromptTokensDetails.ts deleted file mode 100644 index 1f93cdbc4..000000000 --- a/tools/aimux-web/web/src/types/PromptTokensDetails.ts +++ /dev/null @@ -1,3 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type PromptTokensDetails = { cached_tokens: number, cache_write_tokens: number | null, }; diff --git a/tools/aimux-web/web/src/types/ProviderAuditSpec.ts b/tools/aimux-web/web/src/types/ProviderAuditSpec.ts deleted file mode 100644 index 6f60f4e92..000000000 --- a/tools/aimux-web/web/src/types/ProviderAuditSpec.ts +++ /dev/null @@ -1,34 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ProviderFamily } from "./ProviderFamily"; - -/** - * Provider audit parameters (RFC-0015 §7 matrix — the part that lives in - * core; display logic belongs to the CLI). - */ -export type ProviderAuditSpec = { -/** - * Provider family for family-specific invariants. - */ -family: ProviderFamily, -/** - * Token quantization granularity; `None` = no quantization. - */ -gran: bigint | null, -/** - * Minimum cache threshold (tokens): below it `claimed` must be 0. - */ -threshold: bigint, -/** - * TTL_idle (ms), conservative upper bound. - */ -ttl_ms: bigint, -/** - * gpt-5.6+ semantics (no quantization, implicit-breakpoint whitelist, - * write/read equality). - */ -model56plus: boolean, -/** - * Claimed-hit ceiling: above this multiple of the client upper bound - * the verdict is overclaim (W) even in shared mode. `None` = use U+τ. - */ -shared_ceiling_mult: number | null, }; diff --git a/tools/aimux-web/web/src/types/ProviderFamily.ts b/tools/aimux-web/web/src/types/ProviderFamily.ts deleted file mode 100644 index 8f0a8815d..000000000 --- a/tools/aimux-web/web/src/types/ProviderFamily.ts +++ /dev/null @@ -1,7 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Provider family (only families with distinct invariants need dispatch; - * gran/threshold/ttl live in `ProviderAuditSpec`). - */ -export type ProviderFamily = "OpenAi" | "DeepSeek" | "Anthropic" | "Bedrock" | "Gemini" | "Vllm" | "Generic"; diff --git a/tools/aimux-web/web/src/types/ProviderName.ts b/tools/aimux-web/web/src/types/ProviderName.ts deleted file mode 100644 index 34fccb295..000000000 --- a/tools/aimux-web/web/src/types/ProviderName.ts +++ /dev/null @@ -1,262 +0,0 @@ -// ProviderName — GENERATED by `scripts/gen_provider_names.py`. -// Do not edit manually; single source of truth is `provider-registry.json`. - -/** - * Names of all built-in OpenAI-compatible providers. - * Use `ProviderName.groq` etc. as the `name` argument of `provider()`. - */ -export const ProviderName = { - abacus: 'abacus', - abliteration_ai: 'abliteration_ai', - ai21: 'ai21', - ai302: 'ai302', - ai_router: 'ai_router', - aiand: 'aiand', - aibadgr: 'aibadgr', - aigc2d: 'aigc2d', - aihubmix: 'aihubmix', - ails: 'ails', - aiml: 'aiml', - aki_io: 'aki_io', - albert: 'albert', - alibaba: 'alibaba', - alibaba_coding_plan: 'alibaba_coding_plan', - alibaba_coding_plan_cn: 'alibaba_coding_plan_cn', - alibaba_token_plan: 'alibaba_token_plan', - alibaba_token_plan_cn: 'alibaba_token_plan_cn', - ambient: 'ambient', - anyapi: 'anyapi', - anyscale: 'anyscale', - apertis: 'apertis', - api2d: 'api2d', - api2gpt: 'api2gpt', - apiserpent: 'apiserpent', - atlascloud: 'atlascloud', - atomic_chat: 'atomic_chat', - auriko: 'auriko', - azure_ai: 'azure_ai', - baichuan: 'baichuan', - baidu: 'baidu', - baidu_v2: 'baidu_v2', - bailing: 'bailing', - baseten: 'baseten', - berget: 'berget', - bigmodel: 'bigmodel', - blueclaw: 'blueclaw', - bytedance: 'bytedance', - byteplus: 'byteplus', - bytez: 'bytez', - canopywave: 'canopywave', - cerebras: 'cerebras', - chatgpt: 'chatgpt', - cherryin: 'cherryin', - chutes: 'chutes', - clarifai: 'clarifai', - claudinio: 'claudinio', - cline_pass: 'cline_pass', - closeai: 'closeai', - cloudferro_sherlock: 'cloudferro_sherlock', - cloudflare: 'cloudflare', - cloudflare_workers_ai: 'cloudflare_workers_ai', - codestral: 'codestral', - cometapi: 'cometapi', - commandcode: 'commandcode', - compactifai: 'compactifai', - copilot: 'copilot', - cortecs: 'cortecs', - coze: 'coze', - crof: 'crof', - crossmodel: 'crossmodel', - crusoe: 'crusoe', - daoxe: 'daoxe', - darkbloom: 'darkbloom', - databricks: 'databricks', - datarobot: 'datarobot', - deepbricks: 'deepbricks', - deepinfra: 'deepinfra', - deepseek: 'deepseek', - digitalocean: 'digitalocean', - dinference: 'dinference', - doubao: 'doubao', - doubleword: 'doubleword', - drun: 'drun', - ebcloud: 'ebcloud', - embercloud: 'embercloud', - empiriolabs: 'empiriolabs', - evroc: 'evroc', - fastcrw: 'fastcrw', - fastgpt: 'fastgpt', - fastrouter: 'fastrouter', - featherless_ai: 'featherless_ai', - firepass: 'firepass', - fireworks: 'fireworks', - freemodel: 'freemodel', - friendliai: 'friendliai', - frogbot: 'frogbot', - galadriel: 'galadriel', - gdc: 'gdc', - gigachat: 'gigachat', - github: 'github', - gmi: 'gmi', - gmicloud: 'gmicloud', - gonka24: 'gonka24', - gradient_ai: 'gradient_ai', - groq: 'groq', - helicone: 'helicone', - heroku: 'heroku', - hetzner: 'hetzner', - hosted_vllm: 'hosted_vllm', - hpc_ai: 'hpc_ai', - hyperbolic: 'hyperbolic', - iflowcn: 'iflowcn', - inception: 'inception', - inceptron: 'inceptron', - inference_net: 'inference_net', - inferencehub: 'inferencehub', - inferx: 'inferx', - infinity: 'infinity', - io_net: 'io_net', - jiekou: 'jiekou', - kenari: 'kenari', - kilo: 'kilo', - kimi: 'kimi', - kimi_for_coding: 'kimi_for_coding', - kiro: 'kiro', - kluster_ai: 'kluster_ai', - krutrim: 'krutrim', - kuae_cloud_coding_plan: 'kuae_cloud_coding_plan', - lambda_ai: 'lambda_ai', - lemonade: 'lemonade', - lemonfox_ai: 'lemonfox_ai', - libertai: 'libertai', - lilac: 'lilac', - lingyiwanwu: 'lingyiwanwu', - llama: 'llama', - llamagate: 'llamagate', - llmgateway: 'llmgateway', - llmtr: 'llmtr', - longcat: 'longcat', - lucidquery: 'lucidquery', - lynkr: 'lynkr', - matterai: 'matterai', - meganova: 'meganova', - merge_gateway: 'merge_gateway', - meta: 'meta', - meta_llama: 'meta_llama', - mimo: 'mimo', - minimax: 'minimax', - minimax_cn: 'minimax_cn', - minimax_cn_coding_plan: 'minimax_cn_coding_plan', - minimax_coding_plan: 'minimax_coding_plan', - mira: 'mira', - mixlayer: 'mixlayer', - moark: 'moark', - modal: 'modal', - model_oracle_ai: 'model_oracle_ai', - modelscope: 'modelscope', - moonshotai: 'moonshotai', - moonshotai_cn: 'moonshotai_cn', - morph: 'morph', - nanogpt: 'nanogpt', - ncompass: 'ncompass', - nearai: 'nearai', - nebius: 'nebius', - neon: 'neon', - neuralwatt: 'neuralwatt', - nextbit: 'nextbit', - nlp_cloud: 'nlp_cloud', - nous_research: 'nous_research', - novita: 'novita', - nscale: 'nscale', - nvidia_nim: 'nvidia_nim', - oci: 'oci', - ofox: 'ofox', - ohmygpt: 'ohmygpt', - ollama_cloud: 'ollama_cloud', - openaimax: 'openaimax', - openaisb: 'openaisb', - opencode: 'opencode', - opencode_go: 'opencode_go', - opencode_zen: 'opencode_zen', - orcarouter: 'orcarouter', - ovhcloud: 'ovhcloud', - parasail: 'parasail', - perfxcloud: 'perfxcloud', - perplexity: 'perplexity', - perplexity_agent: 'perplexity_agent', - petals: 'petals', - pinstripes: 'pinstripes', - pioneer: 'pioneer', - poe: 'poe', - poolside: 'poolside', - portkey: 'portkey', - ppinfra: 'ppinfra', - predibase: 'predibase', - privatemode_ai: 'privatemode_ai', - publicai: 'publicai', - qihang_ai: 'qihang_ai', - qihoo360: 'qihoo360', - qiniu_ai: 'qiniu_ai', - regolo_ai: 'regolo_ai', - reka_ai: 'reka_ai', - requesty: 'requesty', - reve: 'reve', - routing_run: 'routing_run', - sakana: 'sakana', - sambanova: 'sambanova', - sarvam: 'sarvam', - scaleway: 'scaleway', - scx_ai: 'scx_ai', - siliconflow: 'siliconflow', - snowflake: 'snowflake', - snowflake_cortex: 'snowflake_cortex', - stackit: 'stackit', - stepfun: 'stepfun', - stepfun_ai_step_plan: 'stepfun_ai_step_plan', - stepfun_step_plan: 'stepfun_step_plan', - subconscious: 'subconscious', - submodel: 'submodel', - synthetic: 'synthetic', - tencent: 'tencent', - tencent_coding_plan: 'tencent_coding_plan', - tencent_token_plan: 'tencent_token_plan', - tencent_token_plan_enterprise_auto: 'tencent_token_plan_enterprise_auto', - tencent_token_plan_enterprise_pro: 'tencent_token_plan_enterprise_pro', - tencent_token_plan_general_personal: 'tencent_token_plan_general_personal', - tencent_token_plan_hy_personal: 'tencent_token_plan_hy_personal', - tencent_tokenhub: 'tencent_tokenhub', - tensormesh: 'tensormesh', - the_grid_ai: 'the_grid_ai', - thinkingmachines: 'thinkingmachines', - tinfoil: 'tinfoil', - togetherai: 'togetherai', - tokenflux: 'tokenflux', - tokenpony: 'tokenpony', - trustedrouter: 'trustedrouter', - tundra: 'tundra', - umans_ai: 'umans_ai', - unorouter: 'unorouter', - upstage: 'upstage', - v0: 'v0', - venice: 'venice', - vercel: 'vercel', - vivgrid: 'vivgrid', - volc_engine: 'volc_engine', - vultr: 'vultr', - wafer: 'wafer', - wandb: 'wandb', - xiaomi_token_plan_ams: 'xiaomi_token_plan_ams', - xiaomi_token_plan_cn: 'xiaomi_token_plan_cn', - xiaomi_token_plan_sgp: 'xiaomi_token_plan_sgp', - xiaomimimo: 'xiaomimimo', - xpersona: 'xpersona', - xunfei: 'xunfei', - zai: 'zai', - zai_coding_plan: 'zai_coding_plan', - zeldoc: 'zeldoc', - zenmux: 'zenmux', - zhipu_v4: 'zhipu_v4', - zhipuai_coding_plan: 'zhipuai_coding_plan', -} as const - -export type ProviderName = typeof ProviderName[keyof typeof ProviderName] diff --git a/tools/aimux-web/web/src/types/ProviderRecord.ts b/tools/aimux-web/web/src/types/ProviderRecord.ts deleted file mode 100644 index 4af13e674..000000000 --- a/tools/aimux-web/web/src/types/ProviderRecord.ts +++ /dev/null @@ -1,31 +0,0 @@ -// 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"; - -/** - * ② 配置侧:provider 身份(请求回放重建用)。 - */ -export type ProviderRecord = { -/** - * `model.provider()`,如 "openai"。 - */ -provider: string, -/** - * `model.model_id()`,如 "gpt-4o"。 - */ -model_id: string, -/** - * provider 的 base_url。 - */ -base_url: string | null, -/** - * api_key 来源(不存明文):"env:OPENAI_API_KEY" / "explicit" / "none" / "unknown"。 - */ -api_key_source: string, -/** - * OpenAICompatProfile(能力差异)。 - */ -profile: JsonValue | null, -/** - * ProviderOptions(headers/org/project/...;已脱敏)。 - */ -provider_options: JsonValue | null, }; diff --git a/tools/aimux-web/web/src/types/ProviderTool.ts b/tools/aimux-web/web/src/types/ProviderTool.ts deleted file mode 100644 index c7770fef8..000000000 --- a/tools/aimux-web/web/src/types/ProviderTool.ts +++ /dev/null @@ -1,25 +0,0 @@ -// 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"; - -/** - * A provider-defined tool (e.g. `anthropic.web_search_20250305`, - * `google.googleSearch`). Mirrors the V4 `LanguageModelV4ProviderTool`. - * - * Provider tools are specific to a certain provider. The input and output - * schemas are defined by the provider, and some are executed on the provider's - * servers (e.g. web search, code execution). - */ -export type ProviderTool = { -/** - * The ID of the tool, following the format `.`. - */ -id: string, -/** - * The name of the tool, unique within this model call. - */ -name: string, -/** - * The arguments for configuring the tool. Must match the expected - * arguments defined by the provider for this tool. - */ -args: JsonValue, }; diff --git a/tools/aimux-web/web/src/types/ReasoningEffort.ts b/tools/aimux-web/web/src/types/ReasoningEffort.ts deleted file mode 100644 index b1beb7b01..000000000 --- a/tools/aimux-web/web/src/types/ReasoningEffort.ts +++ /dev/null @@ -1,9 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Reasoning effort level (aligned with V4 `reasoning` option). - * - * Controls how much reasoning/thinking the model performs. Maps to - * OpenAI `reasoning_effort` and Anthropic `thinking` config. - */ -export type ReasoningEffort = "provider-default" | "none" | "minimal" | "low" | "medium" | "high" | "xhigh"; diff --git a/tools/aimux-web/web/src/types/ReasoningMode.ts b/tools/aimux-web/web/src/types/ReasoningMode.ts deleted file mode 100644 index b694e49cb..000000000 --- a/tools/aimux-web/web/src/types/ReasoningMode.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * How reasoning / thinking is controlled for a model. - */ -export type ReasoningMode = "Budget" | "Effort" | "Level" | "Fixed" | "Mixed" | "Toggle" | "Other"; diff --git a/tools/aimux-web/web/src/types/ReasoningPart.ts b/tools/aimux-web/web/src/types/ReasoningPart.ts deleted file mode 100644 index 83feed56b..000000000 --- a/tools/aimux-web/web/src/types/ReasoningPart.ts +++ /dev/null @@ -1,10 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * A reasoning / thinking segment, extracted to the top level (M7). - */ -export type ReasoningPart = { -/** - * The reasoning text. - */ -text: string, }; diff --git a/tools/aimux-web/web/src/types/ReasoningSpec.ts b/tools/aimux-web/web/src/types/ReasoningSpec.ts deleted file mode 100644 index 2ce4266a0..000000000 --- a/tools/aimux-web/web/src/types/ReasoningSpec.ts +++ /dev/null @@ -1,40 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ReasoningMode } from "./ReasoningMode"; -import type { ReasoningVisibility } from "./ReasoningVisibility"; - -/** - * Reasoning / thinking portrait for a model (advisory). - */ -export type ReasoningSpec = { -/** - * Whether the model family supports reasoning at all. - */ -supported: boolean, -/** - * Whether reasoning should be on by default. - */ -default_enabled: boolean, -/** - * Primary control mode. - */ -mode: ReasoningMode | null, -/** - * Default effort level (when `mode` is Effort/Mixed). - */ -effort_default?: string | null, -/** - * Supported effort levels. - */ -effort_options?: Array, -/** - * Minimum token budget (when `mode` is Budget/Mixed). - */ -budget_min?: number | null, -/** - * Interleaved reasoning (thinking blocks between tool calls). - */ -interleaved: boolean, -/** - * Output visibility. - */ -visibility: ReasoningVisibility | null, }; diff --git a/tools/aimux-web/web/src/types/ReasoningVisibility.ts b/tools/aimux-web/web/src/types/ReasoningVisibility.ts deleted file mode 100644 index 9738c8a80..000000000 --- a/tools/aimux-web/web/src/types/ReasoningVisibility.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Visibility of reasoning output to the caller. - */ -export type ReasoningVisibility = "Hidden" | "Summary" | "Full" | "Mixed" | "Omitted" | "Other"; diff --git a/tools/aimux-web/web/src/types/Recording.ts b/tools/aimux-web/web/src/types/Recording.ts deleted file mode 100644 index 3bd5bc35c..000000000 --- a/tools/aimux-web/web/src/types/Recording.ts +++ /dev/null @@ -1,57 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { HttpExchange } from "./HttpExchange"; -import type { InputRecord } from "./InputRecord"; -import type { OutcomeRecord } from "./OutcomeRecord"; -import type { ProviderRecord } from "./ProviderRecord"; - -/** - * 一次完整调用的录制记录(三层 + call_id 关联)。 - */ -export type Recording = { -/** - * 格式版本。 - */ -schema: number, -/** - * 暴露逻辑调用 ID,关联三层。 - */ -call_id: string, -/** - * ISO 8601 时间戳。 - */ -recorded_at: string, -/** - * ① 输入侧:调用参数(prompt + options)。 - */ -input: InputRecord, -/** - * ② 配置侧:provider 身份与配置。 - */ -provider: ProviderRecord, -/** - * ③ HTTP 侧:实际 wire 交换(per-attempt 一条)。 - */ -exchanges: Array, -/** - * 最终结果摘要(状态 + finish_reason + usage)。 - */ -outcome: OutcomeRecord, -/** - * wire 是否完整(流式 body 未补全时为 false;由 writer 标记)。 - */ -complete: boolean, -/** - * 传输层封闭信号:层 B 声明"不再有 exchange"(P1:层 A 收尾自动发; - * P2:由层 B 发送)。false = 仍可能来 exchange(记录暂不可定稿)。 - */ -transport_closed: boolean, -/** - * 会话归组(RFC-0024 P3):所在会话 id。None = 未归组(无 session_id 且 - * 推断关闭)。由 `Recorder::record_session` 填充,写入 InputRecord 之前 - * 或之后均可(writer 端按 call_id 合并)。 - */ -session_id?: string | null, -/** - * 会话内步号(0 起,由 SessionStore 分配)。与 `session_id` 同生命周期。 - */ -step?: number | null, }; diff --git a/tools/aimux-web/web/src/types/RequestCacheHints.ts b/tools/aimux-web/web/src/types/RequestCacheHints.ts deleted file mode 100644 index 9e3ff369d..000000000 --- a/tools/aimux-web/web/src/types/RequestCacheHints.ts +++ /dev/null @@ -1,10 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Request-side cache hints (best-effort, e.g. Anthropic `cache_control`). - */ -export type RequestCacheHints = { -/** - * Whether any message requested `cache_control` (write) this call. - */ -requested_write: boolean, }; diff --git a/tools/aimux-web/web/src/types/RequestInfo.ts b/tools/aimux-web/web/src/types/RequestInfo.ts deleted file mode 100644 index 098d3be65..000000000 --- a/tools/aimux-web/web/src/types/RequestInfo.ts +++ /dev/null @@ -1,13 +0,0 @@ -// 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"; - -/** - * Optional request information for telemetry and debugging. - * - * Aligned with the `request?` object that appears on some V4 model results. - */ -export type RequestInfo = { -/** - * The request body that was sent (opaque JSON). - */ -body: JsonValue | null, }; diff --git a/tools/aimux-web/web/src/types/RerankingCallOptions.ts b/tools/aimux-web/web/src/types/RerankingCallOptions.ts deleted file mode 100644 index 3de78c9a5..000000000 --- a/tools/aimux-web/web/src/types/RerankingCallOptions.ts +++ /dev/null @@ -1,30 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { RerankingDocuments } from "./RerankingDocuments"; -import type { JsonValue } from "./serde_json/JsonValue"; - -/** - * Options passed to [`RerankingModel::do_rerank`]. - * - * Aligned with V4 `RerankingModelV4CallOptions`. - */ -export type RerankingCallOptions = { -/** - * Documents to rerank. - */ -documents: RerankingDocuments, -/** - * The query to rerank the documents against. - */ -query: string, -/** - * Optional limit: return only the top `n` documents. - */ -top_n: number | null, -/** - * Additional provider-specific options, keyed by provider name. - */ -provider_options: { [key in string]: JsonValue } | null, -/** - * Additional HTTP headers to send with the request. - */ -headers: { [key in string]: string } | null, }; diff --git a/tools/aimux-web/web/src/types/RerankingDocuments.ts b/tools/aimux-web/web/src/types/RerankingDocuments.ts deleted file mode 100644 index 2fd5f51f2..000000000 --- a/tools/aimux-web/web/src/types/RerankingDocuments.ts +++ /dev/null @@ -1,9 +0,0 @@ -// 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"; - -/** - * Documents to rerank: either a list of texts or a list of JSON objects. - * - * Aligned with V4 `RerankingModelV4CallOptions.documents`. - */ -export type RerankingDocuments = { "Text": { values: Array, } } | { "Object": { values: Array, } }; diff --git a/tools/aimux-web/web/src/types/RerankingRank.ts b/tools/aimux-web/web/src/types/RerankingRank.ts deleted file mode 100644 index 986d3b2e0..000000000 --- a/tools/aimux-web/web/src/types/RerankingRank.ts +++ /dev/null @@ -1,14 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * A single reranked entry: the original index and its relevance score. - */ -export type RerankingRank = { -/** - * The index of the document in the original list (before reranking). - */ -index: number, -/** - * The relevance score of the document after reranking. - */ -relevance_score: number, }; diff --git a/tools/aimux-web/web/src/types/RerankingResponse.ts b/tools/aimux-web/web/src/types/RerankingResponse.ts deleted file mode 100644 index 83a869529..000000000 --- a/tools/aimux-web/web/src/types/RerankingResponse.ts +++ /dev/null @@ -1,27 +0,0 @@ -// 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"; - -/** - * Optional response information for a reranking call. - */ -export type RerankingResponse = { -/** - * ID for the generated response, if the provider sends one. - */ -id: string | null, -/** - * Timestamp for the start of the generated response (ISO 8601 string). - */ -timestamp: string | null, -/** - * The ID of the model that was used to generate the response. - */ -model_id: string | null, -/** - * Response headers. - */ -headers: { [key in string]: string } | null, -/** - * Response body (opaque JSON). - */ -body: JsonValue | null, }; diff --git a/tools/aimux-web/web/src/types/RerankingResult.ts b/tools/aimux-web/web/src/types/RerankingResult.ts deleted file mode 100644 index d72face95..000000000 --- a/tools/aimux-web/web/src/types/RerankingResult.ts +++ /dev/null @@ -1,30 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { RerankingRank } from "./RerankingRank"; -import type { RerankingResponse } from "./RerankingResponse"; -import type { Warning } from "./Warning"; -import type { JsonValue } from "./serde_json/JsonValue"; - -/** - * The result of [`RerankingModel::do_rerank`]. - * - * Aligned with V4 `RerankingModelV4Result`. - */ -export type RerankingResult = { -/** - * Ordered list of reranked documents, sorted by descending relevance - * score. Each entry's `index` refers to the position in the original - * `documents` list. - */ -ranking: Array, -/** - * Additional provider-specific metadata, keyed by provider name. - */ -provider_metadata: { [key in string]: JsonValue } | null, -/** - * Warnings for the call, e.g. unsupported settings. - */ -warnings: Array | null, -/** - * Optional response information for debugging. - */ -response: RerankingResponse | null, }; diff --git a/tools/aimux-web/web/src/types/ResponseFormat.ts b/tools/aimux-web/web/src/types/ResponseFormat.ts deleted file mode 100644 index 88f2405e1..000000000 --- a/tools/aimux-web/web/src/types/ResponseFormat.ts +++ /dev/null @@ -1,7 +0,0 @@ -// 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"; - -/** - * How the model should format its response. - */ -export type ResponseFormat = "Text" | { "Json": { schema: JsonValue | null, name: string | null, description: string | null, } }; diff --git a/tools/aimux-web/web/src/types/ResponseInfo.ts b/tools/aimux-web/web/src/types/ResponseInfo.ts deleted file mode 100644 index 44514337c..000000000 --- a/tools/aimux-web/web/src/types/ResponseInfo.ts +++ /dev/null @@ -1,29 +0,0 @@ -// 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"; - -/** - * Response information for telemetry and debugging. - * - * Aligned with the `response` object that appears on most V4 model results. - * All fields are optional because different providers populate different - * subsets; traits whose TS spec marks `response` as required use - * `ResponseInfo` directly, while those that mark it optional use - * `Option`. - */ -export type ResponseInfo = { -/** - * Timestamp for the start of the generated response (ISO 8601 string). - */ -timestamp: string | null, -/** - * The ID of the model that was used to generate the response. - */ -model_id: string | null, -/** - * Response headers. - */ -headers: { [key in string]: string } | null, -/** - * The response body (opaque JSON). - */ -body: JsonValue | null, }; diff --git a/tools/aimux-web/web/src/types/ResponseMetadata.ts b/tools/aimux-web/web/src/types/ResponseMetadata.ts deleted file mode 100644 index 4c2084e8c..000000000 --- a/tools/aimux-web/web/src/types/ResponseMetadata.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Metadata about the API response. - */ -export type ResponseMetadata = { id: string | null, timestamp: string | null, model_id: string | null, }; diff --git a/tools/aimux-web/web/src/types/ResponseRecord.ts b/tools/aimux-web/web/src/types/ResponseRecord.ts deleted file mode 100644 index 28abf8d33..000000000 --- a/tools/aimux-web/web/src/types/ResponseRecord.ts +++ /dev/null @@ -1,11 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type ResponseRecord = { status: number, headers: Array<[string, string]>, -/** - * 非流式:完整 JSON;流式:原始 SSE 拼接文本(上限截断)。 - */ -body: string | null, stream_chunks: number | null, -/** - * 首字节延迟(流式)。 - */ -ttfb_ms?: number | null, }; diff --git a/tools/aimux-web/web/src/types/Role.ts b/tools/aimux-web/web/src/types/Role.ts deleted file mode 100644 index c118faae1..000000000 --- a/tools/aimux-web/web/src/types/Role.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Who sent the message. - */ -export type Role = "system" | "user" | "assistant" | "tool"; diff --git a/tools/aimux-web/web/src/types/RuntimeModel.ts b/tools/aimux-web/web/src/types/RuntimeModel.ts deleted file mode 100644 index f12ea8802..000000000 --- a/tools/aimux-web/web/src/types/RuntimeModel.ts +++ /dev/null @@ -1,23 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * A model entry returned by a provider's `/models` endpoint (the sparse, - * account-level truth of "what this key can call"). - * - * Carries only what the provider returns — typically just `id`. Configuration - * comes from [`ModelSpec`] (community knowledge); the host merges the two as - * needed (RFC-0027). aimux itself does not combine them. - */ -export type RuntimeModel = { -/** - * Model identifier, e.g. `"gpt-4o"`. - */ -id: string, -/** - * `owned_by` field from OpenAI-compatible `/models` responses. - */ -owned_by?: string | null, -/** - * `created` unix timestamp from OpenAI-compatible `/models` responses. - */ -created?: number | null, }; diff --git a/tools/aimux-web/web/src/types/SearchCallOptions.ts b/tools/aimux-web/web/src/types/SearchCallOptions.ts deleted file mode 100644 index e11420fef..000000000 --- a/tools/aimux-web/web/src/types/SearchCallOptions.ts +++ /dev/null @@ -1,43 +0,0 @@ -// 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"; - -/** - * Options passed to [`SearchModel::do_search`]. - */ -export type SearchCallOptions = { -/** - * The search query string. - */ -query: string, -/** - * Maximum number of results to return. - */ -max_results: number | null, -/** - * Whether to include raw page content in results. - * - * Provider support varies; providers that cannot honor this should - * issue a warning rather than erroring. - */ -include_raw_content: boolean | null, -/** - * Optional time range filter (e.g. `"day"`, `"week"`, `"month"`, - * `"year"`). Provider support varies. - */ -time_range: string | null, -/** - * Optional list of domains to include in results. - */ -include_domains: Array | null, -/** - * Optional list of domains to exclude from results. - */ -exclude_domains: Array | null, -/** - * Additional provider-specific options, keyed by provider name. - */ -provider_options: { [key in string]: JsonValue } | null, -/** - * Additional HTTP headers to send with the request. - */ -headers: { [key in string]: string } | null, }; diff --git a/tools/aimux-web/web/src/types/SearchResponse.ts b/tools/aimux-web/web/src/types/SearchResponse.ts deleted file mode 100644 index 287e4d97b..000000000 --- a/tools/aimux-web/web/src/types/SearchResponse.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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"; - -/** - * Optional response information for a search call. - */ -export type SearchResponse = { -/** - * Response headers. - */ -headers: { [key in string]: string } | null, -/** - * The response body (opaque JSON). - */ -body: JsonValue | null, }; diff --git a/tools/aimux-web/web/src/types/SearchResult.ts b/tools/aimux-web/web/src/types/SearchResult.ts deleted file mode 100644 index d1e49ac60..000000000 --- a/tools/aimux-web/web/src/types/SearchResult.ts +++ /dev/null @@ -1,31 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { SearchResponse } from "./SearchResponse"; -import type { SearchResultItem } from "./SearchResultItem"; -import type { Warning } from "./Warning"; -import type { JsonValue } from "./serde_json/JsonValue"; - -/** - * The result of [`SearchModel::do_search`]. - */ -export type SearchResult = { -/** - * Ordered list of search results. - */ -results: Array, -/** - * An optional direct answer / summary (some providers return - * an AI-generated answer alongside results). - */ -answer: string | null, -/** - * Additional provider-specific metadata. - */ -provider_metadata: { [key in string]: JsonValue } | null, -/** - * Warnings for the call. - */ -warnings: Array, -/** - * Optional response information for debugging. - */ -response: SearchResponse | null, }; diff --git a/tools/aimux-web/web/src/types/SearchResultItem.ts b/tools/aimux-web/web/src/types/SearchResultItem.ts deleted file mode 100644 index 5d5c81218..000000000 --- a/tools/aimux-web/web/src/types/SearchResultItem.ts +++ /dev/null @@ -1,32 +0,0 @@ -// 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"; - -/** - * A single search result item. - */ -export type SearchResultItem = { -/** - * The title of the result (e.g. page title). - */ -title: string | null, -/** - * The URL of the result. - */ -url: string | null, -/** - * A snippet/summary of the result content. - */ -content: string | null, -/** - * Raw page content (when `include_raw_content` is requested and - * supported by the provider). - */ -raw_content: string | null, -/** - * A relevance score (0.0–1.0) if the provider returns one. - */ -score: number | null, -/** - * Provider-specific metadata for this result. - */ -provider_metadata: { [key in string]: JsonValue } | null, }; diff --git a/tools/aimux-web/web/src/types/SessionCall.ts b/tools/aimux-web/web/src/types/SessionCall.ts deleted file mode 100644 index 695d5a603..000000000 --- a/tools/aimux-web/web/src/types/SessionCall.ts +++ /dev/null @@ -1,21 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * A single call within a session (an index entry only — call content lives - * in Recording (RFC-0023) / TraceRecord (RFC-0015)). - */ -export type SessionCall = { -/** - * Call-level unique id. This is the association key for the future - * Recording / TraceRecord `call_id` (RFC-0015/0023): once those land, - * this field carries their value. - */ -call_id: string, -/** - * Step within the session (0-based). - */ -step: number, -/** - * When the call was recorded (RFC 3339 UTC, millisecond precision). - */ -recorded_at: string, }; diff --git a/tools/aimux-web/web/src/types/SessionChainView.ts b/tools/aimux-web/web/src/types/SessionChainView.ts deleted file mode 100644 index 3212b456a..000000000 --- a/tools/aimux-web/web/src/types/SessionChainView.ts +++ /dev/null @@ -1,7 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { PrefixBreak } from "./PrefixBreak"; - -/** - * Session chain view (append-only order + prefix stability, RFC-0015 §5.3). - */ -export type SessionChainView = { session_id: string, record_ids: Array, prefix_stability: number, breaks: Array, }; diff --git a/tools/aimux-web/web/src/types/SessionSource.ts b/tools/aimux-web/web/src/types/SessionSource.ts deleted file mode 100644 index cf55dca73..000000000 --- a/tools/aimux-web/web/src/types/SessionSource.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Where a session id came from. - */ -export type SessionSource = "Explicit" | "Inferred"; diff --git a/tools/aimux-web/web/src/types/SessionStepStat.ts b/tools/aimux-web/web/src/types/SessionStepStat.ts deleted file mode 100644 index 1eeb3bf02..000000000 --- a/tools/aimux-web/web/src/types/SessionStepStat.ts +++ /dev/null @@ -1,28 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { Verdict } from "./Verdict"; - -/** - * One step of a session's cache-hit trajectory (RFC-0024 §4.3). - */ -export type SessionStepStat = { -/** - * Step within the session (0-based, session order). - */ -step: number, -/** - * The call's id (association key into Recording / replay, RFC-0023). - */ -call_id: string, -/** - * Reported hit rate for this step (`cache_read / input_total`); `None` - * when the call carried no input/cache-read usage. - */ -hit_rate?: number | null, -/** - * Audit verdict (present only when an auditor is attached). - */ -verdict?: Verdict | null, -/** - * Error string for failed calls (failures are part of the session). - */ -error?: string | null, }; diff --git a/tools/aimux-web/web/src/types/SessionView.ts b/tools/aimux-web/web/src/types/SessionView.ts deleted file mode 100644 index 60a153778..000000000 --- a/tools/aimux-web/web/src/types/SessionView.ts +++ /dev/null @@ -1,16 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { SessionCall } from "./SessionCall"; -import type { SessionSource } from "./SessionSource"; - -/** - * Aggregated view of one session (session_id → ordered calls). - */ -export type SessionView = { session_id: string, -/** - * Where the session id came from (first call's source). - */ -source: SessionSource, -/** - * Ordered calls (by step). - */ -calls: Array, }; diff --git a/tools/aimux-web/web/src/types/Size.ts b/tools/aimux-web/web/src/types/Size.ts deleted file mode 100644 index 960627114..000000000 --- a/tools/aimux-web/web/src/types/Size.ts +++ /dev/null @@ -1,10 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Image/video size in `{width}x{height}` format (e.g. `"1024x1024"`). - * - * Newtype that enforces the `WxH` format used by V4 image `size` and video - * `resolution` options. Aligned with the TS template-literal type - * `` `${number}x${number}` ``. - */ -export type Size = { width: number, height: number, }; diff --git a/tools/aimux-web/web/src/types/SourcePart.ts b/tools/aimux-web/web/src/types/SourcePart.ts deleted file mode 100644 index 050ce8115..000000000 --- a/tools/aimux-web/web/src/types/SourcePart.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * A source / citation, extracted to the top level (M7). - */ -export type SourcePart = { id: string, source_type: string, url?: string | null, title?: string | null, }; diff --git a/tools/aimux-web/web/src/types/SpeechCallOptions.ts b/tools/aimux-web/web/src/types/SpeechCallOptions.ts deleted file mode 100644 index 39cae2f43..000000000 --- a/tools/aimux-web/web/src/types/SpeechCallOptions.ts +++ /dev/null @@ -1,44 +0,0 @@ -// 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"; - -/** - * Options passed to [`SpeechModel::do_generate`]. - * - * Aligned with V4 `SpeechModelV4CallOptions`. - */ -export type SpeechCallOptions = { -/** - * Text to convert to speech. - */ -text: string, -/** - * The voice to use (provider-specific voice ID, name, or other - * identifier). - */ -voice: string | null, -/** - * The desired output format for the audio, e.g. `"mp3"`, `"wav"`. - */ -output_format: string | null, -/** - * Instructions for the speech generation, e.g. - * `"Speak in a slow and steady tone"`. - */ -instructions: string | null, -/** - * The speed of the speech generation. - */ -speed: number | null, -/** - * The language for speech generation (ISO 639-1 code, e.g. `"en"`, or - * `"auto"` for automatic detection). Provider support varies. - */ -language: string | null, -/** - * Additional provider-specific options, keyed by provider name. - */ -provider_options: { [key in string]: JsonValue } | null, -/** - * Additional HTTP headers to send with the request. - */ -headers: { [key in string]: string } | null, }; diff --git a/tools/aimux-web/web/src/types/SpeechRequest.ts b/tools/aimux-web/web/src/types/SpeechRequest.ts deleted file mode 100644 index 1d5fbe382..000000000 --- a/tools/aimux-web/web/src/types/SpeechRequest.ts +++ /dev/null @@ -1,11 +0,0 @@ -// 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"; - -/** - * Optional request information for a speech call. - */ -export type SpeechRequest = { -/** - * Response body (HTTP providers only). - */ -body: JsonValue | null, }; diff --git a/tools/aimux-web/web/src/types/SpeechResponse.ts b/tools/aimux-web/web/src/types/SpeechResponse.ts deleted file mode 100644 index 9d83fe59d..000000000 --- a/tools/aimux-web/web/src/types/SpeechResponse.ts +++ /dev/null @@ -1,23 +0,0 @@ -// 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"; - -/** - * Response information for a speech call. - */ -export type SpeechResponse = { -/** - * Timestamp for the start of the generated response (ISO 8601 string). - */ -timestamp: string | null, -/** - * The ID of the model that was used to generate the response. - */ -model_id: string | null, -/** - * Response headers. - */ -headers: { [key in string]: string } | null, -/** - * Response body (opaque JSON). - */ -body: JsonValue | null, }; diff --git a/tools/aimux-web/web/src/types/SpeechResult.ts b/tools/aimux-web/web/src/types/SpeechResult.ts deleted file mode 100644 index ab9b5304f..000000000 --- a/tools/aimux-web/web/src/types/SpeechResult.ts +++ /dev/null @@ -1,33 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AudioData } from "./AudioData"; -import type { SpeechRequest } from "./SpeechRequest"; -import type { SpeechResponse } from "./SpeechResponse"; -import type { Warning } from "./Warning"; -import type { JsonValue } from "./serde_json/JsonValue"; - -/** - * The result of [`SpeechModel::do_generate`]. - * - * Aligned with V4 `SpeechModelV4Result`. - */ -export type SpeechResult = { -/** - * Generated audio (base64 string or binary data). - */ -audio: AudioData, -/** - * Warnings for the call, e.g. unsupported settings. - */ -warnings: Array, -/** - * Optional request information for telemetry and debugging. - */ -request: SpeechRequest | null, -/** - * Response information for telemetry and debugging. - */ -response: SpeechResponse, -/** - * Additional provider-specific metadata, keyed by provider name. - */ -provider_metadata: { [key in string]: JsonValue } | null, }; diff --git a/tools/aimux-web/web/src/types/StreamPart.ts b/tools/aimux-web/web/src/types/StreamPart.ts deleted file mode 100644 index 8402cfa65..000000000 --- a/tools/aimux-web/web/src/types/StreamPart.ts +++ /dev/null @@ -1,66 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AiMuxError } from "./AiMuxError"; -import type { FileData } from "./FileData"; -import type { FinishReason } from "./FinishReason"; -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`. - */ -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, -/** - * 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, -/** - * 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, 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, 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, -/** - * Provider-specific metadata (e.g. xAI `itemId`). - */ -provider_metadata: JsonValue | null, } } | { "ReasoningDelta": { id: string, delta: string, -/** - * Provider-specific metadata (e.g. xAI `itemId`). - */ -provider_metadata: JsonValue | null, } } | { "ReasoningEnd": { id: string, -/** - * Provider-specific metadata (e.g. xAI `itemId`, `reasoningEncryptedContent`). - */ -provider_metadata: JsonValue | null, } } | { "ResponseMetadata": { id: string | null, timestamp: string | null, model_id: string | null, } } | { "Source": { id: string, source_type: string, url: string | null, title: string | null, provider_metadata?: JsonValue | null, } } | { "Raw": { raw_value: JsonValue, } }; diff --git a/tools/aimux-web/web/src/types/StreamTextResultAggregated.ts b/tools/aimux-web/web/src/types/StreamTextResultAggregated.ts deleted file mode 100644 index ae61b42bc..000000000 --- a/tools/aimux-web/web/src/types/StreamTextResultAggregated.ts +++ /dev/null @@ -1,75 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { FilePart } from "./FilePart"; -import type { FinishReason } from "./FinishReason"; -import type { ModelMessage } from "./ModelMessage"; -import type { ReasoningPart } from "./ReasoningPart"; -import type { ResponseMetadata } from "./ResponseMetadata"; -import type { SourcePart } from "./SourcePart"; -import type { ToolCall } from "./ToolCall"; -import type { Usage } from "./Usage"; -import type { Warning } from "./Warning"; -import type { JsonValue } from "./serde_json/JsonValue"; - -/** - * Aggregated result of `stream_text().consume()` (M11). Mirrors - * `GenerateTextResult`'s user-facing fields (without `raw`, since streaming - * has no `GenerateResult` equivalent). - */ -export type StreamTextResultAggregated = { -/** - * The generated text (concatenated `TextDelta`). - */ -text: string, -/** - * Reasoning / thinking segments. - */ -reasoning: Array, -/** - * Concatenated reasoning text. - */ -reasoning_text: string, -/** - * Tool calls requested by the model. - */ -tool_calls: Array, -/** - * Sources / citations. - */ -sources: Array, -/** - * Files generated by the model. - */ -files: Array, -/** - * Why generation stopped. - */ -finish_reason: FinishReason, -/** - * Raw provider-specific finish reason string. - */ -raw_finish_reason: string | null, -/** - * Token usage. - */ -usage: Usage, -/** - * Total token usage across all steps. In single-step mode (aimux's - * default), `total_usage` equals `usage`. Provided for AI SDK parity. - */ -total_usage: Usage, -/** - * Warnings from the provider. - */ -warnings: Array, -/** - * Provider-specific metadata from the Finish chunk. - */ -provider_metadata: JsonValue | null, -/** - * Response metadata (id, timestamp, model_id) if emitted by the stream. - */ -response: ResponseMetadata | null, -/** - * Assistant messages ready to append for the next turn. - */ -response_messages: Array, }; diff --git a/tools/aimux-web/web/src/types/TimeoutConfiguration.ts b/tools/aimux-web/web/src/types/TimeoutConfiguration.ts deleted file mode 100644 index 8e3a34444..000000000 --- a/tools/aimux-web/web/src/types/TimeoutConfiguration.ts +++ /dev/null @@ -1,24 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Per-call timeout configuration. - * - * Aligned with V4 `LanguageModelV4CallOptions.timeout` - * (`TimeoutConfiguration`). All values are milliseconds; `None` disables the - * corresponding limit. A `total` timeout also covers retry backoff and the - * whole streamed response. - */ -export type TimeoutConfiguration = { -/** - * Overall timeout for the entire call (including retries and, for - * streaming, the whole stream), in milliseconds. - */ -total_ms: number | null, -/** - * Timeout waiting for the first stream chunk (streaming only). - */ -first_chunk_ms: number | null, -/** - * Maximum idle time between consecutive stream chunks (streaming only). - */ -chunk_ms: number | null, }; diff --git a/tools/aimux-web/web/src/types/TimingRecord.ts b/tools/aimux-web/web/src/types/TimingRecord.ts deleted file mode 100644 index b24ae6478..000000000 --- a/tools/aimux-web/web/src/types/TimingRecord.ts +++ /dev/null @@ -1,7 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type TimingRecord = { -/** - * ms 级延迟,TS 用 number(远小于 2^53,避免 bigint)。 - */ -latency_ms: number, ttfb_ms: number | null, }; diff --git a/tools/aimux-web/web/src/types/TokenUsage.ts b/tools/aimux-web/web/src/types/TokenUsage.ts deleted file mode 100644 index ddab251a8..000000000 --- a/tools/aimux-web/web/src/types/TokenUsage.ts +++ /dev/null @@ -1,30 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Token usage detail (with cache breakdown). - */ -export type TokenUsage = { -/** - * Total tokens. - */ -total: number | null, -/** - * Tokens that were not served from cache. - */ -no_cache: number | null, -/** - * Tokens served from cache (read). - */ -cache_read: number | null, -/** - * Tokens written to cache. - */ -cache_write: number | null, -/** - * Text (non-reasoning) tokens (output side). - */ -text: number | null, -/** - * Reasoning tokens (output side). - */ -reasoning: number | null, }; diff --git a/tools/aimux-web/web/src/types/Tool.ts b/tools/aimux-web/web/src/types/Tool.ts deleted file mode 100644 index 5cfcaa975..000000000 --- a/tools/aimux-web/web/src/types/Tool.ts +++ /dev/null @@ -1,10 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { FunctionTool } from "./FunctionTool"; -import type { ProviderTool } from "./ProviderTool"; - -/** - * A tool that can be either a user-defined function tool or a - * provider-defined tool. Mirrors the V4 - * `Array`. - */ -export type Tool = { "type": "function" } & FunctionTool | { "type": "provider" } & ProviderTool; diff --git a/tools/aimux-web/web/src/types/ToolCall.ts b/tools/aimux-web/web/src/types/ToolCall.ts deleted file mode 100644 index d16cbbc4d..000000000 --- a/tools/aimux-web/web/src/types/ToolCall.ts +++ /dev/null @@ -1,34 +0,0 @@ -// 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"; - -/** - * A tool call requested by the model. - */ -export type ToolCall = { -/** - * Provider-assigned call id. - */ -tool_call_id: string, -/** - * Tool name. - */ -tool_name: string, -/** - * Arguments as a JSON value (usually an object). - */ -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; thinking models reject the request - * otherwise. - */ -thought_signature?: string | null, }; diff --git a/tools/aimux-web/web/src/types/ToolChoice.ts b/tools/aimux-web/web/src/types/ToolChoice.ts deleted file mode 100644 index b8f658f4a..000000000 --- a/tools/aimux-web/web/src/types/ToolChoice.ts +++ /dev/null @@ -1,11 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * How the model should choose tools. - * - * Wire format aligns with Vercel AI SDK `toolChoice`: - * `"auto" | "none" | "required" | { "type": "tool", "toolName": "..." }`. - * This is a mixed tagged/untagged shape (unit variants as bare strings, - * `Tool` variant as a tagged object), so serde is implemented by hand. - */ -export type ToolChoice = "auto" | "none" | "required" | { type: "tool"; toolName: string }; diff --git a/tools/aimux-web/web/src/types/ToolResult.ts b/tools/aimux-web/web/src/types/ToolResult.ts deleted file mode 100644 index 1398d1a46..000000000 --- a/tools/aimux-web/web/src/types/ToolResult.ts +++ /dev/null @@ -1,23 +0,0 @@ -// 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"; - -/** - * The result of executing a tool call, to be sent back to the model. - */ -export type ToolResult = { -/** - * Must match the corresponding `ToolCall::tool_call_id`. - */ -tool_call_id: string, -/** - * The tool's output (usually a JSON-serializable value or plain text). - */ -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, }; diff --git a/tools/aimux-web/web/src/types/TraceFilter.ts b/tools/aimux-web/web/src/types/TraceFilter.ts deleted file mode 100644 index 314c21083..000000000 --- a/tools/aimux-web/web/src/types/TraceFilter.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Query filter (RFC-0015 §5.3). - */ -export type TraceFilter = { provider: string | null, model: string | null, session_id: string | null, since_unix_ms: bigint | null, }; diff --git a/tools/aimux-web/web/src/types/TraceRecord.ts b/tools/aimux-web/web/src/types/TraceRecord.ts deleted file mode 100644 index fe903fb3f..000000000 --- a/tools/aimux-web/web/src/types/TraceRecord.ts +++ /dev/null @@ -1,47 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { Fingerprint } from "./Fingerprint"; -import type { RequestCacheHints } from "./RequestCacheHints"; -import type { UsageSnapshot } from "./UsageSnapshot"; -import type { Verdict } from "./Verdict"; - -/** - * One probed call. Plaintext never persists — fingerprints only. - */ -export type TraceRecord = { provider: string, model: string, request_id: string | null, -/** - * From `CallOptions.session_id` (RFC-0024) — explicit first, inference - * fallback; may also be the layer's default session. - */ -session_id: string | null, -/** - * Call-level unique id (association key for Recording / replay, - * RFC-0023). - */ -call_id: string, -/** - * When the call was sent (epoch ms). - */ -sent_at_unix_ms: number, -/** - * Client-side LCP token upper bound (block upper bound, byte-proxy - * len/4). `None` when no history matched. Consumed by `TraceStats` - * aggregation. - */ -lcp_token_upper?: bigint | null, -/** - * Time to first streamed token (ms). Non-streaming: `None`. - */ -ttft_ms: number | null, fingerprint: Fingerprint, usage: UsageSnapshot, -/** - * Response-side cache headers (e.g. `x-openrouter-cache-status`). - */ -response_cache_headers?: { [key in string]: string } | null, request_cache_hints?: RequestCacheHints | null, -/** - * Audit verdict (`None` when no auditor is attached — the default). - */ -verdict?: Verdict | null, -/** - * Error string when the call failed (still recorded: failures are part - * of the session). - */ -error?: string | null, }; diff --git a/tools/aimux-web/web/src/types/TraceStats.ts b/tools/aimux-web/web/src/types/TraceStats.ts deleted file mode 100644 index 2f836deac..000000000 --- a/tools/aimux-web/web/src/types/TraceStats.ts +++ /dev/null @@ -1,7 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Aggregated statistics (RFC-0015 §5.2 — the two hit rates are reported - * side by side, never merged into a single number). - */ -export type TraceStats = { provider: string, model: string, requests: bigint, input_tokens_total: bigint, claimed_cache_read_total: bigint, claimed_cache_write_total: bigint, reported_hit_rate: number | null, client_upper_bound_hit_rate: number | null, verdict_counts: { [key in string]: bigint }, ttft_p50_ms: bigint | null, ttft_p95_ms: bigint | null, errors: bigint, }; diff --git a/tools/aimux-web/web/src/types/TranscriptionCallOptions.ts b/tools/aimux-web/web/src/types/TranscriptionCallOptions.ts deleted file mode 100644 index 79acc81ee..000000000 --- a/tools/aimux-web/web/src/types/TranscriptionCallOptions.ts +++ /dev/null @@ -1,26 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AudioInput } from "./AudioInput"; -import type { JsonValue } from "./serde_json/JsonValue"; - -/** - * Options passed to [`TranscriptionModel::do_generate`]. - * - * Aligned with V4 `TranscriptionModelV4CallOptions`. - */ -export type TranscriptionCallOptions = { -/** - * Audio data to transcribe (raw bytes or a base64-encoded string). - */ -audio: AudioInput, -/** - * The IANA media type of the audio data, e.g. `"audio/mp3"`. - */ -media_type: string, -/** - * Additional provider-specific options, keyed by provider name. - */ -provider_options: { [key in string]: JsonValue } | null, -/** - * Additional HTTP headers to send with the request. - */ -headers: { [key in string]: string } | null, }; diff --git a/tools/aimux-web/web/src/types/TranscriptionRequest.ts b/tools/aimux-web/web/src/types/TranscriptionRequest.ts deleted file mode 100644 index d1dac6010..000000000 --- a/tools/aimux-web/web/src/types/TranscriptionRequest.ts +++ /dev/null @@ -1,10 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Optional request information for a transcription call. - */ -export type TranscriptionRequest = { -/** - * Raw request HTTP body that was sent (JSON stringified). - */ -body: string | null, }; diff --git a/tools/aimux-web/web/src/types/TranscriptionResponse.ts b/tools/aimux-web/web/src/types/TranscriptionResponse.ts deleted file mode 100644 index cdf3331b2..000000000 --- a/tools/aimux-web/web/src/types/TranscriptionResponse.ts +++ /dev/null @@ -1,23 +0,0 @@ -// 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"; - -/** - * Response information for a transcription call. - */ -export type TranscriptionResponse = { -/** - * Timestamp for the start of the generated response (ISO 8601 string). - */ -timestamp: string | null, -/** - * The ID of the model that was used to generate the response. - */ -model_id: string | null, -/** - * Response headers. - */ -headers: { [key in string]: string } | null, -/** - * Response body (opaque JSON). - */ -body: JsonValue | null, }; diff --git a/tools/aimux-web/web/src/types/TranscriptionResult.ts b/tools/aimux-web/web/src/types/TranscriptionResult.ts deleted file mode 100644 index ee229f7c2..000000000 --- a/tools/aimux-web/web/src/types/TranscriptionResult.ts +++ /dev/null @@ -1,45 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { TranscriptionRequest } from "./TranscriptionRequest"; -import type { TranscriptionResponse } from "./TranscriptionResponse"; -import type { TranscriptionSegment } from "./TranscriptionSegment"; -import type { Warning } from "./Warning"; -import type { JsonValue } from "./serde_json/JsonValue"; - -/** - * The result of [`TranscriptionModel::do_generate`]. - * - * Aligned with V4 `TranscriptionModelV4Result`. - */ -export type TranscriptionResult = { -/** - * The complete transcribed text from the audio. - */ -text: string, -/** - * Transcript segments with timing information. - */ -segments: Array, -/** - * The detected language (ISO 639-1 code, e.g. `"en"`), if detected. - */ -language: string | null, -/** - * The total duration of the audio in seconds, if determined. - */ -duration_in_seconds: number | null, -/** - * Warnings for the call, e.g. unsupported settings. - */ -warnings: Array, -/** - * Optional request information for telemetry and debugging. - */ -request: TranscriptionRequest | null, -/** - * Response information for telemetry and debugging. - */ -response: TranscriptionResponse, -/** - * Additional provider-specific metadata, keyed by provider name. - */ -provider_metadata: { [key in string]: JsonValue } | null, }; diff --git a/tools/aimux-web/web/src/types/TranscriptionSegment.ts b/tools/aimux-web/web/src/types/TranscriptionSegment.ts deleted file mode 100644 index dcb5e02a2..000000000 --- a/tools/aimux-web/web/src/types/TranscriptionSegment.ts +++ /dev/null @@ -1,18 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * A transcript segment with timing information. - */ -export type TranscriptionSegment = { -/** - * The text content of this segment. - */ -text: string, -/** - * The start time of this segment in seconds. - */ -start_second: number, -/** - * The end time of this segment in seconds. - */ -end_second: number, }; diff --git a/tools/aimux-web/web/src/types/TranscriptionStreamPart.ts b/tools/aimux-web/web/src/types/TranscriptionStreamPart.ts deleted file mode 100644 index 45228feec..000000000 --- a/tools/aimux-web/web/src/types/TranscriptionStreamPart.ts +++ /dev/null @@ -1,11 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { TranscriptionSegment } from "./TranscriptionSegment"; -import type { Warning } from "./Warning"; -import type { JsonValue } from "./serde_json/JsonValue"; - -/** - * A single chunk in the stream returned by `do_stream`. - * - * Aligned with V4 `TranscriptionModelV4StreamPart`. - */ -export type TranscriptionStreamPart = { "StreamStart": { warnings: Array, } } | { "TranscriptDelta": { id: string | null, delta: string, provider_metadata: { [key in string]: JsonValue } | null, } } | { "TranscriptPartial": { id: string | null, text: string, start_second: number | null, duration_in_seconds: number | null, channel_index: number | null, provider_metadata: { [key in string]: JsonValue } | null, } } | { "TranscriptFinal": { id: string | null, text: string, start_second: number | null, end_second: number | null, channel_index: number | null, provider_metadata: { [key in string]: JsonValue } | null, } } | { "ResponseMetadata": { timestamp: string | null, model_id: string | null, headers: { [key in string]: string } | null, body: JsonValue | null, } } | { "Finish": { text: string, segments: Array, language: string | null, duration_in_seconds: number | null, provider_metadata: { [key in string]: JsonValue } | null, } } | { "Raw": { raw_value: JsonValue, } } | { "Error": { error: JsonValue, } }; diff --git a/tools/aimux-web/web/src/types/UploadFileCallOptions.ts b/tools/aimux-web/web/src/types/UploadFileCallOptions.ts deleted file mode 100644 index 027e634aa..000000000 --- a/tools/aimux-web/web/src/types/UploadFileCallOptions.ts +++ /dev/null @@ -1,26 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { UploadFileData } from "./UploadFileData"; -import type { JsonValue } from "./serde_json/JsonValue"; - -/** - * Options passed to [`Files::upload_file`]. - * - * Aligned with V4 `FilesV4UploadFileCallOptions`. - */ -export type UploadFileCallOptions = { -/** - * The file data (raw bytes/base64 or inline text). - */ -data: UploadFileData, -/** - * The IANA media type of the file, e.g. `"application/pdf"`. - */ -media_type: string, -/** - * The filename of the file. - */ -filename: string | null, -/** - * Additional provider-specific options, keyed by provider name. - */ -provider_options: { [key in string]: JsonValue } | null, }; diff --git a/tools/aimux-web/web/src/types/UploadFileData.ts b/tools/aimux-web/web/src/types/UploadFileData.ts deleted file mode 100644 index 6b50bff3d..000000000 --- a/tools/aimux-web/web/src/types/UploadFileData.ts +++ /dev/null @@ -1,11 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { FileBytes } from "./FileBytes"; - -/** - * File data accepted by [`Files::upload_file`]. - * - * The V4 spec restricts upload input to the `data` and `text` variants of - * `SharedV4FileData` (URLs and provider references are not valid uploads), so - * this is a dedicated two-variant enum rather than the full [`crate::shared::FileData`]. - */ -export type UploadFileData = { "Data": { data: FileBytes, } } | { "Text": { text: string, } }; diff --git a/tools/aimux-web/web/src/types/UploadFileResult.ts b/tools/aimux-web/web/src/types/UploadFileResult.ts deleted file mode 100644 index dc9c7412e..000000000 --- a/tools/aimux-web/web/src/types/UploadFileResult.ts +++ /dev/null @@ -1,32 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { Warning } from "./Warning"; -import type { JsonValue } from "./serde_json/JsonValue"; - -/** - * Result of [`Files::upload_file`]. - * - * Aligned with V4 `FilesV4UploadFileResult`. - */ -export type UploadFileResult = { -/** - * A provider reference mapping provider names to provider-specific file - * identifiers. - */ -provider_reference: { [key in string]: string }, -/** - * The IANA media type of the uploaded file, if available from the - * provider. - */ -media_type: string | null, -/** - * The filename of the uploaded file, if available from the provider. - */ -filename: string | null, -/** - * Additional provider-specific metadata, keyed by provider name. - */ -provider_metadata: { [key in string]: JsonValue } | null, -/** - * Warnings from the provider. - */ -warnings: Array, }; diff --git a/tools/aimux-web/web/src/types/Usage.ts b/tools/aimux-web/web/src/types/Usage.ts deleted file mode 100644 index 76961cf7e..000000000 --- a/tools/aimux-web/web/src/types/Usage.ts +++ /dev/null @@ -1,12 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { TokenUsage } from "./TokenUsage"; -import type { JsonValue } from "./serde_json/JsonValue"; - -/** - * Token usage statistics. - */ -export type Usage = { input_tokens: TokenUsage, output_tokens: TokenUsage, -/** - * Raw usage information from the provider (opaque, provider-specific). - */ -raw?: JsonValue | null, }; diff --git a/tools/aimux-web/web/src/types/UsageSnapshot.ts b/tools/aimux-web/web/src/types/UsageSnapshot.ts deleted file mode 100644 index 2db90e806..000000000 --- a/tools/aimux-web/web/src/types/UsageSnapshot.ts +++ /dev/null @@ -1,12 +0,0 @@ -// 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"; - -/** - * Token usage snapshot (7 flat fields + raw passthrough). - */ -export type UsageSnapshot = { input_total: bigint | null, input_no_cache: bigint | null, cache_read: bigint | null, cache_write: bigint | null, output_total: bigint | null, output_text: bigint | null, output_reasoning: bigint | null, -/** - * Raw provider usage payload (opaque passthrough; numbers only — no - * prompt text). - */ -raw?: JsonValue | null, }; diff --git a/tools/aimux-web/web/src/types/Verdict.ts b/tools/aimux-web/web/src/types/Verdict.ts deleted file mode 100644 index 14b282efa..000000000 --- a/tools/aimux-web/web/src/types/Verdict.ts +++ /dev/null @@ -1,20 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { VerdictConfidence } from "./VerdictConfidence"; -import type { VerdictKind } from "./VerdictKind"; - -/** - * A verdict for one call. - */ -export type Verdict = { kind: VerdictKind, confidence: VerdictConfidence, -/** - * Violated rule ids (R-1.1 / R-1.2 / …). - */ -violated: Array, -/** - * Expected upper bound U (tokens). - */ -expected_max: bigint, claimed: bigint, -/** - * Client-side LCP (bytes). - */ -lcp_bytes: bigint, notes: Array, }; diff --git a/tools/aimux-web/web/src/types/VerdictConfidence.ts b/tools/aimux-web/web/src/types/VerdictConfidence.ts deleted file mode 100644 index 0340dba07..000000000 --- a/tools/aimux-web/web/src/types/VerdictConfidence.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Confidence level. - */ -export type VerdictConfidence = "High" | "Medium" | "Low"; diff --git a/tools/aimux-web/web/src/types/VerdictKind.ts b/tools/aimux-web/web/src/types/VerdictKind.ts deleted file mode 100644 index 9d8c9f985..000000000 --- a/tools/aimux-web/web/src/types/VerdictKind.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Verdict kinds (RFC-0015 §4.1). - */ -export type VerdictKind = "Trusted" | "SuspectOverclaim" | "SuspectUnderclaim" | "Unknown"; diff --git a/tools/aimux-web/web/src/types/VideoCallOptions.ts b/tools/aimux-web/web/src/types/VideoCallOptions.ts deleted file mode 100644 index ecdc9f265..000000000 --- a/tools/aimux-web/web/src/types/VideoCallOptions.ts +++ /dev/null @@ -1,66 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AspectRatio } from "./AspectRatio"; -import type { Size } from "./Size"; -import type { VideoFile } from "./VideoFile"; -import type { VideoFrameImage } from "./VideoFrameImage"; -import type { JsonValue } from "./serde_json/JsonValue"; - -/** - * Options passed to [`VideoModel::do_generate`]. - * - * Aligned with V4 `VideoModelV4CallOptions`. - */ -export type VideoCallOptions = { -/** - * Text prompt for the video generation. `None` when not required. - */ -prompt: string | null, -/** - * Number of videos to generate. Default `1`; most models only support - * `n = 1` due to computational cost. - */ -n: number, -/** - * Aspect ratio, in `{width}:{height}` format (e.g. `"16:9"`). - */ -aspect_ratio: AspectRatio | null, -/** - * Resolution, in `{width}x{height}` format (e.g. `"1280x720"`). - */ -resolution: Size | null, -/** - * Duration of the video in seconds. Typically 3–10 seconds. - */ -duration: number | null, -/** - * Frames per second. Common values: 24, 30, 60. - */ -fps: number | null, -/** - * Seed for deterministic generation. `None` uses a random seed. - */ -seed: number | null, -/** - * Input image for image-to-video generation (the starting frame). - */ -image: VideoFile | null, -/** - * Role-tagged image inputs for first-last-frame generation. - */ -frame_images: Array | null, -/** - * Reference inputs for reference-to-video generation (images or videos). - */ -input_references: Array | null, -/** - * Whether the model should generate audio alongside the video. - */ -generate_audio: boolean | null, -/** - * Additional provider-specific options, keyed by provider name. - */ -provider_options: { [key in string]: JsonValue }, -/** - * Additional HTTP headers to send with the request. - */ -headers: { [key in string]: string } | null, }; diff --git a/tools/aimux-web/web/src/types/VideoData.ts b/tools/aimux-web/web/src/types/VideoData.ts deleted file mode 100644 index 443d5449b..000000000 --- a/tools/aimux-web/web/src/types/VideoData.ts +++ /dev/null @@ -1,9 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Generated video data: a URL, base64-encoded string, or binary data. - * - * Aligned with V4 `VideoModelV4VideoData`. Most providers return URLs due to - * large file sizes. - */ -export type VideoData = { "Url": { url: string, media_type: string, } } | { "Base64": { data: string, media_type: string, } } | { "Binary": { data: Array, media_type: string, } }; diff --git a/tools/aimux-web/web/src/types/VideoFile.ts b/tools/aimux-web/web/src/types/VideoFile.ts deleted file mode 100644 index 9d32e3648..000000000 --- a/tools/aimux-web/web/src/types/VideoFile.ts +++ /dev/null @@ -1,25 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { VideoFileData } from "./VideoFileData"; - -/** - * A video or image file used for video editing or image-to-video generation. - * - * Aligned with V4 `VideoModelV4File`. - */ -export type VideoFile = { "File": { -/** - * IANA media type, e.g. `"video/mp4"` or `"image/png"`. - */ -media_type: string, -/** - * File data as a base64 string or binary bytes. - */ -data: VideoFileData, } } | { "Url": { -/** - * The URL of the video or image file. - */ -url: string, -/** - * The media type of the referenced file, when known. - */ -media_type: string | null, } }; diff --git a/tools/aimux-web/web/src/types/VideoFileData.ts b/tools/aimux-web/web/src/types/VideoFileData.ts deleted file mode 100644 index d7b1d6a6b..000000000 --- a/tools/aimux-web/web/src/types/VideoFileData.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * File payload for a [`VideoFile::File`]: base64 string or raw bytes. - */ -export type VideoFileData = { "Base64": string } | { "Binary": Array }; diff --git a/tools/aimux-web/web/src/types/VideoFrameImage.ts b/tools/aimux-web/web/src/types/VideoFrameImage.ts deleted file mode 100644 index de49fcb3d..000000000 --- a/tools/aimux-web/web/src/types/VideoFrameImage.ts +++ /dev/null @@ -1,18 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { VideoFile } from "./VideoFile"; -import type { VideoFrameType } from "./VideoFrameType"; - -/** - * A role-tagged image input for image-to-video and first-last-frame generation. - * - * Aligned with V4 `VideoModelV4FrameImage`. - */ -export type VideoFrameImage = { -/** - * The image file used for this frame. - */ -image: VideoFile, -/** - * Which frame this image represents. - */ -frame_type: VideoFrameType, }; diff --git a/tools/aimux-web/web/src/types/VideoFrameType.ts b/tools/aimux-web/web/src/types/VideoFrameType.ts deleted file mode 100644 index 4279f6dfe..000000000 --- a/tools/aimux-web/web/src/types/VideoFrameType.ts +++ /dev/null @@ -1,8 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * The role a frame image plays in video generation. - * - * Aligned with V4 `VideoModelV4FrameType`. - */ -export type VideoFrameType = "FirstFrame" | "LastFrame"; diff --git a/tools/aimux-web/web/src/types/VideoResponse.ts b/tools/aimux-web/web/src/types/VideoResponse.ts deleted file mode 100644 index 9e7fff9cb..000000000 --- a/tools/aimux-web/web/src/types/VideoResponse.ts +++ /dev/null @@ -1,18 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Response information for a video generation call. - */ -export type VideoResponse = { -/** - * Timestamp for the start of the generated response (ISO 8601 string). - */ -timestamp: string | null, -/** - * The ID of the model that was used to generate the response. - */ -model_id: string | null, -/** - * Response headers. - */ -headers: { [key in string]: string } | null, }; diff --git a/tools/aimux-web/web/src/types/VideoResult.ts b/tools/aimux-web/web/src/types/VideoResult.ts deleted file mode 100644 index 9bda831ef..000000000 --- a/tools/aimux-web/web/src/types/VideoResult.ts +++ /dev/null @@ -1,28 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { VideoData } from "./VideoData"; -import type { VideoResponse } from "./VideoResponse"; -import type { Warning } from "./Warning"; -import type { JsonValue } from "./serde_json/JsonValue"; - -/** - * The result of [`VideoModel::do_generate`]. - * - * Aligned with V4 `VideoModelV4Result`. - */ -export type VideoResult = { -/** - * Generated videos as URLs, base64 strings, or binary data. - */ -videos: Array, -/** - * Warnings for the call, e.g. unsupported features. - */ -warnings: Array, -/** - * Additional provider-specific metadata, keyed by provider name. - */ -provider_metadata: { [key in string]: JsonValue } | null, -/** - * Response information for telemetry and debugging. - */ -response: VideoResponse, }; diff --git a/tools/aimux-web/web/src/types/Warning.ts b/tools/aimux-web/web/src/types/Warning.ts deleted file mode 100644 index 14edc447c..000000000 --- a/tools/aimux-web/web/src/types/Warning.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * A warning issued by the provider (e.g. unsupported parameter). - */ -export type Warning = { "Unsupported": { feature: string, details: string | null, } } | { "Compatibility": { feature: string, details: string | null, } } | { "Deprecated": { setting: string, message: string, } } | { "Other": { message: string, } }; diff --git a/tools/aimux-web/web/src/types/WireCallRequest.ts b/tools/aimux-web/web/src/types/generated/WireCallRequest.ts similarity index 67% rename from tools/aimux-web/web/src/types/WireCallRequest.ts rename to tools/aimux-web/web/src/types/generated/WireCallRequest.ts index caa2c08d3..9298e85c1 100644 --- a/tools/aimux-web/web/src/types/WireCallRequest.ts +++ b/tools/aimux-web/web/src/types/generated/WireCallRequest.ts @@ -5,36 +5,37 @@ import type { WireOptions } from "./WireOptions"; /** * One model call from the console (RFC-0029 §5.3). */ -export type WireCallRequest = { +export type WireCallRequest = { /** * Registry name or native protocol name ("openai", "deepseek", …). */ -provider: string, +provider: string, /** * Model id (free text, e.g. "gpt-4o"). */ -model: string, +model: string, /** - * API key: `None` (provider reads its registered env var), or - * `Some("env:VAR")`. Plaintext keys are rejected (RFC-0029 §9). + * API key: `None` (Settings-saved key or the provider's registered env + * var), `Some("env:VAR")`, or a plaintext literal — the latter only + * while the console is loopback-bound (RFC-0029 §5.5). */ -api_key?: string | null, +api_key?: string | null, /** * Base URL override (proxies / local endpoints). */ -base_url?: string | null, +base_url?: string | null, /** * Stream the response as SSE (`StreamPart` events). Default true. */ -stream: boolean, +stream: boolean, /** * Route through the loaded mock model (offline mode, RFC-0023 P3). */ -mock: boolean, options: WireOptions, +mock: boolean, options: WireOptions, /** * Session grouping id (RFC-0024). The console reuses one id per agent run. */ -session_id?: string | null, +session_id?: string | null, /** * Informational step marker (the backend assigns the authoritative step). */ diff --git a/tools/aimux-web/web/src/types/WireCallResponse.ts b/tools/aimux-web/web/src/types/generated/WireCallResponse.ts similarity index 100% rename from tools/aimux-web/web/src/types/WireCallResponse.ts rename to tools/aimux-web/web/src/types/generated/WireCallResponse.ts diff --git a/tools/aimux-web/web/src/types/WireContentPart.ts b/tools/aimux-web/web/src/types/generated/WireContentPart.ts similarity index 68% rename from tools/aimux-web/web/src/types/WireContentPart.ts rename to tools/aimux-web/web/src/types/generated/WireContentPart.ts index ce96d1265..c3a3fd32d 100644 --- a/tools/aimux-web/web/src/types/WireContentPart.ts +++ b/tools/aimux-web/web/src/types/generated/WireContentPart.ts @@ -4,4 +4,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** * A content part in the wire format. */ -export type WireContentPart = { "type": "text", text: string, } | { "type": "tool_call", tool_call_id: string, tool_name: string, input: JsonValue, } | { "type": "tool_result", tool_call_id: string, result: JsonValue, is_error?: boolean | null, }; +export type WireContentPart = { "type": "text", text: string, } | { "type": "tool_call", tool_call_id: string, tool_name: string, input: JsonValue, provider_executed?: boolean | null, } | { "type": "tool_result", tool_call_id: string, result: JsonValue, is_error?: boolean | null, }; diff --git a/tools/aimux-web/web/src/types/WireMessage.ts b/tools/aimux-web/web/src/types/generated/WireMessage.ts similarity index 91% rename from tools/aimux-web/web/src/types/WireMessage.ts rename to tools/aimux-web/web/src/types/generated/WireMessage.ts index a7d8f0739..9830f1e49 100644 --- a/tools/aimux-web/web/src/types/WireMessage.ts +++ b/tools/aimux-web/web/src/types/generated/WireMessage.ts @@ -4,7 +4,7 @@ import type { WireContentPart } from "./WireContentPart"; /** * A chat message in the wire format. */ -export type WireMessage = { +export type WireMessage = { /** * "system" | "user" | "assistant" | "tool". */ diff --git a/tools/aimux-web/web/src/types/WireMeta.ts b/tools/aimux-web/web/src/types/generated/WireMeta.ts similarity index 100% rename from tools/aimux-web/web/src/types/WireMeta.ts rename to tools/aimux-web/web/src/types/generated/WireMeta.ts diff --git a/tools/aimux-web/web/src/types/WireOptions.ts b/tools/aimux-web/web/src/types/generated/WireOptions.ts similarity index 91% rename from tools/aimux-web/web/src/types/WireOptions.ts rename to tools/aimux-web/web/src/types/generated/WireOptions.ts index c4ec07581..3100d9b47 100644 --- a/tools/aimux-web/web/src/types/WireOptions.ts +++ b/tools/aimux-web/web/src/types/generated/WireOptions.ts @@ -2,7 +2,7 @@ import type { WireTool } from "./WireTool"; import type { JsonValue } from "./serde_json/JsonValue"; -export type WireOptions = { temperature?: number | null, max_output_tokens?: number | null, top_p?: number | null, stop_sequences?: Array | null, tools?: Array | null, +export type WireOptions = { temperature?: number | null, max_output_tokens?: number | null, top_p?: number | null, stop_sequences?: Array | null, tools?: Array | null, /** * `"text"` or `{"json": …}` — passed through as `ResponseFormat`. */ diff --git a/tools/aimux-web/web/src/types/WireTool.ts b/tools/aimux-web/web/src/types/generated/WireTool.ts similarity index 98% rename from tools/aimux-web/web/src/types/WireTool.ts rename to tools/aimux-web/web/src/types/generated/WireTool.ts index 713cb2ddf..966a8679e 100644 --- a/tools/aimux-web/web/src/types/WireTool.ts +++ b/tools/aimux-web/web/src/types/generated/WireTool.ts @@ -4,7 +4,7 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** * A function tool definition (JSON Schema parameters). */ -export type WireTool = { name: string, description?: string | null, +export type WireTool = { name: string, description?: string | null, /** * JSON Schema describing the parameters. */ diff --git a/tools/aimux-web/web/src/types/serde_json/JsonValue.ts b/tools/aimux-web/web/src/types/generated/serde_json/JsonValue.ts similarity index 100% rename from tools/aimux-web/web/src/types/serde_json/JsonValue.ts rename to tools/aimux-web/web/src/types/generated/serde_json/JsonValue.ts diff --git a/tools/aimux-web/web/src/views/Playground.vue b/tools/aimux-web/web/src/views/Playground.vue index 9380a48d3..c60e4b0d5 100644 --- a/tools/aimux-web/web/src/views/Playground.vue +++ b/tools/aimux-web/web/src/views/Playground.vue @@ -3,9 +3,9 @@ import { computed, ref } from 'vue' import { RouterLink, useRouter } from 'vue-router' import { useAppStore } from '../stores/app' import { api, callStream, parseStreamPart } from '../api/client' -import type { WireMessage } from '../types/WireMessage' -import type { WireContentPart } from '../types/WireContentPart' -import type { WireCallRequest } from '../types/WireCallRequest' +import type { WireMessage } from '@/types/generated/WireMessage' +import type { WireContentPart } from '@/types/generated/WireContentPart' +import type { WireCallRequest } from '@/types/generated/WireCallRequest' import Button from '../components/ui/Button.vue' import Input from '../components/ui/Input.vue' import Textarea from '../components/ui/Textarea.vue' diff --git a/tools/aimux-web/web/src/views/Replay.vue b/tools/aimux-web/web/src/views/Replay.vue index 54dae6622..43d1f21f0 100644 --- a/tools/aimux-web/web/src/views/Replay.vue +++ b/tools/aimux-web/web/src/views/Replay.vue @@ -1,7 +1,7 @@