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:
Petter Reinholdtsen 2026-05-27 16:54:54 +02:00 committed by Petter Reinholdtsen
parent 87b7e74770
commit 95bf18e5cc
2 changed files with 35 additions and 3 deletions

View File

@ -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.
/// 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)]
fn strip_routing_prefix(model: &str) -> &str {
if let Some(pos) = model.find('/') {
let prefix = &model[..pos];
// Only strip if the prefix before "/" is a known routing prefix,
// 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..]
} else {
model
@ -965,6 +968,12 @@ fn wire_model_for_base_url<'a>(
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)
}
@ -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/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"
);
}
}

View File

@ -1943,7 +1943,10 @@ fn validate_model_syntax(model: &str) -> Result<(), String> {
}
// Check provider/model format: provider_id/model_id
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
let mut err_msg = format!(
"invalid model syntax: '{}'.\nExpected provider/model (e.g., anthropic/claude-opus-4-7)",