Skip to content

feat: restore provider whitelist model picker#997

Open
ding113 wants to merge 2 commits intodevfrom
feat/provider-whitelist-model-picker
Open

feat: restore provider whitelist model picker#997
ding113 wants to merge 2 commits intodevfrom
feat/provider-whitelist-model-picker

Conversation

@ding113
Copy link
Copy Markdown
Owner

@ding113 ding113 commented Apr 5, 2026

Summary

Restores a model picker in the provider allowlist editing UI that integrates with the new rule-based allowlist system from #996. Models selected from the picker are added as exact-match rules, while advanced match types (prefix, suffix, contains, regex) remain in the detailed rule editor below. The implementation prefers upstream provider model lists when available, falling back to the local price catalog sorted by latest update.

Problem

PR #996 upgraded provider allowlists from simple string[] matching to a powerful rule-based system with 5 match types (exact/prefix/suffix/contains/regex). While this was a major improvement, the transition removed the convenient model picker UI that allowed users to quickly select models from a list. Users had to manually type every model name, even for exact matches.

Additionally, debugging allowlist/redirect rules was done via inline collapsible sections that consumed significant vertical space and created visual clutter.

Related Issues and PRs:

Solution

  1. Model Multi-Select Component (model-multi-select.tsx): New component that fetches models from upstream provider APIs when credentials are available, falling back to the local price catalog sorted by latest update. Features include:

    • Provider type-aware upstream fetching
    • Provider filtering dropdown (shows only providers with available models)
    • Search functionality
    • Bulk selection with "Select All" and "Invert Selection"
    • Manual model entry for edge cases
    • Clear visual indication of source (upstream vs fallback)
  2. Integrated Exact-Match Picker: The AllowedModelRuleEditor now includes a dedicated section at the top for adding exact-match rules via the model picker, clearly separated from the advanced rule editor below.

  3. Compact Rule Testing: Replaced inline collapsible testers with RuleTesterDialogTrigger - a tooltip-triggered dialog pattern that reduces visual clutter while keeping testing functionality accessible.

Changes

Core Changes

  • src/app/[locale]/settings/providers/_components/model-multi-select.tsx (+502): New model picker component with upstream fetching, provider filtering, and bulk selection
  • src/app/[locale]/settings/providers/_components/rule-tester-dialog-trigger.tsx (+68): New compact dialog trigger component for rule testing
  • src/app/[locale]/settings/providers/_components/allowed-model-rule-editor.tsx (+70/-0): Integrated model picker for exact-match rules
  • src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx (+35/-47): Replaced collapsible testers with dialog triggers, connected provider credentials to model picker
  • src/actions/model-prices.ts (+44/-15): Added getAvailableModelCatalog() for fallback catalog with provider and update time metadata

i18n (5 languages: en, ja, ru, zh-CN, zh-TW)

  • allowedModelRules.json (+2 each): Added exactPickerTitle and exactPickerDescription
  • modelSelect.json (+7/-2 each): Added emptyLabel, exactMatchHint, invertSelection, providerFilterAll, providerFilterPlaceholder, updated selected group labels for clarity

Tests

  • tests/unit/settings/providers/model-multi-select.test.tsx (+279): Comprehensive tests for model picker component
  • tests/unit/settings/providers/rule-tester-dialog-trigger.test.tsx (+118): Tests for dialog trigger component
  • tests/unit/settings/providers/allowed-model-rule-editor.test.tsx (+125): Extended tests for integrated model picker functionality
  • Updated 2 existing test files for minor compatibility

Testing

Automated Tests

  • Unit tests added for new components (model-multi-select, rule-tester-dialog-trigger)
  • Existing rule editor tests extended for model picker integration
  • TypeScript: 0 errors
  • Biome lint: 0 errors
  • Production build: success

Manual Testing Steps

  1. Edit a provider → Routing section → Allowed Models
  2. Verify model picker shows with provider filter dropdown
  3. Test upstream fetch (for providers with URL/key configured)
  4. Test fallback to local catalog (when upstream unavailable)
  5. Select multiple models → verify they appear as exact-match rules
  6. Click "Test Rules" → verify dialog opens with match tester
  7. Verify prefix/suffix/contains/regex rules still editable below

Breaking Changes

None - this is a pure UI enhancement that builds on the rule-based allowlist system from #996. The underlying data format remains AllowedModelRule[].

Screenshots / Preview

Model Picker Integration

The exact-match model picker now appears at the top of the allowlist editor, clearly separated from the advanced rule editor:

┌─ Add exact models from list ──────────────────────────┐
│ [ModelMultiSelect Dropdown]  [Provider Filter ▼]       │
│ "Models selected here are added as exact-match..."   │
└───────────────────────────────────────────────────────┘
┌─ Advanced Rule Editor ────────────────────────────────┐
│ [Match Type ▼] [Pattern Input] [Add Rule]            │
│ prefix │ claude-sonnet-4 │ 🗑️                         │
│ regex  │ gpt-.*-preview  │ 🗑️                         │
└───────────────────────────────────────────────────────┘

Compact Rule Testing

Rule testers now open in dialogs triggered by tooltip buttons, reducing visual clutter:

[Allowlist Rules]                    [Test Rules] ← tooltip/dialog trigger

Checklist

  • Code follows project conventions
  • i18n strings added for all 5 languages
  • Tests added/updated with >80% coverage
  • No breaking changes
  • Verified locally (lint, typecheck, build, test)

Description enhanced by Claude AI

Greptile Summary

This PR restores the model picker UI for provider allowlist editing, adding upstream model fetching with a local price catalog fallback and replacing inline rule testers with compact RuleTesterDialogTrigger dialogs. The duplicate-provider-entry bug in the filter dropdown (flagged in a previous review) is correctly resolved with Array.from(new Set(...)) at line 135–141 of model-multi-select.tsx. No blocking issues were found.

Confidence Score: 5/5

Safe to merge — no P0/P1 issues; only minor style findings remain

All findings are P2: six orphaned i18n keys and one duplicate hint label. Core logic is sound — request-ID race protection, provider deduplication, exact-rule sync with the 100-rule cap, and the admin auth guard on getAvailableModelCatalog are all implemented correctly. The previously flagged duplicate-provider-entry bug is fixed.

No files require special attention

Important Files Changed

Filename Overview
src/app/[locale]/settings/providers/_components/model-multi-select.tsx New 538-line model picker: upstream fetching with request-ID race protection and correctly deduplicated provider filter dropdown
src/actions/model-prices.ts Added getAvailableModelCatalog() server action with admin auth guard, chat-mode filtering, and update-time descending sort
src/app/[locale]/settings/providers/_components/allowed-model-rule-editor.tsx Integrates ModelMultiSelect for exact-match rules; handleExactModelsChange preserves non-exact rules and enforces the 100-rule cap
src/app/[locale]/settings/providers/_components/rule-tester-dialog-trigger.tsx Simple tooltip+dialog wrapper for rule testers; no issues found
src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx Correctly threads provider credentials into AllowedModelRuleEditor; replaces verbose inline testers with compact dialog triggers
messages/en/settings/providers/form/modelSelect.json Six unused i18n keys remain (manualAdd, manualDesc, manualPlaceholder, selectedEditEmpty, selectedEditExists, selectedListLabel)

Sequence Diagram

sequenceDiagram
    participant U as User
    participant MM as ModelMultiSelect
    participant PA as providers action
    participant MP as model-prices action

    U->>MM: Opens popover
    MM->>MM: requestId++, setLoading(true)
    alt providerId present & key is masked
        MM->>PA: getUnmaskedProviderKey(providerId)
        PA-->>MM: resolvedKey
    end
    alt providerUrl && resolvedKey available
        MM->>PA: fetchUpstreamModels()
        alt upstream success
            PA-->>MM: models[]
            MM->>MM: setAvailableModels(upstream)
            MM->>MM: setModelSource(upstream)
        else upstream failed
            PA-->>MM: error
            MM->>MM: setFallbackNotice()
            MM->>MP: getAvailableModelCatalog()
            MP-->>MM: catalog[]
            MM->>MM: setAvailableModels(catalog)
            MM->>MM: setModelSource(fallback)
        end
    else no URL or key
        MM->>MP: getAvailableModelCatalog()
        MP-->>MM: catalog[]
        MM->>MM: setAvailableModels(catalog)
        MM->>MM: setModelSource(fallback)
    end
    MM->>MM: setLoading(false) [only if requestId still current]
Loading

Comments Outside Diff (1)

  1. messages/en/settings/providers/form/modelSelect.json, line 2-30 (link)

    P2 Stale i18n keys not used by the component

    Seven keys — allowAllModels, manualAdd, manualDesc, manualPlaceholder, selectedEditEmpty, selectedEditExists, and selectedListLabel — are defined here and mirrored across all five locale files, but none of them are referenced in ModelMultiSelect or any other component in this PR. If they were removed during an earlier refactor they should be pruned from all locale files to avoid dead translation maintenance burden.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: messages/en/settings/providers/form/modelSelect.json
    Line: 2-30
    
    Comment:
    **Stale i18n keys not used by the component**
    
    Seven keys — `allowAllModels`, `manualAdd`, `manualDesc`, `manualPlaceholder`, `selectedEditEmpty`, `selectedEditExists`, and `selectedListLabel` — are defined here and mirrored across all five locale files, but none of them are referenced in `ModelMultiSelect` or any other component in this PR. If they were removed during an earlier refactor they should be pruned from all locale files to avoid dead translation maintenance burden.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: messages/en/settings/providers/form/modelSelect.json
Line: 11-26

Comment:
**Unused i18n keys — orphaned across all 5 locales**

Six keys defined here (`manualAdd`, `manualDesc`, `manualPlaceholder`, `selectedEditEmpty`, `selectedEditExists`, `selectedListLabel`) are not referenced anywhere in `model-multi-select.tsx`. The PR description mentions "Manual model entry for edge cases" but no corresponding UI was shipped, leaving dead translation strings in all 5 locale files (`en`, `ja`, `ru`, `zh-CN`, `zh-TW`). Consider removing them now or tracking the manual-entry feature as a follow-up.

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

---

This is a comment left during a code review.
Path: src/app/[locale]/settings/providers/_components/allowed-model-rule-editor.tsx
Line: 354

Comment:
**Redundant `orderHint` render**

`t("orderHint")` ("Top rules match first") is already displayed in the description card at line 261. Rendering it again in the list header when rules exist duplicates the hint. Consider removing this instance or replacing it with a label that provides new information (e.g. a rule-count summary).

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

Reviews (2): Last reviewed commit: "fix: harden provider whitelist model pic..." | Re-trigger Greptile

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 5, 2026

📝 Walkthrough

Walkthrough

该 PR 在多语言文案中新增精确匹配模型选择相关键,新增并集成了 ModelMultiSelect 与 RuleTesterDialogTrigger 组件,扩展并重构了模型价格/目录接口,更新了允许模型规则编辑器与路由表单,并为这些改动新增/调整单元测试。

Changes

Cohort / File(s) Summary
国际化 (i18n)
messages/en/settings/providers/form/allowedModelRules.json, messages/en/settings/providers/form/modelSelect.json, messages/ja/settings/providers/form/allowedModelRules.json, messages/ja/settings/providers/form/modelSelect.json, messages/ru/settings/providers/form/allowedModelRules.json, messages/ru/settings/providers/form/modelSelect.json, messages/zh-CN/settings/providers/form/allowedModelRules.json, messages/zh-CN/settings/providers/form/modelSelect.json, messages/zh-TW/settings/providers/form/allowedModelRules.json, messages/zh-TW/settings/providers/form/modelSelect.json
新增/更新多语言键以支持“精确匹配规则”选择器(标题、描述、空状态、提示、反转选择、提供商过滤、回退提示及将若干标签改为“精确规则”术语)。
模型价格与目录 API
src/actions/model-prices.ts
新增导出类型 AvailableModelCatalogItemgetAvailableModelCatalog(),封装排序/过滤逻辑;getAvailableModelsByProviderType() 改为委托新函数并统一错误/回退语义。
模型多选组件
src/app/[locale]/settings/providers/_components/model-multi-select.tsx
新增客户端组件 ModelMultiSelect:异步加载上游模型或回退本地目录,支持搜索、提供商过滤(回退时)、全选/反转/清除、保留虚拟选项以保证已选项可见。(大文件,注意异步/回退与去重/大小写归一逻辑)
规则测试对话框触发器
src/app/[locale]/settings/providers/_components/rule-tester-dialog-trigger.tsx
新增 RuleTesterDialogTrigger 组件:封装 Dialog + Tooltip 的触发按钮与对话框布局。
允许模型规则编辑器
src/app/[locale]/settings/providers/_components/allowed-model-rule-editor.tsx
扩展组件 props 以接收提供商相关字段,集成 ModelMultiSelect 并实现将多选结果同步为 exact 规则的合并/去重/限额逻辑(含 MAX_ALLOWED_MODEL_RULES 常量及错误状态设置)。
路由表单区域
src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx
移除原有可折叠 tester UI,改用右侧 RuleTesterDialogTrigger;从表单上下文传入 provider 信息并向编辑器传递额外 provider 字段。
单元测试
tests/unit/settings/providers/allowed-model-rule-editor.test.tsx, tests/unit/settings/providers/model-multi-select.test.tsx, tests/unit/settings/providers/rule-tester-dialog-trigger.test.tsx, tests/unit/settings/providers/provider-form-endpoint-pool.test.tsx, tests/unit/settings/providers/provider-form-total-limit-ui.test.tsx, tests/unit/settings/providers/vendor-keys-compact-list-ui.test.tsx
新增/调整多项测试:为新组件编写单元测试并在若干现有测试中补充 getAvailableModelCatalog 的 mock,包含对回退流、提供商过滤、反转选择与对话框触发的断言。

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • PR #996:直接覆盖/扩展了相同的允许模型 UI、ModelMultiSelect 与 i18n 键更改。
  • PR #583:修改了 src/actions/model-prices.ts 的模型价格/目录相关实现,与本 PR 新增/重构的 API 存在重叠。
  • PR #994:同样涉及 ModelMultiSelect 及路由/模型选择相关组件的改动,代码层面关系密切。
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.33% 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内容。
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 feat/provider-whitelist-model-picker

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 enhancement New feature or request area:UI area:provider area:i18n size/XL Extra Large PR (> 1000 lines) labels Apr 5, 2026
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 introduces a ModelMultiSelect component and a RuleTesterDialogTrigger to streamline provider model allowlist management, allowing users to select exact models from upstream or local catalogs. The changes include multi-language support, a new backend action for model retrieval, and expanded unit tests. Feedback identifies a bug where masked API keys hinder model fetching and suggests improvements for the loadModels effect, specifically using the useEffectEvent pattern and implementing race condition handling.


try {
let resolvedKey = apiKey?.trim() || "";
if (!resolvedKey && providerId) {
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

The current logic only attempts to unmask the API key if apiKey is empty. However, when editing an existing provider, the apiKey prop (passed from state.basic.key) typically contains the masked version (e.g., sk-****). This causes fetchUpstreamModels to be called with an invalid key, making the upstream model list unavailable.

You should check if the key is masked (e.g., contains ***) and trigger the unmasking action in that case as well.

Suggested change
if (!resolvedKey && providerId) {
const isMasked = resolvedKey.includes("***");
if ((!resolvedKey || isMasked) && providerId) {

Comment on lines +251 to +253
useEffect(() => {
void loadModels();
}, [loadModels]);
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

The useEffect hook triggers loadModels whenever its dependencies change, regardless of whether the model picker popover is actually open. To optimize performance and follow repository patterns, use the useEffectEvent pattern. This separates the trigger logic (the open state) from the action (loadModels), ensuring the action only runs when intended and avoiding unnecessary re-runs if loadModels is unstable.

Suggested change
useEffect(() => {
void loadModels();
}, [loadModels]);
const onLoadModels = useEffectEvent(() => {
void loadModels();
});
useEffect(() => {
if (open) {
onLoadModels();
}
}, [open]);
References
  1. Use the useEffectEvent pattern to separate the effect's trigger logic from the action, ensuring the action only runs when its specific dependencies change and preventing unnecessary re-runs from unstable function dependencies.

Comment on lines +205 to +249
const loadModels = useCallback(async () => {
setLoading(true);
setModelSource("loading");

try {
let resolvedKey = apiKey?.trim() || "";
if (!resolvedKey && providerId) {
const keyResult = await getUnmaskedProviderKey(providerId);
if (keyResult.ok && keyResult.data?.key) {
resolvedKey = keyResult.data.key;
}
}

if (providerUrl && resolvedKey) {
const upstreamResult = await fetchUpstreamModels({
providerUrl,
apiKey: resolvedKey,
providerType,
proxyUrl,
proxyFallbackToDirect,
});

if (upstreamResult.ok && upstreamResult.data?.models?.length) {
const upstreamModels = upstreamResult.data.models.map((modelName) =>
buildLocalOption({
modelName,
litellmProvider: null,
updatedAt: "",
})
);
setAvailableModels(upstreamModels);
setModelSource("upstream");
setProviderFilter("__all__");
return;
}
}

const localCatalog = await getAvailableModelCatalog();
setAvailableModels(localCatalog.map(buildLocalOption));
setModelSource("fallback");
setProviderFilter("__all__");
} finally {
setLoading(false);
}
}, [apiKey, providerId, providerType, providerUrl, proxyFallbackToDirect, proxyUrl]);
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

The loadModels function is an asynchronous operation called within a useEffect hook, but it lacks a mechanism to handle race conditions. If the dependencies (like apiKey) change rapidly, multiple requests might be in flight simultaneously, and a slower, stale request could overwrite the results of a newer one.

Consider using an AbortController or a local boolean flag within the useEffect to ignore results from stale executions.

Comment on lines +133 to +146
const unknownProviders = availableModels
.map((model) => model.litellmProvider)
.filter((provider): provider is string => !!provider && !seen.has(provider))
.sort((left, right) => left.localeCompare(right));

for (const provider of unknownProviders) {
seen.add(provider);
options.push({
value: provider,
label: knownProviders.has(provider)
? tPrices(`filters.${knownProviders.get(provider)}`)
: provider,
});
}
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 Duplicate provider entries in filter dropdown

unknownProviders is built by mapping every model to its litellmProvider and filtering for values not yet in seen — but at that snapshot moment seen only contains PRICE_FILTER_VENDORS entries. Multiple models that share the same unknown provider (e.g. "gemini", "sagemaker") each pass the filter independently. The subsequent for loop then pushes every occurrence into options without re-checking seen, producing duplicate <SelectItem> nodes with the same key value. This triggers React key warnings and shows the same provider twice in the dropdown.

Suggested change
const unknownProviders = availableModels
.map((model) => model.litellmProvider)
.filter((provider): provider is string => !!provider && !seen.has(provider))
.sort((left, right) => left.localeCompare(right));
for (const provider of unknownProviders) {
seen.add(provider);
options.push({
value: provider,
label: knownProviders.has(provider)
? tPrices(`filters.${knownProviders.get(provider)}`)
: provider,
});
}
const unknownProviders = availableModels
.map((model) => model.litellmProvider)
.filter((provider): provider is string => !!provider && !seen.has(provider))
.sort((left, right) => left.localeCompare(right));
for (const provider of unknownProviders) {
if (seen.has(provider)) {
continue;
}
seen.add(provider);
options.push({
value: provider,
label: knownProviders.has(provider)
? tPrices(`filters.${knownProviders.get(provider)}`)
: provider,
});
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/[locale]/settings/providers/_components/model-multi-select.tsx
Line: 133-146

Comment:
**Duplicate provider entries in filter dropdown**

`unknownProviders` is built by mapping every model to its `litellmProvider` and filtering for values not yet in `seen` — but at that snapshot moment `seen` only contains `PRICE_FILTER_VENDORS` entries. Multiple models that share the same unknown provider (e.g. `"gemini"`, `"sagemaker"`) each pass the filter independently. The subsequent `for` loop then pushes every occurrence into `options` without re-checking `seen`, producing duplicate `<SelectItem>` nodes with the same `key` value. This triggers React key warnings and shows the same provider twice in the dropdown.

```suggestion
    const unknownProviders = availableModels
      .map((model) => model.litellmProvider)
      .filter((provider): provider is string => !!provider && !seen.has(provider))
      .sort((left, right) => left.localeCompare(right));

    for (const provider of unknownProviders) {
      if (seen.has(provider)) {
        continue;
      }
      seen.add(provider);
      options.push({
        value: provider,
        label: knownProviders.has(provider)
          ? tPrices(`filters.${knownProviders.get(provider)}`)
          : provider,
      });
    }
```

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: 514973eb44

ℹ️ 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 +133 to +136
const unknownProviders = availableModels
.map((model) => model.litellmProvider)
.filter((provider): provider is string => !!provider && !seen.has(provider))
.sort((left, right) => left.localeCompare(right));
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 Deduplicate unknown provider filter entries

When the fallback catalog contains multiple models from the same litellmProvider that is not in PRICE_FILTER_VENDORS, this pipeline keeps every occurrence and later renders duplicate SelectItem values/keys for the provider filter. That produces repeated options and can make selection behavior ambiguous for those providers; convert unknownProviders to a unique set before building options.

Useful? React with 👍 / 👎.

Comment on lines +235 to +237
setAvailableModels(upstreamModels);
setModelSource("upstream");
setProviderFilter("__all__");
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 Prevent stale model loads from overwriting newer results

loadModels can run concurrently (initial effect, dependency changes, and manual refresh), but each completion unconditionally calls setAvailableModels/setModelSource. If an earlier request resolves after a later one (for example while editing URL/key or clicking refresh twice), stale data can replace the most recent catalog in the picker. Add a request token/abort guard so only the latest invocation is allowed to commit state.

Useful? React with 👍 / 👎.

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

🧹 Nitpick comments (2)
src/actions/model-prices.ts (1)

377-380: 同步更新这个 helper 的注释。

这里已经直接复用 getAvailableModelCatalog() 的全部聊天模型,不再按 provider type 过滤;上面的“根据供应商类型”/@param providerType 说明会继续误导后续调用方。

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

In `@src/actions/model-prices.ts` around lines 377 - 380, Update the JSDoc/comment
for the helper getAvailableModelsByProviderType to reflect its current behavior:
it now returns all chat model names by reusing getAvailableModelCatalog()
without filtering by provider type, so remove or correct any "by provider type"
description and delete the stale `@param` providerType tag; keep the function name
and implementation as-is but make the comment clearly state it returns
catalog.map(item => item.modelName) for all providers.
src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx (1)

27-27: 建议把新增导入改为 @/ 别名路径。

当前新增导入使用了相对路径,和仓库既定规范不一致。

♻️ 建议修改
-import { RuleTesterDialogTrigger } from "../../../rule-tester-dialog-trigger";
+import { RuleTesterDialogTrigger } from "@/app/[locale]/settings/providers/_components/rule-tester-dialog-trigger";

As per coding guidelines: "**/*.{ts,tsx,js,jsx}: Use path alias @/ mapped to ./src/ for imports".

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

In
`@src/app/`[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx
at line 27, In routing-section.tsx update the import of RuleTesterDialogTrigger
to use the project path alias '@/...' (which maps to ./src/) instead of the
current relative import; locate the import line that references
RuleTesterDialogTrigger, replace the relative path with the corresponding
'@/...' module path, and ensure the project builds/typechecks after the change.
🤖 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/allowed-model-rule-editor.tsx:
- Around line 215-247: The exact-models handler handleExactModelsChange
currently rebuilds exact rules from selectedModels without enforcing the
100-rule cap, allowing value to grow beyond the component's limit; modify
handleExactModelsChange to apply the same max-rule check used elsewhere (the
logic at the other entry around lines 123-126) before adding new normalized
rules: compute currentCount = nextRules.length, and when iterating
normalizedSelections / creating nextRule via normalizeRule and identity via
getRuleIdentity only push new rules while currentCount < MAX_RULES (e.g., 100),
skipping or truncating excess selections, then call onChange with the capped
nextRules so value never exceeds the unified limit.

In
`@src/app/`[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx:
- Line 244: The current providerUrl prop uses the || operator which treats an
empty string as falsy and falls back to provider?.url, losing the user's
explicit "cleared" URL; change the fallback to use nullish coalescing so empty
string is preserved (e.g. replace providerUrl={state.basic.url || provider?.url}
with providerUrl={state.basic.url ?? provider?.url} or otherwise check for
null/undefined explicitly) so state.basic.url is honored even when it's "".

In `@src/app/`[locale]/settings/providers/_components/model-multi-select.tsx:
- Around line 205-249: loadModels lacks concurrency protection so stale async
responses can overwrite newer state; introduce a per-call token (e.g., requestId
counter or AbortController stored in a ref) and capture it at the start of
loadModels, then verify the token is still current before calling setters like
setAvailableModels, setModelSource, setProviderFilter and before clearing
setLoading in the finally block; ensure the same guard is applied to the other
loadModels invocation site so only the latest request can mutate state.
- Around line 133-145: unknownProviders can contain duplicates because
availableModels is mapped and sorted but not deduped, causing duplicate
SelectItem entries and repeated keys; fix by deduplicating provider values
before pushing into options (e.g., build a Set or use Array.from(new Set(...))
or filter to only include the first occurrence) so that the loop over
unknownProviders (and use of seen, options, knownProviders, tPrices and the
SelectItem key={option.value}) only processes each provider once.
- Around line 210-225: The client currently calls
getUnmaskedProviderKey(providerId) and passes the returned plaintext into
fetchUpstreamModels(apiKey: resolvedKey), exposing secrets to the browser;
instead, change fetchUpstreamModels to accept an optional providerId and move
secret resolution to the server-side action (similar to resetProviderCircuit and
getProviderLimitUsage) so the client never requests or receives the unmasked
key. Update the call site in model-multi-select (remove getUnmaskedProviderKey
usage and pass providerId when apiKey is empty) and make the analogous change in
api-test-button.tsx so both call fetchUpstreamModels({ providerId, providerUrl,
providerType, proxyUrl, proxyFallbackToDirect }) rather than fetching and
sending the plaintext apiKey.

In `@tests/unit/settings/providers/vendor-keys-compact-list-ui.test.tsx`:
- Around line 66-69: The mock for getAvailableModelsByProviderType does not
match the real function signature (it currently returns an { ok, data } object)
— change the hoisted mock for getAvailableModelsByProviderType to return a
string[] (e.g. async () => [] or a list of model ids) so it matches the
production signature; keep getAvailableModelCatalog as-is. Update the vi.hoisted
block that defines modelPricesActionMocks and adjust only
getAvailableModelsByProviderType to return the correct type so
VendorKeysCompactList / ProviderForm tests behave like production.

---

Nitpick comments:
In `@src/actions/model-prices.ts`:
- Around line 377-380: Update the JSDoc/comment for the helper
getAvailableModelsByProviderType to reflect its current behavior: it now returns
all chat model names by reusing getAvailableModelCatalog() without filtering by
provider type, so remove or correct any "by provider type" description and
delete the stale `@param` providerType tag; keep the function name and
implementation as-is but make the comment clearly state it returns
catalog.map(item => item.modelName) for all providers.

In
`@src/app/`[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx:
- Line 27: In routing-section.tsx update the import of RuleTesterDialogTrigger
to use the project path alias '@/...' (which maps to ./src/) instead of the
current relative import; locate the import line that references
RuleTesterDialogTrigger, replace the relative path with the corresponding
'@/...' module path, and ensure the project builds/typechecks after the change.
🪄 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: 90b2b5ab-4a62-4cff-ae06-2a1a856e13f2

📥 Commits

Reviewing files that changed from the base of the PR and between 8e0f55f and 514973e.

📒 Files selected for processing (21)
  • messages/en/settings/providers/form/allowedModelRules.json
  • messages/en/settings/providers/form/modelSelect.json
  • messages/ja/settings/providers/form/allowedModelRules.json
  • messages/ja/settings/providers/form/modelSelect.json
  • messages/ru/settings/providers/form/allowedModelRules.json
  • messages/ru/settings/providers/form/modelSelect.json
  • messages/zh-CN/settings/providers/form/allowedModelRules.json
  • messages/zh-CN/settings/providers/form/modelSelect.json
  • messages/zh-TW/settings/providers/form/allowedModelRules.json
  • messages/zh-TW/settings/providers/form/modelSelect.json
  • src/actions/model-prices.ts
  • src/app/[locale]/settings/providers/_components/allowed-model-rule-editor.tsx
  • src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx
  • src/app/[locale]/settings/providers/_components/model-multi-select.tsx
  • src/app/[locale]/settings/providers/_components/rule-tester-dialog-trigger.tsx
  • tests/unit/settings/providers/allowed-model-rule-editor.test.tsx
  • tests/unit/settings/providers/model-multi-select.test.tsx
  • tests/unit/settings/providers/provider-form-endpoint-pool.test.tsx
  • tests/unit/settings/providers/provider-form-total-limit-ui.test.tsx
  • tests/unit/settings/providers/rule-tester-dialog-trigger.test.tsx
  • tests/unit/settings/providers/vendor-keys-compact-list-ui.test.tsx

Comment on lines +210 to +225
let resolvedKey = apiKey?.trim() || "";
if (!resolvedKey && providerId) {
const keyResult = await getUnmaskedProviderKey(providerId);
if (keyResult.ok && keyResult.data?.key) {
resolvedKey = keyResult.data.key;
}
}

if (providerUrl && resolvedKey) {
const upstreamResult = await fetchUpstreamModels({
providerUrl,
apiKey: resolvedKey,
providerType,
proxyUrl,
proxyFallbackToDirect,
});
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
set -euo pipefail

python - <<'PY'
from pathlib import Path

path = Path("src/actions/providers.ts")
lines = path.read_text().splitlines()

for name in ("getUnmaskedProviderKey", "fetchUpstreamModels"):
    for i, line in enumerate(lines, 1):
        if name in line:
            start = max(1, i - 5)
            end = min(len(lines), i + 80)
            print(f"== {name} around line {i} ==")
            for n in range(start, end + 1):
                print(f"{n}:{lines[n-1]}")
            print()
            break
PY

rg -nC3 --type=tsx '\b(getUnmaskedProviderKey|fetchUpstreamModels)\s*\(' \
  'src/app/[locale]/settings/providers/_components/model-multi-select.tsx'

Repository: ding113/claude-code-hub

Length of output: 5583


🏁 Script executed:

# First, let's look at the model-multi-select.tsx file to understand the actual usage
cat -n src/app/[locale]/settings/providers/_components/model-multi-select.tsx | sed -n '200,235p'

Repository: ding113/claude-code-hub

Length of output: 1449


🏁 Script executed:

# Now let's find the FetchUpstreamModelsArgs type definition
rg -n "type FetchUpstreamModelsArgs|interface FetchUpstreamModelsArgs" src/actions/providers.ts -A 15

Repository: ding113/claude-code-hub

Length of output: 492


🏁 Script executed:

# Check if there's any providerId handling in fetchUpstreamModels or related functions
rg -n "providerId" src/actions/providers.ts | head -20

Repository: ding113/claude-code-hub

Length of output: 1162


🏁 Script executed:

# Check if model-multi-select.tsx is a client component
head -5 src/app/[locale]/settings/providers/_components/model-multi-select.tsx

Repository: ding113/claude-code-hub

Length of output: 117


🏁 Script executed:

# Check for other usages of getUnmaskedProviderKey
rg -n "getUnmaskedProviderKey" --type ts --type tsx

Repository: ding113/claude-code-hub

Length of output: 94


🏁 Script executed:

# Check the imports and understand the component's context
sed -n '1,30p' src/app/[locale]/settings/providers/_components/model-multi-select.tsx

Repository: ding113/claude-code-hub

Length of output: 922


🏁 Script executed:

# Search for getUnmaskedProviderKey usages with fd and grep
fd -e ts -e tsx -x grep -l "getUnmaskedProviderKey" {}

Repository: ding113/claude-code-hub

Length of output: 1000


🏁 Script executed:

# Also check if there are other server actions that might accept providerId pattern
rg -n "export async function" src/actions/providers.ts | grep -E "(providerId|provider\s+id)" | head -10

Repository: ding113/claude-code-hub

Length of output: 334


🏁 Script executed:

# Check the api-test-button.tsx usage to see if it has same pattern
grep -n -A 10 -B 5 "getUnmaskedProviderKey\|fetchUpstreamModels" src/app/[locale]/settings/providers/_components/forms/api-test-button.tsx | head -40

Repository: ding113/claude-code-hub

Length of output: 1070


🏁 Script executed:

# Check vendor-keys-compact-list.tsx 
grep -n -A 5 -B 5 "getUnmaskedProviderKey" src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx | head -30

Repository: ding113/claude-code-hub

Length of output: 869


🏁 Script executed:

# Let's check what testProviderUnified and testProviderGemini signatures look like
rg -n "export async function testProviderUnified|export async function testProviderGemini" src/actions/providers.ts -A 3

Repository: ding113/claude-code-hub

Length of output: 432


🏁 Script executed:

# Check if there's a pattern of server actions that accept providerId
rg -n "providerId.*:" src/actions/providers.ts | grep "export async function" -A 20 | head -30

Repository: ding113/claude-code-hub

Length of output: 1534


不要在客户端补拿已保存的 provider 密钥。

Lines 210-225 中,当 apiKey 为空且 providerId 存在时,会先调用 getUnmaskedProviderKey(providerId) 获取明文密钥,再把返回值传给 fetchUpstreamModels() 作为 apiKey 参数。这会导致明文密钥被序列化到浏览器内存,再通过网络请求原样回传给服务器,掩码保护等于失效。

更安全的做法是在 fetchUpstreamModels() 中添加可选的 providerId 参数,当提供 providerId 时在服务端内部解析已保存的密钥,而不是将密钥暴露给客户端。这与现有的 resetProviderCircuit(providerId)getProviderLimitUsage(providerId) 等 server action 的设计模式一致。

同样的问题也存在于 src/app/[locale]/settings/providers/_components/forms/api-test-button.tsx 中。

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

In `@src/app/`[locale]/settings/providers/_components/model-multi-select.tsx
around lines 210 - 225, The client currently calls
getUnmaskedProviderKey(providerId) and passes the returned plaintext into
fetchUpstreamModels(apiKey: resolvedKey), exposing secrets to the browser;
instead, change fetchUpstreamModels to accept an optional providerId and move
secret resolution to the server-side action (similar to resetProviderCircuit and
getProviderLimitUsage) so the client never requests or receives the unmasked
key. Update the call site in model-multi-select (remove getUnmaskedProviderKey
usage and pass providerId when apiKey is empty) and make the analogous change in
api-test-button.tsx so both call fetchUpstreamModels({ providerId, providerUrl,
providerType, proxyUrl, proxyFallbackToDirect }) rather than fetching and
sending the plaintext apiKey.

Comment on lines 66 to 69
const modelPricesActionMocks = vi.hoisted(() => ({
getAvailableModelCatalog: vi.fn(async () => []),
getAvailableModelsByProviderType: vi.fn(async () => ({ ok: true, data: [] })),
}));
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

顺手把旧 action 的 mock 也对齐真实签名。

getAvailableModelsByProviderType() 在生产代码里返回的是 string[],这里仍然 mock 成 { ok, data }。当前分支没踩到时问题不显,但一旦 VendorKeysCompactListProviderForm 走回这个路径,测试行为就会和生产不一致。

建议修改
 const modelPricesActionMocks = vi.hoisted(() => ({
   getAvailableModelCatalog: vi.fn(async () => []),
-  getAvailableModelsByProviderType: vi.fn(async () => ({ ok: true, data: [] })),
+  getAvailableModelsByProviderType: vi.fn(async () => []),
 }));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/settings/providers/vendor-keys-compact-list-ui.test.tsx` around
lines 66 - 69, The mock for getAvailableModelsByProviderType does not match the
real function signature (it currently returns an { ok, data } object) — change
the hoisted mock for getAvailableModelsByProviderType to return a string[] (e.g.
async () => [] or a list of model ids) so it matches the production signature;
keep getAvailableModelCatalog as-is. Update the vi.hoisted block that defines
modelPricesActionMocks and adjust only getAvailableModelsByProviderType to
return the correct type so VendorKeysCompactList / ProviderForm tests behave
like production.

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 restores the model picker UI for provider allowlists, implementing upstream fetching with fallback to local catalog. While the feature is well-structured and includes comprehensive tests, there are several issues that should be addressed.

PR Size: XL

  • Lines changed: 1361 (1289 additions, 72 deletions)
  • Files changed: 21
  • Recommendation: This is a large PR touching multiple areas (UI, i18n, tests). Consider splitting future refactors into smaller chunks.

Issues Found

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

Critical Issues (Must Fix)

[LOGIC-BUG] Duplicate provider entries in (line ~133)

The calculation filters by where only contains entries. Multiple models sharing the same unknown provider each pass the filter independently, causing duplicate dropdown entries and React key warnings.

Fix: Deduplicate before processing:

High Priority Issues (Should Fix)

[ERROR-SILENT] Upstream fetch errors not surfaced to user (line ~284)

When fails, the error is only logged internally and silently falls back. Users aren't informed why upstream fetch failed.

Fix: Surface the error to users before falling back, or at least log it clearly.

Low Priority Issues

[COMMENT-INACCURATE] Stale i18n keys (messages/*/settings/providers/form/modelSelect.json)

The locale files contain 7 unused keys (allowAllModels, manualAdd, manualDesc, manualPlaceholder, selectedEditEmpty, selectedEditExists, selectedListLabel) that should be removed.

[TEST-BRITTLE] Timing-based test waits (model-multi-select.test.tsx)

Tests rely on magic counts which are inherently brittle. Use explicit conditions or waitFor patterns instead.

Review Coverage

  • Logic and correctness - 1 critical bug found
  • Security (OWASP Top 10) - No issues detected
  • Error handling - 1 silent failure identified
  • Type safety - Clean
  • Documentation accuracy - Stale i18n keys noted
  • Test coverage - Adequate but brittle patterns
  • Code clarity - Generally good

Automated review by Claude AI

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 restores the model picker UI for provider allowlists, implementing upstream fetching with fallback to local catalog. While the feature is well-structured and includes comprehensive tests, there are several issues that should be addressed.

PR Size: XL

  • Lines changed: 1361 (1289 additions, 72 deletions)
  • Files changed: 21
  • Recommendation: This is a large PR touching multiple areas (UI, i18n, tests). Consider splitting future refactors into smaller chunks.

Issues Found

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

Critical Issues (Must Fix)

[LOGIC-BUG] Duplicate provider entries in model-multi-select.tsx (line ~133)

The unknownProviders calculation has a deduplication bug. Multiple models sharing the same unknown provider cause duplicate dropdown entries and React key warnings.

Fix: Deduplicate with [...new Set(unknownProviders)] before processing.

High Priority Issues (Should Fix)

[ERROR-SILENT] Upstream fetch errors not surfaced to user (line ~284)

When fetchUpstreamModels fails, the error is only logged internally and silently falls back. Users are not informed why upstream fetch failed.

Fix: Surface the error to users before falling back.

Low Priority Issues

[COMMENT-INACCURATE] Stale i18n keys in all 5 locale files

The modelSelect.json files contain 7 unused keys that should be removed: allowAllModels, manualAdd, manualDesc, manualPlaceholder, selectedEditEmpty, selectedEditExists, selectedListLabel

[TEST-BRITTLE] Timing-based test waits in model-multi-select.test.tsx

Tests rely on magic flushTicks(5) counts which are inherently brittle. Use explicit conditions instead.

Review Coverage

  • Logic and correctness - 1 critical bug found
  • Security (OWASP Top 10) - No issues detected
  • Error handling - 1 silent failure identified
  • Type safety - Clean
  • Documentation accuracy - Stale i18n keys noted
  • Test coverage - Adequate but brittle patterns
  • Code clarity - Generally good

Automated review by Claude AI

}
}

const unknownProviders = availableModels
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.

[Critical] [LOGIC-BUG] Duplicate provider entries in filter dropdown

The unknownProviders calculation filters by !seen.has(provider) where seen only contains PRICE_FILTER_VENDORS entries. If multiple models share the same unknown provider, each occurrence passes the filter independently because they all check against the same initial seen state. The subsequent for loop pushes all occurrences to options without checking for duplicates, causing React key warnings and duplicate dropdown entries.

Fix: Deduplicate before processing:

const uniqueUnknownProviders = [...new Set(unknownProviders)].sort(...);


onChange(next);
};

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] [ERROR-SILENT] Upstream fetch errors not surfaced to user

When fetchUpstreamModels fails, the error is only logged internally and the code silently falls back to local catalog. Users are not informed that their upstream fetch failed or why, making debugging difficult.

Fix: Surface error to user before fallback:

if (!upstreamResult.ok && upstreamResult.error) {
  logger.warn('Upstream fetch failed:', upstreamResult.error);
  // setErrorState(upstreamResult.error) to display in UI
}

@@ -2,20 +2,25 @@
"allowAllModels": "Allow all {type} models",
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.

[Low] [COMMENT-INACCURATE] Stale i18n keys not used by component

This file contains 7 unused keys: allowAllModels, manualAdd, manualDesc, manualPlaceholder, selectedEditEmpty, selectedEditExists, selectedListLabel. These appear to be from a previous implementation and should be removed to reduce translation maintenance burden.

Fix: Remove these keys from all 5 locale files (en, ja, ru, zh-CN, zh-TW).

document.body.innerHTML = "";
vi.clearAllMocks();
});

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.

[Low] [TEST-BRITTLE] Test uses arbitrary flushTicks(5) timing-based waits

Tests rely on magic tick counts (5, 4, 2) to wait for async operations. This is inherently brittle and can cause flaky tests if timing changes.

Fix: Replace arbitrary tick counts with explicit conditions or waitFor pattern from testing-library.

.filter(Boolean);

const handleExactModelsChange = (selectedModels: string[]) => {
const normalizedSelections = selectedModels.map((model) => model.trim()).filter(Boolean);
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] Exact-model picker bypasses 100-rule cap

Evidence: src/app/[locale]/settings/providers/_components/allowed-model-rule-editor.tsx adds exact rules in handleExactModelsChange without enforcing the existing cap:

if (value.length >= 100) {
  setError(t("maxRules"));
  return;
}

Why this is a problem: The picker supports bulk actions (Select All / Invert) and can push the allowlist past 100 rules, contradicting the UI contract and risking oversized provider configs.

Suggested fix:

const MAX_RULES = 100;

const nonExactRules = value.filter((rule) => rule.matchType !== "exact");
const availableSlots = Math.max(0, MAX_RULES - nonExactRules.length);
const limitedSelections = normalizedSelections.slice(0, availableSlots);

setError(limitedSelections.length < normalizedSelections.length ? t("quickAddReachedLimit") : null);

// Build `nextRules` using `limitedSelections` instead of `normalizedSelections`.

let resolvedKey = apiKey?.trim() || "";
if (!resolvedKey && providerId) {
const keyResult = await getUnmaskedProviderKey(providerId);
if (keyResult.ok && keyResult.data?.key) {
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] [SECURITY-VULNERABILITY] Unmasked provider key is fetched on mount to load models

Evidence: src/app/[locale]/settings/providers/_components/model-multi-select.tsx calls getUnmaskedProviderKey(providerId) inside loadModels() and useEffect(() => void loadModels(), ...) runs on mount, so simply rendering the routing section can fetch the full API key into the browser.

This also triggers the audit log in src/actions/providers.ts:2898 ("Admin viewed provider key") even when the user never explicitly views/copies the key.

Why this is a problem: It expands secret exposure surface area and creates noisy/misleading audit logs; it also adds unnecessary network work for initial render when the picker may never be opened.

Suggested fix (lazy-load + avoid key fetch unless needed):

useEffect(() => {
  if (!open) return;
  void loadModels();
}, [loadModels, open]);

// In loadModels(), only resolve the key when you will actually attempt upstream fetch.
if (!providerUrl) {
  const localCatalog = await getAvailableModelCatalog();
  setAvailableModels(localCatalog.map(buildLocalOption));
  setModelSource("fallback");
  return;
}

If you want upstream-by-default without exposing keys to the client, consider updating fetchUpstreamModels to accept providerId and resolve the stored key server-side.


const providerActionMocks = vi.hoisted(() => ({
fetchUpstreamModels: vi.fn(async () => ({ ok: false, error: "upstream unavailable" })),
getUnmaskedProviderKey: vi.fn(async () => ({ ok: false })),
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] Upstream-success branch is untested

Evidence: tests/unit/settings/providers/model-multi-select.test.tsx currently mocks fetchUpstreamModels as always { ok: false }, so the upstream-preferred path in src/app/[locale]/settings/providers/_components/model-multi-select.tsx is never exercised.

Why this is a problem: Preferring upstream models is the core behavior of the picker. Without a test, regressions like "still fetching local catalog on upstream success" or "provider filter shown for upstream" can slip in.

Suggested fix (add an upstream test):

test("prefers upstream models when available", async () => {
  const messages = loadMessages();
  providerActionMocks.fetchUpstreamModels.mockResolvedValueOnce({
    ok: true,
    data: { models: ["claude-opus-4-1", "claude-sonnet-4-1"], source: "upstream" },
  });

  const { unmount } = render(
    <NextIntlClientProvider locale="en" messages={messages} timeZone="UTC">
      <ModelMultiSelect
        providerType="claude"
        providerUrl="https://api.example.com"
        apiKey="sk-test"
        selectedModels={[]}
        onChange={vi.fn()}
      />
    </NextIntlClientProvider>
  );

  // If you change to lazy-load on popover open, open the trigger before awaiting ticks.
  await flushTicks(5);

  expect(modelPricesActionMocks.getAvailableModelCatalog).not.toHaveBeenCalled();
  expect(providerActionMocks.fetchUpstreamModels).toHaveBeenCalled();

  unmount();
});

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 introduces a new exact-model picker (upstream-first with local fallback) and refactors rule testing UI into dialog triggers in the provider routing settings. The change set is XL and spans UI, server actions, translations, and tests; a couple of issues should be addressed to avoid exceeding the allowlist rule cap and to reduce unnecessary unmasked-key retrieval/audit logging.

PR Size: XL

  • Lines changed: 1361
  • Files changed: 21
  • Split suggestion (recommended for reviewability): (1) server action + i18n message updates, (2) UI components/refactor (picker + dialog trigger + routing section wiring), (3) test additions/mocks.

Issues Found

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

Critical Issues (Must Fix)

  • src/app/[locale]/settings/providers/_components/allowed-model-rule-editor.tsx exact-model picker can exceed the 100-rule cap (confidence 98).
  • src/app/[locale]/settings/providers/_components/model-multi-select.tsx fetches unmasked provider key on mount (secret exposure surface + noisy audit logging) (confidence 97).

High Priority Issues (Should Fix)

  • tests/unit/settings/providers/model-multi-select.test.tsx no unit test covers upstream-success branch of the picker (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
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.

PR #997 review completed.

  • Applied label size/XL.
  • Left inline comments on:
    • src/app/[locale]/settings/providers/_components/allowed-model-rule-editor.tsx#L215 – exact-model picker can bypass the 100-rule cap.
    • src/app/[locale]/settings/providers/_components/model-multi-select.tsx#L211 – unmasked provider key fetched on mount (secret exposure surface + noisy audit logging); recommend lazy-load and/or server-side key resolution.
    • tests/unit/settings/providers/model-multi-select.test.tsx#L38 – upstream-success branch is untested; add a focused unit test.
  • Submitted the required markdown summary review via gh pr review --comment.

@ding113
Copy link
Copy Markdown
Owner Author

ding113 commented Apr 5, 2026

第二轮修复已推送,已处理这轮 review 里关于 allowlist 规则上限、provider 去重、lazy-load upstream 拉取、以及 upstream 成功分支测试的建议。

当前 PR 剩余唯一红灯仍是 tests/unit/actions/dispatch-simulator.test.ts 的两条已知失败:

  • skips model allowlist filtering for resource-style requests without model
  • accepts gemini-cli format and keeps gemini-cli providers eligible

这两条我已在当前 dev 基线 8e0f55fc 上单独复跑,结果同样失败,因此不是本 PR 引入的回归。其余与本次改动相关的 lint / typecheck / build / targeted tests / API / integration checks 均已通过。

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: 728c51c4ae

ℹ️ 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 +220 to +223
if ((!resolvedKey || resolvedKey.includes("***")) && providerId) {
const keyResult = await getUnmaskedProviderKey(providerId);
if (keyResult.ok && keyResult.data?.key) {
resolvedKey = keyResult.data.key;
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 Fetch provider key only when upstream request can run

This branch unconditionally calls getUnmaskedProviderKey(providerId) whenever providerId is present and the form key is blank/masked, before checking whether providerUrl is even configured. In edit mode this means simply opening the picker can pull the full secret key into client memory and emit a false "Admin viewed provider key" audit event, even when the component will immediately fall back to local catalog data and never attempt an upstream fetch. Move key unmasking inside the providerUrl && ... path (or perform upstream fetch server-side by provider id) so secrets are only retrieved when actually needed.

Useful? React with 👍 / 👎.

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: Backlog

Development

Successfully merging this pull request may close these issues.

1 participant