Skip to content

feat(providers): support arbitrary model lists and optional fast models - #159

Open
AIR-Xie wants to merge 3 commits into
mainfrom
feat/issue-120-provider-model-lists
Open

AIR-Xie wants to merge 3 commits into
mainfrom
feat/issue-120-provider-model-lists

Conversation

@AIR-Xie

@AIR-Xie AIR-Xie commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Summary / 概要

This PR resolves #120 by replacing the four visible Claude role slots with an ordered provider model list and an optional provider-local fast model. It adds model discovery, migration, runtime role mapping, editable display names, and cross-provider session selection.

本 PR 解决 #120:将用户可见的四个 Claude 角色槽位替换为有序的 Provider 模型列表,并支持 Provider 内可选的快速模型;同时加入模型发现、旧数据迁移、运行时角色映射、显示名称编辑和跨 Provider 会话选模。

Issue #120 implementation — English

Issue requirement Implementation
1. Data model, migration, presets SavedProvider.models is now an ordered { id, name? }[]; fastModel optionally references an id in that list. Presets use the same shape. Legacy main, sonnet, opus, haiku values migrate in that order, are deduplicated, and set fastModel from a distinct haiku.
2. Server-side model discovery Discovery goes through the server/upstream client so credentials stay off the browser. OpenAI-compatible and Anthropic /v1/models responses preserve ids and optional names; Anthropic discovery follows has_more, last_id, and after_id, requests limit=1000, and has a 10-page safety cap.
3. Runtime role mapping The selected session model fills ANTHROPIC_MODEL, Sonnet, and Opus roles. The provider fast model fills Haiku and small-fast roles, falling back to the selected model. Titles and scheduled tasks follow the same provider-local rules.
4. Desktop editor and picker Users can fetch candidates, add arbitrary ids, edit display names, remove/reorder models, select a fast model, and choose models across providers. Edit-mode discovery uses the unsaved form values currently on screen.
5. Tests Coverage includes legacy migration, duplicates and empty values, idempotency, model discovery and errors, Anthropic pagination, names and ids, runtime mapping, picker behavior, settings JSON preservation, and fast-model update validation.

Review follow-ups implemented

  1. Unrelated Settings JSON edits now preserve all existing model rows, names, order, and fast-model selection; only changed runtime ids are merged.
  2. Provider mode no longer reads or writes the legacy managed model/modelContext fields, and ids containing : such as qwen3.5:9b are kept intact.
  3. Anthropic model discovery now supports pagination with a bounded loop and cursor validation.
  4. Editing a provider discovers models from the current form values; the obsolete saved-provider discovery endpoint and client/store methods were removed.
  5. Discovery shows localized success counts, empty-result status, and accessible alert errors in English and Chinese.
  6. Migration and provider-update tests cover empty input, duplicates, idempotency, fast-model removal/null, and rejection of an explicitly invalid fast model.
  7. Unused picker parameters and translation keys were removed. The two point-in-time report files were deleted and the implementation/verification record was moved into this PR description.

Issue #120 开发对照 — 中文

Issue 要求 开发内容
1. 数据模型、迁移和预设 SavedProvider.models 改为有序的 { id, name? }[];fastModel 可选,并且必须引用列表中的模型 ID。预设使用相同结构。旧版 main、sonnet、opus、haiku 按该顺序迁移、去重,并在 haiku 与 main 不同时设置快速模型。
2. 服务端模型发现 模型发现由服务端 upstream 客户端完成,API key 不进入浏览器。OpenAI-compatible 和 Anthropic /v1/models 均保留模型 ID 和可选名称;Anthropic 支持 has_more、last_id、after_id 分页,使用 limit=1000,并设置 10 页安全上限。
3. 运行时角色映射 会话选中的模型写入主模型、Sonnet 和 Opus 运行时角色;Provider 的快速模型写入 Haiku 和 small-fast 角色,未配置时回退到会话模型。标题和定时任务使用相同的 Provider 内映射规则。
4. 桌面端编辑器与选择器 支持获取候选模型、添加任意 ID、修改显示名称、删除/排序模型、选择快速模型,以及跨 Provider 选择会话模型。编辑 Provider 时,模型发现使用屏幕上尚未保存的表单值。
5. 测试 覆盖旧结构迁移、重复项和空值、迁移幂等性、模型发现和错误路径、Anthropic 分页、名称与 ID、运行时映射、选择器、Settings JSON 数据保留及快速模型更新校验。

Review 后续 7 项修复

  1. 修改无关 Settings JSON 字段时,保留已有模型行、名称、顺序和快速模型;只有确实变化的运行时模型 ID 会被合并。
  2. Provider 模式不再读取或写入旧的 model/modelContext;qwen3.5:9b 等包含冒号的模型 ID 保持完整。
  3. Anthropic 模型发现增加带上限和游标校验的分页。
  4. 编辑态发现模型使用当前表单值;删除旧的“按已保存 Provider 发现模型”服务端接口以及桌面端方法。
  5. 增加中英文的发现成功数量、空结果状态和可访问的错误提示。
  6. 迁移和 Provider 更新测试新增空输入、重复项、幂等性、快速模型删除/null,以及显式非法快速模型拒绝场景。
  7. 删除未使用的选择器参数和翻译键;删除两份阶段性报告文件,将开发与验证记录统一移入 PR 描述。

Schema / Schema 说明

type ProviderModel = {
  id: string
  name?: string
}

type SavedProvider = {
  // other provider fields...
  models: ProviderModel[]
  fastModel?: string
}

Rules / 规则:

  • models is ordered; the first model is the provider default when no session/task override exists. / models 有顺序;没有会话或任务覆盖值时,第一项是 Provider 默认模型。
  • Model ids must be non-empty and unique inside a provider. / 同一 Provider 内模型 ID 必须非空且唯一。
  • name is display-only; runtime requests always use id. / name 只用于显示,运行时请求始终使用 id。
  • fastModel, when present, must equal an id in models. / fastModel 存在时必须引用 models 中的 ID。
  • Legacy migration order is main → sonnet → opus → haiku, with duplicate ids removed. / 旧结构按 main → sonnet → opus → haiku 迁移并去重。

Runtime mapping / 运行时映射:

Runtime role Value / 值
ANTHROPIC_MODEL, ANTHROPIC_DEFAULT_SONNET_MODEL, ANTHROPIC_DEFAULT_OPUS_MODEL selected session/task model id / 会话或任务选中的模型 ID
ANTHROPIC_DEFAULT_HAIKU_MODEL, ANTHROPIC_SMALL_FAST_MODEL provider fastModel, otherwise selected model / Provider 快速模型,未配置则使用选中模型

Validation / 验证

Automated / 自动化:

  • Desktop TypeScript check: passed. / Desktop TypeScript 检查通过。
  • Review-focused desktop tests: 21 passed, 0 failed. / Review 相关桌面端测试 21 个通过、0 个失败。
  • Focused server tests: 174 passed, 3 skipped, 0 failed. / 服务端定向测试 174 个通过、3 个跳过、0 个失败。
  • Vite production build: passed, 4,419 modules transformed. / Vite 生产构建通过,转换 4,419 个模块。
  • git diff --check: passed. / git diff --check 通过。

Manual Ollama validation / Ollama 人工验证:

  • Local Ollama models used: qwen3.8:latest, qwen3.5:9b, and qwen3.5:4b.
  • 本地 Ollama 使用的模型:qwen3.8:latest、qwen3.5:9b、qwen3.5:4b。
  • PASS: changing unrelated Settings JSON values preserved five model rows, names, order, and fast-model selection. / PASS:修改无关 Settings JSON 值后,五条模型、名称、顺序和快速模型均保持不变。
  • PASS: session runtime kept the complete colon-containing id qwen3.5:9b; the conversation succeeded and legacy model/modelContext remained unchanged. / PASS:会话运行时保留完整的 qwen3.5:9b;对话成功,旧 model/modelContext 未被改写。
  • PASS: edit-mode discovery used the current unsaved URL and showed both error and success/count states. / PASS:编辑态模型发现使用当前未保存的 URL,并正确显示错误与成功/数量状态。
  • PASS: removing the selected fast model cleared the fast-model setting. / PASS:删除已选快速模型后,快速模型设置被正确清除。

Pending / 待验证:

  • Real OpenAI/OpenAI-compatible API validation is pending. / 真实 OpenAI/OpenAI-compatible API 仍待验证。
  • Real Anthropic API validation, including live pagination, is pending; pagination is covered by automated two-page tests. / 真实 Anthropic API(包括实机分页)仍待验证;两页分页场景已有自动化测试。
  • Native Windows Tauri sidecar rebuild was blocked by Bun target-package download error CERT_NOT_YET_VALID; manual validation used the current backend and frontend source in browser mode. / Windows 原生 Tauri sidecar 重编译因 Bun 目标包下载出现 CERT_NOT_YET_VALID 而受阻;人工验证使用当前前后端源码的浏览器模式完成。

Closes #120

@GoDiao GoDiao left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks for taking on #120! This is a big one, and the core of it is right. Every model role is now read from the ordered list. The role env mapping follows the table in the issue. The migration keeps every id. The settings UI covers add, rename, remove and fast model. We went through it against the issue line by line, and almost everything is there. Below is what we'd like changed before merging, most important first.

1. Editing the settings JSON wipes the model list (must fix).
ProviderFormModal.tsx L1046-1062: any valid edit to the settings JSON textarea rebuilds models from the four env keys. That happens even for an unrelated key like skipWebFetchPreflight. updateSettingsJsonModels only ever writes the main and fast ids into those keys, so a 5-row list with display names collapses to at most 2 rows, without names, and that's what gets saved. Only touch the list when the pasted ids actually differ from the current mapping, and merge them in instead of replacing:

const current = normalizeModels(models)
const runtime = runtimeModelMapping(current, fastModel)
if (pastedMain && (pastedMain !== runtime.main || (pastedFast || pastedMain) !== runtime.fast)) {
  const head = current.find((m) => m.id === pastedMain) ?? { id: pastedMain }
  const merged = [head, ...current.filter((m) => m.id !== pastedMain)]
  if (pastedFast && !merged.some((m) => m.id === pastedFast)) merged.push({ id: pastedFast })
  setModels(merged)
  setFastModel(pastedFast && pastedFast !== pastedMain ? pastedFast : '')
}

Please add a test as well: a list of 5 named rows, edit an unrelated key, and assert that the list is unchanged.

2. Provider mode reads and writes model in settings.json again (must fix).
#150 settled this rule (docs/session-state-tech-design.md:553): in provider mode, the managed model / modelContext are neither read nor written, because a stale value there used to override the provider's model. This PR brings it back:

  • GET /api/models/current returns the saved model first, without checking that it belongs to the active provider.
  • PUT writes it again, and splits it at :, so qwen3.6:27b becomes qwen3.6 + context 27b.
  • resolveProviderDefaultModel gained an explicitModel branch.

Example: a user upgrading with claude-haiku-4-5 · 1m left in settings.json sees that model in the input box, while sessions run on the provider's first model. The test that pinned this, "should use the active provider main model over stale saved model keys" in settings.test.ts, was rewritten into a different case.

Please go back to main's behavior:

  • In GET's provider branch, use resolveProviderDefaultModel(activeProvider, managedSettings) and no context tier.
  • In PUT, return { ok: true, model: modelId } early when a provider is active, before any splitting.
  • Drop the explicitModel branch.
  • Restore the stale-model test.
  • Update the models.main wording at line 553 of that design doc to the list.

3. Anthropic model discovery stops at 20.
fetchUpstreamModels (upstream/client.ts L118-173) makes one GET /v1/models call. The Anthropic Models API pages with a default limit of 20 (has_more / last_id / after_id, limit up to 1000), so Anthropic-format providers get a silently truncated list. Send ?limit=1000 for the Anthropic format, and follow has_more with a small page cap. Please add a two-page test.

4. "Fetch models" in edit mode can hit the old endpoint.
When the API key field is empty, for example on an Ollama provider, handleDiscoverModels calls discoverProviderModels(provider.id). The server then uses the saved baseUrl / apiFormat, not what's on screen. Change the base URL, click Fetch, and you get the old host's models. Since saving with an empty key already clears the key, this branch doesn't buy anything. Always call discoverModels with the current form values. If nothing else uses POST /api/providers/:id/models and its store/api methods, they can go too.

5. A successful fetch shows nothing.
Results only land in the hidden <datalist>, so a fetch that returned 40 models, one that returned 0, and not clicking at all look the same. Please add a line under the button with role="status", such as "40 models found" or "No models returned" (en and zh keys). Also add role="alert" on the error text.

6. Tests for the migration and the fast-model update path.

  • persistence-upgrade.test.ts: the issue asks for "duplicates and an empty haiku". The fixture used to have haiku: '', and this PR changed it to 'model-fast', so that case is now gone. Please cover these cases. Then run the upgrade a second time and assert that nothing is migrated and no second .bak file is written; this runs on every launch.

    • { main: 'm', haiku: '' }: the list is [m], with no fastModel.
    • { main: 'm', haiku: 'm', sonnet: 's', opus: 'm' }: the list is [m, s], with no fastModel.
    • All four slots empty: the list is [].
  • providers.test.ts, updateProvider: there's nothing for fastModel yet, though every desktop save sends it. Please cover two cases: removing the fast model's row clears it, and fastModel: null clears it. While there, a small asymmetry: an explicit fastModel that isn't in the new models is silently dropped on update, but rejected on create. This makes them match:

    if (input.fastModel === null || (input.fastModel === undefined && input.models !== undefined && !updated.models.some((m) => m.id === updated.fastModel))) {

7. Small cleanups.

  • ModelSelector.tsx: buildProviderChoices no longer uses _availableModels. Drop the parameter, the argument and the useMemo dep.
  • en.ts / zh.ts: these 10 keys are now unused, so please remove them from both files:
    • mainModel, haikuModel, sonnetModel, opusModel
    • the four *ContextWindow keys
    • modelMapping, sameAsMain
  • docs/ISSUE_120_IMPLEMENTATION_REPORT_en.md / _zh.md: these are point-in-time status notes ("Complete locally", "pending", a report date), and they'll go stale right after merge. Please delete them, and move the requirement table and the list of pending real-API checks into the PR description.

No need to touch the CHANGELOG; we'll add the entry after merging. Once these are in, we'll run the manual checks on our side against real OpenAI-compatible and Anthropic providers. Thanks again, this unblocks #119 and #105!

@GoDiao GoDiao left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks for the quick turnaround! We went through 67e6bda against all seven points, and every one is addressed, with the tests we asked for. Two things left, both in ProviderFormModal.tsx:

1. Typing a model id in the settings JSON leaves a row for every prefix (our fault).
This comes from the merge snippet we suggested. We didn't think about typing. The textarea syncs on every keystroke, and every intermediate value inside the quotes is valid JSON. Each keystroke compares the pasted id with the head of the list from the previous keystroke. So it inserts a new head and keeps the old one. Changing A to gpt-4o in [A, B, C] ends with [gpt-4o, gpt-4, gpt-, gpt, gp, g, A, B, C]. Backspacing behaves the same way.

Merging against a snapshot taken when the textarea gains focus fixes it. Each keystroke then re-merges onto the same base:

const modelsSnapshotRef = useRef<{ models: ProviderModel[]; fastModel: string } | null>(null)

<textarea
  onFocus={() => { modelsSnapshotRef.current = { models, fastModel } }}
  onBlur={() => { modelsSnapshotRef.current = null }}
  ...

const base = modelsSnapshotRef.current ?? { models, fastModel }
const currentModels = normalizeModels(base.models)
const runtime = runtimeModelMapping(currentModels, base.fastModel)

Please add a test as well. Fire changes for g, gp, gpt on ANTHROPIC_MODEL, then assert the list gained only the final id.

2. Fetched models outlive the endpoint they came from.
After a fetch, changing the base URL, API key, API format or auth strategy keeps the "N models found" line and the suggestions from the old endpoint. Today only a preset change clears them. A fetch that is still in flight can also land after an edit and write the old endpoint's models back. Please clear the result on those edits, and drop responses from a superseded request:

const discoveryRequestRef = useRef(0)
const resetDiscovery = () => {
  discoveryRequestRef.current++
  setDiscoveredModels([])
  setDiscoveryError(null)
  setDiscoveryCount(null)
}

Call resetDiscovery() in the four connection handlers and in the preset change. In handleDiscoverModels, take const requestId = ++discoveryRequestRef.current. After the await, and in catch / finally, return early when requestId !== discoveryRequestRef.current. A test that fetches and then edits the base URL should assert that the status line and the suggestions are gone.

Optional while you're there: fetchModels in providerService.ts now has only one caller, discoverModels, so it can be inlined.

Once these are in, we'll run the real-API checks on our side. Thanks!

@AIR-Xie

AIR-Xie commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator Author

Final review follow-up and manual reproduction record / 最终 Review 修复与人工复现记录

Implemented in 958af98 (fix(providers): stabilize model discovery editing).

Requirement completion / Requirement 完成情况

  • The seven items from the first owner review remain complete; the owner confirmed all seven against 67e6bda.
  • Latest requirement 1 is complete: Settings JSON model merging now uses the model-list snapshot captured when the textarea receives focus. Progressive typing such as g → gp → gpt is always merged against the same snapshot, so intermediate prefixes cannot accumulate as model rows.
  • Latest requirement 2 is complete: changing the preset, Base URL, API key, API format, or auth strategy clears discovery results and invalidates the in-flight request. Superseded success, error, and completion updates are ignored.
  • Manual-review feedback is also complete: model discovery automatically recommends a fetched model when the Model ID is empty; the picker uses the shared styled Dropdown used by API Format; stale native/browser suggestions are no longer used; genuinely hand-entered model ids remain editable.
  • The optional suggestion to inline providerService.fetchModels was intentionally left unchanged because it is not a behavioral or merge requirement.

Automated checks for this commit:

  • ProviderFormModal.test.tsx: 12/12 passed, including progressive JSON typing and superseded discovery responses.
  • Desktop TypeScript (tsc --noEmit): passed.
  • Vite production build: passed, 4,419 modules transformed.
  • git diff --check: passed.

Manual environment:

  • Local Ollama: http://127.0.0.1:11434
  • Models: qwen3.8:latest, qwen3.5:9b, qwen3.5:4b
  • Current backend and frontend source were run in browser mode.

A — Settings JSON progressive model editing

Steps:

  1. Open Settings → Providers and edit the Ollama provider containing the existing ordered/named model list and fast-model selection.
  2. Focus the Settings JSON textarea. This focus event is important because it captures the merge snapshot.
  3. Change ANTHROPIC_MODEL to qwen3.5:9b through normal character-by-character editing.
  4. Save, close, and reopen the provider.

Expectations:

  • Exactly one complete qwen3.5:9b row exists.
  • No intermediate prefix rows such as q, qw, qwen, or qwen3.5: exist.
  • qwen3.5:9b becomes the first/default model because ANTHROPIC_MODEL selects the main model.
  • All original model rows remain present.
  • Existing display names are preserved.
  • The relative order of the other models is unchanged.
  • The selected fast model is unchanged.

Result: PASS. qwen3.5:9b became the default model, no prefix rows were created, the fast model stayed correct, and all other models retained their relative order and names.

A — Settings JSON 连续输入模型 ID

步骤:

  1. 打开 Settings → Providers,编辑包含原有有序模型列表、显示名称和快速模型的 Ollama Provider。
  2. 点击并聚焦 Settings JSON 文本框。聚焦动作会保存本次编辑使用的模型列表快照。
  3. 按正常逐字符输入方式,将 ANTHROPIC_MODEL 改成 qwen3.5:9b。
  4. 保存、关闭并重新打开 Provider。

预期:

  • 列表中只出现一个完整的 qwen3.5:9b。
  • 不出现 q、qw、qwen、qwen3.5: 等输入过程中的前缀模型行。
  • qwen3.5:9b 成为第一项/默认模型,因为 ANTHROPIC_MODEL 表示主模型。
  • 原有模型行全部保留。
  • 原有显示名称不丢失。
  • 其他模型的相对顺序不变。
  • 快速模型选择不变。

结果:PASS。qwen3.5:9b 正确成为默认模型,没有生成前缀行;快速模型、其他模型的相对顺序和名称均保持正确。


B — Automatic recommendation and styled model picker

Steps:

  1. Open the Ollama provider and confirm Base URL http://127.0.0.1:11434.
  2. Leave Model ID empty and click Fetch models.
  3. After discovery completes, inspect the Model ID field and open its dropdown.
  4. Change a connection field such as Base URL before fetching again.
  5. Restore the Ollama Base URL and fetch again.

Expectations:

  • Discovery reports three models.
  • With an empty Model ID, one fetched model is automatically placed in the Model ID field as the recommendation; the user does not need to type it manually.
  • The dropdown uses the same shared visual structure as the API Format dropdown, shows model names/ids, and marks the recommendation.
  • Free-text Model ID entry still works.
  • Changing a connection field immediately removes the old count and old dropdown candidates.
  • The native browser datalist/history is not used, so candidates from 11434 cannot remain as stale Model ID suggestions for another endpoint.
  • A fresh fetch after restoring 11434 shows only the current Ollama models.

Result: PASS. Automatic recommendation, dropdown appearance, editable Model ID, and candidate clearing all behaved as expected.

B — 自动推荐与统一样式的模型下拉菜单

步骤:

  1. 打开 Ollama Provider,确认 Base URL 为 http://127.0.0.1:11434。
  2. 保持 Model ID 为空,点击“获取模型”。
  3. 获取完成后检查 Model ID,并展开下拉菜单。
  4. 修改 Base URL 等任一连接字段,暂时不要再次获取。
  5. 恢复 Ollama 地址后重新获取。

预期:

  • 显示找到三个模型。
  • Model ID 为空时,系统自动将一个获取到的模型填入 Model ID 作为推荐,不需要人工输入。
  • 下拉菜单复用“API 格式”下拉菜单的视觉结构,显示模型名称/ID,并标记推荐项。
  • Model ID 仍可自由手工输入。
  • 修改连接字段后,旧数量提示和旧候选立即清除。
  • 不再使用浏览器原生 datalist/history,因此来自 11434 的旧候选不会残留到另一个 endpoint。
  • 恢复 11434 并重新获取后,只显示当前 Ollama 模型。

结果:PASS。自动推荐、下拉菜单样式、自由输入和旧候选清除均符合预期。


C — Ignore a delayed response from a superseded endpoint

Start a delayed test server from Windows PowerShell:

$bun = 'E:\Repo\PhD\GoDiao\windows\dreamcoder-main\.pr-worktree\bun-runner\package\bin\bun.exe'

& $bun -e "Bun.serve({ hostname: '127.0.0.1', port: 14567, async fetch() { await Bun.sleep(5000); return Response.json({ data: [{ id: 'stale-delayed-model' }], has_more: false }); } }); console.log('Delayed model server: http://127.0.0.1:14567')"

Server-start expectation: the console prints Delayed model server: http://127.0.0.1:14567, stays running, and does not return to the PowerShell prompt.

Steps and expectations:

  1. Open the Ollama provider without saving temporary test values. Clear Model ID to make an incorrect auto-selection obvious.
  2. Set Base URL to http://127.0.0.1:14567.
    • Expected immediately: previous discovery count, candidates, and errors are cleared; Fetch models is available.
  3. Click Fetch models.
    • Expected during the first five seconds: loading is visible; no result count, stale-delayed-model, or auto-selection is present.
  4. Within five seconds, change Base URL back to http://127.0.0.1:11434.
    • Expected immediately: loading stops; Fetch models becomes available; no result count/error remains; Model ID stays empty; no delayed candidate is visible.
  5. Wait at least 6–8 seconds so the old server has returned.
    • Expected after the old response: absolutely no UI state is restored by it. There is no 1 model found, no stale-delayed-model in the field or dropdown, no old error, and loading does not restart. Base URL remains 11434.
  6. Click Fetch models against Ollama.
    • Expected: discovery works normally, reports three models, and shows only qwen3.8:latest, qwen3.5:9b, and qwen3.5:4b; stale-delayed-model is absent.
  7. Cancel the provider form instead of saving test changes, then stop the delayed server with Ctrl+C.

Result: PASS. Editing Base URL immediately invalidated the in-flight request, its delayed success response was discarded, and the following Ollama discovery remained functional.

C — 丢弃已被连接变化取代的延迟响应

在 Windows PowerShell 中使用上面的命令启动延迟服务器。成功启动时应打印 Delayed model server: http://127.0.0.1:14567,进程保持运行且不会返回 PowerShell 提示符。

步骤与预期:

  1. 打开 Ollama Provider,不保存测试期间的临时配置;清空 Model ID,以便明显观察错误的自动推荐。
  2. 将 Base URL 改为 http://127.0.0.1:14567。
    • 立即预期:之前的模型数量、候选和错误全部清除;“获取模型”按钮可用。
  3. 点击“获取模型”。
    • 前五秒预期:界面显示加载;不显示结果数量、stale-delayed-model 或自动选择。
  4. 在五秒内将 Base URL 改回 http://127.0.0.1:11434。
    • 立即预期:加载立即停止;按钮恢复可用;没有数量或错误提示;Model ID 保持为空;没有延迟候选。
  5. 等待至少 6–8 秒,让旧服务器完成响应。
    • 旧响应完成后的预期:界面完全不被旧响应回写;不显示“已找到 1 个模型”,Model ID 和下拉菜单都没有 stale-delayed-model,不显示旧错误,加载状态不会重新出现,Base URL 仍为 11434。
  6. 对 Ollama 再次点击“获取模型”。
    • 预期:正常找到三个模型,仅包含 qwen3.8:latest、qwen3.5:9b 和 qwen3.5:4b,不存在 stale-delayed-model。
  7. 取消 Provider 表单,不保存临时测试值;在延迟服务器窗口按 Ctrl+C 退出。

结果:PASS。修改 Base URL 后,在途请求立即失效;延迟成功响应被丢弃;随后对 Ollama 的模型发现仍然正常。

Remaining live-provider scope / 剩余实机范围

  • Local Ollama A/B/C validation: complete and passed.
  • Real OpenAI/OpenAI-compatible API validation: still pending.
  • Real Anthropic API validation, including live pagination: still pending; pagination has automated two-page coverage.

@GoDiao GoDiao left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

English

Thanks for the follow-up and for the detailed manual test record! The discovery invalidation works as described, and the server side looks good end to end. We went through the whole PR again. Four things left, all in ProviderFormModal.tsx:

1. Make the model keys in the settings JSON read-only (a change of plan on our side, sorry).
The snapshot fixed the prefix rows, but one more bug remains. Changing A to AX and back to A leaves [AX, A, B] in the list, because nothing runs once the ids match the snapshot. This is the third bug we've hit in the JSON → list sync. Each one came from our own suggestions, so we'd rather remove the sync than patch it again. The keys don't do anything at runtime anyway:

  • The CLI env is built from the list (buildProviderManagedEnv).
  • Saving the active provider rewrites those keys in settings.json from the list (syncToSettings).

So the model list is the only source. In the JSON textarea, treat these keys as generated:

  • ANTHROPIC_MODEL
  • ANTHROPIC_DEFAULT_HAIKU_MODEL
  • ANTHROPIC_SMALL_FAST_MODEL
  • ANTHROPIC_DEFAULT_SONNET_MODEL
  • ANTHROPIC_DEFAULT_OPUS_MODEL
  • CLAUDE_CODE_MODEL_CONTEXT_WINDOWS

Concretely:

  • No reverse sync. onChange no longer reads these keys back into models, fastModel or the context inputs. Remove the whole merge block, modelsSnapshotRef, and the onFocus / onBlur handlers.
  • Write them back from the list. After parsing, run the same updateSettingsJsonModels / updateSettingsJsonModelContextWindows writes before setSettingsJson. The JSON then always shows what the list says, and an edit to one of these keys snaps back.
  • Add a hint. Put a short line under the textarea, in en and zh, along the lines of "Model variables are generated from the model list above".
  • Tests. Replace the progressive-typing, unrelated-key and merge tests with one test: edit ANTHROPIC_MODEL (and the context-windows key) in the JSON, save, and assert that models, fastModel and modelContextWindows are unchanged.

Base URL, API key, auth strategy and the other keys stay editable in the JSON as they are now.

2. Connection changes made in the JSON don't reset discovery.
The five form handlers call resetDiscovery(), but the JSON textarea also sets the base URL, API key and auth strategy, and it doesn't. Editing ANTHROPIC_BASE_URL in the JSON keeps the old endpoint's models and count. An in-flight fetch can still land, because the request id isn't bumped. onChange runs on every keystroke, so only reset when a value actually changes:

const connectionChanged =
  (env.ANTHROPIC_BASE_URL && env.ANTHROPIC_BASE_URL !== baseUrl) ||
  (validNextApiKey && validNextApiKey !== apiKey) ||
  (nextAuthStrategy && nextAuthStrategy !== authStrategy)
if (connectionChanged) resetDiscovery()

Here validNextApiKey is the key after the placeholder checks.

3. The model picker needs to be a filterable combobox.
A styled picker is the right direction. The shared Dropdown is a click-to-toggle menu, though, and it doesn't work as a text input's suggestion list:

  • It doesn't filter as you type. Endpoints like OpenRouter return hundreds of models in a 240px list.
  • A keyboard user can't open it. It only listens for clicks on its wrapper, so Enter and Space in an input do nothing. There's also no aria-expanded.
  • Clicking into the input to place the caret toggles the list.
  • The input moves between the bare branch and the Dropdown branch when results arrive or reset. It remounts and loses focus while you type.

Please add a small combobox component for this. The branch picker in components/shared/RepositoryLaunchControls.tsx (L250-320, L584) is a good reference in our codebase:

  • The Model ID input itself is the filter. It always renders in the same place in the tree, so it never remounts.
  • With discovered models present, the list opens on focus, on typing and on ArrowDown. It closes on Escape, on an outside click and on selection.
  • It shows the models whose id or name contains the input text, case-insensitive.
  • ArrowUp and ArrowDown move the highlight. Enter picks the highlighted model, and otherwise leaves the typed text alone. Free-text ids keep working.
  • On the input: role="combobox", aria-expanded, aria-controls and aria-activedescendant. On the list: role="listbox", with role="option" items.
  • The styling matches Dropdown and the branch menu.

Tests to add: filtering, picking with the keyboard, free text still being saved as typed, and focus staying in the input when a fetch completes.

4. Please drop the auto-recommend.
The auto-fill takes the first fetched model that isn't in the list yet. That's just the upstream order. A provider like OpenRouter returns hundreds of models, and the first one isn't a sensible pick. Since the combobox opens on focus, the user can choose the model themselves. Please remove the auto-fill, recommendedDiscoveredModel and the auto_awesome marker.

These are suggestions, not the only way to do it. If you have a better approach for any of them, we're happy to discuss it here.

Once these are in, we'll run the real-API checks on our side. Thanks again for sticking with this one!


中文

感谢跟进,也感谢这么详细的人工测试记录!模型获取的请求作废逻辑和描述的一样有效,服务端整体也没问题。我们把整个 PR 又看了一遍,还剩四件事,都在 ProviderFormModal.tsx:

1. 设置 JSON 里的模型相关 key 改成只读(这是我们这边改了方案,抱歉)。

快照解决了前缀行的问题,但还剩一个 bug:把 A 改成 AX 再改回 A,列表里还是 [AX, A, B],因为 id 和快照一致后就不再执行任何逻辑。这已经是"JSON 反写列表"出的第三个 bug,而且每个都来自我们自己的建议,所以我们宁可去掉这套同步,也不想再打补丁。而且这些 key 在运行时本来就不起作用:

  • CLI 的环境变量是从模型列表生成的(buildProviderManagedEnv);
  • 保存当前启用的服务商时,settings.json 里的这些 key 会按列表重写(syncToSettings)。

所以模型列表是唯一的来源。在 JSON 文本框里,下面这些 key 当作"由列表生成"处理:

  • ANTHROPIC_MODEL
  • ANTHROPIC_DEFAULT_HAIKU_MODEL
  • ANTHROPIC_SMALL_FAST_MODEL
  • ANTHROPIC_DEFAULT_SONNET_MODEL
  • ANTHROPIC_DEFAULT_OPUS_MODEL
  • CLAUDE_CODE_MODEL_CONTEXT_WINDOWS

具体来说:

  • 不再反向同步:onChange 不再把这些 key 读回 models、fastModel 或上下文输入。整段合并逻辑、modelsSnapshotRef、onFocus / onBlur 都删掉。
  • 从列表写回去:解析之后、setSettingsJson 之前,用现有的 updateSettingsJsonModels / updateSettingsJsonModelContextWindows 再写一遍。这样 JSON 显示的始终和列表一致,改这些 key 会弹回原值。
  • 加一行提示:在文本框下面加一句简短说明(中英文),意思是"模型相关变量由上方的模型列表生成"。
  • 测试:把逐字输入、改无关 key、合并这三个测试换成一个:在 JSON 里改 ANTHROPIC_MODEL(以及上下文窗口那个 key),保存,断言 models、fastModel、modelContextWindows 都没变。

base URL、API key、认证方式和其他 key 在 JSON 里照旧可以改。

2. 在 JSON 里改连接信息不会清空获取结果。

表单上的五个处理函数都会调用 resetDiscovery(),但 JSON 文本框也会设置 base URL、API key 和认证方式,却没有调用。在 JSON 里改 ANTHROPIC_BASE_URL 后,旧地址的模型和数量还留着;正在进行的请求也还可能写回来,因为请求编号没有递增。onChange 每敲一个键都会执行,所以只在值真的变了时才清空(代码见英文部分),其中 validNextApiKey 指排除占位符之后的 key。

3. 模型选择器要做成能筛选的 combobox。

用我们自己风格的选择器是对的方向。但共享的 Dropdown 是点击开关的菜单,不适合做输入框的候选列表:

  • 不能边打字边筛选。OpenRouter 这类地址会返回几百个模型,全挤在 240px 高的列表里。
  • 只用键盘打不开。它只监听外层的点击,在输入框里按回车或空格没有反应,也没有 aria-expanded。
  • 点进输入框放光标时,列表会跟着开关。
  • 拿到结果或清空时,输入框会在两种结构之间切换,被重建后打字时会丢焦点。

请为此写一个小的 combobox 组件。仓库里的分支选择器 components/shared/RepositoryLaunchControls.tsx(L250-320、L584)可以参考:

  • Model ID 输入框本身就是筛选框,始终渲染在同一位置,不会被重建。
  • 有获取结果时,获得焦点、打字或按 ↓ 打开列表;按 Esc、点外面或选中后关闭。
  • 显示 id 或名字包含输入内容的模型,不区分大小写。
  • ↑↓ 移动高亮,回车选中高亮项;没有高亮项时不改动用户输入的文字。手动输入任意 id 仍然有效。
  • 输入框加 role="combobox"、aria-expanded、aria-controls、aria-activedescendant;列表加 role="listbox",每项加 role="option"。
  • 样式和 Dropdown、分支菜单一致。

要加的测试:筛选、键盘选择、手动输入的内容按原样保存、获取完成时焦点仍在输入框里。

4. 请去掉自动推荐。

自动填入的是获取结果里第一个还不在列表中的模型,这只是上游返回的顺序。OpenRouter 这类服务商有几百个模型,取第一个并不合适。新的 combobox 获得焦点就会展开,用户可以自己挑。请删掉自动填入、recommendedDiscoveredModel 和 auto_awesome 标记。

以上只是我们的建议,不是唯一做法。如果你对其中任何一条有更好的思路,欢迎在这里一起讨论。

这些改完后,我们这边会跑真实 API 的手测。再次感谢你一直跟进这个 PR!

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Let providers have any number of models, with an optional fast model

2 participants