mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 11:19:50 +08:00
fix(gateway): support claude_code cross-format Claude messages
This commit is contained in:
+8
-6
@@ -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");
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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('密钥已禁用')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user