Skip to content

支持自动复测已自动禁用的 Codex 渠道 - #4215

Open
FlamesCN wants to merge 6 commits into
QuantumNous:mainfrom
FlamesCN:pr/codex-auto-test-recovery
Open

支持自动复测已自动禁用的 Codex 渠道#4215
FlamesCN wants to merge 6 commits into
QuantumNous:mainfrom
FlamesCN:pr/codex-auto-test-recovery

Conversation

@FlamesCN

@FlamesCN FlamesCN commented Apr 13, 2026

Copy link
Copy Markdown

这个改动主要是为了解决 Codex 渠道因为额度或状态波动被自动禁用后,即使后面恢复了,也只能靠人工再测一次才能重新启用的问题。

这次调整了两块:

  • 给渠道测试加了默认 stream 开关,并把它落到渠道设置里,避免只停留在临时测试弹窗状态
  • 给监控加了一个开关,允许定时任务把“自动禁用”的渠道也纳入复测,但仍然跳过人工禁用的渠道

这样已有的自动启用逻辑就能真正接上,恢复后的 Codex 渠道可以靠定时复测自动回到可用状态,不用再等人手动点一遍。

验证:

  • go test ./controller ./setting/operation_setting
  • bun x prettier --check src/components/settings/OperationSetting.jsx src/components/table/channels/modals/EditChannelModal.jsx src/components/table/channels/modals/ModelTestModal.jsx src/hooks/channels/useChannelsData.jsx src/pages/Setting/Operation/SettingsMonitoring.jsx
  • bun run build

补充说明:

  • 这里有意只放开“自动禁用”的渠道恢复,人工禁用的渠道还是保持不自动拉起
  • 代码里没有改数据库 schema,沿用了现有 channel settings 和 monitor settings 结构

Summary by CodeRabbit

  • New Features

    • Channel testing now respects per-channel streaming defaults with an optional per-test override.
  • Settings

    • New option to include/exclude auto-disabled channels from automatic testing; UI switches added in Monitoring and Channel Affinity pages.
    • Test modal messaging now reflects streaming vs non-streaming mode.
  • Channel Affinity

    • Improved affinity templates and header/json synchronization for session/prompt keys (Claude/GPT compatibility) and request-header key sources.
  • Tests

    • Added unit and integration tests for stream-resolution, auto-test skipping, affinity, and sync behaviors.

Codex accounts can recover quota after being auto-disabled, but the existing scheduler skipped disabled channels and the stream-test preference only lived in the temporary test modal state.

This change persists a per-channel default stream test flag, adds a monitor setting that lets scheduled tests include auto-disabled channels while still skipping manually disabled ones, and wires the existing auto-enable flow so recovered channels can come back online without manual retesting.

Constraint: Reuse existing channel settings JSON and monitor settings without schema changes
Rejected: Keep recovery manual via per-channel retest | still leaves recovered Codex accounts offline until an operator notices
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Manually disabled channels are intentionally excluded from automatic recovery; do not widen this without product review
Tested: go test ./controller ./setting/operation_setting
Tested: bun x prettier --check src/components/settings/OperationSetting.jsx src/components/table/channels/modals/EditChannelModal.jsx src/components/table/channels/modals/ModelTestModal.jsx src/hooks/channels/useChannelsData.jsx src/pages/Setting/Operation/SettingsMonitoring.jsx
Tested: bun run build
Not-tested: go test ./... | existing unrelated failures remain in relay/channel/claude and relay/helper
(cherry picked from commit 36db011)
@coderabbitai

coderabbitai Bot commented Apr 13, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

Adds per-channel test-stream setting and resolution (request override → stored setting → channel-type default), centralizes auto-test skip logic, extends channel-affinity context/override handling, updates frontend UI/hooks to honor stream defaults and new monitoring option, and expands related tests and templates.

Changes

Cohort / File(s) Summary
Channel test logic (backend)
controller/channel-test.go
Read test_stream_enabled from channel.OtherSettings, resolve final stream via resolveChannelTestStream(override, stored, default), centralize shouldSkipChannelAutoTest, accept *bool override, update bulk test flows.
Channel test unit tests
controller/channel_test_logic_test.go
New tests for resolveChannelTestStream and shouldSkipChannelAutoTest.
DTO / Monitor settings (backend)
dto/channel_settings.go, setting/operation_setting/monitor_setting.go
Added TestStreamEnabled to ChannelOtherSettings; added AutoTestAutoDisabledChannelsEnabled to MonitorSetting (default true).
Frontend hooks & modals
web/src/hooks/channels/useChannelsData.jsx, web/src/components/table/channels/modals/ModelTestModal.jsx
Frontend resolves channel default stream, initializes modal toggle, test API always includes `&stream=<true
Frontend settings/UI
web/src/components/settings/OperationSetting.jsx, web/src/pages/Setting/Operation/SettingsMonitoring.jsx
Added monitor_setting.auto_test_auto_disabled_channels_enabled option and a Form.Switch UI, plus hints for auto-test switch.
Channel-affinity: context & templates (backend + relay + service + frontend)
relay/common/override.go, relay/common/relay_info.go, relay/common/override_test.go, service/channel_affinity.go, setting/operation_setting/channel_affinity_setting.go, service/channel_affinity_template_test.go, web/src/constants/channel-affinity-template.constants.js, web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx, web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx, constant/context_key.go
Added param-override context support, context: sync target, nestedPath for key sources, propagate ParamOverrideContext in RelayInfo, extend affinity meta with nested key info, add cache invalidation and retry reason handling, update Claude/GPT templates and tests.
Relay/middleware retry behavior
controller/relay.go, middleware/distributor.go
Changed retry-skip predicates to pass error info and added disabled-channel-specific skip checks.
Claude/OpenAI conversion tweaks
service/convert.go
Adjusted Claude→OpenAI request/response streaming and usage handling logic.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Frontend
    participant API
    participant Controller
    participant DB

    User->>Frontend: open ModelTestModal / click Test
    Frontend->>API: request modal data / trigger test (optional ?stream override)
    API->>Controller: handle TestChannel request (with optional override)
    Controller->>DB: read channel.OtherSettings (test_stream_enabled)
    DB-->>Controller: return settings JSON
    Controller->>Controller: resolveChannelTestStream(override, storedSetting, channelTypeDefault)
    Controller->>API: perform/queue test with resolved stream param
    API-->>Frontend: response (started/result)
    Frontend->>User: display banner/status based on resolved stream
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

ready to merge

Suggested reviewers

  • seefs001

Poem

🐇 I tunneled through JSON, toggles in my paw,
A stream, an override, defaults I saw.
I stitched contexts, templates, and tests so neat,
Channels hum along — oh what a treat!
Hop, hop, hooray — the pipeline's complete.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.23% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: enabling auto-retest of Codex channels that have been auto-disabled, which aligns with the PR objectives of allowing disabled Codex channels to be automatically recovered through scheduled testing.

✏️ 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 (1)
web/src/components/settings/OperationSetting.jsx (1)

76-82: Comment placement is misleading.

The inline comment /* 签到设置 */ (checkin settings) on line 77 now appears to describe the new monitoring setting on line 78, which is confusing. Consider moving the comment to its proper location.

📝 Suggested fix
     'monitor_setting.auto_test_channel_enabled': false,
-    'monitor_setting.auto_test_channel_minutes': 10 /* 签到设置 */,
+    'monitor_setting.auto_test_channel_minutes': 10,
     'monitor_setting.auto_test_auto_disabled_channels_enabled': true,
+
+    /* 签到设置 */
     'checkin_setting.enabled': false,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/components/settings/OperationSetting.jsx` around lines 76 - 82, The
inline comment "/* 签到设置 */" is placed after
'monitor_setting.auto_test_channel_minutes' making it look like it documents the
monitor setting; move that comment to immediately before (or after) the block
starting with 'checkin_setting.enabled' so it clearly applies to the checkin
settings; e.g., relocate the comment from near
'monitor_setting.auto_test_channel_minutes' to above 'checkin_setting.enabled'
(or next to 'checkin_setting.min_quota'/'checkin_setting.max_quota') so the
intent for checkin_setting.* is unambiguous.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@web/src/components/settings/OperationSetting.jsx`:
- Around line 76-82: The inline comment "/* 签到设置 */" is placed after
'monitor_setting.auto_test_channel_minutes' making it look like it documents the
monitor setting; move that comment to immediately before (or after) the block
starting with 'checkin_setting.enabled' so it clearly applies to the checkin
settings; e.g., relocate the comment from near
'monitor_setting.auto_test_channel_minutes' to above 'checkin_setting.enabled'
(or next to 'checkin_setting.min_quota'/'checkin_setting.max_quota') so the
intent for checkin_setting.* is unambiguous.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e824883f-0305-4b43-8c3c-ae80c753734d

📥 Commits

Reviewing files that changed from the base of the PR and between 3ab65a8 and 7488878.

📒 Files selected for processing (9)
  • controller/channel-test.go
  • controller/channel_test_logic_test.go
  • dto/channel_settings.go
  • setting/operation_setting/monitor_setting.go
  • web/src/components/settings/OperationSetting.jsx
  • web/src/components/table/channels/modals/EditChannelModal.jsx
  • web/src/components/table/channels/modals/ModelTestModal.jsx
  • web/src/hooks/channels/useChannelsData.jsx
  • web/src/pages/Setting/Operation/SettingsMonitoring.jsx

Legacy Codex channels without a persisted test_stream_enabled flag were treated
as non-stream by the edit form and scheduled auto-tests. After an operator
opened Edit Channel to refresh OAuth and saved, the modal wrote back an explicit
false value, which kept scheduled recovery probes on /v1/responses failing with
"Stream must be set to true".

This change treats a missing test_stream_enabled flag as stream-on for Codex
channels in both the backend scheduler and the frontend edit/test flows, while
still honoring an explicitly saved false value.

Constraint: Existing channels may already rely on an explicit false flag, so missing and false must stay distinguishable
Rejected: One-off database backfill for current channels | fixes today's rows but not future edits of other legacy Codex channels
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Codex defaults should remain compatibility-aware; do not collapse missing and explicit false back into one path
Tested: go test ./controller
Tested: bun x prettier --check web/src/components/table/channels/modals/EditChannelModal.jsx web/src/hooks/channels/useChannelsData.jsx
Tested: bun run build
Not-tested: Manual UI verification before deploy

@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)
web/src/hooks/channels/useChannelsData.jsx (2)

43-43: Reuse CODEX_CHANNEL_TYPE for the remaining Codex branch too.

This file now has both CODEX_CHANNEL_TYPE and a separate 57 literal in updateChannelBalance. Keeping both makes the Codex-specific behavior easy to desync later.

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

In `@web/src/hooks/channels/useChannelsData.jsx` at line 43, The Codex channel
type literal (57) is used directly in updateChannelBalance while a constant
CODEX_CHANNEL_TYPE is defined; replace the hardcoded 57 in the
updateChannelBalance logic with the CODEX_CHANNEL_TYPE constant to avoid drift,
updating any conditional checks or comparisons inside updateChannelBalance (and
any helper functions it calls) to reference CODEX_CHANNEL_TYPE instead of the
numeric literal.

902-923: Keep stream optional unless the user explicitly overrides it.

Line 922 always sends stream=true|false, so this UI path no longer lets the backend apply its new fallback chain (request override -> stored setting -> channel default). That recreates a second source of truth in the client and can drift from persisted settings if the row data is stale. Consider tracking an explicit override flag and only appending stream when the user actually changed the switch.

♻️ Direction for the request-building change
- const testChannel = async (record, model, endpointType = '', stream) => {
-   const resolvedStream =
-     typeof stream === 'boolean'
-       ? stream
-       : getChannelDefaultStreamTest(record);
+ const testChannel = async (record, model, endpointType = '', streamOverride) => {
+   const hasStreamOverride = typeof streamOverride === 'boolean';

    let url = `/api/channel/test/${record.id}?model=${model}`;
    if (endpointType) {
      url += `&endpoint_type=${endpointType}`;
    }
-   url += `&stream=${resolvedStream ? 'true' : 'false'}`;
+   if (hasStreamOverride) {
+     url += `&stream=${streamOverride ? 'true' : 'false'}`;
+   }

You'd then keep the modal's displayed default separate from whether the user has explicitly overridden it.

[scratchpad_end] -->
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/hooks/channels/useChannelsData.jsx` around lines 902 - 923, The
testChannel function currently always appends &stream=true|false which forces
the client-side resolvedStream and prevents the backend fallback; change
testChannel to accept and use an explicit override flag (e.g., streamOverride or
streamIsOverridden) and only append the &stream=... query param when that
override is true, leaving the param out when undefined so the backend can apply
its fallback chain; locate the logic around resolvedStream, the stream
parameter, and the URL construction in testChannel and adjust the API.get URL
building to conditionally include stream based on the new override flag while
keeping existing checks like shouldStopBatchTestingRef, isBatchTesting, and
setTestingModels intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@web/src/hooks/channels/useChannelsData.jsx`:
- Line 43: The Codex channel type literal (57) is used directly in
updateChannelBalance while a constant CODEX_CHANNEL_TYPE is defined; replace the
hardcoded 57 in the updateChannelBalance logic with the CODEX_CHANNEL_TYPE
constant to avoid drift, updating any conditional checks or comparisons inside
updateChannelBalance (and any helper functions it calls) to reference
CODEX_CHANNEL_TYPE instead of the numeric literal.
- Around line 902-923: The testChannel function currently always appends
&stream=true|false which forces the client-side resolvedStream and prevents the
backend fallback; change testChannel to accept and use an explicit override flag
(e.g., streamOverride or streamIsOverridden) and only append the &stream=...
query param when that override is true, leaving the param out when undefined so
the backend can apply its fallback chain; locate the logic around
resolvedStream, the stream parameter, and the URL construction in testChannel
and adjust the API.get URL building to conditionally include stream based on the
new override flag while keeping existing checks like shouldStopBatchTestingRef,
isBatchTesting, and setTestingModels intact.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a1079798-c9d6-45f7-bf7c-bfaaf78cdc3c

📥 Commits

Reviewing files that changed from the base of the PR and between 7488878 and 37b0ad0.

📒 Files selected for processing (4)
  • controller/channel-test.go
  • controller/channel_test_logic_test.go
  • web/src/components/table/channels/modals/EditChannelModal.jsx
  • web/src/hooks/channels/useChannelsData.jsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • controller/channel_test_logic_test.go
  • controller/channel-test.go

…upstreams

Channel affinity was pinning requests to dead cache entries and still treating
some quota failures as hard stop conditions even when the new retry toggles were
enabled. This makes the retry decision reason-aware, invalidates unusable cached
channels, wires the new operation settings into the backend and UI, and adds
regression coverage for text-only quota errors such as the upstream usage-limit
message.

Constraint: Upstream providers do not report quota exhaustion with one stable error code or message
Rejected: Match only insufficient_quota code/type | misses providers that only return usage-limit text
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Quota-failure matching is a compatibility surface; extend it only with observed upstream evidence
Tested: go test ./service -run "TestShouldSkipRetryAfterChannelAffinity|TestGetPreferredChannelByAffinity|TestChannelAffinityHitCodexTemplatePassHeadersEffective"
Tested: go test ./controller ./middleware ./service ./setting/...
Not-tested: Frontend production build before commit
Claude Code cache reuse on the /v1/messages -> responses compatibility path
should be driven by channel-affinity rules and override templates, not by
hardcoded metadata parsing. This change adds request-header and nested JSON
key extraction, exposes the resolved affinity key to override context, and
lets templates sync that key into prompt_cache_key and session_id from the
console.

Constraint: Operators need to adapt to client header/metadata shape changes without shipping a new binary
Rejected: Keep parsing metadata.user_id.session_id in convert.go | brittle to client request format changes
Rejected: Add a separate Claude-specific settings surface | duplicates existing channel-affinity and param-override controls
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep Claude cache-affinity behavior driven by key_sources plus override templates; avoid reintroducing client-specific parsing in convert paths
Tested: go test ./...
Tested: bun run build
Tested: production smoke test on 38.76.144.165 for /v1/messages -> gpt-5.4 with metadata-only and header-only session keys; second request hit cache in both cases
Not-tested: GitHub PR creation via gh CLI (local auth token invalid)
…sabled

Codex requests can hit channel affinity with skip-retry enabled, which unintentionally blocked failover when the preferred channel returned a disabled-channel error. This change classifies disabled-channel failures alongside the existing affinity retry exceptions so the configured retry path can continue selecting another healthy channel.\n\nConstraint: Must preserve skip-retry behavior for unrelated affinity failures\nRejected: Disable skip-retry for the Codex affinity template globally | would broaden retries beyond disabled/quota failures and change existing operator intent\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nDirective: Keep disabled-channel detection aligned with upstream/user-facing error variants before adding new affinity retry exceptions\nTested: go test ./service -run 'TestShouldSkipRetryAfterChannelAffinity(DisabledChannel|Error_)'\nNot-tested: Full relay integration against live Codex channels
… branch

This branch predates the newer channel-affinity retry toggles, so backporting the stale-cache, Claude cache-key, and disabled-channel failover fixes left the settings model without the fields those changes expect. Restoring the missing config surface keeps the backported behavior consistent with dev without reopening older logic paths.\n\nConstraint: The clean branch must remain buildable after backporting later channel-affinity fixes\nRejected: Drop the backported retry logic from this branch | would leave the related PR lane without the disabled-channel failover fix the user asked to carry over\nConfidence: medium\nScope-risk: narrow\nReversibility: clean\nDirective: If more dev-era affinity fixes are backported here, keep the settings schema aligned before testing service behavior\nTested: go test ./service -run 'TestShouldSkipRetryAfterChannelAffinity(DisabledChannel|Error_)'\nNot-tested: Broader branch regression suite

@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: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx (1)

152-163: ⚠️ Potential issue | 🟡 Minor

Guard nested_path normalization against non-string values

Line 156 can throw if nested_path is a truthy non-string value from JSON mode input. Coerce before trimming to avoid runtime crashes in the editor/render path.

💡 Suggested fix
+const safeTrim = (v) => String(v ?? '').trim();
+
 const normalizeKeySource = (src) => {
-  const type = (src?.type || '').trim();
-  const key = (src?.key || '').trim();
-  const path = (src?.path || '').trim();
-  const nestedPath = (src?.nested_path || src?.nestedPath || '').trim();
+  const type = safeTrim(src?.type);
+  const key = safeTrim(src?.key);
+  const path = safeTrim(src?.path);
+  const nestedPath = safeTrim(src?.nested_path ?? src?.nestedPath);

   if (type === 'gjson') {
     return { type, key: '', path, nested_path: nestedPath };
   }

   return { type, key, path: '', nested_path: nestedPath };
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx` around lines 152
- 163, normalizeKeySource can throw when src.nested_path (or src.nestedPath) is
a non-string truthy value because trim() is called directly; update the
normalization to guard/coerce nestedPath to a string before trimming (e.g.,
check typeof or use String(src?.nested_path || src?.nestedPath || '') then
.trim()) and return the coerced nested_path in the returned object so
normalizeKeySource always returns strings for nested_path.
service/channel_affinity.go (1)

751-783: ⚠️ Potential issue | 🟠 Major

Reason-aware retry overrides are currently bypassed once affinity has been marked used.

MarkChannelAffinityUsed() stores ginKeyChannelAffinitySkipRetry = meta.SkipRetry, and this helper returns that flag before it ever looks at RetryOnDisabledChannel / RetryOnChannelQuotaExceeded. In the normal runtime path that means a rule with SkipRetryOnFailure: true will still suppress failover for disabled/quota failures, so the new toggles never take effect after an affinity hit.

Proposed fix
 func shouldSkipRetryAfterChannelAffinityFailureWithReason(c *gin.Context, reason channelAffinityFailureReason) bool {
 	if c == nil {
 		return false
 	}
-	v, ok := c.Get(ginKeyChannelAffinitySkipRetry)
-	if ok {
-		b, ok := v.(bool)
-		if ok {
-			return b
-		}
-	}
 	meta, ok := getChannelAffinityMeta(c)
 	if !ok {
 		return false
 	}
-	if !meta.SkipRetry {
+
+	skipRetry := meta.SkipRetry
+	if v, ok := c.Get(ginKeyChannelAffinitySkipRetry); ok {
+		if b, ok := v.(bool); ok {
+			skipRetry = b
+		}
+	}
+	if !skipRetry {
 		return false
 	}
 	setting := operation_setting.GetChannelAffinitySetting()
 	if setting == nil {
 		return true
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/channel_affinity.go` around lines 751 - 783, The helper
shouldSkipRetryAfterChannelAffinityFailureWithReason currently returns the
stored ginKeyChannelAffinitySkipRetry flag before consulting reason-specific
toggles; change it to first retrieve the operation_setting via
GetChannelAffinitySetting(), and if a setting exists apply the reason-specific
RetryOnDisabledChannel / RetryOnChannelQuotaExceeded checks to override skipping
(return false when retry is allowed for that reason), otherwise fall back to the
saved meta.SkipRetry or ginKeyChannelAffinitySkipRetry; reference
shouldSkipRetryAfterChannelAffinityFailureWithReason, MarkChannelAffinityUsed
and ginKeyChannelAffinitySkipRetry to locate and update the logic so SkipRetry
is not a hard short-circuit that prevents the setting toggles from taking
effect.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@service/convert.go`:
- Around line 620-623: The current assignment to claudeResponse.Usage only sets
InputTokens/OutputTokens and drops cache-related fields; update the mapping to
populate dto.ClaudeUsage's CacheCreationInputTokens, CacheReadInputTokens and
the cache split fields (the 5m/1h counters) from the source openAIResponse.Usage
(e.g. openAIResponse.Usage.CacheCreationInputTokens,
openAIResponse.Usage.CacheReadInputTokens,
openAIResponse.Usage.CacheReadInputTokens5m/1h or whatever the exact field names
are) so non-stream Claude responses report the same cache usage as the streaming
path — you can still take the address of openAIResponse.Usage (it’s embedded by
value) when assigning to claudeResponse.Usage.
- Around line 429-447: The branch handling OpenAI responses with
len(openAIResponse.Choices) == 0 is currently gated by
info.ClaudeConvertInfo.Done and therefore never runs because
StreamResponseOpenAI2Claude returns before Done is set; fix it by removing or
changing the Done gate so final usage-only chunks are emitted: inside
StreamResponseOpenAI2Claude, when choices==0 detect the final chunk via
info.FinishReason (or the presence of info.ClaudeConvertInfo.Usage) and call
stopOpenBlocks() and append the message_delta (using
buildClaudeUsageFromOpenAIUsage(info.ClaudeConvertInfo.Usage)) and message_stop
to claudeResponses regardless of info.ClaudeConvertInfo.Done, or set
info.ClaudeConvertInfo.Done earlier before returning so the existing append code
executes; update the logic around stopOpenBlocks(), the construction of
dto.ClaudeResponse (Type/message_delta, Usage, Delta.StopReason) and the
subsequent message_stop append accordingly.
- Around line 237-240: Restore the cache-creation split normalization when
populating usage.CacheCreation: when building dto.ClaudeCacheCreationUsage from
oaiUsage, prefer existing
oaiUsage.ClaudeCacheCreation5mTokens/ClaudeCacheCreation1hTokens if they are
present, but if they are missing or zero and oaiUsage.CacheCreationInputTokens
(aggregate) is set, derive the 5m/1h split using the same normalization logic
used elsewhere (i.e., compute Ephemeral5mInputTokens and Ephemeral1hInputTokens
from CacheCreationInputTokens), then assign those normalized values to
Ephemeral5mInputTokens and Ephemeral1hInputTokens on usage.CacheCreation so
cache_creation is not dropped or inconsistent.

---

Outside diff comments:
In `@service/channel_affinity.go`:
- Around line 751-783: The helper
shouldSkipRetryAfterChannelAffinityFailureWithReason currently returns the
stored ginKeyChannelAffinitySkipRetry flag before consulting reason-specific
toggles; change it to first retrieve the operation_setting via
GetChannelAffinitySetting(), and if a setting exists apply the reason-specific
RetryOnDisabledChannel / RetryOnChannelQuotaExceeded checks to override skipping
(return false when retry is allowed for that reason), otherwise fall back to the
saved meta.SkipRetry or ginKeyChannelAffinitySkipRetry; reference
shouldSkipRetryAfterChannelAffinityFailureWithReason, MarkChannelAffinityUsed
and ginKeyChannelAffinitySkipRetry to locate and update the logic so SkipRetry
is not a hard short-circuit that prevents the setting toggles from taking
effect.

In `@web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx`:
- Around line 152-163: normalizeKeySource can throw when src.nested_path (or
src.nestedPath) is a non-string truthy value because trim() is called directly;
update the normalization to guard/coerce nestedPath to a string before trimming
(e.g., check typeof or use String(src?.nested_path || src?.nestedPath || '')
then .trim()) and return the coerced nested_path in the returned object so
normalizeKeySource always returns strings for nested_path.
🪄 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: 213444b3-da57-4d0f-ad15-23db7ef5be61

📥 Commits

Reviewing files that changed from the base of the PR and between 37b0ad0 and b7e1742.

📒 Files selected for processing (13)
  • constant/context_key.go
  • controller/relay.go
  • middleware/distributor.go
  • relay/common/override.go
  • relay/common/override_test.go
  • relay/common/relay_info.go
  • service/channel_affinity.go
  • service/channel_affinity_template_test.go
  • service/convert.go
  • setting/operation_setting/channel_affinity_setting.go
  • web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx
  • web/src/constants/channel-affinity-template.constants.js
  • web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx
✅ Files skipped from review due to trivial changes (2)
  • constant/context_key.go
  • relay/common/override_test.go

Comment thread service/convert.go
Comment on lines +237 to +240
if oaiUsage.ClaudeCacheCreation5mTokens > 0 || oaiUsage.ClaudeCacheCreation1hTokens > 0 {
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{
Ephemeral5mInputTokens: cacheCreation5m,
Ephemeral1hInputTokens: cacheCreation1h,
Ephemeral5mInputTokens: oaiUsage.ClaudeCacheCreation5mTokens,
Ephemeral1hInputTokens: oaiUsage.ClaudeCacheCreation1hTokens,

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 | 🟠 Major

Restore cache-creation split normalization here.

CacheCreationInputTokens can be set even when the 5m/1h split is absent or partial. Copying the raw split fields directly can now emit inconsistent Claude usage, or drop cache_creation entirely when only the aggregate count is known. The other Claude usage path still normalizes these values before exposing them.

🔧 Suggested fix
-	if oaiUsage.ClaudeCacheCreation5mTokens > 0 || oaiUsage.ClaudeCacheCreation1hTokens > 0 {
+	cacheCreation5m, cacheCreation1h := NormalizeCacheCreationSplit(
+		usage.CacheCreationInputTokens,
+		oaiUsage.ClaudeCacheCreation5mTokens,
+		oaiUsage.ClaudeCacheCreation1hTokens,
+	)
+	if cacheCreation5m > 0 || cacheCreation1h > 0 {
 		usage.CacheCreation = &dto.ClaudeCacheCreationUsage{
-			Ephemeral5mInputTokens: oaiUsage.ClaudeCacheCreation5mTokens,
-			Ephemeral1hInputTokens: oaiUsage.ClaudeCacheCreation1hTokens,
+			Ephemeral5mInputTokens: cacheCreation5m,
+			Ephemeral1hInputTokens: cacheCreation1h,
 		}
 	}
📝 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
if oaiUsage.ClaudeCacheCreation5mTokens > 0 || oaiUsage.ClaudeCacheCreation1hTokens > 0 {
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{
Ephemeral5mInputTokens: cacheCreation5m,
Ephemeral1hInputTokens: cacheCreation1h,
Ephemeral5mInputTokens: oaiUsage.ClaudeCacheCreation5mTokens,
Ephemeral1hInputTokens: oaiUsage.ClaudeCacheCreation1hTokens,
cacheCreation5m, cacheCreation1h := NormalizeCacheCreationSplit(
usage.CacheCreationInputTokens,
oaiUsage.ClaudeCacheCreation5mTokens,
oaiUsage.ClaudeCacheCreation1hTokens,
)
if cacheCreation5m > 0 || cacheCreation1h > 0 {
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{
Ephemeral5mInputTokens: cacheCreation5m,
Ephemeral1hInputTokens: cacheCreation1h,
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/convert.go` around lines 237 - 240, Restore the cache-creation split
normalization when populating usage.CacheCreation: when building
dto.ClaudeCacheCreationUsage from oaiUsage, prefer existing
oaiUsage.ClaudeCacheCreation5mTokens/ClaudeCacheCreation1hTokens if they are
present, but if they are missing or zero and oaiUsage.CacheCreationInputTokens
(aggregate) is set, derive the 5m/1h split using the same normalization logic
used elsewhere (i.e., compute Ephemeral5mInputTokens and Ephemeral1hInputTokens
from CacheCreationInputTokens), then assign those normalized values to
Ephemeral5mInputTokens and Ephemeral1hInputTokens on usage.CacheCreation so
cache_creation is not dropped or inconsistent.

Comment thread service/convert.go
Comment on lines 429 to 447
if len(openAIResponse.Choices) == 0 {
// Some OpenAI-compatible upstreams end with a usage-only SSE chunk.
oaiUsage := openAIResponse.Usage
if oaiUsage == nil {
oaiUsage = info.ClaudeConvertInfo.Usage
}
if oaiUsage != nil {
// no choices
// 可能为非标准的 OpenAI 响应,判断是否已经完成
if info.ClaudeConvertInfo.Done {
stopOpenBlocks()
stopReason := stopReasonOpenAI2Claude(info.FinishReason)
if stopReason == "" {
stopReason = "end_turn"
oaiUsage := info.ClaudeConvertInfo.Usage
if oaiUsage != nil {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
Delta: &dto.ClaudeMediaMessage{
StopReason: common.GetPointer[string](stopReasonOpenAI2Claude(info.FinishReason)),
},
})
}
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
Delta: &dto.ClaudeMediaMessage{
StopReason: common.GetPointer[string](stopReason),
},
})
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_stop",
})
info.ClaudeConvertInfo.Done = true
}

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 | 🔴 Critical

This terminal branch is unreachable in the current call flow.

StreamResponseOpenAI2Claude returns immediately when info.ClaudeConvertInfo.Done is already true, and the main caller only flips Done after this function returns. For usage-only final chunks (choices == 0), this condition never passes, so Claude clients can miss the final message_delta/message_stop.

🔧 Suggested fix
 	if len(openAIResponse.Choices) == 0 {
 		// no choices
 		// 可能为非标准的 OpenAI 响应,判断是否已经完成
-		if info.ClaudeConvertInfo.Done {
+		if info.FinishReason != "" || info.ClaudeConvertInfo.Usage != nil {
 			stopOpenBlocks()
 			oaiUsage := info.ClaudeConvertInfo.Usage
 			if oaiUsage != nil {
 				claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
 					Type:  "message_delta",
 					Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
 					Delta: &dto.ClaudeMediaMessage{
 						StopReason: common.GetPointer[string](stopReasonOpenAI2Claude(info.FinishReason)),
 					},
 				})
 			}
 			claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
 				Type: "message_stop",
 			})
+			info.ClaudeConvertInfo.Done = true
 		}
 		return claudeResponses
 	}
📝 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
if len(openAIResponse.Choices) == 0 {
// Some OpenAI-compatible upstreams end with a usage-only SSE chunk.
oaiUsage := openAIResponse.Usage
if oaiUsage == nil {
oaiUsage = info.ClaudeConvertInfo.Usage
}
if oaiUsage != nil {
// no choices
// 可能为非标准的 OpenAI 响应,判断是否已经完成
if info.ClaudeConvertInfo.Done {
stopOpenBlocks()
stopReason := stopReasonOpenAI2Claude(info.FinishReason)
if stopReason == "" {
stopReason = "end_turn"
oaiUsage := info.ClaudeConvertInfo.Usage
if oaiUsage != nil {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
Delta: &dto.ClaudeMediaMessage{
StopReason: common.GetPointer[string](stopReasonOpenAI2Claude(info.FinishReason)),
},
})
}
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
Delta: &dto.ClaudeMediaMessage{
StopReason: common.GetPointer[string](stopReason),
},
})
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_stop",
})
info.ClaudeConvertInfo.Done = true
}
if len(openAIResponse.Choices) == 0 {
// no choices
// 可能为非标准的 OpenAI 响应,判断是否已经完成
if info.FinishReason != "" || info.ClaudeConvertInfo.Usage != nil {
stopOpenBlocks()
oaiUsage := info.ClaudeConvertInfo.Usage
if oaiUsage != nil {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
Delta: &dto.ClaudeMediaMessage{
StopReason: common.GetPointer[string](stopReasonOpenAI2Claude(info.FinishReason)),
},
})
}
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_stop",
})
info.ClaudeConvertInfo.Done = true
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/convert.go` around lines 429 - 447, The branch handling OpenAI
responses with len(openAIResponse.Choices) == 0 is currently gated by
info.ClaudeConvertInfo.Done and therefore never runs because
StreamResponseOpenAI2Claude returns before Done is set; fix it by removing or
changing the Done gate so final usage-only chunks are emitted: inside
StreamResponseOpenAI2Claude, when choices==0 detect the final chunk via
info.FinishReason (or the presence of info.ClaudeConvertInfo.Usage) and call
stopOpenBlocks() and append the message_delta (using
buildClaudeUsageFromOpenAIUsage(info.ClaudeConvertInfo.Usage)) and message_stop
to claudeResponses regardless of info.ClaudeConvertInfo.Done, or set
info.ClaudeConvertInfo.Done earlier before returning so the existing append code
executes; update the logic around stopOpenBlocks(), the construction of
dto.ClaudeResponse (Type/message_delta, Usage, Delta.StopReason) and the
subsequent message_stop append accordingly.

Comment thread service/convert.go
Comment on lines +620 to +623
claudeResponse.Usage = &dto.ClaudeUsage{
InputTokens: openAIResponse.PromptTokens,
OutputTokens: openAIResponse.CompletionTokens,
}

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 | 🟠 Major

Non-stream Claude responses now drop cache usage fields.

This replacement keeps only prompt/completion totals and loses CacheCreationInputTokens, CacheReadInputTokens, and the 5m/1h cache split that the streaming path still preserves. Cached Claude responses will now under-report usage compared with the rest of the relay.

🔧 Suggested fix
-	claudeResponse.Usage = &dto.ClaudeUsage{
-		InputTokens:  openAIResponse.PromptTokens,
-		OutputTokens: openAIResponse.CompletionTokens,
-	}
+	claudeResponse.Usage = buildClaudeUsageFromOpenAIUsage(&openAIResponse.Usage)
Based on learnings, `dto.OpenAITextResponse` embeds `Usage` by value (`Usage`, not `*Usage`), so taking `&openAIResponse.Usage` here does not need a nil guard.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/convert.go` around lines 620 - 623, The current assignment to
claudeResponse.Usage only sets InputTokens/OutputTokens and drops cache-related
fields; update the mapping to populate dto.ClaudeUsage's
CacheCreationInputTokens, CacheReadInputTokens and the cache split fields (the
5m/1h counters) from the source openAIResponse.Usage (e.g.
openAIResponse.Usage.CacheCreationInputTokens,
openAIResponse.Usage.CacheReadInputTokens,
openAIResponse.Usage.CacheReadInputTokens5m/1h or whatever the exact field names
are) so non-stream Claude responses report the same cache usage as the streaming
path — you can still take the address of openAIResponse.Usage (it’s embedded by
value) when assigning to claudeResponse.Usage.

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.

1 participant