Skip to content

feat(providers): add group-level priority override#641

Closed
NieiR wants to merge 11 commits intoding113:devfrom
NieiR:feature/group-priority-override
Closed

feat(providers): add group-level priority override#641
NieiR wants to merge 11 commits intoding113:devfrom
NieiR:feature/group-priority-override

Conversation

@NieiR
Copy link
Contributor

@NieiR NieiR commented Jan 22, 2026

Summary

Add group-level priority override feature for providers, allowing different priorities for different user groups.

Changes:

  • Add group_priorities JSONB field for per-group priority settings
  • Update provider selection algorithm to calculate effective priority based on user group
  • Add GroupPriorityPopover component for editing priorities on multi-group providers
  • Add GroupTagEditPopover component for inline group tag editing
  • Full i18n support (zh-CN, zh-TW, en, ja, ru)

Use Case

When a provider serves multiple user groups, administrators can configure different priorities per group. For example:

  • cli group: priority 1 (highest)
  • chat group: priority 5 (lower)

Test Plan

  • Build passes (bun run build)
  • TypeScript check passes (bun run typecheck)
  • Verify default priority works when no group override is set
  • Verify group-specific priority is applied correctly
  • Verify UI editing works for both priority and group tags

Screenshots

N/A (backend + existing UI patterns)

Greptile Overview

Greptile Summary

This PR adds group-level priority override functionality, allowing providers to have different priorities for different user groups. The implementation is comprehensive and well-structured:

Core Changes:

  • Added group_priorities JSONB field to database schema and all provider interfaces
  • Implemented getEffectivePriority() function with first-match resolution for comma-separated groups
  • Created GroupPriorityPopover and GroupTagEditPopover UI components for editing
  • Updated provider selection algorithm to use group-specific priorities during routing
  • Full i18n support across 5 languages (en, zh-CN, zh-TW, ja, ru)

Implementation Quality:

  • Database migration properly adds JSONB column with null default
  • Type definitions are consistent across all layers (schema, repository, actions, UI)
  • Validation schemas properly constrain priority values (0-2147483647)
  • UI components include proper validation and user feedback
  • First-match priority resolution is documented and tested

Issues Found:

  • Test file uses incorrect property names (apiKey/enabled instead of key/isEnabled) - this will cause TypeScript compilation errors

Confidence Score: 4/5

  • This PR is mostly safe to merge with one critical test file syntax error that must be fixed
  • The implementation is solid with proper type safety, validation, and comprehensive i18n support. However, the test file contains property name mismatches that will cause TypeScript errors. Once the test file is corrected, the feature is production-ready.
  • tests/unit/proxy/provider-selector-group-priority.test.ts - contains property name mismatches that will break TypeScript compilation

Important Files Changed

Filename Overview
tests/unit/proxy/provider-selector-group-priority.test.ts Test mock uses incorrect property names (apiKey, enabled) instead of interface properties (key, isEnabled)
src/app/v1/_lib/proxy/provider-selector.ts Implements group-level priority override logic with first-match resolution strategy
src/drizzle/schema.ts Adds group_priorities JSONB column with proper type annotation and null default
src/types/provider.ts Adds groupPriorities field to all provider interfaces with proper typing
src/app/[locale]/settings/providers/_components/group-priority-popover.tsx Implements UI component for editing group-level priorities with validation and multi-group support

Sequence Diagram

sequenceDiagram
    participant Client
    participant UI as Provider List UI
    participant Popover as GroupPriorityPopover
    participant Action as editProvider Action
    participant Repo as Provider Repository
    participant DB as Database
    participant Selector as ProviderSelector

    Note over Client,Selector: Setup: Provider with group priorities configured

    Client->>UI: View providers list
    UI->>UI: Calculate displayPriority<br/>based on selectedGroup filter
    UI-->>Client: Show provider with priority<br/>(may show group override)

    Client->>Popover: Click priority to edit
    Popover->>Popover: Load current priority<br/>and groupPriorities
    Popover-->>Client: Show edit form with<br/>default + per-group inputs

    Client->>Popover: Edit priorities<br/>(default: 5, cli: 1, chat: 3)
    Popover->>Popover: Validate inputs<br/>(int, 0-2147483647)
    Popover->>Action: onSave(priority, groupPriorities)
    Action->>Repo: updateProvider(id, data)
    Repo->>DB: UPDATE providers SET<br/>priority=5, group_priorities={cli:1, chat:3}
    DB-->>Repo: Success
    Repo-->>Action: Updated provider
    Action->>Action: publishProviderCacheInvalidation()
    Action-->>Popover: Success
    Popover-->>Client: Close popover, refresh list

    Note over Client,Selector: Runtime: Provider selection with group override

    Client->>Selector: API request with user group "cli"
    Selector->>Selector: getEffectivePriority(provider, "cli")
    Selector->>Selector: Check groupPriorities["cli"]
    Selector->>Selector: Return 1 (override)
    Selector->>Selector: selectTopPriority()<br/>filters to priority=1
    Selector-->>Client: Select provider with<br/>effective priority 1

    Client->>Selector: API request with user group "web"
    Selector->>Selector: getEffectivePriority(provider, "web")
    Selector->>Selector: Check groupPriorities["web"]
    Selector->>Selector: No match, return 5 (default)
    Selector->>Selector: selectTopPriority()<br/>filters to priority=5
    Selector-->>Client: Select provider with<br/>effective priority 5
Loading

- Add group_priorities JSONB field for per-group priority settings
- Update provider selection algorithm to use effective priority based on user group
- Add GroupPriorityPopover component for editing priorities on multi-group providers
- Add GroupTagEditPopover component for inline group tag editing
- Full i18n support (zh-CN, zh-TW, en, ja, ru)
@coderabbitai
Copy link

coderabbitai bot commented Jan 22, 2026

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

📝 Walkthrough

Walkthrough

新增 providers.group_priorities(jsonb,可空,默认 null);移除 providers.provider_vendor_id 的 NOT NULL 约束;后端类型、验证、仓库、选择器与动作支持群组优先级;前端新增群组标签与优先级编辑组件、移动端列表与多语言资源;添加单元测试覆盖组优先级逻辑。

Changes

Cohort / File(s) 变更摘要
数据库:迁移与快照
drizzle/0057_jazzy_blink.sql, drizzle/meta/0057_snapshot.json, drizzle/meta/_journal.json
添加 providers.group_priorities 列(jsonb,可空,默认 null);更新快照与 journal;移除 provider_vendor_id 的 NOT NULL 约束。
类型、验证与 schema
src/types/provider.ts, src/lib/validation/schemas.ts, src/drizzle/schema.ts
在 Provider/ProviderDisplay/Create/Update 类型中新增 groupPriorities / group_priorities 字段;验证规则允许 nullable record;drizzle schema 增加 jsonb 列定义。
数据访问 层
src/repository/_shared/transformers.ts, src/repository/provider.ts
toProvider 与 CRUD 路径映射并持久化 groupPriorities,创建/查询/更新均返回该字段;新增 Create/Update 接口字段。
后端 接口/动作
src/actions/providers.ts
getProviders 输出包含 groupPriorities;editProvider 接受可选 group_priorities 更新输入。
代理选择与优先级逻辑 + 测试
src/app/v1/_lib/proxy/provider-selector.ts, tests/unit/proxy/provider-selector-group-priority.test.ts
新增 getEffectivePriority(provider, userGroup)selectTopPriority 支持 userGroup 并依据组覆盖计算优先级;新增单元测试覆盖匹配与边界情况。
前端 组件:群组优先级与标签编辑
src/app/[locale]/settings/providers/_components/group-priority-popover.tsx, .../group-tag-edit-popover.tsx
新增 GroupPriorityPopover 与 GroupTagEditPopover,用于编辑默认/分组优先级与组标签(含验证、键盘交互、onSave 回调)。
前端 列表/项/管理器 集成
src/app/[locale]/settings/providers/_components/provider-list.tsx, .../provider-manager.tsx, .../provider-rich-list-item.tsx
为列表组件添加 selectedGroupavailableGroups props;ProviderManager 计算并传递组信息;项内集成群组优先级/标签编辑并据选中组计算展示优先级。
国际化 资源
messages/*/settings/index.ts, messages/*/settings/providers/groupEdit.json, .../groupPriority.json (en, ja, ru, zh-CN, zh-TW)
新增群组编辑与群组优先级的多语言字符串,并在各 locale 的 providers 导出中加入 batchEdit, groupEdit, groupPriority
移动端与排行/日志 UI
src/lib/hooks/use-mobile.ts, src/app/[locale]/dashboard/..., src/app/[locale]/dashboard/logs/_components/...
新增 useIsMobile 钩子;为 leaderboard 与 logs 添加移动卡片组件(MobileLeaderboardCard、MobileLogCard、MobileLogsList),并在虚拟表格中按断点切换渲染。
其它 UI 与小改动
src/components/ui/tag-input.tsx, 若干 dashboard/providers UI 文件、测试更新
TagInput 建议项改为切换行为并调整样式;移除若干未用导入;删除 ProviderForm 的 allowedProviderTypes prop;若干测试与样式/导入微调。

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.59% 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 标题准确概括了本次提交的核心功能:为提供者添加组级优先级覆盖。
Description check ✅ Passed 描述详细说明了功能变更、用例、测试计划和设计决策,与代码更改高度相关。

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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 size/XL Extra Large PR (> 1000 lines) enhancement New feature or request area:provider area:UI area:i18n labels Jan 22, 2026
Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

3 files reviewed, 3 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +119 to +128
try {
const newPriority = Number(draft.default);

// Build new groupPriorities
let newGroupPriorities: Record<string, number> | null = null;
for (const group of groups) {
const value = draft.groups[group]?.trim();
if (value && value.length > 0) {
if (!newGroupPriorities) newGroupPriorities = {};
newGroupPriorities[group] = Number(value);
Copy link

Choose a reason for hiding this comment

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

Empty string values could bypass validation. When a user clears an input field (empty string), the validation passes on line 36 (if (value.length === 0) return true), but here on line 124 the check if (value && value.length > 0) filters it out correctly. However, the logic is slightly inconsistent - value.length > 0 already implies truthiness. Consider simplifying to if (value.length > 0) for clarity.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/[locale]/settings/providers/_components/group-priority-popover.tsx
Line: 119:128

Comment:
Empty string values could bypass validation. When a user clears an input field (empty string), the validation passes on line 36 (`if (value.length === 0) return true`), but here on line 124 the check `if (value && value.length > 0)` filters it out correctly. However, the logic is slightly inconsistent - `value.length > 0` already implies truthiness. Consider simplifying to `if (value.length > 0)` for clarity.

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the feedback! The validation logic works correctly:

  • Line 36 (if (value.length === 0) return true) allows empty input during typing
  • Line 124 filters out empty values before submission

I've simplified the condition as suggested. See commit 8abd137.

Comment on lines +74 to +78
// Return first matching group's priority
for (const group of userGroups) {
if (group in provider.groupPriorities) {
return provider.groupPriorities[group];
}
Copy link

Choose a reason for hiding this comment

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

First-match priority resolution may cause unexpected behavior. If a user belongs to groups ["cli", "chat"] and both have overrides, only cli's priority is used. Consider documenting this behavior or implementing a "lowest priority wins" strategy.

For example, with user groups "cli,chat":

  • If cli priority = 1 and chat priority = 5
  • Current: returns 1 (first match)
  • Alternative: Math.min(1, 5) = 1 (highest priority across all groups)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/v1/_lib/proxy/provider-selector.ts
Line: 74:78

Comment:
First-match priority resolution may cause unexpected behavior. If a user belongs to groups `["cli", "chat"]` and both have overrides, only `cli`'s priority is used. Consider documenting this behavior or implementing a "lowest priority wins" strategy.

For example, with user groups `"cli,chat"`:
- If `cli` priority = 1 and `chat` priority = 5
- Current: returns 1 (first match)
- Alternative: `Math.min(1, 5) = 1` (highest priority across all groups)

<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is intentional behavior - first-match priority is documented in the function's JSDoc comments:

// Return first matching group's priority
for (const group of userGroups) {
  if (group in provider.groupPriorities) {
    return provider.groupPriorities[group];
  }
}

The rationale: user groups are ordered by preference (e.g., "cli,chat" means cli is primary). First-match respects this ordering.

Added unit tests to document this behavior. See commit 8abd137.

Comment on lines +414 to +422
// Calculate display priority based on selected group filter
const displayPriority = (() => {
if (!selectedGroup || selectedGroup === "default") {
return provider.priority;
}
if (provider.groupPriorities?.[selectedGroup] !== undefined) {
return provider.groupPriorities[selectedGroup];
}
return provider.priority;
Copy link

Choose a reason for hiding this comment

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

Display priority calculation mirrors backend first-match logic - if selected group filter has no override, falls back to default priority.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx
Line: 414:422

Comment:
Display priority calculation mirrors backend first-match logic - if selected group filter has no override, falls back to default priority.

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for confirming! Yes, the frontend display logic intentionally mirrors the backend first-match behavior for consistency.

Copy link
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 a group-level priority override feature for providers, allowing different priorities for different user groups. The implementation is well-structured with proper i18n support and UI components. However, there is one critical issue that must be addressed.

PR Size: XL

  • Lines changed: 3739 (3711 additions + 28 deletions)
  • Files changed: 30

Split Suggestions for XL PR:
This PR could be split into smaller, more reviewable PRs:

  1. Database schema + types (migration, schema.ts, types, repository layer)
  2. Core business logic (provider-selector.ts with getEffectivePriority)
  3. UI components (group-priority-popover, group-tag-edit-popover)
  4. i18n translations (all 5 language files)

Issues Found

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

Critical Issues (Must Fix)

1. [TEST-MISSING-CRITICAL] No test coverage for group-level priority override feature

Location: src/app/v1/_lib/proxy/provider-selector.ts - getEffectivePriority() function and selectTopPriority() modifications

Why this is a problem:
This PR introduces a new group-level priority override feature that modifies critical provider selection logic. The getEffectivePriority() function and selectTopPriority() changes directly affect which provider is selected for each request, which is a critical code path.

Violated guideline: CLAUDE.md line 12 states: "Test Coverage - All new features must have unit test coverage of at least 80%"

Without tests, there is no verification that:

  1. Group-specific priorities correctly override default priorities
  2. The first matching group is used when a user belongs to multiple groups
  3. Default priority is used when no group override exists
  4. Edge cases are handled (null groups, empty groupPriorities, invalid data)

Suggested fix:
Add unit tests in tests/unit/ or source-adjacent tests. Minimum test cases:

// tests/unit/provider-selector.test.ts or src/app/v1/_lib/proxy/provider-selector.test.ts

describe('getEffectivePriority', () => {
  it('should return default priority when user has no group', () => {
    const provider = { priority: 5, groupPriorities: { cli: 1 } };
    expect(getEffectivePriority(provider, null)).toBe(5);
  });

  it('should return group-specific priority when user group matches', () => {
    const provider = { priority: 5, groupPriorities: { cli: 1, chat: 3 } };
    expect(getEffectivePriority(provider, 'cli')).toBe(1);
  });

  it('should return first matching group priority for comma-separated groups', () => {
    const provider = { priority: 5, groupPriorities: { cli: 1, chat: 3 } };
    expect(getEffectivePriority(provider, 'chat,cli')).toBe(3);
  });

  it('should return default priority when no group override exists', () => {
    const provider = { priority: 5, groupPriorities: { cli: 1 } };
    expect(getEffectivePriority(provider, 'web')).toBe(5);
  });

  it('should return default priority when groupPriorities is null', () => {
    const provider = { priority: 5, groupPriorities: null };
    expect(getEffectivePriority(provider, 'cli')).toBe(5);
  });
});

describe('selectTopPriority with group overrides', () => {
  it('should select providers with lowest effective priority for user group', () => {
    const providers = [
      { id: 1, priority: 5, groupPriorities: { cli: 1 } },
      { id: 2, priority: 3, groupPriorities: { cli: 2 } },
      { id: 3, priority: 1, groupPriorities: null }
    ];
    const result = selectTopPriority(providers, 'cli');
    expect(result).toEqual([providers[0]]); // cli group priority 1 is lowest
  });
});

Review Coverage

  • Logic and correctness - Clean
  • Security (OWASP Top 10) - Clean
  • Error handling - Clean (proper try/finally patterns, errors propagate correctly)
  • Type safety - Clean (proper TypeScript types, validation schemas)
  • Documentation accuracy - Clean (comments match implementation)
  • Test coverage - CRITICAL: Missing tests for new feature
  • Code clarity - Good (well-structured, clear naming)

Positive Observations

  • Proper i18n implementation across all 5 languages
  • Good validation logic in UI components (validatePriority function)
  • Proper error handling with try/finally blocks
  • Clean separation of concerns (UI, business logic, data layer)
  • Well-documented code with clear comments
  • Follows existing code patterns and conventions

Automated review by Claude AI

Copy link

@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

🤖 Fix all issues with AI agents
In `@src/app/`[locale]/settings/providers/_components/group-tag-edit-popover.tsx:
- Around line 141-144: Replace the hard-coded English label
PROVIDER_GROUP.DEFAULT used inside the Badge in group-tag-edit-popover.tsx with
a next-intl translation key (e.g. use intl.formatMessage or the
<FormattedMessage> from next-intl) and add the corresponding translation entries
for that key in the five locale JSON/TS files; update the Badge rendering to
call the intl lookup (keeping the same Badge variant and classes) so the
default-group badge text is localized across all supported languages.
- Around line 90-96: The handleAddGroup function should validate newGroup before
adding: trim newGroup, reject values containing commas and the reserved names
"default" and "*" (case-insensitive), and prevent adding empty or duplicate
entries to selectedGroups; on validation failure surface a user-facing message
(e.g., via an existing error state or toast) and do not call setSelectedGroups
or clear the input. Update handleAddGroup to perform these checks (referencing
newGroup, selectedGroups, setSelectedGroups, setNewGroup) and return early on
invalid input.

In `@src/lib/validation/schemas.ts`:
- Around line 556-560: CreateProviderSchema is missing the group_priorities
field that UpdateProviderSchema already allows; add a group_priorities property
to CreateProviderSchema using the same shape as in UpdateProviderSchema but
constrain the record keys to match group_tag (max 50 chars). Specifically, in
CreateProviderSchema add group_priorities: z.record(z.string().max(50),
z.number().int().min(0).max(2147483647)).nullable().optional(), matching the
UpdateProviderSchema definition and keeping the same null/optional semantics.
🧹 Nitpick comments (2)
drizzle/0057_jazzy_blink.sql (1)

2-2: DEFAULT 'null'::jsonb 可能导致查询混淆

当前默认值设置为 JSON null 字面量,而非 SQL NULL。这会导致:

  • group_priorities IS NULL 查询不会匹配这些行
  • 需要用 group_priorities = 'null'::jsonbgroup_priorities::text = 'null' 来检测

建议改为 DEFAULT NULL 或直接不设置默认值,让列自然为 SQL NULL。

建议修改
-ALTER TABLE "providers" ADD COLUMN "group_priorities" jsonb DEFAULT 'null'::jsonb;
+ALTER TABLE "providers" ADD COLUMN "group_priorities" jsonb DEFAULT NULL;
src/types/provider.ts (1)

55-56: 建议明确 groupPriorities 的 null/undefined 语义。
可在注释中约定:null 表示显式清空/无覆盖,undefined(在 Create/Update 中)表示不修改或继承默认,以保持与其它额度字段的语义一致。

Based on learnings, 建议明确 null 与 undefined 的语义边界。

Also applies to: 160-161, 252-253, 323-324

- Fix lint errors: remove unused imports (Power, cn) and params (allowedProviderTypes)
- Add i18n for default group badge text in group-tag-edit-popover
- Add group name validation: reject commas and reserved names (default, *)
- Add unit tests for getEffectivePriority() function (12 test cases)
- Export getEffectivePriority for testing
@NieiR
Copy link
Contributor Author

NieiR commented Jan 22, 2026

Addressed Code Review Feedback

All critical and high-priority issues from the code review bots have been addressed in commit 8abd137:

Fixed Issues

Issue Status
[Critical] Missing unit tests for getEffectivePriority() Added 12 test cases covering all scenarios
[CodeRabbit] Group name validation Added validation for commas and reserved names (default, *)
[CodeRabbit] i18n for default group badge Added t("defaultGroup") with translations for all 5 languages
[Lint] Unused imports/params Removed Power, cn, and allowedProviderTypes

Intentional Design Decisions

Feedback Response
[Greptile] Multi-group first-match behavior This is intentional - user groups are ordered by preference, first-match respects this ordering
[CodeRabbit] CreateProviderSchema missing group_priorities Optional improvement - new providers use default priority, can configure via edit

Test Results

  • All 12 new unit tests passing
  • Lint check passing

Copy link

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

🤖 Fix all issues with AI agents
In `@src/app/`[locale]/settings/providers/_components/group-tag-edit-popover.tsx:
- Around line 130-132: allDisplayGroups currently only filters out "default"
literally, which allows reserved names like "*" or case variants (e.g.
"DEFAULT", "*") from availableGroups/selectedGroups to be shown and selected,
bypassing handleAddGroup validation; update the creation of allDisplayGroups to
normalize group names to a consistent case and filter against a constant set of
reserved names (e.g., RESERVED_GROUPS = new Set(["default","*"]) ) using
case-insensitive comparison (toLowerCase) so any variant is excluded before
rendering/selection and cannot bypass handleAddGroup.
- Around line 221-230: The icon-only Add Group Button (Button with
onClick={handleAddGroup}, disabled={saving || !newGroup.trim()}) lacks an
accessible name; add an aria-label that uses the i18n translation key (e.g.
"settings.group.add") instead of hardcoding text, wire it to the app's
translation function (e.g. t('settings.group.add')) and ensure the new key is
added to the translations for all five locales; keep the visible UI unchanged
(icon only) and pass the aria-label prop to the Button component.

In `@src/app/v1/_lib/proxy/provider-selector.ts`:
- Around line 65-79: Replace the prototype-sensitive "in" check inside
getEffectivePriority with an own-property check (e.g.,
Object.hasOwn(provider.groupPriorities, group) or
provider.groupPriorities.hasOwnProperty(group)) so only direct keys are matched,
and ensure getEffectivePriority falls back to returning provider.priority if no
group match is found; update the loop that iterates userGroups and the
function's final return accordingly.

In `@tests/unit/proxy/provider-selector-group-priority.test.ts`:
- Around line 5-31: The test helper createProvider returns an object that no
longer matches the Provider interface (uses old fields like apiKey/enabled and
omits required fields such as key, isEnabled, and other mandatory properties);
update createProvider (or reuse the existing Provider test factory) to construct
and return a fully compliant Provider object: rename/remove deprecated fields
(apiKey → key, enabled → isEnabled), add all required properties from the
current Provider type (including key, isEnabled, providerVendorId, limits,
timestamps, and any other non-optional fields), and ensure groupPriorities and
other provided params are applied; verify the returned shape satisfies the
Provider interface used by the tests.

Comment on lines +130 to +132
const allDisplayGroups = Array.from(new Set([...availableGroups, ...selectedGroups])).filter(
(g) => g !== "default"
);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

避免保留分组通过列表选择绕过校验

allDisplayGroups 只排除了 "default",若 availableGroups"*" 或大小写变体,会被渲染并可勾选保存,绕过 handleAddGroup 的保留名校验。建议用常量并做大小写无关过滤。

Proposed fix
-  const allDisplayGroups = Array.from(new Set([...availableGroups, ...selectedGroups])).filter(
-    (g) => g !== "default"
-  );
+  const allDisplayGroups = Array.from(new Set([...availableGroups, ...selectedGroups])).filter(
+    (g) => {
+      const normalized = g.trim().toLowerCase();
+      return (
+        normalized !== PROVIDER_GROUP.DEFAULT &&
+        normalized !== PROVIDER_GROUP.ALL
+      );
+    }
+  );
📝 Committable suggestion

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

Suggested change
const allDisplayGroups = Array.from(new Set([...availableGroups, ...selectedGroups])).filter(
(g) => g !== "default"
);
const allDisplayGroups = Array.from(new Set([...availableGroups, ...selectedGroups])).filter(
(g) => {
const normalized = g.trim().toLowerCase();
return (
normalized !== PROVIDER_GROUP.DEFAULT &&
normalized !== PROVIDER_GROUP.ALL
);
}
);
🤖 Prompt for AI Agents
In `@src/app/`[locale]/settings/providers/_components/group-tag-edit-popover.tsx
around lines 130 - 132, allDisplayGroups currently only filters out "default"
literally, which allows reserved names like "*" or case variants (e.g.
"DEFAULT", "*") from availableGroups/selectedGroups to be shown and selected,
bypassing handleAddGroup validation; update the creation of allDisplayGroups to
normalize group names to a consistent case and filter against a constant set of
reserved names (e.g., RESERVED_GROUPS = new Set(["default","*"]) ) using
case-insensitive comparison (toLowerCase) so any variant is excluded before
rendering/selection and cannot bypass handleAddGroup.

Comment on lines +221 to +230
<Button
type="button"
size="sm"
variant="outline"
onClick={handleAddGroup}
disabled={saving || !newGroup.trim()}
className="h-8 px-2"
>
<Plus className="h-4 w-4" />
</Button>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

为仅图标按钮补充可访问名称并走 i18n

当前按钮只有图标,屏幕阅读器无法获得名称。建议添加 aria-label 并新增对应翻译键,补齐 5 语种。

Proposed fix
             <Button
               type="button"
               size="sm"
               variant="outline"
               onClick={handleAddGroup}
               disabled={saving || !newGroup.trim()}
               className="h-8 px-2"
+              aria-label={t("addGroup")}
             >
               <Plus className="h-4 w-4" />
             </Button>
As per coding guidelines, 请确保新增文案走 i18n 并补齐 5 语种。
🤖 Prompt for AI Agents
In `@src/app/`[locale]/settings/providers/_components/group-tag-edit-popover.tsx
around lines 221 - 230, The icon-only Add Group Button (Button with
onClick={handleAddGroup}, disabled={saving || !newGroup.trim()}) lacks an
accessible name; add an aria-label that uses the i18n translation key (e.g.
"settings.group.add") instead of hardcoding text, wire it to the app's
translation function (e.g. t('settings.group.add')) and ensure the new key is
added to the translations for all five locales; keep the visible UI unchanged
(icon only) and pass the aria-label prop to the Button component.

Comment on lines +65 to +79
function getEffectivePriority(provider: Provider, userGroup: string | null): number {
// No group or no overrides: use global priority
if (!userGroup || !provider.groupPriorities) {
return provider.priority;
}

// Parse user groups (may be comma-separated)
const userGroups = parseGroupString(userGroup);

// Return first matching group's priority
for (const group of userGroups) {
if (group in provider.groupPriorities) {
return provider.groupPriorities[group];
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

避免使用 in 触发原型链命中

in 会命中原型链键(如 toString),在少数分组名场景下可能返回 undefined,进而影响后续 Math.min 计算。建议用 Object.hasOwn(或 hasOwnProperty)确保只匹配自有属性。

可选修正
-  for (const group of userGroups) {
-    if (group in provider.groupPriorities) {
-      return provider.groupPriorities[group];
-    }
-  }
+  for (const group of userGroups) {
+    if (Object.hasOwn(provider.groupPriorities, group)) {
+      return provider.groupPriorities[group];
+    }
+  }
📝 Committable suggestion

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

Suggested change
function getEffectivePriority(provider: Provider, userGroup: string | null): number {
// No group or no overrides: use global priority
if (!userGroup || !provider.groupPriorities) {
return provider.priority;
}
// Parse user groups (may be comma-separated)
const userGroups = parseGroupString(userGroup);
// Return first matching group's priority
for (const group of userGroups) {
if (group in provider.groupPriorities) {
return provider.groupPriorities[group];
}
}
function getEffectivePriority(provider: Provider, userGroup: string | null): number {
// No group or no overrides: use global priority
if (!userGroup || !provider.groupPriorities) {
return provider.priority;
}
// Parse user groups (may be comma-separated)
const userGroups = parseGroupString(userGroup);
// Return first matching group's priority
for (const group of userGroups) {
if (Object.hasOwn(provider.groupPriorities, group)) {
return provider.groupPriorities[group];
}
}
🤖 Prompt for AI Agents
In `@src/app/v1/_lib/proxy/provider-selector.ts` around lines 65 - 79, Replace the
prototype-sensitive "in" check inside getEffectivePriority with an own-property
check (e.g., Object.hasOwn(provider.groupPriorities, group) or
provider.groupPriorities.hasOwnProperty(group)) so only direct keys are matched,
and ensure getEffectivePriority falls back to returning provider.priority if no
group match is found; update the loop that iterates userGroups and the
function's final return accordingly.

Comment on lines +5 to +31
// Helper to create a minimal Provider for testing
function createProvider(
priority: number,
groupPriorities: Record<string, number> | null = null
): Provider {
return {
id: 1,
name: "test-provider",
providerType: "claude",
url: "https://api.anthropic.com",
apiKey: "test-key",
enabled: true,
priority,
weight: 1,
costMultiplier: 1,
groupTag: null,
allowedModels: null,
modelRedirects: null,
joinClaudePool: false,
groupPriorities,
providerVendorId: null,
limitTotalUsd: null,
usedTotalUsd: 0,
createdAt: new Date(),
updatedAt: new Date(),
};
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

createProvider 未符合 Provider 接口字段,可能导致类型检查失败

当前对象使用了 apiKey/enabled 等旧字段名,并缺少 keyisEnabled 等必填字段(以及多项必需属性)。如果未来把 tests 目录纳入 TS 检查,这里会直接报错,也会降低测试数据的代表性。建议对齐 Provider 接口(或复用现有的 Provider 测试工厂/构造器)。

🤖 Prompt for AI Agents
In `@tests/unit/proxy/provider-selector-group-priority.test.ts` around lines 5 -
31, The test helper createProvider returns an object that no longer matches the
Provider interface (uses old fields like apiKey/enabled and omits required
fields such as key, isEnabled, and other mandatory properties); update
createProvider (or reuse the existing Provider test factory) to construct and
return a fully compliant Provider object: rename/remove deprecated fields
(apiKey → key, enabled → isEnabled), add all required properties from the
current Provider type (including key, isEnabled, providerVendorId, limits,
timestamps, and any other non-optional fields), and ensure groupPriorities and
other provided params are applied; verify the returned shape satisfies the
Provider interface used by the tests.

Transform the group selection dropdown from vertical list to horizontal
flow layout (tag cloud style) for better space utilization and easier
selection when there are many groups.

Changes:
- TagInput: suggestions panel uses flex-wrap horizontal layout
- TagInput: click toggles selection instead of add-only
- GroupTagEditPopover: checkbox labels use horizontal flow layout
Copy link

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

🤖 Fix all issues with AI agents
In `@src/components/ui/tag-input.tsx`:
- Around line 308-322: handleSuggestionClick currently calls onChangeCommit when
removing a tag but not when adding one (it calls addTag(suggestionValue, true)
which updates state via onChange only), causing inconsistent commit behavior;
fix by invoking onChangeCommit for the add-path as well: either have
addTag(suggestionValue, true) return or accept a callback/flag that triggers
onChangeCommit internally, or after calling addTag compute the new tags array
and call onChangeCommit(newTags); update handleSuggestionClick to call
onChangeCommit with the same signature used in the removal branch so both
branches commit consistently (references: handleSuggestionClick, addTag,
onChange, onChangeCommit).
♻️ Duplicate comments (2)
src/app/[locale]/settings/providers/_components/group-tag-edit-popover.tsx (2)

130-132: 改进分组列表过滤以避免保留名绕过

Line 130-132 仅过滤了字面量 "default""*" 或大小写/空白变体仍可能被渲染并选中,绕过保留名校验。建议规范化并过滤 PROVIDER_GROUP.DEFAULT / PROVIDER_GROUP.ALL

建议修改
-  const allDisplayGroups = Array.from(new Set([...availableGroups, ...selectedGroups])).filter(
-    (g) => g !== "default"
-  );
+  const allDisplayGroups = Array.from(new Set([...availableGroups, ...selectedGroups])).filter(
+    (g) => {
+      const normalized = g.trim().toLowerCase();
+      return normalized !== PROVIDER_GROUP.DEFAULT && normalized !== PROVIDER_GROUP.ALL;
+    }
+  );

226-235: 为仅图标按钮补充可访问名称

Line 226-235 的按钮仅图标显示,缺少可访问名称。建议添加 aria-label 并通过 i18n 文案提供,同时补齐 5 语种翻译。

建议修改
             <Button
               type="button"
               size="sm"
               variant="outline"
               onClick={handleAddGroup}
               disabled={saving || !newGroup.trim()}
               className="h-8 px-2"
+              aria-label={t("addGroup")}
             >
               <Plus className="h-4 w-4" />
             </Button>
As per coding guidelines, 请确保新增文案走 i18n 并补齐 5 语种。
🧹 Nitpick comments (1)
src/components/ui/tag-input.tsx (1)

103-112: 注释已过时,需更新。

第 103 行的注释 // 过滤建议列表:匹配输入值且未被选中 与当前实现不符。代码已改为仅按搜索词过滤,不再排除已选中项(选中状态通过视觉样式展示)。

建议更新注释以准确描述当前行为:

建议修改
-  // 过滤建议列表:匹配输入值且未被选中
+  // 过滤建议列表:按输入值匹配(已选中项通过样式区分)
   const filteredSuggestions = React.useMemo(() => {

Comment on lines 308 to 322
const handleSuggestionClick = React.useCallback(
(suggestionValue: string) => {
addTag(suggestionValue, true); // keepOpen=true 保持下拉展开
if (value.includes(suggestionValue)) {
// Already selected -> deselect (remove tag)
const nextTags = value.filter((v) => v !== suggestionValue);
onChange(nextTags);
onChangeCommit?.(nextTags);
} else {
// Not selected -> select (add tag)
addTag(suggestionValue, true);
}
inputRef.current?.focus();
},
[addTag]
[value, onChange, onChangeCommit, addTag]
);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

onChangeCommit 调用行为不一致。

切换逻辑中,取消选择时调用了 onChangeCommit(第 314 行),但选择时通过 addTag 仅调用 onChange,未触发 onChangeCommit。这可能导致依赖 onChangeCommit 进行持久化的调用方出现不一致行为。

建议在选择路径中也触发 onChangeCommit,或在 addTag 中增加可选的 commit 回调:

建议修改
     if (value.includes(suggestionValue)) {
       // Already selected -> deselect (remove tag)
       const nextTags = value.filter((v) => v !== suggestionValue);
       onChange(nextTags);
       onChangeCommit?.(nextTags);
     } else {
       // Not selected -> select (add tag)
-      addTag(suggestionValue, true);
+      const nextTags = [...value, suggestionValue];
+      onChange(nextTags);
+      onChangeCommit?.(nextTags);
     }
🤖 Prompt for AI Agents
In `@src/components/ui/tag-input.tsx` around lines 308 - 322,
handleSuggestionClick currently calls onChangeCommit when removing a tag but not
when adding one (it calls addTag(suggestionValue, true) which updates state via
onChange only), causing inconsistent commit behavior; fix by invoking
onChangeCommit for the add-path as well: either have addTag(suggestionValue,
true) return or accept a callback/flag that triggers onChangeCommit internally,
or after calling addTag compute the new tags array and call
onChangeCommit(newTags); update handleSuggestionClick to call onChangeCommit
with the same signature used in the removal branch so both branches commit
consistently (references: handleSuggestionClick, addTag, onChange,
onChangeCommit).

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

2 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

providerType: "claude",
url: "https://api.anthropic.com",
apiKey: "test-key",
enabled: true,
Copy link

Choose a reason for hiding this comment

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

enabled field doesn't exist in the Provider type. Should be isEnabled instead.

Suggested change
enabled: true,
isEnabled: true,
Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/unit/proxy/provider-selector-group-priority.test.ts
Line: 16:16

Comment:
`enabled` field doesn't exist in the Provider type. Should be `isEnabled` instead.

```suggestion
    isEnabled: true,
```

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

name: "test-provider",
providerType: "claude",
url: "https://api.anthropic.com",
apiKey: "test-key",
Copy link

Choose a reason for hiding this comment

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

Property name mismatch with Provider interface - should be key not apiKey

Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/unit/proxy/provider-selector-group-priority.test.ts
Line: 15:15

Comment:
Property name mismatch with Provider interface - should be `key` not `apiKey`

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

… key

Address CodeRabbit feedback: constrain group_priorities keys to max 50 chars,
consistent with group_tag field validation.
Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

1 file reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Comment on lines +14 to +16
url: "https://api.anthropic.com",
apiKey: "test-key",
enabled: true,
Copy link

Choose a reason for hiding this comment

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

Property names don't match the Provider interface

The test uses apiKey and enabled, but the Provider interface expects key and isEnabled.

Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/unit/proxy/provider-selector-group-priority.test.ts
Line: 14:16

Comment:
Property names don't match the Provider interface

The test uses `apiKey` and `enabled`, but the Provider interface expects `key` and `isEnabled`.

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

NieiR added 4 commits January 24, 2026 08:38
Design document for optimizing usage logs display on mobile devices
by switching from table to card-based layout.
Design plan for improving leaderboard usability on mobile:
- Simplify tab labels (e.g., "缓存率" instead of "供应商缓存命中率排行")
- Replace table with expandable card layout
- Stack filter inputs vertically
…ty-override

# Conflicts:
#	src/components/ui/tag-input.tsx
…ard, and logs pages

- Add useIsMobile hook for viewport detection
- Provider management: mobile filter sheet, group quick-filter bar, card layout with dropdown actions
- Leaderboard: mobile card view with expandable details, shortened tab labels
- Usage logs: mobile card list replacing virtualized table
- Full i18n support (zh-CN, zh-TW, en, ja, ru)
Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

2 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

providerType: "claude",
url: "https://api.anthropic.com",
apiKey: "test-key",
enabled: true,
Copy link

Choose a reason for hiding this comment

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

enabled doesn't exist in the Provider interface - should be isEnabled

Suggested change
enabled: true,
isEnabled: true,
Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/unit/proxy/provider-selector-group-priority.test.ts
Line: 16:16

Comment:
`enabled` doesn't exist in the Provider interface - should be `isEnabled`

```suggestion
    isEnabled: true,
```

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

Comment on lines +26 to +27
limitTotalUsd: null,
usedTotalUsd: 0,
Copy link

Choose a reason for hiding this comment

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

These fields don't exist in the Provider type - remove them

Suggested change
limitTotalUsd: null,
usedTotalUsd: 0,
limitTotalUsd: null,
Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/unit/proxy/provider-selector-group-priority.test.ts
Line: 26:27

Comment:
These fields don't exist in the Provider type - remove them

```suggestion
    limitTotalUsd: null,
```

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

- Remove unused imports and variables
- Fix useEffect dependency array
- Prefix unused parameter with underscore
Copy link

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

Caution

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

⚠️ Outside diff range comments (2)
src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx (1)

84-211: 移动端会触发双份日志请求与渲染

Line 84–211:VirtualizedLogsTableisMobile 分支返回 MobileLogsList 前已执行 useInfiniteQuery 和虚拟化逻辑,移动端会同时跑两套查询/轮询与滚动监听,导致重复请求与资源浪费。建议将移动端分流提升到组件最外层,桌面逻辑拆成独立组件,避免双请求。

建议的重构方向(将桌面实现下沉)
-export function VirtualizedLogsTable({
-  filters,
-  currencyCode = "USD",
-  billingModelSource = "original",
-  autoRefreshEnabled = true,
-  autoRefreshIntervalMs = 5000,
-  hideStatusBar = false,
-  hideScrollToTop = false,
-  hiddenColumns,
-  bodyClassName,
-}: VirtualizedLogsTableProps) {
-  const t = useTranslations("dashboard");
-  const tChain = useTranslations("provider-chain");
-  const parentRef = useRef<HTMLDivElement>(null);
-  const [showScrollToTop, setShowScrollToTop] = useState(false);
-  const isMobile = useIsMobile();
-  ...
-  if (isMobile) {
-    return (
-      <MobileLogsList
-        filters={filters}
-        currencyCode={currencyCode}
-        billingModelSource={billingModelSource}
-        autoRefreshEnabled={autoRefreshEnabled}
-        autoRefreshIntervalMs={autoRefreshIntervalMs}
-        hideStatusBar={hideStatusBar}
-        hideScrollToTop={hideScrollToTop}
-        bodyClassName={bodyClassName}
-      />
-    );
-  }
-  return (...桌面实现...);
-}
+export function VirtualizedLogsTable(props: VirtualizedLogsTableProps) {
+  const isMobile = useIsMobile();
+  if (isMobile) {
+    const {
+      filters,
+      currencyCode = "USD",
+      billingModelSource = "original",
+      autoRefreshEnabled = true,
+      autoRefreshIntervalMs = 5000,
+      hideStatusBar = false,
+      hideScrollToTop = false,
+      bodyClassName,
+    } = props;
+    return (
+      <MobileLogsList
+        filters={filters}
+        currencyCode={currencyCode}
+        billingModelSource={billingModelSource}
+        autoRefreshEnabled={autoRefreshEnabled}
+        autoRefreshIntervalMs={autoRefreshIntervalMs}
+        hideStatusBar={hideStatusBar}
+        hideScrollToTop={hideScrollToTop}
+        bodyClassName={bodyClassName}
+      />
+    );
+  }
+  return <VirtualizedLogsTableDesktop {...props} />;
+}
+
+function VirtualizedLogsTableDesktop({
+  filters,
+  currencyCode = "USD",
+  billingModelSource = "original",
+  autoRefreshEnabled = true,
+  autoRefreshIntervalMs = 5000,
+  hideStatusBar = false,
+  hideScrollToTop = false,
+  hiddenColumns,
+  bodyClassName,
+}: VirtualizedLogsTableProps) {
+  const t = useTranslations("dashboard");
+  const tChain = useTranslations("provider-chain");
+  const parentRef = useRef<HTMLDivElement>(null);
+  const [showScrollToTop, setShowScrollToTop] = useState(false);
+  // 现有桌面实现移动到这里
+}
src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx (1)

55-156: 切换榜单范围/周期后移动端展开索引可能错位

Line 66–156:expandedIndex 在 scope/period/dateRange/筛选变化后不重置,移动端可能默认展开与新数据不匹配的条目。建议在关键依赖变化时清空展开状态。

建议补充重置逻辑
+  useEffect(() => {
+    if (!isMobile) return;
+    setExpandedIndex(null);
+  }, [isMobile, scope, period, dateRange, providerTypeFilter, userTagFilters, userGroupFilters]);
🤖 Fix all issues with AI agents
In `@docs/plans/2026-01-24-mobile-usage-logs-design.md`:
- Around line 21-39: The fenced code blocks in the design doc are missing a
language identifier; update each markdown fence (``` ... ```) shown in the diff
to include the "text" language tag (i.e. change ``` to ```text) for the block
that starts with the ASCII layout diagram and the other two occurrences
referenced (the blocks around the other ASCII tables), so all three fenced code
blocks are ```text to satisfy markdownlint.

In
`@src/app/`[locale]/dashboard/leaderboard/_components/mobile-leaderboard-card.tsx:
- Around line 59-193: The UI hardcodes "$", "ms", and "tok/s" in
getPrimaryMetric and renderExpandedContent; update these to use the existing
formatted fields (e.g., entry.totalCostFormatted) or a currency-aware formatter
(Intl.NumberFormat with a provided currency/locale prop) instead of "$", and
replace "ms" and "tok/s" with i18n strings via t(...) or use
Intl.NumberFormat/unit formatting for milliseconds and tokens-per-second; touch
functions getPrimaryMetric and renderExpandedContent, ensure the component
accepts a currency and locale prop (or uses context) and use those when
formatting numbers and units so all cost and unit displays respect localization
and currency settings.

In `@src/app/`[locale]/dashboard/logs/_components/mobile-log-card.tsx:
- Around line 186-201: The hardcoded labels "In:", "Out:", "W:", and "R:" in
mobile-log-card.tsx should be replaced with i18n calls like the existing t(...)
usage; update the JSX where those strings appear (inside the divs rendering
token and cache counts) to use t(...) keys (e.g., t("logs.tokens.in"),
t("logs.tokens.out"), t("logs.cache.write"), t("logs.cache.read")) while keeping
formatTokenAmount(log.*) unchanged, and add those keys to your locale resource
files so translations are available.
- Around line 78-79: Replace the hard-coded "OK" prefix in the status string
(the return that currently does `OK ${statusCode}` in
src/app/[locale]/dashboard/logs/_components/mobile-log-card.tsx) with an i18n
lookup (use the project's translate function, e.g. t('dashboard/logs.statusOk',
{ code: statusCode }) or equivalent) and add the corresponding translation key
("dashboard/logs.statusOk") to the dashboard/logs.json files for all five
locales with the appropriate localized templates (e.g. "OK {code}" or localized
equivalent).
♻️ Duplicate comments (1)
src/components/ui/tag-input.tsx (1)

373-386: 新增分支仍未触发 onChangeCommit,提交语义不一致。

Line 381-383 仅调用 addTag,未同步触发 onChangeCommit,与移除分支不一致,可能导致依赖持久化回调的调用方漏提交。建议在 addTag 成功后统一触发 onChangeCommit(例如让 addTag 返回 nextTags 或接收回调)。

🧹 Nitpick comments (4)
src/app/v1/_lib/proxy/provider-selector.ts (1)

74-79: 使用 Object.hasOwn 替代 in 运算符以避免原型链命中

in 运算符会检查原型链上的属性(如 toStringconstructor 等)。虽然 Zod 验证会拒绝 default*,但无法阻止所有原型链键名。建议使用 Object.hasOwn 确保只匹配自有属性。

建议修复
   // Return first matching group's priority
   for (const group of userGroups) {
-    if (group in provider.groupPriorities) {
+    if (Object.hasOwn(provider.groupPriorities, group)) {
       return provider.groupPriorities[group];
     }
   }
src/app/[locale]/settings/providers/_components/provider-manager.tsx (1)

376-512: 移动端筛选 Sheet 中分组筛选逻辑重复

分组筛选按钮的渲染逻辑在快速访问区(lines 357-372)和 Sheet 内部(lines 456-472)基本相同。考虑提取为共享组件以减少重复。

可选:提取分组筛选按钮组件
// 可以创建一个 GroupFilterButtons 组件
function GroupFilterButtons({
  groups,
  selectedGroups,
  onToggle,
  onClear,
  disabled,
  allLabel,
}: {
  groups: string[];
  selectedGroups: string[];
  onToggle: (group: string) => void;
  onClear: () => void;
  disabled?: boolean;
  allLabel: string;
}) {
  return (
    <>
      <Button
        variant={selectedGroups.length === 0 ? "default" : "outline"}
        size="sm"
        onClick={onClear}
        disabled={disabled}
      >
        {allLabel}
      </Button>
      {groups.map((group) => (
        <Button
          key={group}
          variant={selectedGroups.includes(group) ? "default" : "outline"}
          size="sm"
          onClick={() => onToggle(group)}
          disabled={disabled}
        >
          {group}
        </Button>
      ))}
    </>
  );
}
src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx (1)

709-745: 条件渲染逻辑可简化

当前的条件渲染在 canEdit 为 false 时有些冗余:先检查 groupTags.length > 0,然后在渲染时又重新解析 provider.groupTag。可以直接复用已计算的 groupTags 变量。

建议简化
             ) : (provider.groupTag
-                ? provider.groupTag
-                    .split(",")
-                    .map((t) => t.trim())
-                    .filter(Boolean)
-                : []
-              ).length > 0 ? (
-              provider.groupTag
-                ?.split(",")
-                .map((t) => t.trim())
-                .filter(Boolean)
-                .map((tag, index) => {
+              ) : groupTags.length > 0 ? (
+              groupTags.map((tag, index) => {
                   const bgColor = getGroupColor(tag);
                   return (
                     <Badge
src/app/[locale]/dashboard/logs/_components/mobile-log-card.tsx (1)

220-224: 性能指标标签应使用 i18n

"TTFB" 和 "tok/s" 是硬编码的技术缩写,建议使用 i18n 以保持一致性并支持本地化。

建议的修复
       <div className="flex items-center justify-between text-xs text-muted-foreground font-mono">
         <span>{formatDuration(log.durationMs)}</span>
-        {log.ttfbMs != null && log.ttfbMs > 0 && <span>TTFB {formatDuration(log.ttfbMs)}</span>}
-        {rate !== null && <span>{rate.toFixed(0)} tok/s</span>}
+        {log.ttfbMs != null && log.ttfbMs > 0 && <span>{t("logs.mobile.ttfb")} {formatDuration(log.ttfbMs)}</span>}
+        {rate !== null && <span>{t("logs.mobile.tokensPerSecond", { rate: rate.toFixed(0) })}</span>}
       </div>

Comment on lines +21 to +39
```
+-------------------------------------+
| [Header] Time + Status Badge |
| Left: Relative time (3s ago) |
| Right: Status badge (OK 200) |
+-------------------------------------+
| [Identity] User + Provider + Model |
| Username - Provider name |
| Model name (with redirect arrow) |
+-------------------------------------+
| [Data] Tokens + Cache + Cost |
| Col1: Input/Output tokens |
| Col2: Cache write/read |
| Col3: Cost amount |
+-------------------------------------+
| [Performance] Duration + TTFB + Rate|
| Total time - TTFB - Output rate |
+-------------------------------------+
```
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

为代码块补充语言标识以通过 markdownlint。
目前的围栏代码块缺少语言标识,建议统一标注为 text

修改建议
-```
+```text
+-------------------------------------+
...
-```
+```text
+-----------+-----------+---------+
...
-```
+```text
+src/app/[locale]/dashboard/logs/_components/
基于静态分析提示。

Also applies to: 66-72, 80-85

🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

21-21: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
In `@docs/plans/2026-01-24-mobile-usage-logs-design.md` around lines 21 - 39, The
fenced code blocks in the design doc are missing a language identifier; update
each markdown fence (``` ... ```) shown in the diff to include the "text"
language tag (i.e. change ``` to ```text) for the block that starts with the
ASCII layout diagram and the other two occurrences referenced (the blocks around
the other ASCII tables), so all three fenced code blocks are ```text to satisfy
markdownlint.

Comment on lines +59 to +193
const getPrimaryMetric = (): string => {
switch (scope) {
case "user": {
const entry = data as UserEntry;
return entry.totalCostFormatted ?? `$${Number(entry.totalCost).toFixed(2)}`;
}
case "provider": {
const entry = data as ProviderEntry;
return entry.totalCostFormatted ?? `$${Number(entry.totalCost).toFixed(2)}`;
}
case "providerCacheHitRate": {
const entry = data as ProviderCacheHitRateEntry;
return `${(Number(entry.cacheHitRate || 0) * 100).toFixed(1)}%`;
}
case "model": {
const entry = data as ModelEntry;
return entry.totalCostFormatted ?? `$${Number(entry.totalCost).toFixed(2)}`;
}
}
};

const renderExpandedContent = () => {
switch (scope) {
case "user": {
const entry = data as UserEntry;
return (
<div className="grid grid-cols-3 gap-2 text-xs">
<div>
<div className="text-muted-foreground">{t("columns.requests")}</div>
<div className="font-mono">{entry.totalRequests.toLocaleString()}</div>
</div>
<div>
<div className="text-muted-foreground">{t("columns.tokens")}</div>
<div className="font-mono">{formatTokenAmount(entry.totalTokens)}</div>
</div>
<div>
<div className="text-muted-foreground">{t("columns.cost")}</div>
<div className="font-mono">
{entry.totalCostFormatted ?? `$${Number(entry.totalCost).toFixed(2)}`}
</div>
</div>
</div>
);
}
case "provider": {
const entry = data as ProviderEntry;
return (
<div className="grid grid-cols-3 gap-2 text-xs">
<div>
<div className="text-muted-foreground">{t("columns.requests")}</div>
<div className="font-mono">{entry.totalRequests.toLocaleString()}</div>
</div>
<div>
<div className="text-muted-foreground">{t("columns.cost")}</div>
<div className="font-mono">
{entry.totalCostFormatted ?? `$${Number(entry.totalCost).toFixed(2)}`}
</div>
</div>
<div>
<div className="text-muted-foreground">{t("columns.tokens")}</div>
<div className="font-mono">{formatTokenAmount(entry.totalTokens)}</div>
</div>
<div>
<div className="text-muted-foreground">{t("columns.successRate")}</div>
<div className="font-mono">
{`${(Number(entry.successRate || 0) * 100).toFixed(1)}%`}
</div>
</div>
<div>
<div className="text-muted-foreground">{t("columns.avgTtfbMs")}</div>
<div className="font-mono">
{entry.avgTtfbMs && entry.avgTtfbMs > 0
? `${Math.round(entry.avgTtfbMs).toLocaleString()} ms`
: "-"}
</div>
</div>
<div>
<div className="text-muted-foreground">{t("columns.avgTokensPerSecond")}</div>
<div className="font-mono">
{entry.avgTokensPerSecond && entry.avgTokensPerSecond > 0
? `${entry.avgTokensPerSecond.toFixed(1)} tok/s`
: "-"}
</div>
</div>
</div>
);
}
case "providerCacheHitRate": {
const entry = data as ProviderCacheHitRateEntry;
return (
<div className="grid grid-cols-3 gap-2 text-xs">
<div>
<div className="text-muted-foreground">{t("columns.cacheHitRequests")}</div>
<div className="font-mono">{entry.totalRequests.toLocaleString()}</div>
</div>
<div>
<div className="text-muted-foreground">{t("columns.cacheHitRate")}</div>
<div className="font-mono">
{`${(Number(entry.cacheHitRate || 0) * 100).toFixed(1)}%`}
</div>
</div>
<div>
<div className="text-muted-foreground">{t("columns.cacheReadTokens")}</div>
<div className="font-mono">{formatTokenAmount(entry.cacheReadTokens)}</div>
</div>
<div className="col-span-3">
<div className="text-muted-foreground">{t("columns.totalTokens")}</div>
<div className="font-mono">{formatTokenAmount(entry.totalInputTokens)}</div>
</div>
</div>
);
}
case "model": {
const entry = data as ModelEntry;
return (
<div className="grid grid-cols-3 gap-2 text-xs">
<div>
<div className="text-muted-foreground">{t("columns.requests")}</div>
<div className="font-mono">{entry.totalRequests.toLocaleString()}</div>
</div>
<div>
<div className="text-muted-foreground">{t("columns.tokens")}</div>
<div className="font-mono">{formatTokenAmount(entry.totalTokens)}</div>
</div>
<div>
<div className="text-muted-foreground">{t("columns.cost")}</div>
<div className="font-mono">
{entry.totalCostFormatted ?? `$${Number(entry.totalCost).toFixed(2)}`}
</div>
</div>
<div className="col-span-3">
<div className="text-muted-foreground">{t("columns.successRate")}</div>
<div className="font-mono">
{`${(Number(entry.successRate || 0) * 100).toFixed(1)}%`}
</div>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

成本与单位硬编码,可能与币种/本地化不一致

Line 63–187:$mstok/s 为硬编码展示文本,既不符合 i18n 规范,也可能在非 USD 场景误导用户。建议成本优先使用已格式化字段或按系统币种格式化;单位建议用 i18n 或 Intl.NumberFormat 的单位格式化。

成本字段的最小修正示例(先移除硬编码美元符号)
-        return entry.totalCostFormatted ?? `$${Number(entry.totalCost).toFixed(2)}`;
+        return entry.totalCostFormatted ?? Number(entry.totalCost).toLocaleString();
...
-                {entry.totalCostFormatted ?? `$${Number(entry.totalCost).toFixed(2)}`}
+                {entry.totalCostFormatted ?? Number(entry.totalCost).toLocaleString()}
...
-        return entry.totalCostFormatted ?? `$${Number(entry.totalCost).toFixed(2)}`;
+        return entry.totalCostFormatted ?? Number(entry.totalCost).toLocaleString();
...
-                {entry.totalCostFormatted ?? `$${Number(entry.totalCost).toFixed(2)}`}
+                {entry.totalCostFormatted ?? Number(entry.totalCost).toLocaleString()}

如需保留币种符号,建议在父组件传入币种并用统一的货币格式化工具处理。单位(ms/tok/s)也建议提取为 i18n 或使用单位格式化。

🤖 Prompt for AI Agents
In
`@src/app/`[locale]/dashboard/leaderboard/_components/mobile-leaderboard-card.tsx
around lines 59 - 193, The UI hardcodes "$", "ms", and "tok/s" in
getPrimaryMetric and renderExpandedContent; update these to use the existing
formatted fields (e.g., entry.totalCostFormatted) or a currency-aware formatter
(Intl.NumberFormat with a provided currency/locale prop) instead of "$", and
replace "ms" and "tok/s" with i18n strings via t(...) or use
Intl.NumberFormat/unit formatting for milliseconds and tokens-per-second; touch
functions getPrimaryMetric and renderExpandedContent, ensure the component
accepts a currency and locale prop (or uses context) and use those when
formatting numbers and units so all cost and unit displays respect localization
and currency settings.

Comment on lines +78 to +79
if (statusCode >= 200 && statusCode < 300) return `OK ${statusCode}`;
return `${statusCode}`;
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

硬编码的用户可见字符串应使用 i18n

状态文本中的 "OK" 前缀是硬编码的英文,应使用 i18n 翻译。

建议的修复
-    if (statusCode >= 200 && statusCode < 300) return `OK ${statusCode}`;
+    if (statusCode >= 200 && statusCode < 300) return t("logs.status.ok", { code: statusCode });

并在 5 种语言的 dashboard/logs.json 中添加对应的翻译键。

📝 Committable suggestion

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

Suggested change
if (statusCode >= 200 && statusCode < 300) return `OK ${statusCode}`;
return `${statusCode}`;
if (statusCode >= 200 && statusCode < 300) return t("logs.status.ok", { code: statusCode });
return `${statusCode}`;
🤖 Prompt for AI Agents
In `@src/app/`[locale]/dashboard/logs/_components/mobile-log-card.tsx around lines
78 - 79, Replace the hard-coded "OK" prefix in the status string (the return
that currently does `OK ${statusCode}` in
src/app/[locale]/dashboard/logs/_components/mobile-log-card.tsx) with an i18n
lookup (use the project's translate function, e.g. t('dashboard/logs.statusOk',
{ code: statusCode }) or equivalent) and add the corresponding translation key
("dashboard/logs.statusOk") to the dashboard/logs.json files for all five
locales with the appropriate localized templates (e.g. "OK {code}" or localized
equivalent).

Comment on lines +186 to +201
<div className="text-muted-foreground text-[10px]">{t("logs.columns.tokens")}</div>
<div className="font-mono tabular-nums">
<div>In: {formatTokenAmount(log.inputTokens)}</div>
<div className="text-muted-foreground">Out: {formatTokenAmount(log.outputTokens)}</div>
</div>
</div>

{/* Cache */}
<div className="space-y-0.5">
<div className="text-muted-foreground text-[10px]">{t("logs.columns.cache")}</div>
<div className="font-mono tabular-nums">
<div>W: {formatTokenAmount(log.cacheCreationInputTokens)}</div>
<div className="text-muted-foreground">
R: {formatTokenAmount(log.cacheReadInputTokens)}
</div>
</div>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Token 和 Cache 标签应使用 i18n

"In:"、"Out:"、"W:"、"R:" 这些标签是硬编码的英文缩写,应使用 i18n 以支持多语言显示。根据编码规范,所有用户可见字符串必须使用 i18n。

建议的修复
         <div className="font-mono tabular-nums">
-            <div>In: {formatTokenAmount(log.inputTokens)}</div>
-            <div className="text-muted-foreground">Out: {formatTokenAmount(log.outputTokens)}</div>
+            <div>{t("logs.mobile.inputShort")}: {formatTokenAmount(log.inputTokens)}</div>
+            <div className="text-muted-foreground">{t("logs.mobile.outputShort")}: {formatTokenAmount(log.outputTokens)}</div>
          </div>
        </div>

        {/* Cache */}
        <div className="space-y-0.5">
          <div className="text-muted-foreground text-[10px]">{t("logs.columns.cache")}</div>
          <div className="font-mono tabular-nums">
-            <div>W: {formatTokenAmount(log.cacheCreationInputTokens)}</div>
+            <div>{t("logs.mobile.cacheWriteShort")}: {formatTokenAmount(log.cacheCreationInputTokens)}</div>
            <div className="text-muted-foreground">
-              R: {formatTokenAmount(log.cacheReadInputTokens)}
+              {t("logs.mobile.cacheReadShort")}: {formatTokenAmount(log.cacheReadInputTokens)}
            </div>
📝 Committable suggestion

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

Suggested change
<div className="text-muted-foreground text-[10px]">{t("logs.columns.tokens")}</div>
<div className="font-mono tabular-nums">
<div>In: {formatTokenAmount(log.inputTokens)}</div>
<div className="text-muted-foreground">Out: {formatTokenAmount(log.outputTokens)}</div>
</div>
</div>
{/* Cache */}
<div className="space-y-0.5">
<div className="text-muted-foreground text-[10px]">{t("logs.columns.cache")}</div>
<div className="font-mono tabular-nums">
<div>W: {formatTokenAmount(log.cacheCreationInputTokens)}</div>
<div className="text-muted-foreground">
R: {formatTokenAmount(log.cacheReadInputTokens)}
</div>
</div>
<div className="text-muted-foreground text-[10px]">{t("logs.columns.tokens")}</div>
<div className="font-mono tabular-nums">
<div>{t("logs.mobile.inputShort")}: {formatTokenAmount(log.inputTokens)}</div>
<div className="text-muted-foreground">{t("logs.mobile.outputShort")}: {formatTokenAmount(log.outputTokens)}</div>
</div>
</div>
{/* Cache */}
<div className="space-y-0.5">
<div className="text-muted-foreground text-[10px]">{t("logs.columns.cache")}</div>
<div className="font-mono tabular-nums">
<div>{t("logs.mobile.cacheWriteShort")}: {formatTokenAmount(log.cacheCreationInputTokens)}</div>
<div className="text-muted-foreground">
{t("logs.mobile.cacheReadShort")}: {formatTokenAmount(log.cacheReadInputTokens)}
</div>
</div>
🤖 Prompt for AI Agents
In `@src/app/`[locale]/dashboard/logs/_components/mobile-log-card.tsx around lines
186 - 201, The hardcoded labels "In:", "Out:", "W:", and "R:" in
mobile-log-card.tsx should be replaced with i18n calls like the existing t(...)
usage; update the JSX where those strings appear (inside the divs rendering
token and cache counts) to use t(...) keys (e.g., t("logs.tokens.in"),
t("logs.tokens.out"), t("logs.cache.write"), t("logs.cache.read")) while keeping
formatTokenAmount(log.*) unchanged, and add those keys to your locale resource
files so translations are available.

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

2 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +26 to +27
limitTotalUsd: null,
usedTotalUsd: 0,
Copy link

Choose a reason for hiding this comment

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

Remove unused test properties - usedTotalUsd doesn't exist in Provider type

Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/unit/proxy/provider-selector-group-priority.test.ts
Line: 26:27

Comment:
Remove unused test properties - `usedTotalUsd` doesn't exist in Provider type

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

Comment on lines +15 to +16
apiKey: "test-key",
enabled: true,
Copy link

Choose a reason for hiding this comment

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

Property name mismatch - Provider interface uses key not apiKey, and isEnabled not enabled

Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/unit/proxy/provider-selector-group-priority.test.ts
Line: 15:16

Comment:
Property name mismatch - Provider interface uses `key` not `apiKey`, and `isEnabled` not `enabled`

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

Copy link

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

Caution

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

⚠️ Outside diff range comments (1)
src/app/[locale]/dashboard/availability/_components/endpoint/probe-terminal.tsx (1)

76-80: 自动滚动依赖缺少日志变化,功能回退

Line 76-80:useEffect 不再依赖日志变化,导致新日志到达时不会触发滚动,和注释“新日志到达时自动滚动”的行为不一致。建议把日志变化加入依赖,至少用 logs.length 触发。

建议修改
-  }, [autoScroll, userScrolled]);
+  }, [autoScroll, userScrolled, logs.length]);

Update selectors and assertions to match the new horizontal flow layout
where suggestions use inline-flex buttons with accent hover for unselected
and primary background for selected items.
Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

1 file reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Comment on lines +15 to +16
apiKey: "test-key",
enabled: true,
Copy link

Choose a reason for hiding this comment

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

Property names don't match Provider interface - change apiKey to key and enabled to isEnabled per the interface definition in src/types/provider.ts:39-56

Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/unit/proxy/provider-selector-group-priority.test.ts
Line: 15:16

Comment:
Property names don't match Provider interface - change `apiKey` to `key` and `enabled` to `isEnabled` per the interface definition in `src/types/provider.ts:39-56`

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

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 enhancement New feature or request size/XL Extra Large PR (> 1000 lines)

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant

Comments