diff --git a/aimux-providers/tests/config_snapshot_test.rs b/aimux-providers/tests/config_snapshot_test.rs deleted file mode 100644 index 54a1f49ed..000000000 --- a/aimux-providers/tests/config_snapshot_test.rs +++ /dev/null @@ -1,208 +0,0 @@ -//! RFC-0023 `config_snapshot` / `api_key_source` coverage (findings C2/C3/C6). -//! -//! These are pure construction-time checks (no HTTP): build a config the way -//! real callers do (`from_env` / `new` / registry `provider()`), then assert -//! the recorded `ProviderRecord` carries the right provider identity and -//! api-key *source* (never the plaintext key). - -use aimux_core::language_model::LanguageModel; - -use aimux_providers::provider; -use aimux_providers::{ - AnthropicConfig, AnthropicProvider, AzureConfig, AzureModel, CodexConfig, CodexProvider, - CohereConfig, CohereProvider, GoogleConfig, GoogleProvider, MistralConfig, MistralProvider, - XAIConfig, XAIProvider, -}; - -// ── C2: OpenAI-compatible chat path surfaces the registry provider name ───── - -/// A registry-built deepseek model records `provider == "deepseek"` (not the -/// old hardcoded `"openai"`). The Chat path must use `config.provider`. -#[test] -fn registry_chat_snapshot_records_real_provider() { - let model = provider("deepseek", Some("sk-test".into()), "deepseek-chat", None) - .expect("deepseek constructs via registry"); - let snap = model.config_snapshot(); - assert_eq!( - snap.provider, "deepseek", - "snapshot must carry the registry name" - ); - assert_eq!(snap.model_id, "deepseek-chat"); - assert_eq!(model.provider(), "deepseek"); - // Registry `provider(name, None, ..)` reads the env var → env source. - assert!( - snap.api_key_source == "explicit" || snap.api_key_source.starts_with("env:"), - "api_key_source should be explicit (key given) or env:, got {}", - snap.api_key_source - ); -} - -/// Explicit key → `explicit`; no plaintext leaks into the snapshot. -#[test] -fn registry_chat_explicit_key_source() { - let model = provider("groq", Some("sk-test".into()), "llama-3.3-70b", None).unwrap(); - let snap = model.config_snapshot(); - assert_eq!(snap.api_key_source, "explicit"); - let json = serde_json::to_string(&snap).unwrap(); - assert!(!json.contains("sk-test"), "plaintext key leaked: {json}"); -} - -// ── C3: native providers record env vs explicit api_key_source ────────────── - -/// Google `from_env` → `env:GOOGLE_GENERATIVE_AI_API_KEY`; `new` → `explicit`. -#[test] -fn google_api_key_source_env_vs_explicit() { - let explicit = GoogleConfig::new("g-explicit"); - let snap = GoogleProvider::new(explicit) - .model("gemini-2.0-flash") - .config_snapshot(); - assert_eq!(snap.provider, "google.generative-ai"); - assert_eq!(snap.api_key_source, "explicit"); - - // from_env requires the env var; set it so construction succeeds. - unsafe { std::env::set_var("GOOGLE_GENERATIVE_AI_API_KEY", "g-env") }; - let env_cfg = GoogleConfig::from_env().expect("env var set"); - unsafe { std::env::remove_var("GOOGLE_GENERATIVE_AI_API_KEY") }; - let snap = GoogleProvider::new(env_cfg) - .model("gemini-2.0-flash") - .config_snapshot(); - assert_eq!( - snap.api_key_source, "env:GOOGLE_GENERATIVE_AI_API_KEY", - "from_env must mark the env source" - ); - let json = serde_json::to_string(&snap).unwrap(); - assert!(!json.contains("g-env"), "plaintext key leaked: {json}"); -} - -/// Anthropic `from_env` → `env:ANTHROPIC_API_KEY`; `new` → `explicit`. Also -/// covers C6: the snapshot's `provider_options` is a ProviderOptions-shaped -/// object carrying `headers`/`api_version`/`max_retries`/`body_overrides`. -#[test] -fn anthropic_api_key_source_and_snapshot_shape() { - let explicit = AnthropicConfig::new("a-explicit"); - let snap = AnthropicProvider::new(explicit) - .model("claude-3-5-sonnet-20241022") - .config_snapshot(); - assert_eq!(snap.provider, "anthropic.messages"); - assert_eq!(snap.api_key_source, "explicit"); - - unsafe { std::env::set_var("ANTHROPIC_API_KEY", "a-env") }; - let env_cfg = AnthropicConfig::from_env().expect("env var set"); - unsafe { std::env::remove_var("ANTHROPIC_API_KEY") }; - let snap = AnthropicProvider::new(env_cfg) - .model("claude-3-5-sonnet-20241022") - .config_snapshot(); - assert_eq!(snap.api_key_source, "env:ANTHROPIC_API_KEY"); - - // C6: provider_options shape (not a bare headers map). - let opts = snap - .provider_options - .as_ref() - .expect("anthropic snapshot must carry provider_options"); - assert!(opts.get("headers").is_some(), "missing headers: {opts}"); - assert_eq!(opts["api_version"], "2023-06-01"); - assert!( - opts.get("max_retries").is_some(), - "missing max_retries: {opts}" - ); - assert!( - opts.get("body_overrides").is_some(), - "missing body_overrides: {opts}" - ); - - let json = serde_json::to_string(&snap).unwrap(); - assert!(!json.contains("a-env"), "plaintext key leaked: {json}"); -} - -/// Mistral `from_env` → `env:MISTRAL_API_KEY`. -#[test] -fn mistral_api_key_source_env() { - unsafe { std::env::set_var("MISTRAL_API_KEY", "m-env") }; - let cfg = MistralConfig::from_env().expect("env var set"); - unsafe { std::env::remove_var("MISTRAL_API_KEY") }; - let snap = MistralProvider::new(cfg) - .model("mistral-small-latest") - .config_snapshot(); - assert_eq!(snap.provider, "mistral"); - assert_eq!(snap.api_key_source, "env:MISTRAL_API_KEY"); -} - -/// Cohere `from_env` → `env:COHERE_API_KEY`. -#[test] -fn cohere_api_key_source_env() { - unsafe { std::env::set_var("COHERE_API_KEY", "c-env") }; - let cfg = CohereConfig::from_env().expect("env var set"); - unsafe { std::env::remove_var("COHERE_API_KEY") }; - let snap = CohereProvider::new(cfg) - .model("command-r-plus") - .config_snapshot(); - assert_eq!(snap.provider, "cohere"); - assert_eq!(snap.api_key_source, "env:COHERE_API_KEY"); -} - -/// Azure `from_env` → `env:AZURE_API_KEY`; explicit api-key → `explicit`. -#[test] -fn azure_api_key_source_env_vs_explicit() { - let cfg = AzureConfig::new() - .with_resource_name("my-resource") - .with_api_key("az-explicit"); - let snap = AzureModel::new("gpt-4o".into(), cfg).config_snapshot(); - assert_eq!(snap.provider, "azure"); - assert_eq!(snap.api_key_source, "explicit"); - - unsafe { - std::env::set_var("AZURE_API_KEY", "az-env"); - std::env::set_var("AZURE_RESOURCE_NAME", "res"); - } - let cfg = AzureConfig::from_env().expect("env var set"); - unsafe { - std::env::remove_var("AZURE_API_KEY"); - std::env::remove_var("AZURE_RESOURCE_NAME"); - } - let snap = AzureModel::new("gpt-4o".into(), cfg).config_snapshot(); - assert_eq!(snap.api_key_source, "env:AZURE_API_KEY"); - let json = serde_json::to_string(&snap).unwrap(); - assert!(!json.contains("az-env"), "plaintext key leaked: {json}"); -} - -/// Codex `from_env` → `env:CODEX_API_KEY`; `new` → `explicit`. -#[test] -fn codex_api_key_source_env_vs_explicit() { - let snap = CodexProvider::new(CodexConfig::new("sk-test")) - .model("gpt-5.2-codex") - .config_snapshot(); - assert_eq!(snap.provider, "codex"); - assert_eq!(snap.api_key_source, "explicit"); - - unsafe { std::env::set_var("CODEX_API_KEY", "cx-env") }; - let cfg = CodexConfig::from_env().expect("env var set"); - unsafe { std::env::remove_var("CODEX_API_KEY") }; - let snap = CodexProvider::new(cfg) - .model("gpt-5.2-codex") - .config_snapshot(); - assert_eq!(snap.api_key_source, "env:CODEX_API_KEY"); -} - -/// xAI `from_env` → `env:XAI_API_KEY`; `new` → `explicit`. M2b: `model()` must -/// preserve the credential source instead of reconstructing `XAIConfig::new` -/// (which previously dropped `api_key_source` → snapshot wrongly said -/// "explicit" even for env-built providers). -#[test] -fn xai_api_key_source_env_vs_explicit() { - let snap = XAIProvider::new(XAIConfig::new("x-explicit")) - .model("grok-2") - .config_snapshot(); - assert_eq!(snap.provider, "xai.chat"); - assert_eq!(snap.api_key_source, "explicit"); - - unsafe { std::env::set_var("XAI_API_KEY", "x-env") }; - let cfg = XAIConfig::from_env().expect("env var set"); - unsafe { std::env::remove_var("XAI_API_KEY") }; - let snap = XAIProvider::new(cfg).model("grok-2").config_snapshot(); - assert_eq!( - snap.api_key_source, "env:XAI_API_KEY", - "model() must preserve the env credential source from from_env" - ); - let json = serde_json::to_string(&snap).unwrap(); - assert!(!json.contains("x-env"), "plaintext key leaked: {json}"); -} diff --git a/aimux-providers/tests/openai_transcription_stream_test.rs b/aimux-providers/tests/openai_transcription_stream_test.rs deleted file mode 100644 index 7ed19a2f4..000000000 --- a/aimux-providers/tests/openai_transcription_stream_test.rs +++ /dev/null @@ -1,771 +0,0 @@ -//! Realtime transcription streaming tests (RFC-0028 Phase 1). -//! -//! Each test spins up a local WebSocket server (tokio-tungstenite `accept_async`), -//! plays the OpenAI realtime endpoint's role (consume `session.update` / -//! `input_audio_buffer.append` / `commit`, emit `transcription.delta` / -//! `completed` events), and asserts on the `TranscriptionStreamPart` sequence -//! produced by `do_stream`. -//! -//! These replace the TS mock-WebSocket tests that -//! `openai_transcription_test.rs` omitted ("not practical to translate" — -//! they became practical with RFC-0028's WebSocket infrastructure). - -use std::time::Duration; - -use futures::{SinkExt, StreamExt}; -use tokio::net::TcpListener; -use tokio_tungstenite::tungstenite::Message; - -use aimux_core::AbortSignal; -use aimux_core::error::AiMuxError; -use aimux_core::transcription_model::{ - AudioChunk, InputAudioFormat, TranscriptionModel, TranscriptionStreamOptions, - TranscriptionStreamPart, -}; -use aimux_providers::{OpenAIConfig, OpenAIProvider}; - -// ── helpers ───────────────────────────────────────────────────────────────── - -/// A local WebSocket "OpenAI realtime" server. Handshake + `session.update` -/// ack, then run the scripted behavior. -struct RealtimeServer { - port: u16, -} - -impl RealtimeServer { - /// Start a listener; the accept loop runs on a spawned task. - async fn start() -> Self { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); - tokio::spawn(async move { - // Accept a single connection (tests make one). - let (stream, _) = listener.accept().await.unwrap(); - let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); - serve(&mut ws).await; - }); - Self { port } - } - - fn base_url(&self) -> String { - format!("http://127.0.0.1:{}", self.port) - } -} - -/// Server behavior shared by the happy-path tests: read session.update, -/// ack it, collect audio appends until commit, then emit delta + completed, -/// and finally assert the client closes with code 1000. -async fn serve(ws: &mut tokio_tungstenite::WebSocketStream) { - // 1. Read session.update, capture the model + format from it. - let mut session_model = String::new(); - let mut format_type = String::new(); - if let Some(Ok(Message::Text(text))) = ws.next().await { - let v: serde_json::Value = serde_json::from_str(&text).unwrap(); - assert_eq!( - v["type"], "session.update", - "first message must be session.update" - ); - assert_eq!(v["session"]["type"], "transcription"); - session_model = v["session"]["audio"]["input"]["transcription"]["model"] - .as_str() - .unwrap() - .to_string(); - format_type = v["session"]["audio"]["input"]["format"]["type"] - .as_str() - .unwrap() - .to_string(); - assert!( - v["session"]["audio"]["input"]["turn_detection"].is_null(), - "turn_detection must be null (nested under audio.input)" - ); - ws.send(Message::Text(r#"{"type":"session.created"}"#.to_string())) - .await - .unwrap(); - ws.send(Message::Text(r#"{"type":"session.updated"}"#.to_string())) - .await - .unwrap(); - } - let _ = (&session_model, &format_type); - - // 2. Collect audio appends until commit. - let mut appended: Vec = Vec::new(); - loop { - match ws.next().await { - Some(Ok(Message::Text(text))) => { - let v: serde_json::Value = serde_json::from_str(&text).unwrap(); - match v["type"].as_str().unwrap_or("") { - "input_audio_buffer.append" => { - appended.push(v["audio"].as_str().unwrap().to_string()); - } - "input_audio_buffer.commit" => break, - other => panic!("unexpected client message: {other}"), - } - } - other => panic!("expected client text message, got {other:?}"), - } - } - assert!( - !appended.is_empty(), - "client must append at least one audio chunk" - ); - - // 3. Emit transcript deltas + completed (the shape OpenAI sends). - ws.send(Message::Text( - r#"{"type":"conversation.item.input_audio_transcription.delta","item_id":"item-1","delta":"Hello"}"#.to_string(), - )) - .await - .unwrap(); - ws.send(Message::Text( - r#"{"type":"conversation.item.input_audio_transcription.delta","item_id":"item-1","delta":" world"}"#.to_string(), - )) - .await - .unwrap(); - ws.send(Message::Text( - r#"{"type":"conversation.item.input_audio_transcription.completed","item_id":"item-1","transcript":"Hello world"}"#.to_string(), - )) - .await - .unwrap(); - - // 4. Expect the client to close with code 1000 (normal). - match ws.next().await { - Some(Ok(Message::Close(frame))) => { - let code = frame.map(|f| u16::from(f.code)); - assert_eq!(code, Some(1000), "client must close(1000) after completion"); - } - other => panic!("expected close frame, got {other:?}"), - } -} - -fn realtime_model(base_url: &str) -> aimux_providers::OpenAITranscriptionModel { - let config = OpenAIConfig::new("test-api-key").with_base_url(base_url.to_string()); - let provider = OpenAIProvider::new(config); - provider.transcription("gpt-realtime-whisper") -} - -fn stream_options( - chunks: Vec, - abort: Option, -) -> TranscriptionStreamOptions { - TranscriptionStreamOptions { - audio: Box::pin(futures::stream::iter(chunks)), - input_audio_format: InputAudioFormat { - format_type: "audio/pcm".to_string(), - rate: Some(24000), - }, - provider_options: None, - abort_signal: abort, - headers: None, - include_raw_chunks: false, - timeout: None, - } -} - -/// Collect all parts from a do_stream result into a Vec (Err passthrough). -async fn collect( - result: aimux_core::transcription_model::TranscriptionStreamResult, -) -> Vec> { - let mut out = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - out.push(part); - } - out -} - -// ── tests ─────────────────────────────────────────────────────────────────── - -/// Happy path: connect → session.update → appends → commit → deltas → -/// completed → final + finish → client close(1000). -#[tokio::test] -async fn stream_realtime_happy_path() { - let server = RealtimeServer::start().await; - let model = realtime_model(&server.base_url()); - - let options = stream_options( - vec![ - AudioChunk::Binary(vec![1, 2, 3]), - AudioChunk::Binary(vec![4, 5, 6]), - ], - None, - ); - let result = model - .do_stream(options) - .await - .expect("do_stream should connect"); - let parts = collect(result).await; - - // StreamStart + 2 deltas + final + finish. - assert_eq!(parts.len(), 5, "parts: {parts:?}"); - - assert!(matches!( - &parts[0], - Ok(TranscriptionStreamPart::StreamStart { .. }) - )); - - match &parts[1] { - Ok(TranscriptionStreamPart::TranscriptDelta { id, delta, .. }) => { - assert_eq!(id.as_deref(), Some("item-1")); - assert_eq!(delta, "Hello"); - } - other => panic!("expected delta 'Hello', got {other:?}"), - } - match &parts[2] { - Ok(TranscriptionStreamPart::TranscriptDelta { delta, .. }) => { - assert_eq!(delta, " world"); - } - other => panic!("expected delta ' world', got {other:?}"), - } - match &parts[3] { - Ok(TranscriptionStreamPart::TranscriptFinal { id, text, .. }) => { - assert_eq!(id.as_deref(), Some("item-1")); - assert_eq!(text, "Hello world"); - } - other => panic!("expected final, got {other:?}"), - } - match &parts[4] { - Ok(TranscriptionStreamPart::Finish { text, segments, .. }) => { - assert_eq!(text, "Hello world"); - assert!(segments.is_empty()); - } - other => panic!("expected finish, got {other:?}"), - } -} - -/// The request snapshot records the exact session.update wire shape. -#[tokio::test] -async fn stream_request_carries_session_update() { - let server = RealtimeServer::start().await; - let model = realtime_model(&server.base_url()); - - let options = stream_options(vec![AudioChunk::Binary(vec![1])], None); - let result = model.do_stream(options).await.unwrap(); - let body = result - .request - .as_ref() - .and_then(|r| r.body.as_ref()) - .expect("request body snapshot"); - let v: serde_json::Value = serde_json::from_str(body).unwrap(); - // B1 regression guard: model in audio.input.transcription.model (not URL), - // turn_detection nested under audio.input, format carries the rate. - assert_eq!(v["type"], "session.update"); - assert_eq!( - v["session"]["audio"]["input"]["transcription"]["model"], - "gpt-realtime-whisper" - ); - assert_eq!( - v["session"]["audio"]["input"]["format"]["type"], - "audio/pcm" - ); - assert_eq!(v["session"]["audio"]["input"]["format"]["rate"], 24000); - assert!(v["session"]["audio"]["input"]["turn_detection"].is_null()); - drop(result); -} - -/// Binary audio chunks are base64-encoded in the append messages. -#[tokio::test] -async fn stream_audio_chunks_are_base64() { - // Server variant that asserts the base64 payload. - struct AssertBase64Server; - let _ = AssertBase64Server; - - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); - tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); - // session.update - let _ = ws.next().await; - // first append — verify base64 of [1,2,3] - if let Some(Ok(Message::Text(text))) = ws.next().await { - let v: serde_json::Value = serde_json::from_str(&text).unwrap(); - assert_eq!(v["type"], "input_audio_buffer.append"); - assert_eq!(v["audio"], "AQID", "binary [1,2,3] must arrive as base64"); - } - // second append — Base64 chunk passes through verbatim - if let Some(Ok(Message::Text(text))) = ws.next().await { - let v: serde_json::Value = serde_json::from_str(&text).unwrap(); - assert_eq!( - v["audio"], "cGFzcw==", - "base64 chunk must pass through as-is" - ); - } - // commit - let _ = ws.next().await; - // complete the session so the client closes cleanly - ws.send(Message::Text( - r#"{"type":"conversation.item.input_audio_transcription.completed","item_id":"i","transcript":"x"}"# - .to_string(), - )) - .await - .unwrap(); - let _ = ws.next().await; // close - }); - - let model = realtime_model(&format!("http://127.0.0.1:{port}")); - let options = stream_options( - vec![ - AudioChunk::Binary(vec![1, 2, 3]), - AudioChunk::Base64("cGFzcw==".to_string()), - ], - None, - ); - let result = model.do_stream(options).await.unwrap(); - let parts = collect(result).await; - // StreamStart + final + finish (no deltas in this server script). - assert_eq!(parts.len(), 3, "parts: {parts:?}"); -} - -/// Abort mid-session surfaces `AiMuxError::Aborted`. -#[tokio::test] -async fn stream_abort_mid_session() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); - let abort = AbortSignal::new(); - let abort_clone = abort.clone(); - - tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); - let _ = ws.next().await; // session.update - // Hold the connection open (drain client messages without acting) so - // the abort — not a server-side disconnect — is what ends the session. - while let Some(_msg) = ws.next().await { - tokio::time::sleep(Duration::from_millis(10)).await; - } - }); - - let model = realtime_model(&format!("http://127.0.0.1:{port}")); - let options = stream_options(vec![AudioChunk::Binary(vec![1])], Some(abort_clone)); - - let result = model.do_stream(options).await.unwrap(); - // Abort shortly after the stream starts. - tokio::spawn(async move { - tokio::time::sleep(Duration::from_millis(100)).await; - abort.abort(); - }); - let parts = collect(result).await; - assert!( - parts - .iter() - .any(|p| matches!(p, Err(AiMuxError::Aborted(_)))), - "expected Aborted in parts: {parts:?}" - ); -} - -/// Non-realtime models are rejected from do_stream (inverse gating). -#[tokio::test] -async fn stream_rejects_non_realtime_models() { - let config = OpenAIConfig::new("k").with_base_url("http://127.0.0.1:1"); - let provider = OpenAIProvider::new(config); - let model = provider.transcription("whisper-1"); - let err = model - .do_stream(stream_options(vec![], None)) - .await - .unwrap_err(); - match err { - AiMuxError::UnsupportedFunctionality(msg) => { - assert!(msg.contains("whisper-1"), "got: {msg}"); - } - other => panic!("expected UnsupportedFunctionality, got {other:?}"), - } -} - -/// A closed port (connection refused) — `do_stream` itself must fail with a -/// clear connect error, not an in-stream error part (#118). -#[tokio::test] -async fn stream_connect_failure_errors() { - // Port 1 is never listening on loopback: TCP refuses immediately. - let config = OpenAIConfig::new("k").with_base_url("http://127.0.0.1:1".to_string()); - let provider = OpenAIProvider::new(config); - let model = provider.transcription("gpt-realtime-whisper"); - - let err = model - .do_stream(stream_options(vec![AudioChunk::Binary(vec![1])], None)) - .await - .unwrap_err(); - match &err { - AiMuxError::ApiCall(d) => { - assert!( - d.message.contains("websocket connect failed"), - "connect failure must name the phase, got: {}", - d.message - ); - } - other => panic!("expected ApiCall connect error, got {other:?}"), - } -} - -/// Abort while the WS handshake is still in flight — `Aborted`, not a -/// timeout and not an IO error (#118). -#[tokio::test] -async fn stream_abort_during_connect() { - // A black-hole server: accepts TCP but never completes the WS handshake, - // so `connect_async` pends and only the abort can end the connect phase. - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); - tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - // Hold the socket open without writing the 101 response. - let _hold = stream; - std::future::pending::<()>().await; - }); - - let abort = AbortSignal::new(); - let abort_clone = abort.clone(); - tokio::spawn(async move { - tokio::time::sleep(Duration::from_millis(150)).await; - abort.abort(); - }); - - let model = realtime_model(&format!("http://127.0.0.1:{port}")); - let err = model - .do_stream(stream_options( - vec![AudioChunk::Binary(vec![1])], - Some(abort_clone), - )) - .await - .unwrap_err(); - // Abort semantics exactly: distinct from timeout and from IO failure. - assert!( - matches!(err, AiMuxError::Aborted(_)), - "connect-phase abort must surface Aborted, got {err:?}" - ); - assert!(!matches!(&err, AiMuxError::Timeout(_))); - assert!(!matches!(&err, AiMuxError::ApiCall(_))); -} - -/// `first_chunk_ms` doubles as the connect budget: against a black-hole -/// server the CONNECT phase itself must time out with its own message — -/// distinguishable from both the first-EVENT timeout and a connect IO -/// failure (#118). -#[tokio::test] -async fn stream_connect_timeout_fires() { - // Accepts TCP but never answers the WS handshake — connect pends. - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); - tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let _hold = stream; - std::future::pending::<()>().await; - }); - - let config = OpenAIConfig::new("k").with_base_url(format!("http://127.0.0.1:{port}")); - let provider = OpenAIProvider::new(config); - let model = provider.transcription("gpt-realtime-whisper"); - - let options = TranscriptionStreamOptions { - audio: Box::pin(futures::stream::iter(vec![AudioChunk::Binary(vec![1])])), - input_audio_format: InputAudioFormat { - format_type: "audio/pcm".to_string(), - rate: None, - }, - provider_options: None, - abort_signal: None, - headers: None, - include_raw_chunks: false, - timeout: Some(aimux_core::options::TimeoutConfiguration { - first_chunk_ms: Some(300), - total_ms: None, - step_ms: None, - chunk_ms: None, - }), - }; - let err = model.do_stream(options).await.unwrap_err(); - match &err { - AiMuxError::Timeout(msg) => { - assert_eq!( - msg, "websocket connect timed out", - "connect timeout must carry its own message" - ); - assert!( - !msg.contains("first websocket event"), - "must not be confused with the first-event timeout" - ); - } - other => panic!("expected connect Timeout, got {other:?}"), - } -} - -/// A silent server (accepts + reads session.update, then goes quiet) — the -/// first-chunk timeout must fire (B1/timer coverage). -#[tokio::test] -async fn stream_first_chunk_timeout_fires() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); - tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); - // Read session.update, then send NOTHING but KEEP the socket alive - // (drain client appends) well past the client's 500ms first-chunk - // deadline — the timeout must fire before any disconnect could. - let _ = ws.next().await; - while let Ok(Some(_)) = tokio::time::timeout(Duration::from_secs(2), ws.next()).await {} - }); - - let config = OpenAIConfig::new("k").with_base_url(format!("http://127.0.0.1:{port}")); - let provider = OpenAIProvider::new(config); - let model = provider.transcription("gpt-realtime-whisper"); - - let options = TranscriptionStreamOptions { - audio: Box::pin(futures::stream::iter(vec![AudioChunk::Binary(vec![1])])), - input_audio_format: InputAudioFormat { - format_type: "audio/pcm".to_string(), - rate: None, - }, - provider_options: None, - abort_signal: None, - headers: None, - include_raw_chunks: false, - timeout: Some(aimux_core::options::TimeoutConfiguration { - // first_chunk covers connect + session ack; 500ms is generous on - // loopback but far below the server's 2s hold. - first_chunk_ms: Some(500), - total_ms: None, - step_ms: None, - chunk_ms: None, - }), - }; - let result = model.do_stream(options).await.unwrap(); - let parts = collect(result).await; - let timed_out = parts - .iter() - .any(|p| matches!(p, Err(AiMuxError::Timeout(_)))); - assert!( - timed_out, - "expected a Timeout error against a silent server; got: {parts:?}" - ); -} - -/// The server emits a realtime `error` event — the provider message must -/// propagate verbatim and readable (not wrapped in transport noise), and the -/// session must terminate there (#118). -#[tokio::test] -async fn stream_server_error_event_propagates() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); - tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); - let _ = ws.next().await; // session.update - ws.send(Message::Text( - r#"{"type":"error","error":{"message":"insufficient quota for realtime transcription","type":"insufficient_quota"}}"# - .to_string(), - )) - .await - .unwrap(); - // The client closes(1000) after surfacing the error. - let _ = ws.next().await; - }); - - let model = realtime_model(&format!("http://127.0.0.1:{port}")); - let result = model - .do_stream(stream_options(vec![AudioChunk::Binary(vec![1])], None)) - .await - .unwrap(); - let parts = collect(result).await; - - // StreamStart then the terminal error — nothing after it. - assert_eq!(parts.len(), 2, "parts: {parts:?}"); - assert!(matches!( - &parts[0], - Ok(TranscriptionStreamPart::StreamStart { .. }) - )); - match &parts[1] { - Err(AiMuxError::ApiCall(d)) => { - assert_eq!( - d.message, "insufficient quota for realtime transcription", - "the provider's own message must propagate verbatim" - ); - assert!( - !d.message.contains("websocket"), - "must not be wrapped in transport noise: {}", - d.message - ); - } - other => panic!("expected ApiCall from server error event, got {other:?}"), - } -} - -/// The server closes the socket (close frame with code + reason) after the -/// handshake — the client must surface peer-closed semantics carrying the -/// code/reason, not a bare EOF or a hang (#118). -#[tokio::test] -async fn stream_peer_close_before_completion() { - use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; - use tokio_tungstenite::tungstenite::protocol::frame::{CloseFrame as WsCloseFrame, Frame}; - - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); - tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); - let _ = ws.next().await; // session.update - // Server-initiated abnormal close: code 1011 + a human reason. - let frame = WsCloseFrame { - code: CloseCode::Error, - reason: std::borrow::Cow::Borrowed("server overloaded"), - }; - ws.send(Message::Frame(Frame::close(Some(frame)))) - .await - .unwrap(); - // Drain until the client side tears down. - let _ = ws.next().await; - }); - - let model = realtime_model(&format!("http://127.0.0.1:{port}")); - let result = model - .do_stream(stream_options(vec![AudioChunk::Binary(vec![1])], None)) - .await - .unwrap(); - let parts = collect(result).await; - - assert_eq!(parts.len(), 2, "parts: {parts:?}"); - match &parts[1] { - Err(AiMuxError::ApiCall(d)) => { - assert!( - d.message.contains("websocket closed by peer"), - "peer close must be identifiable, got: {}", - d.message - ); - assert!( - d.message.contains("1011") && d.message.contains("server overloaded"), - "close code and reason must be carried: {}", - d.message - ); - } - other => panic!("expected ApiCall peer-closed error, got {other:?}"), - } -} - -/// chunk-idle timeout: the server delivers one event, then goes silent while -/// holding the socket open — `chunk_ms` must trip with its own message, -/// distinct from the total timeout (#118). -#[tokio::test] -async fn stream_chunk_idle_timeout_fires() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); - tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); - let _ = ws.next().await; // session.update - // One event so the stream is established and alive… - ws.send(Message::Text(r#"{"type":"session.created"}"#.to_string())) - .await - .unwrap(); - // …then total silence while KEEPING the socket open (drain client - // messages for 2s, far past the 400ms idle window) — the idle timer, - // not a disconnect, must end the session. - while let Ok(Some(_)) = tokio::time::timeout(Duration::from_secs(2), ws.next()).await {} - }); - - let config = OpenAIConfig::new("k").with_base_url(format!("http://127.0.0.1:{port}")); - let provider = OpenAIProvider::new(config); - let model = provider.transcription("gpt-realtime-whisper"); - - let options = TranscriptionStreamOptions { - audio: Box::pin(futures::stream::iter(vec![AudioChunk::Binary(vec![1])])), - input_audio_format: InputAudioFormat { - format_type: "audio/pcm".to_string(), - rate: None, - }, - provider_options: None, - abort_signal: None, - headers: None, - include_raw_chunks: false, - timeout: Some(aimux_core::options::TimeoutConfiguration { - first_chunk_ms: None, - total_ms: None, - step_ms: None, - chunk_ms: Some(400), - }), - }; - let result = model.do_stream(options).await.unwrap(); - let parts = collect(result).await; - - assert_eq!(parts.len(), 2, "parts: {parts:?}"); - match &parts[1] { - Err(AiMuxError::Timeout(msg)) => { - assert_eq!( - msg, "websocket chunk idle timeout", - "chunk-idle must fire with its own message" - ); - assert!( - !msg.contains("total"), - "must not be confused with the total timeout" - ); - } - other => panic!("expected chunk-idle Timeout, got {other:?}"), - } -} - -/// total timeout: the server keeps events flowing (so chunk-idle never -/// trips) — the hard connection deadline must still fire, with a message -/// distinct from chunk-idle (#118). -#[tokio::test] -async fn stream_total_timeout_fires() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); - tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); - let _ = ws.next().await; // session.update - // Keep the stream visibly alive: a delta every 100ms for 2s — well - // inside the 300ms chunk window, well past the 700ms total deadline. - for i in 0..20 { - tokio::time::sleep(Duration::from_millis(100)).await; - let event = format!( - r#"{{"type":"conversation.item.input_audio_transcription.delta","item_id":"i","delta":"{i}"}}"# - ); - if ws.send(Message::Text(event)).await.is_err() { - break; // client tore down after the total timeout - } - } - }); - - let config = OpenAIConfig::new("k").with_base_url(format!("http://127.0.0.1:{port}")); - let provider = OpenAIProvider::new(config); - let model = provider.transcription("gpt-realtime-whisper"); - - let options = TranscriptionStreamOptions { - audio: Box::pin(futures::stream::iter(vec![AudioChunk::Binary(vec![1])])), - input_audio_format: InputAudioFormat { - format_type: "audio/pcm".to_string(), - rate: None, - }, - provider_options: None, - abort_signal: None, - headers: None, - include_raw_chunks: false, - timeout: Some(aimux_core::options::TimeoutConfiguration { - first_chunk_ms: None, - total_ms: Some(700), - step_ms: None, - chunk_ms: Some(300), - }), - }; - let result = model.do_stream(options).await.unwrap(); - let parts = collect(result).await; - - // StreamStart + several deltas (alive stream) + the terminal total error. - let deltas = parts - .iter() - .filter(|p| matches!(p, Ok(TranscriptionStreamPart::TranscriptDelta { .. }))) - .count(); - assert!( - deltas >= 2, - "stream must be alive pre-timeout; parts: {parts:?}" - ); - let last = parts.last().expect("must end with an error part"); - match last { - Err(AiMuxError::Timeout(msg)) => { - assert_eq!( - msg, "websocket total timeout", - "total deadline must fire with its own message" - ); - assert!( - !msg.contains("chunk"), - "must not be confused with the chunk-idle timeout" - ); - } - other => panic!("expected total Timeout as the last part, got {other:?}"), - } -} diff --git a/aimux-providers/tests/reasoning_map_test.rs b/aimux-providers/tests/reasoning_map_test.rs deleted file mode 100644 index 0f9bdc6d1..000000000 --- a/aimux-providers/tests/reasoning_map_test.rs +++ /dev/null @@ -1,361 +0,0 @@ -//! stage2-002(RFC-0017 阶段 2):退役回归 + max_tokens_key 矩阵 + reasoning_effort 直传。 -//! -//! 设计来源:[stage2-reasoning-map.md](../../docs/plan/analysis/stage2-reasoning-map.md) §4 I4-I5、§5。 -//! -//! 覆盖: -//! - **I4**:退役后 DeepSeek 请求体不含 `thinking` 注入(除非用户 bodyOverrides 注入) -//! - **I5**:用户 `body_overrides: { thinking: { type: 'disabled' } }` → 请求体含之 -//! (阶段 1 能力回归) -//! - **max_tokens_key 矩阵**:8 家接线(stepfun/siliconflow/sarvam/reka_ai/publicai/ -//! perplexity → `"max_tokens"`;groq/heroku → `"max_completion_tokens"`)× -//! 推理/非推理两分支。profile 取自注册表 `provider_registry_entry(name)` -//! (锁注册表接线,防死代码)。 -//! - **无 warning 断言**:直传语义下 reasoning 不再产生"未翻译"warning(防未来误加)。 -//! - **reasoning_effort 直传**:7 档无归一化(none/minimal/low/medium/high/xhigh 原样 -//! 透传;provider-default 不发字段)。 - -use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; -use aimux_core::options::CallOptions; -use aimux_core::types::{ReasoningEffort, Warning}; -use aimux_providers::openai::OpenAICompatProfile; -use aimux_providers::openai::convert::build_request_body_with_warnings; -use aimux_providers::provider_registry_entry; -use serde_json::json; - -fn user_prompt() -> LanguageModelPrompt { - vec![LanguageModelMessage::user_text("Hello")] -} - -fn opts_with_max_tokens(n: u32) -> CallOptions { - CallOptions { - prompt: user_prompt(), - max_output_tokens: Some(n), - ..CallOptions::default() - } -} - -fn opts_with_reasoning(r: ReasoningEffort) -> CallOptions { - CallOptions { - prompt: user_prompt(), - reasoning: Some(r), - ..CallOptions::default() - } -} - -fn has_reasoning_warning(warnings: &[Warning]) -> bool { - warnings - .iter() - .any(|w| matches!(w, Warning::Compatibility { feature, .. } if feature == "reasoning")) -} - -// ════════════════════════════════════════════════════════════════════════════ -// I4: 退役后 DeepSeek 请求体不含 thinking(除非用户 bodyOverrides) -// ════════════════════════════════════════════════════════════════════════════ - -/// I4: `reasoning:'none'` 透传为 `reasoning_effort:"none"`,请求体**不含** -/// `thinking` 注入(退役语义——thinking 注入不再由内置特化产生)。 -#[test] -fn i4_deepseek_retired_no_thinking_injection() { - let result = build_request_body_with_warnings( - "deepseek-reasoner", - &opts_with_reasoning(ReasoningEffort::None), - false, - "deepseek", - &OpenAICompatProfile::deepseek(), - ) - .unwrap(); - assert_eq!(result.body["reasoning_effort"], json!("none")); - assert!( - result.body.get("thinking").is_none(), - "I4: 退役后 DeepSeek 请求体不应含 thinking 注入: {:?}", - result.body - ); -} - -/// I4 补充: 完全不设 reasoning 时同样不含 thinking(依赖 API 默认)。 -#[test] -fn i4_deepseek_no_thinking_when_reasoning_unset() { - let result = build_request_body_with_warnings( - "deepseek-reasoner", - &CallOptions::new(user_prompt()), - false, - "deepseek", - &OpenAICompatProfile::deepseek(), - ) - .unwrap(); - assert!(result.body.get("thinking").is_none()); - assert!(result.body.get("reasoning_effort").is_none()); -} - -// ════════════════════════════════════════════════════════════════════════════ -// I5: 用户 body_overrides 注入 thinking → 请求体含之(阶段 1 能力回归) -// ════════════════════════════════════════════════════════════════════════════ - -/// I5: 用户 `body_overrides: { thinking: { type: 'disabled' } }` 原样进入请求体。 -/// 关思考的语义完全由用户定义(退役后不再由 reasoning:'none' 自动注入)。 -#[test] -fn i5_body_overrides_injects_thinking_disabled() { - let opts = CallOptions { - prompt: user_prompt(), - reasoning: Some(ReasoningEffort::None), - body_overrides: Some(json!({ "thinking": { "type": "disabled" } })), - ..CallOptions::default() - }; - let result = build_request_body_with_warnings( - "deepseek-reasoner", - &opts, - false, - "deepseek", - &OpenAICompatProfile::deepseek(), - ) - .unwrap(); - assert_eq!(result.body["thinking"], json!({ "type": "disabled" })); - assert_eq!(result.body["reasoning_effort"], json!("none")); -} - -/// I5 补充: 用户注入 `thinking: { type: 'enabled' }` 同样原样进入请求体 -/// (开思考回归——退役前由特化注入,现由用户定义)。 -#[test] -fn i5_body_overrides_injects_thinking_enabled() { - let opts = CallOptions { - prompt: user_prompt(), - body_overrides: Some(json!({ "thinking": { "type": "enabled" } })), - ..CallOptions::default() - }; - let result = build_request_body_with_warnings( - "deepseek-reasoner", - &opts, - false, - "deepseek", - &OpenAICompatProfile::deepseek(), - ) - .unwrap(); - assert_eq!(result.body["thinking"], json!({ "type": "enabled" })); -} - -// ════════════════════════════════════════════════════════════════════════════ -// max_tokens_key 矩阵:8 家接线 × 推理/非推理两分支 -// -// profile 从注册表 `provider_registry_entry(name)` 实际构造取出——直接锁注册表接线 -// (若注册表行被改回 full(),此处 profile.max_tokens_key 断言即失败,防死代码)。 -// ════════════════════════════════════════════════════════════════════════════ - -/// 8 家接线清单:(provider 名, 实际 profile, 期望 max_tokens_key)。 -fn wired_vendors() -> Vec<(&'static str, OpenAICompatProfile, &'static str)> { - vec![ - ( - "stepfun", - provider_registry_entry("stepfun").unwrap(), - "max_tokens", - ), - ( - "siliconflow", - provider_registry_entry("siliconflow").unwrap(), - "max_tokens", - ), - ( - "sarvam", - provider_registry_entry("sarvam").unwrap(), - "max_tokens", - ), - ( - "reka_ai", - provider_registry_entry("reka_ai").unwrap(), - "max_tokens", - ), - ( - "publicai", - provider_registry_entry("publicai").unwrap(), - "max_tokens", - ), - ( - "perplexity", - provider_registry_entry("perplexity").unwrap(), - "max_tokens", - ), - ( - "groq", - provider_registry_entry("groq").unwrap(), - "max_completion_tokens", - ), - ( - "heroku", - provider_registry_entry("heroku").unwrap(), - "max_completion_tokens", - ), - ] -} - -/// 断言单个厂商在指定模型(推理/非推理)下请求体 key 名正确、另一 key 缺席。 -fn assert_vendor_key( - provider: &str, - profile: &OpenAICompatProfile, - expected_key: &str, - model_id: &str, - branch: &str, -) { - let result = build_request_body_with_warnings( - model_id, - &opts_with_max_tokens(100), - false, - provider, - profile, - ) - .unwrap(); - assert_eq!( - result.body[expected_key], - json!(100), - "[{provider}] {branch} 分支: 请求体应含 {expected_key}" - ); - let other_key = if expected_key == "max_tokens" { - "max_completion_tokens" - } else { - "max_tokens" - }; - assert!( - result.body.get(other_key).is_none(), - "[{}] {} 分支: 不应含 {}(只认 {}): {:?}", - provider, - branch, - other_key, - expected_key, - result.body - ); -} - -/// 接线本身:每个厂商 profile.max_tokens_key 与清单一致(防注册表行被改回)。 -#[test] -fn max_tokens_key_wiring_registry() { - for (provider, profile, expected) in wired_vendors() { - assert_eq!( - profile.max_tokens_key, - Some(expected), - "[{provider}] 注册表接线错误" - ); - } -} - -/// 矩阵 × 推理分支(o3-mini 是推理模型 → 若未接线会推断发 mct,接线后按厂商 key 发)。 -#[test] -fn max_tokens_key_matrix_reasoning_branch() { - for (provider, profile, expected) in wired_vendors() { - assert_vendor_key(provider, &profile, expected, "o3-mini", "推理"); - } -} - -/// 矩阵 × 非推理分支(gpt-4o 非推理 → 若未接线会推断发 max_tokens,接线后按厂商 key 发)。 -#[test] -fn max_tokens_key_matrix_non_reasoning_branch() { - for (provider, profile, expected) in wired_vendors() { - assert_vendor_key(provider, &profile, expected, "gpt-4o", "非推理"); - } -} - -/// 未接线厂商(None)保持现状推断:推理模型发 mct、非推理发 max_tokens -/// (回归护栏——full() 默认不受接线影响)。 -#[test] -fn max_tokens_key_none_keeps_inference() { - let profile = OpenAICompatProfile::full(); - assert_eq!(profile.max_tokens_key, None); - - let reasoning = build_request_body_with_warnings( - "o3-mini", - &opts_with_max_tokens(100), - false, - "openai", - &profile, - ) - .unwrap(); - assert_eq!(reasoning.body["max_completion_tokens"], json!(100)); - assert!(reasoning.body.get("max_tokens").is_none()); - - let non_reasoning = build_request_body_with_warnings( - "gpt-4o", - &opts_with_max_tokens(100), - false, - "openai", - &profile, - ) - .unwrap(); - assert_eq!(non_reasoning.body["max_tokens"], json!(100)); - assert!(non_reasoning.body.get("max_completion_tokens").is_none()); -} - -// ════════════════════════════════════════════════════════════════════════════ -// 无 warning 断言(直传语义下无"未翻译"warning——防未来误加) -// ════════════════════════════════════════════════════════════════════════════ - -/// 直传语义下 `reasoning` 各档一律映射为 `reasoning_effort`,不应产生 -/// "reasoning 未翻译/无映射"兼容性 warning(stage2 已删除该死代码块)。 -#[test] -fn no_reasoning_warning_on_direct_passthrough() { - for effort in [ - ReasoningEffort::None, - ReasoningEffort::Minimal, - ReasoningEffort::Low, - ReasoningEffort::Medium, - ReasoningEffort::High, - ReasoningEffort::Xhigh, - ] { - let result = build_request_body_with_warnings( - "deepseek-reasoner", - &opts_with_reasoning(effort), - false, - "deepseek", - &OpenAICompatProfile::deepseek(), - ) - .unwrap(); - assert!( - !has_reasoning_warning(&result.warnings), - "effort={} 直传不应产生 reasoning warning: {:?}", - effort, - result.warnings - ); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// reasoning_effort 直传:7 档无归一化 -// ════════════════════════════════════════════════════════════════════════════ - -/// 7 档(provider-default 不发字段;其余 6 档原样透传,含 none/minimal/xhigh, -/// 无 xhigh→max / minimal→low 归一化)。 -#[test] -fn reasoning_effort_passthrough_all_seven_levels() { - // provider-default: 不发 reasoning_effort(非自定义)。 - let default_body = build_request_body_with_warnings( - "deepseek-reasoner", - &opts_with_reasoning(ReasoningEffort::ProviderDefault), - false, - "deepseek", - &OpenAICompatProfile::deepseek(), - ) - .unwrap(); - assert!( - default_body.body.get("reasoning_effort").is_none(), - "provider-default 不应发 reasoning_effort" - ); - - for (effort, expected) in [ - (ReasoningEffort::None, "none"), - (ReasoningEffort::Minimal, "minimal"), - (ReasoningEffort::Low, "low"), - (ReasoningEffort::Medium, "medium"), - (ReasoningEffort::High, "high"), - (ReasoningEffort::Xhigh, "xhigh"), - ] { - let result = build_request_body_with_warnings( - "deepseek-reasoner", - &opts_with_reasoning(effort), - false, - "deepseek", - &OpenAICompatProfile::deepseek(), - ) - .unwrap(); - assert_eq!( - result.body["reasoning_effort"], - json!(expected), - "reasoning 档位应原样透传(无归一化)" - ); - } -} diff --git a/bindings/node/__test__/body_overrides.test.ts b/bindings/node/__test__/body_overrides.test.ts deleted file mode 100644 index 67c9e2372..000000000 --- a/bindings/node/__test__/body_overrides.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -// body_overrides.test.ts — Node e2e tests for RFC-0017 phase 1: bodyOverrides -// (JSON deep-merge) and provider factory config (headers/maxRetries/bodyOverrides). -// -// These verify the full chain: JS → napi → Rust → HTTP mock, asserting the -// outbound request body carries the user's overrides. - -import test from 'ava' -import { createServer, type Server } from 'node:http' -import { openai, deepseek } 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())) -} - -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 CHAT_RESPONSE = JSON.stringify({ - id: 'chatcmpl-test', - model: 'gpt-4o', - choices: [{ - message: { role: 'assistant', content: 'Done.' }, - finish_reason: 'stop', - }], - usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 }, -}) - -// ── per-call bodyOverrides ─────────────────────────────────────────────────── - -test('per-call bodyOverrides injects a field into the request body', 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(CHAT_RESPONSE) - }) - - try { - const model = await openai('test-key', 'gpt-4o', url) - const opts = JSON.stringify({ - body_overrides: { enable_thinking: false }, - }) - await model.generateText(JSON.stringify('Hello'), opts) - - t.is(requestBody.enable_thinking, false) - t.is(requestBody.model, 'gpt-4o') - } finally { - await closeServer(server) - } -}) - -test('per-call bodyOverrides overwrites a standard 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(CHAT_RESPONSE) - }) - - try { - const model = await openai('test-key', 'gpt-4o', url) - // temperature set via standard option, overridden by body_overrides - const opts = JSON.stringify({ - temperature: 0.9, - body_overrides: { temperature: 0.1 }, - }) - await model.generateText(JSON.stringify('Hello'), opts) - - t.is(requestBody.temperature, 0.1) - } finally { - await closeServer(server) - } -}) - -test('per-call bodyOverrides null deletes a 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(CHAT_RESPONSE) - }) - - try { - const model = await openai('test-key', 'gpt-4o', url) - const opts = JSON.stringify({ - temperature: 0.5, - body_overrides: { temperature: null }, - }) - await model.generateText(JSON.stringify('Hello'), opts) - - t.true(requestBody.temperature === undefined, 'temperature should be deleted by null') - } finally { - await closeServer(server) - } -}) - -// ── provider-level bodyOverrides (factory config) ──────────────────────────── - -test('factory config bodyOverrides are merged into every request', 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(CHAT_RESPONSE) - }) - - try { - const model = await openai('test-key', 'gpt-4o', { - baseUrl: url, - bodyOverrides: '{"X-Relay-Tag":"my-team"}', - }) - await model.generateText(JSON.stringify('Hello')) - - t.is(requestBody['X-Relay-Tag'], 'my-team') - } finally { - await closeServer(server) - } -}) - -test('factory config headers are sent on every request', async (t) => { - let headers: any = null - const { server, url } = await startMockServer(async (req, res) => { - headers = req.headers - // consume body - await readBody(req) - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(CHAT_RESPONSE) - }) - - try { - const model = await openai('test-key', 'gpt-4o', { - baseUrl: url, - headers: JSON.stringify({ 'X-Custom-Header': 'custom-value' }), - }) - await model.generateText(JSON.stringify('Hello')) - - t.is(headers['x-custom-header'], 'custom-value') - } finally { - await closeServer(server) - } -}) - -// ── backward compatibility ─────────────────────────────────────────────────── - -test('factory accepts bare string baseUrl (backward compatible)', async (t) => { - const { server, url } = await startMockServer(async (req, res) => { - await readBody(req) - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(CHAT_RESPONSE) - }) - - try { - // 3rd param is a plain string (old API) - const model = await openai('test-key', 'gpt-4o', url) - const result = JSON.parse(await model.generateText(JSON.stringify('Hello'))) - t.is(result.text, 'Done.') - } finally { - await closeServer(server) - } -}) - -// ── per-call overrides provider-level ──────────────────────────────────────── - -test('per-call bodyOverrides take precedence over provider-level', 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(CHAT_RESPONSE) - }) - - try { - const model = await openai('test-key', 'gpt-4o', { - baseUrl: url, - bodyOverrides: '{"custom_field":"provider"}', - }) - const opts = JSON.stringify({ - body_overrides: { custom_field: 'call' }, - }) - await model.generateText(JSON.stringify('Hello'), opts) - - t.is(requestBody.custom_field, 'call', 'per-call should override provider-level') - } finally { - await closeServer(server) - } -}) - -// ── maxRetries ──────────────────────────────────────────────────────────────── - -test('maxRetries: 0 disables retries (single request on 500)', async (t) => { - let requestCount = 0 - const { server, url } = await startMockServer(async (req, res) => { - requestCount++ - await readBody(req) - res.writeHead(500, { 'content-type': 'application/json' }) - res.end(JSON.stringify({ error: { message: 'Internal server error', type: 'server_error' } })) - }) - - try { - const model = await openai('test-key', 'gpt-4o', url) - const opts = JSON.stringify({ max_retries: 0 }) - - await t.throwsAsync( - async () => model.generateText(JSON.stringify('Hello'), opts), - { message: /500|Internal|server_error/i }, - ) - - // With retries disabled, exactly 1 request should have been made. - t.is(requestCount, 1, 'should make exactly 1 request when maxRetries=0') - } finally { - await closeServer(server) - } -}) - -test('maxRetries: factory-level disables retries', async (t) => { - let requestCount = 0 - const { server, url } = await startMockServer(async (req, res) => { - requestCount++ - await readBody(req) - res.writeHead(500, { 'content-type': 'application/json' }) - res.end(JSON.stringify({ error: { message: 'Internal server error', type: 'server_error' } })) - }) - - try { - const model = await openai('test-key', 'gpt-4o', { - baseUrl: url, - maxRetries: 0, - }) - - await t.throwsAsync( - async () => model.generateText(JSON.stringify('Hello')), - { message: /500|Internal|server_error/i }, - ) - - t.is(requestCount, 1, 'factory maxRetries:0 should make exactly 1 request') - } finally { - await closeServer(server) - } -})