fix(proxy): bill route-takeover traffic by the real upstream model

The model mapped for takeover (env mapping, Claude Desktop routes,
Copilot normalization, Codex chat override) was discarded inside the
forwarder, so usage attribution depended entirely on the upstream
echoing it back. When the upstream omitted the model or mirrored the
client alias, kimi/glm tokens were recorded and priced as claude-*
(roughly 5-25x overstatement).

- capture the final outbound model in forward(), return it via
  ForwardResult, and store it on the request context
- attribution fallback order is now: upstream echo (empty string
  treated as missing) -> outbound model -> client-requested model
- 'request' pricing mode anchors to the outbound model instead of the
  pre-mapping client alias; unchanged when no mapping applies
- persist the resolved pricing_model on every usage row
- Claude Desktop rows now log app_type "claude-desktop" on streaming
  and transform paths too (was hardcoded "claude", silently dropping
  desktop provider pricing overrides and splitting the cost basis by
  the stream flag); its global pricing defaults inherit the claude
  config since proxy_config only allows claude/codex/gemini rows
This commit is contained in:
Jason
2026-06-10 11:56:54 +08:00
parent 4282856683
commit feea81e5bb
6 changed files with 305 additions and 62 deletions
+65 -11
View File
@@ -207,6 +207,7 @@ async fn handle_messages_for_app(
};
let connection_guard = result.connection_guard.take();
ctx.outbound_model = result.outbound_model.take();
ctx.provider = result.provider;
let api_format = result
.claude_api_format
@@ -334,29 +335,44 @@ async fn handle_claude_transform(
let state = state.clone();
let provider_id = ctx.provider.id.clone();
let request_model = ctx.request_model.clone();
// 上游/转换层未回显模型时,优先用映射后的出站模型兜底(路由接管真值),
// 其次才是客户端请求别名。空字符串视为缺失(转换器对无回显上游会合成 "")。
let fallback_model = ctx
.outbound_model
.clone()
.unwrap_or_else(|| ctx.request_model.clone());
let status_code = status.as_u16();
let start_time = ctx.start_time;
let session_id = ctx.session_id.clone();
// 用 ctx 的 app_typeClaude Desktop 网关也走此转换路径,硬编码
// "claude" 会把 claude-desktop 的行错记到 claude 名下
let app_type_str = ctx.app_type_str;
Some(SseUsageCollector::new(
start_time,
Some(claude_stream_usage_event_filter),
move |events, first_token_ms| {
if let Some(usage) = TokenUsage::from_claude_stream_events(&events) {
let model = usage.model.clone().unwrap_or(request_model.clone());
let model = usage
.model
.clone()
.filter(|m| !m.is_empty())
.unwrap_or_else(|| fallback_model.clone());
let latency_ms = start_time.elapsed().as_millis() as u64;
let state = state.clone();
let provider_id = provider_id.clone();
let session_id = session_id.clone();
let request_model = request_model.clone();
let outbound_model = fallback_model.clone();
tokio::spawn(async move {
log_usage(
&state,
&provider_id,
"claude",
app_type_str,
&model,
&request_model,
&outbound_model,
usage,
latency_ms,
first_token_ms,
@@ -442,25 +458,35 @@ async fn handle_claude_transform(
// 记录使用量
if let Some(usage) = TokenUsage::from_claude_response(&anthropic_response) {
// 转换后的响应缺失/合成空 model 时,回退到映射后的出站模型(接管真值),
// 再回退到客户端请求别名
let model = anthropic_response
.get("model")
.and_then(|m| m.as_str())
.unwrap_or("unknown");
.filter(|m| !m.is_empty())
.map(str::to_string)
.or_else(|| ctx.outbound_model.clone())
.unwrap_or_else(|| ctx.request_model.clone());
let latency_ms = ctx.latency_ms();
let request_model = ctx.request_model.clone();
let outbound_model = ctx
.outbound_model
.clone()
.unwrap_or_else(|| ctx.request_model.clone());
let app_type_str = ctx.app_type_str;
tokio::spawn({
let state = state.clone();
let provider_id = ctx.provider.id.clone();
let model = model.to_string();
let session_id = ctx.session_id.clone();
async move {
log_usage(
&state,
&provider_id,
"claude",
app_type_str,
&model,
&request_model,
&outbound_model,
usage,
latency_ms,
None,
@@ -563,6 +589,7 @@ pub async fn handle_chat_completions(
};
let connection_guard = result.connection_guard.take();
ctx.outbound_model = result.outbound_model.take();
ctx.provider = result.provider;
let response = result.response;
@@ -628,6 +655,7 @@ pub async fn handle_responses(
};
let connection_guard = result.connection_guard.take();
ctx.outbound_model = result.outbound_model.take();
ctx.provider = result.provider;
let response = result.response;
@@ -705,6 +733,7 @@ pub async fn handle_responses_compact(
};
let connection_guard = result.connection_guard.take();
ctx.outbound_model = result.outbound_model.take();
ctx.provider = result.provider;
let response = result.response;
@@ -756,6 +785,12 @@ async fn handle_codex_chat_to_responses_transform(
let state = state.clone();
let provider_id = ctx.provider.id.clone();
let request_model = ctx.request_model.clone();
// 接管/模型覆写场景的归因兜底:出站真值优先于客户端请求别名
let fallback_model = ctx
.outbound_model
.clone()
.unwrap_or_else(|| ctx.request_model.clone());
let app_type_str = ctx.app_type_str;
let start_time = ctx.start_time;
let session_id = ctx.session_id.clone();
@@ -774,21 +809,27 @@ async fn handle_codex_chat_to_responses_transform(
log::debug!("[Codex] 流式响应 usage 全 0 或缺失,跳过消费记录");
return;
}
let model = usage.model.clone().unwrap_or_else(|| request_model.clone());
let model = usage
.model
.clone()
.filter(|m| !m.is_empty())
.unwrap_or_else(|| fallback_model.clone());
let latency_ms = start_time.elapsed().as_millis() as u64;
let state = state.clone();
let provider_id = provider_id.clone();
let request_model = request_model.clone();
let outbound_model = fallback_model.clone();
let session_id = session_id.clone();
tokio::spawn(async move {
log_usage(
&state,
&provider_id,
"codex",
app_type_str,
&model,
&request_model,
&outbound_model,
usage,
latency_ms,
first_token_ms,
@@ -863,21 +904,29 @@ async fn handle_codex_chat_to_responses_transform(
let model = responses_response
.get("model")
.and_then(|m| m.as_str())
.unwrap_or(&ctx.request_model);
.filter(|m| !m.is_empty())
.map(str::to_string)
.or_else(|| ctx.outbound_model.clone())
.unwrap_or_else(|| ctx.request_model.clone());
let request_model = ctx.request_model.clone();
let outbound_model = ctx
.outbound_model
.clone()
.unwrap_or_else(|| ctx.request_model.clone());
let app_type_str = ctx.app_type_str;
tokio::spawn({
let state = state.clone();
let provider_id = ctx.provider.id.clone();
let model = model.to_string();
let session_id = ctx.session_id.clone();
let latency_ms = ctx.latency_ms();
async move {
log_usage(
&state,
&provider_id,
"codex",
app_type_str,
&model,
&request_model,
&outbound_model,
usage,
latency_ms,
None,
@@ -1242,6 +1291,7 @@ pub async fn handle_gemini(
};
let connection_guard = result.connection_guard.take();
ctx.outbound_model = result.outbound_model.take();
ctx.provider = result.provider;
let response = result.response;
@@ -1370,6 +1420,9 @@ fn log_forward_error(
}
/// 记录请求使用量
///
/// `outbound_model` 是「按请求计价」模式的锚点:实际发往上游的模型
/// (路由接管映射后的真值,无映射时等于 request_model)。
#[allow(clippy::too_many_arguments)]
async fn log_usage(
state: &ProxyState,
@@ -1377,6 +1430,7 @@ async fn log_usage(
app_type: &str,
model: &str,
request_model: &str,
outbound_model: &str,
usage: TokenUsage,
latency_ms: u64,
first_token_ms: Option<u64>,
@@ -1395,7 +1449,7 @@ async fn log_usage(
let (multiplier, pricing_model_source) =
logger.resolve_pricing_config(provider_id, app_type).await;
let pricing_model = if pricing_model_source == PRICING_SOURCE_REQUEST {
request_model
outbound_model
} else {
model
};