Skip to content

feat: restore provider whitelist model picker#997

Merged
ding113 merged 3 commits intodevfrom
feat/provider-whitelist-model-picker
Apr 6, 2026
Merged

feat: restore provider whitelist model picker#997
ding113 merged 3 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 on the provider allowlist editor. The picker integrates cleanly with the existing rule-based allowlist system from #996: selections from the picker are added as exact-match rules, while prefix/suffix/contains/regex rules remain editable in the advanced editor below. The duplicate-provider bug flagged in the previous thread has been fixed by using new Set() before iterating.

The only notable finding is a P2 cosmetic issue: the picker trigger initializes loading = true so it displays a spinning loader icon from first render, before the user has ever opened the dropdown. Setting the initial state to false would be more accurate.

Confidence Score: 5/5

Safe to merge — only a P2 cosmetic issue remains.

All remaining findings are P2 style/UX improvements. The core logic for exact-match reconciliation, race-condition handling, auth delegation, and provider-filter deduplication (previous P1 thread) is correct. No data-integrity or runtime-error risks were found.

model-multi-select.tsx — initial loading state initialization.

Important Files Changed

Filename Overview
src/app/[locale]/settings/providers/_components/model-multi-select.tsx New 537-line model picker with upstream/fallback load, race-condition guard, bulk selection, and provider filter; spurious initial loading spinner (P2).
src/app/[locale]/settings/providers/_components/allowed-model-rule-editor.tsx Integrates ModelMultiSelect for exact-match rules; handleExactModelsChange correctly reconciles picker selections with the existing rule list and respects MAX_ALLOWED_MODEL_RULES.
src/app/[locale]/settings/providers/_components/rule-tester-dialog-trigger.tsx New compact dialog trigger wrapping rule testers; small, correct, and well-tested.
src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx Replaces inline collapsible testers with RuleTesterDialogTrigger and passes provider credentials through to AllowedModelRuleEditor.
src/actions/model-prices.ts Adds getAvailableModelCatalog() with admin auth check; getAvailableModelsByProviderType now delegates to it, changing sort order from alphabetical to updatedAt-desc (intentional).
tests/unit/settings/providers/model-multi-select.test.tsx Comprehensive tests covering upstream/fallback paths, selection toggling, bulk actions, and the provider filter dropdown.
tests/unit/settings/providers/rule-tester-dialog-trigger.test.tsx Unit tests for dialog open/close and content rendering.

Sequence Diagram

sequenceDiagram
    participant U as User
    participant P as ModelMultiSelect
    participant A as AllowedModelRuleEditor
    participant UP as fetchUpstreamModels
    participant LC as getAvailableModelCatalog

    U->>P: Opens picker
    P->>UP: fetchUpstreamModels(url, key)
    alt Upstream succeeds
        UP-->>P: models[]
        P->>P: setModelSource(upstream)
    else Upstream fails / no credentials
        UP-->>P: error
        P->>LC: getAvailableModelCatalog()
        LC-->>P: catalog[]
        P->>P: setModelSource(fallback)
    end
    U->>P: Selects models
    P->>A: onChange(selectedModels)
    A->>A: handleExactModelsChange()
    note over A: Keep non-exact rules + selected exact
    A->>A: onChange(nextRules)
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: src/app/[locale]/settings/providers/_components/model-multi-select.tsx
Line: 110

Comment:
**Spurious spinner before first open**

`loading` is initialized to `true`, but `loadModels` is only triggered when the popover opens (`open` becomes `true`). Until the user first opens the picker, the trigger button shows a spinning `Loader2` icon, implying activity when nothing is loading.

```suggestion
  const [loading, setLoading] = useState(false);
```

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

Reviews (3): Last reviewed commit: "test: align dispatch simulator default g..." | Re-trigger Greptile

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 5, 2026

📝 Walkthrough

Walkthrough

新增并本地化“精确匹配模型”选择文案,添加并集成 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
新增导出类型 AvailableModelCatalogItem 与函数 getAvailableModelCatalog()(含排序/过滤/映射逻辑);将 getAvailableModelsByProviderType() 委托给新函数,统一错误与回退语义。
模型多选组件
src/app/[locale]/settings/providers/_components/model-multi-select.tsx
新增客户端组件 ModelMultiSelect:在弹出时异步加载上游模型或回退到本地目录,支持搜索、(回退时)提供商过滤、全选/反转/清除、保留“虚拟”已选项、大小写归一与去重。注意异步、回退与选择合并逻辑。
规则测试对话框触发器
src/app/[locale]/settings/providers/_components/rule-tester-dialog-trigger.tsx
新增 RuleTesterDialogTrigger 组件:封装带 Tooltip 的触发按钮与 Dialog 布局以承载测试器内容。
允许模型规则编辑器
src/app/[locale]/settings/providers/_components/allowed-model-rule-editor.tsx
扩展组件 props 接收 provider 字段,集成 ModelMultiSelect,实现将多选结果与现有规则合并/替换 exact 规则、去重、限制(MAX_ALLOWED_MODEL_RULES=100)并设置错误状态。
路由表单区域
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, tests/unit/actions/dispatch-simulator.test.ts
新增/调整多项测试:为新组件添加单元测试,扩展对 getAvailableModelCatalog 的测试 mock;测试覆盖回退流、提供商过滤、反转选择、对话框触发及少量决策流程断言更新。

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • PR #583:同一文件 src/actions/model-prices.ts 增加/修改导出类型与定价相关逻辑,与本次新增的 catalog API 存在代码级重叠。
  • PR #996:涉及允许模型 UI、ModelMultiSelect 与相同 i18n 键的更改,功能与实现边界高度相关。
  • 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 pull request 描述与变更集相关联,清晰阐述了恢复模型选择器 UI 的目标、问题背景、解决方案及实现细节。

✏️ 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 thread src/app/[locale]/settings/providers/_components/model-multi-select.tsx Outdated
Comment thread src/app/[locale]/settings/providers/_components/model-multi-select.tsx Outdated
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 👍 / 👎.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 6, 2026

🧪 测试结果

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

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

@ding113 ding113 merged commit d78e29f into dev Apr 6, 2026
10 checks passed
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