Skip to content

fix(provider-groups): sync provider_groups table with groupTag source of truth#1030

Merged
ding113 merged 2 commits intodevfrom
fix/provider-group-tab-sync
Apr 17, 2026
Merged

fix(provider-groups): sync provider_groups table with groupTag source of truth#1030
ding113 merged 2 commits intodevfrom
fix/provider-group-tab-sync

Conversation

@ding113
Copy link
Copy Markdown
Owner

@ding113 ding113 commented Apr 17, 2026

Summary

  • 修复「供应商分组管理」Tab 显示「暂无分组配置」的 bug——根因是 provider_groups 元数据表从未与 legacy providers.groupTag 字符串同步
  • 引入读时自愈 + 写时同步双向一致性机制:Tab 打开时补齐历史数据,供应商表单新增 tag 时立即建表行
  • 新增成员优先级编辑入口:每个分组行可展开,列出归属 providers 并行内编辑 groupPriorities[name](只改 target key,不影响全局 priority 和其他分组覆盖)

Related PRs:

Changes

后端

  • src/repository/provider-groups.ts:新增 ensureProviderGroupsExist()ON CONFLICT DO NOTHING 幂等批量 upsert,并触发 invalidateGroupMultiplierCache()
  • src/actions/provider-groups.tsgetProviderGroups 加读时自愈——从 providers.groupTag 聚合被引用的分组名,补齐缺失的表行(costMultiplier 使用默认 1.0,与历史 fallback 行为一致,不改账单)
  • src/actions/providers.ts:3 处写 groupTag 的路径(addProvider / 单字段编辑 / 批量 updateProvider)成功后调 ensureProviderGroupsExist,try/catch 包裹避免阻塞主流程

前端

  • src/app/[locale]/settings/providers/_components/provider-group-tab.tsx:分组行增加展开/折叠;展开后列出成员(过滤 parseProviderGroups(groupTag).includes(groupName) + default 分组兜底),显示当前有效优先级(groupPriorities[name] ?? priority),行内编辑并通过 editProvidergroup_priorities 合并提交
  • messages/{zh-CN,zh-TW,en,ja,ru}/settings/providers/providerGroups.json:补全 5 语言 i18n 键

关键设计原则

  • Source of truth 不变providers.groupTag 仍是路由/下拉/计费的权威来源,provider_groups 只是元数据侧表
  • 不支持改名(避开 groupTag 字符串 + groupPriorities JSON key 双重级联问题)
  • 不自动删除空分组:保留用户配置的 costMultiplier / description,用户可在 providerCount=0 时手动删除
  • 成员优先级编辑走现有 editProvider action:自动触发 publishProviderCacheInvalidation,30s provider cache + Redis pub/sub 跨实例生效

Test plan

  • bun run typecheck — 无错误
  • bun run build — 通过
  • bun run lint — 无新增 warning
  • bun run test — 606 provider 相关测试全部通过(1 个 pre-existing 的 API 健康检查失败与本 PR 无关,需要本地 DB/Redis)
  • 手动:/dashboard/providers → 分组管理 Tab → 确认显示所有实际在用分组(至少 default)
  • 手动:改某分组倍率为 2.0,立即发请求确认计费乘以 2
  • 手动:展开任意分组行 → 编辑某 provider 在本组的优先级 → 刷新回显新值且 groupPriorities 只有该 key 变化
  • 手动:通过供应商表单给某 provider 打一个全新 tag「test-group」→ 不进 Tab 直接查 DB,provider_groups 应已存在该行

🤖 Generated with Claude Code

Greptile Summary

This PR fixes the empty "Provider Groups" tab by introducing a dual consistency mechanism: read-time self-healing in getProviderGroups (backfills any groupTag-referenced groups missing from provider_groups) and write-time sync on the three main write paths (addProvider, editProvider, batchUpdateProviders). It also adds an inline priority editor per group member. Previous review concerns (wrong column header, self-heal swallowing the full response) are addressed.

Confidence Score: 5/5

Safe to merge; the core fix is correct and all prior feedback has been addressed.

The only new finding is a P2 gap in applyProviderBatchPatch — it doesn't call ensureProviderGroupsExist after writing groupTag, but the read-time self-heal in getProviderGroups already covers this case on the next tab load, so there is no data-loss or routing/billing impact. All three previously-raised concerns are resolved.

src/actions/providers.ts — the applyProviderBatchPatch function is the only write path missing the eager group-sync call.

Important Files Changed

Filename Overview
src/actions/providers.ts Adds ensureProviderGroupsExist to addProvider, editProvider, and batchUpdateProviders; applyProviderBatchPatch is the one write path that can change groupTag but lacks the sync call.
src/actions/provider-groups.ts Adds read-time self-heal in getProviderGroups with proper try/catch isolation; correctly filters names exceeding the 200-char schema limit before insertion.
src/repository/provider-groups.ts Adds ensureProviderGroupsExist with idempotent ON CONFLICT DO NOTHING batch insert and invalidateGroupMultiplierCache call; clean and correct.
src/app/[locale]/settings/providers/_components/provider-group-tab.tsx Adds collapsible GroupMembersTable with inline priority editing; uses providerName i18n key (previous concern addressed); filterGroupMembers correctly handles the default-group fallback.
messages/en/settings/providers/providerGroups.json Adds 6 new i18n keys (providerName, effectivePriority, groupMembers, noMembers, savePriorityFailed, savePrioritySuccess) matching the component usage.

Sequence Diagram

sequenceDiagram
    participant Admin
    participant ProviderGroupTab
    participant getProviderGroups
    participant provider_groups_table
    participant providers_table

    Note over Admin,providers_table: Read-time self-heal (tab open)
    Admin->>ProviderGroupTab: opens Groups tab
    ProviderGroupTab->>getProviderGroups: call
    getProviderGroups->>provider_groups_table: findAllProviderGroups()
    getProviderGroups->>providers_table: findAllProvidersFresh()
    getProviderGroups->>getProviderGroups: diff referenced vs existing names
    alt missing groups exist
        getProviderGroups->>provider_groups_table: ensureProviderGroupsExist(missing) ON CONFLICT DO NOTHING
        getProviderGroups->>provider_groups_table: findAllProviderGroups() (refresh)
    end
    getProviderGroups-->>ProviderGroupTab: groups + providerCount

    Note over Admin,providers_table: Write-time sync (provider form)
    Admin->>addProvider: save provider with groupTag
    addProvider->>providers_table: createProvider()
    addProvider->>provider_groups_table: ensureProviderGroupsExist(tags)

    Admin->>editProvider: update groupTag / groupPriorities
    editProvider->>providers_table: updateProvider()
    editProvider->>provider_groups_table: ensureProviderGroupsExist(names)
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/actions/providers.ts
Line: 2069-2088

Comment:
**`applyProviderBatchPatch` missing `ensureProviderGroupsExist` sync**

The PR adds write-time sync to `addProvider`, `editProvider`, and `batchUpdateProviders`, but `applyProviderBatchPatch` calls `updateProvidersBatch` with `repositoryUpdates.groupTag` directly (lines 2070 and 2083) without calling `ensureProviderGroupsExist`. If an admin uses the batch-patch apply flow to set `group_tag` for multiple providers, the new group name won't appear in `provider_groups` until the next tab open triggers the read-time self-heal.

`batchUpdateProviders` (line 2396) already has the correct pattern — the same block can be mirrored here after the update:

```ts
// After the dbUpdatedCount block, before publishProviderCacheInvalidation():
if (repositoryUpdates.groupTag !== undefined) {
  try {
    await ensureProviderGroupsExist(parseProviderGroups(repositoryUpdates.groupTag));
  } catch (error) {
    logger.warn("applyProviderBatchPatch:provider_groups_sync_failed", {
      error: error instanceof Error ? error.message : String(error),
    });
  }
}
```

The read-time self-heal means this is not a correctness regression, but it is the only write path among the four that skips the eager sync.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (2): Last reviewed commit: "fix(provider-groups): address bugbot rev..." | Re-trigger Greptile

… of truth

Provider group management tab displayed "no groups configured" even when
groups existed, because `provider_groups` metadata table was never populated
from legacy `providers.groupTag` strings. Also adds per-member priority
editing so the tab can manage `groupPriorities` overrides.

- repo: add ensureProviderGroupsExist() for idempotent batch upsert with
  ON CONFLICT DO NOTHING + cache invalidation
- action: getProviderGroups read-time self-heal — backfill any groups
  referenced by providers.groupTag but missing from the table
- action: write-time sync in addProvider / single-field edit / batch
  updateProvider, so new tags immediately materialize group rows
- ui: expandable group rows list members with inline per-group priority
  editing; saves via editProvider.group_priorities merge (only touches
  the target key, preserving other overrides and global priority)
- i18n: add groupMembers / effectivePriority / noMembers /
  savePriorityFailed / savePrioritySuccess keys across 5 locales

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 17, 2026

📝 Walkthrough

Walkthrough

扩展提供者组相关本地化字符串(五种语言),新增后端批量保证提供者组存在的仓库函数,并在服务层与前端组件中加入提供者组同步、成员展开与按组优先级编辑功能。

Changes

Cohort / File(s) Summary
本地化文件
messages/en/settings/providers/providerGroups.json, messages/ja/settings/providers/providerGroups.json, messages/ru/settings/providers/providerGroups.json, messages/zh-CN/settings/providers/providerGroups.json, messages/zh-TW/settings/providers/providerGroups.json
为提供者组界面添加新键:groupMembersproviderNameeffectivePrioritynoMemberssavePriorityFailedsavePrioritySuccess;并为现有 groupInUse 增加尾随逗号。
数据访问层
src/repository/provider-groups.ts
新增导出函数 ensureProviderGroupsExist(names: string[]): Promise<void>:去重规范化、批量插入缺失组名(ON CONFLICT DO NOTHING),并失效相关缓存。
业务逻辑层
src/actions/provider-groups.ts, src/actions/providers.ts
getProviderGroups 中检测由 provider.group_tag 引用但缺失的组并尝试补全;在 addProvidereditProviderbatchUpdateProviders 中以非阻塞方式调用 ensureProviderGroupsExist 同步提供者组(失败仅记录日志)。
UI组件
src/app/[locale]/settings/providers/_components/provider-group-tab.tsx
在 ProviderGroupTab 中并行获取组与提供者数据,新增展开/折叠行显示组成员表格;在成员表中展示并可编辑每提供者的组内生效优先级(含输入校验、持久化、操作提示和刷新回调)。

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • PR #701:实现了提供者组成员展示与按组优先级内联编辑,直接重叠前端与本地化改动。
  • PR #1025:触及相同的 provider-groups 模块与本地化文件,包含仓储/服务层相关变更。
  • PR #940:修改了 group_tag 的解析/序列化/验证逻辑,本 PR 的同步行为依赖于解析结果,存在代码级相关性。
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 70.00% 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
Title check ✅ Passed PR 标题清晰准确地总结了核心改动:修复 provider_groups 表与 groupTag 源数据的同步问题。
Description check ✅ Passed PR 描述详细阐述了问题、解决方案、设计原则和测试计划,与改动内容完全相关。

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/provider-group-tab-sync

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.

@github-actions github-actions bot added bug Something isn't working area:UI area:provider area:i18n size/M Medium PR (< 500 lines) labels Apr 17, 2026
@github-actions
Copy link
Copy Markdown
Contributor

🧪 测试结果

测试类型 状态
代码质量
单元测试
集成测试
API 测试

总体结果: ✅ 所有测试通过

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (5)
src/repository/provider-groups.ts (1)

179-184: 即使无新增行也会清缓存

onConflictDoNothing 不返回受影响行数,当前实现无论是否真的插入了新行都会调用 invalidateGroupMultiplierCache()。在批量回填、并发调用或写热路径(addProvider / editProvider / batchUpdateProviders 每次写 group_tag 都会触发)下,会频繁使 60 秒 TTL 的 multiplierCache 失效,抵消缓存价值。可用 .returning({ id: providerGroups.id }) 得到实际插入条数后再决定是否失效。

♻️ 建议改动
-  await db
+  const inserted = await db
     .insert(providerGroups)
     .values(unique.map((name) => ({ name })))
-    .onConflictDoNothing({ target: providerGroups.name });
-
-  invalidateGroupMultiplierCache();
+    .onConflictDoNothing({ target: providerGroups.name })
+    .returning({ id: providerGroups.id });
+
+  if (inserted.length > 0) {
+    invalidateGroupMultiplierCache();
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/repository/provider-groups.ts` around lines 179 - 184, The current insert
into providerGroups always calls invalidateGroupMultiplierCache() even when no
rows were inserted; change the db.insert(...).values(...).onConflictDoNothing({
target: providerGroups.name }) chain to include .returning({ id:
providerGroups.id }) and capture the returned rows, then only call
invalidateGroupMultiplierCache() when the returned rows array is non-empty (i.e.
at least one new id was inserted) so the multiplier cache is only invalidated on
actual inserts.
src/actions/providers.ts (1)

641-649: 三处写时同步可抽小工具

addProvider / editProvider / batchUpdateProviders 里三段 try { await ensureProviderGroupsExist(parseProviderGroups(tag)) } catch { logger.warn(...) } 完全重复。可以抽一个内部 helper(如 syncProviderGroupsFromTag(tag, context))统一处理,降低后续三处漂移的风险。非阻塞。

Also applies to: 842-852, 2386-2395

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

In `@src/actions/providers.ts` around lines 641 - 649, Several places
(addProvider, editProvider, batchUpdateProviders) repeat the same try/await
ensureProviderGroupsExist(parseProviderGroups(...)) with identical catch
logging; extract that logic into a small internal helper
syncProviderGroupsFromTag(tag, context) that calls parseProviderGroups(tag) then
await ensureProviderGroupsExist(...), catches errors and calls
logger.warn("addProvider:provider_groups_sync_failed", { providerId:
context?.provider?.id, error: error instanceof Error ? error.message :
String(error) }); replace the three duplicated blocks with calls to
syncProviderGroupsFromTag(payload.group_tag, { provider }) (or the appropriate
context) so all parsing, awaiting and standardized logging live in one place to
avoid drift.
src/actions/provider-groups.ts (1)

50-82: 重复遍历 providers 可合并

第 52-59 行聚合 referenced 与第 73-82 行统计 groupCounts 是两次几乎相同的循环(都 parseProviderGroups(provider.groupTag) + 空列表落到 DEFAULT)。可合并为一次遍历,referenced 直接取自 groupCounts.keys(),顺便减少一次数组创建。非必须,但能去重逻辑减少未来漂移。

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

In `@src/actions/provider-groups.ts` around lines 50 - 82, The code currently
loops providers twice (building referenced set then groupCounts); merge these
into one pass over providers where you call
parseProviderGroups(provider.groupTag) once, update groupCounts (Map) and also
collect referenced names (or derive referenced from groupCounts.keys()), falling
back to PROVIDER_GROUP.DEFAULT when parsedGroups is empty; then use referenced
to compute missing and call ensureProviderGroupsExist/maybe
findAllProviderGroups as before (keep symbols: providers, parseProviderGroups,
referenced, groupCounts, PROVIDER_GROUP.DEFAULT, ensureProviderGroupsExist,
findAllProviderGroups, initialGroups).
src/app/[locale]/settings/providers/_components/provider-group-tab.tsx (2)

286-299: 展开/收起按钮的 aria 语义建议优化

aria-label 固定为 t("groupMembers")("组成员"),不随展开/收起状态变化,屏幕阅读器无法感知当前是展开还是收起。建议改用 aria-expanded(+ 可选的 aria-controls),并把 label 也切换成"展开/收起"语义。属于 a11y 小优化。

♿ 建议的改动
             <Button
               variant="ghost"
               size="icon"
               className="h-8 w-8"
               onClick={() => toggleExpand(group.id)}
-              aria-label={t("groupMembers")}
+              aria-expanded={isExpanded}
+              aria-label={isExpanded ? t("collapseMembers") : t("expandMembers")}
             >

需要在 5 种语言的 providerGroups.json 中补充 expandMembers / collapseMembers 文案。

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

In `@src/app/`[locale]/settings/providers/_components/provider-group-tab.tsx
around lines 286 - 299, The expand/collapse button currently uses a static
aria-label t("groupMembers") so screen readers can’t tell state; update the
Button rendered in provider-group-tab.tsx to use aria-expanded={isExpanded} (and
optionally aria-controls={`group-${group.id}-members`}) and switch the
aria-label to t(isExpanded ? "collapseMembers" : "expandMembers") so the label
reflects state; also add the new keys "expandMembers" and "collapseMembers" to
providerGroups.json in all five languages.

279-282: 可将分组成员索引预先聚合,避免 O(G·P) 遍历

每次渲染时,外层 groups.map 对每个分组都会在 filterGroupMembers 内对整个 providers 做一遍 parseProviderGroups+filter。当前规模下没问题,但可以用 useMemoproviders 一次性按组名聚合成 Map<string, ProviderDisplay[]>,渲染时按 group.name 直接取。纯优化项,可按需延后。

♻️ 建议示例
+  const membersByGroup = useMemo(() => {
+    const map = new Map<string, ProviderDisplay[]>();
+    for (const p of providers) {
+      const tags = parseProviderGroups(p.groupTag);
+      const keys = tags.length === 0 ? [PROVIDER_GROUP.DEFAULT] : tags;
+      for (const k of keys) {
+        const list = map.get(k);
+        if (list) list.push(p);
+        else map.set(k, [p]);
+      }
+    }
+    return map;
+  }, [providers]);
   ...
-  const members = filterGroupMembers(providers, group.name);
+  const members = membersByGroup.get(group.name) ?? [];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`[locale]/settings/providers/_components/provider-group-tab.tsx
around lines 279 - 282, The render currently calls filterGroupMembers(providers,
group.name) inside groups.map causing O(G·P) work because parseProviderGroups
runs for each group; fix by computing a Map<string, ProviderDisplay[]> once with
useMemo (keyed on providers) that runs parseProviderGroups and groups providers
by group.name, then inside the groups.map replace filterGroupMembers(...) with a
direct lookup like groupedProviders.get(group.name); keep existing refs such as
groups.map, filterGroupMembers, providers, expandedGroups and
PROVIDER_GROUP.DEFAULT but remove per-group parsing to avoid repeated work.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/actions/provider-groups.ts`:
- Around line 45-69: The read-path healing (in getProviderGroups) currently
calls ensureProviderGroupsExist(missing) and then re-fetches groups via
findAllProviderGroups() without error handling, which causes the whole request
to fail on transient DB errors; wrap the ensureProviderGroupsExist(missing) and
the subsequent groups = await findAllProviderGroups() in a try/catch, log the
error (logger.warn/error) and on catch keep groups = initialGroups so the
function falls back to the original set (still allow aggregation over
providers/parseProviderGroups to include missing names in counts) instead of
throwing.

In `@src/actions/providers.ts`:
- Around line 2386-2395: The log prefix used in the catch for provider group
sync is inconsistent with the surrounding function: change the logger.warn key
from "updateProvider:provider_groups_sync_failed" to
"batchUpdateProviders:provider_groups_sync_failed" inside the
batchUpdateProviders flow; update the call near
ensureProviderGroupsExist(parseProviderGroups(repositoryUpdates.groupTag)) where
logger.warn is invoked so its first argument matches the function name and keep
the existing payload (error message extraction) unchanged.

In `@src/app/`[locale]/settings/providers/_components/provider-group-tab.tsx:
- Around line 517-523: handleSave currently converts draft to Number which
treats empty or whitespace-only strings as 0; change the validation to first
reject empty/whitespace drafts and non-numeric input before calling Number().
Specifically, inside handleSave (the function using draft, trimmed, value and
calling toast.error(t("savePriorityFailed"))), check trimmed === "" or that
trimmed does not match /^\d+$/ (or otherwise is not an integer string) and call
toast.error/return in that case; only then parse Number(trimmed) and proceed
with the existing Number.isFinite/Number.isInteger/value < 0 checks.

---

Nitpick comments:
In `@src/actions/provider-groups.ts`:
- Around line 50-82: The code currently loops providers twice (building
referenced set then groupCounts); merge these into one pass over providers where
you call parseProviderGroups(provider.groupTag) once, update groupCounts (Map)
and also collect referenced names (or derive referenced from
groupCounts.keys()), falling back to PROVIDER_GROUP.DEFAULT when parsedGroups is
empty; then use referenced to compute missing and call
ensureProviderGroupsExist/maybe findAllProviderGroups as before (keep symbols:
providers, parseProviderGroups, referenced, groupCounts, PROVIDER_GROUP.DEFAULT,
ensureProviderGroupsExist, findAllProviderGroups, initialGroups).

In `@src/actions/providers.ts`:
- Around line 641-649: Several places (addProvider, editProvider,
batchUpdateProviders) repeat the same try/await
ensureProviderGroupsExist(parseProviderGroups(...)) with identical catch
logging; extract that logic into a small internal helper
syncProviderGroupsFromTag(tag, context) that calls parseProviderGroups(tag) then
await ensureProviderGroupsExist(...), catches errors and calls
logger.warn("addProvider:provider_groups_sync_failed", { providerId:
context?.provider?.id, error: error instanceof Error ? error.message :
String(error) }); replace the three duplicated blocks with calls to
syncProviderGroupsFromTag(payload.group_tag, { provider }) (or the appropriate
context) so all parsing, awaiting and standardized logging live in one place to
avoid drift.

In `@src/app/`[locale]/settings/providers/_components/provider-group-tab.tsx:
- Around line 286-299: The expand/collapse button currently uses a static
aria-label t("groupMembers") so screen readers can’t tell state; update the
Button rendered in provider-group-tab.tsx to use aria-expanded={isExpanded} (and
optionally aria-controls={`group-${group.id}-members`}) and switch the
aria-label to t(isExpanded ? "collapseMembers" : "expandMembers") so the label
reflects state; also add the new keys "expandMembers" and "collapseMembers" to
providerGroups.json in all five languages.
- Around line 279-282: The render currently calls filterGroupMembers(providers,
group.name) inside groups.map causing O(G·P) work because parseProviderGroups
runs for each group; fix by computing a Map<string, ProviderDisplay[]> once with
useMemo (keyed on providers) that runs parseProviderGroups and groups providers
by group.name, then inside the groups.map replace filterGroupMembers(...) with a
direct lookup like groupedProviders.get(group.name); keep existing refs such as
groups.map, filterGroupMembers, providers, expandedGroups and
PROVIDER_GROUP.DEFAULT but remove per-group parsing to avoid repeated work.

In `@src/repository/provider-groups.ts`:
- Around line 179-184: The current insert into providerGroups always calls
invalidateGroupMultiplierCache() even when no rows were inserted; change the
db.insert(...).values(...).onConflictDoNothing({ target: providerGroups.name })
chain to include .returning({ id: providerGroups.id }) and capture the returned
rows, then only call invalidateGroupMultiplierCache() when the returned rows
array is non-empty (i.e. at least one new id was inserted) so the multiplier
cache is only invalidated on actual inserts.
🪄 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: 73e2b426-3fc2-4b06-a44f-9a560b1d59da

📥 Commits

Reviewing files that changed from the base of the PR and between 63be2d7 and 91c9b44.

📒 Files selected for processing (9)
  • messages/en/settings/providers/providerGroups.json
  • messages/ja/settings/providers/providerGroups.json
  • messages/ru/settings/providers/providerGroups.json
  • messages/zh-CN/settings/providers/providerGroups.json
  • messages/zh-TW/settings/providers/providerGroups.json
  • src/actions/provider-groups.ts
  • src/actions/providers.ts
  • src/app/[locale]/settings/providers/_components/provider-group-tab.tsx
  • src/repository/provider-groups.ts

Comment thread src/actions/provider-groups.ts Outdated
Comment thread src/actions/providers.ts
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("groupName")}</TableHead>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Wrong column header in members sub-table

t("groupName") resolves to "Group Name" / "分组名称" in every locale, but this column in GroupMembersTable displays the provider name (member.name), not a group name. Admins expanding a group will see a column header that says "Group Name" for a column listing providers — the opposite of what's shown.

A dedicated i18n key (providerName) should be added to all 5 locale files, or a generic "name" key if it already exists.

Suggested change
<TableHead>{t("groupName")}</TableHead>
<TableHead>{t("providerName")}</TableHead>
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/[locale]/settings/providers/_components/provider-group-tab.tsx
Line: 482

Comment:
**Wrong column header in members sub-table**

`t("groupName")` resolves to "Group Name" / "分组名称" in every locale, but this column in `GroupMembersTable` displays the **provider name** (`member.name`), not a group name. Admins expanding a group will see a column header that says "Group Name" for a column listing providers — the opposite of what's shown.

A dedicated i18n key (`providerName`) should be added to all 5 locale files, or a generic `"name"` key if it already exists.

```suggestion
            <TableHead>{t("providerName")}</TableHead>
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +524 to +537

const existing = (member.groupPriorities ?? null) as Record<string, number> | null;
const merged: Record<string, number> = { ...(existing ?? {}), [groupName]: value };

startSaveTransition(async () => {
const result = await editProvider(member.id, { group_priorities: merged });
if (result.ok) {
toast.success(t("savePrioritySuccess"));
onSaved();
} else {
toast.error(result.error ?? t("savePriorityFailed"));
}
});
}, [draft, member.groupPriorities, member.id, groupName, onSaved, t]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Client-side merge can silently clobber concurrent group-priority saves

handleSave reads member.groupPriorities from the component's stale prop to build the merged object, then calls editProvider which replaces the field entirely. If the same provider is visible in two expanded group rows simultaneously and both saves race (e.g., admin sets group A priority → sets group B priority before the first fetchGroups() completes), the second save sends a merged object built from the pre-save snapshot, which no longer includes the first save's key — silently losing it.

Consider sending only the delta ({ groupName: value }) and performing the merge atomically on the server (a JSON patch / jsonb_set in Postgres), or at minimum document that concurrent edits on the same provider from two group panels are unsupported.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/[locale]/settings/providers/_components/provider-group-tab.tsx
Line: 524-537

Comment:
**Client-side merge can silently clobber concurrent group-priority saves**

`handleSave` reads `member.groupPriorities` from the component's stale prop to build the merged object, then calls `editProvider` which **replaces** the field entirely. If the same provider is visible in two expanded group rows simultaneously and both saves race (e.g., admin sets group A priority → sets group B priority before the first `fetchGroups()` completes), the second save sends a `merged` object built from the pre-save snapshot, which no longer includes the first save's key — silently losing it.

Consider sending only the delta (`{ groupName: value }`) and performing the merge atomically on the server (a JSON patch / `jsonb_set` in Postgres), or at minimum document that concurrent edits on the same provider from two group panels are unsupported.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread src/actions/provider-groups.ts Outdated
Comment on lines +65 to +68
if (missing.length > 0) {
await ensureProviderGroupsExist(missing);
// 重新拉取一次,拿到新插入行的完整字段(id/timestamps/默认倍率)
groups = await findAllProviderGroups();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Self-heal failure in read path silently breaks the entire response

Unlike the write paths in providers.ts (which wrap ensureProviderGroupsExist in try/catch and log a warning without blocking), the call here is inside the outer try/catch of getProviderGroups. A transient DB error during self-heal returns { ok: false, error: "Failed to fetch provider groups" }, causing the whole tab to show an error. For a best-effort background sync, consider wrapping this block independently so a heal failure degrades gracefully:

try {
  await ensureProviderGroupsExist(missing);
  groups = await findAllProviderGroups();
} catch (err) {
  logger.warn("getProviderGroups:self_heal_failed", { missing, err });
  // fall through with initialGroups; next load will retry
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/actions/provider-groups.ts
Line: 65-68

Comment:
**Self-heal failure in read path silently breaks the entire response**

Unlike the write paths in `providers.ts` (which wrap `ensureProviderGroupsExist` in try/catch and log a warning without blocking), the call here is inside the outer try/catch of `getProviderGroups`. A transient DB error during self-heal returns `{ ok: false, error: "Failed to fetch provider groups" }`, causing the whole tab to show an error. For a best-effort background sync, consider wrapping this block independently so a heal failure degrades gracefully:

```ts
try {
  await ensureProviderGroupsExist(missing);
  groups = await findAllProviderGroups();
} catch (err) {
  logger.warn("getProviderGroups:self_heal_failed", { missing, err });
  // fall through with initialGroups; next load will retry
}
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 91c9b445bb

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/actions/provider-groups.ts Outdated
const missing = [...referenced].filter((n) => !existingNames.has(n));
let groups = initialGroups;
if (missing.length > 0) {
await ensureProviderGroupsExist(missing);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Guard read-time group sync against overlength tags

getProviderGroups now writes missing names from providers.groupTag directly into provider_groups via ensureProviderGroupsExist, but provider tags can be up to 255 chars while provider_groups.name is capped at 200 (providers.groupTag vs provider_groups.name schema limits). When an existing provider has a tag longer than 200 chars, this insert throws and the whole action falls into the catch path, so the group management tab fails to load instead of returning existing groups. Please filter/validate oversized names before calling the sync helper (or skip invalid names) so read-time self-heal cannot take down the page.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request implements a self-healing mechanism for provider groups and enhances the management UI to support viewing group members and editing group-specific priorities. Feedback highlights a potential race condition when updating priorities, redundant database queries and iterations in the backend actions, and opportunities to optimize the frontend rendering performance by memoizing provider-to-group mappings. Additionally, it is suggested to sync group names from the priorities object as well as the tags.

}

const existing = (member.groupPriorities ?? null) as Record<string, number> | null;
const merged: Record<string, number> = { ...(existing ?? {}), [groupName]: value };
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.

high

There is a potential race condition here. Since merged is constructed using the member object from the component's state, if a user saves priorities for the same provider in two different groups in rapid succession, the second save might overwrite the first one if the state hasn't been refreshed yet. A safer approach would be to perform an atomic JSONB merge on the server side in the editProvider action.

Comment on lines +51 to +59
const referenced = new Set<string>();
for (const provider of providers) {
const parsed = parseProviderGroups(provider.groupTag);
if (parsed.length === 0) {
referenced.add(PROVIDER_GROUP.DEFAULT);
} else {
for (const name of parsed) referenced.add(name);
}
}
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.

medium

This loop over providers to aggregate referenced group names is redundant because a similar loop is executed later (lines 73-82) to calculate groupCounts. You can derive the referenced set directly from the keys of the groupCounts map after it is populated, avoiding an extra iteration over the entire provider list.

Comment on lines +83 to +86
const [groupsResult, providersResult] = await Promise.all([
getProviderGroups(),
getProviders(),
]);
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.

medium

Calling getProviderGroups() and getProviders() in parallel results in redundant database queries. Both server actions internally call findAllProvidersFresh() to fetch the complete list of providers. Consider optimizing this by having getProviderGroups return the provider list as well, or by fetching the providers once and deriving the group metadata from them.

const [groups, setGroups] = useState<ProviderGroupWithCount[]>([]);
const [providers, setProviders] = useState<ProviderDisplay[]>([]);
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set());
const [isLoading, startLoadTransition] = useTransition();
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.

medium

To improve performance in the render loop, consider pre-calculating a mapping of group names to their member providers. Currently, filterGroupMembers is called for every group row on every render, which is an O(G * P) operation.

Suggested change
const [isLoading, startLoadTransition] = useTransition();
const [isLoading, startLoadTransition] = useTransition();
const providersByGroup = useMemo(() => {
const map = new Map<string, ProviderDisplay[]>();
providers.forEach((p) => {
const tags = parseProviderGroups(p.groupTag);
const effectiveTags = tags.length > 0 ? tags : [PROVIDER_GROUP.DEFAULT];
effectiveTags.forEach((tag) => {
if (!map.has(tag)) map.set(tag, []);
map.get(tag)!.push(p);
});
});
return map;
}, [providers]);

{groups.map((group) => {
const isDefault = group.name === PROVIDER_GROUP.DEFAULT;
const isExpanded = expandedGroups.has(group.id);
const members = filterGroupMembers(providers, group.name);
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.

medium

Use the memoized group mapping to avoid re-filtering the entire provider list for every group row.

Suggested change
const members = filterGroupMembers(providers, group.name);
const members = providersByGroup.get(group.name) || [];

Comment thread src/actions/providers.ts
Comment on lines +843 to +852
if (payload.group_tag !== undefined) {
try {
await ensureProviderGroupsExist(parseProviderGroups(payload.group_tag));
} catch (error) {
logger.warn("editProvider:provider_groups_sync_failed", {
providerId,
error: error instanceof Error ? error.message : String(error),
});
}
}
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.

medium

When updating a provider, we should also ensure that any group names referenced in group_priorities exist in the provider_groups table, not just the ones in group_tag.

Suggested change
if (payload.group_tag !== undefined) {
try {
await ensureProviderGroupsExist(parseProviderGroups(payload.group_tag));
} catch (error) {
logger.warn("editProvider:provider_groups_sync_failed", {
providerId,
error: error instanceof Error ? error.message : String(error),
});
}
}
// 同步 provider_groups 表(系统级,失败不影响主流程)
const groupsToSync = new Set<string>();
if (payload.group_tag !== undefined) {
parseProviderGroups(payload.group_tag).forEach((g) => groupsToSync.add(g));
}
if (payload.group_priorities) {
Object.keys(payload.group_priorities).forEach((g) => groupsToSync.add(g));
}
if (groupsToSync.size > 0) {
try {
await ensureProviderGroupsExist(Array.from(groupsToSync));
} catch (error) {
logger.warn("editProvider:provider_groups_sync_failed", {
providerId,
error: error instanceof Error ? error.message : String(error),
});
}
}

- Wrap read-time self-heal in try/catch so Tab stays usable when sync
  fails (e.g. historical groupTag exceeds provider_groups.name 200-char
  cap); filter oversized names before upsert
- Merge the two provider-iteration passes in getProviderGroups into one
- editProvider now also materializes groups referenced only in
  group_priorities (previously only group_tag triggered sync)
- batchUpdateProviders log key mismatch (updateProvider -> batchUpdateProviders)
- Member priority editor rejects empty/whitespace input instead of
  silently saving 0 (Number("") === 0 bypasses all numeric validators)
- Members sub-table column header fixed: providerName, not groupName

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ding113 ding113 merged commit ffe95aa into dev Apr 17, 2026
8 of 9 checks passed
@github-project-automation github-project-automation bot moved this from Backlog to Done in Claude Code Hub Roadmap Apr 17, 2026
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("groupName")}</TableHead>
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.

[Medium] [LOGIC-BUG] Group members table header labels provider names as "Group Name"

Why this is a problem: This column renders provider names ({member.name}), but the header uses t("groupName") which resolves to "Group Name" (e.g. messages/en/settings/providers/providerGroups.json:7). This is user-visible and misleading when expanding a group.

Suggested fix:

// src/app/[locale]/settings/providers/_components/provider-group-tab.tsx
<TableHead>{t("providerName")}</TableHead>
// messages/en/settings/providers/providerGroups.json
"providerName": "Provider"

Comment thread src/actions/provider-groups.ts Outdated
const missing = [...referenced].filter((n) => !existingNames.has(n));
let groups = initialGroups;
if (missing.length > 0) {
await ensureProviderGroupsExist(missing);
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.

[High] [TEST-MISSING-CRITICAL] New getProviderGroups() self-heal branch has no unit coverage

Why this is a problem: This PR adds a new behavior branch that mutates state during reads:

  • const missing = [...referenced].filter((n) => !existingNames.has(n));
  • await ensureProviderGroupsExist(missing);

Repo rule: **Test Coverage** - All new features must have unit test coverage of at least 80%.

Without a test, regressions in the "missing referenced groups" path (and default-group counting) can ship silently.

Suggested fix:

// tests/unit/actions/provider-groups-get-provider-groups-self-heal.test.ts
import { beforeEach, describe, expect, it, vi } from "vitest";

const getSessionMock = vi.fn();
const findAllProviderGroupsMock = vi.fn();
const findAllProvidersFreshMock = vi.fn();
const ensureProviderGroupsExistMock = vi.fn();

vi.mock("@/lib/auth", () => ({ getSession: getSessionMock }));
vi.mock("@/repository/provider", () => ({ findAllProvidersFresh: findAllProvidersFreshMock }));
vi.mock("@/repository/provider-groups", () => ({
  countProvidersUsingGroup: vi.fn(),
  ensureProviderGroupsExist: ensureProviderGroupsExistMock,
  findAllProviderGroups: findAllProviderGroupsMock,
  findProviderGroupById: vi.fn(),
  findProviderGroupByName: vi.fn(),
  createProviderGroup: vi.fn(),
  deleteProviderGroup: vi.fn(),
  updateProviderGroup: vi.fn(),
}));
vi.mock("@/lib/audit/emit", () => ({ emitActionAudit: vi.fn() }));
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));

describe("getProviderGroups self-heal", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    getSessionMock.mockResolvedValue({ user: { role: "admin" } });
  });

  it("inserts missing referenced groups and returns refreshed list", async () => {
    findAllProvidersFreshMock.mockResolvedValue([{ groupTag: "premium" }, { groupTag: null }]);
    findAllProviderGroupsMock
      .mockResolvedValueOnce([
        { id: 1, name: "default", costMultiplier: 1, description: null, createdAt: new Date(), updatedAt: new Date() },
      ])
      .mockResolvedValueOnce([
        { id: 1, name: "default", costMultiplier: 1, description: null, createdAt: new Date(), updatedAt: new Date() },
        { id: 2, name: "premium", costMultiplier: 1, description: null, createdAt: new Date(), updatedAt: new Date() },
      ]);

    const { getProviderGroups } = await import("@/actions/provider-groups");
    const result = await getProviderGroups();

    expect(ensureProviderGroupsExistMock).toHaveBeenCalledWith(["premium"]);
    expect(result.ok).toBe(true);
    expect(result.data?.find((g) => g.name === "premium")?.providerCount).toBe(1);
    expect(result.data?.find((g) => g.name === "default")?.providerCount).toBe(1);
  });
});

@github-actions
Copy link
Copy Markdown
Contributor

🧪 测试结果

测试类型 状态
代码质量
单元测试
集成测试
API 测试

总体结果: ✅ 所有测试通过

*
* 不触发 audit——这是系统级同步,非用户显式操作。
*/
export async function ensureProviderGroupsExist(names: string[]): Promise<void> {
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.

[High] [TEST-MISSING-CRITICAL] ensureProviderGroupsExist() added without unit tests

Why this is a problem: This PR introduces a new repository helper that writes to provider_groups:

  • export async function ensureProviderGroupsExist(names: string[]): Promise<void> { ... }
  • await db.insert(providerGroups).values(...).onConflictDoNothing({ target: providerGroups.name });

Repo rule: **Test Coverage** - All new features must have unit test coverage of at least 80%.

There is already a dedicated repository test file (tests/unit/repository/provider-groups.test.ts) that can be extended to cover trimming/deduping and the ON CONFLICT DO NOTHING behavior.

Suggested fix:

// tests/unit/repository/provider-groups.test.ts
// 1) Extend the insert mock chain to support .onConflictDoNothing()
const onConflictDoNothingMock = vi.fn();

function resetChainMocks() {
  // ...existing...
  insertMock.mockReturnValue({ values: valuesMock });
  valuesMock.mockReturnValue({ returning: returningMock, onConflictDoNothing: onConflictDoNothingMock });
  onConflictDoNothingMock.mockResolvedValue(undefined);
}

it("ensureProviderGroupsExist() trims/dedupes and skips empties", async () => {
  const { ensureProviderGroupsExist } = await import("@/repository/provider-groups");

  await ensureProviderGroupsExist([" premium ", "", "premium", "enterprise"]);

  expect(valuesMock).toHaveBeenCalledWith([{ name: "premium" }, { name: "enterprise" }]);
  expect(onConflictDoNothingMock).toHaveBeenCalledWith({ target: "name" });
});

<Table>
<TableHeader>
<TableRow>
<TableHead>{t("groupName")}</TableHead>
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.

[High] [LOGIC-BUG] Wrong column header in members sub-table

Why this is a problem: The <TableHead> at line 482 uses t("groupName") (renders as "Group Name" / "分组名称" in all 5 locales), but the column actually displays member.name (the provider name) in the MemberRow below. Admins expanding a group will see a column labeled "Group Name" listing provider names — confusing and incorrect across every language.

Suggested fix: Add a new i18n key (e.g. providerName) to all 5 locale files and use it here:

<TableHead>{t("providerName")}</TableHead>

Comment thread src/actions/provider-groups.ts Outdated
if (missing.length > 0) {
await ensureProviderGroupsExist(missing);
// 重新拉取一次,拿到新插入行的完整字段(id/timestamps/默认倍率)
groups = await findAllProviderGroups();
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.

[Medium] [ERROR-BROAD-CATCH] Self-heal failure breaks the entire response

Why this is a problem: The write paths in src/actions/providers.ts wrap ensureProviderGroupsExist in their own try/catch with a warn log, so a self-heal failure never blocks the primary action. Here in the read path, the call sits inside the outer try/catch of getProviderGroups (line 90: catch (error) -> returns { ok: false, error: "Failed to fetch provider groups" }). A transient DB hiccup during the best-effort backfill will surface as a full tab error, even though initialGroups was already fetched successfully — the user sees "Failed to fetch provider groups" instead of the (slightly stale) data the system already has.

Suggested fix: Wrap the heal block independently so failures degrade gracefully:

if (missing.length > 0) {
  try {
    await ensureProviderGroupsExist(missing);
    groups = await findAllProviderGroups();
  } catch (err) {
    logger.warn("getProviderGroups:self_heal_failed", {
      missing,
      error: err instanceof Error ? err.message : String(err),
    });
    // fall through with initialGroups; next load retries
  }
}

Copy link
Copy Markdown
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

Code Review Summary

The dual-sync approach (read-time self-heal + write-time propagation) is sound and the idempotent ON CONFLICT DO NOTHING upsert is correctly implemented. Two real issues identified: one user-facing i18n bug (wrong column header across all 5 locales) and one error-handling asymmetry where the read-path self-heal is not isolated from the outer catch.

PR Size: M

  • Lines changed: 364 (+318 / -46)
  • Files changed: 9

Issues Found

Category Critical High Medium Low
Logic/Bugs 0 1 0 0
Security 0 0 0 0
Error Handling 0 0 1 0
Types 0 0 0 0
Comments/Docs 0 0 0 0
Tests 0 0 0 0
Simplification 0 0 0 0

Critical Issues (Must Fix)

None.

High Priority Issues (Should Fix)

  • Wrong column header in members sub-table (src/app/[locale]/settings/providers/_components/provider-group-tab.tsx:482) — t("groupName") labels the provider-name column as "Group Name" / "分组名称" in all 5 locales.

Medium Priority Issues (Consider Fixing)

  • Self-heal failure breaks the entire response (src/actions/provider-groups.ts:65-68) — unlike the symmetric write-path wrappers in providers.ts, the read-path self-heal is not isolated, so a transient DB error during backfill turns into a full tab error even though initialGroups already loaded.

Review Coverage

  • Logic and correctness
  • Security (OWASP Top 10)
  • Error handling
  • Type safety
  • Documentation accuracy
  • Test coverage
  • Code clarity

Automated review by Claude AI

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9b4d433f08

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +529 to +533
const existing = (member.groupPriorities ?? null) as Record<string, number> | null;
const merged: Record<string, number> = { ...(existing ?? {}), [groupName]: value };

startSaveTransition(async () => {
const result = await editProvider(member.id, { group_priorities: merged });
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Merge group-priority updates atomically on the server

handleSave computes merged from the row's current member.groupPriorities snapshot and then sends that whole object to editProvider, but group_priorities is stored as a full-field replacement. If the same provider is edited from another expanded group (or another admin session) before fetchGroups() refreshes this row, the later save can overwrite and drop keys written by the earlier save. This creates silent config data loss for concurrent edits; the update should be merged server-side (or refreshed from latest state before submit).

Useful? React with 👍 / 👎.

Comment on lines +179 to +182
await db
.insert(providerGroups)
.values(unique.map((name) => ({ name })))
.onConflictDoNothing({ target: providerGroups.name });
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Filter oversized names before bulk upserting provider groups

ensureProviderGroupsExist only trims and de-duplicates names, then bulk inserts all of them in one statement. Since provider_groups.name is limited to 200 chars while source tags can be longer, one oversized value causes the entire insert to fail, so valid shorter names in the same batch are skipped too. In write paths this exception is swallowed with a warning, so provider edits succeed while metadata silently remains unsynced.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

Code Review Summary

This PR adds new provider group synchronization behavior (read-time self-heal + write-time sync) and expands the settings UI with per-group member priority editing. The highest risk is regression in the new sync paths because no unit tests were added/updated to lock in the behavior. There is also a user-visible labeling bug in the new group members table.

PR Size: M

  • Lines changed: 364
  • Files changed: 9

Issues Found

Category Critical High Medium Low
Logic/Bugs 0 0 1 0
Security 0 0 0 0
Error Handling 0 0 0 0
Types 0 0 0 0
Comments/Docs 0 0 0 0
Tests 0 2 0 0
Simplification 0 0 0 0

Critical Issues (Must Fix)

None.

High Priority Issues (Should Fix)

  • [High] [TEST-MISSING-CRITICAL] getProviderGroups() self-heal branch lacks unit coverage (src/actions/provider-groups.ts:66, Confidence: 90)
  • [High] [TEST-MISSING-CRITICAL] ensureProviderGroupsExist() added without unit coverage (src/repository/provider-groups.ts:175, Confidence: 90)
  • [Medium] [LOGIC-BUG] Group members table header labels provider names as "Group Name" (src/app/[locale]/settings/providers/_components/provider-group-tab.tsx:482, Confidence: 85)

Review Coverage

  • Logic and correctness
  • Security (OWASP Top 10)
  • Error handling
  • Type safety
  • Documentation accuracy
  • Test coverage
  • Code clarity

Automated review by Codex AI

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

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 (2)
src/actions/provider-groups.ts (1)

70-73: PROVIDER_GROUP_NAME_MAX 建议集中定义

硬编码的 200 依赖注释来与 schema 保持同步,后续 schema 调整时容易漏改。可在 @/lib/constants/provider.constants@/types/provider-group 中统一导出,让 repository(upsert 校验)与 action(预过滤)共用同一个常量。非阻塞。

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

In `@src/actions/provider-groups.ts` around lines 70 - 73, The local constant
PROVIDER_GROUP_NAME_MAX (used in the missing filter that references referenced
and existingNames) should be centralized: add and export a single MAX_NAME (or
PROVIDER_GROUP_NAME_MAX) from the shared constants module (e.g.,
provider.constants or types/provider-group) and replace the local const in
provider-groups.ts with an import of that exported constant; also update the
repository/upsert validation to consume the same exported constant so both the
pre-filter in the missing computation and the upsert validation use the
identical source of truth.
src/app/[locale]/settings/providers/_components/provider-group-tab.tsx (1)

279-353: 每次渲染都重算所有分组成员 (可选)

filterGroupMembers(providers, group.name) 在 groups.map 里每渲染都会对每个组全量遍历 providers。toggleExpand / dialog 开关 / 父组件更新都会触发。当 provider 量级上来后有轻微开销。可在循环外一次性构建 Map<groupName, ProviderDisplay[]>,或用 useMemoproviders 做记忆。非阻塞。

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

In `@src/app/`[locale]/settings/providers/_components/provider-group-tab.tsx
around lines 279 - 353, The code repeatedly calls filterGroupMembers(providers,
group.name) inside the groups.map loop which re-traverses providers on every
render; change this to compute a single lookup (e.g., Map<string,
ProviderDisplay[]>) for group members outside the map or memoize it with useMemo
dependent on providers (and groups if needed), then replace filterGroupMembers
calls in the loop with a quick map lookup (used by GroupMembersTable's members
prop) to avoid full scans on each render.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/app/`[locale]/settings/providers/_components/provider-group-tab.tsx:
- Around line 81-94: fetchGroups currently awaits
Promise.all([getProviderGroups(), getProviders()]) inside startLoadTransition
without a try/catch so any rejection (e.g. getProviders) aborts the transition
and hides group results; wrap the awaited calls in error boundaries (either use
try/catch around separate awaits or use Promise.allSettled) so getProviderGroups
and getProviders degrade independently: call getProviderGroups() and if
groupsResult.ok call setGroups(groupsResult.data) else
toast.error(groupsResult.error); for providers, if the call succeeds call
setProviders(...) otherwise toast.error(...) — keep this logic inside the
existing startLoadTransition and ensure rejections are caught to prevent
unhandled promise rejections and to allow partial success to render.
- Around line 505-541: The save handler currently always writes
group_priorities["default"] which downstream routing ignores; update handleSave
so when groupName === "default" it instead writes the provider's top-level
priority: detect the default group name (string "default" or the DEFAULT_GROUP
constant), and call editProvider(member.id, { priority: value }) (optionally
removing any "default" key from group_priorities) rather than merging into
group_priorities; keep the existing behavior for non-default groups (use merged
= { ...(existing ?? {}), [groupName]: value }) and leave toast/onSaved logic
unchanged.

---

Nitpick comments:
In `@src/actions/provider-groups.ts`:
- Around line 70-73: The local constant PROVIDER_GROUP_NAME_MAX (used in the
missing filter that references referenced and existingNames) should be
centralized: add and export a single MAX_NAME (or PROVIDER_GROUP_NAME_MAX) from
the shared constants module (e.g., provider.constants or types/provider-group)
and replace the local const in provider-groups.ts with an import of that
exported constant; also update the repository/upsert validation to consume the
same exported constant so both the pre-filter in the missing computation and the
upsert validation use the identical source of truth.

In `@src/app/`[locale]/settings/providers/_components/provider-group-tab.tsx:
- Around line 279-353: The code repeatedly calls filterGroupMembers(providers,
group.name) inside the groups.map loop which re-traverses providers on every
render; change this to compute a single lookup (e.g., Map<string,
ProviderDisplay[]>) for group members outside the map or memoize it with useMemo
dependent on providers (and groups if needed), then replace filterGroupMembers
calls in the loop with a quick map lookup (used by GroupMembersTable's members
prop) to avoid full scans on each render.
🪄 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: f2009015-31ce-4977-a0ed-159b9e5261b3

📥 Commits

Reviewing files that changed from the base of the PR and between 91c9b44 and 9b4d433.

📒 Files selected for processing (8)
  • messages/en/settings/providers/providerGroups.json
  • messages/ja/settings/providers/providerGroups.json
  • messages/ru/settings/providers/providerGroups.json
  • messages/zh-CN/settings/providers/providerGroups.json
  • messages/zh-TW/settings/providers/providerGroups.json
  • src/actions/provider-groups.ts
  • src/actions/providers.ts
  • src/app/[locale]/settings/providers/_components/provider-group-tab.tsx
✅ Files skipped from review due to trivial changes (4)
  • messages/zh-TW/settings/providers/providerGroups.json
  • messages/ja/settings/providers/providerGroups.json
  • messages/zh-CN/settings/providers/providerGroups.json
  • messages/ru/settings/providers/providerGroups.json
🚧 Files skipped from review as they are similar to previous changes (2)
  • messages/en/settings/providers/providerGroups.json
  • src/actions/providers.ts

Comment on lines 81 to 94
const fetchGroups = useCallback(() => {
startLoadTransition(async () => {
const result = await getProviderGroups();
if (result.ok) {
setGroups(result.data);
const [groupsResult, providersResult] = await Promise.all([
getProviderGroups(),
getProviders(),
]);
if (groupsResult.ok) {
setGroups(groupsResult.data);
} else {
toast.error(result.error);
toast.error(groupsResult.error);
}
setProviders(providersResult);
});
}, []);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

fetchGroups 缺失错误边界

Promise.all([getProviderGroups(), getProviders()]) 中若 getProviders() 抛错(网络/鉴权异常),整个 transition 会以未捕获 rejection 结束:既不会走 groupsResult.ok 分支展示分组,也不会提示用户,Tab 会停留在上一状态且没有反馈。即便分组侧成功,也会被整体吞掉。

建议包一层 try/catch,分组与 providers 独立降级:

建议修复
   const fetchGroups = useCallback(() => {
     startLoadTransition(async () => {
-      const [groupsResult, providersResult] = await Promise.all([
-        getProviderGroups(),
-        getProviders(),
-      ]);
-      if (groupsResult.ok) {
-        setGroups(groupsResult.data);
-      } else {
-        toast.error(groupsResult.error);
-      }
-      setProviders(providersResult);
+      const [groupsSettled, providersSettled] = await Promise.allSettled([
+        getProviderGroups(),
+        getProviders(),
+      ]);
+      if (groupsSettled.status === "fulfilled") {
+        if (groupsSettled.value.ok) setGroups(groupsSettled.value.data);
+        else toast.error(groupsSettled.value.error);
+      } else {
+        toast.error(t("loadFailed"));
+      }
+      if (providersSettled.status === "fulfilled") {
+        setProviders(providersSettled.value);
+      }
     });
-  }, []);
+  }, [t]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`[locale]/settings/providers/_components/provider-group-tab.tsx
around lines 81 - 94, fetchGroups currently awaits
Promise.all([getProviderGroups(), getProviders()]) inside startLoadTransition
without a try/catch so any rejection (e.g. getProviders) aborts the transition
and hides group results; wrap the awaited calls in error boundaries (either use
try/catch around separate awaits or use Promise.allSettled) so getProviderGroups
and getProviders degrade independently: call getProviderGroups() and if
groupsResult.ok call setGroups(groupsResult.data) else
toast.error(groupsResult.error); for providers, if the call succeeds call
setProviders(...) otherwise toast.error(...) — keep this logic inside the
existing startLoadTransition and ensure rejections are caught to prevent
unhandled promise rejections and to allow partial success to render.

Comment on lines +505 to +541
const effective = useMemo(() => {
const groupPriorities = (member.groupPriorities ?? null) as Record<string, number> | null;
return groupPriorities?.[groupName] ?? member.priority;
}, [member.groupPriorities, member.priority, groupName]);

const [draft, setDraft] = useState<string>(String(effective));
const [isSaving, startSaveTransition] = useTransition();

useEffect(() => {
setDraft(String(effective));
}, [effective]);

const handleSave = useCallback(() => {
const trimmed = draft.trim();
if (trimmed === "") {
toast.error(t("savePriorityFailed"));
return;
}
const value = Number(trimmed);
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
toast.error(t("savePriorityFailed"));
return;
}

const existing = (member.groupPriorities ?? null) as Record<string, number> | null;
const merged: Record<string, number> = { ...(existing ?? {}), [groupName]: value };

startSaveTransition(async () => {
const result = await editProvider(member.id, { group_priorities: merged });
if (result.ok) {
toast.success(t("savePrioritySuccess"));
onSaved();
} else {
toast.error(result.error ?? t("savePriorityFailed"));
}
});
}, [draft, member.groupPriorities, member.id, groupName, onSaved, t]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 查找 groupPriorities 在运行时路径中的读取点,确认对 DEFAULT/无 tag 的回退规则
rg -nP --type=ts -C4 '\bgroupPriorities\b' -g '!**/*.test.*' -g '!**/__tests__/**'
# 额外查一下 selector/router 处可能按 groupName 查优先级的关键词
rg -nP --type=ts -C3 '(selectProvider|pickProvider|providerSelector|chooseProvider)' -g '!**/*.test.*'

Repository: ding113/claude-code-hub

Length of output: 50046


🏁 Script executed:

rg -nP --type=tsx 'MemberRow' -A5 -B5 -g '!**/*.test.*' | head -100

Repository: ding113/claude-code-hub

Length of output: 94


🏁 Script executed:

# Look for DEFAULT group references and how provider-group-tab uses groups
rg -nP --type=tsx '"DEFAULT"|\'DEFAULT\'' -C3 src/app/[locale]/settings/providers/

Repository: ding113/claude-code-hub

Length of output: 135


🏁 Script executed:

# Check where provider-group-tab is used and what groups are passed
rg -nP --type=tsx 'provider-group-tab|ProviderGroupTab' -C3 -g '!**/*.test.*'

Repository: ding113/claude-code-hub

Length of output: 94


🏁 Script executed:

rg -nP --type=ts 'MemberRow' -A5 -B5 -g '!**/*.test.*' | head -150

Repository: ding113/claude-code-hub

Length of output: 2745


🏁 Script executed:

# Look for where MemberRow is instantiated with groupName parameter
rg -nP --type=ts 'MemberRow.*groupName' -B10 -A2 -g '!**/*.test.*'

Repository: ding113/claude-code-hub

Length of output: 2787


🏁 Script executed:

# Check provider-group-tab for group iteration and how groupName is determined
rg -nP --type=ts 'groupName.*DEFAULT|DEFAULT.*group|state\.routing\.groupTag' src/app/[locale]/settings/providers/ -C3

Repository: ding113/claude-code-hub

Length of output: 5839


🏁 Script executed:

rg -nP --type=ts 'PROVIDER_GROUP\s*[=:]|const.*PROVIDER_GROUP' -A3 -g '!**/*.test.*' | head -50

Repository: ding113/claude-code-hub

Length of output: 3778


🏁 Script executed:

# Find where PROVIDER_GROUP is defined
fd -t f -name '*.ts' | xargs grep -l 'PROVIDER_GROUP.*=' | head -5

Repository: ding113/claude-code-hub

Length of output: 297


🏁 Script executed:

# Check the full resolveEffectivePriority logic in provider-selector
rg -nP --type=ts 'resolveEffectivePriority' -A15 src/app/v1/_lib/proxy/provider-selector.ts

Repository: ding113/claude-code-hub

Length of output: 2784


🏁 Script executed:

# Confirm by checking how effectiveGroupPick is computed when user has no group
rg -nP --type=ts 'effectiveGroupPick\s*=' -B5 -A2 src/app/v1/_lib/proxy/provider-selector.ts | head -60

Repository: ding113/claude-code-hub

Length of output: 782


🏁 Script executed:

# Check if there's special handling for "default" string in selector
rg -nP --type=ts '"default"|parseProviderGroups.*default' src/app/v1/_lib/proxy/provider-selector.ts -C2

Repository: ding113/claude-code-hub

Length of output: 49


DEFAULT 组优先级写入与下游消费不一致——引发路由静默失效

验证已确认:对未打 tag 的 provider(DEFAULT 组),当前逻辑存在下游消费不匹配问题。

问题详情:

  • MemberRow 编辑 DEFAULT 组优先级时,会写入 groupPriorities["default"] = value
  • 但 provider-selector 的 resolveEffectivePriority() 方法(1126-1137 行)只在 if (userGroup && provider.groupPriorities) 成立时才查询 groupPriorities
  • 当用户/密钥未分配 providerGroup 时,effectiveGroupPick = null,条件失败,总是回退到 provider.priority
  • 结果:UI 层显示保存成功(因为 effective 计算为 groupPriorities["default"] ?? priority),但实际路由调度完全忽略 groupPriorities["default"],继续使用原始 provider.priority

建议修复:
对 DEFAULT 组编辑时,改为直接更新 priority 字段,而非 groupPriorities["default"];或在 selector 侧对 DEFAULT 组添加特殊处理,使其不依赖 userGroup 存在的条件。

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

In `@src/app/`[locale]/settings/providers/_components/provider-group-tab.tsx
around lines 505 - 541, The save handler currently always writes
group_priorities["default"] which downstream routing ignores; update handleSave
so when groupName === "default" it instead writes the provider's top-level
priority: detect the default group name (string "default" or the DEFAULT_GROUP
constant), and call editProvider(member.id, { priority: value }) (optionally
removing any "default" key from group_priorities) rather than merging into
group_priorities; keep the existing behavior for non-default groups (use merged
= { ...(existing ?? {}), [groupName]: value }) and leave toast/onSaved logic
unchanged.

Copy link
Copy Markdown
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

  • Identified open PR #1030 and applied label size/M (318 additions, 46 deletions; 9 files; 364 lines changed total).
  • Posted inline review comments on diff lines:
    • src/actions/provider-groups.ts:66[High] [TEST-MISSING-CRITICAL] missing unit coverage for new read-time self-heal branch.
    • src/repository/provider-groups.ts:175[High] [TEST-MISSING-CRITICAL] missing unit coverage for new ensureProviderGroupsExist().
    • src/app/[locale]/settings/providers/_components/provider-group-tab.tsx:482[Medium] [LOGIC-BUG] members table header uses groupName while rendering provider names.
  • Submitted the required PR review summary via gh pr review --comment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:i18n area:provider area:UI bug Something isn't working size/M Medium PR (< 500 lines)

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant