fix(gateway): support claude_code cross-format Claude messages

This commit is contained in:
zhefox
2026-08-05 13:57:20 +08:00
parent 1aab31a148
commit 4d67569873
13 changed files with 349 additions and 46 deletions
@@ -8,6 +8,7 @@ use crate::antigravity::is_antigravity_provider_transport;
use crate::auth::{
resolve_local_gemini_auth, resolve_local_openai_bearer_auth, resolve_local_standard_auth,
};
use crate::claude_code::local_claude_code_transport_unsupported_reason_with_network;
use crate::kiro::{
is_kiro_claude_messages_transport, local_kiro_request_transport_unsupported_reason_with_network,
};
@@ -125,6 +126,18 @@ pub fn request_conversion_transport_unsupported_reason(
transport: &GatewayProviderTransportSnapshot,
_kind: RequestConversionKind,
) -> Option<&'static str> {
if transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("claude_code")
&& normalize_api_format_alias(&transport.endpoint.api_format) == "claude:messages"
{
return local_claude_code_transport_unsupported_reason_with_network(
transport,
"claude:messages",
);
}
if is_kiro_claude_messages_transport(transport, &transport.endpoint.api_format) {
return local_kiro_request_transport_unsupported_reason_with_network(transport);
}
@@ -720,6 +733,28 @@ mod tests {
));
}
#[test]
fn claude_code_messages_transport_supports_openai_conversions() {
let transport =
transport_snapshot("claude_code", "claude:messages", "oauth", true, None);
for client_api_format in ["openai:chat", "openai:responses"] {
assert!(request_pair_allowed_for_transport(
&transport,
client_api_format,
"claude:messages"
));
assert_eq!(
candidate_transport_pair_skip_reason(&transport, client_api_format),
None
);
}
assert!(request_conversion_transport_supported(
&transport,
RequestConversionKind::ToClaudeStandard
));
}
#[test]
fn windsurf_openai_chat_anchor_supports_cross_format_conversion_via_cascade() {
let mut transport = transport_snapshot("windsurf", "openai:chat", "oauth", true, None);
@@ -278,8 +278,8 @@ const WINDSURF_RUNTIME_POLICY: ProviderRuntimePolicy = ProviderRuntimePolicy {
const CLAUDE_CODE_FIXED_PROVIDER_TEMPLATE: FixedProviderTemplate = FixedProviderTemplate {
provider_type: "claude_code",
version: 1,
base_url: "https://api.anthropic.com",
version: 2,
base_url: "https://api.anthropic.com/v1",
endpoints: &[FixedProviderEndpointTemplate {
item_key: "claude:messages",
api_format: "claude:messages",
@@ -638,6 +638,20 @@ mod tests {
ADMIN_PROVIDER_OAUTH_TEMPLATE_TYPES,
};
#[test]
fn claude_code_fixed_provider_uses_messages_api_root_and_conversion_default() {
let template = fixed_provider_template("claude_code")
.expect("claude code template should exist");
assert_eq!(template.base_url, "https://api.anthropic.com/v1");
assert_eq!(template.version, 2);
assert!(template.runtime_policy.enable_format_conversion_by_default);
assert!(!template.runtime_policy.supports_local_same_format_transport);
assert!(!provider_type_supports_local_same_format_transport(
"claude_code"
));
}
#[test]
fn codex_fixed_provider_template_includes_codex_companion_endpoints() {
let template = fixed_provider_template("codex").expect("codex template should exist");
@@ -1,5 +1,6 @@
use serde_json::{Map, Value};
use crate::claude_code::sanitize_claude_code_request_body;
use crate::snapshot::GatewayProviderTransportSnapshot;
use crate::vertex::is_vertex_transport_context;
@@ -32,6 +33,15 @@ pub fn apply_transport_request_body_semantics(
provider_api_format: &str,
) -> Result<(), TransportRequestBodySemanticsError> {
let provider_api_format = aether_ai_formats::normalize_api_format_alias(provider_api_format);
if provider_api_format == "claude:messages"
&& transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("claude_code")
{
sanitize_claude_code_request_body(provider_request_body);
}
if provider_api_format == "gemini:embedding" && is_vertex_transport_context(transport) {
apply_vertex_gemini_embedding_body_semantics(provider_request_body)?;
}
@@ -334,6 +344,29 @@ mod tests {
assert_eq!(body["model"], "gemini-embedding-2");
}
#[test]
fn claude_code_messages_body_applies_provider_sanitizer_after_conversion() {
let transport = sample_transport("claude_code", "https://api.anthropic.com/v1");
let mut body = json!({
"model": "claude-opus-4-6",
"messages": [{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "unsigned"},
{"type": "text", "text": "answer"}
]
}]
});
apply_transport_request_body_semantics(&mut body, &transport, "claude:messages")
.expect("Claude Code body semantics should apply");
assert_eq!(
body["messages"][0]["content"],
json!([{"type": "text", "text": "answer"}])
);
}
#[test]
fn vertex_gemini_embedding_batch_body_uses_predict_instances() {
let transport = sample_transport("vertex_ai", "https://aiplatform.googleapis.com");
@@ -1456,6 +1456,46 @@ mod tests {
);
}
#[test]
fn claude_code_official_api_root_builds_documented_messages_urls() {
let mut transport = sample_transport(
"claude_code",
"claude:messages",
"https://api.anthropic.com/v1",
None,
);
transport.endpoint.config = Some(json!({
"anthropic": {"supported_operations": ["messages", "count_tokens"]}
}));
for (operation, expected_url) in [
(
ApiOperation::ClaudeMessagesCreate,
"https://api.anthropic.com/v1/messages",
),
(
ApiOperation::ClaudeCountTokens,
"https://api.anthropic.com/v1/messages/count_tokens",
),
] {
assert_eq!(
build_transport_request_url(
&transport,
TransportRequestUrlParams {
provider_api_format: "claude:messages",
mapped_model: Some("claude-opus-4-6"),
upstream_is_stream: false,
request_query: None,
kiro_api_region: None,
api_operation: Some(operation),
},
)
.as_deref(),
Some(expected_url)
);
}
}
#[test]
fn count_tokens_config_fields_fall_back_from_endpoint_to_provider() {
let mut transport = sample_transport(
@@ -6,6 +6,7 @@ use crate::auth::{
build_claude_passthrough_headers, build_complete_passthrough_headers_with_auth,
build_openai_passthrough_headers, build_passthrough_headers, ensure_upstream_auth_header,
};
use crate::claude_code::build_claude_code_passthrough_headers;
use crate::headers::force_identity_accept_encoding;
use crate::rules::{
apply_local_body_rules, apply_local_body_rules_with_request_headers,
@@ -224,7 +225,23 @@ pub fn build_standard_provider_request_headers(
) -> Option<StandardProviderRequestHeaders> {
let uses_vertex_query_auth =
uses_vertex_api_key_query_auth(input.transport, input.provider_api_format);
let mut headers = if input.same_format {
let is_claude_code_messages = input
.transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("claude_code")
&& aether_ai_formats::normalize_api_format_alias(input.provider_api_format)
== "claude:messages";
let mut headers = if is_claude_code_messages {
build_claude_code_passthrough_headers(
input.headers,
input.auth_header,
input.auth_value,
input.extra_headers,
input.upstream_is_stream,
)
} else if input.same_format {
build_complete_passthrough_headers_with_auth(
input.headers,
input.auth_header,
@@ -473,6 +490,41 @@ mod tests {
);
}
#[test]
fn builds_claude_code_identity_headers_for_cross_format_messages() {
let mut transport = sample_transport("claude:messages");
transport.provider.provider_type = "claude_code".to_string();
let resolved =
build_standard_provider_request_headers(StandardProviderRequestHeadersInput {
transport: &transport,
provider_api_format: "claude:messages",
same_format: false,
headers: &HeaderMap::new(),
auth_header: "authorization",
auth_value: "Bearer oauth-token",
extra_headers: &BTreeMap::new(),
header_rules: None,
provider_request_body: &json!({"model":"claude-opus-4-6"}),
original_request_body: &json!({"model":"gpt-source"}),
upstream_is_stream: true,
})
.expect("Claude Code headers should build");
assert_eq!(resolved.headers.get("x-app"), Some(&"cli".to_string()));
assert!(resolved
.headers
.get("user-agent")
.is_some_and(|value| value.starts_with("claude-cli/")));
assert!(resolved
.headers
.get("anthropic-beta")
.is_some_and(|value| value.contains("claude-code-20250219")));
assert_eq!(
resolved.headers.get("authorization"),
Some(&"Bearer oauth-token".to_string())
);
}
#[test]
fn endpoint_header_rules_do_not_override_protected_authorization() {
let mut transport = sample_transport("openai:responses");