Skip to content

feat: per-model testing and disable in channel test - #4380

Open
wanghualoong wants to merge 4 commits into
QuantumNous:mainfrom
wanghualoong:feature/per-model-channel-test
Open

feat: per-model testing and disable in channel test#4380
wanghualoong wants to merge 4 commits into
QuantumNous:mainfrom
wanghualoong:feature/per-model-channel-test

Conversation

@wanghualoong

@wanghualoong wanghualoong commented Apr 22, 2026

Copy link
Copy Markdown

Summary

  • Per-model testing: testAllChannels() now iterates every model in each channel individually, instead of testing one representative model per channel
  • Granular disable: Model-level errors (timeout, model not found) only disable that specific model's ability, not the entire channel; channel-level errors (invalid API key, insufficient quota, auth errors) still disable the entire channel
  • 120s timeout protection: Each model test runs with a 120-second timeout via goroutine + select + time.After, preventing upstream non-response from blocking the entire test flow
  • Image model skip: Models containing seedream or image-preview in their name are automatically skipped (marked as unsupported)
  • Auto-enable respects settings: Model and channel re-enable now checks AutomaticEnableChannelEnabled setting
  • Per-model notifications: Each disabled model sends its own notification with channel name + model name + reason
  • Per-model test history: Each model's test result is recorded individually in channel_test_histories table

Changed files

File Change
controller/channel-test.go Rewrote testAllChannels() with dual-loop per-model testing, 120s timeout, image model skip
model/ability.go Added UpdateAbilityModelStatus(), IsAbilityModelEnabled()
service/channel.go Added IsChannelLevelError(), DisableChannelModel(), EnableChannelModel()
model/channel_test_history.go New file: ChannelTestHistory model with RecordChannelTestHistory(), PruneChannelTestHistory()
model/main.go Added ChannelTestHistory to AutoMigrate

Error classification

  • Channel-level (IsChannelLevelError()): invalid_api_key, account_deactivated, billing_not_active, insufficient_quota, authentication_error, permission_error, etc. → disable entire channel + skip remaining models
  • Model-level: timeout, model not supported, other errors → disable only that model's ability via DisableChannelModel()

Test plan

  • Docker build succeeds with cache (~22s)
  • Manual trigger via UI: 46 models tested (38 operational, 7 unsupported, 1 timeout handled gracefully)
  • Channel-level error correctly disables entire channel
  • Model-level error only disables the specific model's ability
  • Previously disabled model re-enabled on successful test (respects AutomaticEnableChannelEnabled)
  • Per-model notifications sent with correct channel + model info
  • channel_test_histories table records per-model results
  • 120s timeout protection prevents goroutine hang (tested with GLM upstream non-response)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Per-model channel testing with individual timeouts, averaged response-time tracking, and conditional per-model auto-disable/auto-enable.
    • Persistent per-test history recording with automatic 30-day pruning.
    • Improved classification of channel-level vs. model-level failures and notifications when models are auto-disabled or re-enabled.
  • Chores

    • Database migrations updated to include channel test history.
    • Channel cache construction refined for more accurate model-to-channel mapping.

@coderabbitai

coderabbitai Bot commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Iterates channel models and runs per-model tests with a 120s timeout, classifies errors as channel- or model-level, disables/enables models individually, averages model latencies to update channel response time, records per-model test history, prunes 30-day history, and conditionally re-enables channels only if no channel-level errors occurred.

Changes

Cohort / File(s) Summary
Channel Testing Refactor
controller/channel-test.go
Rewrote testAllChannels to loop channel.GetModels(), run testChannel per-model with a 120s timeout, treat timeouts as errors, aggregate/average model latencies, and adjust disable/enable logic to model-level decisions; prune per-channel test history.
Model Status Management
model/ability.go
Added UpdateAbilityModelStatus(channelId, modelName, status) and IsAbilityModelEnabled(channelId, modelName) to set/query per-model enabled state and refresh channel cache.
Test History Tracking & Migrations
model/channel_test_history.go, model/main.go
Added ChannelTestHistory model, RecordChannelTestHistory(...) (async DB create) and PruneChannelTestHistory(retentionDays); included model in GORM migrations.
Service Control Logic
service/channel.go
Added IsChannelLevelError(*types.NewAPIError) to classify channel-level errors; added DisableChannelModel(...) and EnableChannelModel(...) to toggle per-model ability and notify root users.
Channel Cache Construction
model/channel_cache.go
Reworked cache build to iterate Ability rows and map (group, model) -> []channelId using ability.ChannelId/ability.Model/ability.Group, skipping non-enabled channels and sorting by priority.

Sequence Diagram

sequenceDiagram
    participant Controller as testAllChannels
    participant Tester as testChannel
    participant Classifier as IsChannelLevelError
    participant Service as service (Enable/Disable)
    participant Model as Ability/DB
    participant History as ChannelTestHistory

    loop per model in channel.GetModels()
        Controller->>Tester: call testChannel(model) (120s timeout)
        Tester-->>Controller: response / error / latency

        Controller->>Classifier: classify error (if any)
        Classifier-->>Controller: channel-level? yes/no

        alt channel-level error
            Controller->>Service: process channel-level error / ShouldDisableChannel
            Service-->>Controller: decision
            Controller->>Service: DisableChannel(channelId) / stop remaining models
        else model-level error or timeout
            Controller->>Service: DisableChannelModel(channelId, modelName, reason)
            Service->>Model: UpdateAbilityModelStatus(channelId, modelName, false)
        else success
            Controller->>Service: EnableChannelModel(channelId, modelName)
            Service->>Model: UpdateAbilityModelStatus(channelId, modelName, true)
        end

        Controller->>History: RecordChannelTestHistory(channelId, modelName, status, ms, err)
    end

    Controller->>History: PruneChannelTestHistory(30)
    Controller->>Service: If no channel-level error & all models enabled -> EnableChannel(channelId)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • seefs001

Poem

🐇 I hopped through models, one by one,
Timed each ping beneath the sun,
Noted faults and cheered the spry,
Logged each hop and trimmed the dry,
Now channels hum — hooray, we’re done! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.78% 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 accurately summarizes the main change: switching from single-channel testing to per-model testing with per-model disable capability in the channel test flow.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

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

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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.

@wanghualoong
wanghualoong force-pushed the feature/per-model-channel-test branch from 06ad813 to c820f31 Compare April 22, 2026 04:48

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

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

Inline comments:
In `@controller/channel-test.go`:
- Around line 883-918: The model-disable/invariant is inconsistent: tighten the
guard on the model-level disable (the block that calls
service.DisableChannelModel) to require common.AutomaticDisableChannelEnabled
and isChannelEnabled in addition to shouldBanModel and channel.GetAutoBan()
(e.g., if common.AutomaticDisableChannelEnabled && isChannelEnabled &&
shouldBanModel && channel.GetAutoBan() && testStatus != "unsupported") so the
automatic-disable feature is a single explicit switch; and protect the
processChannelError call by ensuring result.context is non-nil (or ensure the
timeout path carries a non-nil context) before calling processChannelError(...,
common.GetContextKeyString(result.context, ...)) to avoid a nil context panic if
IsChannelLevelError behavior changes.
- Around line 831-949: The average response time is divided by
int64(len(models)) even though totalMs only sums measured models (skips,
continues, and early break), so compute the average using the actual tested
model count: introduce a testedCount (int64) next to totalMs, increment
testedCount whenever you add milliseconds (after tok.Sub and totalMs +=
milliseconds), and at the end call channel.UpdateResponseTime(totalMs /
testedCount) only when testedCount > 0 (otherwise skip updating); update
references in this block (totalMs, models, channel.UpdateResponseTime, the loop
that breaks on channel-level error) so the average reflects only actually tested
models and avoids division-by-zero.
- Around line 848-863: The test goroutine spawns testChannel which makes HTTP
requests via adaptor.DoRequest without a cancellable context, so the child
goroutine can hang after the 120s select; fix by creating a
context.WithTimeout(testTimeout) in the caller of testChannel
(AutomaticallyTestChannels or the function that schedules the test), pass that
context into testChannel, propagate it into adaptor.DoRequest (and any
lower-level request builders) and replace http.NewRequest(...) calls with
http.NewRequestWithContext(ctx, ...) so the upstream http.Client.Do honors the
timeout and the goroutine is cancelled when the 120s deadline elapses.

In `@model/channel_test_history.go`:
- Around line 10-34: Change the ChannelTestHistory.ErrorMessage column to use a
TEXT type for cross-DB safety by adding a GORM column type tag (e.g.
gorm:"type:TEXT") on the ErrorMessage field in the ChannelTestHistory struct so
long JSON error bodies won't be truncated or cause strict-mode insert failures;
in RecordChannelTestHistory, capture and surface the result of
DB.Create(&history) (don't discard the returned error inside the gopool.Go
closure) and log any create error so failing inserts are discoverable (reference
ChannelTestHistory, ErrorMessage, RecordChannelTestHistory, gopool.Go, and
DB.Create).

In `@service/channel.go`:
- Around line 130-140: DisableChannelModel currently disables a model regardless
of the global automatic-disable toggle; update it to honor
common.AutomaticDisableChannelEnabled (or ensure callers check it) so behavior
matches DisableChannel/ShouldDisableChannel: add a guard at the start of
DisableChannelModel that returns early when
common.AutomaticDisableChannelEnabled is false, or alternatively add that same
check at the controller/channel-test.go caller (the per-model branch) before
calling DisableChannelModel; reference DisableChannelModel,
common.AutomaticDisableChannelEnabled, DisableChannel and ShouldDisableChannel
when making this change.
- Around line 129-152: The notify dedup key used in DisableChannelModel and
EnableChannelModel collides with channel-level events because both call
formatNotifyType(channelId, common.ChannelStatusAutoDisabled) /
formatNotifyType(channelId, common.ChannelStatusEnabled); update these two
functions to include the model name in the notify type (e.g. append or
interpolate modelName into the second argument) or introduce distinct
model-specific status constants and pass those to formatNotifyType so
NotifyRootUser receives a unique key per model and no longer collides with
DisableChannel/EnableChannel events.
🪄 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: ffb5b0f3-32ff-4816-a35e-7235297f7487

📥 Commits

Reviewing files that changed from the base of the PR and between f995a86 and 06ad813.

📒 Files selected for processing (5)
  • controller/channel-test.go
  • model/ability.go
  • model/channel_test_history.go
  • model/main.go
  • service/channel.go

Comment thread controller/channel-test.go
Comment thread controller/channel-test.go Outdated
Comment thread controller/channel-test.go
Comment thread model/channel_test_history.go
Comment thread service/channel.go Outdated
Comment thread service/channel.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.

Actionable comments posted: 2

♻️ Duplicate comments (2)
service/channel.go (2)

101-115: ⚠️ Potential issue | 🟠 Major

Use a model-specific notification key.

Both model disable/enable notifications use the same dedup key as channel-level updates and the same key for every model on the channel. This can collapse per-model notifications and suppress later channel-level notifications.

🛠️ Proposed fix
 func formatNotifyType(channelId int, status int) string {
 	return fmt.Sprintf("%s_%d_%d", dto.NotifyTypeChannelUpdate, channelId, status)
 }
+
+func formatModelNotifyType(channelId int, modelName string, status int) string {
+	return fmt.Sprintf("%s_%d_%s_%d", dto.NotifyTypeChannelUpdate, channelId, modelName, status)
+}
@@
-	NotifyRootUser(formatNotifyType(channelId, common.ChannelStatusAutoDisabled), subject, content)
+	NotifyRootUser(formatModelNotifyType(channelId, modelName, common.ChannelStatusAutoDisabled), subject, content)
@@
-	NotifyRootUser(formatNotifyType(channelId, common.ChannelStatusEnabled), subject, content)
+	NotifyRootUser(formatModelNotifyType(channelId, modelName, common.ChannelStatusEnabled), subject, content)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/channel.go` around lines 101 - 115, Notifications for per-model
enable/disable are using the same deduplication key as channel-level updates and
across all models; change the NotifyRootUser calls in EnableChannelModel and the
corresponding disable function to use a model-specific dedup key by including
modelName in the key generation (e.g. call a new or extended helper like
formatModelNotifyType(channelId, modelName,
common.ChannelStatusEnabled/ChannelStatusAutoDisabled) instead of
formatNotifyType), update the NotifyRootUser invocation arguments accordingly so
each model gets a unique notification key, and ensure any helper used
(formatModelNotifyType) is implemented consistently with existing
formatNotifyType semantics.

94-96: ⚠️ Potential issue | 🟡 Minor

Honor the global automatic-disable switch for model disables.

DisableChannelModel can disable abilities even when global automatic channel disabling is off. Add the same global guard used by channel-disable decisions, or ensure every caller checks it before calling this public helper.

🛠️ Proposed fix
 func DisableChannelModel(channelId int, channelName string, modelName string, reason string) {
 	common.SysLog(fmt.Sprintf("通道「%s」(#%d)的模型「%s」发生错误,准备禁用,原因:%s", channelName, channelId, modelName, reason))
+	if !common.AutomaticDisableChannelEnabled {
+		common.SysLog(fmt.Sprintf("自动禁用未启用,跳过禁用通道「%s」(#%d)的模型「%s」", channelName, channelId, modelName))
+		return
+	}
 	err := model.UpdateAbilityModelStatus(channelId, modelName, false)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/channel.go` around lines 94 - 96, DisableChannelModel can disable
models regardless of the global automatic-disable setting; ensure we honor that
global switch. Modify DisableChannelModel to check the global automatic-disable
flag (the same flag used by channel-disable logic—e.g., the project's
EnableAutoDisable/AutoDisableChannels config variable) and return immediately
when automatic disables are turned off, or alternatively ensure every caller of
DisableChannelModel checks that flag before invoking it; update the call site
expectations and keep the existing logging and
model.UpdateAbilityModelStatus(channelId, modelName, false) behavior only when
the flag allows disables.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@model/ability.go`:
- Around line 267-276: UpdateAbilityModelStatus currently updates
abilities.enabled in the DB but never refreshes the channel cache, causing stale
routing; modify UpdateAbilityModelStatus to invoke the channel cache rebuild
function immediately after a successful DB update (e.g., call
RebuildChannelCache(channelId) or the repository's existing cache refresh
function used by the ability rebuild path) and propagate or log any rebuild
error; leave IsAbilityModelEnabled as-is (it only reads), but ensure any other
code paths that flip per-model enabled state also trigger the same cache
refresh.

In `@service/channel.go`:
- Around line 73-89: Replace the custom multi-switch logic that inspects
err.StatusCode, err.ToOpenAIError(), and err.GetErrorCode() with the existing
channel-level classification helper types.IsChannelError to ensure 403 and
channel:* errors disable the channel; specifically, in the function using the
local err variable, remove the current blocks referencing err.StatusCode, oaiErr
(from err.ToOpenAIError()), and errorCode (from err.GetErrorCode()) and instead
call and return types.IsChannelError(err) (or pass the same err value to that
helper) so channel-level errors are consistently detected.

---

Duplicate comments:
In `@service/channel.go`:
- Around line 101-115: Notifications for per-model enable/disable are using the
same deduplication key as channel-level updates and across all models; change
the NotifyRootUser calls in EnableChannelModel and the corresponding disable
function to use a model-specific dedup key by including modelName in the key
generation (e.g. call a new or extended helper like
formatModelNotifyType(channelId, modelName,
common.ChannelStatusEnabled/ChannelStatusAutoDisabled) instead of
formatNotifyType), update the NotifyRootUser invocation arguments accordingly so
each model gets a unique notification key, and ensure any helper used
(formatModelNotifyType) is implemented consistently with existing
formatNotifyType semantics.
- Around line 94-96: DisableChannelModel can disable models regardless of the
global automatic-disable setting; ensure we honor that global switch. Modify
DisableChannelModel to check the global automatic-disable flag (the same flag
used by channel-disable logic—e.g., the project's
EnableAutoDisable/AutoDisableChannels config variable) and return immediately
when automatic disables are turned off, or alternatively ensure every caller of
DisableChannelModel checks that flag before invoking it; update the call site
expectations and keep the existing logging and
model.UpdateAbilityModelStatus(channelId, modelName, false) behavior only when
the flag allows disables.
🪄 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: 1d61f0cb-b42c-469d-a976-0bbb34f97c73

📥 Commits

Reviewing files that changed from the base of the PR and between 06ad813 and c820f31.

📒 Files selected for processing (5)
  • controller/channel-test.go
  • model/ability.go
  • model/channel_test_history.go
  • model/main.go
  • service/channel.go
✅ Files skipped from review due to trivial changes (1)
  • controller/channel-test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • model/channel_test_history.go

Comment thread model/ability.go
Comment thread service/channel.go Outdated

@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 `@model/ability.go`:
- Around line 269-272: The cache rebuild currently uses channel.Models to
populate group2model2channels without checking per-model Ability.Enabled, so
update the cache rebuild logic in model/channel_cache.go (the code that
constructs group2model2channels) to consult Ability for each (channel.ID,
modelName) and only include the model when Ability.Enabled is true; to avoid N+1
queries preload or query all Ability rows for the set of channels/models being
processed into a map keyed by channelId+model before the loop and reference that
map while iterating channels.Models so disabled abilities are excluded from the
cache (this fixes the stale routability after InitChannelCache is called from
the Ability update).
🪄 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: e4e41604-7901-44bd-8884-d62442ae6984

📥 Commits

Reviewing files that changed from the base of the PR and between c820f31 and 12dfe6a.

📒 Files selected for processing (4)
  • controller/channel-test.go
  • model/ability.go
  • model/channel_test_history.go
  • service/channel.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • model/channel_test_history.go
  • controller/channel-test.go

Comment thread model/ability.go
@wanghualoong
wanghualoong marked this pull request as draft May 15, 2026 05:55
wanghualoong and others added 3 commits May 15, 2026 14:21
Instead of testing one representative model per channel and disabling
the entire channel on failure, testAllChannels() now iterates every
model individually. Model-level errors (timeout, unsupported) only
disable that model's ability; channel-level errors (invalid key, quota)
still disable the entire channel. Each model test has 120s timeout
protection and records its own test history entry. Auto-enable respects
the AutomaticEnableChannelEnabled setting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix average response time dividing by actual tested count instead of
  total model count (avoids bias from skipped/broken models)
- Gate model-level disable on AutomaticDisableChannelEnabled for
  consistency with threshold-based disable path
- Add gorm type:text tag to ErrorMessage to prevent truncation on MySQL
- Use per-model notify type key to avoid notification deduplication
  across multiple models in the same channel
- Refresh channel cache after updating per-model ability status
- Reuse types.IsChannelError() and add HTTP 403 to IsChannelLevelError
  for broader channel-error classification

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The channel cache previously rebuilt group2model2channels by splitting
channel.Models, ignoring Ability.Enabled. This meant a model disabled
via UpdateAbilityModelStatus remained routable when memory cache was
active. Switch to iterating abilities directly so disabled models are
excluded from the routing cache.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@wanghualoong
wanghualoong force-pushed the feature/per-model-channel-test branch from c99377c to cc0cac7 Compare May 15, 2026 06:22
@wanghualoong
wanghualoong marked this pull request as ready for review May 15, 2026 06:25
- Use context.WithTimeout instead of time.After so the HTTP request is
  cancelled when the 120s deadline fires, preventing goroutine accumulation
- Guard processChannelError with result.context != nil to avoid panic
  if IsChannelLevelError classification changes in the future
- Log DB.Create errors in RecordChannelTestHistory instead of silently
  discarding them

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@Calcium-Ion
Calcium-Ion force-pushed the main branch 2 times, most recently from 51fdfc5 to 2b6f1df Compare August 30, 2026 15:03
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