From a44f41cd8175f88c6019162b51d2d5903edf3f30 Mon Sep 17 00:00:00 2001 From: yanjw3 Date: Sun, 25 Oct 2026 09:43:41 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9AQoder=20?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E8=A1=A5=20business.model=5Fkey=EF=BC=8C?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=BD=92=E5=B1=9E=E4=B8=8D=E5=86=8D=E5=9B=9E?= =?UTF-8?q?=E8=90=BD=20Auto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 官方 Qoder 客户端的 agent 循环在 business 块里固定携带当前模型键 (运行时 d8() 的第 5 个参数)。上游后台的模型归属/计费优先读它; Agent2API 之前只带 model_config.key 与 X-Model-Key,选择 Kimi-K3.1-Pro 等模型时后台一律记成账号默认档 Auto。 补上 business.model_key 后,同一请求在 Qoder 后台由 Auto 变为 Kimi-K3.1-Pro;新增单测锁住该字段。 Co-Authored-By: Claude Code --- .../server/core/providers/qoder/protocol.rs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/desktop-tauri/src-tauri/server/src/server/core/providers/qoder/protocol.rs b/desktop-tauri/src-tauri/server/src/server/core/providers/qoder/protocol.rs index 3a9c895b..6770b7aa 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/providers/qoder/protocol.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/providers/qoder/protocol.rs @@ -447,6 +447,11 @@ pub fn build_upstream_body( "version": "1.0.0", "type": "agent", "stage": "start", + // 官方 agent 循环的 business 固定带 `model_key`(官方运行时 `d8()` + // 的第 5 个参数就是当前模型键)。上游后台的模型归属/计费优先读它, + // 缺了会把请求记到账号默认档 Auto —— 即使 model_config.key 与 + // X-Model-Key 都是正确的。 + "model_key": upstream_key, "id": super::cosy::random_uuid().unwrap_or_default(), "name": last_user_text.chars().take(30).collect::(), "begin_at": crate::server::logging::now_ms(), @@ -555,3 +560,36 @@ pub fn truthy(value: &Value) -> bool { Value::Array(_) | Value::Object(_) => true, } } + +#[cfg(test)] +mod tests { + use super::*; + + /// 上游后台按 `business.model_key` 归属模型;缺了会把请求记成账号默认档 + /// Auto(即使 `model_config.key` / `X-Model-Key` 正确)。 + #[test] + fn business_carries_requested_model_key() { + let messages = vec![json!({ "role": "user", "content": "hi" })]; + let body = build_upstream_body( + "kmodel_latest", + &json!({ "key": "kmodel_latest", "source": "system" }), + &messages, + None, + Some(16), + &ThinkingChoice { + enable: None, + effort: None, + }, + "uid", + None, + ); + assert_eq!( + body.pointer("/business/model_key").and_then(Value::as_str), + Some("kmodel_latest") + ); + assert_eq!( + body.pointer("/model_config/key").and_then(Value::as_str), + Some("kmodel_latest") + ); + } +} From a1aed89b604307e3a6eccce0bcd75610ac7cf399 Mon Sep 17 00:00:00 2001 From: yanjw3 Date: Sun, 25 Oct 2026 09:43:41 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9AAnthropic=20?= =?UTF-8?q?=E6=B5=81=E5=BC=8F=E5=93=8D=E5=BA=94=E5=9B=9E=E4=BC=A0=20input/?= =?UTF-8?q?cache=20usage=EF=BC=8Ccontext-hud=20=E4=B8=8D=E5=86=8D=E6=81=92?= =?UTF-8?q?=E4=B8=BA=200?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /v1/messages 流式响应收尾的 message_delta 原来只回传 output_tokens, 上游已拿到的 input_tokens / cache_read_input_tokens 被丢弃,客户端 (Claude Code context-hud)因此始终认为本次请求没有输入,上下文用量 恒为 0。 - message_delta 改用 usage_to_anthropic(self.usage),回传完整用量; - message_start 用请求体粗估的输入 token 数占位(真实值随后覆盖), 保证首帧也不是 0; - 估算为纯内存字符扫描,不增加网络等待。 Co-Authored-By: Claude Code --- .../server/src/server/api/protocol.rs | 6 +- .../src/server/core/protocol/anthropic.rs | 55 +++++++++++++++---- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/desktop-tauri/src-tauri/server/src/server/api/protocol.rs b/desktop-tauri/src-tauri/server/src/server/api/protocol.rs index 3fa44b8e..f7d689f7 100644 --- a/desktop-tauri/src-tauri/server/src/server/api/protocol.rs +++ b/desktop-tauri/src-tauri/server/src/server/api/protocol.rs @@ -419,6 +419,10 @@ pub async fn messages_endpoint( telemetry.set_cancel_token(token); } let mut guard = DisconnectGuard::new(state.request_stats(), telemetry_id.clone()); + // `message_start` 必须立刻发、而上游 usage 要到流末尾才到;这里先按 + // 请求体估一个输入 token 数占位(真实值在 `message_delta` 覆盖)。 + // 纯内存扫一遍消息 + 工具定义,不增加任何网络等待。 + let prompt_estimate = anthropic::estimate_chat_tokens(&chat_body); let outcome = forward_chat( &state, chat_body, @@ -455,7 +459,7 @@ pub async fn messages_endpoint( let model = requested_model.clone(); let context = RecordContext { status: i64::from(status.as_u16()), ..context }; if stream { - let mut machine = anthropic::AnthropicStream::new(&model); + let mut machine = anthropic::AnthropicStream::with_input_tokens(&model, prompt_estimate); let transformed = pipeline::transformed_stream( source, context, diff --git a/desktop-tauri/src-tauri/server/src/server/core/protocol/anthropic.rs b/desktop-tauri/src-tauri/server/src/server/core/protocol/anthropic.rs index c75ffa89..03249382 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/protocol/anthropic.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/protocol/anthropic.rs @@ -676,6 +676,35 @@ pub(super) fn parse_json_object(value: &Value) -> Value { } /// Chat 的 usage → Anthropic 的 usage +/// 输入 token 的粗估(CJK 一字一 token,其余约 4 字符一 token)。 +/// +/// 只用于 `message_start` 的占位:上游真实用量要到流末尾才出现,而 +/// `message_start` 必须在首帧就发。真实值随后在 `message_delta` 覆盖。 +pub fn estimate_chat_tokens(chat: &Value) -> i64 { + let payload = json!({ + "messages": chat.get("messages").cloned().unwrap_or_else(|| json!([])), + "tools": chat.get("tools").cloned().unwrap_or_else(|| json!([])), + }); + let text = payload.to_string(); + let (mut cjk, mut total) = (0i64, 0i64); + for ch in text.chars() { + total += 1; + if is_cjk_char(ch) { + cjk += 1; + } + } + cjk + ((total - cjk) + 3) / 4 +} + +fn is_cjk_char(ch: char) -> bool { + matches!(ch as u32, + 0x1100..=0x11ff + | 0x2e80..=0x9fff + | 0xac00..=0xd7af + | 0xf900..=0xfaff + | 0xff00..=0xffef) +} + pub fn usage_to_anthropic(usage: Option<&Value>) -> Value { let Some(usage) = usage.filter(|value| value.is_object()) else { return json!({ "input_tokens": 0, "output_tokens": 0 }); @@ -731,6 +760,12 @@ pub struct AnthropicStream { impl AnthropicStream { pub fn new(model: &str) -> Self { + Self::with_input_tokens(model, 0) + } + + /// `input_tokens` 用于 `message_start` 的占位(上游真实用量要到流末尾 + /// 才出现);`message_delta` 会用真实值覆盖。 + pub fn with_input_tokens(model: &str, input_tokens: i64) -> Self { Self { buffer: SseLineBuffer::new(), message_id: random_id("msg"), @@ -748,7 +783,7 @@ impl AnthropicStream { has_tool: false, stop_reason: None, usage: None, - input_tokens: 0, + input_tokens, } } @@ -789,25 +824,22 @@ impl AnthropicStream { .stop_reason .clone() .unwrap_or_else(|| if self.has_tool { "tool_use".to_string() } else { "end_turn".to_string() }); + // 真实 Anthropic 流把 `input_tokens` / `cache_read_input_tokens` 放在 + // 收尾的 `message_delta`(开头那帧只报 0 占位,见 + // `anthropic_outbound::ChatFromAnthropicStream::absorb_usage`)。 + // 只回传 output_tokens 会让客户端(含 Claude Code 的 context-hud) + // 认为本次请求没有输入,上下文用量永远是 0。 out.push(self.event( "message_delta", json!({ "delta": { "stop_reason": stop_reason, "stop_sequence": Value::Null }, - "usage": { "output_tokens": self.output_tokens() }, + "usage": usage_to_anthropic(self.usage.as_ref()), }), )); out.push(self.event("message_stop", json!({}))); out } - fn output_tokens(&self) -> i64 { - self.usage - .as_ref() - .and_then(|usage| usage.get("completion_tokens")) - .and_then(Value::as_i64) - .unwrap_or(0) - } - fn consume(&mut self, chunk: &Value) -> Vec { if self.finished { return Vec::new(); @@ -833,7 +865,8 @@ impl AnthropicStream { } } if let Some(usage) = chunk.get("usage").filter(|value| value.is_object()) { - // Anthropic 的 message_delta 只报 output_tokens,input 在 message_start + // 上游的 usage 只在流末尾出现;`input_tokens` 留到收尾的 + // `message_delta` 一起下发(见 `finish`)。 self.input_tokens = usage.get("prompt_tokens").and_then(Value::as_i64).unwrap_or(0); self.usage = Some(usage.clone()); }