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
@@ -167,20 +167,21 @@ pub(crate) async fn materialize_local_same_format_provider_candidate_attempts(
.collect(), .collect(),
LocalCandidateResolutionMode::Standard, LocalCandidateResolutionMode::Standard,
|eligible| { |eligible| {
let provider_api_format = eligible.provider_api_format.clone();
let (execution_strategy, conversion_mode) = ai_local_execution_contract_for_formats( let (execution_strategy, conversion_mode) = ai_local_execution_contract_for_formats(
spec_metadata.api_format, spec_metadata.api_format,
spec_metadata.api_format, &provider_api_format,
); );
Some(build_local_execution_candidate_contract_metadata( Some(build_local_execution_candidate_contract_metadata(
LocalExecutionCandidateMetadataParts { LocalExecutionCandidateMetadataParts {
eligible, eligible,
provider_api_format: spec_metadata.api_format, provider_api_format: provider_api_format.as_str(),
client_api_format: spec_metadata.api_format, client_api_format: spec_metadata.api_format,
extra_fields: serde_json::Map::new(), extra_fields: serde_json::Map::new(),
}, },
execution_strategy, execution_strategy,
conversion_mode, conversion_mode,
spec_metadata.api_format, provider_api_format.as_str(),
)) ))
}, },
|mut skipped_candidate| { |mut skipped_candidate| {
@@ -273,20 +274,21 @@ pub(crate) async fn build_local_same_format_provider_candidate_attempt_source<'a
.collect(), .collect(),
LocalCandidateResolutionMode::Standard, LocalCandidateResolutionMode::Standard,
|eligible| { |eligible| {
let provider_api_format = eligible.provider_api_format.clone();
let (execution_strategy, conversion_mode) = ai_local_execution_contract_for_formats( let (execution_strategy, conversion_mode) = ai_local_execution_contract_for_formats(
spec_metadata.api_format, spec_metadata.api_format,
spec_metadata.api_format, &provider_api_format,
); );
Some(build_local_execution_candidate_contract_metadata( Some(build_local_execution_candidate_contract_metadata(
LocalExecutionCandidateMetadataParts { LocalExecutionCandidateMetadataParts {
eligible, eligible,
provider_api_format: spec_metadata.api_format, provider_api_format: provider_api_format.as_str(),
client_api_format: spec_metadata.api_format, client_api_format: spec_metadata.api_format,
extra_fields: serde_json::Map::new(), extra_fields: serde_json::Map::new(),
}, },
execution_strategy, execution_strategy,
conversion_mode, conversion_mode,
spec_metadata.api_format, provider_api_format.as_str(),
)) ))
}, },
|mut skipped_candidate| { |mut skipped_candidate| {
@@ -55,8 +55,6 @@ pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_
.. ..
} = &attempt; } = &attempt;
let candidate = &eligible.candidate; let candidate = &eligible.candidate;
let (execution_strategy, conversion_mode) =
ai_local_execution_contract_for_formats(spec_metadata.api_format, spec_metadata.api_format);
let Some(resolved) = resolve_local_same_format_provider_candidate_payload_parts( let Some(resolved) = resolve_local_same_format_provider_candidate_payload_parts(
state, parts, trace_id, body_json, input, &attempt, spec, state, parts, trace_id, body_json, input, &attempt, spec,
) )
@@ -164,6 +162,10 @@ pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_
} }
} }
let provider_api_format = resolved.provider_api_format.clone(); let provider_api_format = resolved.provider_api_format.clone();
let (execution_strategy, conversion_mode) = ai_local_execution_contract_for_formats(
spec_metadata.api_format,
provider_api_format.as_str(),
);
let effective_headers = input.effective_headers(&parts.headers); let effective_headers = input.effective_headers(&parts.headers);
let report_context = append_local_failover_policy_to_value( let report_context = append_local_failover_policy_to_value(
append_execution_contract_fields_to_value( append_execution_contract_fields_to_value(
@@ -207,7 +209,10 @@ pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_
.unwrap_or(false), .unwrap_or(false),
upstream_is_stream: resolved.upstream_is_stream, upstream_is_stream: resolved.upstream_is_stream,
has_envelope: resolved.is_kiro || resolved.is_antigravity || resolved.is_gemini_cli, has_envelope: resolved.is_kiro || resolved.is_antigravity || resolved.is_gemini_cli,
needs_conversion: false, needs_conversion: matches!(
conversion_mode,
crate::ai_serving::ConversionMode::Bidirectional
),
extra_fields, extra_fields,
}), }),
execution_strategy, execution_strategy,
@@ -1633,10 +1633,12 @@ async fn gateway_returns_openai_chat_error_for_local_cross_format_claude_cli_syn
struct SeenExecutionRuntimeSyncRequest { struct SeenExecutionRuntimeSyncRequest {
trace_id: String, trace_id: String,
url: String, url: String,
model: String, body: serde_json::Value,
client_api_format: String,
provider_api_format: String,
auth_header_value: String, auth_header_value: String,
anthropic_version: String,
endpoint_tag: String, endpoint_tag: String,
has_messages: bool,
} }
fn hash_api_key(value: &str) -> String { fn hash_api_key(value: &str) -> String {
@@ -1654,7 +1656,7 @@ async fn gateway_returns_openai_chat_error_for_local_cross_format_claude_cli_syn
"local".to_string(), "local".to_string(),
true, true,
false, false,
Some(serde_json::json!(["openai", "claude"])), Some(serde_json::json!(["openai", "claude", "claude_code"])),
Some(serde_json::json!(["openai:chat"])), Some(serde_json::json!(["openai:chat"])),
Some(serde_json::json!(["gpt-5"])), Some(serde_json::json!(["gpt-5"])),
api_key_id.to_string(), api_key_id.to_string(),
@@ -1665,7 +1667,7 @@ async fn gateway_returns_openai_chat_error_for_local_cross_format_claude_cli_syn
Some(60), Some(60),
Some(5), Some(5),
Some(4_102_444_800), Some(4_102_444_800),
Some(serde_json::json!(["openai", "claude"])), Some(serde_json::json!(["openai", "claude", "claude_code"])),
Some(serde_json::json!(["openai:chat"])), Some(serde_json::json!(["openai:chat"])),
Some(serde_json::json!(["gpt-5"])), Some(serde_json::json!(["gpt-5"])),
) )
@@ -1675,8 +1677,8 @@ async fn gateway_returns_openai_chat_error_for_local_cross_format_claude_cli_syn
fn sample_candidate_row() -> StoredMinimalCandidateSelectionRow { fn sample_candidate_row() -> StoredMinimalCandidateSelectionRow {
StoredMinimalCandidateSelectionRow { StoredMinimalCandidateSelectionRow {
provider_id: "provider-openai-chat-claude-cli-local-1".to_string(), provider_id: "provider-openai-chat-claude-cli-local-1".to_string(),
provider_name: "claude".to_string(), provider_name: "claude_code".to_string(),
provider_type: "custom".to_string(), provider_type: "claude_code".to_string(),
provider_priority: 10, provider_priority: 10,
provider_is_active: true, provider_is_active: true,
endpoint_id: "endpoint-openai-chat-claude-cli-local-1".to_string(), endpoint_id: "endpoint-openai-chat-claude-cli-local-1".to_string(),
@@ -1686,7 +1688,7 @@ async fn gateway_returns_openai_chat_error_for_local_cross_format_claude_cli_syn
endpoint_is_active: true, endpoint_is_active: true,
key_id: "key-openai-chat-claude-cli-local-1".to_string(), key_id: "key-openai-chat-claude-cli-local-1".to_string(),
key_name: "prod".to_string(), key_name: "prod".to_string(),
key_auth_type: "bearer".to_string(), key_auth_type: "oauth".to_string(),
key_is_active: true, key_is_active: true,
key_api_formats: Some(vec!["claude:messages".to_string()]), key_api_formats: Some(vec!["claude:messages".to_string()]),
key_allowed_models: None, key_allowed_models: None,
@@ -1715,9 +1717,9 @@ async fn gateway_returns_openai_chat_error_for_local_cross_format_claude_cli_syn
fn sample_provider_catalog_provider() -> StoredProviderCatalogProvider { fn sample_provider_catalog_provider() -> StoredProviderCatalogProvider {
StoredProviderCatalogProvider::new( StoredProviderCatalogProvider::new(
"provider-openai-chat-claude-cli-local-1".to_string(), "provider-openai-chat-claude-cli-local-1".to_string(),
"claude".to_string(), "claude_code".to_string(),
Some("https://example.com".to_string()), Some("https://example.com".to_string()),
"custom".to_string(), "claude_code".to_string(),
) )
.expect("provider should build") .expect("provider should build")
.with_transport_fields( .with_transport_fields(
@@ -1729,7 +1731,11 @@ async fn gateway_returns_openai_chat_error_for_local_cross_format_claude_cli_syn
None, None,
Some(20.0), Some(20.0),
None, None,
None, Some(serde_json::json!({
"claude_code_advanced": {
"cli_only_enabled": false
}
})),
) )
} }
@@ -1744,13 +1750,13 @@ async fn gateway_returns_openai_chat_error_for_local_cross_format_claude_cli_syn
) )
.expect("endpoint should build") .expect("endpoint should build")
.with_transport_fields( .with_transport_fields(
"https://api.anthropic.example".to_string(), "https://api.anthropic.com/v1".to_string(),
Some(serde_json::json!([ Some(serde_json::json!([
{"action":"set","key":"x-endpoint-tag","value":"openai-chat-claude-cli-cross-format"} {"action":"set","key":"x-endpoint-tag","value":"openai-chat-claude-cli-cross-format"}
])), ])),
None, None,
Some(2), Some(2),
Some("/custom/v1/messages".to_string()), None,
None, None,
None, None,
None, None,
@@ -1763,7 +1769,7 @@ async fn gateway_returns_openai_chat_error_for_local_cross_format_claude_cli_syn
"key-openai-chat-claude-cli-local-1".to_string(), "key-openai-chat-claude-cli-local-1".to_string(),
"provider-openai-chat-claude-cli-local-1".to_string(), "provider-openai-chat-claude-cli-local-1".to_string(),
"prod".to_string(), "prod".to_string(),
"bearer".to_string(), "oauth".to_string(),
None, None,
true, true,
) )
@@ -1865,10 +1871,18 @@ async fn gateway_returns_openai_chat_error_for_local_cross_format_claude_cli_syn
.and_then(|value| value.as_str()) .and_then(|value| value.as_str())
.unwrap_or_default() .unwrap_or_default()
.to_string(), .to_string(),
model: payload body: payload
.get("body") .get("body")
.and_then(|value| value.get("json_body")) .and_then(|value| value.get("json_body"))
.and_then(|value| value.get("model")) .cloned()
.unwrap_or(serde_json::Value::Null),
client_api_format: payload
.get("client_api_format")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
provider_api_format: payload
.get("provider_api_format")
.and_then(|value| value.as_str()) .and_then(|value| value.as_str())
.unwrap_or_default() .unwrap_or_default()
.to_string(), .to_string(),
@@ -1878,17 +1892,18 @@ async fn gateway_returns_openai_chat_error_for_local_cross_format_claude_cli_syn
.and_then(|value| value.as_str()) .and_then(|value| value.as_str())
.unwrap_or_default() .unwrap_or_default()
.to_string(), .to_string(),
anthropic_version: payload
.get("headers")
.and_then(|value| value.get("anthropic-version"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
endpoint_tag: payload endpoint_tag: payload
.get("headers") .get("headers")
.and_then(|value| value.get("x-endpoint-tag")) .and_then(|value| value.get("x-endpoint-tag"))
.and_then(|value| value.as_str()) .and_then(|value| value.as_str())
.unwrap_or_default() .unwrap_or_default()
.to_string(), .to_string(),
has_messages: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("messages"))
.is_some(),
}); });
Json(json!({ Json(json!({
"request_id": "trace-openai-chat-claude-cli-local-error-123", "request_id": "trace-openai-chat-claude-cli-local-error-123",
@@ -1961,15 +1976,22 @@ async fn gateway_returns_openai_chat_error_for_local_cross_format_claude_cli_syn
.await .await
.expect("request should succeed"); .expect("request should succeed");
assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); let status = response.status();
let execution_path = response
.headers()
.get(EXECUTION_PATH_HEADER)
.and_then(|value| value.to_str().ok())
.map(ToOwned::to_owned);
let response_json: serde_json::Value = response.json().await.expect("body should parse");
assert_eq!( assert_eq!(
response status,
.headers() StatusCode::TOO_MANY_REQUESTS,
.get(EXECUTION_PATH_HEADER) "unexpected status={status} body={response_json}"
.and_then(|value| value.to_str().ok()), );
assert_eq!(
execution_path.as_deref(),
Some(EXECUTION_PATH_EXECUTION_RUNTIME_SYNC) Some(EXECUTION_PATH_EXECUTION_RUNTIME_SYNC)
); );
let response_json: serde_json::Value = response.json().await.expect("body should parse");
assert_eq!( assert_eq!(
response_json, response_json,
json!({ json!({
@@ -1991,18 +2013,41 @@ async fn gateway_returns_openai_chat_error_for_local_cross_format_claude_cli_syn
); );
assert_eq!( assert_eq!(
seen_execution_runtime_request.url, seen_execution_runtime_request.url,
"https://api.anthropic.example/custom/v1/messages" "https://api.anthropic.com/v1/messages"
);
assert_eq!(
seen_execution_runtime_request.body["model"],
"claude-code-upstream"
);
assert_eq!(seen_execution_runtime_request.body["max_tokens"], 64);
assert_eq!(
seen_execution_runtime_request.body["system"],
"You are terse."
);
assert_eq!(
seen_execution_runtime_request.body["messages"],
json!([{"role":"user","content":"Say hello"}])
);
assert_eq!(
seen_execution_runtime_request.client_api_format,
"openai:chat"
);
assert_eq!(
seen_execution_runtime_request.provider_api_format,
"claude:messages"
); );
assert_eq!(seen_execution_runtime_request.model, "claude-code-upstream");
assert_eq!( assert_eq!(
seen_execution_runtime_request.auth_header_value, seen_execution_runtime_request.auth_header_value,
"Bearer sk-upstream-openai-chat-claude-cli" "Bearer sk-upstream-openai-chat-claude-cli"
); );
assert_eq!(
seen_execution_runtime_request.anthropic_version,
"2023-06-01"
);
assert_eq!( assert_eq!(
seen_execution_runtime_request.endpoint_tag, seen_execution_runtime_request.endpoint_tag,
"openai-chat-claude-cli-cross-format" "openai-chat-claude-cli-cross-format"
); );
assert!(seen_execution_runtime_request.has_messages);
let stored_candidates = request_candidate_repository let stored_candidates = request_candidate_repository
.list_by_request_id("trace-openai-chat-claude-cli-local-error-123") .list_by_request_id("trace-openai-chat-claude-cli-local-error-123")
@@ -2010,6 +2055,12 @@ async fn gateway_returns_openai_chat_error_for_local_cross_format_claude_cli_syn
.expect("request candidate trace should read"); .expect("request candidate trace should read");
assert_eq!(stored_candidates.len(), 1); assert_eq!(stored_candidates.len(), 1);
assert_eq!(stored_candidates[0].status, RequestCandidateStatus::Failed); assert_eq!(stored_candidates[0].status, RequestCandidateStatus::Failed);
let extra_data = stored_candidates[0]
.extra_data
.as_ref()
.expect("request candidate extra_data should exist");
assert_eq!(extra_data["client_api_format"], "openai:chat");
assert_eq!(extra_data["provider_api_format"], "claude:messages");
tokio::time::sleep(std::time::Duration::from_millis(100)).await; tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert!( assert!(
@@ -48,6 +48,8 @@ async fn gateway_executes_claude_code_cli_sync_via_local_decision_gate_with_loca
trace_id: String, trace_id: String,
url: String, url: String,
model: String, model: String,
client_api_format: String,
provider_api_format: String,
authorization: String, authorization: String,
accept: String, accept: String,
anthropic_version: String, anthropic_version: String,
@@ -173,7 +175,7 @@ async fn gateway_executes_claude_code_cli_sync_via_local_decision_gate_with_loca
) )
.expect("endpoint should build") .expect("endpoint should build")
.with_transport_fields( .with_transport_fields(
"https://api.anthropic.example/v1/messages".to_string(), "https://api.anthropic.com/v1".to_string(),
Some(serde_json::json!([ Some(serde_json::json!([
{"action":"set","key":"x-endpoint-tag","value":"claude-code-cli-local"} {"action":"set","key":"x-endpoint-tag","value":"claude-code-cli-local"}
])), ])),
@@ -315,6 +317,16 @@ async fn gateway_executes_claude_code_cli_sync_via_local_decision_gate_with_loca
.and_then(|value| value.as_str()) .and_then(|value| value.as_str())
.unwrap_or_default() .unwrap_or_default()
.to_string(), .to_string(),
client_api_format: payload
.get("client_api_format")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
provider_api_format: payload
.get("provider_api_format")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
authorization: payload authorization: payload
.get("headers") .get("headers")
.and_then(|value| value.get("authorization")) .and_then(|value| value.get("authorization"))
@@ -522,9 +534,17 @@ async fn gateway_executes_claude_code_cli_sync_via_local_decision_gate_with_loca
); );
assert_eq!( assert_eq!(
seen_execution_runtime_request.url, seen_execution_runtime_request.url,
"https://api.anthropic.example/v1/messages" "https://api.anthropic.com/v1/messages"
); );
assert_eq!(seen_execution_runtime_request.model, "claude-code-upstream"); assert_eq!(seen_execution_runtime_request.model, "claude-code-upstream");
assert_eq!(
seen_execution_runtime_request.client_api_format,
"claude:messages"
);
assert_eq!(
seen_execution_runtime_request.provider_api_format,
"claude:messages"
);
assert_eq!( assert_eq!(
seen_execution_runtime_request.authorization, seen_execution_runtime_request.authorization,
"Bearer sk-upstream-claude-code-oauth" "Bearer sk-upstream-claude-code-oauth"
@@ -8,6 +8,7 @@ use crate::antigravity::is_antigravity_provider_transport;
use crate::auth::{ use crate::auth::{
resolve_local_gemini_auth, resolve_local_openai_bearer_auth, resolve_local_standard_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::{ use crate::kiro::{
is_kiro_claude_messages_transport, local_kiro_request_transport_unsupported_reason_with_network, 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, transport: &GatewayProviderTransportSnapshot,
_kind: RequestConversionKind, _kind: RequestConversionKind,
) -> Option<&'static str> { ) -> 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) { if is_kiro_claude_messages_transport(transport, &transport.endpoint.api_format) {
return local_kiro_request_transport_unsupported_reason_with_network(transport); 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] #[test]
fn windsurf_openai_chat_anchor_supports_cross_format_conversion_via_cascade() { fn windsurf_openai_chat_anchor_supports_cross_format_conversion_via_cascade() {
let mut transport = transport_snapshot("windsurf", "openai:chat", "oauth", true, None); 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 { const CLAUDE_CODE_FIXED_PROVIDER_TEMPLATE: FixedProviderTemplate = FixedProviderTemplate {
provider_type: "claude_code", provider_type: "claude_code",
version: 1, version: 2,
base_url: "https://api.anthropic.com", base_url: "https://api.anthropic.com/v1",
endpoints: &[FixedProviderEndpointTemplate { endpoints: &[FixedProviderEndpointTemplate {
item_key: "claude:messages", item_key: "claude:messages",
api_format: "claude:messages", api_format: "claude:messages",
@@ -638,6 +638,20 @@ mod tests {
ADMIN_PROVIDER_OAUTH_TEMPLATE_TYPES, 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] #[test]
fn codex_fixed_provider_template_includes_codex_companion_endpoints() { fn codex_fixed_provider_template_includes_codex_companion_endpoints() {
let template = fixed_provider_template("codex").expect("codex template should exist"); let template = fixed_provider_template("codex").expect("codex template should exist");
@@ -1,5 +1,6 @@
use serde_json::{Map, Value}; use serde_json::{Map, Value};
use crate::claude_code::sanitize_claude_code_request_body;
use crate::snapshot::GatewayProviderTransportSnapshot; use crate::snapshot::GatewayProviderTransportSnapshot;
use crate::vertex::is_vertex_transport_context; use crate::vertex::is_vertex_transport_context;
@@ -32,6 +33,15 @@ pub fn apply_transport_request_body_semantics(
provider_api_format: &str, provider_api_format: &str,
) -> Result<(), TransportRequestBodySemanticsError> { ) -> Result<(), TransportRequestBodySemanticsError> {
let provider_api_format = aether_ai_formats::normalize_api_format_alias(provider_api_format); 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) { if provider_api_format == "gemini:embedding" && is_vertex_transport_context(transport) {
apply_vertex_gemini_embedding_body_semantics(provider_request_body)?; apply_vertex_gemini_embedding_body_semantics(provider_request_body)?;
} }
@@ -334,6 +344,29 @@ mod tests {
assert_eq!(body["model"], "gemini-embedding-2"); 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] #[test]
fn vertex_gemini_embedding_batch_body_uses_predict_instances() { fn vertex_gemini_embedding_batch_body_uses_predict_instances() {
let transport = sample_transport("vertex_ai", "https://aiplatform.googleapis.com"); 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] #[test]
fn count_tokens_config_fields_fall_back_from_endpoint_to_provider() { fn count_tokens_config_fields_fall_back_from_endpoint_to_provider() {
let mut transport = sample_transport( let mut transport = sample_transport(
@@ -6,6 +6,7 @@ use crate::auth::{
build_claude_passthrough_headers, build_complete_passthrough_headers_with_auth, build_claude_passthrough_headers, build_complete_passthrough_headers_with_auth,
build_openai_passthrough_headers, build_passthrough_headers, ensure_upstream_auth_header, 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::headers::force_identity_accept_encoding;
use crate::rules::{ use crate::rules::{
apply_local_body_rules, apply_local_body_rules_with_request_headers, apply_local_body_rules, apply_local_body_rules_with_request_headers,
@@ -224,7 +225,23 @@ pub fn build_standard_provider_request_headers(
) -> Option<StandardProviderRequestHeaders> { ) -> Option<StandardProviderRequestHeaders> {
let uses_vertex_query_auth = let uses_vertex_query_auth =
uses_vertex_api_key_query_auth(input.transport, input.provider_api_format); 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( build_complete_passthrough_headers_with_auth(
input.headers, input.headers,
input.auth_header, 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] #[test]
fn endpoint_header_rules_do_not_override_protected_authorization() { fn endpoint_header_rules_do_not_override_protected_authorization() {
let mut transport = sample_transport("openai:responses"); let mut transport = sample_transport("openai:responses");
+34
View File
@@ -0,0 +1,34 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { patchMock } = vi.hoisted(() => ({
patchMock: vi.fn(),
}))
vi.mock('@/api/client', () => ({
default: {
patch: patchMock,
},
}))
import { meApi } from '@/api/me'
describe('meApi API key status', () => {
beforeEach(() => {
patchMock.mockReset()
patchMock.mockResolvedValue({
data: {
id: 'user-key-1',
is_active: false,
},
})
})
it('sends the desired disabled state in the patch body', async () => {
await meApi.toggleApiKey('user-key-1', false)
expect(patchMock).toHaveBeenCalledWith(
'/api/users/me/api-keys/user-key-1',
{ is_active: false },
)
})
})
+4 -2
View File
@@ -300,8 +300,10 @@ export const meApi = {
return response.data return response.data
}, },
async toggleApiKey(keyId: string): Promise<ApiKey> { async toggleApiKey(keyId: string, isActive: boolean): Promise<ApiKey> {
const response = await apiClient.patch<ApiKey>(`/api/users/me/api-keys/${keyId}`) const response = await apiClient.patch<ApiKey>(`/api/users/me/api-keys/${keyId}`, {
is_active: isActive,
})
return response.data return response.data
}, },
+1 -1
View File
@@ -1530,7 +1530,7 @@ async function deleteApiKey() {
async function toggleApiKey(apiKey: ApiKey) { async function toggleApiKey(apiKey: ApiKey) {
try { try {
const updated = await meApi.toggleApiKey(apiKey.id) const updated = await meApi.toggleApiKey(apiKey.id, !apiKey.is_active)
const index = apiKeys.value.findIndex(k => k.id === apiKey.id) const index = apiKeys.value.findIndex(k => k.id === apiKey.id)
if (index !== -1) { if (index !== -1) {
apiKeys.value[index].is_active = updated.is_active apiKeys.value[index].is_active = updated.is_active
@@ -224,4 +224,19 @@ describe('MyApiKeys CC Switch import', () => {
expect(meApiMock.getFullApiKey).not.toHaveBeenCalled() expect(meApiMock.getFullApiKey).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('导入到 CC Switch') expect(document.body.textContent).toContain('导入到 CC Switch')
}) })
it('sends the desired inactive state when disabling an active key', async () => {
meApiMock.getApiKeys.mockResolvedValue([apiKey()])
meApiMock.toggleApiKey.mockResolvedValue({
id: 'user-key-1',
is_active: false,
})
await mountMyApiKeys()
document.querySelector<HTMLButtonElement>('[title="禁用"]')?.click()
await flushPromises()
expect(meApiMock.toggleApiKey).toHaveBeenCalledWith('user-key-1', false)
expect(toastMock.success).toHaveBeenCalledWith('密钥已禁用')
})
}) })