Skip to content

Commit 5402076

Browse files
committed
fix(inference): prepend publisher prefix for Vertex non-Anthropic models
Vertex AI's OpenAI-compatible endpoint requires the request body's model field to carry a publisher prefix (e.g. google/gemini-2.5-flash), but validate_vertex_model_id rejects slash as a path-traversal guard. This created a deadlock: bare model IDs pass validation but are rejected by Vertex with HTTP 400 "Malformed publisher model"; prefixed IDs are rejected at configuration time. Fix: in resolve_vertex_ai_route, compute body_model_id for non-Anthropic routes by prepending the publisher from infer_vertex_publisher() or the explicit VERTEX_AI_PUBLISHER config value. The bare model_id still goes through the path-traversal validator unchanged. Anthropic rawPredict routes encode the model in the URL path, not the body, and are unaffected. Both the project/region path and the base-URL-override path apply the prefix. For unrecognised models with no explicit publisher the bare ID is forwarded unchanged; Vertex's 400 is the correct observable signal in that case. Add an integration test in openshell-router that spins up a mock Vertex endpoint accepting only the publisher-prefixed form and rejecting the bare model name, verifying the body rewrite produces the required format. Closes #2351 Signed-off-by: politerealism <burdcat17@gmail.com>
1 parent 0f8fad2 commit 5402076

2 files changed

Lines changed: 173 additions & 4 deletions

File tree

crates/openshell-router/src/backend.rs

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -982,8 +982,8 @@ fn is_vertex_anthropic_rawpredict_route(route: &ResolvedRoute) -> bool {
982982
mod tests {
983983
use super::{
984984
ValidationFailure, ValidationFailureKind, build_backend_url, build_provider_url,
985-
parse_bedrock_invocation_path, prepare_backend_request, rewrite_bedrock_path,
986-
route_is_bedrock, verify_backend_endpoint,
985+
parse_bedrock_invocation_path, prepare_backend_request, proxy_to_backend,
986+
rewrite_bedrock_path, route_is_bedrock, verify_backend_endpoint,
987987
};
988988
use crate::RouterError;
989989
use crate::config::{DEFAULT_ROUTE_TIMEOUT, ResolvedRoute};
@@ -2597,6 +2597,90 @@ mod tests {
25972597
);
25982598
}
25992599

2600+
/// Vertex AI's OpenAI-compatible endpoint requires the body `model` field to
2601+
/// carry a publisher prefix (e.g. `google/gemini-2.5-flash`). This test
2602+
/// simulates the fix: `resolve_vertex_ai_route` sets `route.model` to the
2603+
/// prefixed form, and the body rewrite here forwards that value to Vertex.
2604+
///
2605+
/// The mock server only accepts the prefixed form — matching Vertex's
2606+
/// behaviour — and returns 400 "Malformed publisher model" for the bare name.
2607+
#[tokio::test]
2608+
async fn vertex_openai_compat_rewrites_body_model_to_publisher_prefixed_form() {
2609+
let mock_server = MockServer::start().await;
2610+
2611+
// Simulate Vertex accepting only the publisher-prefixed model name.
2612+
Mock::given(method("POST"))
2613+
.and(path("/chat/completions"))
2614+
.and(body_partial_json(
2615+
serde_json::json!({"model": "google/gemini-2.5-flash"}),
2616+
))
2617+
.respond_with(
2618+
ResponseTemplate::new(200).set_body_json(
2619+
serde_json::json!({"choices": [{"message": {"content": "hi"}}]}),
2620+
),
2621+
)
2622+
.expect(1)
2623+
.mount(&mock_server)
2624+
.await;
2625+
2626+
// Simulate Vertex rejecting the bare model name — the pre-fix failure.
2627+
Mock::given(method("POST"))
2628+
.and(path("/chat/completions"))
2629+
.and(body_partial_json(
2630+
serde_json::json!({"model": "gemini-2.5-flash"}),
2631+
))
2632+
.respond_with(ResponseTemplate::new(400).set_body_json(
2633+
serde_json::json!({"error": {"message": "Malformed publisher model"}}),
2634+
))
2635+
.expect(0) // must never be reached after the fix
2636+
.mount(&mock_server)
2637+
.await;
2638+
2639+
// Route as produced by resolve_vertex_ai_route after the fix:
2640+
// route.model carries the publisher prefix.
2641+
let route = ResolvedRoute {
2642+
name: "vertex-gemini".to_string(),
2643+
endpoint: mock_server.uri(),
2644+
model: "google/gemini-2.5-flash".to_string(),
2645+
api_key: "ya29.token".to_string(),
2646+
protocols: vec!["openai_chat_completions".to_string()],
2647+
auth: AuthHeader::Bearer,
2648+
default_headers: vec![],
2649+
passthrough_headers: vec![],
2650+
timeout: DEFAULT_ROUTE_TIMEOUT,
2651+
model_in_path: false,
2652+
request_path_override: Some("/chat/completions".to_string()),
2653+
};
2654+
2655+
// The client sends the bare model name; the body rewrite must replace it
2656+
// with route.model (the publisher-prefixed form) before forwarding.
2657+
let client_body = serde_json::to_vec(&serde_json::json!({
2658+
"model": "gemini-2.5-flash",
2659+
"messages": [{"role": "user", "content": "hello"}]
2660+
}))
2661+
.unwrap();
2662+
2663+
let client = reqwest::Client::new();
2664+
let result = proxy_to_backend(
2665+
&client,
2666+
&route,
2667+
"openai_chat_completions",
2668+
"POST",
2669+
"/chat/completions",
2670+
vec![("content-type".to_string(), "application/json".to_string())],
2671+
bytes::Bytes::from(client_body),
2672+
)
2673+
.await
2674+
.expect("proxy should succeed");
2675+
2676+
assert_eq!(
2677+
result.status, 200,
2678+
"Vertex mock must accept the publisher-prefixed model; \
2679+
got {}: body rewrite did not apply the prefix",
2680+
result.status
2681+
);
2682+
}
2683+
26002684
/// Defense-in-depth: a Bedrock route receiving a non-Bedrock path
26012685
/// is rejected rather than forwarded. The L7 pattern detector
26022686
/// upstream of the router should never produce this combination,

crates/openshell-server/src/inference.rs

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,25 @@ fn resolve_vertex_ai_route(
698698
|p| p.eq_ignore_ascii_case("anthropic"),
699699
);
700700

701+
// Vertex's OpenAI-compatible endpoint requires the request body's `model`
702+
// field to carry a publisher prefix: `<publisher>/<model_id>` (e.g.
703+
// `google/gemini-2.5-flash`). The publisher is taken from the explicit
704+
// VERTEX_AI_PUBLISHER config value (when set to a non-Anthropic value) or
705+
// inferred from the model name. For unrecognised models with no explicit
706+
// publisher, the bare model ID is forwarded unchanged; Vertex will return
707+
// a 400 in that case, which is the correct observable signal to the caller.
708+
// Anthropic rawPredict routes encode the model in the URL path, not the
709+
// body, so they are unaffected.
710+
let body_model_id: String = if !is_anthropic {
711+
let publisher = explicit_publisher.or_else(|| infer_vertex_publisher(model_id));
712+
match publisher {
713+
Some(p) => format!("{p}/{model_id}"),
714+
None => model_id.to_string(),
715+
}
716+
} else {
717+
model_id.to_string()
718+
};
719+
701720
// Escape hatch: caller-supplied full base URL still uses the model-derived
702721
// protocol and path contract, but only for the OpenAI-compatible Vertex surface.
703722
// Anthropic-on-Vertex needs model-path shaping and body adaptation that a fully
@@ -721,7 +740,7 @@ fn resolve_vertex_ai_route(
721740
return Ok(build_vertex_route(
722741
route_name,
723742
base_url,
724-
model_id,
743+
&body_model_id,
725744
api_key,
726745
vec!["openai_chat_completions".to_string()],
727746
profile,
@@ -772,7 +791,7 @@ fn resolve_vertex_ai_route(
772791
Ok(build_vertex_route(
773792
route_name,
774793
endpoint,
775-
model_id,
794+
&body_model_id,
776795
api_key,
777796
protocols,
778797
profile,
@@ -2613,6 +2632,11 @@ mod tests {
26132632
.contains(&"anthropic_messages".to_string()),
26142633
"must not have anthropic_messages protocol for gemini"
26152634
);
2635+
// Vertex OpenAI-compatible endpoint requires publisher prefix in body model field
2636+
assert_eq!(
2637+
resolved.route.model, "google/gemini-pro",
2638+
"Vertex non-Anthropic body model must carry publisher prefix"
2639+
);
26162640
}
26172641

26182642
#[test]
@@ -2651,6 +2675,67 @@ mod tests {
26512675
.contains(&"anthropic_messages".to_string()),
26522676
"must not have anthropic_messages for unknown model"
26532677
);
2678+
// Unknown models have no inferred publisher; body model ID is unchanged
2679+
assert_eq!(resolved.route.model, "some-unknown-model");
2680+
}
2681+
2682+
#[test]
2683+
fn resolve_vertex_ai_route_non_anthropic_publisher_prefix_gemini() {
2684+
// Gemini models must get `google/<model>` in route.model so the
2685+
// OpenAI-compatible Vertex endpoint accepts the request body.
2686+
let config =
2687+
std::iter::once(("VERTEX_AI_PROJECT_ID".to_string(), "proj-abc".to_string())).collect();
2688+
let provider = make_vertex_provider_with_config("vertex-gemini-flash", config);
2689+
2690+
let resolved =
2691+
resolve_provider_route(&provider, "gemini-2.5-flash").expect("should resolve");
2692+
2693+
assert_eq!(resolved.route.model, "google/gemini-2.5-flash");
2694+
}
2695+
2696+
#[test]
2697+
fn resolve_vertex_ai_route_non_anthropic_publisher_prefix_llama() {
2698+
let config =
2699+
std::iter::once(("VERTEX_AI_PROJECT_ID".to_string(), "proj-abc".to_string())).collect();
2700+
let provider = make_vertex_provider_with_config("vertex-llama", config);
2701+
2702+
let resolved = resolve_provider_route(&provider, "llama-3-70b").expect("should resolve");
2703+
2704+
assert_eq!(resolved.route.model, "meta/llama-3-70b");
2705+
}
2706+
2707+
#[test]
2708+
fn resolve_vertex_ai_route_explicit_publisher_overrides_inference() {
2709+
// VERTEX_AI_PUBLISHER takes precedence over infer_vertex_publisher for
2710+
// unknown model names.
2711+
let config = [
2712+
("VERTEX_AI_PROJECT_ID".to_string(), "proj-abc".to_string()),
2713+
("VERTEX_AI_PUBLISHER".to_string(), "acme".to_string()),
2714+
]
2715+
.into_iter()
2716+
.collect();
2717+
let provider = make_vertex_provider_with_config("vertex-explicit", config);
2718+
2719+
let resolved =
2720+
resolve_provider_route(&provider, "some-acme-model").expect("should resolve");
2721+
2722+
assert_eq!(resolved.route.model, "acme/some-acme-model");
2723+
}
2724+
2725+
#[test]
2726+
fn resolve_vertex_ai_route_base_url_override_gemini_gets_publisher_prefix() {
2727+
// Publisher prefix must also be applied when a base URL override is used.
2728+
let config = std::iter::once((
2729+
"VERTEX_AI_BASE_URL".to_string(),
2730+
"https://us-central1-aiplatform.googleapis.com/v1beta1/projects/my-project/locations/us-central1/endpoints/openapi".to_string(),
2731+
))
2732+
.collect();
2733+
let provider = make_vertex_provider_with_config("vertex-base-url-gemini", config);
2734+
2735+
let resolved =
2736+
resolve_provider_route(&provider, "gemini-2.0-flash").expect("should resolve");
2737+
2738+
assert_eq!(resolved.route.model, "google/gemini-2.0-flash");
26542739
}
26552740

26562741
#[test]

0 commit comments

Comments
 (0)