Skip to content

Commit db606c1

Browse files
committed
fix(router): match model hint against route model ID in multi-model selection
When multiple routes share the same protocol (e.g. openai_responses), select_route() only matched the model hint from the request body against route aliases (names). If an agent sent the actual model ID (e.g. "gpt-5.4") instead of the alias ("openai-codex"), the alias lookup missed and the router fell back to the first protocol-compatible route, which could be a completely different provider. Add a second lookup pass that matches the hint against route.model before falling back to blind protocol selection. Priority order: 1. Alias match (route name == hint) — existing behavior 2. Model ID match (route model == hint) — new 3. First protocol-compatible route — existing fallback Also add strip_version_prefix field to InferenceApiPattern so the codex pattern (/v1/codex/*) can strip the /v1 proxy artifact before forwarding, allowing backends whose base URL omits /v1 to receive the correct path. Signed-off-by: Lyle Hopkins <lyle@cosmicnetworks.com>
1 parent 099f2e5 commit db606c1

3 files changed

Lines changed: 68 additions & 5 deletions

File tree

crates/openshell-router/src/lib.rs

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,25 +34,37 @@ pub struct Router {
3434
client: reqwest::Client,
3535
}
3636

37-
/// Select a route from `candidates` using alias-first, protocol-fallback strategy.
37+
/// Select a route from `candidates` using alias-first, model-second,
38+
/// protocol-fallback strategy.
3839
///
39-
/// 1. If `model_hint` is provided, find a candidate whose `name` matches the hint
40-
/// **and** whose protocols include `protocol`.
41-
/// 2. Otherwise, return the first candidate whose protocols contain `protocol`.
40+
/// 1. If `model_hint` is provided, find a candidate whose `name` (alias)
41+
/// matches the hint **and** whose protocols include `protocol`.
42+
/// 2. Else if `model_hint` is provided, find a candidate whose `model`
43+
/// matches the hint **and** whose protocols include `protocol`.
44+
/// 3. Otherwise, return the first candidate whose protocols contain `protocol`.
4245
fn select_route<'a>(
4346
candidates: &'a [ResolvedRoute],
4447
protocol: &str,
4548
model_hint: Option<&str>,
4649
) -> Option<&'a ResolvedRoute> {
4750
if let Some(hint) = model_hint {
4851
let normalized_hint = hint.trim().to_ascii_lowercase();
52+
// 1. Alias match (route name == model hint).
4953
if let Some(r) = candidates.iter().find(|r| {
5054
r.name.trim().to_ascii_lowercase() == normalized_hint
5155
&& r.protocols.iter().any(|p| p == protocol)
5256
}) {
5357
return Some(r);
5458
}
59+
// 2. Model ID match (route model == model hint).
60+
if let Some(r) = candidates.iter().find(|r| {
61+
r.model.trim().to_ascii_lowercase() == normalized_hint
62+
&& r.protocols.iter().any(|p| p == protocol)
63+
}) {
64+
return Some(r);
65+
}
5566
}
67+
// 3. First protocol-compatible route.
5668
candidates
5769
.iter()
5870
.find(|r| r.protocols.iter().any(|p| p == protocol))
@@ -273,4 +285,32 @@ mod tests {
273285
let r = select_route(&routes, "openai_chat_completions", Some("my-gpt")).unwrap();
274286
assert_eq!(r.name, "My-GPT");
275287
}
288+
289+
#[test]
290+
fn select_route_model_id_match() {
291+
// When the hint doesn't match any alias but does match a route's model,
292+
// that route is selected.
293+
let routes = vec![
294+
make_route("ollama-local", vec!["openai_responses"]),
295+
make_route("openai-codex", vec!["openai_responses"]),
296+
];
297+
// openai-codex has model "openai-codex-model"; ollama-local has "ollama-local-model".
298+
// Hint "openai-codex-model" doesn't match any alias, but matches the model field.
299+
let r = select_route(&routes, "openai_responses", Some("openai-codex-model")).unwrap();
300+
assert_eq!(r.name, "openai-codex");
301+
}
302+
303+
#[test]
304+
fn select_route_alias_beats_model_id() {
305+
// Alias match takes priority over model ID match.
306+
let mut routes = vec![
307+
make_route("ollama-local", vec!["openai_chat_completions"]),
308+
make_route("openai-prod", vec!["openai_chat_completions"]),
309+
];
310+
// Give ollama-local a model that matches the second route's name.
311+
routes[0].model = "openai-prod".to_string();
312+
let r = select_route(&routes, "openai_chat_completions", Some("openai-prod")).unwrap();
313+
// Alias match wins: route named "openai-prod", not the one with model="openai-prod".
314+
assert_eq!(r.name, "openai-prod");
315+
}
276316
}

crates/openshell-sandbox/src/l7/inference.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ pub struct InferenceApiPattern {
1414
pub path_glob: String,
1515
pub protocol: String,
1616
pub kind: String,
17+
/// When true, the `/v1` version prefix is stripped from the request path
18+
/// before forwarding to the backend. This is needed for endpoints whose
19+
/// base URL does not include `/v1` (e.g. `chatgpt.com/backend-api`).
20+
pub strip_version_prefix: bool,
1721
}
1822

1923
/// Default patterns for known inference APIs (`OpenAI`, Anthropic).
@@ -24,60 +28,70 @@ pub fn default_patterns() -> Vec<InferenceApiPattern> {
2428
path_glob: "/v1/chat/completions".to_string(),
2529
protocol: "openai_chat_completions".to_string(),
2630
kind: "chat_completion".to_string(),
31+
strip_version_prefix: false,
2732
},
2833
InferenceApiPattern {
2934
method: "POST".to_string(),
3035
path_glob: "/v1/completions".to_string(),
3136
protocol: "openai_completions".to_string(),
3237
kind: "completion".to_string(),
38+
strip_version_prefix: false,
3339
},
3440
InferenceApiPattern {
3541
method: "POST".to_string(),
3642
path_glob: "/v1/responses".to_string(),
3743
protocol: "openai_responses".to_string(),
3844
kind: "responses".to_string(),
45+
strip_version_prefix: false,
3946
},
4047
InferenceApiPattern {
4148
method: "POST".to_string(),
4249
path_glob: "/v1/codex/*".to_string(),
4350
protocol: "openai_responses".to_string(),
4451
kind: "codex_responses".to_string(),
52+
strip_version_prefix: true,
4553
},
4654
InferenceApiPattern {
4755
method: "POST".to_string(),
4856
path_glob: "/v1/messages".to_string(),
4957
protocol: "anthropic_messages".to_string(),
5058
kind: "messages".to_string(),
59+
strip_version_prefix: false,
5160
},
5261
InferenceApiPattern {
5362
method: "POST".to_string(),
5463
path_glob: "/api/chat".to_string(),
5564
protocol: "ollama_chat".to_string(),
5665
kind: "ollama_chat".to_string(),
66+
strip_version_prefix: false,
5767
},
5868
InferenceApiPattern {
5969
method: "GET".to_string(),
6070
path_glob: "/api/tags".to_string(),
6171
protocol: "ollama_model_discovery".to_string(),
6272
kind: "ollama_tags".to_string(),
73+
strip_version_prefix: false,
6374
},
6475
InferenceApiPattern {
6576
method: "POST".to_string(),
6677
path_glob: "/api/show".to_string(),
6778
protocol: "ollama_model_discovery".to_string(),
6879
kind: "ollama_show".to_string(),
80+
strip_version_prefix: false,
6981
},
7082
InferenceApiPattern {
7183
method: "GET".to_string(),
7284
path_glob: "/v1/models".to_string(),
7385
protocol: "model_discovery".to_string(),
7486
kind: "models_list".to_string(),
87+
strip_version_prefix: false,
7588
},
7689
InferenceApiPattern {
7790
method: "GET".to_string(),
7891
path_glob: "/v1/models/*".to_string(),
7992
protocol: "model_discovery".to_string(),
8093
kind: "models_get".to_string(),
94+
strip_version_prefix: false,
8195
},
8296
]
8397
}

crates/openshell-sandbox/src/proxy.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1032,12 +1032,21 @@ async fn route_inference_request(
10321032
.ok()
10331033
.and_then(|v| v.get("model")?.as_str().map(String::from));
10341034

1035+
// For patterns like codex that set strip_version_prefix, remove the
1036+
// /v1 proxy artifact from the path so the backend endpoint base URL
1037+
// alone controls the path prefix.
1038+
let routed_path = if pattern.strip_version_prefix && normalized_path.starts_with("/v1/") {
1039+
&normalized_path[3..]
1040+
} else {
1041+
&normalized_path
1042+
};
1043+
10351044
match ctx
10361045
.router
10371046
.proxy_with_candidates_streaming(
10381047
&pattern.protocol,
10391048
&request.method,
1040-
&normalized_path,
1049+
routed_path,
10411050
filtered_headers,
10421051
bytes::Bytes::from(request.body.clone()),
10431052
&routes,

0 commit comments

Comments
 (0)