Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions aimux-providers/src/openai/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -261,7 +261,7 @@ pub fn create_openai(settings: OpenAIProviderSettings) -> Result<OpenAIProvider,
.collect(),
settings.headers,
)
.with_user_agent("openai", "4.0.80"),
.with_user_agent("openai", "4.0.83"),
fetch: settings.fetch,
#[cfg(feature = "realtime")]
web_socket: settings.web_socket,
Expand Down
177 changes: 147 additions & 30 deletions aimux-providers/src/openai/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -133,28 +131,97 @@ impl LanguageModel for OpenAIModel {
}

async fn do_generate(&self, options: &CallOptions) -> Result<GenerateResult, AiMuxError> {
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<StreamResult, AiMuxError> {
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<EncodedChatRequest, AiMuxError> {
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<Warning>,
}

/// 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<EncodedChatRequest, AiMuxError> {
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
Expand All @@ -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
///
Expand All @@ -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<GenerateResult, AiMuxError> {
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,
Expand Down Expand Up @@ -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,
Expand All @@ -322,24 +384,20 @@ 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
///
/// Returns request-build conversion errors and `ApiCall` when establishing the
/// 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<StreamResult, AiMuxError> {
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(
Expand Down Expand Up @@ -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 })
);
}
}
96 changes: 95 additions & 1 deletion aimux-providers/tests/openai_factory_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -777,3 +777,97 @@ fn _assert_provider_is_shareable() {
fn shareable<T: Send + Sync + 'static>() {}
shareable::<OpenAIProvider>();
}

/// 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<String, String> = 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<String, String> = 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"
);
}
Loading