fix(ai-serving): route provider-aware normalization through root seams

Move Antigravity schema-preservation policy into the format crate and expose provider-aware Chat and Responses builders through the existing gateway root seam. Preserve legacy conversion and scoped Responses history behavior without weakening architecture tests.

Validated: 61 standalone architecture tests, 947 format tests, 527 transport tests, 2 actual-source planner tests, and gateway cargo check.
This commit is contained in:
wangpengxiang
2026-09-17 15:38:27 +08:00
parent 5a55116b62
commit 4124749a7d
6 changed files with 248 additions and 41 deletions
+2
View File
@@ -224,9 +224,11 @@ pub use crate::formats::{
standard_normalize::{
build_cross_format_openai_chat_request_body,
build_cross_format_openai_chat_request_body_with_model_directives,
build_cross_format_openai_chat_request_body_with_provider_context,
build_cross_format_openai_responses_request_body,
build_cross_format_openai_responses_request_body_with_model_directives,
build_cross_format_openai_responses_request_body_with_model_directives_and_history_scope,
build_cross_format_openai_responses_request_body_with_provider_context,
build_local_openai_chat_request_body,
build_local_openai_chat_request_body_with_model_directives,
build_local_openai_responses_request_body,
@@ -23,6 +23,17 @@ use crate::formats::shared::standard_normalize::{
is_claude_messages_shaped_body_on_openai_chat_endpoint,
};
/// Tool schema preservation is a format-conversion policy, shared by the
/// standard matrix and provider-aware Chat/Responses entry points.
pub(super) fn preserves_gemini_tool_schemas(
provider_type: &str,
provider_api_format: &str,
) -> bool {
provider_type.trim().eq_ignore_ascii_case("antigravity")
&& aether_ai_formats::normalize_api_format_alias(provider_api_format)
== "gemini:generate_content"
}
#[allow(clippy::too_many_arguments)]
pub fn build_standard_request_body(
body_json: &Value,
@@ -132,9 +143,7 @@ pub fn build_standard_request_body_with_model_directives_and_request_headers_and
.with_request_path(request_path)
.with_upstream_stream(upstream_is_stream);
format_context.preserve_gemini_tool_schemas =
provider_type.trim().eq_ignore_ascii_case("antigravity")
&& aether_ai_formats::normalize_api_format_alias(provider_api_format)
== "gemini:generate_content";
preserves_gemini_tool_schemas(provider_type, provider_api_format);
if let Some(history_scope) = user_api_key_id {
format_context = format_context.with_history_scope(history_scope);
}
@@ -167,6 +167,40 @@ pub fn build_cross_format_openai_chat_request_body(
)
}
/// Provider-aware entry point for gateway Chat planners. Keep private schema
/// conversion policy in the format crate while retaining legacy behavior elsewhere.
pub fn build_cross_format_openai_chat_request_body_with_provider_context(
body_json: &Value,
mapped_model: &str,
provider_type: &str,
provider_api_format: &str,
upstream_is_stream: bool,
enable_model_directives: bool,
history_scope: Option<&str>,
) -> Option<Value> {
if super::standard_matrix::preserves_gemini_tool_schemas(provider_type, provider_api_format) {
return super::standard_matrix::build_standard_request_body_with_model_directives(
body_json,
"openai:chat",
mapped_model,
provider_type,
provider_api_format,
"",
upstream_is_stream,
None,
history_scope,
enable_model_directives,
);
}
build_cross_format_openai_chat_request_body_with_model_directives(
body_json,
mapped_model,
provider_api_format,
upstream_is_stream,
enable_model_directives,
)
}
pub fn build_cross_format_openai_chat_request_body_with_model_directives(
body_json: &Value,
mapped_model: &str,
@@ -342,6 +376,44 @@ pub fn build_cross_format_openai_responses_request_body_with_model_directives(
)
}
/// Provider-aware Responses entry point; preserve history scoping and defer
/// private tool schema lowering without exposing provider policy to the gateway.
#[allow(clippy::too_many_arguments)]
pub fn build_cross_format_openai_responses_request_body_with_provider_context(
body_json: &Value,
mapped_model: &str,
client_api_format: &str,
provider_type: &str,
provider_api_format: &str,
upstream_is_stream: bool,
enable_model_directives: bool,
history_scope: Option<&str>,
) -> Option<Value> {
if super::standard_matrix::preserves_gemini_tool_schemas(provider_type, provider_api_format) {
return super::standard_matrix::build_standard_request_body_with_model_directives(
body_json,
client_api_format,
mapped_model,
provider_type,
provider_api_format,
"",
upstream_is_stream,
None,
history_scope,
enable_model_directives,
);
}
build_cross_format_openai_responses_request_body_with_model_directives_and_history_scope(
body_json,
mapped_model,
client_api_format,
provider_api_format,
upstream_is_stream,
enable_model_directives,
history_scope,
)
}
pub fn build_cross_format_openai_responses_request_body_with_model_directives_and_history_scope(
body_json: &Value,
mapped_model: &str,
@@ -433,6 +505,145 @@ mod tests {
};
use serde_json::{json, Value};
#[test]
fn provider_context_builders_preserve_private_schemas_and_legacy_routes() {
use crate::api::{
build_cross_format_openai_chat_request_body_with_provider_context as chat,
build_cross_format_openai_responses_request_body_with_provider_context as responses,
};
let schema = json!({"type":"object", "properties":{"mode":{"const":"fast"}}});
let chat_input = json!({"model":"client", "messages":[{"role":"user","content":"hi"}],
"tools":[{"type":"function","function":{"name":"probe","parameters":schema}}]});
let responses_input = json!({"model":"client", "input":"hi",
"tools":[{"type":"function","name":"probe","parameters":schema}]});
for provider in ["antigravity", " AnTiGrAvItY ", "gemini", "openai"] {
for target in [
"gemini:generate_content",
"claude:messages",
"openai:responses",
] {
for stream in [false, true] {
for directives in [false, true] {
for input in [&chat_input, &responses_input] {
let actual = chat(
input,
"claude-test",
provider,
target,
stream,
directives,
Some("seam-test"),
);
let expected =
if super::super::standard_matrix::preserves_gemini_tool_schemas(
provider, target,
) {
super::super::standard_matrix::build_standard_request_body_with_model_directives(
input, "openai:chat", "claude-test", provider, target, "", stream, None, Some("seam-test"), directives)
} else {
super::build_cross_format_openai_chat_request_body_with_model_directives(
input, "claude-test", target, stream, directives)
};
assert!(actual.is_some(), "chat {provider} {target}");
assert_eq!(actual, expected);
if target == "gemini:generate_content" {
assert_eq!(
actual.unwrap()["tools"][0]["functionDeclarations"][0]
["parameters"]
== schema,
provider.trim().eq_ignore_ascii_case("antigravity")
);
}
}
let actual = responses(
&responses_input,
"claude-test",
"openai:responses",
provider,
target,
stream,
directives,
Some("seam-test"),
);
let expected =
if super::super::standard_matrix::preserves_gemini_tool_schemas(
provider, target,
) {
super::super::standard_matrix::build_standard_request_body_with_model_directives(
&responses_input, "openai:responses", "claude-test", provider, target, "", stream, None, Some("seam-test"), directives)
} else {
super::build_cross_format_openai_responses_request_body_with_model_directives_and_history_scope(
&responses_input, "claude-test", "openai:responses", target, stream, directives, Some("seam-test"))
};
// Same-format Responses uses the local builder, not this cross-format API.
assert_eq!(
actual.is_some(),
target != "openai:responses",
"responses {provider} {target}"
);
assert_eq!(actual, expected);
}
}
}
}
}
#[test]
fn provider_context_builders_keep_scoped_responses_history() {
use crate::api::{
build_cross_format_openai_chat_request_body_with_provider_context as chat,
build_cross_format_openai_responses_request_body_with_provider_context as responses,
record_converted_response_history,
};
let response_id = "resp_provider_context_seam_history";
let scope = "provider-context-seam-history";
record_converted_response_history(&json!({
"needs_conversion":true, "client_api_format":"openai:responses",
"provider_api_format":"openai:chat", "api_key_id":scope,
"original_request_body":{"model":"client", "input":"first"}
}), &json!({"id":response_id, "status":"completed", "output":[{
"type":"message", "role":"assistant", "content":[{"type":"output_text", "text":"remembered"}]
}]})).expect("seed scoped history");
let input = json!({"model":"client", "previous_response_id":response_id, "input":"second"});
for use_chat in [false, true] {
let build = |history_scope| {
if use_chat {
chat(
&input,
"claude-test",
"antigravity",
"gemini:generate_content",
true,
false,
history_scope,
)
} else {
responses(
&input,
"claude-test",
"openai:responses",
"antigravity",
"gemini:generate_content",
true,
false,
history_scope,
)
}
};
if use_chat {
// The legacy Chat alternate-shape path does not hydrate scoped
// Responses history. Preserve that behavior during this refactor.
assert!(build(Some(scope)).is_none());
continue;
}
let output = build(Some(scope)).expect("expand scoped history");
assert_eq!(output["contents"][0]["parts"][0]["text"], "first");
assert_eq!(output["contents"][1]["parts"][0]["text"], "remembered");
assert_eq!(output["contents"][2]["parts"][0]["text"], "second");
assert!(build(Some("different-seam-key")).is_none());
}
}
fn object_keys(value: &Value) -> Vec<&str> {
value
.as_object()