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:
5 files changed
+639
-37
No files matched your search
@@ -73,6 +73,7 @@ impl BedrockClient {
|
|||||||
url: _,
|
url: _,
|
||||||
headers,
|
headers,
|
||||||
body,
|
body,
|
||||||
|
..
|
||||||
} = request_data;
|
} = request_data;
|
||||||
|
|
||||||
let builder = aws_fetch(
|
let builder = aws_fetch(
|
||||||
@@ -126,6 +127,7 @@ impl BedrockClient {
|
|||||||
url: _,
|
url: _,
|
||||||
headers,
|
headers,
|
||||||
body,
|
body,
|
||||||
|
..
|
||||||
} = request_data;
|
} = request_data;
|
||||||
|
|
||||||
let builder = aws_fetch(
|
let builder = aws_fetch(
|
||||||
|
|||||||
+258
-6
@@ -221,23 +221,39 @@ pub trait Client: Sync + Send {
|
|||||||
request_data.into_builder(client)
|
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) {
|
fn patch_request_data(&self, request_data: &mut RequestData) {
|
||||||
let model_type = self.model().model_type();
|
let model_type = self.model().model_type();
|
||||||
|
let wire = request_data.wire;
|
||||||
if let Some(patch) = self.model().patch() {
|
if let Some(patch) = self.model().patch() {
|
||||||
|
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());
|
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!(
|
let patch_map = std::env::var(get_env_name(&format!(
|
||||||
"patch_{}_{}",
|
"patch_{}_{api_name}",
|
||||||
self.model().client_name(),
|
self.model().client_name(),
|
||||||
model_type.api_name(),
|
|
||||||
)))
|
)))
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|v| serde_json::from_str(&v).ok())
|
.and_then(|v| serde_json::from_str(&v).ok())
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
self.patch_config()
|
self.patch_config().and_then(|v| match wire {
|
||||||
.and_then(|v| model_type.extract_patch(v))
|
WireApi::Responses => v.responses.clone(),
|
||||||
.cloned()
|
WireApi::Chat => model_type.extract_patch(v).cloned(),
|
||||||
|
})
|
||||||
});
|
});
|
||||||
let patch_map = match patch_map {
|
let patch_map = match patch_map {
|
||||||
Some(v) => v,
|
Some(v) => v,
|
||||||
@@ -271,16 +287,26 @@ pub struct ExtraConfig {
|
|||||||
#[derive(Debug, Clone, Deserialize, Default)]
|
#[derive(Debug, Clone, Deserialize, Default)]
|
||||||
pub struct RequestPatch {
|
pub struct RequestPatch {
|
||||||
pub chat_completions: Option<ApiPatch>,
|
pub chat_completions: Option<ApiPatch>,
|
||||||
|
pub responses: Option<ApiPatch>,
|
||||||
pub embeddings: Option<ApiPatch>,
|
pub embeddings: Option<ApiPatch>,
|
||||||
pub rerank: Option<ApiPatch>,
|
pub rerank: Option<ApiPatch>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type ApiPatch = IndexMap<String, Value>;
|
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 struct RequestData {
|
||||||
pub url: String,
|
pub url: String,
|
||||||
pub headers: IndexMap<String, String>,
|
pub headers: IndexMap<String, String>,
|
||||||
pub body: Value,
|
pub body: Value,
|
||||||
|
pub wire: WireApi,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RequestData {
|
impl RequestData {
|
||||||
@@ -292,9 +318,15 @@ impl RequestData {
|
|||||||
url: url.to_string(),
|
url: url.to_string(),
|
||||||
headers: Default::default(),
|
headers: Default::default(),
|
||||||
body,
|
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)
|
pub fn bearer_auth<T>(&mut self, auth: T)
|
||||||
where
|
where
|
||||||
T: std::fmt::Display,
|
T: std::fmt::Display,
|
||||||
@@ -312,7 +344,9 @@ impl RequestData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn into_builder(self, client: &ReqwestClient) -> RequestBuilder {
|
pub fn into_builder(self, client: &ReqwestClient) -> RequestBuilder {
|
||||||
let RequestData { url, headers, body } = self;
|
let RequestData {
|
||||||
|
url, headers, body, ..
|
||||||
|
} = self;
|
||||||
debug!("Request {url} {body}");
|
debug!("Request {url} {body}");
|
||||||
|
|
||||||
let mut builder = client.post(url);
|
let mut builder = client.post(url);
|
||||||
@@ -1524,4 +1558,222 @@ mod tests {
|
|||||||
assert_eq!(client.streaming_calls.load(Ordering::SeqCst), 1);
|
assert_eq!(client.streaming_calls.load(Ordering::SeqCst), 1);
|
||||||
assert_paced_reply(&text, &results);
|
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")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1460,6 +1460,7 @@ echo_pkce_in_token_exchange: true
|
|||||||
api_key: None,
|
api_key: None,
|
||||||
auth: auth.map(str::to_string),
|
auth: auth.map(str::to_string),
|
||||||
oauth: oauth.map(Box::new),
|
oauth: oauth.map(Box::new),
|
||||||
|
wire_api: None,
|
||||||
models: vec![],
|
models: vec![],
|
||||||
patch: None,
|
patch: None,
|
||||||
extra: None,
|
extra: None,
|
||||||
@@ -1496,6 +1497,7 @@ echo_pkce_in_token_exchange: true
|
|||||||
organization_id: None,
|
organization_id: None,
|
||||||
auth: auth.map(str::to_string),
|
auth: auth.map(str::to_string),
|
||||||
oauth: oauth.map(Box::new),
|
oauth: oauth.map(Box::new),
|
||||||
|
wire_api: None,
|
||||||
models: vec![],
|
models: vec![],
|
||||||
patch: None,
|
patch: None,
|
||||||
extra: None,
|
extra: None,
|
||||||
|
|||||||
+219
-23
@@ -21,6 +21,7 @@ pub struct OpenAIConfig {
|
|||||||
pub organization_id: Option<String>,
|
pub organization_id: Option<String>,
|
||||||
pub auth: Option<String>,
|
pub auth: Option<String>,
|
||||||
pub oauth: Option<Box<OAuthConfig>>,
|
pub oauth: Option<Box<OAuthConfig>>,
|
||||||
|
pub wire_api: Option<WireApi>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub models: Vec<ModelData>,
|
pub models: Vec<ModelData>,
|
||||||
pub patch: Option<RequestPatch>,
|
pub patch: Option<RequestPatch>,
|
||||||
@@ -47,12 +48,11 @@ impl Client for OpenAIClient {
|
|||||||
client: &ReqwestClient,
|
client: &ReqwestClient,
|
||||||
data: ChatCompletionsData,
|
data: ChatCompletionsData,
|
||||||
) -> Result<ChatCompletionsOutput> {
|
) -> 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);
|
let builder = self.request_builder(client, request_data);
|
||||||
if uses_codex {
|
match wire {
|
||||||
openai_responses_chat_completions(builder, self.model()).await
|
WireApi::Responses => openai_responses_chat_completions(builder, self.model()).await,
|
||||||
} else {
|
WireApi::Chat => openai_chat_completions(builder, self.model()).await,
|
||||||
openai_chat_completions(builder, self.model()).await
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,15 +62,16 @@ impl Client for OpenAIClient {
|
|||||||
handler: &mut SseHandler,
|
handler: &mut SseHandler,
|
||||||
data: ChatCompletionsData,
|
data: ChatCompletionsData,
|
||||||
) -> Result<()> {
|
) -> 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);
|
let builder = self.request_builder(client, request_data);
|
||||||
|
|
||||||
if uses_codex {
|
match wire {
|
||||||
openai_responses_streaming(builder, handler).await
|
WireApi::Responses => openai_responses_streaming(builder, handler).await,
|
||||||
} else {
|
WireApi::Chat => {
|
||||||
openai_chat_completions_streaming(builder, handler, self.model()).await
|
openai_chat_completions_streaming(builder, handler, self.model()).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn embeddings_inner(
|
async fn embeddings_inner(
|
||||||
&self,
|
&self,
|
||||||
@@ -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(
|
async fn prepare_chat_completions(
|
||||||
self_: &OpenAIClient,
|
self_: &OpenAIClient,
|
||||||
client: &ReqwestClient,
|
client: &ReqwestClient,
|
||||||
data: ChatCompletionsData,
|
data: ChatCompletionsData,
|
||||||
) -> Result<(RequestData, bool)> {
|
) -> Result<(RequestData, WireApi)> {
|
||||||
let uses_oauth = self_.config.auth.as_deref() == Some("oauth");
|
let uses_oauth = self_.config.auth.as_deref() == Some("oauth");
|
||||||
|
|
||||||
if !uses_oauth && self_.config.oauth.is_some() {
|
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)));
|
let uses_stock_provider = matches!(oauth_provider, Some((_, true)));
|
||||||
// Stock oauth with no `api_base` routes to the ChatGPT codex backend (Responses API).
|
// 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 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 {
|
let url = if uses_codex {
|
||||||
CODEX_API_ENDPOINT.to_string()
|
CODEX_API_ENDPOINT.to_string()
|
||||||
} else {
|
} else {
|
||||||
let api_base = resolve_api_base(self_)?;
|
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 {
|
let body = match wire {
|
||||||
openai_build_responses_body(data, &self_.model)
|
WireApi::Responses => openai_build_responses_body(data, &self_.model),
|
||||||
} else {
|
WireApi::Chat => openai_build_chat_completions_body(data, &self_.model),
|
||||||
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 {
|
if let Some((provider, _)) = oauth_provider {
|
||||||
let ready = oauth::prepare_oauth_access_token(client, &*provider, self_.name()).await?;
|
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);
|
request_data.header("OpenAI-Organization", organization_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((request_data, uses_codex))
|
Ok((request_data, wire))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn prepare_embeddings(
|
async fn prepare_embeddings(
|
||||||
@@ -909,7 +935,11 @@ mod tests {
|
|||||||
.unwrap()
|
.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 {
|
let data = ChatCompletionsData {
|
||||||
messages: vec![Message::new(
|
messages: vec![Message::new(
|
||||||
MessageRole::User,
|
MessageRole::User,
|
||||||
@@ -919,7 +949,7 @@ mod tests {
|
|||||||
top_p: None,
|
top_p: None,
|
||||||
reasoning_effort: None,
|
reasoning_effort: None,
|
||||||
functions: None,
|
functions: None,
|
||||||
stream: false,
|
stream,
|
||||||
};
|
};
|
||||||
tokio::runtime::Builder::new_current_thread()
|
tokio::runtime::Builder::new_current_thread()
|
||||||
.enable_all()
|
.enable_all()
|
||||||
@@ -1006,9 +1036,9 @@ mod tests {
|
|||||||
let client = make_client(config.clone(), vec![ClientConfig::OpenAIConfig(config)]);
|
let client = make_client(config.clone(), vec![ClientConfig::OpenAIConfig(config)]);
|
||||||
set_access_token(name, "codex-at".into(), Utc::now().timestamp() + 3600, None);
|
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);
|
assert_eq!(request_data.url, CODEX_API_ENDPOINT);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1024,9 +1054,9 @@ mod tests {
|
|||||||
None,
|
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!(
|
assert_eq!(
|
||||||
request_data.url,
|
request_data.url,
|
||||||
"https://gateway.example/v1/chat/completions"
|
"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"
|
"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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,7 @@ pub struct OpenAICompatibleConfig {
|
|||||||
pub api_key: Option<String>,
|
pub api_key: Option<String>,
|
||||||
pub auth: Option<String>,
|
pub auth: Option<String>,
|
||||||
pub oauth: Option<Box<OAuthConfig>>,
|
pub oauth: Option<Box<OAuthConfig>>,
|
||||||
|
pub wire_api: Option<WireApi>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub models: Vec<ModelData>,
|
pub models: Vec<ModelData>,
|
||||||
pub patch: Option<RequestPatch>,
|
pub patch: Option<RequestPatch>,
|
||||||
@@ -42,10 +43,13 @@ impl Client for OpenAICompatibleClient {
|
|||||||
client: &ReqwestClient,
|
client: &ReqwestClient,
|
||||||
data: ChatCompletionsData,
|
data: ChatCompletionsData,
|
||||||
) -> Result<ChatCompletionsOutput> {
|
) -> 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);
|
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(
|
async fn chat_completions_streaming_inner(
|
||||||
@@ -54,11 +58,16 @@ impl Client for OpenAICompatibleClient {
|
|||||||
handler: &mut SseHandler,
|
handler: &mut SseHandler,
|
||||||
data: ChatCompletionsData,
|
data: ChatCompletionsData,
|
||||||
) -> Result<()> {
|
) -> 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);
|
let builder = self.request_builder(client, request_data);
|
||||||
|
|
||||||
|
match wire {
|
||||||
|
WireApi::Responses => openai_responses_streaming(builder, handler).await,
|
||||||
|
WireApi::Chat => {
|
||||||
openai_chat_completions_streaming(builder, handler, self.model()).await
|
openai_chat_completions_streaming(builder, handler, self.model()).await
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn embeddings_inner(
|
async fn embeddings_inner(
|
||||||
&self,
|
&self,
|
||||||
@@ -87,15 +96,22 @@ async fn prepare_chat_completions(
|
|||||||
self_: &OpenAICompatibleClient,
|
self_: &OpenAICompatibleClient,
|
||||||
client: &ReqwestClient,
|
client: &ReqwestClient,
|
||||||
data: ChatCompletionsData,
|
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 api_base = get_api_base_ext(self_)?;
|
||||||
let url = format!("{api_base}/chat/completions");
|
let url = match wire {
|
||||||
let body = openai_build_chat_completions_body(data, &self_.model);
|
WireApi::Responses => format!("{api_base}/responses"),
|
||||||
let mut request_data = RequestData::new(url, body);
|
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?;
|
apply_auth(self_, client, &mut request_data).await?;
|
||||||
|
|
||||||
Ok(request_data)
|
Ok((request_data, wire))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn prepare_embeddings(
|
async fn prepare_embeddings(
|
||||||
@@ -246,3 +262,137 @@ pub fn generic_build_rerank_body(data: &RerankData, model: &Model) -> Value {
|
|||||||
}
|
}
|
||||||
body
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user