Skip to content

fix(claude): preserve output_config on OpenAI-compat path - #4318

Open
minimAluminiumalism wants to merge 3 commits into
QuantumNous:mainfrom
minimAluminiumalism:fix/output-config-passthrough
Open

fix(claude): preserve output_config on OpenAI-compat path#4318
minimAluminiumalism wants to merge 3 commits into
QuantumNous:mainfrom
minimAluminiumalism:fix/output-config-passthrough

Conversation

@minimAluminiumalism

@minimAluminiumalism minimAluminiumalism commented Apr 17, 2026

Copy link
Copy Markdown

⚠️ 提交说明 / PR Notice

Important

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

📝 变更描述 / Description

修复 OpenAI 兼容端点 /v1/chat/completions 调用 Claude 模型时,output_config 字段(task_budget)被静默丢弃的问题。

🚀 变更类型 / 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

curl -X POST http://localhost:13001/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer sk-****" \
  -H "anthropic-beta: task-budgets-2026-03-13" \
  -d '{"model":"claude-opus-4-7","max_tokens":128,"messages":[{"role":"user","content":"reply with single letter
A"}],"output_config":{"effort":"high","task_budget":{"type":"tokens","total":20000}}}'
{"id":"msg_01PhJUfX283EfZMik1Ei4GFb","model":"claude-opus-4-7","object":"chat.completion","created":1776451877,"choices":[{"index":0,"message":{"role":"assistant","content":"A
"},"finish_reason":"stop"}],"usage":{"prompt_tokens":54,"completion_tokens":5,"total_tokens":59}}
HTTP_STATUS: 200

Summary by CodeRabbit

  • New Features

    • Claude requests accept an output configuration that preserves user task-budget settings while allowing effort levels to be applied across model variants and suffixes.
    • HTTP request handling will append the appropriate beta flag when task-budget settings are present.
  • Bug Fixes

    • Preserve existing output configuration when applying effort instead of overwriting it.
  • Tests

    • Added tests covering output-config merging, effort handling for model suffixes, and header behavior.

@coderabbitai

coderabbitai Bot commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

Added merging of an effort key into existing Claude output_config JSON, propagated output_config through the OpenAI-compatible request path, replaced direct overwrites with merges, and ensured anthropic-beta header is appended when task_budget is present. Added unit tests covering merge and header behaviors.

Changes

Cohort / File(s) Summary
Core helper & tests
dto/claude.go, dto/claude_output_config_test.go
Added MergeEffortIntoOutputConfig(existing json.RawMessage, effort string) json.RawMessage and tests for nil/empty/existing JSON, preserving task_budget, and overriding existing effort.
Request DTO
dto/openai_request.go
Added OutputConfig json.RawMessage \json:"output_config,omitempty"`toGeneralOpenAIRequestso OpenAI-compatible requests retain Claudeoutput_config`.
Relay translation & tests
relay/channel/claude/relay-claude.go, relay/channel/claude/relay_claude_output_config_test.go
Now propagates textRequest.OutputConfig into Claude requests; uses MergeEffortIntoOutputConfig when deriving effort from model suffixes or thinking paths; added tests verifying passthrough, suffix-to-effort merge, and header injection.
Handler & adaptor
relay/claude_handler.go, relay/channel/claude/adaptor.go
Replaced overwrite behavior in handler with merge; added EnsureBetaHeader(c *gin.Context, beta string) to append/merge anthropic-beta header when task_budget is present.

Sequence Diagram

sequenceDiagram
    participant Client as Client
    participant RelayHandler as Relay Handler
    participant DTOConverter as RequestOpenAI2ClaudeMessage
    participant MergeHelper as MergeEffortIntoOutputConfig
    participant Anthropic as Anthropic API

    Client->>RelayHandler: POST /v1/chat/completions with model, messages, output_config
    RelayHandler->>RelayHandler: Unmarshal GeneralOpenAIRequest (includes OutputConfig)
    RelayHandler->>DTOConverter: RequestOpenAI2ClaudeMessage(textRequest)

    alt model has effort suffix
        DTOConverter->>MergeHelper: MergeEffortIntoOutputConfig(textRequest.OutputConfig, effort)
        MergeHelper-->>DTOConverter: merged OutputConfig (effort set, existing keys preserved)
    else plain model
        DTOConverter-->>DTOConverter: propagate textRequest.OutputConfig as-is
    end

    DTOConverter-->>RelayHandler: claudeRequest with OutputConfig
    RelayHandler->>RelayHandler: if OutputConfig contains "task_budget" then EnsureBetaHeader(...,"task-budgets-2026-03-13")
    RelayHandler->>Anthropic: POST /v1/messages with claudeRequest (includes output_config)
    Anthropic-->>RelayHandler: response
    RelayHandler-->>Client: proxied response
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

Suggested reviewers

  • seefs001

Poem

🐰 I nibble JSON, tuck effort in tight,
Preserving budgets through day and night.
No more lost fields on the relay road,
Task budgets safe in my little code. 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.88% 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 'fix(claude): preserve output_config on OpenAI-compat path' clearly summarizes the main change—preserving output_config in OpenAI-compatible endpoint calls to Claude.
Linked Issues check ✅ Passed All three objectives from issue #4317 are met: (1) OutputConfig field added to GeneralOpenAIRequest [dto/openai_request.go], (2) OutputConfig propagated through relay-claude.go, (3) effort merged rather than replaced using MergeEffortIntoOutputConfig in both relay-claude.go and relay/claude_handler.go.
Out of Scope Changes check ✅ Passed All changes are directly related to the output_config preservation objective: helper function MergeEffortIntoOutputConfig, field additions, merge logic in request handlers, and beta header support for task-budgets. No unrelated changes detected.

✏️ 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.

Actionable comments posted: 1

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

11-70: LGTM — covers the main passthrough/merge scenarios end-to-end.

Tests validate: bare-model passthrough, suffix merge preserving task_budget, suffix-only effort when no user config, and thinking-suffix merging. One optional extension: a case asserting that a user-supplied effort in OutputConfig gets overridden by the suffix-derived value (mirroring the _OverridesExistingEffort unit test at the DTO level) would lock in end-to-end override semantics.

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

In `@relay/channel/claude/relay_claude_output_config_test.go` around lines 11 -
70, Add an end-to-end test that asserts a suffix-derived effort overrides a
user-supplied effort: create a new test (e.g.,
TestOutputConfig_SuffixOverridesExistingEffort) that calls
RequestOpenAI2ClaudeMessage with a model containing a suffix (like
"claude-opus-4-7-xhigh") and an OutputConfig that already contains
"effort":"low" plus another field such as task_budget; assert the returned
cr.Model has the suffix stripped, cr.OutputConfig unmarshals to a map where
"effort" equals the suffix-derived value ("xhigh") (not the user-provided
"low"), and other fields like "task_budget" are preserved.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@dto/claude.go`:
- Around line 424-432: The current MergeEffortIntoOutputConfig silently discards
non-object JSON by ignoring common.Unmarshal errors and resetting oc to an empty
map; change the logic in MergeEffortIntoOutputConfig to check the unmarshal
error and the runtime type: call common.Unmarshal into a temporary variable
(e.g., tmp any), if err != nil or tmp is not a map[string]any then return the
original existing payload unchanged (or propagate/log the error) so upstream
invalid/non-object JSON isn't masked; only when unmarshalling succeeds and tmp
asserts to map[string]any populate oc from that map, set oc["effort"]=effort,
then marshal and return.

---

Nitpick comments:
In `@relay/channel/claude/relay_claude_output_config_test.go`:
- Around line 11-70: Add an end-to-end test that asserts a suffix-derived effort
overrides a user-supplied effort: create a new test (e.g.,
TestOutputConfig_SuffixOverridesExistingEffort) that calls
RequestOpenAI2ClaudeMessage with a model containing a suffix (like
"claude-opus-4-7-xhigh") and an OutputConfig that already contains
"effort":"low" plus another field such as task_budget; assert the returned
cr.Model has the suffix stripped, cr.OutputConfig unmarshals to a map where
"effort" equals the suffix-derived value ("xhigh") (not the user-provided
"low"), and other fields like "task_budget" are preserved.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1771b73f-d806-4639-a4c4-5ae84dcdd9df

📥 Commits

Reviewing files that changed from the base of the PR and between 5b9dcf1 and d01c5d9.

📒 Files selected for processing (6)
  • dto/claude.go
  • dto/claude_output_config_test.go
  • dto/openai_request.go
  • relay/channel/claude/relay-claude.go
  • relay/channel/claude/relay_claude_output_config_test.go
  • relay/claude_handler.go

Comment thread dto/claude.go
Comment on lines +424 to +432
func MergeEffortIntoOutputConfig(existing json.RawMessage, effort string) json.RawMessage {
oc := map[string]any{}
if len(existing) > 0 {
_ = common.Unmarshal(existing, &oc)
}
oc["effort"] = effort
b, _ := common.Marshal(oc)
return b
}

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.

⚠️ Potential issue | 🟡 Minor

Silent fallback when existing is non-empty but not a JSON object.

If existing is non-empty but unmarshaling into map[string]any fails (e.g., it is a JSON array, number, or malformed), the error is discarded and oc silently resets to an empty map — the user-supplied payload is then dropped and replaced with only {"effort": ...}. For well-formed clients this is a non-issue, but it diverges from the PR's stated goal of surfacing upstream validation errors rather than silently dropping fields. Consider passing existing through unchanged (or logging) when it is non-nil but not a decodable object, so bad input is visible rather than masked.

🛡️ Optional defensive tweak
 func MergeEffortIntoOutputConfig(existing json.RawMessage, effort string) json.RawMessage {
 	oc := map[string]any{}
 	if len(existing) > 0 {
-		_ = common.Unmarshal(existing, &oc)
+		if err := common.Unmarshal(existing, &oc); err != nil {
+			common.SysLog("MergeEffortIntoOutputConfig: existing output_config is not a JSON object, ignoring: " + err.Error())
+		}
 	}
 	oc["effort"] = effort
 	b, _ := common.Marshal(oc)
 	return b
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@dto/claude.go` around lines 424 - 432, The current
MergeEffortIntoOutputConfig silently discards non-object JSON by ignoring
common.Unmarshal errors and resetting oc to an empty map; change the logic in
MergeEffortIntoOutputConfig to check the unmarshal error and the runtime type:
call common.Unmarshal into a temporary variable (e.g., tmp any), if err != nil
or tmp is not a map[string]any then return the original existing payload
unchanged (or propagate/log the error) so upstream invalid/non-object JSON isn't
masked; only when unmarshalling succeeds and tmp asserts to map[string]any
populate oc from that map, set oc["effort"]=effort, then marshal and return.

@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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@relay/channel/claude/adaptor.go`:
- Around line 83-96: EnsureBetaHeader currently checks for c == nil but can
still panic if c.Request or c.Request.Header is nil; update EnsureBetaHeader to
return early if c.Request is nil, and if c.Request.Header is nil initialize it
(c.Request.Header = make(http.Header)) before calling Get/Set so Header.Get/Set
won't panic during tests or ungated call sites.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f4cc977b-e63c-495e-a33a-2b0f2faa3fc5

📥 Commits

Reviewing files that changed from the base of the PR and between d01c5d9 and add9d8a.

📒 Files selected for processing (5)
  • dto/claude.go
  • relay/channel/claude/adaptor.go
  • relay/channel/claude/relay-claude.go
  • relay/channel/claude/relay_claude_output_config_test.go
  • relay/claude_handler.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • relay/channel/claude/relay_claude_output_config_test.go

Comment on lines +83 to +96
func EnsureBetaHeader(c *gin.Context, beta string) {
if c == nil {
return
}
existing := c.Request.Header.Get("anthropic-beta")
if strings.Contains(existing, beta) {
return
}
if existing != "" {
c.Request.Header.Set("anthropic-beta", existing+","+beta)
} else {
c.Request.Header.Set("anthropic-beta", beta)
}
}

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.

⚠️ Potential issue | 🟡 Minor

Guard against nil c.Request to avoid a panic.

EnsureBetaHeader already guards c == nil, but if c.Request is nil (e.g., in some test setups or ungated call sites) the c.Request.Header.Get(...) call will panic. A small extra nil check keeps the helper as defensive as its counterpart guard.

🛡️ Proposed tweak
 func EnsureBetaHeader(c *gin.Context, beta string) {
-	if c == nil {
+	if c == nil || c.Request == nil {
 		return
 	}
 	existing := c.Request.Header.Get("anthropic-beta")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func EnsureBetaHeader(c *gin.Context, beta string) {
if c == nil {
return
}
existing := c.Request.Header.Get("anthropic-beta")
if strings.Contains(existing, beta) {
return
}
if existing != "" {
c.Request.Header.Set("anthropic-beta", existing+","+beta)
} else {
c.Request.Header.Set("anthropic-beta", beta)
}
}
func EnsureBetaHeader(c *gin.Context, beta string) {
if c == nil || c.Request == nil {
return
}
existing := c.Request.Header.Get("anthropic-beta")
if strings.Contains(existing, beta) {
return
}
if existing != "" {
c.Request.Header.Set("anthropic-beta", existing+","+beta)
} else {
c.Request.Header.Set("anthropic-beta", beta)
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@relay/channel/claude/adaptor.go` around lines 83 - 96, EnsureBetaHeader
currently checks for c == nil but can still panic if c.Request or
c.Request.Header is nil; update EnsureBetaHeader to return early if c.Request is
nil, and if c.Request.Header is nil initialize it (c.Request.Header =
make(http.Header)) before calling Get/Set so Header.Get/Set won't panic during
tests or ungated call sites.

…ompat path

- Add OutputConfig field to GeneralOpenAIRequest so it survives JSON
  deserialization on /v1/chat/completions
- Copy user-supplied OutputConfig into ClaudeRequest before the
  effort-suffix / thinking-suffix branches run
- Replace direct OutputConfig assignment with MergeEffortIntoOutputConfig
  so the effort key is injected without clobbering task_budget or other
  user-supplied fields
- Add 8 unit / integration tests covering nil, merge, override, and
  passthrough scenarios

Closes QuantumNous#4317
- Auto-inject anthropic-beta: task-budgets-2026-03-13 header when
  output_config contains task_budget, so OpenAI SDK users don't need
  to manually set the beta header
- Add error logging in MergeEffortIntoOutputConfig when existing
  output_config is not a valid JSON object (CodeRabbit review feedback)
- Add 3 tests for beta header injection (inject, skip, append)
The Claude native path already has client passthrough and admin-config
mechanisms for anthropic-beta header. Auto-injection only belongs on
the OpenAI-compat conversion path.
@minimAluminiumalism
minimAluminiumalism force-pushed the fix/output-config-passthrough branch from 3acbddf to 9384105 Compare April 17, 2026 20:15

@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.go (1)

440-442: Substring match on "task_budget" is fragile.

bytes.Contains will also match when the literal string "task_budget" appears as a JSON value (or inside an escaped nested string) rather than as a top-level key. In practice OutputConfig is small and user-controlled, so the blast radius is limited, but a structural check is more robust and keeps intent explicit.

♻️ Proposed refactor
-	if bytes.Contains(claudeRequest.OutputConfig, []byte(`"task_budget"`)) {
-		EnsureBetaHeader(c, "task-budgets-2026-03-13")
-	}
+	if len(claudeRequest.OutputConfig) > 0 {
+		var oc map[string]json.RawMessage
+		if err := common.Unmarshal(claudeRequest.OutputConfig, &oc); err == nil {
+			if _, ok := oc["task_budget"]; ok {
+				EnsureBetaHeader(c, "task-budgets-2026-03-13")
+			}
+		}
+	}

With this change the bytes import on line 4 can also be dropped if no other usage remains.

🤖 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 440 - 442, The current
bytes.Contains check on claudeRequest.OutputConfig is brittle; instead parse
OutputConfig as JSON (e.g., unmarshal into map[string]json.RawMessage or
map[string]interface{}) and test for the presence of the top-level key
"task_budget" before calling EnsureBetaHeader(c, "task-budgets-2026-03-13");
also remove the bytes import if it becomes unused. Ensure you handle a non-JSON
or empty OutputConfig gracefully (treat as absent) and reference
claudeRequest.OutputConfig and EnsureBetaHeader in the change.
🤖 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.go`:
- Around line 440-442: The current bytes.Contains check on
claudeRequest.OutputConfig is brittle; instead parse OutputConfig as JSON (e.g.,
unmarshal into map[string]json.RawMessage or map[string]interface{}) and test
for the presence of the top-level key "task_budget" before calling
EnsureBetaHeader(c, "task-budgets-2026-03-13"); also remove the bytes import if
it becomes unused. Ensure you handle a non-JSON or empty OutputConfig gracefully
(treat as absent) and reference claudeRequest.OutputConfig and EnsureBetaHeader
in the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 350d24b2-dcad-45db-979c-3454de5dd266

📥 Commits

Reviewing files that changed from the base of the PR and between 3acbddf and 9384105.

📒 Files selected for processing (7)
  • dto/claude.go
  • dto/claude_output_config_test.go
  • dto/openai_request.go
  • relay/channel/claude/adaptor.go
  • relay/channel/claude/relay-claude.go
  • relay/channel/claude/relay_claude_output_config_test.go
  • relay/claude_handler.go
✅ Files skipped from review due to trivial changes (1)
  • dto/openai_request.go
🚧 Files skipped from review as they are similar to previous changes (4)
  • relay/channel/claude/adaptor.go
  • dto/claude.go
  • dto/claude_output_config_test.go
  • relay/channel/claude/relay_claude_output_config_test.go

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.

Bug: OpenAI 兼容端点 /v1/chat/completions 静默丢弃 Claude output_config(task_budget 等参数)

1 participant