Skip to content

fix(proxy): 规范化 Anthropic system 消息#3775

Merged
farion1231 merged 1 commit into
farion1231:mainfrom
Dearli666:fix/anthropic-system-message-normalization
Jun 5, 2026
Merged

fix(proxy): 规范化 Anthropic system 消息#3775
farion1231 merged 1 commit into
farion1231:mainfrom
Dearli666:fix/anthropic-system-message-normalization

Conversation

@Dearli666

Copy link
Copy Markdown
Contributor

Summary / 概述

  • 规范化 Anthropic 直通请求中的 messages[].role = "system",将其移动到顶层 system,避免 Claude Desktop / Anthropic-compatible proxy / DeepSeek 端点拒绝非标准 system 消息。
  • 将原有 DeepSeek/Kimi/MiMo 的 tool thinking history 修复串到统一入口 normalize_anthropic_messages_for_provider,保留 content[].thinking must be passed back 场景的兼容处理。
  • 修复两个 Windows 下测试数据构造问题:TOML/JSON 中直接拼接 C:\... 路径会触发转义解析错误,改为可跨平台解析的测试数据。

Related Issue / 关联 Issue

未关联具体 Issue;对应近期 Codex / Claude / DeepSeek 协议兼容反馈。

Screenshots / 截图

不涉及 UI 变更。

Checklist / 检查清单

  • pnpm typecheck passes / 通过 TypeScript 类型检查
  • pnpm format:check passes / 通过代码格式检查
  • cargo clippy --lib passes / 通过 Clippy 检查(剩余 warning 为仓库既有未触及项)
  • 未修改用户可见文本,无需更新国际化文件

Tests / 测试

  • cargo fmt
  • cargo test proxy::providers::claude --lib:51 passed
  • cargo test proxy::providers::transform_codex_chat --lib:48 passed
  • cargo test proxy::providers::streaming_codex_chat --lib:12 passed
  • cargo test --lib:1532 passed, 2 ignored
  • pnpm typecheck
  • pnpm format:check
  • pnpm exec vitest run tests/config/codexChatProviderPresets.test.ts tests/components/ClaudeDesktopProviderForm.test.tsx tests/utils/providerConfigUtils.codex.test.ts:29 passed

说明:pnpm test:unit 全量前端测试中 tests/integration/App.test.tsx 有 2 个既有集成测试失败(一个超时、一个重复 provider-list DOM),与本次 Rust 协议修复无直接关联。

@farion1231

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep it up!

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@farion1231 farion1231 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

感谢您的贡献!

@farion1231
farion1231 merged commit 3cd9a0d into farion1231:main Jun 5, 2026
2 checks passed
@lyr0430

lyr0430 commented Jun 11, 2026

Copy link
Copy Markdown

windows版本上 好像并没有生效 我的mac上是正常的

@lihongjing-2023

Copy link
Copy Markdown

@Dearli666 @farion1231

Here's a polished comment you can post on the PR:


Impact on DeepSeek Prefix Cache Hit Rate

This PR's system message normalization has a significant side effect on providers that use byte-prefix caching (DeepSeek, Moonshot, etc.). After this change, my cache hit rate dropped from ~95% to ~25% when routing Claude Code through CC Switch to DeepSeek.

Root cause: DeepSeek's context cache requires strict byte-prefix matching from token 0. The current normalize_openai_system_messages logic (transform.rs#L265-L314) does two things that break this:

  1. Merging multiple system messages into one — the concatenated result changes across turns as new system instructions appear, invalidating the entire prefix.
  2. Repositioning the sole system message to index 0 — while correct for OpenAI compatibility, the merge step above makes the content unstable.

Even a single character drift in the system message (which sits at the very start of the prefix) causes a full cache miss for all subsequent tokens — that's the entire conversation history being re-processed at full price ($0.14/M vs $0.014/M cached).

Proposed fix: For byte-prefix-sensitive providers, instead of merging all system messages into one, keep only the first system message in place and downgrade the rest to role: "user" with a semantic prefix:

fn normalize_openai_system_messages(messages: &mut Vec<Value>) {
    let mut first_system_seen = false;
    for msg in messages.iter_mut() {
        if msg.get("role").and_then(|v| v.as_str()) == Some("system") {
            if !first_system_seen {
                first_system_seen = true;
                // Keep the first system message — it's the stable prefix anchor
            } else {
                // Downgrade subsequent system messages to user role
                // to avoid breaking the byte-prefix chain
                msg["role"] = json!("user");
                if let Some(text) = msg.get("content").and_then(|c| c.as_str()).cloned() {
                    msg["content"] = json!(format!("[System Instruction]\n{}", text));
                }
            }
        }
    }
    // Ensure the first system message is at index 0
    if first_system_seen {
        if let Some(idx) = messages.iter().position(|m| {
            m.get("role").and_then(|v| v.as_str()) == Some("system")
        }) {
            if idx > 0 {
                let m = messages.remove(idx);
                messages.insert(0, m);
            }
        }
    }
}

Why this works:

Aspect Current (merge) Proposed (downgrade)
Prefix stability ❌ Merged text changes each turn ✅ First system stays identical across turns
Cache hit ❌ Full miss on any system change ✅ Prefix anchored by stable first system
Semantic authority ✅ Single system block ⚠️ Slight reduction for downgraded messages, mitigated by [System Instruction] prefix
Multi-provider safety ⚠️ Hurts prefix-cache providers ✅ Safe for both Claude (segmented cache) and DeepSeek (byte-prefix)

Important caveat: This strategy should be provider-aware. Claude's prompt cache uses segmented hashing with cache_control breakpoints, so merging system messages doesn't hurt it. The downgrade approach is only beneficial for byte-prefix-cache APIs. A runtime flag (e.g., prefix_cache_sensitive) that selects the strategy per upstream would be the cleanest solution.

This aligns with the direction of #3841 (stripping cache_control on OpenAI conversion paths) — the proxy layer needs to be aware of downstream caching semantics when transforming request formats.


Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants