Skip to content

fix: preserve Claude reasoning content in message conversions - #4497

Open
seefs001 wants to merge 2 commits into
QuantumNous:mainfrom
seefs001:fix/claude-message-reasoning-content
Open

fix: preserve Claude reasoning content in message conversions#4497
seefs001 wants to merge 2 commits into
QuantumNous:mainfrom
seefs001:fix/claude-message-reasoning-content

Conversation

@seefs001

@seefs001 seefs001 commented Apr 27, 2026

Copy link
Copy Markdown
Collaborator

⚠️ 提交说明 / PR Notice

Important

  • 请提供人工撰写的简洁摘要,避免直接粘贴未经整理的 AI 输出。

📝 变更描述 / Description

(简述:做了什么?为什么这样改能生效?请基于你对代码逻辑的理解来写,避免粘贴未经整理的内容)

deepseek/kimi 的交错思考在message上新增了reasoning_content,和原生Anthropic端点不一致,需要补充字段进行兼容。
该PR处理

  • 原生Anthropic端点透传reasoning_content
  • OpenAI和Anthropic端点互相转换,处理特定厂商(deepseek/kimi)的特殊字段

🚀 变更类型 / Type of change

  • 🐛 Bug 修复 (Bug fix) - 请关联对应 Issue,避免将设计取舍、理解偏差或预期不一致直接归类为 bug
  • ✨ 新功能 (New feature) - 重大特性建议先通过 Issue 沟通
  • ⚡ 性能优化 / 重构 (Refactor)
  • 📝 文档更新 (Documentation)

🔗 关联任务 / Related Issue

✅ 提交前检查项 / Checklist

  • 人工确认: 我已亲自整理并撰写此描述,没有直接粘贴未经处理的 AI 输出。
  • 非重复提交: 我已搜索现有的 IssuesPRs,确认不是重复提交。
  • Bug fix 说明: 若此 PR 标记为 Bug fix,我已提交或关联对应 Issue,且不会将设计取舍、预期不一致或理解偏差直接归类为 bug。
  • 变更理解: 我已理解这些更改的工作原理及可能影响。
  • 范围聚焦: 本 PR 未包含任何与当前任务无关的代码改动。
  • 本地验证: 已在本地运行并通过测试或手动验证,维护者可以据此复核结果。
  • 安全合规: 代码中无敏感凭据,且符合项目代码规范。

📸 运行证明 / Proof of Work

(请在此粘贴截图、关键日志或测试报告,以证明变更生效)

Summary by CodeRabbit

  • New Features
    • Preserves assistant "reasoning" content when converting messages between different AI model formats, both directions.
  • Bug Fixes
    • Ensures reasoning content is forwarded only when present, avoiding accidental overwrites.
  • Tests
    • Added unit tests validating reasoning-content preservation, including edge-case raw values.

@coderabbitai

coderabbitai Bot commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

Adds a reasoning_content payload to Claude messages and propagates it through OpenAI↔Claude conversion paths, with tests and accessor updates to preserve raw JSON reasoning content across request/response transformations.

Changes

Cohort / File(s) Summary
Claude DTO
dto/claude.go
Added ReasoningContent json.RawMessage to ClaudeMessage to carry raw reasoning payloads.
OpenAI DTO & accessors
dto/openai_request.go, dto/openai_request_zero_value_test.go
Changed Message.ReasoningContent from string to json.RawMessage; added SetReasoningContent/GetReasoningContent accessors and a zero-value preservation test.
Relay: OpenAI → Claude
relay/channel/claude/relay-claude.go, relay/channel/claude/relay_claude_test.go
RequestOpenAI2ClaudeMessage now forwards non-empty OpenAI reasoning into intermediate messages and dto.ClaudeMessage; added unit test verifying reasoning is preserved when tool calls exist.
Relay: Other channels
relay/channel/gemini/relay-gemini.go, relay/channel/ollama/stream.go
Switched to using SetReasoningContent(...) setter when assigning part/thinking content instead of direct field writes.
Claude → OpenAI conversion
service/convert.go, relay/channel/claude/relay-claude.go
Map claudeMessage.ReasoningContent into resulting OpenAI message ReasoningContent (use message setter); adjust ResponseClaude2OpenAI to set reasoning on message when present.
OpenAI handler token counting
relay/channel/openai/relay-openai.go
Token counting fallback now uses choice.Message.GetReasoningContent() instead of direct field access.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested reviewers

  • Calcium-Ion

Poem

🐰
I hop along the message trail,
Carrying thoughts in tidy mail,
Reasoning now rides every hop,
From Claude to OpenAI — never stop! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: preserving Claude reasoning content during message conversions across vendor integrations.
Linked Issues check ✅ Passed The PR addresses all requirements from #4408: adds ReasoningContent field to ClaudeMessage, updates ResponseClaude2OpenAI to set both choice.ReasoningContent and choice.Message.ReasoningContent, preserves reasoning_content during conversions, and handles tool calls with thinking enabled.
Out of Scope Changes check ✅ Passed All changes are directly scoped to preserving reasoning_content across vendor conversions; updates to OpenAI, Claude, Gemini, Ollama, and service conversion handlers all support the core objective of handling reasoning content in message conversions.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
relay/channel/claude/relay-claude.go (1)

337-343: Marshaling a Go string to json.RawMessage is correct, but the assignment runs for every non-system role.

common.Marshal(string) always returns a JSON-quoted string and effectively cannot fail at runtime, so the error branch is defensive-only — that's fine and consistent with house style.

One subtle behavior worth a sanity check: this assignment runs unconditionally inside the else branch for all non-system roles, including tool. In the tool → merge-into-prior-user path (line 361 continue) the value is harmlessly discarded along with claudeMessage. But in the standalone tool path (lines 362-371) claudeMessage.Role is rewritten to "user", which would emit a user Claude message carrying reasoning_content — semantically odd, even if real-world clients never put reasoning_content on tool messages.

If you want belt-and-suspenders behavior, gate the assignment to assistant role:

🛡️ Optional gate
-		if message.ReasoningContent != "" {
+		if message.Role == "assistant" && message.ReasoningContent != "" {
 			reasoningContent, err := common.Marshal(message.ReasoningContent)
 			if err != nil {
 				return nil, err
 			}
 			claudeMessage.ReasoningContent = reasoningContent
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@relay/channel/claude/relay-claude.go` around lines 337 - 343, The code
currently marshals message.ReasoningContent into a json.RawMessage via
common.Marshal and assigns it to claudeMessage.ReasoningContent for every
non-system role; to avoid emitting reasoning_content on messages that are later
rewritten from tool→user, only set claudeMessage.ReasoningContent when the role
is "assistant" (i.e., guard the common.Marshal + assignment with a check like if
message.Role == "assistant" { ... }), leaving the existing error handling intact
and ensuring tool messages that are converted to user do not carry
reasoning_content.
relay/channel/claude/relay_claude_test.go (1)

384-414: Consider a more representative test value and a companion reverse-direction test.

The test correctly exercises the forward path, but the reasoning string is a single space (" "). A more realistic value (multi-line text, embedded quotes/backslashes) would catch JSON-escaping regressions that a whitespace-only payload misses, e.g.:

♻️ Stronger payload
-	message := dto.Message{
-		Role:             "assistant",
-		Content:          "",
-		ReasoningContent: " ",
-	}
+	const reasoning = "thought:\n  step 1: analyze \"input\"\n  step 2: call tool"
+	message := dto.Message{
+		Role:             "assistant",
+		Content:          "",
+		ReasoningContent: reasoning,
+	}
@@
-	require.JSONEq(t, `" "`, string(claudeRequest.Messages[1].ReasoningContent))
+	expected, _ := json.Marshal(reasoning)
+	require.JSONEq(t, string(expected), string(claudeRequest.Messages[1].ReasoningContent))

Additionally, the linked issue (#4408) covers the reverse direction (Claude → OpenAI) via service.ClaudeToOpenAIRequest, but there's no test for the new mapping at service/convert.go:137-139. A short companion test that constructs a dto.ClaudeRequest with ReasoningContent set on a message and asserts the resulting dto.Message.ReasoningContent would lock in the round-trip behavior (and would surface the JsonRawMessageToString quoting question I raised on service/convert.go).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@relay/channel/claude/relay_claude_test.go` around lines 384 - 414, Update the
tests to use a realistic, hard-to-escape reasoning string and add a
reverse-direction companion test: modify
TestRequestOpenAI2ClaudeMessagePreservesReasoningContent to set ReasoningContent
to a multi-line string containing quotes, backslashes and JSON-like fragments
(e.g. "Line1\nQuote:\"text\"\nPath:\\\\server\\share\n{\"key\":\"val\"}") to
exercise JSON-escaping, verify it round-trips through
RequestOpenAI2ClaudeMessage by asserting the preserved value, and add a new test
for service.ClaudeToOpenAIRequest that constructs a dto.ClaudeRequest with a
message whose ReasoningContent uses the same complex payload and asserts the
resulting dto.Message.ReasoningContent matches exactly (this will also validate
JsonRawMessageToString behavior).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@relay/channel/claude/relay_claude_test.go`:
- Around line 384-414: Update the tests to use a realistic, hard-to-escape
reasoning string and add a reverse-direction companion test: modify
TestRequestOpenAI2ClaudeMessagePreservesReasoningContent to set ReasoningContent
to a multi-line string containing quotes, backslashes and JSON-like fragments
(e.g. "Line1\nQuote:\"text\"\nPath:\\\\server\\share\n{\"key\":\"val\"}") to
exercise JSON-escaping, verify it round-trips through
RequestOpenAI2ClaudeMessage by asserting the preserved value, and add a new test
for service.ClaudeToOpenAIRequest that constructs a dto.ClaudeRequest with a
message whose ReasoningContent uses the same complex payload and asserts the
resulting dto.Message.ReasoningContent matches exactly (this will also validate
JsonRawMessageToString behavior).

In `@relay/channel/claude/relay-claude.go`:
- Around line 337-343: The code currently marshals message.ReasoningContent into
a json.RawMessage via common.Marshal and assigns it to
claudeMessage.ReasoningContent for every non-system role; to avoid emitting
reasoning_content on messages that are later rewritten from tool→user, only set
claudeMessage.ReasoningContent when the role is "assistant" (i.e., guard the
common.Marshal + assignment with a check like if message.Role == "assistant" {
... }), leaving the existing error handling intact and ensuring tool messages
that are converted to user do not carry reasoning_content.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6b8bed8a-2d2b-41c8-8b55-c7ea5f42ea65

📥 Commits

Reviewing files that changed from the base of the PR and between e36d191 and 184b8ca.

📒 Files selected for processing (4)
  • dto/claude.go
  • relay/channel/claude/relay-claude.go
  • relay/channel/claude/relay_claude_test.go
  • service/convert.go

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
relay/channel/claude/relay_claude_test.go (1)

405-413: Make this assertion less order-coupled.

Lines 407-413 assume the tool-use message is always at index 1. Consider selecting the message by tool_use content type first, then asserting its ReasoningContent; this avoids brittle failures if conversion ordering changes without breaking behavior.

Proposed test hardening
-	require.Len(t, claudeRequest.Messages, 2)
-	require.JSONEq(t, `" "`, string(claudeRequest.Messages[1].ReasoningContent))
-
-	content, ok := claudeRequest.Messages[1].Content.([]dto.ClaudeMediaMessage)
+	require.Len(t, claudeRequest.Messages, 2)
+	var target *dto.ClaudeMessage
+	for i := range claudeRequest.Messages {
+		content, ok := claudeRequest.Messages[i].Content.([]dto.ClaudeMediaMessage)
+		if !ok || len(content) == 0 {
+			continue
+		}
+		if content[len(content)-1].Type == "tool_use" {
+			target = &claudeRequest.Messages[i]
+			break
+		}
+	}
+	require.NotNil(t, target)
+	require.JSONEq(t, `" "`, string(target.ReasoningContent))
+
+	content, ok := target.Content.([]dto.ClaudeMediaMessage)
 	require.True(t, ok)
 	require.NotEmpty(t, content)
 	require.Equal(t, "tool_use", content[len(content)-1].Type)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@relay/channel/claude/relay_claude_test.go` around lines 405 - 413, The test
is brittle because it assumes the tool-use message is at index 1; update the
assertions in the RequestOpenAI2ClaudeMessage test to locate the message with
Type == "tool_use" instead of using a fixed index: iterate
claudeRequest.Messages, find the entry where Content casts to
[]dto.ClaudeMediaMessage and the last element's Type == "tool_use", then assert
that that message's ReasoningContent equals the expected JSON and that the
content slice is non-empty; keep references to claudeRequest, Messages,
ReasoningContent, Content, dto.ClaudeMediaMessage and Type to find the correct
message.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@relay/channel/claude/relay_claude_test.go`:
- Around line 405-413: The test is brittle because it assumes the tool-use
message is at index 1; update the assertions in the RequestOpenAI2ClaudeMessage
test to locate the message with Type == "tool_use" instead of using a fixed
index: iterate claudeRequest.Messages, find the entry where Content casts to
[]dto.ClaudeMediaMessage and the last element's Type == "tool_use", then assert
that that message's ReasoningContent equals the expected JSON and that the
content slice is non-empty; keep references to claudeRequest, Messages,
ReasoningContent, Content, dto.ClaudeMediaMessage and Type to find the correct
message.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2d07cbc9-5cf5-42c1-a9ef-644665b7d730

📥 Commits

Reviewing files that changed from the base of the PR and between 184b8ca and edbd534.

📒 Files selected for processing (8)
  • dto/openai_request.go
  • dto/openai_request_zero_value_test.go
  • relay/channel/claude/relay-claude.go
  • relay/channel/claude/relay_claude_test.go
  • relay/channel/gemini/relay-gemini.go
  • relay/channel/ollama/stream.go
  • relay/channel/openai/relay-openai.go
  • service/convert.go
✅ Files skipped from review due to trivial changes (1)
  • dto/openai_request_zero_value_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • service/convert.go
  • relay/channel/claude/relay-claude.go

@chuxiacxx

Copy link
Copy Markdown

很急火速审核发布好吗

@Miraaaacle

Copy link
Copy Markdown

补充:PR #4497 合并后仍存在 reasoning_content is missing 问题的根因分析与修复

背景

我在本地分支手动复制了 PR #4497 的改动(将 Message.ReasoningContent*string 改为 json.RawMessage),但在 kimi-k2.6 开启 thinking 模式后,多轮 tool call 对话仍然报错:

thinking is enabled but reasoning_content is missing in assistant tool call message at index 2

复现路径

请求经过 new-api 的 Claude 兼容入口 /v1/messages,转发给上游 OpenAI 兼容接口 /v1/chat/completions

转换链路:ClaudeRequestClaudeToOpenAIRequest() → OpenAI 格式 → 发往上游

修复前日志(问题复现)

入口日志

第二轮请求(含 tool call 历史)进入时,assistant 消息的 content block 类型为 ["thinking", "tool_use", "tool_use"]

[debug-reasoning] relay entry format=claude path=/v1/messages model=kimi-k2.6 stream=true
messages=[
  {"role":"user", "content_block_types":["text","text","text","text","text"], "has_reasoning_content":false},
  {"role":"assistant", "content_block_types":["thinking","tool_use","tool_use"], "has_reasoning_content":false},
  {"role":"user", "content_block_types":["tool_result","tool_result"], "has_reasoning_content":false}
]

转换输出日志

ClaudeToOpenAIRequest 转换后,assistant 消息丢失了 reasoning_content

[debug-reasoning] ClaudeToOpenAIRequest output_messages=[
  {"role":"system", "has_reasoning_content":false},
  {"role":"user", "has_reasoning_content":false},
  {"role":"assistant", "content_type":"nil", "has_tool_calls":true, "tool_calls_len":2, "has_reasoning_content":false},
  {"role":"tool", "has_reasoning_content":false},
  {"role":"tool", "has_reasoning_content":false}
]

最终上游请求日志

发给上游的 assistant tool-call message 没有 reasoning_content

[debug-reasoning] upstream assistant tool_call message index=2
summary=[{"role":"assistant","content_type":"nil","has_tool_calls":true,"tool_calls_len":2,"has_reasoning_content":false}]
json={"role":"assistant","content":null,"tool_calls":[...]}

上游返回 400:

relay error: thinking is enabled but reasoning_content is missing in assistant tool call message at index 2

根因分析

PR #4497 解决的是 "字段已存在但被 omitempty 吞掉" 的问题(序列化层面)。

但这次的问题更靠前:Claude 格式的 thinking content block 根本没有被转换为 OpenAI 格式的 reasoning_content

具体来说,在 service/convert.goClaudeToOpenAIRequest() 中:

for _, mediaMsg := range contents {
    switch mediaMsg.Type {
    case "text", "input_text":
        // ...
    case "image":
        // ...
    case "tool_use":
        // 转换为 tool_calls ✓
    case "tool_result":
        // ...
    // ← 缺少 case "thinking" 的处理!
    }
}

Claude 格式中,assistant 消息的思考内容是放在 content 数组里的 {"type": "thinking", "thinking": "..."} block 中,而不是在 ClaudeMessage.ReasoningContent 字段里。所以即使 ReasoningContent 字段的序列化问题修好了,这条转换路径上的 thinking 内容仍然会丢失。

修复方案

service/convert.goClaudeToOpenAIRequest() 中增加 thinking block 的处理:

contents := content
var toolCalls []dto.ToolCallRequest
mediaMessages := make([]dto.MediaContent, 0, len(contents))
var thinkingParts []string  // ← 新增

for _, mediaMsg := range contents {
    switch mediaMsg.Type {
    case "text", "input_text":
        // ...
    case "image":
        // ...
    case "thinking":  // ← 新增
        if mediaMsg.Thinking != nil && *mediaMsg.Thinking != "" {
            thinkingParts = append(thinkingParts, *mediaMsg.Thinking)
        }
    case "tool_use":
        // ...
    case "tool_result":
        // ...
    }
}

if len(toolCalls) > 0 {
    openAIMessage.SetToolCalls(toolCalls)
}
if len(thinkingParts) > 0 {  // ← 新增
    openAIMessage.SetReasoningContent(strings.Join(thinkingParts, "\n"))
}

同时放宽消息追加条件,避免"只有 reasoning_content 但没有 content"的消息被跳过:

// 修改前
if len(openAIMessage.ParseContent()) > 0 || len(openAIMessage.ToolCalls) > 0 {

// 修改后
if len(openAIMessage.ParseContent()) > 0 || len(openAIMessage.ToolCalls) > 0 || len(openAIMessage.ReasoningContent) > 0 {

修复后日志(问题解决)

转换输出日志

ClaudeToOpenAIRequest 转换后,assistant 消息正确携带了 reasoning_content

[debug-reasoning] ClaudeToOpenAIRequest output_messages=[
  {"role":"system", "has_reasoning_content":false},
  {"role":"user", "has_reasoning_content":false},
  {"role":"assistant", "content_type":"nil", "has_tool_calls":true, "tool_calls_len":2, "has_reasoning_content":true, "reasoning_content_type":"string"},
  {"role":"tool", "has_reasoning_content":false},
  {"role":"tool", "has_reasoning_content":false}
]

最终上游请求日志

发给上游的 assistant tool-call message 已正确包含 reasoning_content

[debug-reasoning] upstream assistant tool_call message index=2
summary=[{"role":"assistant","content_type":"nil","has_tool_calls":true,"tool_calls_len":2,"has_reasoning_content":true,"reasoning_content_type":"string"}]
json={"role":"assistant","content":null,"reasoning_content":"用户要求我先读取两个文件,然后给出分析结论。让我先读取这两个文件。","tool_calls":[...]}

请求成功

上游正常返回 200,流式响应完成:

[GIN] 2026/04/29 - 11:13:02 | relay | ... | 200 | 17.962593132s | POST /v1/messages?beta=true
stream ended: reason=done

多轮 tool call 后续请求同样正常(含多条 assistant thinking+tool_use 消息):

[GIN] 2026/04/29 - 11:13:45 | relay | ... | 200 | 24.346150844s | POST /v1/messages?beta=true
stream ended: reason=done

总结

层面 PR #4497 解决的问题 本次修复解决的问题
位置 DTO 序列化 Claude→OpenAI 转换逻辑
根因 omitempty 吞掉空字符串 thinking block 未被映射到 reasoning_content
场景 OpenAI 格式入口,字段已存在但被丢弃 Claude 格式入口,字段从未被生成

两个修复互补:PR #4497 确保"有值不丢",本次修复确保"值能被生成"。建议将本次修复也合入主线。

@seefs001

Copy link
Copy Markdown
Collaborator Author

补充:PR #4497 合并后仍存在 reasoning_content is missing 问题的根因分析与修复

背景

我在本地分支手动复制了 PR #4497 的改动(将 Message.ReasoningContent*string 改为 json.RawMessage),但在 kimi-k2.6 开启 thinking 模式后,多轮 tool call 对话仍然报错:

thinking is enabled but reasoning_content is missing in assistant tool call message at index 2

复现路径

请求经过 new-api 的 Claude 兼容入口 /v1/messages,转发给上游 OpenAI 兼容接口 /v1/chat/completions

转换链路:ClaudeRequestClaudeToOpenAIRequest() → OpenAI 格式 → 发往上游

修复前日志(问题复现)

入口日志

第二轮请求(含 tool call 历史)进入时,assistant 消息的 content block 类型为 ["thinking", "tool_use", "tool_use"]

[debug-reasoning] relay entry format=claude path=/v1/messages model=kimi-k2.6 stream=true
messages=[
  {"role":"user", "content_block_types":["text","text","text","text","text"], "has_reasoning_content":false},
  {"role":"assistant", "content_block_types":["thinking","tool_use","tool_use"], "has_reasoning_content":false},
  {"role":"user", "content_block_types":["tool_result","tool_result"], "has_reasoning_content":false}
]

转换输出日志

ClaudeToOpenAIRequest 转换后,assistant 消息丢失了 reasoning_content

[debug-reasoning] ClaudeToOpenAIRequest output_messages=[
  {"role":"system", "has_reasoning_content":false},
  {"role":"user", "has_reasoning_content":false},
  {"role":"assistant", "content_type":"nil", "has_tool_calls":true, "tool_calls_len":2, "has_reasoning_content":false},
  {"role":"tool", "has_reasoning_content":false},
  {"role":"tool", "has_reasoning_content":false}
]

最终上游请求日志

发给上游的 assistant tool-call message 没有 reasoning_content

[debug-reasoning] upstream assistant tool_call message index=2
summary=[{"role":"assistant","content_type":"nil","has_tool_calls":true,"tool_calls_len":2,"has_reasoning_content":false}]
json={"role":"assistant","content":null,"tool_calls":[...]}

上游返回 400:

relay error: thinking is enabled but reasoning_content is missing in assistant tool call message at index 2

根因分析

PR #4497 解决的是 "字段已存在但被 omitempty 吞掉" 的问题(序列化层面)。

但这次的问题更靠前:Claude 格式的 thinking content block 根本没有被转换为 OpenAI 格式的 reasoning_content

具体来说,在 service/convert.goClaudeToOpenAIRequest() 中:

for _, mediaMsg := range contents {
    switch mediaMsg.Type {
    case "text", "input_text":
        // ...
    case "image":
        // ...
    case "tool_use":
        // 转换为 tool_calls ✓
    case "tool_result":
        // ...
    // ← 缺少 case "thinking" 的处理!
    }
}

Claude 格式中,assistant 消息的思考内容是放在 content 数组里的 {"type": "thinking", "thinking": "..."} block 中,而不是在 ClaudeMessage.ReasoningContent 字段里。所以即使 ReasoningContent 字段的序列化问题修好了,这条转换路径上的 thinking 内容仍然会丢失。

修复方案

service/convert.goClaudeToOpenAIRequest() 中增加 thinking block 的处理:

contents := content
var toolCalls []dto.ToolCallRequest
mediaMessages := make([]dto.MediaContent, 0, len(contents))
var thinkingParts []string  // ← 新增

for _, mediaMsg := range contents {
    switch mediaMsg.Type {
    case "text", "input_text":
        // ...
    case "image":
        // ...
    case "thinking":  // ← 新增
        if mediaMsg.Thinking != nil && *mediaMsg.Thinking != "" {
            thinkingParts = append(thinkingParts, *mediaMsg.Thinking)
        }
    case "tool_use":
        // ...
    case "tool_result":
        // ...
    }
}

if len(toolCalls) > 0 {
    openAIMessage.SetToolCalls(toolCalls)
}
if len(thinkingParts) > 0 {  // ← 新增
    openAIMessage.SetReasoningContent(strings.Join(thinkingParts, "\n"))
}

同时放宽消息追加条件,避免"只有 reasoning_content 但没有 content"的消息被跳过:

// 修改前
if len(openAIMessage.ParseContent()) > 0 || len(openAIMessage.ToolCalls) > 0 {

// 修改后
if len(openAIMessage.ParseContent()) > 0 || len(openAIMessage.ToolCalls) > 0 || len(openAIMessage.ReasoningContent) > 0 {

修复后日志(问题解决)

转换输出日志

ClaudeToOpenAIRequest 转换后,assistant 消息正确携带了 reasoning_content

[debug-reasoning] ClaudeToOpenAIRequest output_messages=[
  {"role":"system", "has_reasoning_content":false},
  {"role":"user", "has_reasoning_content":false},
  {"role":"assistant", "content_type":"nil", "has_tool_calls":true, "tool_calls_len":2, "has_reasoning_content":true, "reasoning_content_type":"string"},
  {"role":"tool", "has_reasoning_content":false},
  {"role":"tool", "has_reasoning_content":false}
]

最终上游请求日志

发给上游的 assistant tool-call message 已正确包含 reasoning_content

[debug-reasoning] upstream assistant tool_call message index=2
summary=[{"role":"assistant","content_type":"nil","has_tool_calls":true,"tool_calls_len":2,"has_reasoning_content":true,"reasoning_content_type":"string"}]
json={"role":"assistant","content":null,"reasoning_content":"用户要求我先读取两个文件,然后给出分析结论。让我先读取这两个文件。","tool_calls":[...]}

请求成功

上游正常返回 200,流式响应完成:

[GIN] 2026/04/29 - 11:13:02 | relay | ... | 200 | 17.962593132s | POST /v1/messages?beta=true
stream ended: reason=done

多轮 tool call 后续请求同样正常(含多条 assistant thinking+tool_use 消息):

[GIN] 2026/04/29 - 11:13:45 | relay | ... | 200 | 24.346150844s | POST /v1/messages?beta=true
stream ended: reason=done

总结

层面 PR #4497 解决的问题 本次修复解决的问题
位置 DTO 序列化 Claude→OpenAI 转换逻辑
根因 omitempty 吞掉空字符串 thinking block 未被映射到 reasoning_content
场景 OpenAI 格式入口,字段已存在但被丢弃 Claude 格式入口,字段从未被生成
两个修复互补:PR #4497 确保"有值不丢",本次修复确保"值能被生成"。建议将本次修复也合入主线。

现在的问题是对接官方可以对接这个服务不行,我的修改是打算让直接原生格式对接可以生效,不走转换的场景,你这也没前置说明你是走怎么个逻辑怎么个转发,就让AI给你指一下可能毫不相干转换逻辑,然后生成这么一大段内容,我的修改未必是正确的,但是你这种回复方式真的令人感到不舒服,没有任何前置说明让AI替你回复内容,这很不礼貌。

@Miraaaacle

Copy link
Copy Markdown

补充:PR #4497 合并后仍存在 reasoning_content is missing 问题的根因分析与修复

背景

我在本地分支手动复制了 PR #4497 的改动(将 Message.ReasoningContent*string 改为 json.RawMessage),但在 kimi-k2.6 开启 thinking 模式后,多轮 tool call 对话仍然报错:

thinking is enabled but reasoning_content is missing in assistant tool call message at index 2

复现路径

请求经过 new-api 的 Claude 兼容入口 /v1/messages,转发给上游 OpenAI 兼容接口 /v1/chat/completions
转换链路:ClaudeRequestClaudeToOpenAIRequest() → OpenAI 格式 → 发往上游

修复前日志(问题复现)

入口日志

第二轮请求(含 tool call 历史)进入时,assistant 消息的 content block 类型为 ["thinking", "tool_use", "tool_use"]

[debug-reasoning] relay entry format=claude path=/v1/messages model=kimi-k2.6 stream=true
messages=[
  {"role":"user", "content_block_types":["text","text","text","text","text"], "has_reasoning_content":false},
  {"role":"assistant", "content_block_types":["thinking","tool_use","tool_use"], "has_reasoning_content":false},
  {"role":"user", "content_block_types":["tool_result","tool_result"], "has_reasoning_content":false}
]

转换输出日志

ClaudeToOpenAIRequest 转换后,assistant 消息丢失了 reasoning_content

[debug-reasoning] ClaudeToOpenAIRequest output_messages=[
  {"role":"system", "has_reasoning_content":false},
  {"role":"user", "has_reasoning_content":false},
  {"role":"assistant", "content_type":"nil", "has_tool_calls":true, "tool_calls_len":2, "has_reasoning_content":false},
  {"role":"tool", "has_reasoning_content":false},
  {"role":"tool", "has_reasoning_content":false}
]

最终上游请求日志

发给上游的 assistant tool-call message 没有 reasoning_content

[debug-reasoning] upstream assistant tool_call message index=2
summary=[{"role":"assistant","content_type":"nil","has_tool_calls":true,"tool_calls_len":2,"has_reasoning_content":false}]
json={"role":"assistant","content":null,"tool_calls":[...]}

上游返回 400:

relay error: thinking is enabled but reasoning_content is missing in assistant tool call message at index 2

根因分析

PR #4497 解决的是 "字段已存在但被 omitempty 吞掉" 的问题(序列化层面)。
但这次的问题更靠前:Claude 格式的 thinking content block 根本没有被转换为 OpenAI 格式的 reasoning_content
具体来说,在 service/convert.goClaudeToOpenAIRequest() 中:

for _, mediaMsg := range contents {
    switch mediaMsg.Type {
    case "text", "input_text":
        // ...
    case "image":
        // ...
    case "tool_use":
        // 转换为 tool_calls ✓
    case "tool_result":
        // ...
    // ← 缺少 case "thinking" 的处理!
    }
}

Claude 格式中,assistant 消息的思考内容是放在 content 数组里的 {"type": "thinking", "thinking": "..."} block 中,而不是在 ClaudeMessage.ReasoningContent 字段里。所以即使 ReasoningContent 字段的序列化问题修好了,这条转换路径上的 thinking 内容仍然会丢失。

修复方案

service/convert.goClaudeToOpenAIRequest() 中增加 thinking block 的处理:

contents := content
var toolCalls []dto.ToolCallRequest
mediaMessages := make([]dto.MediaContent, 0, len(contents))
var thinkingParts []string  // ← 新增

for _, mediaMsg := range contents {
    switch mediaMsg.Type {
    case "text", "input_text":
        // ...
    case "image":
        // ...
    case "thinking":  // ← 新增
        if mediaMsg.Thinking != nil && *mediaMsg.Thinking != "" {
            thinkingParts = append(thinkingParts, *mediaMsg.Thinking)
        }
    case "tool_use":
        // ...
    case "tool_result":
        // ...
    }
}

if len(toolCalls) > 0 {
    openAIMessage.SetToolCalls(toolCalls)
}
if len(thinkingParts) > 0 {  // ← 新增
    openAIMessage.SetReasoningContent(strings.Join(thinkingParts, "\n"))
}

同时放宽消息追加条件,避免"只有 reasoning_content 但没有 content"的消息被跳过:

// 修改前
if len(openAIMessage.ParseContent()) > 0 || len(openAIMessage.ToolCalls) > 0 {

// 修改后
if len(openAIMessage.ParseContent()) > 0 || len(openAIMessage.ToolCalls) > 0 || len(openAIMessage.ReasoningContent) > 0 {

修复后日志(问题解决)

转换输出日志

ClaudeToOpenAIRequest 转换后,assistant 消息正确携带了 reasoning_content

[debug-reasoning] ClaudeToOpenAIRequest output_messages=[
  {"role":"system", "has_reasoning_content":false},
  {"role":"user", "has_reasoning_content":false},
  {"role":"assistant", "content_type":"nil", "has_tool_calls":true, "tool_calls_len":2, "has_reasoning_content":true, "reasoning_content_type":"string"},
  {"role":"tool", "has_reasoning_content":false},
  {"role":"tool", "has_reasoning_content":false}
]

最终上游请求日志

发给上游的 assistant tool-call message 已正确包含 reasoning_content

[debug-reasoning] upstream assistant tool_call message index=2
summary=[{"role":"assistant","content_type":"nil","has_tool_calls":true,"tool_calls_len":2,"has_reasoning_content":true,"reasoning_content_type":"string"}]
json={"role":"assistant","content":null,"reasoning_content":"用户要求我先读取两个文件,然后给出分析结论。让我先读取这两个文件。","tool_calls":[...]}

请求成功

上游正常返回 200,流式响应完成:

[GIN] 2026/04/29 - 11:13:02 | relay | ... | 200 | 17.962593132s | POST /v1/messages?beta=true
stream ended: reason=done

多轮 tool call 后续请求同样正常(含多条 assistant thinking+tool_use 消息):

[GIN] 2026/04/29 - 11:13:45 | relay | ... | 200 | 24.346150844s | POST /v1/messages?beta=true
stream ended: reason=done

总结

层面 PR #4497 解决的问题 本次修复解决的问题
位置 DTO 序列化 Claude→OpenAI 转换逻辑
根因 omitempty 吞掉空字符串 thinking block 未被映射到 reasoning_content
场景 OpenAI 格式入口,字段已存在但被丢弃 Claude 格式入口,字段从未被生成
两个修复互补:PR #4497 确保"有值不丢",本次修复确保"值能被生成"。建议将本次修复也合入主线。

现在的问题是对接官方可以对接这个服务不行,我的修改是打算让直接原生格式对接可以生效,不走转换的场景,你这也没前置说明你是走怎么个逻辑怎么个转发,就让AI给你指一下可能毫不相干转换逻辑,然后生成这么一大段内容,我的修改未必是正确的,但是你这种回复方式真的令人感到不舒服,没有任何前置说明让AI替你回复内容,这很不礼貌。

抱歉,我的回复确实没有说清楚前置说明,给您造成困扰了;我再次梳理了场景与代码,我们针对的是不同场景,您此次pr修复的是接收claude格式转发为claude格式的场景,我在本地应用了这个pr后没有认真阅读你修改的场景,尝试用claude格式转发给openai格式的上游仍然报错,于是我打日志进行了debug并修改了代码;是我滥用AI进行回复了,冒犯到您,再次给您说声抱歉;最后想请教您一下,我们针对不同的场景,那么我此次的修复需要另提一个pr吗

@luoban

luoban commented May 8, 2026

Copy link
Copy Markdown

这个pr咋还不合进去呢? claude - newapi- kimi2.6claudeapi,1.0.0-rc4还是疯狂报错
image

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.

thinking is enabled but reasoning_content is missing in assistant tool call message at index 108

4 participants