From 4d6756987387ffd71df258941e8a44042027a378 Mon Sep 17 00:00:00 2001 From: zhefox Date: Wed, 5 Aug 2026 13:55:38 +0800 Subject: [PATCH] fix(gateway): support claude_code cross-format Claude messages --- .../passthrough/provider/family/candidates.rs | 14 ++- .../passthrough/provider/family/payload.rs | 11 +- .../ai_execute/sync/chat/local_decision.rs | 109 +++++++++++++----- .../ai_execute/sync/claude/claude_code.rs | 24 +++- .../transport/src/conversion.rs | 35 ++++++ .../transport/src/provider_types.rs | 18 ++- .../transport/src/request_body.rs | 33 ++++++ .../transport/src/request_url/mod.rs | 40 +++++++ .../transport/src/standard/mod.rs | 54 ++++++++- frontend/src/api/__tests__/me.spec.ts | 34 ++++++ frontend/src/api/me.ts | 6 +- frontend/src/views/user/MyApiKeys.vue | 2 +- .../user/__tests__/MyApiKeys.ccswitch.spec.ts | 15 +++ 13 files changed, 349 insertions(+), 46 deletions(-) create mode 100644 frontend/src/api/__tests__/me.spec.ts diff --git a/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/candidates.rs b/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/candidates.rs index dd00705f9..f357405ab 100644 --- a/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/candidates.rs +++ b/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/candidates.rs @@ -167,20 +167,21 @@ pub(crate) async fn materialize_local_same_format_provider_candidate_attempts( .collect(), LocalCandidateResolutionMode::Standard, |eligible| { + let provider_api_format = eligible.provider_api_format.clone(); let (execution_strategy, conversion_mode) = ai_local_execution_contract_for_formats( spec_metadata.api_format, - spec_metadata.api_format, + &provider_api_format, ); Some(build_local_execution_candidate_contract_metadata( LocalExecutionCandidateMetadataParts { eligible, - provider_api_format: spec_metadata.api_format, + provider_api_format: provider_api_format.as_str(), client_api_format: spec_metadata.api_format, extra_fields: serde_json::Map::new(), }, execution_strategy, conversion_mode, - spec_metadata.api_format, + provider_api_format.as_str(), )) }, |mut skipped_candidate| { @@ -273,20 +274,21 @@ pub(crate) async fn build_local_same_format_provider_candidate_attempt_source<'a .collect(), LocalCandidateResolutionMode::Standard, |eligible| { + let provider_api_format = eligible.provider_api_format.clone(); let (execution_strategy, conversion_mode) = ai_local_execution_contract_for_formats( spec_metadata.api_format, - spec_metadata.api_format, + &provider_api_format, ); Some(build_local_execution_candidate_contract_metadata( LocalExecutionCandidateMetadataParts { eligible, - provider_api_format: spec_metadata.api_format, + provider_api_format: provider_api_format.as_str(), client_api_format: spec_metadata.api_format, extra_fields: serde_json::Map::new(), }, execution_strategy, conversion_mode, - spec_metadata.api_format, + provider_api_format.as_str(), )) }, |mut skipped_candidate| { diff --git a/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/payload.rs b/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/payload.rs index 0d3c578b8..ed3797c25 100644 --- a/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/payload.rs +++ b/apps/aether-gateway/src/ai_serving/planner/passthrough/provider/family/payload.rs @@ -55,8 +55,6 @@ pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_ .. } = &attempt; 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( 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 (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 report_context = append_local_failover_policy_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), upstream_is_stream: resolved.upstream_is_stream, 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, }), execution_strategy, diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/chat/local_decision.rs b/apps/aether-gateway/src/tests/ai_execute/sync/chat/local_decision.rs index 9d92d1fd6..2e04fae22 100644 --- a/apps/aether-gateway/src/tests/ai_execute/sync/chat/local_decision.rs +++ b/apps/aether-gateway/src/tests/ai_execute/sync/chat/local_decision.rs @@ -1633,10 +1633,12 @@ async fn gateway_returns_openai_chat_error_for_local_cross_format_claude_cli_syn struct SeenExecutionRuntimeSyncRequest { trace_id: String, url: String, - model: String, + body: serde_json::Value, + client_api_format: String, + provider_api_format: String, auth_header_value: String, + anthropic_version: String, endpoint_tag: String, - has_messages: bool, } 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(), true, 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!(["gpt-5"])), 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(5), 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!(["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 { StoredMinimalCandidateSelectionRow { provider_id: "provider-openai-chat-claude-cli-local-1".to_string(), - provider_name: "claude".to_string(), - provider_type: "custom".to_string(), + provider_name: "claude_code".to_string(), + provider_type: "claude_code".to_string(), provider_priority: 10, provider_is_active: true, 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, key_id: "key-openai-chat-claude-cli-local-1".to_string(), key_name: "prod".to_string(), - key_auth_type: "bearer".to_string(), + key_auth_type: "oauth".to_string(), key_is_active: true, key_api_formats: Some(vec!["claude:messages".to_string()]), 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 { StoredProviderCatalogProvider::new( "provider-openai-chat-claude-cli-local-1".to_string(), - "claude".to_string(), + "claude_code".to_string(), Some("https://example.com".to_string()), - "custom".to_string(), + "claude_code".to_string(), ) .expect("provider should build") .with_transport_fields( @@ -1729,7 +1731,11 @@ async fn gateway_returns_openai_chat_error_for_local_cross_format_claude_cli_syn None, Some(20.0), 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") .with_transport_fields( - "https://api.anthropic.example".to_string(), + "https://api.anthropic.com/v1".to_string(), Some(serde_json::json!([ {"action":"set","key":"x-endpoint-tag","value":"openai-chat-claude-cli-cross-format"} ])), None, Some(2), - Some("/custom/v1/messages".to_string()), + 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(), "provider-openai-chat-claude-cli-local-1".to_string(), "prod".to_string(), - "bearer".to_string(), + "oauth".to_string(), None, 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()) .unwrap_or_default() .to_string(), - model: payload + body: payload .get("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()) .unwrap_or_default() .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()) .unwrap_or_default() .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 .get("headers") .and_then(|value| value.get("x-endpoint-tag")) .and_then(|value| value.as_str()) .unwrap_or_default() .to_string(), - has_messages: payload - .get("body") - .and_then(|value| value.get("json_body")) - .and_then(|value| value.get("messages")) - .is_some(), }); Json(json!({ "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 .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!( - response - .headers() - .get(EXECUTION_PATH_HEADER) - .and_then(|value| value.to_str().ok()), + status, + StatusCode::TOO_MANY_REQUESTS, + "unexpected status={status} body={response_json}" + ); + assert_eq!( + execution_path.as_deref(), Some(EXECUTION_PATH_EXECUTION_RUNTIME_SYNC) ); - let response_json: serde_json::Value = response.json().await.expect("body should parse"); assert_eq!( response_json, json!({ @@ -1991,18 +2013,41 @@ async fn gateway_returns_openai_chat_error_for_local_cross_format_claude_cli_syn ); assert_eq!( 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!( seen_execution_runtime_request.auth_header_value, "Bearer sk-upstream-openai-chat-claude-cli" ); + assert_eq!( + seen_execution_runtime_request.anthropic_version, + "2023-06-01" + ); assert_eq!( seen_execution_runtime_request.endpoint_tag, "openai-chat-claude-cli-cross-format" ); - assert!(seen_execution_runtime_request.has_messages); let stored_candidates = request_candidate_repository .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"); assert_eq!(stored_candidates.len(), 1); 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; assert!( diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/claude/claude_code.rs b/apps/aether-gateway/src/tests/ai_execute/sync/claude/claude_code.rs index b6d290690..7c5687444 100644 --- a/apps/aether-gateway/src/tests/ai_execute/sync/claude/claude_code.rs +++ b/apps/aether-gateway/src/tests/ai_execute/sync/claude/claude_code.rs @@ -48,6 +48,8 @@ async fn gateway_executes_claude_code_cli_sync_via_local_decision_gate_with_loca trace_id: String, url: String, model: String, + client_api_format: String, + provider_api_format: String, authorization: String, accept: 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") .with_transport_fields( - "https://api.anthropic.example/v1/messages".to_string(), + "https://api.anthropic.com/v1".to_string(), Some(serde_json::json!([ {"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()) .unwrap_or_default() .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 .get("headers") .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!( 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.client_api_format, + "claude:messages" + ); + assert_eq!( + seen_execution_runtime_request.provider_api_format, + "claude:messages" + ); assert_eq!( seen_execution_runtime_request.authorization, "Bearer sk-upstream-claude-code-oauth" diff --git a/crates/aether-provider/transport/src/conversion.rs b/crates/aether-provider/transport/src/conversion.rs index 1e88d4da9..6f2e2cede 100644 --- a/crates/aether-provider/transport/src/conversion.rs +++ b/crates/aether-provider/transport/src/conversion.rs @@ -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); diff --git a/crates/aether-provider/transport/src/provider_types.rs b/crates/aether-provider/transport/src/provider_types.rs index 5f7935c1d..2ca2bf920 100644 --- a/crates/aether-provider/transport/src/provider_types.rs +++ b/crates/aether-provider/transport/src/provider_types.rs @@ -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"); diff --git a/crates/aether-provider/transport/src/request_body.rs b/crates/aether-provider/transport/src/request_body.rs index 49623354c..3fda480cb 100644 --- a/crates/aether-provider/transport/src/request_body.rs +++ b/crates/aether-provider/transport/src/request_body.rs @@ -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"); diff --git a/crates/aether-provider/transport/src/request_url/mod.rs b/crates/aether-provider/transport/src/request_url/mod.rs index 5d0171f47..3590bb6fa 100644 --- a/crates/aether-provider/transport/src/request_url/mod.rs +++ b/crates/aether-provider/transport/src/request_url/mod.rs @@ -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( diff --git a/crates/aether-provider/transport/src/standard/mod.rs b/crates/aether-provider/transport/src/standard/mod.rs index 8e69c0cbf..0cc24b961 100644 --- a/crates/aether-provider/transport/src/standard/mod.rs +++ b/crates/aether-provider/transport/src/standard/mod.rs @@ -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 { 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"); diff --git a/frontend/src/api/__tests__/me.spec.ts b/frontend/src/api/__tests__/me.spec.ts new file mode 100644 index 000000000..eff68ff5a --- /dev/null +++ b/frontend/src/api/__tests__/me.spec.ts @@ -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 }, + ) + }) +}) \ No newline at end of file diff --git a/frontend/src/api/me.ts b/frontend/src/api/me.ts index e4a312cb5..e91dbea7f 100644 --- a/frontend/src/api/me.ts +++ b/frontend/src/api/me.ts @@ -300,8 +300,10 @@ export const meApi = { return response.data }, - async toggleApiKey(keyId: string): Promise { - const response = await apiClient.patch(`/api/users/me/api-keys/${keyId}`) + async toggleApiKey(keyId: string, isActive: boolean): Promise { + const response = await apiClient.patch(`/api/users/me/api-keys/${keyId}`, { + is_active: isActive, + }) return response.data }, diff --git a/frontend/src/views/user/MyApiKeys.vue b/frontend/src/views/user/MyApiKeys.vue index 466f7f1fe..ea6eda7cc 100644 --- a/frontend/src/views/user/MyApiKeys.vue +++ b/frontend/src/views/user/MyApiKeys.vue @@ -1530,7 +1530,7 @@ async function deleteApiKey() { async function toggleApiKey(apiKey: ApiKey) { 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) if (index !== -1) { apiKeys.value[index].is_active = updated.is_active diff --git a/frontend/src/views/user/__tests__/MyApiKeys.ccswitch.spec.ts b/frontend/src/views/user/__tests__/MyApiKeys.ccswitch.spec.ts index 7ebee56ea..869851ade 100644 --- a/frontend/src/views/user/__tests__/MyApiKeys.ccswitch.spec.ts +++ b/frontend/src/views/user/__tests__/MyApiKeys.ccswitch.spec.ts @@ -224,4 +224,19 @@ describe('MyApiKeys CC Switch import', () => { expect(meApiMock.getFullApiKey).not.toHaveBeenCalled() 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('[title="禁用"]')?.click() + await flushPromises() + + expect(meApiMock.toggleApiKey).toHaveBeenCalledWith('user-key-1', false) + expect(toastMock.success).toHaveBeenCalledWith('密钥已禁用') + }) })