diff --git a/src/client/bedrock.rs b/src/client/bedrock.rs index 1a94f5d..cabcb42 100644 --- a/src/client/bedrock.rs +++ b/src/client/bedrock.rs @@ -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( diff --git a/src/client/common.rs b/src/client/common.rs index 7a223cb..53f255f 100644 --- a/src/client/common.rs +++ b/src/client/common.rs @@ -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, + pub responses: Option, pub embeddings: Option, pub rerank: Option, } pub type ApiPatch = IndexMap; +#[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, 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(&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, + } + + #[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 { + 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, + patch: Option, + ) -> 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") + ); + } } diff --git a/src/client/oauth.rs b/src/client/oauth.rs index e456591..a03c05d 100644 --- a/src/client/oauth.rs +++ b/src/client/oauth.rs @@ -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, diff --git a/src/client/openai.rs b/src/client/openai.rs index 5011ca5..eef437a 100644 --- a/src/client/openai.rs +++ b/src/client/openai.rs @@ -21,6 +21,7 @@ pub struct OpenAIConfig { pub organization_id: Option, pub auth: Option, pub oauth: Option>, + pub wire_api: Option, #[serde(default)] pub models: Vec, pub patch: Option, @@ -47,12 +48,11 @@ impl Client for OpenAIClient { client: &ReqwestClient, 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_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, + uses_codex: bool, + _stock_openai_without_api_base: bool, +) -> Result { + 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::("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) -> 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"); + } } diff --git a/src/client/openai_compatible.rs b/src/client/openai_compatible.rs index d407864..80c7c36 100644 --- a/src/client/openai_compatible.rs +++ b/src/client/openai_compatible.rs @@ -16,6 +16,7 @@ pub struct OpenAICompatibleConfig { pub api_key: Option, pub auth: Option, pub oauth: Option>, + pub wire_api: Option, #[serde(default)] pub models: Vec, pub patch: Option, @@ -42,10 +43,13 @@ impl Client for OpenAICompatibleClient { client: &ReqwestClient, 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(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 { +) -> 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) -> 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::("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" + ); + } +}