use super::error::ProviderError;
const CHAT_SUFFIX: &str = "/chat/completions";
pub(crate) fn responses_url(chat_url: &str) -> Option<String> {
let trimmed = chat_url.trim();
if trimmed.is_empty() {
return None;
}
let url = trimmed.strip_suffix('/').unwrap_or(trimmed);
if let Some(base) = url.strip_suffix(CHAT_SUFFIX) {
return non_empty_base(base).map(|b| format!("{b}/responses"));
}
if let Some(base) = url.strip_suffix("/completions") {
return non_empty_base(base).map(|b| format!("{b}/responses"));
}
if let Some(base) = url.strip_suffix("/v1") {
return non_empty_base(base).map(|b| format!("{b}/v1/responses"));
}
None
}
fn non_empty_base(base: &str) -> Option<&str> {
if base.is_empty() { None } else { Some(base) }
}
const DIALECT_REJECTION_NEEDLES: &[&str] = &[
"unknown url",
"unknown endpoint",
"no such endpoint",
"unrecognized request url",
"unknown request url",
"endpoint not found",
"unsupported endpoint",
"not a chat model",
"does not support the chat completions",
"does not support the responses",
"only supported in v1/responses",
"please use v1/responses",
];
pub(crate) fn is_dialect_rejection(status: u16, message: &str) -> bool {
if status == 404 {
return true;
}
if status != 400 && status != 422 {
return false;
}
let lower = message.to_ascii_lowercase();
DIALECT_REJECTION_NEEDLES.iter().any(|n| lower.contains(n))
}
pub(crate) fn is_dialect_rejection_error(err: &ProviderError) -> bool {
match err {
ProviderError::ApiError {
status, message, ..
} => is_dialect_rejection(*status, message),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn responses_url_rewrites_a_chat_completions_url() {
assert_eq!(
responses_url("https://api.openai.com/v1/chat/completions").as_deref(),
Some("https://api.openai.com/v1/responses")
);
assert_eq!(
responses_url("http://localhost:1234/v1/chat/completions").as_deref(),
Some("http://localhost:1234/v1/responses")
);
}
#[test]
fn responses_url_accepts_a_trailing_slash() {
assert_eq!(
responses_url("https://api.openai.com/v1/chat/completions/").as_deref(),
Some("https://api.openai.com/v1/responses")
);
}
#[test]
fn responses_url_handles_version_root_and_legacy_completions() {
assert_eq!(
responses_url("https://api.openai.com/v1").as_deref(),
Some("https://api.openai.com/v1/responses")
);
assert_eq!(
responses_url("https://gateway.example/v1/completions").as_deref(),
Some("https://gateway.example/v1/responses")
);
}
#[test]
fn responses_url_refuses_unknown_shapes() {
assert_eq!(responses_url(""), None);
assert_eq!(responses_url(" "), None);
assert_eq!(responses_url("https://api.openai.com"), None);
assert_eq!(responses_url("/chat/completions"), None);
assert_eq!(
responses_url("https://host/some/other/path"),
None,
"an unrecognised endpoint must stay on chat, never be guessed"
);
}
#[test]
fn a_404_is_always_a_dialect_rejection() {
assert!(is_dialect_rejection(404, ""));
assert!(is_dialect_rejection(
404,
"The model `gpt-6` does not exist or you do not have access to it."
));
}
#[test]
fn a_400_rejects_only_on_endpoint_shaped_messages() {
assert!(is_dialect_rejection(
400,
"Unknown request URL: POST /v1/chat/completions"
));
assert!(is_dialect_rejection(
400,
"This model does not support the chat completions endpoint, please use v1/responses"
));
assert!(
!is_dialect_rejection(400, "Invalid value for 'temperature': expected a number"),
"a plain validation error must not trigger a dialect switch"
);
assert!(
!is_dialect_rejection(422, "Schema validation failed (2 field(s)): tools: extra"),
"a 422 schema error is about the request, not the dialect"
);
}
#[test]
fn non_http_statuses_never_trigger_a_switch() {
assert!(!is_dialect_rejection(500, "internal error"));
assert!(!is_dialect_rejection(401, "invalid api key"));
assert!(!is_dialect_rejection(429, "rate limited"));
}
#[test]
fn error_adapter_only_matches_api_errors() {
assert!(is_dialect_rejection_error(&ProviderError::ApiError {
status: 404,
message: "no such endpoint".to_string(),
error_type: None,
}));
assert!(!is_dialect_rejection_error(&ProviderError::Timeout(30)));
assert!(!is_dialect_rejection_error(
&ProviderError::RateLimitExceeded("slow down".to_string())
));
assert!(!is_dialect_rejection_error(&ProviderError::InvalidApiKey));
}
}