diff --git a/aimux-providers/src/openai/mod.rs b/aimux-providers/src/openai/mod.rs index af77a47d9..89810fda6 100644 --- a/aimux-providers/src/openai/mod.rs +++ b/aimux-providers/src/openai/mod.rs @@ -31,7 +31,7 @@ pub use decision::OpenAIDecisionModel; pub use embedding::OpenAIEmbeddingModel; pub use files::OpenAIFiles; pub use image::OpenAIImageModel; -pub use model::OpenAIModel; +pub use model::{EncodedChatRequest, OpenAIModel}; pub use responses::OpenAIResponsesModel; pub use speech::OpenAISpeechModel; pub use tools::{OpenAIComputerAction, OpenAIComputerSafetyCheck}; @@ -261,7 +261,7 @@ pub fn create_openai(settings: OpenAIProviderSettings) -> Result Result { - let headers = self + let encoded = self.encode_request(options, false).await?; + let http = self .config - .request_headers(options.headers.as_ref()) - .await?; - let http = - self.config - .http_request(self.config.url("/chat/completions")?, headers, options); - execute_generate(http, &self.model_id, options, self.config.chat_options).await + .http_request(encoded.url.clone(), encoded.headers.clone(), options); + execute_generate(http, encoded).await } async fn do_stream(&self, options: &CallOptions) -> Result { + let encoded = self.encode_request(options, true).await?; + let http = self + .config + .http_request(encoded.url.clone(), encoded.headers.clone(), options); + execute_stream(http, encoded, options).await + } +} + +impl OpenAIModel { + /// The request this model would send for `options`, as plain data and + /// without touching the transport (W.1). Resolves the configured URL and + /// headers (credential included), then calls the pure [`encode_chat`]. + /// + /// # Errors + /// + /// Returns header/credential resolution errors and request-build + /// conversion errors. + #[doc(hidden)] + pub async fn encode_request( + &self, + options: &CallOptions, + stream: bool, + ) -> Result { let headers = self .config .request_headers(options.headers.as_ref()) .await?; - let http = - self.config - .http_request(self.config.url("/chat/completions")?, headers, options); - execute_stream(http, &self.model_id, options, self.config.chat_options).await + encode_chat( + self.config.url("/chat/completions")?, + headers, + &self.model_id, + options, + stream, + self.config.chat_options, + ) } } +/// An encoded OpenAI chat-completions request: plain data only (no +/// transport, no closures). Content-type, user-agent and the transport are +/// added by the `aimux-provider-utils` helper when it is sent. +#[derive(Debug, Clone)] +pub struct EncodedChatRequest { + /// Full chat-completions URL. + pub url: String, + /// Resolved request headers (credential included). + pub headers: Vec<(String, String)>, + /// The JSON request body. + pub body: Value, + /// Call warnings produced while building the body. + pub warnings: Vec, +} + +/// Encode an OpenAI chat-completions request (W.1): a pure function from the +/// resolved URL/headers, model id and call options to plain data. The only +/// place the chat body is built. +/// +/// # Errors +/// +/// Returns request-build conversion errors. +pub(crate) fn encode_chat( + url: String, + headers: Vec<(String, String)>, + model_id: &str, + options: &CallOptions, + stream: bool, + parse_chat_options: super::options::ChatOptionsParser, +) -> Result { + let RequestBodyResult { body, warnings } = + super::convert::build_request_body_with_chat_options( + model_id, + options, + stream, + parse_chat_options, + )?; + Ok(EncodedChatRequest { + url, + headers, + body, + warnings, + }) +} + // ── Shared OpenAI chat-completions execution ───────────────────────────────── // // These free functions contain the actual HTTP + response-parsing logic. They @@ -164,8 +231,7 @@ impl LanguageModel for OpenAIModel { /// Execute a non-streaming OpenAI chat-completion request. /// /// `http` carries the full chat-completions URL, the auth and request headers -/// and the transport; `model_id` is placed in the request body's `model` -/// field. +/// and the transport; `encoded` is the request from [`encode_chat`]. /// /// # Errors /// @@ -174,13 +240,9 @@ impl LanguageModel for OpenAIModel { /// `choices` is empty. pub(crate) async fn execute_generate( http: HttpRequest, - model_id: &str, - options: &CallOptions, - parse_chat_options: super::options::ChatOptionsParser, + encoded: EncodedChatRequest, ) -> Result { - let request_result = - build_request_body_with_chat_options(model_id, options, false, parse_chat_options)?; - let body = request_result.body; + let EncodedChatRequest { body, warnings, .. } = encoded; let resp = aimux_provider_utils::post_json_to_api( http, @@ -302,7 +364,7 @@ pub(crate) async fn execute_generate( content, finish_reason, usage, - warnings: request_result.warnings, + warnings, provider_metadata, response: Some(aimux_core::shared::ResponseInfo { id: data.id, @@ -322,8 +384,7 @@ pub(crate) async fn execute_generate( /// Execute a streaming OpenAI chat-completion request. /// /// `http` carries the full chat-completions URL, the auth and request headers -/// and the transport; `model_id` is placed in the request body's `model` -/// field. +/// and the transport; `encoded` is the request from [`encode_chat`]. /// /// # Errors /// @@ -331,15 +392,12 @@ pub(crate) async fn execute_generate( /// stream fails; transport errors surface as `Err` items in the stream. pub(crate) async fn execute_stream( http: HttpRequest, - model_id: &str, + encoded: EncodedChatRequest, options: &CallOptions, - parse_chat_options: super::options::ChatOptionsParser, ) -> Result { - let request_result = - build_request_body_with_chat_options(model_id, options, true, parse_chat_options)?; // M9 (RFC-0016): keep the warnings computed while building the body — // they are emitted in `StreamStart` below instead of being dropped. - let RequestBodyResult { body, warnings } = request_result; + let EncodedChatRequest { body, warnings, .. } = encoded; let endpoint = http.url.clone(); let resp = aimux_provider_utils::post_json_to_api( @@ -749,3 +807,62 @@ pub(crate) async fn list_models_once( }) .collect()) } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + /// `encode_chat` is pure: its URL and headers are passed through as given + /// and the body is built from the model id, options and stream flag. + #[test] + fn encode_chat_is_plain_data_from_its_inputs() { + let options = CallOptions { + prompt: serde_json::from_value(json!([ + { "role": "user", "content": [{ "type": "text", "text": "hi" }] } + ])) + .unwrap(), + max_output_tokens: Some(5), + ..CallOptions::default() + }; + let headers = vec![("authorization".to_string(), "Bearer k".to_string())]; + let url = "https://example.test/v1/chat/completions".to_string(); + + let encoded = encode_chat( + url.clone(), + headers.clone(), + "gpt-4o", + &options, + false, + super::super::options::parse_chat_options, + ) + .unwrap(); + assert_eq!(encoded.url, url); + assert_eq!(encoded.headers, headers); + assert!(encoded.warnings.is_empty(), "{:?}", encoded.warnings); + assert_eq!( + encoded.body, + json!({ + "model": "gpt-4o", + "max_tokens": 5, + "messages": [{ "role": "user", "content": "hi" }], + }) + ); + + let streamed = encode_chat( + url, + headers, + "gpt-4o", + &options, + true, + super::super::options::parse_chat_options, + ) + .unwrap(); + assert_eq!(streamed.body["stream"], json!(true)); + assert_eq!( + streamed.body["stream_options"], + json!({ "include_usage": true }) + ); + } +} diff --git a/aimux-providers/tests/openai_factory_test.rs b/aimux-providers/tests/openai_factory_test.rs index 872de6e86..91652c906 100644 --- a/aimux-providers/tests/openai_factory_test.rs +++ b/aimux-providers/tests/openai_factory_test.rs @@ -281,7 +281,7 @@ impl Fixture { .collect(); expected.insert( "user-agent".to_string(), - "ai-sdk-openai/4.0.80 ai-sdk-provider-utils/5.0.51".to_string(), + "ai-sdk-openai/4.0.83 ai-sdk-provider-utils/5.0.51".to_string(), ); assert_eq!( expected.get("authorization").map(String::as_str), @@ -777,3 +777,97 @@ fn _assert_provider_is_shareable() { fn shareable() {} shareable::(); } + +/// W.1: the pure chat encode, compared with every recorded chat request +/// directly — no transport involved. URL and body must match exactly; every +/// header encode produces must match the recording (the redacted key is the +/// test key). `content-type` and the `user-agent` suffix are added later by +/// the provider-utils helper. +#[tokio::test] +async fn chat_encode_matches_recorded_requests_without_fetch() { + let versions: Value = serde_json::from_str( + &std::fs::read_to_string(fixtures_dir().join("../VERSIONS.json")).unwrap(), + ) + .unwrap(); + let pinned_ua = format!( + "ai-sdk-openai/{}", + versions["@ai-sdk/openai"].as_str().unwrap() + ); + let mut provider_layer_uas = 0; + let mut checked = 0; + for name in FIXTURES.iter().filter(|name| name.starts_with("chat-")) { + let fixture = Fixture::load(name); + let recorded = &fixture.json["request"]; + if recorded.is_null() { + continue; + } + let mock = MockFetch::new(vec![]); + // The settings each recording was made with (the per-case tests above; + // scripts/aisdk-fixtures/cases/openai.mjs). + let settings = match *name { + "chat-custom-name" => OpenAIProviderSettings { + name: Some("proxy".to_string()), + ..settings(&mock) + }, + "chat-headers-merge" => OpenAIProviderSettings { + headers: Some( + [("x-a", "1"), ("X-B", "2")] + .into_iter() + .map(|(k, v)| (k.to_string(), Some(v.to_string()))) + .collect(), + ), + ..settings(&mock) + }, + _ => settings(&mock), + }; + let model = create_openai(settings).unwrap().chat(fixture.model_id()); + let stream = recorded["body"]["stream"] == json!(true); + let encoded = model + .encode_request(&fixture.call_options(), stream) + .await + .unwrap_or_else(|e| panic!("{name}: {e}")); + assert!(mock.seen().is_empty(), "{name}: encode used the transport"); + + assert_eq!( + encoded.url, + recorded["url"].as_str().unwrap(), + "{name}: url" + ); + assert_eq!(encoded.body, recorded["body"], "{name}: body"); + + let mut expected: BTreeMap = recorded["headers"] + .as_object() + .unwrap() + .iter() + .map(|(k, v)| (k.to_ascii_lowercase(), v.as_str().unwrap().to_string())) + .collect(); + expected.insert("authorization".into(), bearer(KEY)); + expected.remove("content-type"); + let recorded_ua = expected.remove("user-agent").expect("recorded user-agent"); + let mut got: BTreeMap = encoded + .headers + .iter() + .map(|(k, v)| (k.to_ascii_lowercase(), v.clone())) + .collect(); + // Encode carries the package part of the user-agent; the helper + // appends the provider-utils suffix when sending (see assert_request). + // It names the pinned package; a recording made at the provider layer + // (not through `ai`, which puts its own id first) starts with it. + let ua = got.remove("user-agent").expect("encoded user-agent"); + assert_eq!(ua, pinned_ua, "{name}: user-agent"); + if recorded_ua.starts_with("ai-sdk-openai/") { + assert!( + recorded_ua.starts_with(&format!("{ua} ")), + "{name}: recorded user-agent {recorded_ua:?} does not start with {ua:?}" + ); + provider_layer_uas += 1; + } + assert_eq!(got, expected, "{name}: headers"); + checked += 1; + } + assert_eq!(checked, 7, "chat fixtures checked"); + assert!( + provider_layer_uas > 0, + "no provider-layer user-agent recorded" + ); +}