feat: add local/ routing prefix for model IDs with embedded slashes
Some backends (e.g., gpt.uio.no running Ollama) have model IDs containing slashes like Qwen/Qwen3.6-27B-FP8. The existing routing prefix logic would strip 'Qwen/' as a provider prefix, sending only the remainder on the wire and causing 403 Forbidden from the server. The new local/ prefix is an escape hatch: it strips just 'local/' and sends everything after it verbatim, preserving embedded slashes in model IDs. Usage: --model local/Qwen/Qwen3.6-27B-FP8 This patch was developed with assistance from OpenCode and local Qwen 3.6 API server.
This commit is contained in:
parent
87b7e74770
commit
95bf18e5cc
|
|
@ -915,14 +915,17 @@ pub fn model_requires_reasoning_content_in_history(model: &str) -> bool {
|
||||||
|
|
||||||
/// Strip routing prefix (e.g., "openai/gpt-4" → "gpt-4") for the wire.
|
/// Strip routing prefix (e.g., "openai/gpt-4" → "gpt-4") for the wire.
|
||||||
/// The prefix is used only to select transport; the backend expects the
|
/// The prefix is used only to select transport; the backend expects the
|
||||||
/// bare model id.
|
/// bare model id. Use `"local/"` as an escape hatch when the server's model
|
||||||
|
/// ID itself contains a slash (e.g., `local/Qwen/Qwen3.6-27B-FP8`).
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
fn strip_routing_prefix(model: &str) -> &str {
|
fn strip_routing_prefix(model: &str) -> &str {
|
||||||
if let Some(pos) = model.find('/') {
|
if let Some(pos) = model.find('/') {
|
||||||
let prefix = &model[..pos];
|
let prefix = &model[..pos];
|
||||||
// Only strip if the prefix before "/" is a known routing prefix,
|
// Only strip if the prefix before "/" is a known routing prefix,
|
||||||
// not if "/" appears in the middle of the model name for other reasons.
|
// not if "/" appears in the middle of the model name for other reasons.
|
||||||
if matches!(prefix, "openai" | "xai" | "grok" | "qwen" | "kimi") {
|
// "local" is an escape hatch: strips just "local/" and sends everything
|
||||||
|
// after it verbatim (including embedded slashes) on the wire.
|
||||||
|
if matches!(prefix, "openai" | "xai" | "grok" | "qwen" | "kimi" | "local") {
|
||||||
&model[pos + 1..]
|
&model[pos + 1..]
|
||||||
} else {
|
} else {
|
||||||
model
|
model
|
||||||
|
|
@ -965,6 +968,12 @@ fn wire_model_for_base_url<'a>(
|
||||||
return Cow::Borrowed(&model[pos + 1..]);
|
return Cow::Borrowed(&model[pos + 1..]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "local/" escape hatch: strips just the prefix and sends everything after it
|
||||||
|
// verbatim on the wire (including embedded slashes in model IDs).
|
||||||
|
if lowered_prefix == "local" {
|
||||||
|
return Cow::Borrowed(&model[pos + 1..]);
|
||||||
|
}
|
||||||
|
|
||||||
Cow::Borrowed(model)
|
Cow::Borrowed(model)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2730,4 +2739,24 @@ mod tests {
|
||||||
assert_eq!(super::strip_routing_prefix("kimi-k2.5"), "kimi-k2.5"); // no prefix, unchanged
|
assert_eq!(super::strip_routing_prefix("kimi-k2.5"), "kimi-k2.5"); // no prefix, unchanged
|
||||||
assert_eq!(super::strip_routing_prefix("kimi/kimi-k1.5"), "kimi-k1.5");
|
assert_eq!(super::strip_routing_prefix("kimi/kimi-k1.5"), "kimi-k1.5");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_routing_prefix_preserves_embedded_slash_with_local_prefix() {
|
||||||
|
// "local/" escape hatch: strips just the prefix, preserves everything after it verbatim
|
||||||
|
assert_eq!(
|
||||||
|
super::strip_routing_prefix("local/Qwen/Qwen3.6-27B-FP8"),
|
||||||
|
"Qwen/Qwen3.6-27B-FP8"
|
||||||
|
);
|
||||||
|
assert_eq!(super::strip_routing_prefix("local/mistralai/Mistral-Large-3"), "mistralai/Mistral-Large-3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wire_model_preserves_embedded_slash_with_local_prefix() {
|
||||||
|
let config = OpenAiCompatConfig::openai();
|
||||||
|
// "local/" prefix: strips just "local/", sends rest verbatim on the wire
|
||||||
|
assert_eq!(
|
||||||
|
super::wire_model_for_base_url("local/Qwen/Qwen3.6-27B-FP8", config.clone(), ""),
|
||||||
|
"Qwen/Qwen3.6-27B-FP8"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1943,7 +1943,10 @@ fn validate_model_syntax(model: &str) -> Result<(), String> {
|
||||||
}
|
}
|
||||||
// Check provider/model format: provider_id/model_id
|
// Check provider/model format: provider_id/model_id
|
||||||
let parts: Vec<&str> = trimmed.split('/').collect();
|
let parts: Vec<&str> = trimmed.split('/').collect();
|
||||||
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
|
if (parts.len() != 2 && !trimmed.starts_with("local/"))
|
||||||
|
|| parts[0].is_empty()
|
||||||
|
|| parts[1].is_empty()
|
||||||
|
{
|
||||||
// #154: hint if the model looks like it belongs to a different provider
|
// #154: hint if the model looks like it belongs to a different provider
|
||||||
let mut err_msg = format!(
|
let mut err_msg = format!(
|
||||||
"invalid model syntax: '{}'.\nExpected provider/model (e.g., anthropic/claude-opus-4-7)",
|
"invalid model syntax: '{}'.\nExpected provider/model (e.g., anthropic/claude-opus-4-7)",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue