feat(TASK-041): add WireApi enum and wire-aware Responses dispatch

Introduce a WireApi enum (responses|chat) with an optional wire_api
config field on the openai and openai-compatible clients, resolved
through a single resolve_wire_api fn: codex forces the responses wire
(an explicit wire_api: chat there is a hard config error), an explicit
wire_api wins everywhere else, and the default stays chat for now.
The openai dispatch generalizes from (RequestData, bool) to
(RequestData, WireApi), and the openai-compatible client gains opt-in
Responses dispatch (URL, body, extractor, and streaming forks).

Both patch layers are now wire-aware: RequestData carries its wire,
RequestPatch gains a responses section, and patch_request_data selects
patches per wire. This deliberately FIXES a codex bug: chat-shaped
model patches (e.g. o3-high's max_tokens/temperature/top_p/
reasoning_effort tweaks) previously merged wire-blind into codex
responses bodies, poisoning them with chat-only keys. On the responses
wire only an explicit responses sub-patch applies; chat-wire patching
is byte-identical to before (pinned by a regression test).
This commit is contained in:
Dark-Alex-17 committed 2026-09-16 11:29:31 -06:00
1 parent 8be9c8e152
commit 57c1a86195
5 files changed
+646 -44

No files matched your search

+2
View File
@@ -73,6 +73,7 @@ impl BedrockClient {
url: _,
headers,
body,
..
} = request_data;
let builder = aws_fetch(
@@ -126,6 +127,7 @@ impl BedrockClient {
url: _,
headers,
body,
..
} = request_data;
let builder = aws_fetch(
+259 -7
View File
@@ -221,23 +221,39 @@ pub trait Client: Sync + Send {
request_data.into_builder(client)
}
/// Patches are chat-shaped unless keyed otherwise: on the Responses wire
/// only the `responses` sub-patch of a model patch and the `responses`
/// section of a client patch apply, so chat-only keys never leak into a
/// responses body.
fn patch_request_data(&self, request_data: &mut RequestData) {
let model_type = self.model().model_type();
let wire = request_data.wire;
if let Some(patch) = self.model().patch() {
request_data.apply_patch(patch.clone());
match wire {
WireApi::Chat => request_data.apply_patch(patch.clone()),
WireApi::Responses => {
if let Some(patch) = patch.get("responses") {
request_data.apply_patch(patch.clone());
}
}
}
}
let api_name = match wire {
WireApi::Responses => "responses",
WireApi::Chat => model_type.api_name(),
};
let patch_map = std::env::var(get_env_name(&format!(
"patch_{}_{}",
"patch_{}_{api_name}",
self.model().client_name(),
model_type.api_name(),
)))
.ok()
.and_then(|v| serde_json::from_str(&v).ok())
.or_else(|| {
self.patch_config()
.and_then(|v| model_type.extract_patch(v))
.cloned()
self.patch_config().and_then(|v| match wire {
WireApi::Responses => v.responses.clone(),
WireApi::Chat => model_type.extract_patch(v).cloned(),
})
});
let patch_map = match patch_map {
Some(v) => v,
@@ -271,16 +287,26 @@ pub struct ExtraConfig {
#[derive(Debug, Clone, Deserialize, Default)]
pub struct RequestPatch {
pub chat_completions: Option<ApiPatch>,
pub responses: Option<ApiPatch>,
pub embeddings: Option<ApiPatch>,
pub rerank: Option<ApiPatch>,
}
pub type ApiPatch = IndexMap<String, Value>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum WireApi {
Responses,
Chat,
}
#[derive(Debug)]
pub struct RequestData {
pub url: String,
pub headers: IndexMap<String, String>,
pub body: Value,
pub wire: WireApi,
}
impl RequestData {
@@ -292,9 +318,15 @@ impl RequestData {
url: url.to_string(),
headers: Default::default(),
body,
wire: WireApi::Chat,
}
}
pub fn wire(mut self, wire: WireApi) -> Self {
self.wire = wire;
self
}
pub fn bearer_auth<T>(&mut self, auth: T)
where
T: std::fmt::Display,
@@ -312,7 +344,9 @@ impl RequestData {
}
pub fn into_builder(self, client: &ReqwestClient) -> RequestBuilder {
let RequestData { url, headers, body } = self;
let RequestData {
url, headers, body, ..
} = self;
debug!("Request {url} {body}");
let mut builder = client.post(url);
@@ -1524,4 +1558,222 @@ mod tests {
assert_eq!(client.streaming_calls.load(Ordering::SeqCst), 1);
assert_paced_reply(&text, &results);
}
/// Minimal client for exercising `patch_request_data`: only the model
/// and the client-level patch config matter.
struct PatchProbeClient {
config: AppConfig,
model: Model,
patch: Option<RequestPatch>,
}
#[async_trait::async_trait]
impl Client for PatchProbeClient {
fn app_config(&self) -> &AppConfig {
&self.config
}
fn extra_config(&self) -> Option<&ExtraConfig> {
None
}
fn patch_config(&self) -> Option<&RequestPatch> {
self.patch.as_ref()
}
fn name(&self) -> &str {
self.model.client_name()
}
fn model(&self) -> &Model {
&self.model
}
async fn chat_completions_inner(
&self,
_client: &ReqwestClient,
_data: ChatCompletionsData,
) -> Result<ChatCompletionsOutput> {
bail!("the patch probe makes no requests")
}
async fn chat_completions_streaming_inner(
&self,
_client: &ReqwestClient,
_handler: &mut SseHandler,
_data: ChatCompletionsData,
) -> Result<()> {
bail!("the patch probe makes no requests")
}
}
fn patch_probe(
client_name: &str,
model_patch: Option<Value>,
patch: Option<RequestPatch>,
) -> PatchProbeClient {
let mut data = ModelData::new("gpt-test");
data.patch = model_patch;
PatchProbeClient {
config: AppConfig::default(),
model: Model::from_config(client_name, &[data]).remove(0),
patch,
}
}
fn client_patch(json: Value) -> RequestPatch {
serde_json::from_value(json).unwrap()
}
fn wire_request(wire: WireApi) -> RequestData {
RequestData::new("https://example.test/v1", json!({"model": "gpt-test"})).wire(wire)
}
#[test]
fn request_patch_accepts_a_responses_section() {
let patch = client_patch(json!({
"responses": {".*": {"body": {"store": true}}},
"chat_completions": {".*": {"body": {"temperature": 0.5}}},
}));
assert!(patch.responses.is_some());
assert!(patch.chat_completions.is_some());
}
#[test]
fn chat_client_patch_never_touches_the_responses_wire() {
let client = patch_probe(
"patchprobechatiso",
None,
Some(client_patch(json!({
"chat_completions": {".*": {"body": {"temperature": 0.5}}}
}))),
);
let mut request_data = wire_request(WireApi::Responses);
let before = request_data.body.clone();
client.patch_request_data(&mut request_data);
assert_eq!(request_data.body, before);
}
#[test]
fn responses_client_patch_never_touches_the_chat_wire() {
let patch = Some(client_patch(json!({
"responses": {".*": {"body": {"store": true}}}
})));
let client = patch_probe("patchproberespiso", None, patch);
let mut chat_request = wire_request(WireApi::Chat);
let before = chat_request.body.clone();
client.patch_request_data(&mut chat_request);
assert_eq!(chat_request.body, before);
let mut responses_request = wire_request(WireApi::Responses);
client.patch_request_data(&mut responses_request);
assert_eq!(responses_request.body["store"], json!(true));
}
#[test]
fn chat_shaped_model_patch_never_touches_the_responses_wire() {
let client = patch_probe(
"patchprobemodeliso",
Some(json!({"body": {"reasoning_effort": "high"}})),
None,
);
let mut request_data = wire_request(WireApi::Responses);
let before = request_data.body.clone();
client.patch_request_data(&mut request_data);
assert_eq!(request_data.body, before);
}
#[test]
fn model_patch_responses_subkey_is_selected_per_wire() {
let model_patch = json!({
"body": {"temperature": 0.2},
"responses": {"body": {"store": true}},
});
let client = patch_probe("patchprobesubkey", Some(model_patch), None);
let mut responses_request = wire_request(WireApi::Responses);
client.patch_request_data(&mut responses_request);
assert_eq!(responses_request.body["store"], json!(true));
assert!(responses_request.body.get("temperature").is_none());
let mut chat_request = wire_request(WireApi::Chat);
client.patch_request_data(&mut chat_request);
assert_eq!(chat_request.body["temperature"], json!(0.2));
assert!(chat_request.body.get("store").is_none());
}
#[test]
fn env_patch_override_is_selected_per_wire() {
// Unique client name so the env vars cannot race parallel tests.
let client_name = "patchprobeenvwire";
let responses_env = get_env_name(&format!("patch_{client_name}_responses"));
let chat_env = get_env_name(&format!("patch_{client_name}_chat_completions"));
unsafe {
std::env::set_var(&responses_env, r#"{".*": {"body": {"store": true}}}"#);
std::env::set_var(&chat_env, r#"{".*": {"body": {"temperature": 0.7}}}"#);
}
let client = patch_probe(client_name, None, None);
let mut responses_request = wire_request(WireApi::Responses);
client.patch_request_data(&mut responses_request);
let mut chat_request = wire_request(WireApi::Chat);
client.patch_request_data(&mut chat_request);
unsafe {
std::env::remove_var(&responses_env);
std::env::remove_var(&chat_env);
}
assert_eq!(responses_request.body["store"], json!(true));
assert!(responses_request.body.get("temperature").is_none());
assert_eq!(chat_request.body["temperature"], json!(0.7));
assert!(chat_request.body.get("store").is_none());
}
#[test]
fn chat_wire_patching_is_unchanged_by_wire_awareness() {
let client = patch_probe(
"patchproberegress",
Some(json!({"body": {"max_tokens": null}, "headers": {"x-model": "m"}})),
Some(client_patch(json!({
"chat_completions": {".*": {
"url": "https://patched.example/v1/chat/completions",
"body": {"temperature": 0.9},
}}
}))),
);
let mut request_data = RequestData::new(
"https://example.test/v1/chat/completions",
json!({"model": "gpt-test", "max_tokens": 100}),
);
request_data.bearer_auth("sk-test");
client.patch_request_data(&mut request_data);
assert_eq!(
request_data.url,
"https://patched.example/v1/chat/completions"
);
assert_eq!(
request_data.body,
json!({"model": "gpt-test", "temperature": 0.9})
);
assert_eq!(
request_data.headers.get("x-model").map(String::as_str),
Some("m")
);
assert_eq!(
request_data
.headers
.get("authorization")
.map(String::as_str),
Some("Bearer sk-test")
);
}
}
+2
View File
@@ -1460,6 +1460,7 @@ echo_pkce_in_token_exchange: true
api_key: None,
auth: auth.map(str::to_string),
oauth: oauth.map(Box::new),
wire_api: None,
models: vec![],
patch: None,
extra: None,
@@ -1496,6 +1497,7 @@ echo_pkce_in_token_exchange: true
organization_id: None,
auth: auth.map(str::to_string),
oauth: oauth.map(Box::new),
wire_api: None,
models: vec![],
patch: None,
extra: None,
+220 -24
View File
@@ -21,6 +21,7 @@ pub struct OpenAIConfig {
pub organization_id: Option<String>,
pub auth: Option<String>,
pub oauth: Option<Box<OAuthConfig>>,
pub wire_api: Option<WireApi>,
#[serde(default)]
pub models: Vec<ModelData>,
pub patch: Option<RequestPatch>,
@@ -47,12 +48,11 @@ impl Client for OpenAIClient {
client: &ReqwestClient,
data: ChatCompletionsData,
) -> Result<ChatCompletionsOutput> {
let (request_data, uses_codex) = prepare_chat_completions(self, client, data).await?;
let (request_data, wire) = prepare_chat_completions(self, client, data).await?;
let builder = self.request_builder(client, request_data);
if uses_codex {
openai_responses_chat_completions(builder, self.model()).await
} else {
openai_chat_completions(builder, self.model()).await
match wire {
WireApi::Responses => openai_responses_chat_completions(builder, self.model()).await,
WireApi::Chat => openai_chat_completions(builder, self.model()).await,
}
}
@@ -62,13 +62,14 @@ impl Client for OpenAIClient {
handler: &mut SseHandler,
data: ChatCompletionsData,
) -> Result<()> {
let (request_data, uses_codex) = prepare_chat_completions(self, client, data).await?;
let (request_data, wire) = prepare_chat_completions(self, client, data).await?;
let builder = self.request_builder(client, request_data);
if uses_codex {
openai_responses_streaming(builder, handler).await
} else {
openai_chat_completions_streaming(builder, handler, self.model()).await
match wire {
WireApi::Responses => openai_responses_streaming(builder, handler).await,
WireApi::Chat => {
openai_chat_completions_streaming(builder, handler, self.model()).await
}
}
}
@@ -93,11 +94,29 @@ impl Client for OpenAIClient {
}
}
/// Codex only speaks the Responses API, so it forces the responses wire and
/// rejects an explicit `wire_api: chat`. Anywhere else an explicit `wire_api`
/// wins and the default is chat.
pub fn resolve_wire_api(
explicit: Option<WireApi>,
uses_codex: bool,
_stock_openai_without_api_base: bool,
) -> Result<WireApi> {
match (uses_codex, explicit) {
(true, Some(WireApi::Chat)) => bail!(
"the Codex backend only speaks the Responses API; remove `wire_api: chat` or configure an `api_base`"
),
(true, _) => Ok(WireApi::Responses),
(false, Some(wire)) => Ok(wire),
(false, None) => Ok(WireApi::Chat),
}
}
async fn prepare_chat_completions(
self_: &OpenAIClient,
client: &ReqwestClient,
data: ChatCompletionsData,
) -> Result<(RequestData, bool)> {
) -> Result<(RequestData, WireApi)> {
let uses_oauth = self_.config.auth.as_deref() == Some("oauth");
if !uses_oauth && self_.config.oauth.is_some() {
@@ -116,21 +135,28 @@ async fn prepare_chat_completions(
let uses_stock_provider = matches!(oauth_provider, Some((_, true)));
// Stock oauth with no `api_base` routes to the ChatGPT codex backend (Responses API).
let uses_codex = uses_stock_provider && self_.get_api_base().is_err();
let wire = resolve_wire_api(
self_.config.wire_api,
uses_codex,
self_.get_api_base().is_err(),
)?;
let url = if uses_codex {
CODEX_API_ENDPOINT.to_string()
} else {
let api_base = resolve_api_base(self_)?;
format!("{}/chat/completions", api_base.trim_end_matches('/'))
match wire {
WireApi::Responses => format!("{}/responses", api_base.trim_end_matches('/')),
WireApi::Chat => format!("{}/chat/completions", api_base.trim_end_matches('/')),
}
};
let body = if uses_codex {
openai_build_responses_body(data, &self_.model)
} else {
openai_build_chat_completions_body(data, &self_.model)
let body = match wire {
WireApi::Responses => openai_build_responses_body(data, &self_.model),
WireApi::Chat => openai_build_chat_completions_body(data, &self_.model),
};
let mut request_data = RequestData::new(url, body);
let mut request_data = RequestData::new(url, body).wire(wire);
if let Some((provider, _)) = oauth_provider {
let ready = oauth::prepare_oauth_access_token(client, &*provider, self_.name()).await?;
@@ -167,7 +193,7 @@ async fn prepare_chat_completions(
request_data.header("OpenAI-Organization", organization_id);
}
Ok((request_data, uses_codex))
Ok((request_data, wire))
}
async fn prepare_embeddings(
@@ -909,7 +935,11 @@ mod tests {
.unwrap()
}
fn prepare(client: &OpenAIClient) -> Result<(RequestData, bool)> {
fn prepare(client: &OpenAIClient) -> Result<(RequestData, WireApi)> {
prepare_with_stream(client, false)
}
fn prepare_with_stream(client: &OpenAIClient, stream: bool) -> Result<(RequestData, WireApi)> {
let data = ChatCompletionsData {
messages: vec![Message::new(
MessageRole::User,
@@ -919,7 +949,7 @@ mod tests {
top_p: None,
reasoning_effort: None,
functions: None,
stream: false,
stream,
};
tokio::runtime::Builder::new_current_thread()
.enable_all()
@@ -1006,9 +1036,9 @@ mod tests {
let client = make_client(config.clone(), vec![ClientConfig::OpenAIConfig(config)]);
set_access_token(name, "codex-at".into(), Utc::now().timestamp() + 3600, None);
let (request_data, uses_codex) = prepare(&client).unwrap();
let (request_data, wire) = prepare(&client).unwrap();
assert!(uses_codex);
assert_eq!(wire, WireApi::Responses);
assert_eq!(request_data.url, CODEX_API_ENDPOINT);
}
@@ -1024,9 +1054,9 @@ mod tests {
None,
);
let (request_data, uses_codex) = prepare(&client).unwrap();
let (request_data, wire) = prepare(&client).unwrap();
assert!(!uses_codex);
assert_eq!(wire, WireApi::Chat);
assert_eq!(
request_data.url,
"https://gateway.example/v1/chat/completions"
@@ -1243,4 +1273,170 @@ mod tests {
"bundled models.yaml must not carry an openai oauth block: it would silently disable codex routing for stock ChatGPT oauth users"
);
}
#[test]
fn wire_api_deserializes_on_openai_config() {
let config: OpenAIConfig = serde_yaml::from_str("wire_api: responses").unwrap();
assert_eq!(config.wire_api, Some(WireApi::Responses));
let config: OpenAIConfig = serde_yaml::from_str("wire_api: chat").unwrap();
assert_eq!(config.wire_api, Some(WireApi::Chat));
}
#[test]
fn bogus_wire_api_is_a_config_error() {
let err = serde_yaml::from_str::<OpenAIConfig>("wire_api: bogus")
.unwrap_err()
.to_string();
assert!(
err.contains("responses") && err.contains("chat"),
"unexpected error: {err}"
);
}
#[test]
fn resolver_codex_defaults_to_responses() {
assert_eq!(
resolve_wire_api(None, true, false).unwrap(),
WireApi::Responses
);
assert_eq!(
resolve_wire_api(Some(WireApi::Responses), true, false).unwrap(),
WireApi::Responses
);
}
#[test]
fn resolver_rejects_explicit_chat_on_codex() {
let err = resolve_wire_api(Some(WireApi::Chat), true, false)
.unwrap_err()
.to_string();
assert!(
err.contains("only speaks the Responses API"),
"unexpected error: {err}"
);
}
#[test]
fn resolver_explicit_wins_off_codex() {
for stock in [false, true] {
assert_eq!(
resolve_wire_api(Some(WireApi::Responses), false, stock).unwrap(),
WireApi::Responses
);
assert_eq!(
resolve_wire_api(Some(WireApi::Chat), false, stock).unwrap(),
WireApi::Chat
);
}
}
#[test]
fn resolver_defaults_to_chat_this_milestone() {
for stock in [false, true] {
assert_eq!(resolve_wire_api(None, false, stock).unwrap(), WireApi::Chat);
}
}
fn api_key_config(name: &str, wire_api: Option<WireApi>) -> OpenAIConfig {
OpenAIConfig {
name: Some(name.into()),
api_key: Some("sk-test".into()),
wire_api,
..Default::default()
}
}
#[test]
fn api_key_openai_defaults_to_the_chat_wire() {
let config = api_key_config("openai-wire-default-test", None);
let client = make_client(config.clone(), vec![ClientConfig::OpenAIConfig(config)]);
let (request_data, wire) = prepare(&client).unwrap();
assert_eq!(wire, WireApi::Chat);
assert_eq!(request_data.url, format!("{API_BASE}/chat/completions"));
assert!(
request_data.body.get("messages").is_some(),
"body: {}",
request_data.body
);
}
#[test]
fn explicit_responses_wire_posts_a_responses_body_to_the_responses_endpoint() {
let config = api_key_config("openai-wire-responses-test", Some(WireApi::Responses));
let client = make_client(config.clone(), vec![ClientConfig::OpenAIConfig(config)]);
let (request_data, wire) = prepare(&client).unwrap();
assert_eq!(wire, WireApi::Responses);
assert_eq!(request_data.url, format!("{API_BASE}/responses"));
assert_eq!(request_data.body["store"], json!(false));
assert!(
request_data.body.get("input").is_some() && request_data.body.get("messages").is_none(),
"body: {}",
request_data.body
);
}
#[test]
fn explicit_responses_wire_forks_the_streaming_path_too() {
let config = api_key_config("openai-wire-responses-stream-test", Some(WireApi::Responses));
let client = make_client(config.clone(), vec![ClientConfig::OpenAIConfig(config)]);
let (request_data, wire) = prepare_with_stream(&client, true).unwrap();
assert_eq!(wire, WireApi::Responses);
assert_eq!(request_data.url, format!("{API_BASE}/responses"));
assert_eq!(request_data.body["stream"], json!(true));
assert!(
request_data.body.get("input").is_some(),
"body: {}",
request_data.body
);
}
#[test]
fn codex_with_explicit_chat_wire_is_rejected() {
let name = "openai-codex-chat-wire-test";
let mut config = openai_config(name, Some("oauth"), None);
config.wire_api = Some(WireApi::Chat);
let client = make_client(config.clone(), vec![ClientConfig::OpenAIConfig(config)]);
let err = prepare(&client).unwrap_err().to_string();
assert!(
err.contains("only speaks the Responses API"),
"unexpected error: {err}"
);
}
#[test]
fn codex_responses_body_ignores_a_chat_shaped_model_patch() {
let name = "openai-codex-chat-patch-test";
let config = openai_config(name, Some("oauth"), None);
let mut model_data = ModelData::new("gpt-test");
model_data.patch = Some(json!({
"body": {
"max_tokens": null,
"temperature": null,
"top_p": null,
"reasoning_effort": "high",
}
}));
let mut client = make_client(config.clone(), vec![ClientConfig::OpenAIConfig(config)]);
client.model = Model::from_config("openai", &[model_data]).remove(0);
set_access_token(name, "codex-at".into(), Utc::now().timestamp() + 3600, None);
let (mut request_data, wire) = prepare(&client).unwrap();
assert_eq!(wire, WireApi::Responses);
let body_before = request_data.body.clone();
client.patch_request_data(&mut request_data);
assert_eq!(request_data.body, body_before, "a chat-shaped model patch must not merge into a responses body");
}
}
+163 -13
View File
@@ -16,6 +16,7 @@ pub struct OpenAICompatibleConfig {
pub api_key: Option<String>,
pub auth: Option<String>,
pub oauth: Option<Box<OAuthConfig>>,
pub wire_api: Option<WireApi>,
#[serde(default)]
pub models: Vec<ModelData>,
pub patch: Option<RequestPatch>,
@@ -42,10 +43,13 @@ impl Client for OpenAICompatibleClient {
client: &ReqwestClient,
data: ChatCompletionsData,
) -> Result<ChatCompletionsOutput> {
let request_data = prepare_chat_completions(self, client, data).await?;
let (request_data, wire) = prepare_chat_completions(self, client, data).await?;
let builder = self.request_builder(client, request_data);
openai_chat_completions(builder, self.model()).await
match wire {
WireApi::Responses => openai_responses_chat_completions(builder, self.model()).await,
WireApi::Chat => openai_chat_completions(builder, self.model()).await,
}
}
async fn chat_completions_streaming_inner(
@@ -54,10 +58,15 @@ impl Client for OpenAICompatibleClient {
handler: &mut SseHandler,
data: ChatCompletionsData,
) -> Result<()> {
let request_data = prepare_chat_completions(self, client, data).await?;
let (request_data, wire) = prepare_chat_completions(self, client, data).await?;
let builder = self.request_builder(client, request_data);
openai_chat_completions_streaming(builder, handler, self.model()).await
match wire {
WireApi::Responses => openai_responses_streaming(builder, handler).await,
WireApi::Chat => {
openai_chat_completions_streaming(builder, handler, self.model()).await
}
}
}
async fn embeddings_inner(
@@ -87,15 +96,22 @@ async fn prepare_chat_completions(
self_: &OpenAICompatibleClient,
client: &ReqwestClient,
data: ChatCompletionsData,
) -> Result<RequestData> {
) -> Result<(RequestData, WireApi)> {
let wire = resolve_wire_api(self_.config.wire_api, false, false)?;
let api_base = get_api_base_ext(self_)?;
let url = format!("{api_base}/chat/completions");
let body = openai_build_chat_completions_body(data, &self_.model);
let mut request_data = RequestData::new(url, body);
let url = match wire {
WireApi::Responses => format!("{api_base}/responses"),
WireApi::Chat => format!("{api_base}/chat/completions"),
};
let body = match wire {
WireApi::Responses => openai_build_responses_body(data, &self_.model),
WireApi::Chat => openai_build_chat_completions_body(data, &self_.model),
};
let mut request_data = RequestData::new(url, body).wire(wire);
apply_auth(self_, client, &mut request_data).await?;
Ok(request_data)
Ok((request_data, wire))
}
async fn prepare_embeddings(
@@ -246,3 +262,137 @@ pub fn generic_build_rerank_body(data: &RerankData, model: &Model) -> Value {
}
body
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::AppConfig;
use std::sync::Arc;
fn compat_config(wire_api: Option<WireApi>) -> OpenAICompatibleConfig {
OpenAICompatibleConfig {
name: Some("compat-test".into()),
api_base: Some("https://compat.example/v1".into()),
api_key: Some("sk-test".into()),
auth: None,
oauth: None,
wire_api,
models: vec![],
patch: None,
extra: None,
}
}
fn make_client(config: OpenAICompatibleConfig) -> OpenAICompatibleClient {
OpenAICompatibleClient {
app_config: Arc::new(AppConfig::default()),
config,
model: Model::new("compat-test", "gpt-test"),
}
}
fn prepare(
client: &OpenAICompatibleClient,
stream: bool,
) -> Result<(RequestData, WireApi)> {
let data = ChatCompletionsData {
messages: vec![Message::new(
MessageRole::User,
MessageContent::Text("hello".to_string()),
)],
temperature: None,
top_p: None,
reasoning_effort: None,
functions: None,
stream,
};
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(prepare_chat_completions(
client,
&ReqwestClient::new(),
data,
))
}
#[test]
fn wire_api_deserializes_on_openai_compatible_config() {
let config: OpenAICompatibleConfig = serde_yaml::from_str("wire_api: responses").unwrap();
assert_eq!(config.wire_api, Some(WireApi::Responses));
let config: OpenAICompatibleConfig = serde_yaml::from_str("wire_api: chat").unwrap();
assert_eq!(config.wire_api, Some(WireApi::Chat));
}
#[test]
fn bogus_wire_api_is_a_config_error() {
let err = serde_yaml::from_str::<OpenAICompatibleConfig>("wire_api: bogus")
.unwrap_err()
.to_string();
assert!(
err.contains("responses") && err.contains("chat"),
"unexpected error: {err}"
);
}
#[test]
fn default_wire_is_chat() {
let client = make_client(compat_config(None));
let (request_data, wire) = prepare(&client, false).unwrap();
assert_eq!(wire, WireApi::Chat);
assert_eq!(
request_data.url,
"https://compat.example/v1/chat/completions"
);
assert!(
request_data.body.get("messages").is_some(),
"body: {}",
request_data.body
);
}
#[test]
fn explicit_responses_wire_posts_a_responses_body_to_the_responses_endpoint() {
let client = make_client(compat_config(Some(WireApi::Responses)));
let (request_data, wire) = prepare(&client, false).unwrap();
assert_eq!(wire, WireApi::Responses);
assert_eq!(request_data.url, "https://compat.example/v1/responses");
assert_eq!(request_data.body["store"], json!(false));
assert!(
request_data.body.get("input").is_some() && request_data.body.get("messages").is_none(),
"body: {}",
request_data.body
);
}
#[test]
fn explicit_responses_wire_forks_the_streaming_path_too() {
let client = make_client(compat_config(Some(WireApi::Responses)));
let (request_data, wire) = prepare(&client, true).unwrap();
assert_eq!(wire, WireApi::Responses);
assert_eq!(request_data.url, "https://compat.example/v1/responses");
assert_eq!(request_data.body["stream"], json!(true));
}
#[test]
fn explicit_chat_wire_keeps_the_chat_endpoint() {
let client = make_client(compat_config(Some(WireApi::Chat)));
let (request_data, wire) = prepare(&client, false).unwrap();
assert_eq!(wire, WireApi::Chat);
assert_eq!(
request_data.url,
"https://compat.example/v1/chat/completions"
);
}
}