From c4e802465d0b3a0139cf83bb9de81d6b28d1bda2 Mon Sep 17 00:00:00 2001 From: Trey Kim Date: Sun, 11 Oct 2026 22:53:45 +0800 Subject: [PATCH 1/2] W.1: OpenAI chat encode as one pure function returning plain data encode_chat(url, headers, model_id, &CallOptions, stream, parser) -> EncodedChatRequest { url, headers, body, warnings } is the only place the chat body is built; do_generate/do_stream resolve URL+headers (OpenAIModel::encode_request), encode, then hand the result to the execute_* helpers. New openai_factory_test compares encode output with all 7 recorded chat requests without a transport. --- aimux-providers/src/openai/mod.rs | 2 +- aimux-providers/src/openai/model.rs | 118 ++++++++++++++----- aimux-providers/tests/openai_factory_test.rs | 75 ++++++++++++ 3 files changed, 164 insertions(+), 31 deletions(-) diff --git a/aimux-providers/src/openai/mod.rs b/aimux-providers/src/openai/mod.rs index af77a47d9..fff50697b 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}; diff --git a/aimux-providers/src/openai/model.rs b/aimux-providers/src/openai/model.rs index e1da418f6..f126f615e 100644 --- a/aimux-providers/src/openai/model.rs +++ b/aimux-providers/src/openai/model.rs @@ -28,9 +28,7 @@ use aimux_provider_utils::{ }; use super::config::OpenAIModelConfig; -use super::convert::{ - RequestBodyResult, build_request_body_with_chat_options, parse_finish_reason, -}; +use super::convert::{RequestBodyResult, parse_finish_reason}; use super::types::{ChatCompletionResponse, StreamChunk, UsageResponse}; /// An OpenAI chat-completions language model. @@ -133,28 +131,97 @@ impl LanguageModel for OpenAIModel { } async fn do_generate(&self, options: &CallOptions) -> 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( diff --git a/aimux-providers/tests/openai_factory_test.rs b/aimux-providers/tests/openai_factory_test.rs index 872de6e86..4b3b1d386 100644 --- a/aimux-providers/tests/openai_factory_test.rs +++ b/aimux-providers/tests/openai_factory_test.rs @@ -777,3 +777,78 @@ 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 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"); + expected.remove("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). + assert_eq!( + got.remove("user-agent").as_deref(), + Some("ai-sdk-openai/4.0.80"), + "{name}: user-agent" + ); + assert_eq!(got, expected, "{name}: headers"); + checked += 1; + } + assert_eq!(checked, 7, "chat fixtures checked"); +} From 76d0f8d50197b8db7f3eed982543b350e5c0a792 Mon Sep 17 00:00:00 2001 From: Trey Kim Date: Sun, 11 Oct 2026 23:42:08 +0800 Subject: [PATCH 2/2] W.1: user-agent from the pinned @ai-sdk/openai 4.0.83; test encode_chat directly openai/mod.rs: with_user_agent("openai", "4.0.83") (fixtures/aisdk/VERSIONS.json). chat_encode_matches_recorded_requests_without_fetch derives the expected UA from VERSIONS.json and asserts a provider-layer recorded UA starts with the encoded one instead of comparing a literal. New unit test calls the pure encode_chat. --- aimux-providers/src/openai/mod.rs | 2 +- aimux-providers/src/openai/model.rs | 59 ++++++++++++++++++++ aimux-providers/tests/openai_factory_test.rs | 33 ++++++++--- 3 files changed, 86 insertions(+), 8 deletions(-) diff --git a/aimux-providers/src/openai/mod.rs b/aimux-providers/src/openai/mod.rs index fff50697b..89810fda6 100644 --- a/aimux-providers/src/openai/mod.rs +++ b/aimux-providers/src/openai/mod.rs @@ -261,7 +261,7 @@ pub fn create_openai(settings: OpenAIProviderSettings) -> Result = encoded .headers .iter() @@ -842,13 +851,23 @@ async fn chat_encode_matches_recorded_requests_without_fetch() { .collect(); // Encode carries the package part of the user-agent; the helper // appends the provider-utils suffix when sending (see assert_request). - assert_eq!( - got.remove("user-agent").as_deref(), - Some("ai-sdk-openai/4.0.80"), - "{name}: user-agent" - ); + // 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" + ); }