feat: 扩展 cache creation token 细分统计与 effective_input_tokens 计费逻辑

- 新增 cache_creation_ephemeral_5m/1h_input_tokens 字段,区分不同 TTL 的缓存写入 token
- 引入 effective_input_tokens(扣除 cache read 后的有效输入 token),暴露给 usage 接口
- billing 规则生成器支持 5m/1h ephemeral cache 独立定价与分级计费
- usage_mapper 增加 Claude/Anthropic 格式映射,修复 OpenAI responses 格式字段兼容性
- 迁移逻辑增强:支持 checksum 容错、applied/pending 数量日志、逐步执行信息输出
- executor 抽离 LocalExecutionRequestOutcome 类型,统一 sync/stream 路径返回语义
- provider-transport auth 层新增 complete passthrough headers 构建逻辑
- 前端 usage 类型全面补充 effective_input_tokens、cache_creation_tokens、total_input_context 字段
This commit is contained in:
fawney19
2026-04-10 17:44:55 +08:00
parent 5014e2f5fd
commit 010ab127e2
64 changed files with 4217 additions and 477 deletions

View File

@@ -18,6 +18,8 @@ async fn gateway_executes_claude_chat_sync_via_local_decision_gate_with_local_sy
url: String,
model: String,
auth_header_value: String,
anthropic_version: String,
anthropic_beta: String,
endpoint_tag: String,
metadata_mode: String,
metadata_source: String,
@@ -261,6 +263,18 @@ async fn gateway_executes_claude_chat_sync_via_local_decision_gate_with_local_sy
.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(),
anthropic_beta: payload
.get("headers")
.and_then(|value| value.get("anthropic-beta"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
endpoint_tag: payload
.get("headers")
.and_then(|value| value.get("x-endpoint-tag"))
@@ -357,6 +371,8 @@ async fn gateway_executes_claude_chat_sync_via_local_decision_gate_with_local_sy
.post(format!("{gateway_url}/v1/messages"))
.header(http::header::CONTENT_TYPE, "application/json")
.header("x-api-key", "sk-client-claude-chat-local")
.header("anthropic-version", "2023-06-01")
.header("anthropic-beta", "prompt-caching-2024-07-31,context-1m-2025-08-07")
.header(TRACE_ID_HEADER, "trace-claude-chat-local-123")
.body(
"{\"model\":\"claude-sonnet-4-5\",\"messages\":[],\"metadata\":{\"client\":\"desktop-claude\"}}",
@@ -390,6 +406,14 @@ async fn gateway_executes_claude_chat_sync_via_local_decision_gate_with_local_sy
seen_execution_runtime_request.auth_header_value,
"sk-upstream-claude-chat"
);
assert_eq!(
seen_execution_runtime_request.anthropic_version,
"2023-06-01"
);
assert_eq!(
seen_execution_runtime_request.anthropic_beta,
"prompt-caching-2024-07-31,context-1m-2025-08-07"
);
assert_eq!(
seen_execution_runtime_request.endpoint_tag,
"claude-chat-local"

View File

@@ -18,6 +18,8 @@ async fn gateway_executes_claude_cli_sync_via_local_decision_gate_with_local_syn
url: String,
model: String,
authorization: String,
anthropic_version: String,
anthropic_beta: String,
endpoint_tag: String,
metadata_mode: String,
metadata_source: String,
@@ -261,6 +263,18 @@ async fn gateway_executes_claude_cli_sync_via_local_decision_gate_with_local_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(),
anthropic_beta: payload
.get("headers")
.and_then(|value| value.get("anthropic-beta"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
endpoint_tag: payload
.get("headers")
.and_then(|value| value.get("x-endpoint-tag"))
@@ -359,6 +373,8 @@ async fn gateway_executes_claude_cli_sync_via_local_decision_gate_with_local_syn
http::header::AUTHORIZATION,
"Bearer sk-client-claude-cli-local",
)
.header("anthropic-version", "2023-06-01")
.header("anthropic-beta", "prompt-caching-2024-07-31")
.header(TRACE_ID_HEADER, "trace-claude-cli-local-sync-123")
.body(
"{\"model\":\"claude-code\",\"messages\":[],\"metadata\":{\"client\":\"desktop-claude-cli\"}}",
@@ -398,6 +414,14 @@ async fn gateway_executes_claude_cli_sync_via_local_decision_gate_with_local_syn
seen_execution_runtime_request.authorization,
"Bearer sk-upstream-claude-cli"
);
assert_eq!(
seen_execution_runtime_request.anthropic_version,
"2023-06-01"
);
assert_eq!(
seen_execution_runtime_request.anthropic_beta,
"prompt-caching-2024-07-31"
);
assert_eq!(
seen_execution_runtime_request.endpoint_tag,
"claude-cli-local"

View File

@@ -151,7 +151,7 @@ fn sample_usage_row(
actual_total_cost_usd: f64,
created_at_unix_ms: i64,
) -> StoredRequestUsageAudit {
StoredRequestUsageAudit::new(
let mut usage = StoredRequestUsageAudit::new(
id.to_string(),
request_id.to_string(),
user_id.map(str::to_string),
@@ -190,7 +190,10 @@ fn sample_usage_row(
Some(created_at_unix_ms + 2),
)
.expect("usage row should build")
.with_cache_input_tokens(15, 5)
.with_cache_input_tokens(15, 5);
usage.cache_creation_ephemeral_5m_input_tokens = 6;
usage.cache_creation_ephemeral_1h_input_tokens = 9;
usage
}
fn sample_user_summary(id: &str, username: &str) -> StoredUserSummary {
@@ -272,6 +275,14 @@ async fn gateway_handles_admin_usage_stats_locally_with_trusted_admin_principal(
assert_eq!(payload["total_tokens"], 240);
assert_eq!(payload["error_count"], 1);
assert_eq!(payload["cache_stats"]["cache_creation_tokens"], 30);
assert_eq!(
payload["cache_stats"]["cache_creation_ephemeral_5m_tokens"],
12
);
assert_eq!(
payload["cache_stats"]["cache_creation_ephemeral_1h_tokens"],
18
);
assert_eq!(payload["cache_stats"]["cache_read_tokens"], 10);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
@@ -296,52 +307,66 @@ async fn gateway_handles_admin_usage_aggregation_stats_locally_with_trusted_admi
let (upstream_url, upstream_hits, upstream_handle) =
start_usage_upstream("/api/admin/usage/aggregation/stats").await;
let mut usage_1 = sample_usage_row(
"usage-1",
"req-1",
Some("user-1"),
Some("key-1"),
Some("primary"),
"OpenAI",
"gpt-5",
"completed",
120,
30,
0.3,
0.36,
DAY_1_UNIX_SECS,
);
usage_1.provider_id = Some("provider-openai".to_string());
usage_1.total_tokens = usage_1.input_tokens;
let mut usage_2 = sample_usage_row(
"usage-2",
"req-2",
Some("user-2"),
Some("key-2"),
Some("secondary"),
"OpenAI",
"gpt-5",
"completed",
40,
10,
0.1,
0.12,
DAY_2_UNIX_SECS,
);
usage_2.provider_id = Some("provider-openai".to_string());
usage_2.total_tokens = usage_2.input_tokens;
let mut usage_3 = sample_usage_row(
"usage-3",
"req-3",
Some("user-2"),
Some("key-2"),
Some("secondary"),
"Anthropic",
"claude-3-7",
"completed",
60,
20,
0.2,
0.24,
DAY_2_UNIX_SECS,
);
usage_3.provider_id = Some("provider-anthropic".to_string());
usage_3.total_tokens = usage_3.input_tokens;
usage_3.api_format = Some("claude:cli".to_string());
usage_3.api_family = Some("claude".to_string());
usage_3.endpoint_api_format = Some("claude:cli".to_string());
usage_3.provider_api_family = Some("claude".to_string());
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![
sample_usage_row(
"usage-1",
"req-1",
Some("user-1"),
Some("key-1"),
Some("primary"),
"OpenAI",
"gpt-5",
"completed",
120,
30,
0.3,
0.36,
DAY_1_UNIX_SECS,
),
sample_usage_row(
"usage-2",
"req-2",
Some("user-2"),
Some("key-2"),
Some("secondary"),
"OpenAI",
"gpt-5",
"completed",
40,
10,
0.1,
0.12,
DAY_2_UNIX_SECS,
),
sample_usage_row(
"usage-3",
"req-3",
Some("user-2"),
Some("key-2"),
Some("secondary"),
"Anthropic",
"claude-3-7",
"completed",
60,
20,
0.2,
0.24,
DAY_2_UNIX_SECS,
),
usage_1, usage_2, usage_3,
]));
let gateway = build_router_with_state(
@@ -366,7 +391,53 @@ async fn gateway_handles_admin_usage_aggregation_stats_locally_with_trusted_admi
assert_eq!(items.len(), 2);
assert_eq!(items[0]["model"], "gpt-5");
assert_eq!(items[0]["request_count"], 2);
assert_eq!(items[0]["output_tokens"], 40);
assert_eq!(items[0]["effective_input_tokens"], 150);
assert_eq!(items[0]["total_input_context"], 200);
assert_eq!(items[0]["cache_creation_tokens"], 30);
assert_eq!(items[0]["cache_creation_ephemeral_5m_tokens"], 12);
assert_eq!(items[0]["cache_creation_ephemeral_1h_tokens"], 18);
assert_eq!(items[0]["cache_hit_rate"], 5.0);
assert_eq!(items[1]["model"], "claude-3-7");
assert_eq!(items[1]["output_tokens"], 20);
let provider_response = admin_request(reqwest::Client::new().get(format!(
"{gateway_url}/api/admin/usage/aggregation/stats?group_by=provider&limit=10"
)))
.send()
.await
.expect("request should succeed");
assert_eq!(provider_response.status(), StatusCode::OK);
let provider_payload: serde_json::Value = provider_response
.json()
.await
.expect("json body should parse");
let provider_items = provider_payload.as_array().expect("array response");
assert_eq!(provider_items.len(), 2);
assert_eq!(provider_items[0]["provider"], "OpenAI");
assert_eq!(provider_items[0]["output_tokens"], 40);
assert_eq!(provider_items[1]["provider"], "Anthropic");
assert_eq!(provider_items[1]["output_tokens"], 20);
let api_format_response = admin_request(reqwest::Client::new().get(format!(
"{gateway_url}/api/admin/usage/aggregation/stats?group_by=api_format&limit=10"
)))
.send()
.await
.expect("request should succeed");
assert_eq!(api_format_response.status(), StatusCode::OK);
let api_format_payload: serde_json::Value = api_format_response
.json()
.await
.expect("json body should parse");
let api_format_items = api_format_payload.as_array().expect("array response");
assert_eq!(api_format_items.len(), 2);
assert_eq!(api_format_items[0]["api_format"], "openai:chat");
assert_eq!(api_format_items[0]["output_tokens"], 40);
assert_eq!(api_format_items[1]["api_format"], "claude:cli");
assert_eq!(api_format_items[1]["output_tokens"], 20);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
@@ -616,6 +687,7 @@ async fn gateway_handles_admin_usage_active_locally_with_trusted_admin_principal
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["requests"].as_array().expect("array").len(), 1);
assert_eq!(payload["requests"][0]["id"], "usage-pending");
assert_eq!(payload["requests"][0]["effective_input_tokens"], 5);
assert_eq!(payload["requests"][0]["provider"], "OpenAI");
assert_eq!(
payload["requests"][0]["provider_key_name"],
@@ -707,6 +779,7 @@ async fn gateway_handles_admin_usage_records_locally_with_trusted_admin_principa
payload["records"][0]["provider_key_name"],
"upstream-primary"
);
assert_eq!(payload["records"][0]["effective_input_tokens"], 35);
assert_eq!(payload["records"][0]["first_byte_time_ms"], 120);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
@@ -870,6 +943,7 @@ async fn gateway_handles_admin_usage_detail_locally_with_trusted_admin_principal
assert_eq!(payload["api_key"]["name"], "primary");
assert_eq!(payload["provider"], "OpenAI");
assert_eq!(payload["model"], "gpt-5");
assert_eq!(payload["effective_input_tokens"], 115);
assert_eq!(payload["total_tokens"], 170);
assert_eq!(payload["cache_creation_cost"], 0.0);
assert_eq!(payload["cache_read_cost"], 0.0);

View File

@@ -2387,6 +2387,8 @@ fn sample_user_usage_audit(
)
.expect("usage should build");
usage.cache_creation_input_tokens = 10;
usage.cache_creation_ephemeral_5m_input_tokens = 4;
usage.cache_creation_ephemeral_1h_input_tokens = 6;
usage.cache_read_input_tokens = 15;
usage.cache_creation_cost_usd = 0.05;
usage.cache_read_cost_usd = 0.02;
@@ -4733,6 +4735,15 @@ async fn gateway_handles_users_me_usage_locally_without_proxying_upstream() {
payload["records"].as_array().expect("records array").len(),
3
);
assert_eq!(
payload["records"][0]["cache_creation_ephemeral_5m_input_tokens"],
4
);
assert_eq!(payload["records"][0]["effective_input_tokens"], 105);
assert_eq!(
payload["records"][0]["cache_creation_ephemeral_1h_input_tokens"],
6
);
assert_eq!(
payload["summary_by_model"]
.as_array()
@@ -4740,6 +4751,19 @@ async fn gateway_handles_users_me_usage_locally_without_proxying_upstream() {
.len(),
2
);
assert_eq!(
payload["summary_by_model"][0]["cache_creation_ephemeral_5m_tokens"],
4
);
assert_eq!(
payload["summary_by_model"][0]["cache_creation_ephemeral_1h_tokens"],
6
);
assert_eq!(
payload["summary_by_model"][0]["effective_input_tokens"],
105
);
assert_eq!(payload["summary_by_model"][0]["total_input_context"], 145);
assert_eq!(payload["billing"]["id"], "wallet-auth-1");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
@@ -4822,6 +4846,8 @@ async fn gateway_handles_users_me_usage_active_locally_without_proxying_upstream
let requests = payload["requests"].as_array().expect("requests array");
assert_eq!(requests.len(), 2);
assert_eq!(requests[0]["status"], "streaming");
assert_eq!(requests[0]["cache_creation_ephemeral_5m_input_tokens"], 4);
assert_eq!(requests[0]["cache_creation_ephemeral_1h_input_tokens"], 6);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();

View File

@@ -183,6 +183,212 @@ async fn gateway_handles_local_openai_chat_sync_report_with_local_reporting_when
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_records_failed_usage_when_all_local_openai_chat_candidates_exhaust_after_retryable_sync_failure(
) {
let usage_repository = Arc::new(InMemoryUsageReadRepository::default());
let request_candidate_repository = Arc::new(InMemoryRequestCandidateRepository::default());
let report_hits = Arc::new(Mutex::new(0usize));
let report_hits_clone = Arc::clone(&report_hits);
let decision_hits = Arc::new(Mutex::new(0usize));
let decision_hits_clone = Arc::clone(&decision_hits);
let plan_hits = Arc::new(Mutex::new(0usize));
let plan_hits_clone = Arc::clone(&plan_hits);
let public_hits = Arc::new(Mutex::new(0usize));
let public_hits_clone = Arc::clone(&public_hits);
let upstream = Router::new()
.route(
"/api/internal/gateway/decision-sync",
any(move |_request: Request| {
let decision_hits_inner = Arc::clone(&decision_hits_clone);
async move {
*decision_hits_inner.lock().expect("mutex should lock") += 1;
Json(json!({"action": "proxy_public"}))
}
}),
)
.route(
"/api/internal/gateway/plan-sync",
any(move |_request: Request| {
let plan_hits_inner = Arc::clone(&plan_hits_clone);
async move {
*plan_hits_inner.lock().expect("mutex should lock") += 1;
Json(json!({"action": "proxy_public"}))
}
}),
)
.route(
"/api/internal/gateway/report-sync",
any(move |_request: Request| {
let report_hits_inner = Arc::clone(&report_hits_clone);
async move {
*report_hits_inner.lock().expect("mutex should lock") += 1;
Json(json!({"ok": true}))
}
}),
)
.route(
"/v1/chat/completions",
any(move |_request: Request| {
let public_hits_inner = Arc::clone(&public_hits_clone);
async move {
*public_hits_inner.lock().expect("mutex should lock") += 1;
(StatusCode::IM_A_TEAPOT, Body::from("public-route-hit"))
}
}),
);
let execution_runtime = Router::new().route(
"/v1/execute/sync",
any(|_request: Request| async move {
Json(json!({
"request_id": "trace-openai-chat-local-report-sync-failure-123",
"status_code": 503,
"headers": {
"content-type": "application/json"
},
"body": {
"json_body": {
"error": {
"message": "primary unavailable"
}
}
},
"telemetry": {
"elapsed_ms": 25
}
}))
}),
);
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
Some(hash_api_key("sk-client-openai-local-report-sync-failure")),
sample_local_openai_auth_snapshot(
"api-key-openai-usage-local-failure-1",
"user-openai-usage-local-failure-1",
),
)]));
let candidate_selection_repository =
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
sample_local_openai_candidate_row(),
]));
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_local_openai_provider()],
vec![sample_local_openai_endpoint()],
vec![sample_local_openai_key()],
));
let (upstream_url, upstream_handle) = start_server(upstream).await;
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
let gateway_state =
build_state_with_execution_runtime_override(execution_runtime_url)
.with_data_state_for_tests(
GatewayDataState::with_auth_candidate_selection_provider_catalog_request_candidates_and_usage_for_tests(
auth_repository,
candidate_selection_repository,
provider_catalog_repository,
Arc::clone(&request_candidate_repository),
Arc::clone(&usage_repository),
DEVELOPMENT_ENCRYPTION_KEY,
),
)
.with_usage_runtime_for_tests(UsageRuntimeConfig {
enabled: true,
..UsageRuntimeConfig::default()
});
let gateway = build_router_with_state(gateway_state);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/v1/chat/completions"))
.header(http::header::CONTENT_TYPE, "application/json")
.header(
http::header::AUTHORIZATION,
"Bearer sk-client-openai-local-report-sync-failure",
)
.header(
TRACE_ID_HEADER,
"trace-openai-chat-local-report-sync-failure-123",
)
.body("{\"model\":\"gpt-5\",\"messages\":[]}")
.send()
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body_json: serde_json::Value = response.json().await.expect("body should parse");
assert_eq!(body_json["error"]["type"], "http_error");
let mut stored_usage = None;
for _ in 0..50 {
stored_usage = usage_repository
.find_by_request_id("trace-openai-chat-local-report-sync-failure-123")
.await
.expect("usage lookup should succeed");
if stored_usage.is_some() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
let stored_usage = stored_usage.expect("failed usage should be recorded");
assert_eq!(stored_usage.status, "failed");
assert_eq!(stored_usage.billing_status, "void");
assert_eq!(stored_usage.status_code, Some(503));
assert_eq!(stored_usage.error_category.as_deref(), Some("server_error"));
assert_eq!(
stored_usage.user_id.as_deref(),
Some("user-openai-usage-local-failure-1")
);
assert_eq!(stored_usage.provider_name, "openai");
assert_eq!(stored_usage.model, "gpt-5");
assert_eq!(stored_usage.api_format.as_deref(), Some("openai:chat"));
assert_eq!(
stored_usage
.request_metadata
.as_ref()
.and_then(|value| value.get("trace_id"))
.and_then(|value| value.as_str()),
Some("trace-openai-chat-local-report-sync-failure-123")
);
assert_eq!(
stored_usage
.response_body
.as_ref()
.and_then(|value| value.get("error"))
.and_then(|value| value.get("type"))
.and_then(|value| value.as_str()),
Some("upstream_error")
);
assert_eq!(
stored_usage
.client_response_body
.as_ref()
.and_then(|value| value.get("error"))
.and_then(|value| value.get("type"))
.and_then(|value| value.as_str()),
Some("http_error")
);
let stored_candidates = request_candidate_repository
.list_by_request_id("trace-openai-chat-local-report-sync-failure-123")
.await
.expect("request candidate trace should read");
assert_eq!(stored_candidates.len(), 1);
assert_eq!(stored_candidates[0].status, RequestCandidateStatus::Failed);
assert_eq!(stored_candidates[0].status_code, Some(503));
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert_eq!(*report_hits.lock().expect("mutex should lock"), 0);
assert_eq!(*decision_hits.lock().expect("mutex should lock"), 0);
assert_eq!(*plan_hits.lock().expect("mutex should lock"), 0);
assert_eq!(*public_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
execution_runtime_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_local_openai_chat_stream_report_with_local_reporting_when_usage_runtime_enabled(
) {