bifrost: a base url with a path is the complete api root
Appending /v1 by testing only the last path segment doubled Google's /v1beta/openai into /v1beta/openai/v1, where a POST hangs with zero bytes and GET /models still answers 200. Only a bare authority now gets the default; /v1, /v4 and /zen/go/v1 roots pass through.
This commit is contained in:
parent
9e147093ec
commit
f32f00c6e3
1 changed files with 63 additions and 13 deletions
|
|
@ -528,15 +528,15 @@ enum ErrorClass {
|
||||||
Permanent,
|
Permanent,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True if the URL's final path segment is a version tag like `v1`, `v4`.
|
/// True if the base URL carries a path past the authority. Such a base is the
|
||||||
/// Used to avoid appending `/v1` to an already-versioned base URL.
|
/// complete API root; only a bare host gets the OpenAI default `/v1`.
|
||||||
fn last_segment_is_version(url: &str) -> bool {
|
///
|
||||||
match url.rsplit('/').next() {
|
/// Measured 2026-08-18: testing only the last segment for `v<digits>` appended
|
||||||
Some(seg) if seg.len() >= 2 => {
|
/// `/v1` to Google's `/v1beta/openai`, and a POST to the doubled path hangs
|
||||||
seg.starts_with('v') && seg[1..].chars().all(|c| c.is_ascii_digit())
|
/// (40s, zero bytes) while `GET /models` returns 200 on both — so the mangled
|
||||||
}
|
/// base looks healthy on a listing.
|
||||||
_ => false,
|
fn has_own_path(url: &str) -> bool {
|
||||||
}
|
reqwest::Url::parse(url).is_ok_and(|u| u.path() != "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BifrostClient {
|
impl BifrostClient {
|
||||||
|
|
@ -548,10 +548,7 @@ impl BifrostClient {
|
||||||
timeout_secs: u64,
|
timeout_secs: u64,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let base = base_url.trim_end_matches('/').to_string();
|
let base = base_url.trim_end_matches('/').to_string();
|
||||||
// Only append "/v1" when the base URL has no version path segment.
|
let base_url = if has_own_path(&base) {
|
||||||
// A URL already ending in "/v<digits>" (Bifrost's "/v1", z.ai's
|
|
||||||
// "/api/coding/paas/v4") is left untouched.
|
|
||||||
let base_url = if last_segment_is_version(&base) {
|
|
||||||
base
|
base
|
||||||
} else {
|
} else {
|
||||||
format!("{}/v1", base)
|
format!("{}/v1", base)
|
||||||
|
|
@ -922,6 +919,59 @@ mod tests {
|
||||||
assert_eq!(reset_hint(None), "");
|
assert_eq!(reset_hint(None), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Google's OpenAI-compatible root nests its version: `/v1beta/openai`.
|
||||||
|
/// Appending `/v1` there produced a path that hangs on POST.
|
||||||
|
#[test]
|
||||||
|
fn google_nested_version_path_is_not_doubled() {
|
||||||
|
let client = BifrostClient::new(
|
||||||
|
"https://generativelanguage.googleapis.com/v1beta/openai",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"gemini-3.6-flash",
|
||||||
|
300,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
client.base_url,
|
||||||
|
"https://generativelanguage.googleapis.com/v1beta/openai"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every `[providers.*]` base URL on disk 2026-08-18 and the API root the
|
||||||
|
/// client must end up posting to.
|
||||||
|
#[test]
|
||||||
|
fn base_url_completion_across_configured_providers() {
|
||||||
|
let cases = [
|
||||||
|
// Paths of their own — taken as complete, nested version or not.
|
||||||
|
(
|
||||||
|
"https://generativelanguage.googleapis.com/v1beta/openai",
|
||||||
|
"https://generativelanguage.googleapis.com/v1beta/openai",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||||
|
"https://generativelanguage.googleapis.com/v1beta/openai",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"https://api.z.ai/api/coding/paas/v4",
|
||||||
|
"https://api.z.ai/api/coding/paas/v4",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"https://token-plan-sgp.xiaomimimo.com/v1",
|
||||||
|
"https://token-plan-sgp.xiaomimimo.com/v1",
|
||||||
|
),
|
||||||
|
("https://opencode.ai/zen/go/v1", "https://opencode.ai/zen/go/v1"),
|
||||||
|
// Bare hosts — get the OpenAI default.
|
||||||
|
("https://api.deepseek.com", "https://api.deepseek.com/v1"),
|
||||||
|
("https://api.deepseek.com/", "https://api.deepseek.com/v1"),
|
||||||
|
("http://10.10.20.120:3360", "http://10.10.20.120:3360/v1"),
|
||||||
|
("http://10.10.20.19:8080", "http://10.10.20.19:8080/v1"),
|
||||||
|
];
|
||||||
|
for (base, want) in cases {
|
||||||
|
let client = BifrostClient::new(base, "", "", "m", 30).unwrap();
|
||||||
|
assert_eq!(client.base_url, want, "base {base}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_client_creation() {
|
fn test_client_creation() {
|
||||||
let client = BifrostClient::new(
|
let client = BifrostClient::new(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue