Skip to content

feat: add channel RPM and concurrency admission control - #6583

Open
faithleysath wants to merge 1 commit into
QuantumNous:mainfrom
NexusAgentX:feat/channel-admission-limits
Open

feat: add channel RPM and concurrency admission control#6583
faithleysath wants to merge 1 commit into
QuantumNous:mainfrom
NexusAgentX:feat/channel-admission-limits

Conversation

@faithleysath

@faithleysath faithleysath commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

⚠️ 提交说明 / PR Notice

Important

  • 请提供人工撰写的简洁摘要,避免直接粘贴未经整理的 AI 输出。
  • 本 PR 为 AI-assisted implementation;设计、实现、测试与说明均已按关联 Issue 的行为边界整理,仍请维护者重点复核路由与降级取舍。

📝 变更描述 / Description

为渠道增加可选的 max_concurrencyrpm_limit 准入限制,并在请求发往上游前完成容量预留。

  • 0 或缺省保持不限流,字段复用现有 ChannelSettings JSON,不需要数据库迁移,也不会改变已有渠道行为。
  • 候选渠道按原优先级和权重选择;满载时在同优先级内无放回改选,然后尝试较低优先级或下一个 auto group。容量跳过不占用上游 retry 次数。
  • Token 固定渠道和渠道亲和绑定满载时返回本地 429 channel_capacity_exhaustedRetry-After,不会静默改道或覆盖原亲和绑定。
  • Redis 使用 Lua 原子检查并预留精确滚动 60 秒 RPM 与并发租约。租约带唯一 ID、过期保护和长请求续租;无 Redis 或 Redis 故障时降级为进程内原子限制并限频告警。
  • RPM 预留在真正开始上游调用前可以回退;一旦 Commit,成功、失败、取消或重试均不回退。并发槽覆盖非流式、流式、WebSocket 与任务提交的上游生命周期,并在所有退出路径释放。
  • 所有候选满载时不排队,不发送上游请求,不触发渠道自动禁用,不写入上游错误日志,也不产生额外计费。
  • 增加运行时 snapshot 接口,暴露当前并发、滚动 RPM、配置上限和 Redis/内存模式,供后续可观测性使用。
  • 管理端渠道表单增加两个数值设置及 7 种前端语言翻译;后端容量错误增加 en/zh-CN/zh-TW 文案。

本期刻意不实现请求排队;队列取消、超时和队头阻塞语义应作为独立需求处理。

🚀 变更类型 / Type of change

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

🔗 关联任务 / Related Issue

✅ 提交前检查项 / Checklist

  • 人工确认: 我已亲自整理并撰写此描述,没有直接粘贴未经处理的 AI 输出。(本 PR 已明确标注 AI-assisted,等待人工复核。)
  • 非重复提交: 我已搜索现有的 IssuesPRs,确认不是重复提交。
  • Bug fix 说明: 本 PR 未标记为 Bug fix,关联的是既有功能请求 功能请求:渠道速率限制与并发控制功能 #1730
  • 变更理解: 已核对准入、路由、租约、Redis 降级、热更新和错误分类的完整调用链。
  • 范围聚焦: 本 PR 未包含排队、监控页面、数据库迁移或无关重构。
  • 本地验证: 已在本地运行并通过下列测试和手动验证。
  • 安全合规: 代码中无敏感凭据,容量错误不暴露渠道 Key 或上游错误详情。

📸 运行证明 / Proof of Work

通过:

go test ./...
go vet ./...
go test -race ./service ./middleware ./controller -run 'Test(ChannelAdmission|AcquireChannelAdmission|SelectAdmitted|SelectChannelWithAdmission|PickWeighted|Distributor|GetInitialUnlimitedChannel|ChannelCapacity)' -count=1
(cd relaykit && go test ./...)
(cd web && bun test)                         # 125 passed
(cd web && bun run typecheck)
(cd web && bun run format:check)
(cd web && bun run i18n:sync)                # 7 locales: 0 missing / 0 extra / 0 untranslated
(cd web && bun run build)

改动文件的聚焦 oxlint 检查通过。全仓 bun run lint 仍会报告当前 main 中既有、且不位于本 PR 改动文件内的诊断。

浏览器手动验证覆盖桌面布局、390px 移动布局以及俄语长文案;两个数值控件无重叠或溢出,页面无 console/page error。

回归测试覆盖同级改选、优先级降级、auto group、硬亲和、固定渠道、本地错误分类、配置从不限到有限及升降上限、Redis 双客户端全局并发/RPM、原子组合边界、内存 fallback、竞态、重复释放、租约过期、续租、多 Key 聚合,以及成功/错误/取消/超时/流式结束/panic 的释放路径。

Summary by CodeRabbit

  • New Features
    • Added per-channel maximum concurrency and requests-per-minute limits.
    • Added weighted, priority-aware channel selection that respects capacity.
    • Added lease handling across retries and streaming requests.
    • Added channel limit fields and validation in the management interface.
  • Bug Fixes
    • Capacity exhaustion now returns localized HTTP 429 responses with retry guidance.
    • Improved cleanup for completed, failed, cancelled, timed-out, and streaming requests.
  • Documentation
    • Documented configuration, validation, fallback behavior, and upgrade compatibility.
  • Localization
    • Added translations for channel limits and capacity errors.

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This change adds channel-level concurrency and rolling RPM limits. It introduces Redis and memory admission leases, capacity-aware selection, middleware and relay lifecycle handling, structured 429 errors, validation, documentation, localization, and channel configuration UI controls.

Changes

Channel admission control

Layer / File(s) Summary
Admission settings and error contracts
relaykit/dto/channel_settings.go, relaykit/types/error.go, model/channel.go, i18n/*, docs/channel/other_setting.md
Channel settings support validated max_concurrency and rpm_limit values. Capacity exhaustion has shared error codes and localized messages.
Lease-backed admission engine
service/channel_admission.go, service/lua/*, service/channel_admission_test.go
Admission uses Redis Lua scripts and synchronized memory fallback. Leases support commit, release, renewal, snapshots, concurrency limits, and rolling RPM limits.
Tiered channel selection
model/ability.go, model/channel_cache.go, service/channel_select.go, service/channel_admission_test.go
Selection groups candidates by priority, applies weighted picking, acquires admission before use, and preserves retry state when capacity rejects a candidate.
Middleware and relay lease lifecycle
middleware/distributor.go, middleware/channel_admission_test.go, controller/relay.go, controller/channel_admission_test.go
Request flows enforce admission for explicit, affinity, locked, and dynamically selected channels. Leases are managed across downstream exits, submissions, setup failures, and retries.
Channel configuration UI and documentation
web/src/features/channels/*, web/src/i18n/*, docs/channel/other_setting.md
The channel form validates, loads, displays, and serializes bounded concurrency and RPM inputs. Documentation and locale files describe the settings and capacity responses.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Distributor
  participant ChannelSelector
  participant AdmissionService
  participant Relay
  participant ChannelProvider
  Client->>Distributor: submit request
  Distributor->>ChannelSelector: resolve channel
  ChannelSelector->>AdmissionService: acquire lease
  AdmissionService-->>ChannelSelector: lease or capacity error
  Distributor->>Relay: forward request
  Relay->>ChannelProvider: submit upstream request
  ChannelProvider-->>Relay: response or error
  Relay->>AdmissionService: commit or release lease
  Relay-->>Client: return response
Loading

Poem

A rabbit sees limits in channels today,
Leases guard requests along the way.
Redis counts tokens; memory stands near,
Full channels return “retry” clear.
Forms and translations make limits shine—
Hop, hop, capacity control is online!

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR implements rate and concurrency limits with direct rejection, but it does not implement the linked issue's queueing mode requirement [#1730]. Implement or explicitly split the queueing mode into a separate tracked issue before claiming full compliance with #1730.
Docstring Coverage ⚠️ Warning Docstring coverage is 9.09% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the primary change: channel RPM and concurrency admission control.
Out of Scope Changes check ✅ Passed The changes support channel admission control and its required backend, frontend, localization, documentation, and test coverage.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 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.

@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

🧹 Nitpick comments (8)
service/channel_admission_test.go (1)

424-432: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Move this test next to the code it protects.

TestPickWeightedChannelCandidatePreservesZeroWeightSemantics asserts behavior of model.PickWeightedChannelCandidate. A test in the model package keeps ownership clear.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@service/channel_admission_test.go` around lines 424 - 432, Move
TestPickWeightedChannelCandidatePreservesZeroWeightSemantics from the
service/channel_admission tests into the model package’s test file or test
location alongside PickWeightedChannelCandidate. Preserve the test assertions
and inputs unchanged so it continues verifying zero-weight selection semantics
under the model package.
service/channel_admission.go (2)

355-362: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Extract the RPM window pruning into one helper.

acquireMemory and snapshot contain the same pruning loop. The duplication makes future window changes error prone. memoryChannelAdmissionState can own the logic.

♻️ Proposed refactor
// Call with state.mu held.
func (s *memoryChannelAdmissionState) pruneRPM(cutoff time.Time) {
	firstActive := 0
	for firstActive < len(s.rpmAdmissions) && !s.rpmAdmissions[firstActive].startedAt.After(cutoff) {
		firstActive++
	}
	if firstActive > 0 {
		s.rpmAdmissions = append([]memoryRPMAdmission(nil), s.rpmAdmissions[firstActive:]...)
	}
}
 	state.mu.Lock()
 	defer state.mu.Unlock()
-	cutoff := now.Add(-m.rpmWindow)
-	firstActiveRPM := 0
-	for firstActiveRPM < len(state.rpmAdmissions) && !state.rpmAdmissions[firstActiveRPM].startedAt.After(cutoff) {
-		firstActiveRPM++
-	}
-	if firstActiveRPM > 0 {
-		state.rpmAdmissions = append([]memoryRPMAdmission(nil), state.rpmAdmissions[firstActiveRPM:]...)
-	}
+	state.pruneRPM(now.Add(-m.rpmWindow))

Also applies to: 458-465

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@service/channel_admission.go` around lines 355 - 362, Extract the duplicated
RPM admission pruning loop from acquireMemory and snapshot into a
memoryChannelAdmissionState method named pruneRPM, called with state.mu held.
Have the helper accept the cutoff time, perform the existing removal and
slice-copy behavior, and replace both inline loops with calls to it.

153-208: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Release performs a Redis round trip while holding l.mu.

Commit uses the same mutex. A slow Redis release can block a concurrent Commit for up to channelAdmissionBackendTimeout. Consider capturing the release parameters under the lock, marking the lease released, and running the Redis script after unlocking.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@service/channel_admission.go` around lines 153 - 208, Refactor
ChannelAdmissionLease.Release so it captures the release state and parameters
while holding l.mu, marks the lease released, then unlocks before executing
channelAdmissionReleaseScript.Run. Preserve the existing no-op and memory-mode
behavior, and ensure Redis errors are still returned without holding l.mu so
Commit is not blocked by the backend round trip.
controller/relay.go (1)

366-379: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

channelCapacityAPIError also writes a response header.

The name states that the function builds an error. It also sets Retry-After on the response. RelayTask calls it at Line 601 only to build a TaskError, so the header write there is implicit. Consider returning the computed seconds and setting the header at the call sites, or rename the function to state both effects.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@controller/relay.go` around lines 366 - 379, Update channelCapacityAPIError
so error construction and Retry-After response mutation are no longer implicitly
combined: return the computed retry-after seconds (or otherwise expose it) and
set the header explicitly at each caller, including RelayTask, while preserving
the existing rounding and minimum-one-second behavior.
controller/channel_admission_test.go (1)

23-53: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a case for the limited-channel branch of getChannel.

This test covers the snapshot path when no limits exist and no lease exists. The branch at controller/relay.go Lines 327-339 is not covered: settings declare a limit, the context holds no lease, so getChannel loads the channel and acquires admission. A case there would protect the 429 capacity result for a fixed channel.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@controller/channel_admission_test.go` around lines 23 - 53, Extend
TestGetInitialUnlimitedChannelUsesContextSnapshot with a separate
limited-channel case, configuring the channel settings with a limit while
leaving the context without a lease. Exercise getChannel and verify it loads the
fixed channel, attempts admission, and returns the expected 429 capacity error;
preserve the existing unlimited snapshot assertions.
middleware/distributor.go (2)

44-52: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Read the specific channel ID with a typed helper.

Line 51 uses an unchecked type assertion channelID.(string). If any producer stores this context key as an int, the middleware panics on a live request path. Use common.GetContextKeyString or check the assertion.

🛡️ Proposed guard
 		if specificChannel {
-			id, parseErr := strconv.Atoi(channelID.(string))
-			if parseErr != nil {
+			rawChannelID, isString := channelID.(string)
+			if !isString {
+				abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidChannelId))
+				return
+			}
+			id, parseErr := strconv.Atoi(rawChannelID)
+			if parseErr != nil {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@middleware/distributor.go` around lines 44 - 52, Update the specific-channel
parsing branch in the distributor middleware to retrieve the context value
through common.GetContextKeyString, or safely validate its type before
conversion, instead of asserting channelID as string. Preserve the existing
parse-error handling and channel-selection flow for valid values.

164-172: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Pass the raw retry duration and reuse one rounding helper.

Line 167 converts RetryAfter to whole seconds, then abortWithChannelCapacity rounds up again. The rounding logic at lines 209-212 also duplicates ChannelCapacityError.RetryAfterSeconds in service/channel_select.go. Pass the duration through and keep one implementation of the rounding rule.

♻️ Proposed simplification
-							abortWithChannelCapacity(c, time.Duration(capacityErr.RetryAfterSeconds())*time.Second)
+							abortWithChannelCapacity(c, capacityErr.RetryAfter)
 func abortWithChannelCapacity(c *gin.Context, retryAfter time.Duration) {
-	retryAfterSeconds := int64((retryAfter + time.Second - 1) / time.Second)
-	if retryAfterSeconds < 1 {
-		retryAfterSeconds = 1
-	}
+	retryAfterSeconds := (&service.ChannelCapacityError{RetryAfter: retryAfter}).RetryAfterSeconds()

Also applies to: 208-213

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@middleware/distributor.go` around lines 164 - 172, Pass the raw retry
duration from the selection error to abortWithChannelCapacity instead of
converting RetryAfterSeconds() at the call site. Consolidate the
duration-to-seconds rounding into one shared implementation, and update
abortWithChannelCapacity and ChannelCapacityError.RetryAfterSeconds to reuse it
without applying rounding twice.
middleware/channel_admission_test.go (1)

185-204: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the response status for each exit path.

The subtest only checks that concurrency returns to 0. A regression that rejects the request in the distributor also yields concurrency 0, so the test would pass while the handler never runs. Add an expected status per table row, or assert that the handler was reached.

💚 Proposed strengthening
 	tests := []struct {
 		name    string
 		exit    string
+		status  int
 		context func() (context.Context, context.CancelFunc)
 	}{
-		{name: "success"},
-		{name: "upstream error", exit: "error"},
+		{name: "success", status: http.StatusNoContent},
+		{name: "upstream error", exit: "error", status: http.StatusBadGateway},
 			snapshot, snapshotErr := service.GetChannelAdmissionSnapshot(context.Background(), channel)
 			require.NoError(t, snapshotErr)
 			assert.Equal(t, 0, snapshot.CurrentConcurrency)
+			if test.status != 0 {
+				assert.Equal(t, test.status, response.Code)
+			}

As per coding guidelines: "Backend tests must protect real behavior, API contracts, billing/accounting invariants, compatibility, or regression paths; prefer deterministic table tests with explicit inputs and exact outputs".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@middleware/channel_admission_test.go` around lines 185 - 204, The
table-driven subtests in the channel admission test must verify the HTTP outcome
as well as concurrency cleanup. Add an expected status value to each test case,
then assert response.Code against it after router.ServeHTTP, preserving the
existing snapshot assertion to cover admission accounting.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@controller/relay.go`:
- Around line 600-604: Update shouldRetryTaskRelay to return false for the
channel-capacity error code before the generic http.StatusTooManyRequests retry
condition. Preserve retries for other 429 errors and keep the existing task
relay behavior unchanged.

In `@middleware/distributor.go`:
- Around line 37-42: Update the retry channel-switch logic in
Distribute/getChannel so the currently held distributor lease is released via
service.GetChannelAdmissionLease(c) before replacing it with a newly selected
channel lease. Skip the release when retries continue on the same locked
channel, and ensure the defer only releases the final active lease without
leaving the original reservation held.

---

Nitpick comments:
In `@controller/channel_admission_test.go`:
- Around line 23-53: Extend TestGetInitialUnlimitedChannelUsesContextSnapshot
with a separate limited-channel case, configuring the channel settings with a
limit while leaving the context without a lease. Exercise getChannel and verify
it loads the fixed channel, attempts admission, and returns the expected 429
capacity error; preserve the existing unlimited snapshot assertions.

In `@controller/relay.go`:
- Around line 366-379: Update channelCapacityAPIError so error construction and
Retry-After response mutation are no longer implicitly combined: return the
computed retry-after seconds (or otherwise expose it) and set the header
explicitly at each caller, including RelayTask, while preserving the existing
rounding and minimum-one-second behavior.

In `@middleware/channel_admission_test.go`:
- Around line 185-204: The table-driven subtests in the channel admission test
must verify the HTTP outcome as well as concurrency cleanup. Add an expected
status value to each test case, then assert response.Code against it after
router.ServeHTTP, preserving the existing snapshot assertion to cover admission
accounting.

In `@middleware/distributor.go`:
- Around line 44-52: Update the specific-channel parsing branch in the
distributor middleware to retrieve the context value through
common.GetContextKeyString, or safely validate its type before conversion,
instead of asserting channelID as string. Preserve the existing parse-error
handling and channel-selection flow for valid values.
- Around line 164-172: Pass the raw retry duration from the selection error to
abortWithChannelCapacity instead of converting RetryAfterSeconds() at the call
site. Consolidate the duration-to-seconds rounding into one shared
implementation, and update abortWithChannelCapacity and
ChannelCapacityError.RetryAfterSeconds to reuse it without applying rounding
twice.

In `@service/channel_admission_test.go`:
- Around line 424-432: Move
TestPickWeightedChannelCandidatePreservesZeroWeightSemantics from the
service/channel_admission tests into the model package’s test file or test
location alongside PickWeightedChannelCandidate. Preserve the test assertions
and inputs unchanged so it continues verifying zero-weight selection semantics
under the model package.

In `@service/channel_admission.go`:
- Around line 355-362: Extract the duplicated RPM admission pruning loop from
acquireMemory and snapshot into a memoryChannelAdmissionState method named
pruneRPM, called with state.mu held. Have the helper accept the cutoff time,
perform the existing removal and slice-copy behavior, and replace both inline
loops with calls to it.
- Around line 153-208: Refactor ChannelAdmissionLease.Release so it captures the
release state and parameters while holding l.mu, marks the lease released, then
unlocks before executing channelAdmissionReleaseScript.Run. Preserve the
existing no-op and memory-mode behavior, and ensure Redis errors are still
returned without holding l.mu so Commit is not blocked by the backend round
trip.
🪄 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 Plus

Run ID: f4dc9209-8c4c-4ef5-bc1b-53b70394f951

📥 Commits

Reviewing files that changed from the base of the PR and between cfaba1d and 91d44cd.

📒 Files selected for processing (36)
  • controller/channel_admission_test.go
  • controller/relay.go
  • docs/channel/other_setting.md
  • i18n/keys.go
  • i18n/locales/en.yaml
  • i18n/locales/zh-CN.yaml
  • i18n/locales/zh-TW.yaml
  • middleware/channel_admission_test.go
  • middleware/distributor.go
  • model/ability.go
  • model/channel.go
  • model/channel_cache.go
  • model/channel_settings_test.go
  • relaykit/dto/channel_settings.go
  • relaykit/dto/channel_settings_test.go
  • relaykit/types/error.go
  • service/channel_admission.go
  • service/channel_admission_test.go
  • service/channel_select.go
  • service/lua/channel_admission_acquire.lua
  • service/lua/channel_admission_release.lua
  • service/lua/channel_admission_renew.lua
  • service/lua/channel_admission_snapshot.lua
  • web/src/features/channels/components/drawers/channel-mutate-drawer.tsx
  • web/src/features/channels/lib/__tests__/channel-form-admission-limits.test.ts
  • web/src/features/channels/lib/channel-form-errors.ts
  • web/src/features/channels/lib/channel-form.ts
  • web/src/features/channels/types.ts
  • web/src/i18n/locales/en.json
  • web/src/i18n/locales/fr.json
  • web/src/i18n/locales/ja.json
  • web/src/i18n/locales/ru.json
  • web/src/i18n/locales/vi.json
  • web/src/i18n/locales/zh-TW.json
  • web/src/i18n/locales/zh.json
  • web/src/i18n/static-keys.ts

Comment thread controller/relay.go
Comment thread middleware/distributor.go
@faithleysath
faithleysath force-pushed the feat/channel-admission-limits branch from 91d44cd to aedf377 Compare August 1, 2026 11:19

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

166-169: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Avoid require on the parent t inside the route handler.

The handler closure captures the outer t. The handler executes while a subtest from t.Run is running on its own goroutine. If lease is nil, require.NotNil calls FailNow on the parent t from a goroutine that does not own it. Go documents FailNow as valid only in the goroutine that runs that test, so failures report against the wrong test and the subtest does not stop cleanly.

Use a non-fatal check plus an early return, or capture the active subtest t in a variable that each t.Run body assigns.

♻️ Proposed change: assert and return instead of require
 	router.POST("/v1/chat/completions", func(c *gin.Context) {
 		lease := service.GetChannelAdmissionLease(c)
-		require.NotNil(t, lease)
+		if !assert.NotNil(t, lease) {
+			c.Status(http.StatusInternalServerError)
+			return
+		}
 		lease.Commit()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@middleware/channel_admission_test.go` around lines 166 - 169, Update the
route handler around service.GetChannelAdmissionLease to avoid calling
require.NotNil with the parent test object; use a non-fatal assertion and return
immediately when lease is nil, otherwise preserve the existing lease.Commit
flow.
controller/channel_admission_test.go (1)

132-143: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Isolate the retry discriminator.

The capacity case sets both Code and LocalError: true. The upstream case sets neither. If shouldRetryTaskRelay skips retry for every LocalError, this test passes even when the function ignores ErrorCodeChannelCapacityExhausted. Add a case with LocalError: true and an unrelated code, or a case with the capacity code and LocalError: false, to pin which field drives the decision.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@controller/channel_admission_test.go` around lines 132 - 143, Update the
shouldRetryTaskRelay test cases to isolate the retry discriminator: add coverage
for an unrelated code with LocalError true, or the capacity code with LocalError
false, and assert the expected retry result so the test specifically verifies
handling of ErrorCodeChannelCapacityExhausted rather than only LocalError.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@controller/channel_admission_test.go`:
- Around line 132-143: Update the shouldRetryTaskRelay test cases to isolate the
retry discriminator: add coverage for an unrelated code with LocalError true, or
the capacity code with LocalError false, and assert the expected retry result so
the test specifically verifies handling of ErrorCodeChannelCapacityExhausted
rather than only LocalError.

In `@middleware/channel_admission_test.go`:
- Around line 166-169: Update the route handler around
service.GetChannelAdmissionLease to avoid calling require.NotNil with the parent
test object; use a non-fatal assertion and return immediately when lease is nil,
otherwise preserve the existing lease.Commit flow.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ebf40293-e44a-4746-b5e7-0142765b293e

📥 Commits

Reviewing files that changed from the base of the PR and between 91d44cd and aedf377.

📒 Files selected for processing (37)
  • controller/channel_admission_test.go
  • controller/relay.go
  • docs/channel/other_setting.md
  • i18n/keys.go
  • i18n/locales/en.yaml
  • i18n/locales/zh-CN.yaml
  • i18n/locales/zh-TW.yaml
  • middleware/channel_admission_test.go
  • middleware/distributor.go
  • model/ability.go
  • model/channel.go
  • model/channel_cache.go
  • model/channel_cache_test.go
  • model/channel_settings_test.go
  • relaykit/dto/channel_settings.go
  • relaykit/dto/channel_settings_test.go
  • relaykit/types/error.go
  • service/channel_admission.go
  • service/channel_admission_test.go
  • service/channel_select.go
  • service/lua/channel_admission_acquire.lua
  • service/lua/channel_admission_release.lua
  • service/lua/channel_admission_renew.lua
  • service/lua/channel_admission_snapshot.lua
  • web/src/features/channels/components/drawers/channel-mutate-drawer.tsx
  • web/src/features/channels/lib/__tests__/channel-form-admission-limits.test.ts
  • web/src/features/channels/lib/channel-form-errors.ts
  • web/src/features/channels/lib/channel-form.ts
  • web/src/features/channels/types.ts
  • web/src/i18n/locales/en.json
  • web/src/i18n/locales/fr.json
  • web/src/i18n/locales/ja.json
  • web/src/i18n/locales/ru.json
  • web/src/i18n/locales/vi.json
  • web/src/i18n/locales/zh-TW.json
  • web/src/i18n/locales/zh.json
  • web/src/i18n/static-keys.ts
🚧 Files skipped from review as they are similar to previous changes (34)
  • web/src/features/channels/lib/channel-form-errors.ts
  • web/src/features/channels/types.ts
  • relaykit/types/error.go
  • web/src/features/channels/lib/tests/channel-form-admission-limits.test.ts
  • web/src/i18n/locales/ja.json
  • web/src/i18n/static-keys.ts
  • web/src/features/channels/lib/channel-form.ts
  • web/src/features/channels/components/drawers/channel-mutate-drawer.tsx
  • i18n/locales/en.yaml
  • web/src/i18n/locales/ru.json
  • web/src/i18n/locales/zh-TW.json
  • web/src/i18n/locales/vi.json
  • web/src/i18n/locales/en.json
  • web/src/i18n/locales/zh.json
  • service/lua/channel_admission_renew.lua
  • i18n/locales/zh-TW.yaml
  • service/lua/channel_admission_acquire.lua
  • model/channel.go
  • service/lua/channel_admission_release.lua
  • i18n/locales/zh-CN.yaml
  • model/channel_settings_test.go
  • i18n/keys.go
  • relaykit/dto/channel_settings_test.go
  • docs/channel/other_setting.md
  • web/src/i18n/locales/fr.json
  • service/channel_select.go
  • model/ability.go
  • service/channel_admission_test.go
  • middleware/distributor.go
  • relaykit/dto/channel_settings.go
  • model/channel_cache.go
  • controller/relay.go
  • service/lua/channel_admission_snapshot.lua
  • service/channel_admission.go

@faithleysath

Copy link
Copy Markdown
Contributor Author

Review follow-up is in aedf377f:

  • local channel_capacity_exhausted task errors now bypass the generic 429 retry branch, with a regression that keeps ordinary upstream 429 retries intact;
  • specific-channel context values are type-checked before parsing;
  • distributor capacity errors pass the original retry duration without pre-rounding;
  • limited initial-channel acquisition, non-string fixed-channel IDs, and all downstream exit statuses now have direct coverage;
  • weighted-candidate ownership coverage moved to model, and in-memory rolling-RPM pruning is centralized on the admission state.

I verified the distributor-lease finding separately. The initial distributor lease is the first relay attempt's active lease and is released before retry selection; early exits and panics are covered by defers. The locked task path also releases a mismatched initial lease explicitly, so no second channel-switch release was added.

I also kept Release serialized with Commit. Unlocking before the Redis script while marking the lease released would weaken the current ordering and prevent a later idempotent release retry after a Redis error.

The three OIDC locale lines reported as out of scope are value-preserving relocations caused by the repository's mandatory i18n:sync alphabetical ordering; no OIDC UI or translation value changed. Request queueing remains intentionally outside this initial admission-control scope, as documented in the PR.

Revalidated with go test ./..., go vet ./..., focused admission race tests, all frontend tests, typecheck, production build, and format checks.

@faithleysath
faithleysath force-pushed the feat/channel-admission-limits branch from aedf377 to 21077fd Compare August 2, 2026 00:46
@faithleysath

Copy link
Copy Markdown
Contributor Author

Rebase update

Rebased feat/channel-admission-limits onto upstream/main@0ab020206 after the upstream Auto-group changes made the previous head DIRTY. The new PR head is 21077fd3d50d5abea7efaef6dce7faae83170175.

Conflict resolution preserves the token-specific Auto-group snapshot for both normal selection and affinity selection, while keeping channel admission before upstream I/O.

Validation on the rebased head:

  • go test ./... -count=1 passed.
  • go vet ./... passed.
  • relaykit: test and vet passed.
  • Admission-focused race tests, frontend typecheck, changed-file oxlint, i18n sync, and production build passed.

The full frontend bun test and repository format/copyright checks still reproduce pre-existing failures from upstream commit 0ab020206 / merged PR #6590: its api-key-group-cell.tsx comments out AutoGroupBadge while its own tests require the rings, and the protected-header formatter/copyright checker reports unrelated baseline files. Those files are outside this PR diff.

@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 (2)
service/channel_select.go (2)

164-166: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Advance the auto-group state when a group yields no selection.

Line 145 handles an empty tier list by advancing ContextKeyAutoGroupIndex, resetting ContextKeyAutoGroupRetryIndex, and calling param.SetRetry(0). Line 164 handles a nil selection from a non-empty tier list by only calling continue. The two paths leave different auto-group state. In the nil case the loop moves to the next group, but the stored group index still points at the exhausted group, so a later selection call restarts at that group.

Make both exhaustion paths update the same state.

♻️ Proposed consistency fix
 		if selection == nil {
+			common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex, groupIndex+1)
+			common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupRetryIndex, 0)
+			param.SetRetry(0)
 			continue
 		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@service/channel_select.go` around lines 164 - 166, Update the nil-selection
branch in the group-selection loop near selection handling to apply the same
auto-group state advancement as the empty-tier-list path: advance
ContextKeyAutoGroupIndex, reset ContextKeyAutoGroupRetryIndex, and call
param.SetRetry(0) before continuing. Keep the existing behavior for non-nil
selections unchanged.

112-112: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Pass the request context through channel selection.

SelectChannelWithAdmission currently passes *gin.Context into selectAdmittedChannel, and AcquireChannelAdmission(ctx, ...) then uses the Gin context for Redis acquire/snapshot calls. Use c.Request.Context() in the caller for consistency with the specific-channel and affinity paths. If this value must come from RetryParam, extract/wrap the request context with a Request != nil guard instead of storing *gin.Context directly.

Also applies to: 152-152

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@service/channel_select.go` at line 112, Update SelectChannelWithAdmission and
the related call at the second selection site to pass c.Request.Context() into
selectAdmittedChannel rather than *gin.Context, matching the specific-channel
and affinity paths. If the context is obtained through RetryParam, derive it
from Request with a nil guard and avoid storing the Gin context directly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@service/channel_select.go`:
- Around line 220-235: Update the candidate loop around
PickWeightedChannelCandidate and AcquireChannelAdmission so invalid or nil
candidates are removed and skipped rather than terminating selection. For
admission errors, record the error while continuing through remaining
candidates, and return the recorded error only if no candidate is ultimately
admitted; preserve immediate return for an allowed admission.

---

Nitpick comments:
In `@service/channel_select.go`:
- Around line 164-166: Update the nil-selection branch in the group-selection
loop near selection handling to apply the same auto-group state advancement as
the empty-tier-list path: advance ContextKeyAutoGroupIndex, reset
ContextKeyAutoGroupRetryIndex, and call param.SetRetry(0) before continuing.
Keep the existing behavior for non-nil selections unchanged.
- Line 112: Update SelectChannelWithAdmission and the related call at the second
selection site to pass c.Request.Context() into selectAdmittedChannel rather
than *gin.Context, matching the specific-channel and affinity paths. If the
context is obtained through RetryParam, derive it from Request with a nil guard
and avoid storing the Gin context directly.
🪄 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 Plus

Run ID: 85403d46-2a01-49bb-9b19-50f830204453

📥 Commits

Reviewing files that changed from the base of the PR and between aedf377 and 21077fd.

📒 Files selected for processing (37)
  • controller/channel_admission_test.go
  • controller/relay.go
  • docs/channel/other_setting.md
  • i18n/keys.go
  • i18n/locales/en.yaml
  • i18n/locales/zh-CN.yaml
  • i18n/locales/zh-TW.yaml
  • middleware/channel_admission_test.go
  • middleware/distributor.go
  • model/ability.go
  • model/channel.go
  • model/channel_cache.go
  • model/channel_cache_test.go
  • model/channel_settings_test.go
  • relaykit/dto/channel_settings.go
  • relaykit/dto/channel_settings_test.go
  • relaykit/types/error.go
  • service/channel_admission.go
  • service/channel_admission_test.go
  • service/channel_select.go
  • service/lua/channel_admission_acquire.lua
  • service/lua/channel_admission_release.lua
  • service/lua/channel_admission_renew.lua
  • service/lua/channel_admission_snapshot.lua
  • web/src/features/channels/components/drawers/channel-mutate-drawer.tsx
  • web/src/features/channels/lib/__tests__/channel-form-admission-limits.test.ts
  • web/src/features/channels/lib/channel-form-errors.ts
  • web/src/features/channels/lib/channel-form.ts
  • web/src/features/channels/types.ts
  • web/src/i18n/locales/en.json
  • web/src/i18n/locales/fr.json
  • web/src/i18n/locales/ja.json
  • web/src/i18n/locales/ru.json
  • web/src/i18n/locales/vi.json
  • web/src/i18n/locales/zh-TW.json
  • web/src/i18n/locales/zh.json
  • web/src/i18n/static-keys.ts
🚧 Files skipped from review as they are similar to previous changes (28)
  • i18n/keys.go
  • relaykit/types/error.go
  • model/channel.go
  • service/lua/channel_admission_renew.lua
  • i18n/locales/zh-TW.yaml
  • i18n/locales/en.yaml
  • relaykit/dto/channel_settings_test.go
  • service/lua/channel_admission_acquire.lua
  • model/channel_cache_test.go
  • web/src/i18n/static-keys.ts
  • service/lua/channel_admission_snapshot.lua
  • model/channel_settings_test.go
  • web/src/features/channels/lib/channel-form-errors.ts
  • service/lua/channel_admission_release.lua
  • i18n/locales/zh-CN.yaml
  • web/src/features/channels/types.ts
  • web/src/features/channels/components/drawers/channel-mutate-drawer.tsx
  • model/ability.go
  • middleware/distributor.go
  • middleware/channel_admission_test.go
  • controller/channel_admission_test.go
  • docs/channel/other_setting.md
  • web/src/features/channels/lib/channel-form.ts
  • model/channel_cache.go
  • controller/relay.go
  • service/channel_admission_test.go
  • service/channel_admission.go
  • relaykit/dto/channel_settings.go

Comment thread service/channel_select.go
Comment on lines +220 to 235
for len(candidates) > 0 {
candidate, candidateIndex := model.PickWeightedChannelCandidate(candidates)
if candidateIndex < 0 || candidate.Channel == nil {
break
}
logger.LogDebug(param.Ctx, "Auto selecting group: %s, priorityRetry: %d", autoGroup, priorityRetry)

channel, _ = model.GetRandomSatisfiedChannel(autoGroup, param.ModelName, priorityRetry, param.RequestPath)
if channel == nil {
// Current group has no available channel for this model, try next group
// 当前分组没有该模型的可用渠道,尝试下一个分组
logger.LogDebug(param.Ctx, "No available channel in group %s for model %s at priorityRetry %d, trying next group", autoGroup, param.ModelName, priorityRetry)
// 重置状态以尝试下一个分组
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex, i+1)
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupRetryIndex, 0)
// Reset retry counter so outer loop can continue for next group
// 重置重试计数器,以便外层循环可以为下一个分组继续
param.SetRetry(0)
continue
candidates = append(candidates[:candidateIndex], candidates[candidateIndex+1:]...)

lease, decision, err := AcquireChannelAdmission(ctx, candidate.Channel)
if err != nil {
return nil, fmt.Errorf("acquire channel #%d admission: %w", candidate.Channel.Id, err)
}
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroup, autoGroup)
selectGroup = autoGroup
logger.LogDebug(param.Ctx, "Auto selected group: %s", autoGroup)

// Prepare state for next retry
// 为下一次重试准备状态
if crossGroupRetry && priorityRetry >= common.RetryTimes {
// Current group has exhausted all retries, prepare to switch to next group
// This request still uses current group, but next retry will use next group
// 当前分组已用完所有重试次数,准备切换到下一个分组
// 本次请求仍使用当前分组,但下次重试将使用下一个分组
logger.LogDebug(param.Ctx, "Current group %s retries exhausted (priorityRetry=%d >= RetryTimes=%d), preparing switch to next group for next retry", autoGroup, priorityRetry, common.RetryTimes)
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex, i+1)
// Reset retry counter so outer loop can continue for next group
// 重置重试计数器,以便外层循环可以为下一个分组继续
param.SetRetry(0)
param.ResetRetryNextTry()
} else {
// Stay in current group, save current state
// 保持在当前分组,保存当前状态
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex, i)
if decision.Allowed {
return &ChannelSelection{Channel: candidate.Channel, Group: group, Lease: lease}, nil
}
break
}
} else {
channel, err = model.GetRandomSatisfiedChannel(param.TokenGroup, param.ModelName, param.GetRetry(), param.RequestPath)
if err != nil {
return nil, param.TokenGroup, err
capacityErr.addDecision(decision)
}

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

One bad candidate aborts more than itself.

Two paths in this loop stop more work than the failing candidate requires:

  • Line 222: if candidate.Channel is nil, the code breaks out of the inner loop. That abandons every remaining candidate in the tier. Remove the candidate and continue instead.
  • Line 228: if AcquireChannelAdmission returns an error, the whole selection fails. AcquireChannelAdmission returns an error when settings.ValidateAdmissionLimits() fails, so one channel with an invalid stored max_concurrency or rpm_limit blocks routing for every other channel in the group. Skip that candidate, record the error, and return it only when no candidate is admitted.
🛡️ Proposed fix
 	capacityErr := &ChannelCapacityError{}
+	var lastAcquireErr error
 	for tierIndex := startTier; tierIndex < len(tiers); tierIndex++ {
 		candidates := append([]model.ChannelCandidate(nil), tiers[tierIndex].Candidates...)
 		for len(candidates) > 0 {
 			candidate, candidateIndex := model.PickWeightedChannelCandidate(candidates)
-			if candidateIndex < 0 || candidate.Channel == nil {
+			if candidateIndex < 0 {
 				break
 			}
 			candidates = append(candidates[:candidateIndex], candidates[candidateIndex+1:]...)
+			if candidate.Channel == nil {
+				continue
+			}
 
 			lease, decision, err := AcquireChannelAdmission(ctx, candidate.Channel)
 			if err != nil {
-				return nil, fmt.Errorf("acquire channel #%d admission: %w", candidate.Channel.Id, err)
+				lastAcquireErr = fmt.Errorf("acquire channel #%d admission: %w", candidate.Channel.Id, err)
+				logger.LogWarn(ctx, lastAcquireErr.Error())
+				continue
 			}
 			if decision.Allowed {
 				return &ChannelSelection{Channel: candidate.Channel, Group: group, Lease: lease}, nil
 			}
 			capacityErr.addDecision(decision)
 		}
 	}
 	if capacityErr.ConcurrencyRejects > 0 || capacityErr.RPMRejects > 0 {
 		return nil, capacityErr
 	}
+	if lastAcquireErr != nil {
+		return nil, lastAcquireErr
+	}
 	return nil, nil
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@service/channel_select.go` around lines 220 - 235, Update the candidate loop
around PickWeightedChannelCandidate and AcquireChannelAdmission so invalid or
nil candidates are removed and skipped rather than terminating selection. For
admission errors, record the error while continuing through remaining
candidates, and return the recorded error only if no candidate is ultimately
admitted; preserve immediate return for an allowed admission.

@faithleysath

Copy link
Copy Markdown
Contributor Author

CI result after rebase

The rebased head 21077fd3d50d5abea7efaef6dce7faae83170175 produced:

  • Backend vet/build/test: passed.
  • CodeRabbit: passed.
  • Frontend: failed with 98 passed, 3 direct failures in api-key-group-cell.test.tsx, and 13 follow-on node:test nested-describe() errors.

The direct failures are in upstream commit 0ab020206 / merged PR #6590: api-key-group-cell.tsx comments out AutoGroupBadge while the tests assert the rings. The nested errors are Bun issue #5090 behavior after those test files load. This exact failure already exists on #6590 CI run: https://github.com/QuantumNous/new-api/actions/runs/30705644348/job/91384151015. The current PR job is https://github.com/QuantumNous/new-api/actions/runs/30725832265/job/91437239968. None of those upstream UI files are in this PR diff.

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