From 103d23a3655e97efe2eb5450b882cc13a9b03875 Mon Sep 17 00:00:00 2001 From: chenhaonan Date: Tue, 29 Sep 2026 14:30:03 +0800 Subject: [PATCH 001/164] refactor(providers): remove generated provider name types Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a0eb91-5e79-775e-975e-b85e2ac49ab6 (cherry picked from commit 7f68ac3686d78f1f614e1f313cccce4c10aa9222) --- .github/workflows/ci.yml | 2 - README.md | 10 +- aimux-providers/src/lib.rs | 3 +- aimux-providers/src/provider.rs | 64 +- aimux-providers/src/provider_name.rs | 1053 ----------------- bindings/flutter/lib/provider_name.dart | 258 ---- bindings/go/provider_name.go | 260 ---- .../java/ai/arcships/aimux/ProviderName.java | 260 ---- .../java/ai/arcships/aimux/ModelTest.java | 9 - .../kotlin/ai/arcships/aimux/ProviderName.kt | 258 ---- .../kotlin/ai/arcships/aimux/ModelTest.kt | 9 - bindings/node/package.json | 2 +- bindings/node/src/index.ts | 5 - bindings/node/src/types/ProviderName.ts | 262 ---- bindings/python/python/aimux/provider_name.py | 8 - .../swift/Sources/Aimux/ProviderName.swift | 257 ---- .../swift/Tests/AimuxTests/AimuxTests.swift | 9 - docs/API.md | 13 +- docs/PROJECT-OVERVIEW.md | 4 +- docs/api/flutter.md | 11 +- docs/api/go.md | 8 +- docs/api/java.md | 12 +- docs/api/kotlin.md | 11 +- docs/api/node.md | 11 +- docs/api/providers.md | 4 +- docs/api/reference.md | 5 +- docs/api/rust.md | 13 +- docs/api/swift.md | 11 +- docs/contributing/adding-a-provider.md | 9 +- docs/external-provider-config-research.md | 4 +- .../structured-error-business-semantics.md | 11 +- rfc/0017-provider-config-dx.md | 21 +- rfc/0020-external-provider-config.md | 7 +- scripts/gen_provider_names.py | 250 ---- scripts/gen_providers_doc.py | 4 +- scripts/gen_ts_types.py | 11 +- scripts/local-ci.sh | 7 +- tools/aimux-web/src/api/providers.rs | 6 +- 38 files changed, 90 insertions(+), 3072 deletions(-) delete mode 100644 aimux-providers/src/provider_name.rs delete mode 100644 bindings/flutter/lib/provider_name.dart delete mode 100644 bindings/go/provider_name.go delete mode 100644 bindings/java/src/main/java/ai/arcships/aimux/ProviderName.java delete mode 100644 bindings/kotlin/src/main/kotlin/ai/arcships/aimux/ProviderName.kt delete mode 100644 bindings/node/src/types/ProviderName.ts delete mode 100644 bindings/python/python/aimux/provider_name.py delete mode 100644 bindings/swift/Sources/Aimux/ProviderName.swift delete mode 100644 scripts/gen_provider_names.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26fe5a904..398d4339e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -396,8 +396,6 @@ jobs: 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 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/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..887ac44c3 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,12 @@ pub fn provider_from_env( provider(name, None, model_id, options) } +/// Names of all built-in registry providers. +#[must_use] +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 +614,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 +642,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 +778,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/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/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/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/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/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/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/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/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/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/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..47c34b180 100644 --- a/scripts/gen_providers_doc.py +++ b/scripts/gen_providers_doc.py @@ -180,7 +180,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 +191,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..2babf2928 100755 --- a/scripts/gen_ts_types.py +++ b/scripts/gen_ts_types.py @@ -27,11 +27,6 @@ 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"} def normalize_generated(content: str) -> str: @@ -79,8 +74,8 @@ def aggregate(files: dict[str, str]) -> str: 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(): + """Make TYPES_DIR match expected exactly.""" + for rel in manifest(TYPES_DIR).keys() - expected.keys(): (TYPES_DIR / rel).unlink() for rel, content in expected.items(): path = TYPES_DIR / rel @@ -112,7 +107,7 @@ def main() -> int: 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 {} + 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]) diff --git a/scripts/local-ci.sh b/scripts/local-ci.sh index 11bc206c8..d6b3152e3 100755 --- a/scripts/local-ci.sh +++ b/scripts/local-ci.sh @@ -4,7 +4,7 @@ # 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 +# contract Rust contract tests + Node run-node.ts # python maturin develop --release + pytest (bindings/python venv) # node npm ci + napi build + npm test + tsc typecheck # go go vet + go test (needs local-ci-setup.sh) @@ -72,9 +72,8 @@ 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 \ + section "contract: Rust/Node contract tests" + if cargo test --test contract_test -p aimux-core --quiet \ && node --experimental-strip-types contract-tests/run-node.ts | grep -q '0 failed'; then pass "contract" else 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 } From 837aaaf69913930c518ff0a885621e8cc30604f4 Mon Sep 17 00:00:00 2001 From: chenhaonan Date: Wed, 30 Sep 2026 00:02:41 +0800 Subject: [PATCH 002/164] docs(changelog): record removal of generated ProviderName as breaking Co-Authored-By: Claude Sonnet 5.5 (cherry picked from commit 758d0bcbee451f48b8daa47703281403bb1a2369) --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) 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 From 95230304773bc7eb6556c8fabf8efb06fa19c5c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 16:31:05 +0000 Subject: [PATCH 003/164] fix(providers): drop the redundant #[must_use] on provider_names() The returned impl Iterator is already must_use, so clippy's double_must_use rejected it. (provider.rs part of commit 83974246716cf8e1585c02177b0c87fa4917701e) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A3WxDBWBA4ituJjXsHaEAa --- aimux-providers/src/provider.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/aimux-providers/src/provider.rs b/aimux-providers/src/provider.rs index 887ac44c3..f067b4ffd 100644 --- a/aimux-providers/src/provider.rs +++ b/aimux-providers/src/provider.rs @@ -595,7 +595,6 @@ pub fn provider_from_env( } /// Names of all built-in registry providers. -#[must_use] pub fn provider_names() -> impl Iterator { registry().iter().map(|entry| entry.name.as_str()) } From 94c48aba4141239b80c15da0a3fbf65c29e13cfd Mon Sep 17 00:00:00 2001 From: cunninghamcard-bit Date: Thu, 1 Oct 2026 05:32:52 +0000 Subject: [PATCH 004/164] chore: drop stale provider_name references left after the ProviderName removal - scripts/gen_providers_doc.py: the `provider_name` module no longer exists; the docstrings and the section-header guard now name only `provider`. `--check` output is unchanged. - docs/internal/phase4-audit.md: add a follow-up note (same style as RFC-0020) so the historical audit no longer reads as if the generated ProviderName types and their drift tests still exist. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WGwanvo9WLU9HWD7sRR8TS (cherry picked from commit dfcd54f4da70f1d02866f3027363f6359cad0bb8) --- docs/internal/phase4-audit.md | 1 + scripts/gen_providers_doc.py | 15 +++++++-------- 2 files changed, 8 insertions(+), 8 deletions(-) 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/scripts/gen_providers_doc.py b/scripts/gen_providers_doc.py index 47c34b180..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)) From 1434ae15e603c44607a6afb013dd6dcb326c6aa3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 04:49:24 +0000 Subject: [PATCH 005/164] docs(roadmap): Node drops the ProviderName string-literal union too The A2 line said Node would keep a generated string-literal union for completion. It does not: overlay-registered provider names can never be part of a compile-time union, so keeping one would keep the second name path this PR removes. Record the actual A2 scope. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WGwanvo9WLU9HWD7sRR8TS (cherry picked from commit 3e2d9fc7dce2bcb8b91407617d5fcd7e49a33cb0) --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 周) From 06db6c71ade3bc3453f78138e1f30b01fe17055f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 09:32:01 +0000 Subject: [PATCH 006/164] ci: run checks on every pull request and cancel superseded PR runs Pull requests into a branch other than master got no CI, so a stacked PR could not show it was green against the PR below it. The pull_request trigger now has no branch filter. A new push to a PR cancels that PR's run still in progress; pushes to master are never cancelled. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A3WxDBWBA4ituJjXsHaEAa --- .github/workflows/ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26fe5a904..001ac0a2b 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 From 7e9b5c2a0b58cfc4cd5e7caaf3bfff3a9719ce5d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 17:58:35 +0000 Subject: [PATCH 007/164] test: delete self-generated contract fixtures and hand-written tests that pin Rust-only behaviour The V4 migration that follows replaces these tests with tests ported from the AI SDK. They are deleted first, on their own, so the PRs after this one show only code changes. This commit removes only test files (Rust, the seven bindings, the shared contract fixtures and their Node runner) and the CI steps that ran the contract fixtures. No library code changes. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A3WxDBWBA4ituJjXsHaEAa --- .github/workflows/ci.yml | 28 +- CONTRIBUTING.md | 7 +- aimux-core/tests/contract_test.rs | 363 -- .../tests/host_side_tool_call_repair_test.rs | 421 --- aimux-core/tests/m11_m12_m6_test.rs | 366 -- aimux-core/tests/m7_aggregation_test.rs | 333 -- aimux-core/tests/recording_e2e_test.rs | 236 -- .../tests/response_messages_tool_test.rs | 500 --- aimux-core/tests/session_test.rs | 182 - .../tests/stream_timeout_matrix_test.rs | 179 - .../anthropic_assistant_tool_result_test.rs | 816 ----- .../tests/anthropic_aws_model_test.rs | 594 ---- .../tests/anthropic_cache_control_test.rs | 721 ---- .../tests/anthropic_convert_full_test.rs | 778 ----- .../tests/anthropic_convert_test.rs | 1025 ------ aimux-providers/tests/anthropic_model_test.rs | 1965 ----------- .../tests/anthropic_prepare_tools_test.rs | 408 --- .../tests/anthropic_provider_tools_test.rs | 2078 ----------- .../tests/anthropic_remaining_test.rs | 675 ---- aimux-providers/tests/azure_model_test.rs | 916 ----- aimux-providers/tests/azure_responses_test.rs | 1054 ------ aimux-providers/tests/bedrock_model_test.rs | 751 ---- .../tests/bedrock_reasoning_test.rs | 1359 -------- .../tests/bedrock_remaining_test.rs | 1361 -------- .../tests/bedrock_reranking_test.rs | 392 --- aimux-providers/tests/body_overrides_test.rs | 286 -- aimux-providers/tests/cohere_model_test.rs | 1596 --------- .../tests/cohere_remaining_test.rs | 763 ----- .../tests/cohere_reranking_test.rs | 350 -- aimux-providers/tests/convert_golden_test.rs | 245 -- .../tests/deepseek_reasoning_test.rs | 956 ------ aimux-providers/tests/google_model_test.rs | 2700 --------------- .../tests/google_provider_tools_test.rs | 2595 -------------- .../tests/google_remaining_test.rs | 1440 -------- aimux-providers/tests/mistral_model_test.rs | 1641 --------- .../tests/mistral_remaining_test.rs | 860 ----- .../tests/openai_convert_extended_test.rs | 958 ------ aimux-providers/tests/openai_convert_test.rs | 827 ----- aimux-providers/tests/openai_files_test.rs | 321 -- .../tests/openai_model_extended_test.rs | 936 ----- aimux-providers/tests/openai_model_test.rs | 2155 ------------ aimux-providers/tests/openai_provider_test.rs | 282 -- .../tests/openai_responses_test.rs | 1523 --------- .../tests/stream_error_semantics_test.rs | 139 - .../tool_result_and_reasoning_fix_test.rs | 315 -- .../tests/vertex_anthropic_test.rs | 564 --- .../tests/vertex_embedding_test.rs | 335 -- aimux-providers/tests/vertex_model_test.rs | 1425 -------- .../tests/vertex_remaining_test.rs | 504 --- aimux-providers/tests/xai_responses_test.rs | 3030 ----------------- aimux-providers/tests/xai_test.rs | 2822 --------------- bindings/README.md | 14 - bindings/flutter/test/contract_test.dart | 156 - .../flutter/test/tool_call_repair_test.dart | 222 -- bindings/go/repair_test.go | 279 -- bindings/go/wire_format_test.go | 256 -- .../java/ai/arcships/aimux/ContractTest.java | 398 --- .../ai/arcships/aimux/ToolCallRepairTest.java | 432 --- .../kotlin/ai/arcships/aimux/ContractTest.kt | 207 -- .../ai/arcships/aimux/ToolCallRepairTest.kt | 310 -- .../__test__/thought_signature_replay.test.ts | 211 -- .../node/__test__/tool_call_repair.test.ts | 207 -- bindings/python/tests/test_contract.py | 123 - .../python/tests/test_tool_call_repair.py | 194 -- .../Tests/AimuxTests/ContractTests.swift | 183 - .../AimuxTests/ToolCallRepairTests.swift | 206 -- contract-tests/fixtures/tool-call-repair.json | 1295 ------- contract-tests/fixtures/wire-format.json | 170 - contract-tests/run-node.ts | 192 -- scripts/local-ci.sh | 19 +- 70 files changed, 10 insertions(+), 52140 deletions(-) delete mode 100644 aimux-core/tests/contract_test.rs delete mode 100644 aimux-core/tests/host_side_tool_call_repair_test.rs delete mode 100644 aimux-core/tests/m11_m12_m6_test.rs delete mode 100644 aimux-core/tests/m7_aggregation_test.rs delete mode 100644 aimux-core/tests/recording_e2e_test.rs delete mode 100644 aimux-core/tests/response_messages_tool_test.rs delete mode 100644 aimux-core/tests/session_test.rs delete mode 100644 aimux-core/tests/stream_timeout_matrix_test.rs delete mode 100644 aimux-providers/tests/anthropic_assistant_tool_result_test.rs delete mode 100644 aimux-providers/tests/anthropic_aws_model_test.rs delete mode 100644 aimux-providers/tests/anthropic_cache_control_test.rs delete mode 100644 aimux-providers/tests/anthropic_convert_full_test.rs delete mode 100644 aimux-providers/tests/anthropic_convert_test.rs delete mode 100644 aimux-providers/tests/anthropic_model_test.rs delete mode 100644 aimux-providers/tests/anthropic_prepare_tools_test.rs delete mode 100644 aimux-providers/tests/anthropic_provider_tools_test.rs delete mode 100644 aimux-providers/tests/anthropic_remaining_test.rs delete mode 100644 aimux-providers/tests/azure_model_test.rs delete mode 100644 aimux-providers/tests/azure_responses_test.rs delete mode 100644 aimux-providers/tests/bedrock_model_test.rs delete mode 100644 aimux-providers/tests/bedrock_reasoning_test.rs delete mode 100644 aimux-providers/tests/bedrock_remaining_test.rs delete mode 100644 aimux-providers/tests/bedrock_reranking_test.rs delete mode 100644 aimux-providers/tests/body_overrides_test.rs delete mode 100644 aimux-providers/tests/cohere_model_test.rs delete mode 100644 aimux-providers/tests/cohere_remaining_test.rs delete mode 100644 aimux-providers/tests/cohere_reranking_test.rs delete mode 100644 aimux-providers/tests/convert_golden_test.rs delete mode 100644 aimux-providers/tests/deepseek_reasoning_test.rs delete mode 100644 aimux-providers/tests/google_model_test.rs delete mode 100644 aimux-providers/tests/google_provider_tools_test.rs delete mode 100644 aimux-providers/tests/google_remaining_test.rs delete mode 100644 aimux-providers/tests/mistral_model_test.rs delete mode 100644 aimux-providers/tests/mistral_remaining_test.rs delete mode 100644 aimux-providers/tests/openai_convert_extended_test.rs delete mode 100644 aimux-providers/tests/openai_convert_test.rs delete mode 100644 aimux-providers/tests/openai_files_test.rs delete mode 100644 aimux-providers/tests/openai_model_extended_test.rs delete mode 100644 aimux-providers/tests/openai_model_test.rs delete mode 100644 aimux-providers/tests/openai_provider_test.rs delete mode 100644 aimux-providers/tests/openai_responses_test.rs delete mode 100644 aimux-providers/tests/stream_error_semantics_test.rs delete mode 100644 aimux-providers/tests/tool_result_and_reasoning_fix_test.rs delete mode 100644 aimux-providers/tests/vertex_anthropic_test.rs delete mode 100644 aimux-providers/tests/vertex_embedding_test.rs delete mode 100644 aimux-providers/tests/vertex_model_test.rs delete mode 100644 aimux-providers/tests/vertex_remaining_test.rs delete mode 100644 aimux-providers/tests/xai_responses_test.rs delete mode 100644 aimux-providers/tests/xai_test.rs delete mode 100644 bindings/flutter/test/contract_test.dart delete mode 100644 bindings/flutter/test/tool_call_repair_test.dart delete mode 100644 bindings/go/repair_test.go delete mode 100644 bindings/go/wire_format_test.go delete mode 100644 bindings/java/src/test/java/ai/arcships/aimux/ContractTest.java delete mode 100644 bindings/java/src/test/java/ai/arcships/aimux/ToolCallRepairTest.java delete mode 100644 bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ContractTest.kt delete mode 100644 bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ToolCallRepairTest.kt delete mode 100644 bindings/node/__test__/thought_signature_replay.test.ts delete mode 100644 bindings/node/__test__/tool_call_repair.test.ts delete mode 100644 bindings/python/tests/test_contract.py delete mode 100644 bindings/python/tests/test_tool_call_repair.py delete mode 100644 bindings/swift/Tests/AimuxTests/ContractTests.swift delete mode 100644 bindings/swift/Tests/AimuxTests/ToolCallRepairTests.swift delete mode 100644 contract-tests/fixtures/tool-call-repair.json delete mode 100644 contract-tests/fixtures/wire-format.json delete mode 100644 contract-tests/run-node.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 001ac0a2b..0845b7766 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -395,9 +395,9 @@ jobs: working-directory: bindings/flutter/example run: flutter test - # ── Contract tests: shared JSON fixtures (catches cross-language type drift) ── + # Generated files must match their generators. contract-tests: - name: Contract tests + name: Generator drift checks runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -407,29 +407,13 @@ jobs: run: python3 scripts/gen_providers_doc.py --check - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - - uses: actions/setup-node@v4 - with: - node-version: 22 - - name: Run Rust contract tests - run: cargo test --test contract_test -p aimux-core - name: ts-rs export drift (checked-in types must match generator output) run: python3 scripts/gen_ts_types.py --check - - name: Build Node binding - working-directory: bindings/node - run: | - npm ci - npx napi build --platform - - name: Run Node contract tests - working-directory: contract-tests - run: | - node --experimental-strip-types run-node.ts - # ── Swift contract tests: the eighth binding joins the shared fixtures ───── - # Needs macOS (XCTest) and the FFI cdylib, which the Swift package links via - # the CAimuxFFI system module. The Dart side needs no job here: flutter-binding - # already runs `flutter test`, which picks up bindings/flutter/test/contract_test.dart. + # Swift tests need macOS (XCTest) and the FFI cdylib, which the Swift package + # links via the CAimuxFFI system module. swift-contract-tests: - name: Swift contract tests + name: Swift tests runs-on: macos-latest steps: - uses: actions/checkout@v4 @@ -437,7 +421,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/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/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/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/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/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/test/java/ai/arcships/aimux/ContractTest.java b/bindings/java/src/test/java/ai/arcships/aimux/ContractTest.java deleted file mode 100644 index 30f7b1d19..000000000 --- a/bindings/java/src/test/java/ai/arcships/aimux/ContractTest.java +++ /dev/null @@ -1,398 +0,0 @@ -package ai.arcships.aimux; - -import com.fasterxml.jackson.databind.JsonNode; -import org.junit.jupiter.api.Test; - -import java.io.File; -import java.io.IOException; -import java.util.Arrays; -import java.util.LinkedHashMap; -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Contract tests — validate the Java binding's wire-format consistency against the - * shared fixtures in {@code contract-tests/fixtures/wire-format.json} (the same - * fixtures used by the Rust contract tests and the Node runner). - * - *

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

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

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

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

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

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

Two layers: a replay of the shared - * {@code contract-tests/fixtures/tool-call-repair.json} over the three native - * functions, plus public typed non-streaming/streaming integration and the - * same-model nested-generation deadlock regression. Tests use only mock - * providers, but need the native library on {@code java.library.path}. - */ -class ToolCallRepairTest { - - private static final String[] FIXTURE_CANDIDATES = { - "../../contract-tests/fixtures/tool-call-repair.json", // gradle test cwd = bindings/java - "contract-tests/fixtures/tool-call-repair.json", // run from the repo root - "../contract-tests/fixtures/tool-call-repair.json", - }; - - // ── shared fixture replay ──────────────────────────────────────────────── - - @Test - void contractFixtureReplays() throws Exception { - JsonNode cases = loadFixture().get("cases"); - List replayed = new ArrayList<>(); - for (JsonNode c : cases) { - String name = c.get("name").asText(); - String expectedError = c.path("expected_error").asText(null); - if (expectedError != null) { - // Every error case in the fixture is InvalidArgument; if a new - // variant appears, this pins it rather than silently passing. - assertThat(expectedError).describedAs(name).isEqualTo("InvalidArgument"); - assertThatThrownBy(() -> invoke(c)) - .describedAs(name) - .isInstanceOf(AimuxException.InvalidArgumentError.class); - } else { - assertThat(json(invoke(c))).describedAs(name).isEqualTo(c.get("expected")); - } - replayed.add(name); - } - assertThat(replayed).hasSize(cases.size()); - } - - private static String invoke(JsonNode testCase) { - JsonNode in = testCase.get("input"); - String function = testCase.get("function").asText(); - String opts = text(in.get("opts")); - if ("tool_call_repair_context".equals(function)) { - return ToolCallRepairs.context(in.get("tool_call").toString(), text(in.get("prompt")), opts); - } - if ("apply_tool_call_repair".equals(function)) { - return ToolCallRepairs.apply(in.get("tool_call").toString(), opts, in.get("reply").toString()); - } - if ("apply_tool_call_repair_to_result".equals(function)) { - return ToolCallRepairs.applyToResult(in.get("result").toString(), opts, - in.get("tool_call_id").asText(), in.get("reply").toString()); - } - throw new AssertionError("unknown fixture function: " + function); - } - - // ── end to end through TypedModel (mock provider) ──────────────────────── - - /** The strict tool wants {@code location}; the canned calls send {@code city}. */ - private static Types.RawToolCall cityToLocation(Types.ToolCallRepairContext context) { - return Types.RawToolCall.builder() - .toolCallId(context.getToolCall().getToolCallId()) - .toolName(context.getToolCall().getToolName()) - .input(context.getToolCall().getInput().replace("city", "location")) - .build(); - } - - @Test - void generateTextRepairsTheInvalidCallItGotBack() { - try (MockProviderServer server = new MockProviderServer()) { - server.setResponseBody(toolCallResponse("{\"city\":\"Tokyo\"}")); - Types.GenerateTextOptions options = Types.GenerateTextOptions.builder() - .tools(Collections.singletonList(strictWeatherTool())) - .repairToolCall(context -> { - assertThat(context.getToolCall().getInput()).isEqualTo("{\"city\":\"Tokyo\"}"); - return cityToLocation(context); - }) - .build(); - - try (TypedModel model = - TypedModel.openaiWithBase("sk-test-fake-key", "gpt-4o", server.baseUrl())) { - Types.GenerateTextResult result = - model.generateText("What is the weather in Tokyo?", options); - - Types.ToolCall call = result.getToolCalls().get(0); - assertThat(call.getInvalid()).isNull(); - assertThat(call.getInput().get("location").asText()).isEqualTo("Tokyo"); - Types.ContentPart.ToolCall transcriptCall = result.getResponseMessages().stream() - .flatMap(message -> message.getContentParts().stream()) - .filter(part -> part instanceof Types.ContentPart.ToolCall) - .map(part -> (Types.ContentPart.ToolCall) part) - .findFirst().orElseThrow(AssertionError::new); - assertThat(transcriptCall.getInput().get("location").asText()).isEqualTo("Tokyo"); - } - } - } - - @Test - void generateTextAsOpenAICarriesTheRepairedArguments() { - try (MockProviderServer server = new MockProviderServer()) { - server.setResponseBody(toolCallResponse("{\"city\":\"Tokyo\"}")); - Types.GenerateTextOptions options = Types.GenerateTextOptions.builder() - .tools(Collections.singletonList(strictWeatherTool())) - .repairToolCall(ToolCallRepairTest::cityToLocation) - .build(); - - try (TypedModel model = - TypedModel.openaiWithBase("sk-test-fake-key", "gpt-4o", server.baseUrl())) { - // A ChatCompletion has no invalid marker: the native result is - // repaired, then converted. - Types.ChatCompletion completion = - model.generateTextAsOpenAI("What is the weather in Tokyo?", options); - - Types.ChatCompletionToolCall call = - completion.getChoices().get(0).getMessage().getToolCalls().get(0); - assertThat(call.getFunction().getArguments()).isEqualTo("{\"location\":\"Tokyo\"}"); - } - } - } - - @Test - void aStreamRepairFailingAtTheBoundaryStillDeliversTheCall() { - try (MockProviderServer server = new MockProviderServer()) { - server.setContentType("text/event-stream"); - server.setResponseBody(toolCallSse("{\"city\":\"Tokyo\"}")); - // A replacement without a tool_call_id is not a RawToolCall on the - // wire: the library rejects the reply itself (not the hook failing). - Types.GenerateTextOptions options = Types.GenerateTextOptions.builder() - .tools(Collections.singletonList(strictWeatherTool())) - .repairToolCall(context -> Types.RawToolCall.builder() - .toolCallId(null) - .toolName(context.getToolCall().getToolName()) - .input("{\"location\":\"Tokyo\"}") - .build()) - .build(); - - try (TypedModel model = - TypedModel.openaiWithBase("sk-test-fake-key", "gpt-4o", server.baseUrl())) { - List parts = new ArrayList<>(); - List errors = new ArrayList<>(); - model.streamText("What is the weather in Tokyo?", options, - parts::add, () -> { }, errors::add); - - assertThat(errors).hasSize(1); - assertThat(errors.get(0)).startsWith("failed to repair tool call:"); - List calls = parts.stream() - .filter(p -> p instanceof Types.StreamPart.ToolCall) - .map(p -> (Types.StreamPart.ToolCall) p) - .collect(Collectors.toList()); - assertThat(calls).hasSize(1); - assertThat(calls.get(0).getInvalid()).isTrue(); - } - } - } - - @Test - void streamTextStreamDeliversTheRepairedToolCallPart() { - try (MockProviderServer server = new MockProviderServer()) { - server.setContentType("text/event-stream"); - server.setResponseBody(toolCallSse("{\"city\":\"Tokyo\"}")); - Types.GenerateTextOptions options = Types.GenerateTextOptions.builder() - .tools(Collections.singletonList(strictWeatherTool())) - .repairToolCall(ToolCallRepairTest::cityToLocation) - .build(); - - try (TypedModel model = - TypedModel.openaiWithBase("sk-test-fake-key", "gpt-4o", server.baseUrl())) { - List parts = - model.streamTextStream("What is the weather in Tokyo?", options) - .collect(Collectors.toList()); - - List calls = parts.stream() - .filter(p -> p instanceof Types.StreamPart.ToolCall) - .map(p -> (Types.StreamPart.ToolCall) p) - .collect(Collectors.toList()); - assertThat(calls).hasSize(1); - assertThat(calls.get(0).getInvalid()).isNull(); - assertThat(calls.get(0).getInput().get("location").asText()).isEqualTo("Tokyo"); - - // Argument deltas are forwarded verbatim, repair or not. - assertThat(parts.stream() - .filter(p -> p instanceof Types.StreamPart.ToolInputDelta) - .map(p -> ((Types.StreamPart.ToolInputDelta) p).getDelta()) - .collect(Collectors.joining())).contains("city"); - } - } - } - - @Test - void theRepairLoopHandlesThrowNullValidCallAndNoToolSet() { - try (MockProviderServer server = new MockProviderServer(); - TypedModel model = - TypedModel.openaiWithBase("sk-test-fake-key", "gpt-4o", server.baseUrl())) { - final AtomicInteger hookCalls = new AtomicInteger(); - ToolCallRepair countingNull = context -> { - hookCalls.incrementAndGet(); - return null; - }; - - // The hook throws: ToolCallRepair { original_error, cause: Other }. - server.setResponseBody(toolCallResponse("{\"city\":\"Tokyo\"}")); - Types.ToolCall call = model.generateText("What is the weather in Tokyo?", - Types.GenerateTextOptions.builder() - .tools(Collections.singletonList(strictWeatherTool())) - .repairToolCall(context -> { - hookCalls.incrementAndGet(); - throw new IllegalStateException("repair model unavailable"); - }) - .build()).getToolCalls().get(0); - assertThat(call.getInvalid()).isTrue(); - JsonNode failure = call.getError().path("ToolCallRepair"); - assertThat(failure.path("cause").path("Other").asText()).isEqualTo("repair model unavailable"); - assertThat(failure.path("original_error").has("InvalidToolInput")).isTrue(); - assertThat(hookCalls.get()).isEqualTo(1); - - // The hook returns null: the ORIGINAL error, not wrapped in ToolCallRepair. - server.setResponseBody(toolCallResponse("{\"city\":\"Tokyo\"}")); - call = model.generateText("What is the weather in Tokyo?", - Types.GenerateTextOptions.builder() - .tools(Collections.singletonList(strictWeatherTool())) - .repairToolCall(countingNull) - .build()).getToolCalls().get(0); - assertThat(call.getInvalid()).isTrue(); - assertThat(call.getError().has("ToolCallRepair")).isFalse(); - assertThat(call.getError().has("InvalidToolInput")).isTrue(); - assertThat(hookCalls.get()).isEqualTo(2); - - // A valid call never reaches the hook. - server.setResponseBody(toolCallResponse("{\"location\":\"Tokyo\"}")); - call = model.generateText("What is the weather in Tokyo?", - Types.GenerateTextOptions.builder() - .tools(Collections.singletonList(strictWeatherTool())) - .repairToolCall(countingNull) - .build()).getToolCalls().get(0); - assertThat(call.getInvalid()).isNull(); - assertThat(hookCalls.get()).isEqualTo(2); - - // No tool set: the native context answers null, so the host skips the hook. - server.setResponseBody(toolCallResponse("{\"city\":\"Tokyo\"}")); - call = model.generateText("What is the weather in Tokyo?", - Types.GenerateTextOptions.builder().repairToolCall(countingNull).build()) - .getToolCalls().get(0); - assertThat(call.getInvalid()).isTrue(); - assertThat(hookCalls.get()).isEqualTo(2); - } - } - - @Test - void aStreamHookMayGenerateWithTheSameModelWhileCloseIsPending() { - // Regression: the stream holds Model's (fair) read lock for its whole - // duration, a queued close() waits for it, and a hook generating with - // the SAME model used to queue behind that writer — S waits for R waits - // for C waits for S. The stream thread lends its read hold to the repair - // worker so the hook's call takes no lock at all. - assertTimeoutPreemptively(Duration.ofSeconds(30), () -> { - try (MockProviderServer server = new MockProviderServer()) { - server.setContentType("text/event-stream"); - server.setResponses(toolCallSse("{\"city\":\"Tokyo\"}"), - textResponse("{\"location\":\"Tokyo\"}")); - - final Model raw = - Model.openaiWithBase("sk-test-fake-key", "gpt-4o", server.baseUrl()); - final TypedModel model = TypedModel.of(raw); - final CountDownLatch hookStarted = new CountDownLatch(1); - final AtomicReference closerFailure = new AtomicReference<>(); - Thread closer = new Thread(() -> { - try { - hookStarted.await(); - raw.close(); - } catch (Throwable t) { - closerFailure.set(t); - } - }, "closer"); - closer.start(); - - Types.GenerateTextOptions options = Types.GenerateTextOptions.builder() - .tools(Collections.singletonList(strictWeatherTool())) - .repairToolCall(context -> { - hookStarted.countDown(); - // Let the closer actually enqueue for the write lock; - // without it the race the test reproduces may not happen. - Thread.sleep(200); - // The mock server's content type is global, so flip it - // now that the SSE response has already been written. - server.setContentType("application/json"); - return Types.RawToolCall.builder() - .toolCallId(context.getToolCall().getToolCallId()) - .toolName(context.getToolCall().getToolName()) - .input(model.generateText( - "fix these arguments: " + context.getToolCall().getInput()) - .getText()) - .build(); - }) - .build(); - - List parts = new ArrayList<>(); - model.streamText("What is the weather in Tokyo?", options, - parts::add, () -> {}, error -> { throw new AssertionError(error); }); - - closer.join(10_000); - assertThat(closer.isAlive()).isFalse(); - assertThat(closerFailure.get()).isNull(); - - List calls = parts.stream() - .filter(p -> p instanceof Types.StreamPart.ToolCall) - .map(p -> (Types.StreamPart.ToolCall) p) - .collect(Collectors.toList()); - assertThat(calls).hasSize(1); - assertThat(calls.get(0).getInvalid()).isNull(); - assertThat(calls.get(0).getInput().get("location").asText()).isEqualTo("Tokyo"); - raw.close(); // idempotent; the closer already did it - } - }); - } - - /** A plain OpenAI text response whose content is {@code text}. */ - private static String textResponse(String text) { - return new JSONObject() - .put("id", "chatcmpl-repair") - .put("model", "gpt-4o") - .put("choices", new JSONArray().put(new JSONObject() - .put("message", new JSONObject().put("role", "assistant").put("content", text)) - .put("finish_reason", "stop"))) - .put("usage", new JSONObject() - .put("prompt_tokens", 4).put("completion_tokens", 4).put("total_tokens", 8)) - .toString(); - } - - /** {@code get_weather}, schema-strict so a {@code city} argument is rejected. */ - private static Types.Tool strictWeatherTool() { - ObjectNode schema = Types.AimuxJson.MAPPER.createObjectNode(); - schema.put("type", "object"); - schema.set("properties", Types.AimuxJson.MAPPER.createObjectNode() - .set("location", Types.AimuxJson.MAPPER.createObjectNode().put("type", "string"))); - schema.set("required", Types.AimuxJson.MAPPER.createArrayNode().add("location")); - schema.put("additionalProperties", false); - return Types.Tool.Function.builder().name("get_weather").inputSchema(schema).build(); - } - - private static String toolCallResponse(String arguments) { - return new JSONObject() - .put("id", "chatcmpl-tc") - .put("model", "gpt-4o") - .put("choices", new JSONArray().put(new JSONObject() - .put("message", new JSONObject() - .put("role", "assistant") - .put("content", JSONObject.NULL) - .put("tool_calls", new JSONArray().put(new JSONObject() - .put("id", "call_abc") - .put("type", "function") - .put("function", new JSONObject() - .put("name", "get_weather") - .put("arguments", arguments))))) - .put("finish_reason", "tool_calls"))) - .put("usage", new JSONObject() - .put("prompt_tokens", 20).put("completion_tokens", 10).put("total_tokens", 30)) - .toString(); - } - - private static String toolCallSse(String arguments) { - StringBuilder sb = new StringBuilder(); - sb.append("data: ").append(new JSONObject() - .put("id", "1").put("model", "gpt-4o") - .put("choices", new JSONArray().put(new JSONObject().put("delta", new JSONObject() - .put("role", "assistant") - .put("tool_calls", new JSONArray().put(new JSONObject() - .put("index", 0).put("id", "call_xyz").put("type", "function") - .put("function", new JSONObject().put("name", "get_weather") - .put("arguments", "")))))))).append("\n\n"); - sb.append("data: ").append(new JSONObject() - .put("id", "1").put("model", "gpt-4o") - .put("choices", new JSONArray().put(new JSONObject().put("delta", new JSONObject() - .put("tool_calls", new JSONArray().put(new JSONObject() - .put("index", 0) - .put("function", new JSONObject().put("arguments", arguments)))))))).append("\n\n"); - sb.append("data: ").append(new JSONObject() - .put("id", "1").put("model", "gpt-4o") - .put("choices", new JSONArray().put(new JSONObject() - .put("delta", new JSONObject()).put("finish_reason", "tool_calls"))) - .put("usage", new JSONObject() - .put("prompt_tokens", 5).put("completion_tokens", 2).put("total_tokens", 7))) - .append("\n\n"); - sb.append("data: [DONE]\n\n"); - return sb.toString(); - } - - private static JsonNode json(String raw) throws IOException { - return Types.AimuxJson.MAPPER.readTree(raw); - } - - private static String text(JsonNode node) { - return node == null || node.isNull() ? null : node.toString(); - } - - private static JsonNode loadFixture() throws IOException { - for (String candidate : FIXTURE_CANDIDATES) { - File f = new File(candidate); - if (f.isFile()) { - return Types.AimuxJson.MAPPER.readTree(f); - } - } - throw new IllegalStateException( - "cannot find tool-call-repair.json; tried " + Arrays.toString(FIXTURE_CANDIDATES) - + " from " + new File(".").getAbsolutePath()); - } -} diff --git a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ContractTest.kt b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ContractTest.kt deleted file mode 100644 index bfede7f26..000000000 --- a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ContractTest.kt +++ /dev/null @@ -1,207 +0,0 @@ -package ai.arcships.aimux - -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.put -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import java.io.File - -/** - * Cross-language contract tests for the Kotlin binding. - * - * Consumes the shared `contract-tests/fixtures/wire-format.json` (same - * fixtures as Rust `contract_test.rs`, Node `run-node.ts`, Java - * `ContractTest` and Go `wire_format_test.go`). - * - * Pure serialization tests: no native library is loaded. - */ -class ContractTest { - - /** Candidate locations for the shared fixtures, relative to the test working dir. */ - private val fixtureCandidates = listOf( - "../../contract-tests/fixtures/wire-format.json", // gradle test cwd = bindings/kotlin - "contract-tests/fixtures/wire-format.json", // run from the repo root - "../contract-tests/fixtures/wire-format.json", - ) - - private fun loadFixtures(): JsonArray { - val file = fixtureCandidates.map(::File).firstOrNull { it.isFile } - ?: throw IllegalStateException( - "cannot find wire-format.json; tried $fixtureCandidates from ${File(".").absolutePath}" - ) - return Json.parseToJsonElement(file.readText()).jsonArray - } - - private fun fixtureJson(fixtures: JsonArray, name: String): String { - for (f in fixtures) { - if (name == f.jsonObject["name"]?.jsonPrimitive?.content) { - return f.jsonObject["json"]!!.jsonPrimitive.content - } - } - throw IllegalStateException("no fixture named '$name'") - } - - /// RFC-0016 M2: the shared `stream_part_raw` fixture decodes to the - /// Kotlin `StreamPart.Raw` variant with the raw payload intact. - @Test - fun `stream part raw fixture decodes to Raw variant`() { - val json = fixtureJson(loadFixtures(), "stream_part_raw") - val part = AimuxJson.decodeFromString(json) - assertThat(part).isInstanceOf(StreamPart.Raw::class.java) - val raw = part as StreamPart.Raw - assertThat(raw.rawValue.jsonObject["id"]?.jsonPrimitive?.content).isEqualTo("c1") - assertThat(raw.rawValue.jsonObject["choices"]).isNotNull - } - - /// RFC-0016 M2 true-case: `include_raw_chunks: true` round-trips through - /// the Kotlin typed options. - @Test - fun `include raw chunks true round-trips through typed options`() { - val json = fixtureJson(loadFixtures(), "generate_text_options_include_raw_chunks_true") - val opts = AimuxJson.decodeFromString(json) - assertThat(opts.includeRawChunks).isTrue() - - // Re-encode: include_raw_chunks must survive (encodeDefaults=false, - // so only non-default fields are emitted). - val reencoded = AimuxJson.encodeToString(GenerateTextOptions.serializer(), opts) - assertThat(reencoded).contains("\"include_raw_chunks\":true") - } - - /// RFC-0024: `session_id` decodes from the shared fixture and round-trips. - @Test - fun `session id wire format and round-trip`() { - val json = fixtureJson(loadFixtures(), "generate_text_options_with_session_id") - val opts = AimuxJson.decodeFromString(json) - assertThat(opts.sessionId).isEqualTo("sess-1") - - val reencoded = AimuxJson.encodeToString(GenerateTextOptions.serializer(), opts) - assertThat(reencoded).contains("\"session_id\":\"sess-1\"") - } - - @Test - fun `provider executed tool transcript message round-trips`() { - val json = fixtureJson( - loadFixtures(), - "model_message_provider_executed_tool_transcript", - ) - val message = AimuxJson.decodeFromString(json) - assertThat(message.role).isEqualTo(Role.ASSISTANT) - val parts = message.contentParts!! - assertThat(parts).hasSize(2) - val call = parts[0] as ContentPart.ToolCall - assertThat(call.providerExecuted).isTrue() - val result = parts[1] as ContentPart.ToolResult - assertThat(result.toolName).isEqualTo("search") - assertThat(result.isError).isFalse() - assertThat(result.preliminary).isTrue() - assertThat(result.dynamic).isTrue() - - val reencoded = AimuxJson.encodeToString(ModelMessage.serializer(), message) - assertThat(AimuxJson.parseToJsonElement(reencoded)) - .isEqualTo(AimuxJson.parseToJsonElement(json)) - } - - /// Every `GenerateContent` fixture decodes into its concrete variant. - /// - /// The variant type is asserted, not merely the absence of an exception: - /// `GenerateContent` falls back to [GenerateContent.Unknown] for forward - /// compatibility, so a variant whose shape drifted would still decode — - /// silently, as `Unknown`. Only naming the expected class catches that. - /// The fixture count is asserted so a newly added fixture cannot slip past - /// this test unnoticed. - @Test - fun `generate content fixtures decode into their variants`() { - val fixtures = loadFixtures() - val byName = LinkedHashMap() - for (element in fixtures) { - val obj = element.jsonObject - if (obj["type"]?.jsonPrimitive?.content != "GenerateContent") continue - val name = obj["name"]!!.jsonPrimitive.content - val json = obj["json"]!!.jsonPrimitive.content - byName[name] = AimuxJson.decodeFromString(json) - } - assertThat(byName).hasSize(8) - - assertThat(byName["generate_content_text"]).isInstanceOf(GenerateContent.Text::class.java) - assertThat(byName["generate_content_reasoning_no_metadata"]) - .isInstanceOf(GenerateContent.Reasoning::class.java) - - // The shapes a parse-only check leaves invisible: the tool-call input, - // the nested file union, and Source's optionals. - val toolCall = byName["generate_content_tool_call"] as GenerateContent.ToolCall - assertThat(toolCall.toolCallId).isEqualTo("call_1") - assertThat(toolCall.input.jsonPrimitive.content).isEqualTo("{\"city\":\"Paris\"}") - assertThat(toolCall.providerExecuted).isTrue() - - val file = byName["generate_content_file"] as GenerateContent.File - assertThat(file.mediaType).isEqualTo("image/png") - - val source = - byName["generate_content_source_unset_optionals"] as GenerateContent.Source - assertThat(source.url).isNull() - assertThat(source.title).isNull() - } - - /// The regression lock for `topK`, which this binding declared as `Long` - /// against an `f64` wire. - /// - /// Every value is asserted explicitly rather than relying on decoding to - /// fail: a decode-only test would pass again the moment someone restored - /// the integer type, since a serializer is free to coerce `40.5` rather - /// than reject it. `topK` is the field that was wrong; the others are - /// asserted so a narrowing of any single numeric type is caught here too. - @Test - fun `numeric options decode with full precision`() { - val json = fixtureJson(loadFixtures(), "generate_text_options_numeric_types") - val opts = AimuxJson.decodeFromString(json) - - assertThat(opts.topK).isEqualTo(40.5) - assertThat(opts.frequencyPenalty).isEqualTo(-0.5) - assertThat(opts.temperature).isEqualTo(0.7) - assertThat(opts.topP).isEqualTo(0.95) - assertThat(opts.presencePenalty).isEqualTo(0.5) - assertThat(opts.maxOutputTokens).isEqualTo(256L) - assertThat(opts.seed).isEqualTo(42L) - assertThat(opts.maxRetries).isEqualTo(3L) - } - - @Test - fun `video poll options use the core wire names`() { - val opts = VideoCallOptions( - prompt = "a cat", - poll = VideoPollOptions(intervalMs = 1_000L, timeoutMs = 120_000L), - ) - - val encoded = AimuxJson.encodeToString(VideoCallOptions.serializer(), opts) - assertThat(encoded).contains( - "\"poll\":{\"interval_ms\":1000,\"timeout_ms\":120000}", - ) - val decoded = AimuxJson.decodeFromString(encoded) - assertThat(decoded.poll?.intervalMs).isEqualTo(1_000L) - assertThat(decoded.poll?.timeoutMs).isEqualTo(120_000L) - } - - /// RFC-0016 M10: `Usage.raw` with a vendor-specific field survives a - /// Kotlin round-trip. - @Test - fun `usage raw preserves vendor fields round-trip`() { - val usage = Usage( - inputTokens = TokenUsage(total = 20L), - outputTokens = TokenUsage(total = 5L), - raw = buildJsonObject { - put("prompt_cache_hit_tokens", 42) - put("prompt_tokens", 20) - }, - ) - val json = AimuxJson.encodeToString(Usage.serializer(), usage) - assertThat(json).contains("\"raw\"") - val decoded = AimuxJson.decodeFromString(json) - assertThat(decoded.raw?.jsonObject?.get("prompt_cache_hit_tokens")?.jsonPrimitive?.content) - .isEqualTo("42") - } -} diff --git a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ToolCallRepairTest.kt b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ToolCallRepairTest.kt deleted file mode 100644 index 6b089a37d..000000000 --- a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ToolCallRepairTest.kt +++ /dev/null @@ -1,310 +0,0 @@ -package ai.arcships.aimux - -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.assertThatThrownBy -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assertions.assertTimeoutPreemptively -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import java.io.File -import java.time.Duration -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit - -// ───────────────────────────────────────────────────────────────────────────── -// Host-side tool-call repair (RFC-0035). -// -// Two halves: -// 1. the shared `tool-call-repair.json` fixture replayed through the three -// native entry points (pure functions — no model, no server), -// 2. end-to-end through TypedModel against MockProviderServer: the provider -// returns arguments that violate the tool schema, Core marks the call -// invalid, and the repair function runs on the JVM. -// ───────────────────────────────────────────────────────────────────────────── - -class ToolCallRepairTest { - - private lateinit var server: MockProviderServer - - @BeforeEach - fun setUp() { - server = MockProviderServer() - } - - @AfterEach - fun tearDown() { - server.stop() - } - - // ── shared fixture ────────────────────────────────────────────────── - - private val fixtureCandidates = listOf( - "../../contract-tests/fixtures/tool-call-repair.json", // gradle test cwd = bindings/kotlin - "contract-tests/fixtures/tool-call-repair.json", - "../contract-tests/fixtures/tool-call-repair.json", - ) - - private fun fixtureCases(): List { - val file = fixtureCandidates.map(::File).firstOrNull { it.isFile } - ?: throw IllegalStateException( - "cannot find tool-call-repair.json; tried $fixtureCandidates from ${File(".").absolutePath}" - ) - val cases = AimuxJson.parseToJsonElement(file.readText()).jsonObject["cases"]!! - return cases.let { it as kotlinx.serialization.json.JsonArray } - .map { it.jsonObject } - } - - /** `opts` may be JSON null in the fixture, which is the binding's "defaults". */ - private fun optsOf(input: JsonObject): String? = - input["opts"]?.takeIf { it !is JsonNull }?.toString() - - @Test - fun `all shared contract fixture cases replay`() { - fixtureCases().forEach { case -> - val name = case["name"]!!.jsonPrimitive.content - val input = case["input"]!!.jsonObject - val invoke = { - when (val function = case["function"]!!.jsonPrimitive.content) { - "tool_call_repair_context" -> toolCallRepairContext( - input["tool_call"]!!.toString(), input["prompt"]!!.toString(), optsOf(input)) - "apply_tool_call_repair" -> applyToolCallRepair( - input["tool_call"]!!.toString(), optsOf(input), input["reply"]!!.toString()) - "apply_tool_call_repair_to_result" -> applyToolCallRepairToResult( - input["result"]!!.toString(), optsOf(input), - input["tool_call_id"]!!.jsonPrimitive.content, input["reply"]!!.toString()) - else -> throw AssertionError("unknown fixture function: $function") - } - } - if (case["expected_error"] != null) { - assertThat(case["expected_error"]!!.jsonPrimitive.content).isEqualTo("InvalidArgument") - assertThatThrownBy { invoke() }.describedAs(name) - .isInstanceOf(InvalidArgumentError::class.java) - } else { - assertThat(AimuxJson.parseToJsonElement(invoke())).describedAs(name) - .isEqualTo(case["expected"]!!) - } - } - } - - // ── end to end ────────────────────────────────────────────────────── - - /** `city` is required; the canned response sends `town` instead. */ - private val weatherTool: Tool = Tool.Function( - name = "weather", - inputSchema = AimuxJson.parseToJsonElement( - """{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false}""" - ), - ) - - private fun openAiToolCall(arguments: String, name: String = "weather"): String = - """ - {"id":"chatcmpl-1","model":"gpt-4o","choices":[{"message":{"role":"assistant","content":null, - "tool_calls":[{"id":"call-1","type":"function","function":{"name":"$name","arguments":${ - JsonPrimitive(arguments) - }}}]},"finish_reason":"tool_calls"}], - "usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}} - """.trimIndent() - - private fun model(): TypedModel = TypedModel.openai("sk-test-fake-key", "gpt-4o", server.baseUrl) - - private fun options(repair: RepairToolCall?): GenerateTextOptions = - GenerateTextOptions(tools = listOf(weatherTool), repairToolCall = repair) - - @Test - fun `a repaired call becomes valid in both tool_calls and response_messages`() { - server.responseBody = openAiToolCall("""{"town":"Singapore"}""") - - model().use { model -> - val result = model.generateText( - "weather in Singapore?", - options { context -> - // The repair function sees the RAW argument text, not a parsed object. - assertThat(context.toolCall.input).isEqualTo("""{"town":"Singapore"}""") - assertThat(context.inputSchema.jsonObject["required"].toString()).contains("city") - assertThat(context.messages).hasSize(1) - context.toolCall.copy(input = """{"city":"Singapore"}""") - }, - ) - - val call = result.toolCalls.single() - assertThat(call.invalid).isNotEqualTo(true) - assertThat(call.error).isNull() - assertThat(call.input.jsonObject["city"]?.jsonPrimitive?.content).isEqualTo("Singapore") - - // The transcript must agree, or the next turn replays bad arguments. - val part = result.responseMessages - .flatMap { it.contentParts ?: emptyList() } - .filterIsInstance() - .single() - assertThat(part.toolCallId).isEqualTo("call-1") - assertThat(part.input.jsonObject["city"]?.jsonPrimitive?.content).isEqualTo("Singapore") - } - } - - @Test - fun `generateTextAsOpenAI carries the repaired arguments`() { - server.responseBody = openAiToolCall("""{"town":"Singapore"}""") - - model().use { model -> - // A ChatCompletion has no invalid marker: the native result is - // repaired, then converted. - val completion = model.generateTextAsOpenAI( - "weather in Singapore?", - options { context -> context.toolCall.copy(input = """{"city":"Singapore"}""") }, - ) - - val call = completion.choices.single().message.toolCalls!!.single() - assertThat(call.id).isEqualTo("call-1") - assertThat(call.function.arguments).isEqualTo("""{"city":"Singapore"}""") - } - } - - /** - * The host loop's non-happy branches: what a hook's two failure modes do - * to the call, and the two cases where the hook must not run at all. - */ - @Test - fun `a throwing hook wraps, a null hook preserves, and valid or untooled calls skip repair`() { - val invalidCall = openAiToolCall("""{"town":"Singapore"}""") - var hookCalls = 0 - - model().use { model -> - // Throwing means "repair failed": the call stays invalid and the - // original failure is kept under the ToolCallRepair wrapper. - server.responseBody = invalidCall - var call = model.generateText( - "weather in Singapore?", - options { hookCalls++; throw RuntimeException("repair model unavailable") }, - ).toolCalls.single() - assertThat(hookCalls).isEqualTo(1) - assertThat(call.invalid).isEqualTo(true) - val repairError = call.error!!.jsonObject["ToolCallRepair"]!!.jsonObject - assertThat(repairError["cause"]!!.jsonObject["Other"]!!.jsonPrimitive.content) - .isEqualTo("repair model unavailable") - assertThat(repairError["original_error"]!!.jsonObject.keys) - .containsExactly("InvalidToolInput") - - // Returning null declines the repair: the ORIGINAL error survives - // unwrapped, so the caller cannot tell a decline from no hook. - server.responseBody = invalidCall - call = model.generateText( - "weather in Singapore?", - options { hookCalls++; null }, - ).toolCalls.single() - assertThat(hookCalls).isEqualTo(2) - assertThat(call.invalid).isEqualTo(true) - assertThat(call.error!!.jsonObject.keys).containsExactly("InvalidToolInput") - - // A valid call never reaches the hook. - server.responseBody = openAiToolCall("""{"city":"Singapore"}""") - call = model.generateText( - "weather in Singapore?", - options { hookCalls++; null }, - ).toolCalls.single() - assertThat(hookCalls).isEqualTo(2) - assertThat(call.invalid).isNotEqualTo(true) - - // Without a tool set the native context is null (AI SDK never - // repairs those), so the invalid call is returned as-is. - server.responseBody = invalidCall - call = model.generateText( - "weather in Singapore?", - GenerateTextOptions(repairToolCall = { hookCalls++; null }), - ).toolCalls.single() - assertThat(hookCalls).isEqualTo(2) - assertThat(call.invalid).isEqualTo(true) - } - } - - /** The nested generation a repair hook performs: valid arguments as text. */ - private val repairCompletion: String = - """{"id":"chatcmpl-2","model":"gpt-4o","choices":[{"message":{"role":"assistant", - "content":"{\"city\":\"Singapore\"}"},"finish_reason":"stop"}], - "usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}""" - - /** SSE: tool-call start, one arguments delta violating the schema, finish. */ - private val weatherSse: String = buildString { - append("""data: {"id":"1","model":"gpt-4o","choices":[{"delta":{"role":"assistant","tool_calls":""") - append("""[{"index":0,"id":"call-1","type":"function","function":{"name":"weather","arguments":""}}]}}]}""") - append("\n\n") - append("""data: {"id":"1","model":"gpt-4o","choices":[{"delta":{"tool_calls":""") - append("""[{"index":0,"function":{"arguments":"{\"town\":\"Singapore\"}"}}]}}]}""") - append("\n\n") - append("""data: {"id":"1","model":"gpt-4o","choices":[{"delta":{},"finish_reason":"tool_calls"}]}""") - append("\n\ndata: [DONE]\n\n") - } - - @Test - fun `streaming replaces the tool-call part and leaves the deltas alone`() { - server.contentType = "text/event-stream" - server.responseBody = weatherSse - - model().use { model -> - val parts = model.streamTextSequence( - "weather in Singapore?", - options { it.toolCall.copy(input = """{"city":"Singapore"}""") }, - ).toList() - - val call = parts.filterIsInstance().single() - assertThat(call.invalid).isNotEqualTo(true) - assertThat(call.input.jsonObject["city"]?.jsonPrimitive?.content).isEqualTo("Singapore") - - // Deltas are the provider's text, forwarded untouched. - val delta = parts.filterIsInstance().single() - assertThat(delta.delta).isEqualTo("""{"town":"Singapore"}""") - } - } - - @Test - fun `a streaming hook may call the SAME model while another thread closes it`() { - // The lock is fair, so a reader that arrives after close() has queued - // for the write lock waits for it. Without the lent read hold this is a - // three-way deadlock: stream thread (holds read, waits for the hook) ← - // closer (waits for the write lock) ← hook (waits for a read lock). - server.contentType = "text/event-stream" - server.setResponses(weatherSse, repairCompletion) - - val model = model() - val hookStarted = CountDownLatch(1) - val closer = Thread { - hookStarted.await() - model.close() - } - closer.isDaemon = true - // assertTimeoutPreemptively, not @Timeout: a regression deadlocks, and - // the default @Timeout mode only checks after the body returns. - assertTimeoutPreemptively(Duration.ofSeconds(30)) { - closer.start() - val parts = model.streamTextSequence( - "weather in Singapore?", - options { context -> - // The stream response is already fully written; the nested - // completion is plain JSON off the same one-server queue. - server.contentType = "application/json" - hookStarted.countDown() - // Let close() reach the write lock and queue ahead of this - // thread's read acquisition — that ordering is the bug, and - // without the sleep the hook wins the race and proves - // nothing. - Thread.sleep(250) - val fixed = model.generateText("Fix ${context.toolCall.input}") - context.toolCall.copy(input = fixed.text) - }, - ).toList() - - val call = parts.filterIsInstance().single() - assertThat(call.error).isNull() - assertThat(call.invalid).isNotEqualTo(true) - assertThat(call.input.jsonObject["city"]?.jsonPrimitive?.content).isEqualTo("Singapore") - - closer.join(TimeUnit.SECONDS.toMillis(10)) - assertThat(closer.isAlive).describedAs("close() never completed").isFalse() - } - } - -} diff --git a/bindings/node/__test__/thought_signature_replay.test.ts b/bindings/node/__test__/thought_signature_replay.test.ts deleted file mode 100644 index 590a3910c..000000000 --- a/bindings/node/__test__/thought_signature_replay.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -// thought_signature_replay.test.ts — Regression tests for issue arcships/aimux#9. -// -// Google Gemini **thinking** models (gemini-2.5-pro, gemini-3.x-flash-thinking) -// attach a `thoughtSignature` to every `functionCall` part in responses, and -// the follow-up turn MUST echo it back verbatim as a sibling of the -// `functionCall` part — otherwise the API rejects the request with HTTP 400 -// ("Function call is missing a thought_signature"). -// -// aimux previously dropped the signature at parse time (no field in any -// ToolCall type) and did not emit it when rebuilding assistant tool-call -// parts. These tests use a mock HTTP server (no real API) and assert: -// 1. parse direction: `thoughtSignature` from the response reaches the -// typed `GenerateTextResult.tool_calls[].thought_signature`; -// 2. replay direction: the outbound request carries the signature as a -// part-level sibling of `functionCall` (not nested inside it). -// -// This mirrors the reasoning_replay.test.ts approach for DeepSeek thinking -// models. - -import test from 'ava' -import { createServer, type Server } from 'node:http' -import { google } from '../src/native.ts' - -function startMockServer(handler: (req: any, res: any) => void): Promise<{ server: Server; url: string }> { - return new Promise((resolve) => { - const server = createServer(handler) - server.listen(0, '127.0.0.1', () => { - const addr = server.address() as any - resolve({ server, url: `http://127.0.0.1:${addr.port}` }) - }) - }) -} - -function closeServer(server: Server): Promise { - return new Promise((resolve) => server.close(() => resolve())) -} - -/// Read the full request body as a parsed JSON object. -function readBody(req: any): Promise { - return new Promise((resolve) => { - let body = '' - req.on('data', (chunk: string) => { body += chunk }) - req.on('end', () => resolve(JSON.parse(body))) - }) -} - -const THOUGHT_SIGNATURE = - 'EuIDCt8DARFNMg/aRDRK3THWhBjzltCEy5/VM6ImWLJU8oHmnC75abdcZBMH' - -// A minimal Gemini non-streaming response with a functionCall + thoughtSignature. -function geminiToolCallResponse(signature?: string) { - return JSON.stringify({ - candidates: [{ - content: { - parts: [{ - functionCall: { - id: 'call-1', - name: 'weather', - args: { location: 'San Francisco' }, - }, - ...(signature ? { thoughtSignature: signature } : {}), - }], - role: 'model', - }, - finishReason: 'STOP', - index: 0, - }], - usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 5, totalTokenCount: 10 }, - }) -} - -const TEXT_RESPONSE = JSON.stringify({ - candidates: [{ - content: { parts: [{ text: 'Done.' }], role: 'model' }, - finishReason: 'STOP', - index: 0, - }], - usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 5, totalTokenCount: 10 }, -}) - -// ── parse direction: thoughtSignature reaches the typed tool_calls ────────── - -test('parse: thoughtSignature from a functionCall part lands on tool_calls[].thought_signature', async (t) => { - const { server, url } = await startMockServer(async (_req, res) => { - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(geminiToolCallResponse(THOUGHT_SIGNATURE)) - }) - - try { - const model = await google('test-key', 'gemini-2.5-pro', url) - const resultJson = await model.generateText( - JSON.stringify([{ role: 'user', content: 'weather in SF?' }]), - JSON.stringify({ - tools: [{ - type: 'function', - name: 'weather', - description: 'Get the weather', - input_schema: { - type: 'object', - properties: { location: { type: 'string' } }, - required: ['location'], - additionalProperties: false, - }, - }], - }), - ) - const r = JSON.parse(resultJson) - t.is(r.tool_calls.length, 1) - t.is(r.tool_calls[0].tool_call_id, 'call-1') - t.is(r.tool_calls[0].tool_name, 'weather') - t.is(r.tool_calls[0].thought_signature, THOUGHT_SIGNATURE) - } finally { - await closeServer(server) - } -}) - -// ── replay direction: signature echoed back as a part-level sibling ───────── - -test('replay: assistant tool_call with thought_signature echoes it back verbatim as a part sibling', async (t) => { - let requestBody: any = null - const { server, url } = await startMockServer(async (req, res) => { - requestBody = await readBody(req) - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(TEXT_RESPONSE) - }) - - try { - const model = await google('test-key', 'gemini-2.5-pro', url) - - // Second turn: assistant tool_call (with the captured signature) + the - // tool result. This is the exact shape that used to fail with HTTP 400. - const messages = [ - { role: 'user', content: 'weather in SF?' }, - { - role: 'assistant', - content: [{ - type: 'tool_call', - tool_call_id: 'call-1', - tool_name: 'weather', - input: { location: 'San Francisco' }, - thought_signature: THOUGHT_SIGNATURE, - }], - }, - { - role: 'tool', - content: [{ - type: 'tool_result', - tool_call_id: 'call-1', - result: { temp: 70 }, - }], - }, - ] - - const resultJson = await model.generateText(JSON.stringify(messages)) - const r = JSON.parse(resultJson) - t.is(r.text, 'Done.') - - // The signature must be a SIBLING of `functionCall` on the part. - const part = requestBody.contents[1].parts[0] - t.is(part.thoughtSignature, THOUGHT_SIGNATURE) - t.is(part.functionCall.thoughtSignature, undefined) - // And the tool result must be present as a functionResponse part. - t.is(requestBody.contents[2].role, 'user') - t.is(requestBody.contents[2].parts[0].functionResponse.id, 'call-1') - } finally { - await closeServer(server) - } -}) - -// ── negative: no signature on the input → no thoughtSignature in the body ─── - -test('replay: tool_call without signature emits no thoughtSignature field', async (t) => { - let requestBody: any = null - const { server, url } = await startMockServer(async (req, res) => { - requestBody = await readBody(req) - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(TEXT_RESPONSE) - }) - - try { - const model = await google('test-key', 'gemini-2.5-pro', url) - - const messages = [ - { role: 'user', content: 'weather in SF?' }, - { - role: 'assistant', - content: [{ - type: 'tool_call', - tool_call_id: 'call-1', - tool_name: 'weather', - input: { location: 'San Francisco' }, - }], - }, - { - role: 'tool', - content: [{ - type: 'tool_result', - tool_call_id: 'call-1', - result: { temp: 70 }, - }], - }, - ] - - await model.generateText(JSON.stringify(messages)) - - const part = requestBody.contents[1].parts[0] - t.is(part.thoughtSignature, undefined) - } finally { - await closeServer(server) - } -}) diff --git a/bindings/node/__test__/tool_call_repair.test.ts b/bindings/node/__test__/tool_call_repair.test.ts deleted file mode 100644 index 0011f45af..000000000 --- a/bindings/node/__test__/tool_call_repair.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -import test from 'ava' -import { readFileSync } from 'node:fs' -import { createServer, type Server } from 'node:http' - -import { generateText, generateTextAsOpenai, openai, streamText } from '../src/index.ts' -import type { RawToolCall, RepairToolCall, StreamPart, Tool, ToolCallRepairContext } from '../src/index.ts' -import { - applyToolCallRepair, - applyToolCallRepairToResult, - toolCallRepairContext, -} from '../src/native.ts' - -function startServer(body: string, contentType = 'application/json') { - return new Promise<{ server: Server; url: string }>((resolve) => { - const server = createServer((_req, res) => { - res.writeHead(200, { 'content-type': contentType }) - res.end(body) - }) - server.listen(0, '127.0.0.1', () => { - const address = server.address() - if (!address || typeof address === 'string') throw new Error('missing server address') - resolve({ server, url: `http://127.0.0.1:${address.port}` }) - }) - }) -} - -const weatherTool: Tool = { - type: 'function', - name: 'weather', - input_schema: { - type: 'object', - properties: { city: { type: 'string' } }, - required: ['city'], - additionalProperties: false, - }, -} - -const INVALID_TOOL_CALL = JSON.stringify({ - id: 'chatcmpl-repair', - model: 'gpt-4o', - choices: [{ - message: { - role: 'assistant', - content: null, - tool_calls: [{ - id: 'call-1', - type: 'function', - function: { name: 'weather', arguments: '{"town":"Singapore"}' }, - }], - }, - finish_reason: 'tool_calls', - }], - usage: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }, -}) - -const INVALID_TOOL_CALL_STREAM = [ - 'data: {"id":"1","model":"gpt-4o","choices":[{"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call-1","type":"function","function":{"name":"weather","arguments":""}}]}}]}\n\n', - 'data: {"id":"1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"town\\":\\"Singapore\\"}"}}]}}]}\n\n', - 'data: {"id":"1","model":"gpt-4o","choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}\n\n', - 'data: [DONE]\n\n', -].join('') - -const fixCity = (ctx: ToolCallRepairContext): RawToolCall => ({ - ...ctx.tool_call, - input: ctx.tool_call.input.replace('"town"', '"city"'), -}) - -test('repair: generateText repairs the call and transcript', async (t) => { - const { server, url } = await startServer(INVALID_TOOL_CALL) - try { - const model = await openai('test-key', 'gpt-4o', url) - let rawInput: string | undefined - const result = await generateText(model, 'weather in Singapore?', { - tools: [weatherTool], - repairToolCall: (ctx) => { - rawInput = ctx.tool_call.input - return fixCity(ctx) - }, - }) - - t.is(rawInput, '{"town":"Singapore"}') - t.deepEqual(result.tool_calls[0].input, { city: 'Singapore' }) - const message = result.response_messages[0] as any - const part = message.content.find((item: any) => item.type === 'tool_call') - t.deepEqual(part.input, result.tool_calls[0].input) - } finally { - await new Promise((resolve) => server.close(() => resolve())) - } -}) - -test('repair: generateTextAsOpenai carries the repaired arguments', async (t) => { - const { server, url } = await startServer(INVALID_TOOL_CALL) - try { - const model = await openai('test-key', 'gpt-4o', url) - const completion = await generateTextAsOpenai(model, 'weather in Singapore?', { - tools: [weatherTool], - repairToolCall: fixCity, - }) - const call = completion.choices[0].message.tool_calls?.[0] - t.is(call?.id, 'call-1') - t.deepEqual(JSON.parse(call?.function.arguments ?? ''), { city: 'Singapore' }) - t.is(completion.model, 'gpt-4o') - } finally { - await new Promise((resolve) => server.close(() => resolve())) - } -}) - -test('repair: streamText repairs ToolCall and preserves deltas', async (t) => { - const { server, url } = await startServer(INVALID_TOOL_CALL_STREAM, 'text/event-stream') - try { - const model = await openai('test-key', 'gpt-4o', url) - const parts: StreamPart[] = [] - for await (const part of streamText(model, 'weather in Singapore?', { - tools: [weatherTool], - repairToolCall: fixCity, - })) parts.push(part) - - const call = parts.find((p): p is Extract => 'ToolCall' in p) - t.deepEqual(call?.ToolCall.input, { city: 'Singapore' }) - const deltas = parts - .filter((p): p is Extract => 'ToolInputDelta' in p) - .map((p) => p.ToolInputDelta.delta) - t.is(deltas.join(''), '{"town":"Singapore"}') - } finally { - await new Promise((resolve) => server.close(() => resolve())) - } -}) - -test('repair: shared contract fixture', (t) => { - const fixture = JSON.parse(readFileSync( - new URL('../../../contract-tests/fixtures/tool-call-repair.json', import.meta.url), - 'utf8', - )) as { cases: any[] } - t.true(fixture.cases.length > 0) - - const run = (c: any): string => { - const opts = c.input.opts === null ? undefined : JSON.stringify(c.input.opts) - switch (c.function) { - case 'tool_call_repair_context': - return toolCallRepairContext(JSON.stringify(c.input.tool_call), JSON.stringify(c.input.prompt), opts) - case 'apply_tool_call_repair': - return applyToolCallRepair(JSON.stringify(c.input.tool_call), opts, JSON.stringify(c.input.reply)) - case 'apply_tool_call_repair_to_result': - return applyToolCallRepairToResult( - JSON.stringify(c.input.result), opts, c.input.tool_call_id, JSON.stringify(c.input.reply), - ) - default: - throw new Error(`unknown fixture function ${c.function}`) - } - } - - for (const c of fixture.cases) { - if (c.expected_error) { - const error = t.throws(() => run(c), undefined, c.name) - t.is(error?.constructor.name, `${c.expected_error}Error`, c.name) - } else { - t.deepEqual(JSON.parse(run(c)), c.expected, c.name) - } - } -}) - -test('repair: throwing, null, valid, and no-tool-set branches', async (t) => { - let calls = 0 - const generate = async (body: string, repair: RepairToolCall, tools?: Tool[]) => { - const { server, url } = await startServer(body) - try { - const model = await openai('test-key', 'gpt-4o', url) - return await generateText(model, 'weather in Singapore?', { - tools, - repairToolCall: (ctx) => { - calls += 1 - return repair(ctx) - }, - }) - } finally { - await new Promise((resolve) => server.close(() => resolve())) - } - } - - // Throwing hook: invalid, wrapped in ToolCallRepair over the original error. - const thrown = await generate(INVALID_TOOL_CALL, () => { - throw new Error('repair model unavailable') - }, [weatherTool]) - t.is(calls, 1) - t.is(thrown.tool_calls[0].invalid, true) - const failure = (thrown.tool_calls[0].error as any).ToolCallRepair - t.deepEqual(failure.cause, { Other: 'Error: repair model unavailable' }) - t.true('InvalidToolInput' in failure.original_error) - - // Hook returns null: invalid with the ORIGINAL error, no repair wrapper. - const unchanged = await generate(INVALID_TOOL_CALL, () => null, [weatherTool]) - t.is(calls, 2) - t.is(unchanged.tool_calls[0].invalid, true) - t.deepEqual(Object.keys(unchanged.tool_calls[0].error as any), ['InvalidToolInput']) - - // A valid call never reaches the hook. - const valid = await generate(INVALID_TOOL_CALL.replace('town', 'city'), fixCity, [weatherTool]) - t.is(calls, 2) - t.falsy(valid.tool_calls[0].invalid) - t.deepEqual(valid.tool_calls[0].input, { city: 'Singapore' }) - - // No tool set: not repairable, the hook is skipped, the call stays invalid. - const untooled = await generate(INVALID_TOOL_CALL, fixCity) - t.is(calls, 2) - t.is(untooled.tool_calls[0].invalid, true) - t.deepEqual(Object.keys(untooled.tool_calls[0].error as any), ['NoSuchTool']) -}) diff --git a/bindings/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/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/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" From 94630bfecafb550edd2e40765e4629021847348d Mon Sep 17 00:00:00 2001 From: chenhaonan Date: Tue, 29 Sep 2026 14:14:03 +0800 Subject: [PATCH 008/164] refactor(web): consume generated core types directly Amp-Thread-ID: https://ampcode.com/threads/T-01a0eb91-5e79-775e-975e-b85e2ac49ab6 Co-authored-by: Amp (cherry picked from commit dc1cd1439e330e8fd44833984b4ebfb139a6b502) --- tools/aimux-web/README.md | 6 +- tools/aimux-web/web/src/agent/engine.ts | 2 +- tools/aimux-web/web/src/api/client.ts | 8 +- tools/aimux-web/web/src/types/AiMuxError.ts | 27 -- tools/aimux-web/web/src/types/ApiCallError.ts | 66 ----- tools/aimux-web/web/src/types/AspectRatio.ts | 10 - tools/aimux-web/web/src/types/AudioChunk.ts | 6 - tools/aimux-web/web/src/types/AudioData.ts | 9 - tools/aimux-web/web/src/types/AudioInput.ts | 6 - tools/aimux-web/web/src/types/BreakKind.ts | 3 - tools/aimux-web/web/src/types/CallOptions.ts | 114 -------- .../web/src/types/CatalogueSource.ts | 6 - .../aimux-web/web/src/types/ChatCompletion.ts | 10 - .../web/src/types/ChatCompletionChoice.ts | 5 - .../web/src/types/ChatCompletionChunk.ts | 10 - .../src/types/ChatCompletionChunkChoice.ts | 5 - .../src/types/ChatCompletionChunkFunction.ts | 3 - .../src/types/ChatCompletionChunkToolCall.ts | 4 - .../web/src/types/ChatCompletionDelta.ts | 4 - .../web/src/types/ChatCompletionFunction.ts | 3 - .../web/src/types/ChatCompletionMessage.ts | 5 - .../web/src/types/ChatCompletionToolCall.ts | 4 - .../web/src/types/ChatCompletionUsage.ts | 5 - .../web/src/types/CompletionTokensDetails.ts | 3 - tools/aimux-web/web/src/types/ContentPart.ts | 72 ----- .../web/src/types/EmbeddingCallOptions.ts | 21 -- .../web/src/types/EmbeddingResponse.ts | 15 - .../web/src/types/EmbeddingResult.ts | 32 --- .../aimux-web/web/src/types/EmbeddingUsage.ts | 10 - tools/aimux-web/web/src/types/FileBytes.ts | 10 - tools/aimux-web/web/src/types/FileData.ts | 9 - tools/aimux-web/web/src/types/FilePart.ts | 7 - tools/aimux-web/web/src/types/Fingerprint.ts | 20 -- tools/aimux-web/web/src/types/FinishReason.ts | 15 - .../web/src/types/FinishReasonUnified.ts | 6 - tools/aimux-web/web/src/types/FunctionTool.ts | 34 --- .../web/src/types/GenerateContent.ts | 43 --- .../web/src/types/GenerateObjectResult.ts | 49 ---- .../aimux-web/web/src/types/GenerateResult.ts | 44 --- .../web/src/types/GenerateTextOptions.ts | 46 --- .../web/src/types/GenerateTextResult.ts | 82 ------ tools/aimux-web/web/src/types/HttpExchange.ts | 21 -- tools/aimux-web/web/src/types/HttpRecord.ts | 11 - .../web/src/types/ImageCallOptions.ts | 51 ---- tools/aimux-web/web/src/types/ImageFile.ts | 21 -- .../aimux-web/web/src/types/ImageFileData.ts | 6 - tools/aimux-web/web/src/types/ImageOutputs.ts | 12 - .../aimux-web/web/src/types/ImageResponse.ts | 18 -- tools/aimux-web/web/src/types/ImageResult.ts | 33 --- tools/aimux-web/web/src/types/ImageUsage.ts | 20 -- .../web/src/types/InputAudioFormat.ts | 14 - tools/aimux-web/web/src/types/InputRecord.ts | 17 -- .../src/types/LanguageModelPromptMessage.ts | 19 -- .../aimux-web/web/src/types/MessageContent.ts | 7 - tools/aimux-web/web/src/types/Modality.ts | 6 - .../web/src/types/ModelCapabilities.ts | 22 -- tools/aimux-web/web/src/types/ModelCost.ts | 7 - tools/aimux-web/web/src/types/ModelId.ts | 6 - tools/aimux-web/web/src/types/ModelLimits.ts | 18 -- tools/aimux-web/web/src/types/ModelMessage.ts | 8 - .../web/src/types/ModelModalities.ts | 7 - tools/aimux-web/web/src/types/ModelPrompt.ts | 9 - tools/aimux-web/web/src/types/ModelSpec.ts | 26 -- tools/aimux-web/web/src/types/ModelType.ts | 6 - .../aimux-web/web/src/types/OutcomeRecord.ts | 12 - .../aimux-web/web/src/types/OutcomeStatus.ts | 6 - tools/aimux-web/web/src/types/PrefixBreak.ts | 7 - .../web/src/types/PromptTokensDetails.ts | 3 - .../web/src/types/ProviderAuditSpec.ts | 34 --- .../aimux-web/web/src/types/ProviderFamily.ts | 7 - tools/aimux-web/web/src/types/ProviderName.ts | 262 ------------------ .../aimux-web/web/src/types/ProviderRecord.ts | 31 --- tools/aimux-web/web/src/types/ProviderTool.ts | 25 -- .../web/src/types/ReasoningEffort.ts | 9 - .../aimux-web/web/src/types/ReasoningMode.ts | 6 - .../aimux-web/web/src/types/ReasoningPart.ts | 10 - .../aimux-web/web/src/types/ReasoningSpec.ts | 40 --- .../web/src/types/ReasoningVisibility.ts | 6 - tools/aimux-web/web/src/types/Recording.ts | 57 ---- .../web/src/types/RequestCacheHints.ts | 10 - tools/aimux-web/web/src/types/RequestInfo.ts | 13 - .../web/src/types/RerankingCallOptions.ts | 30 -- .../web/src/types/RerankingDocuments.ts | 9 - .../aimux-web/web/src/types/RerankingRank.ts | 14 - .../web/src/types/RerankingResponse.ts | 27 -- .../web/src/types/RerankingResult.ts | 30 -- .../aimux-web/web/src/types/ResponseFormat.ts | 7 - tools/aimux-web/web/src/types/ResponseInfo.ts | 29 -- .../web/src/types/ResponseMetadata.ts | 6 - .../aimux-web/web/src/types/ResponseRecord.ts | 11 - tools/aimux-web/web/src/types/Role.ts | 6 - tools/aimux-web/web/src/types/RuntimeModel.ts | 23 -- .../web/src/types/SearchCallOptions.ts | 43 --- .../aimux-web/web/src/types/SearchResponse.ts | 15 - tools/aimux-web/web/src/types/SearchResult.ts | 31 --- .../web/src/types/SearchResultItem.ts | 32 --- tools/aimux-web/web/src/types/SessionCall.ts | 21 -- .../web/src/types/SessionChainView.ts | 7 - .../aimux-web/web/src/types/SessionSource.ts | 6 - .../web/src/types/SessionStepStat.ts | 28 -- tools/aimux-web/web/src/types/SessionView.ts | 16 -- tools/aimux-web/web/src/types/Size.ts | 10 - tools/aimux-web/web/src/types/SourcePart.ts | 6 - .../web/src/types/SpeechCallOptions.ts | 44 --- .../aimux-web/web/src/types/SpeechRequest.ts | 11 - .../aimux-web/web/src/types/SpeechResponse.ts | 23 -- tools/aimux-web/web/src/types/SpeechResult.ts | 33 --- tools/aimux-web/web/src/types/StreamPart.ts | 66 ----- .../src/types/StreamTextResultAggregated.ts | 75 ----- .../web/src/types/TimeoutConfiguration.ts | 24 -- tools/aimux-web/web/src/types/TimingRecord.ts | 7 - tools/aimux-web/web/src/types/TokenUsage.ts | 30 -- tools/aimux-web/web/src/types/Tool.ts | 10 - tools/aimux-web/web/src/types/ToolCall.ts | 34 --- tools/aimux-web/web/src/types/ToolChoice.ts | 11 - tools/aimux-web/web/src/types/ToolResult.ts | 23 -- tools/aimux-web/web/src/types/TraceFilter.ts | 6 - tools/aimux-web/web/src/types/TraceRecord.ts | 47 ---- tools/aimux-web/web/src/types/TraceStats.ts | 7 - .../web/src/types/TranscriptionCallOptions.ts | 26 -- .../web/src/types/TranscriptionRequest.ts | 10 - .../web/src/types/TranscriptionResponse.ts | 23 -- .../web/src/types/TranscriptionResult.ts | 45 --- .../web/src/types/TranscriptionSegment.ts | 18 -- .../web/src/types/TranscriptionStreamPart.ts | 11 - .../web/src/types/UploadFileCallOptions.ts | 26 -- .../aimux-web/web/src/types/UploadFileData.ts | 11 - .../web/src/types/UploadFileResult.ts | 32 --- tools/aimux-web/web/src/types/Usage.ts | 12 - .../aimux-web/web/src/types/UsageSnapshot.ts | 12 - tools/aimux-web/web/src/types/Verdict.ts | 20 -- .../web/src/types/VerdictConfidence.ts | 6 - tools/aimux-web/web/src/types/VerdictKind.ts | 6 - .../web/src/types/VideoCallOptions.ts | 66 ----- tools/aimux-web/web/src/types/VideoData.ts | 9 - tools/aimux-web/web/src/types/VideoFile.ts | 25 -- .../aimux-web/web/src/types/VideoFileData.ts | 6 - .../web/src/types/VideoFrameImage.ts | 18 -- .../aimux-web/web/src/types/VideoFrameType.ts | 8 - .../aimux-web/web/src/types/VideoResponse.ts | 18 -- tools/aimux-web/web/src/types/VideoResult.ts | 28 -- tools/aimux-web/web/src/types/Warning.ts | 6 - .../web/src/types/WireCallResponse.ts | 2 +- .../web/src/types/WireContentPart.ts | 2 +- tools/aimux-web/web/src/types/WireOptions.ts | 2 +- tools/aimux-web/web/src/types/WireTool.ts | 2 +- .../web/src/types/serde_json/JsonValue.ts | 3 - tools/aimux-web/web/src/views/Replay.vue | 2 +- tools/aimux-web/web/src/views/Sessions.vue | 4 +- tools/aimux-web/web/src/views/Traces.vue | 2 +- 150 files changed, 16 insertions(+), 3045 deletions(-) delete mode 100644 tools/aimux-web/web/src/types/AiMuxError.ts delete mode 100644 tools/aimux-web/web/src/types/ApiCallError.ts delete mode 100644 tools/aimux-web/web/src/types/AspectRatio.ts delete mode 100644 tools/aimux-web/web/src/types/AudioChunk.ts delete mode 100644 tools/aimux-web/web/src/types/AudioData.ts delete mode 100644 tools/aimux-web/web/src/types/AudioInput.ts delete mode 100644 tools/aimux-web/web/src/types/BreakKind.ts delete mode 100644 tools/aimux-web/web/src/types/CallOptions.ts delete mode 100644 tools/aimux-web/web/src/types/CatalogueSource.ts delete mode 100644 tools/aimux-web/web/src/types/ChatCompletion.ts delete mode 100644 tools/aimux-web/web/src/types/ChatCompletionChoice.ts delete mode 100644 tools/aimux-web/web/src/types/ChatCompletionChunk.ts delete mode 100644 tools/aimux-web/web/src/types/ChatCompletionChunkChoice.ts delete mode 100644 tools/aimux-web/web/src/types/ChatCompletionChunkFunction.ts delete mode 100644 tools/aimux-web/web/src/types/ChatCompletionChunkToolCall.ts delete mode 100644 tools/aimux-web/web/src/types/ChatCompletionDelta.ts delete mode 100644 tools/aimux-web/web/src/types/ChatCompletionFunction.ts delete mode 100644 tools/aimux-web/web/src/types/ChatCompletionMessage.ts delete mode 100644 tools/aimux-web/web/src/types/ChatCompletionToolCall.ts delete mode 100644 tools/aimux-web/web/src/types/ChatCompletionUsage.ts delete mode 100644 tools/aimux-web/web/src/types/CompletionTokensDetails.ts delete mode 100644 tools/aimux-web/web/src/types/ContentPart.ts delete mode 100644 tools/aimux-web/web/src/types/EmbeddingCallOptions.ts delete mode 100644 tools/aimux-web/web/src/types/EmbeddingResponse.ts delete mode 100644 tools/aimux-web/web/src/types/EmbeddingResult.ts delete mode 100644 tools/aimux-web/web/src/types/EmbeddingUsage.ts delete mode 100644 tools/aimux-web/web/src/types/FileBytes.ts delete mode 100644 tools/aimux-web/web/src/types/FileData.ts delete mode 100644 tools/aimux-web/web/src/types/FilePart.ts delete mode 100644 tools/aimux-web/web/src/types/Fingerprint.ts delete mode 100644 tools/aimux-web/web/src/types/FinishReason.ts delete mode 100644 tools/aimux-web/web/src/types/FinishReasonUnified.ts delete mode 100644 tools/aimux-web/web/src/types/FunctionTool.ts delete mode 100644 tools/aimux-web/web/src/types/GenerateContent.ts delete mode 100644 tools/aimux-web/web/src/types/GenerateObjectResult.ts delete mode 100644 tools/aimux-web/web/src/types/GenerateResult.ts delete mode 100644 tools/aimux-web/web/src/types/GenerateTextOptions.ts delete mode 100644 tools/aimux-web/web/src/types/GenerateTextResult.ts delete mode 100644 tools/aimux-web/web/src/types/HttpExchange.ts delete mode 100644 tools/aimux-web/web/src/types/HttpRecord.ts delete mode 100644 tools/aimux-web/web/src/types/ImageCallOptions.ts delete mode 100644 tools/aimux-web/web/src/types/ImageFile.ts delete mode 100644 tools/aimux-web/web/src/types/ImageFileData.ts delete mode 100644 tools/aimux-web/web/src/types/ImageOutputs.ts delete mode 100644 tools/aimux-web/web/src/types/ImageResponse.ts delete mode 100644 tools/aimux-web/web/src/types/ImageResult.ts delete mode 100644 tools/aimux-web/web/src/types/ImageUsage.ts delete mode 100644 tools/aimux-web/web/src/types/InputAudioFormat.ts delete mode 100644 tools/aimux-web/web/src/types/InputRecord.ts delete mode 100644 tools/aimux-web/web/src/types/LanguageModelPromptMessage.ts delete mode 100644 tools/aimux-web/web/src/types/MessageContent.ts delete mode 100644 tools/aimux-web/web/src/types/Modality.ts delete mode 100644 tools/aimux-web/web/src/types/ModelCapabilities.ts delete mode 100644 tools/aimux-web/web/src/types/ModelCost.ts delete mode 100644 tools/aimux-web/web/src/types/ModelId.ts delete mode 100644 tools/aimux-web/web/src/types/ModelLimits.ts delete mode 100644 tools/aimux-web/web/src/types/ModelMessage.ts delete mode 100644 tools/aimux-web/web/src/types/ModelModalities.ts delete mode 100644 tools/aimux-web/web/src/types/ModelPrompt.ts delete mode 100644 tools/aimux-web/web/src/types/ModelSpec.ts delete mode 100644 tools/aimux-web/web/src/types/ModelType.ts delete mode 100644 tools/aimux-web/web/src/types/OutcomeRecord.ts delete mode 100644 tools/aimux-web/web/src/types/OutcomeStatus.ts delete mode 100644 tools/aimux-web/web/src/types/PrefixBreak.ts delete mode 100644 tools/aimux-web/web/src/types/PromptTokensDetails.ts delete mode 100644 tools/aimux-web/web/src/types/ProviderAuditSpec.ts delete mode 100644 tools/aimux-web/web/src/types/ProviderFamily.ts delete mode 100644 tools/aimux-web/web/src/types/ProviderName.ts delete mode 100644 tools/aimux-web/web/src/types/ProviderRecord.ts delete mode 100644 tools/aimux-web/web/src/types/ProviderTool.ts delete mode 100644 tools/aimux-web/web/src/types/ReasoningEffort.ts delete mode 100644 tools/aimux-web/web/src/types/ReasoningMode.ts delete mode 100644 tools/aimux-web/web/src/types/ReasoningPart.ts delete mode 100644 tools/aimux-web/web/src/types/ReasoningSpec.ts delete mode 100644 tools/aimux-web/web/src/types/ReasoningVisibility.ts delete mode 100644 tools/aimux-web/web/src/types/Recording.ts delete mode 100644 tools/aimux-web/web/src/types/RequestCacheHints.ts delete mode 100644 tools/aimux-web/web/src/types/RequestInfo.ts delete mode 100644 tools/aimux-web/web/src/types/RerankingCallOptions.ts delete mode 100644 tools/aimux-web/web/src/types/RerankingDocuments.ts delete mode 100644 tools/aimux-web/web/src/types/RerankingRank.ts delete mode 100644 tools/aimux-web/web/src/types/RerankingResponse.ts delete mode 100644 tools/aimux-web/web/src/types/RerankingResult.ts delete mode 100644 tools/aimux-web/web/src/types/ResponseFormat.ts delete mode 100644 tools/aimux-web/web/src/types/ResponseInfo.ts delete mode 100644 tools/aimux-web/web/src/types/ResponseMetadata.ts delete mode 100644 tools/aimux-web/web/src/types/ResponseRecord.ts delete mode 100644 tools/aimux-web/web/src/types/Role.ts delete mode 100644 tools/aimux-web/web/src/types/RuntimeModel.ts delete mode 100644 tools/aimux-web/web/src/types/SearchCallOptions.ts delete mode 100644 tools/aimux-web/web/src/types/SearchResponse.ts delete mode 100644 tools/aimux-web/web/src/types/SearchResult.ts delete mode 100644 tools/aimux-web/web/src/types/SearchResultItem.ts delete mode 100644 tools/aimux-web/web/src/types/SessionCall.ts delete mode 100644 tools/aimux-web/web/src/types/SessionChainView.ts delete mode 100644 tools/aimux-web/web/src/types/SessionSource.ts delete mode 100644 tools/aimux-web/web/src/types/SessionStepStat.ts delete mode 100644 tools/aimux-web/web/src/types/SessionView.ts delete mode 100644 tools/aimux-web/web/src/types/Size.ts delete mode 100644 tools/aimux-web/web/src/types/SourcePart.ts delete mode 100644 tools/aimux-web/web/src/types/SpeechCallOptions.ts delete mode 100644 tools/aimux-web/web/src/types/SpeechRequest.ts delete mode 100644 tools/aimux-web/web/src/types/SpeechResponse.ts delete mode 100644 tools/aimux-web/web/src/types/SpeechResult.ts delete mode 100644 tools/aimux-web/web/src/types/StreamPart.ts delete mode 100644 tools/aimux-web/web/src/types/StreamTextResultAggregated.ts delete mode 100644 tools/aimux-web/web/src/types/TimeoutConfiguration.ts delete mode 100644 tools/aimux-web/web/src/types/TimingRecord.ts delete mode 100644 tools/aimux-web/web/src/types/TokenUsage.ts delete mode 100644 tools/aimux-web/web/src/types/Tool.ts delete mode 100644 tools/aimux-web/web/src/types/ToolCall.ts delete mode 100644 tools/aimux-web/web/src/types/ToolChoice.ts delete mode 100644 tools/aimux-web/web/src/types/ToolResult.ts delete mode 100644 tools/aimux-web/web/src/types/TraceFilter.ts delete mode 100644 tools/aimux-web/web/src/types/TraceRecord.ts delete mode 100644 tools/aimux-web/web/src/types/TraceStats.ts delete mode 100644 tools/aimux-web/web/src/types/TranscriptionCallOptions.ts delete mode 100644 tools/aimux-web/web/src/types/TranscriptionRequest.ts delete mode 100644 tools/aimux-web/web/src/types/TranscriptionResponse.ts delete mode 100644 tools/aimux-web/web/src/types/TranscriptionResult.ts delete mode 100644 tools/aimux-web/web/src/types/TranscriptionSegment.ts delete mode 100644 tools/aimux-web/web/src/types/TranscriptionStreamPart.ts delete mode 100644 tools/aimux-web/web/src/types/UploadFileCallOptions.ts delete mode 100644 tools/aimux-web/web/src/types/UploadFileData.ts delete mode 100644 tools/aimux-web/web/src/types/UploadFileResult.ts delete mode 100644 tools/aimux-web/web/src/types/Usage.ts delete mode 100644 tools/aimux-web/web/src/types/UsageSnapshot.ts delete mode 100644 tools/aimux-web/web/src/types/Verdict.ts delete mode 100644 tools/aimux-web/web/src/types/VerdictConfidence.ts delete mode 100644 tools/aimux-web/web/src/types/VerdictKind.ts delete mode 100644 tools/aimux-web/web/src/types/VideoCallOptions.ts delete mode 100644 tools/aimux-web/web/src/types/VideoData.ts delete mode 100644 tools/aimux-web/web/src/types/VideoFile.ts delete mode 100644 tools/aimux-web/web/src/types/VideoFileData.ts delete mode 100644 tools/aimux-web/web/src/types/VideoFrameImage.ts delete mode 100644 tools/aimux-web/web/src/types/VideoFrameType.ts delete mode 100644 tools/aimux-web/web/src/types/VideoResponse.ts delete mode 100644 tools/aimux-web/web/src/types/VideoResult.ts delete mode 100644 tools/aimux-web/web/src/types/Warning.ts delete mode 100644 tools/aimux-web/web/src/types/serde_json/JsonValue.ts diff --git a/tools/aimux-web/README.md b/tools/aimux-web/README.md index 06541c334..cf51bfbed 100644 --- a/tools/aimux-web/README.md +++ b/tools/aimux-web/README.md @@ -126,15 +126,15 @@ 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/ aimux-web 专用 Wire 类型 ``` ## 前端类型来源 - **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` - 并同步拷贝。 + 前端通过 `import type` 直接使用该受 CI 漂移检查保护的生成目录。改 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`。 diff --git a/tools/aimux-web/web/src/agent/engine.ts b/tools/aimux-web/web/src/agent/engine.ts index 3065e8d45..f8740c7a1 100644 --- a/tools/aimux-web/web/src/agent/engine.ts +++ b/tools/aimux-web/web/src/agent/engine.ts @@ -8,7 +8,7 @@ import { api, callStream, parseStreamPart } from '../api/client' import type { WireCallRequest } from '../types/WireCallRequest' -import type { StreamPart } from '../types/StreamPart' +import type { StreamPart } from '../../../../../bindings/node/src/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..adf2b65f8 100644 --- a/tools/aimux-web/web/src/api/client.ts +++ b/tools/aimux-web/web/src/api/client.ts @@ -2,10 +2,10 @@ 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 { Recording } from '../../../../../bindings/node/src/types/Recording' +import type { TraceRecord } from '../../../../../bindings/node/src/types/TraceRecord' +import type { SessionView } from '../../../../../bindings/node/src/types/SessionView' +import type { StreamPart } from '../../../../../bindings/node/src/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/WireCallResponse.ts b/tools/aimux-web/web/src/types/WireCallResponse.ts index bd19eb41d..b588d4208 100644 --- a/tools/aimux-web/web/src/types/WireCallResponse.ts +++ b/tools/aimux-web/web/src/types/WireCallResponse.ts @@ -1,6 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { WireMeta } from "./WireMeta"; -import type { JsonValue } from "./serde_json/JsonValue"; +import type { JsonValue } from "../../../../../bindings/node/src/types/serde_json/JsonValue"; /** * Response for non-streaming calls. diff --git a/tools/aimux-web/web/src/types/WireContentPart.ts b/tools/aimux-web/web/src/types/WireContentPart.ts index ce96d1265..915ccaa41 100644 --- a/tools/aimux-web/web/src/types/WireContentPart.ts +++ b/tools/aimux-web/web/src/types/WireContentPart.ts @@ -1,5 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { JsonValue } from "./serde_json/JsonValue"; +import type { JsonValue } from "../../../../../bindings/node/src/types/serde_json/JsonValue"; /** * A content part in the wire format. diff --git a/tools/aimux-web/web/src/types/WireOptions.ts b/tools/aimux-web/web/src/types/WireOptions.ts index c4ec07581..c9de9e081 100644 --- a/tools/aimux-web/web/src/types/WireOptions.ts +++ b/tools/aimux-web/web/src/types/WireOptions.ts @@ -1,6 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { WireTool } from "./WireTool"; -import type { JsonValue } from "./serde_json/JsonValue"; +import type { JsonValue } from "../../../../../bindings/node/src/types/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, /** diff --git a/tools/aimux-web/web/src/types/WireTool.ts b/tools/aimux-web/web/src/types/WireTool.ts index 713cb2ddf..6917d2582 100644 --- a/tools/aimux-web/web/src/types/WireTool.ts +++ b/tools/aimux-web/web/src/types/WireTool.ts @@ -1,5 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { JsonValue } from "./serde_json/JsonValue"; +import type { JsonValue } from "../../../../../bindings/node/src/types/serde_json/JsonValue"; /** * A function tool definition (JSON Schema parameters). diff --git a/tools/aimux-web/web/src/types/serde_json/JsonValue.ts b/tools/aimux-web/web/src/types/serde_json/JsonValue.ts deleted file mode 100644 index 320a852fd..000000000 --- a/tools/aimux-web/web/src/types/serde_json/JsonValue.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 JsonValue = number | string | boolean | Array | { [key in string]: JsonValue } | null; diff --git a/tools/aimux-web/web/src/views/Replay.vue b/tools/aimux-web/web/src/views/Replay.vue index 54dae6622..6ec0e3814 100644 --- a/tools/aimux-web/web/src/views/Replay.vue +++ b/tools/aimux-web/web/src/views/Replay.vue @@ -1,7 +1,7 @@