Merge pull request #5949 from deepseek-harness/worktree/fix-model-catalog-visibility

fix(models): hide unconfigured models and unavailable selection IDs
This commit is contained in:
Yichen Jiang authored and GitHub committed 2026-10-09 16:09:53 +08:00
commit ba41fbdf1d
24 files changed
+193 -65

No files matched your search

@@ -96,14 +96,18 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages sett
await scaffold.ctx.agentDefaultModel.saveSelection({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
await page.reload({ waitUntil: 'load' })
const input = page.locator('[data-composer-input]').first()
const trigger = page.getByRole('button', { name: /^选择模型.*deepseek-official\/deepseek-v4-flash/ })
const trigger = page.getByRole('button', { name: '请选择模型', exact: true })
await trigger.waitFor()
expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({
provider: 'deepseek-official', model: 'deepseek-v4-flash',
})
await expect.poll(() => input.getAttribute('contenteditable')).toBe('true')
await trigger.click()
await page.getByRole('menuitem', { name: /模型/ }).click()
await page.getByRole('menuitemradio', { name: 'Messages Flash', exact: true }).click()
await expect.poll(() => input.getAttribute('contenteditable')).toBe('true')
await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection().provider).toBe('deepseek-official')
await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection()).toMatchObject({
provider: 'deepseek-official', model: 'deepseek-flash',
})
const settings = await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8')
expect(settings).toContain('provider: deepseek-official')
expect(tripwire.pageErrors).toEqual([])
+1 -2
View File
@@ -487,14 +487,13 @@ describe('web e2e: the composer model switch is the default for later sessions',
baseURL: 'https://gateway.origin.example/v1', models: [{ id: START_MODEL, name: 'Origin Large' }] },
} })
const seat = page.getByRole('button', { name: new RegExp(`^选择模型.*${ROUTE}/${MODEL}`) })
const seat = page.getByRole('button', { name: '请选择模型', exact: true })
await seat.waitFor()
expect(await box.isEnabled()).toBe(true)
expect(scaffold.ctx.agentDefaultModel.currentSelection()).toMatchObject({ provider: ROUTE, model: MODEL })
const aria = await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd)
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/default-model/unselected.expected.md', import.meta.url)), aria, webSnapshotMode())
await seat.click()
await page.getByRole('menuitem', { name: /模型/ }).click()
await page.getByRole('menuitemradio').first().click()
await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(true)
expect(tripwire.pageErrors).toEqual([])
@@ -1,5 +1,5 @@
- textbox "描述你想要构建的内容, / 调用指令, @ 文件或对话"
- button "添加文件或调用指令"
- button "访问模式,当前:工作区内修改": 工作区内修改
- button "选择模型,当前 acme-gateway/acme-large": acme-gateway/acme-large
- button "请选择模型"
- button "发送消息" [disabled]
@@ -0,0 +1,2 @@
- group "模型与推理等级":
- status: 暂无可用模型
@@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
@@ -278,20 +279,37 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
// trigger — on the page; the scaffold boots without one.
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd, 'model-fallback-e2e', false)
const modelTrigger = page.getByRole('button', { name: /^选择模型.*deepseek-official\// })
const modelTrigger = page.getByRole('button', { name: '请选择模型', exact: true })
await modelTrigger.waitFor({ timeout: 10_000 })
await modelTrigger.click()
await page.getByRole('menuitem', { name: /模型/ }).click()
expect(await page.getByText('Configured Flash', { exact: true }).count()).toBe(0)
await page.getByRole('menuitemradio', { name: 'Private Preview' }).waitFor({ timeout: 10_000 })
expect(tripwire.warnings).toEqual([])
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('shows an empty model list after removing the API key without replacing the saved default', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-model-catalog-empty'))
await page.keyboard.press('Escape')
const selection = scaffold.ctx.agentDefaultModel.currentSelection()
await scaffold.ctx.credentials.unset(credentialRef('DEEPSEEK_API_KEY'))
const seat = page.getByRole('button', { name: '请选择模型', exact: true })
await seat.waitFor()
await seat.click()
const picker = page.getByRole('group', { name: '模型与推理等级' })
await picker.getByText('暂无可用模型', { exact: true }).waitFor()
expect(await picker.getByRole('menuitemradio').count()).toBe(0)
expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual(selection)
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'empty.expected.md'), await picker.ariaSnapshot(), MODE)
await seat.press('Escape')
await picker.waitFor({ state: 'hidden' })
expect(tripwire.pageErrors).toEqual([])
})
it('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(
SNAPSHOT_DIR,
['welcome.expected.md', 'missing.expected.md', 'models.expected.md', 'default-models.expected.md'],
['welcome.expected.md', 'missing.expected.md', 'models.expected.md', 'default-models.expected.md', 'empty.expected.md'],
)
})
})
@@ -0,0 +1,16 @@
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.1/deepseek-model-discovery/guide.md
/:
en: 059ab5482d52d8b8
zh: 006b65489bc4ec70
/deepseek-api-key-model-visibility:
en: 7b14dc7133967bfd
zh: 4f338ae1e8151feb
/deepseek-api-key-model-visibility/change:
en: ab0b141fbb3167ab
zh: 918f4202223abe44
/deepseek-api-key-model-visibility/migration:
en: 2fda36f7e543f1d0
zh: e8522f256de5adaa
@@ -0,0 +1,17 @@
---
kind: upgrade-guide
description: "DeepSeek API-key model discovery requires a configured credential."
---
# DeepSeek API-key model visibility
English | [中文](guide.zh.md)
## Change
The `deepseek-official` route previously listed its configured models without an API key. It now returns an empty catalog when its credential is missing, so Desktop and Web hide the DeepSeek group. Model discovery reports malformed credentials and credential lookup failures. A saved or default selection absent from the catalog displays “Select model” without changing the stored selection. The independent DeepSeek account route still uses account login.
## Migration
1. To use the API-key route, configure its key in Settings → Models or supply the reference named by `apiKeyEnv` (default `DEEPSEEK_API_KEY`) through the launch environment.
2. Reopen the model selector and confirm the DeepSeek group appears. Custom discovery consumers must handle an empty catalog before credentials are configured; listing models does not verify that the remote API accepts the key.
@@ -0,0 +1,17 @@
---
kind: upgrade-guide
description: "DeepSeek API Key 模型发现要求已配置凭据。"
---
# DeepSeek API Key 模型可见性
[English](guide.md) | 中文
## 变更
`deepseek-official` 路由此前在没有 API Key 时也会列出配置的模型。现在凭据缺失时返回空目录,因此桌面端和 Web 隐藏 DeepSeek 分组。模型发现会报告凭据格式错误和凭据读取失败。已保存或默认的选择不在目录中时,界面显示“请选择模型”,保存的选择保持不变。独立的 DeepSeek 账号路由仍使用账号登录。
## 迁移
1. 要使用 API Key 路由,请在设置 → 模型中配置密钥,或通过启动环境提供 `apiKeyEnv` 指定的凭据引用(默认为 `DEEPSEEK_API_KEY`)。
2. 重新打开模型选择器,确认 DeepSeek 分组出现。自定义模型发现消费者必须处理配置凭据前的空目录;模型列出不代表远端 API 已接受密钥。
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-ui-model-selection/use-this-package:
en: 62235f8c8c9e1e7e
zh: c18dc9c38ef9d4f9
en: ef50e68e7493d0a9
zh: 3b1faea17b606173
/deepseek-ai-dsh-client-ui-model-selection/use-this-package/model-and-effort:
en: 48f8157ffdfe6e69
zh: 64ec9a533e2aa8f4
+2 -2
View File
@@ -29,9 +29,9 @@ The Web GUI lets users switch the model and reasoning effort for an existing ses
DeepSeek account and API-key routes appear as separate provider groups, each exposing the same configured model catalog.
The unselected model label uses the same regular weight as an available model name and retains the saved reasoning effort caption for existing and new sessions; effort editing requires an available model. Clicking the unselected trigger opens the model list directly; Escape closes it.
When no model is selected or the saved selection is absent from the catalog, the composer shows “Select model” in regular weight and hides the reasoning effort caption. Clicking it opens the model list directly; Escape and Shift+Tab close it. An empty catalog displays “No models available.”
Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus through root and effort rows, or move the model-list highlight without leaving search. Enter and Tab settle the highlighted model or focused row; Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling focuses model search when shown, otherwise the current model or effort row, and going back lands on the cell that opened the pane left. The composer shows the catalog name while the selected model is available, and its saved `provider/model` ID when the model or provider is removed, including account sign-out. The stored provider, model, and reasoning effort remain unchanged.
Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus through root and effort rows, or move the model-list highlight without leaving search. Enter and Tab settle the highlighted model or focused row; Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling focuses model search when shown, otherwise the current model or effort row, and going back lands on the cell that opened the pane left. The composer shows the catalog name while the selected model is available. Removing a model or provider, including account sign-out, preserves the stored provider, model, and reasoning effort without displaying their IDs. Restoring the catalog entry restores its name and effort caption.
Mouse selection uses native browser clicks, including their cancellation behavior; a press alone never selects. Opening the root menu focuses its trigger, and clicking the trigger again closes the menu and returns focus there. Root rows and the search-clear button show the same fill for keyboard focus and hover, without a native outline. Selecting a model or effort returns focus to the trigger without a focus ring; leaving the trigger or reopening the menu restores its normal focus indication. While a selection from either entry is pending, focus stays on the trigger, the trigger shows a spinner in place of its chevron, and each row whose value the selection carries shows one in place of its check; a rejected selection leaves the menu open. Tab returns to model search with the current model highlighted when search is shown, otherwise to the current row.
@@ -29,9 +29,9 @@ Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有
DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示相同的已配置模型目录。
未选模型文案与可用模型名使用相同的常规字重,并在既有会话与新会话中保留已保存的推理强度显示;调整推理强度需要先选择可用模型。点击未选模型控件直接打开模型列表,Escape 关闭列表。
未选择模型或已保存的选择不在目录中时,composer 以常规字重显示“请选择模型”,并隐藏推理强度文案。点击后直接打开模型列表,Escape 和 Shift+Tab 关闭列表。目录为空时显示“暂无可用模型”
与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在根菜单和推理等级行间移动焦点,或在模型列表中移动高亮并让焦点保留在搜索框。Enter 和 Tab 选定高亮模型或聚焦行;Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻时若显示模型搜索框则聚焦搜索框,否则聚焦当前模型或推理强度行,返回则落在打开该面板的格子上。所选模型可用时,composer 显示目录中的模型名称;模型或提供方被删除时(包括账号退登),改为显示已保存的 `provider/model` ID。保存的提供方、模型和推理强度均保持不变。
与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在根菜单和推理等级行间移动焦点,或在模型列表中移动高亮并让焦点保留在搜索框。Enter 和 Tab 选定高亮模型或聚焦行;Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻时若显示模型搜索框则聚焦搜索框,否则聚焦当前模型或推理强度行,返回则落在打开该面板的格子上。所选模型可用时,composer 显示目录中的模型名称。模型或提供方被删除时(包括账号退登),保留已保存的提供方、模型和推理强度,但不展示其 ID。目录条目恢复后重新显示名称和推理强度。
鼠标选择沿用浏览器原生点击及其取消行为;仅按下按钮不会选定。打开根菜单时聚焦触发按钮,再次点击触发按钮会关闭菜单并把焦点还给它。根菜单行和搜索清除按钮的键盘焦点与悬停使用相同底色,不显示原生焦点轮廓。选定模型或推理等级后,焦点返回触发按钮但不显示焦点环;焦点离开按钮或重新打开菜单后恢复正常焦点提示。等待任一入口发起的选择结果时,焦点停在触发按钮,触发按钮以加载图标代替下拉箭头,该选择包含的值所在行以加载图标代替勾选标记;选择被拒绝后菜单保持打开。显示模型搜索框时,Tab 返回搜索框并高亮当前模型,否则返回当前选中行。
@@ -119,11 +119,13 @@ export function ModelSelect(
const currentChoice = choices[selectedIndex]
const reasoning = currentChoice?.model.reasoning
const effectiveEffort = state.current?.reasoningEffort ?? reasoning?.defaultEffort
const effortLabel = reasoning === undefined
? state.retainedEffort
: effectiveEffort === undefined
? t('effort.providerDefault')
: reasoning.efforts.find(level => level.id === effectiveEffort)?.name ?? effectiveEffort
const effortLabel = currentChoice === undefined
? undefined
: reasoning === undefined
? state.retainedEffort
: effectiveEffort === undefined
? t('effort.providerDefault')
: reasoning.efforts.find(level => level.id === effectiveEffort)?.name ?? effectiveEffort
const effortChoices = useMemo<readonly EffortChoice[]>(() => reasoning === undefined
? []
: [
@@ -244,8 +246,8 @@ export function ModelSelect(
triggerRef.current?.focus()
setQuery('')
setHighlightedIndex(null)
if (state.current === null) paneFocus.current = 'drill'
setPane(state.current === null ? 'model' : 'root')
if (currentChoice === undefined) paneFocus.current = 'drill'
setPane(currentChoice === undefined ? 'model' : 'root')
setOpen(true)
reload()
}
@@ -297,7 +299,7 @@ export function ModelSelect(
if (event.key === 'Escape' && open) {
event.preventDefault()
// Escape backs out of a drilled pane first, then closes.
if (pane !== 'root' && state.current !== null) back(pane)
if (pane !== 'root' && currentChoice !== undefined) back(pane)
else close(true)
return
}
@@ -325,7 +327,7 @@ export function ModelSelect(
if (event.key === 'Tab') {
if (event.shiftKey) {
event.preventDefault()
if (pane !== 'root' && state.current !== null) back(pane)
if (pane !== 'root' && currentChoice !== undefined) back(pane)
else close(true)
return
}
@@ -411,15 +413,15 @@ export function ModelSelect(
submit(selection)
}
const waiting = state.current === null && state.status === 'loading'
const waiting = currentChoice === undefined && state.status === 'loading'
const modelLabel = waiting
? t('trigger.loading')
: currentChoice?.model.name
?? (state.current === null ? t('trigger.fallback') : `${state.current.provider}/${state.current.model}`)
?? t('trigger.fallback')
const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}`
const triggerAria = waiting
? t('trigger.loading')
: state.current === null
: currentChoice === undefined
? t('trigger.selectAria')
: effortLabel === undefined
? t('trigger.aria', { model: modelLabel })
@@ -31,7 +31,7 @@ export const zh = {
'search.placeholder': '搜索模型…',
'search.clear': '清除搜索',
'search.empty': '没有匹配的模型。',
'empty.models': '没有可用的模型。',
'empty.models': '暂无可用模型',
'empty.efforts': '当前模型未提供推理等级。',
} satisfies Record<string, string>
@@ -238,7 +238,7 @@ describe('ui-model-selection dual entry', () => {
expect(options[0]?.active).toBe(true)
expect(options[1]?.active).toBeUndefined()
expect(b.popup().searchLabels?.()).toEqual(locale === 'zh'
? { placeholder: '搜索模型…', empty: '没有可用的模型。', noResults: '没有匹配的模型。' }
? { placeholder: '搜索模型…', empty: '暂无可用模型', noResults: '没有匹配的模型。' }
: { placeholder: 'Search models…', empty: 'No models available.', noResults: 'No matching models.' })
})
@@ -1 +1 @@
请选择模型High
请选择模型
@@ -143,7 +143,7 @@ describe('ModelSelect reasoning effort', () => {
.toEqual(['Default', 'Standard'])
})
it('shows the durable model id when the catalog has no matching display name', () => {
it('opens model choices without exposing a saved route absent from the catalog', () => {
const directory = createSnapshotStore(state({
current: { provider: 'deepseek-official', model: 'removed-model' },
}))
@@ -157,17 +157,21 @@ describe('ModelSelect reasoning effort', () => {
t={t}
/>)
const trigger = screen.getByRole('button', { name: '选择模型,当前 deepseek-official/removed-model' })
expect(trigger.textContent).toContain('deepseek-official/removed-model')
const trigger = screen.getByRole('button', { name: '请选择模型' })
expect(trigger.textContent).toBe('请选择模型')
fireEvent.click(trigger)
expect(screen.queryByRole('menuitem', { name: /推理等级/ })).toBeNull()
fireEvent.click(screen.getByRole('menuitem', { name: /模型/ }))
expect(screen.queryByRole('menuitem', { name: /模型/ })).toBeNull()
expect(screen.queryByRole('menuitemradio', { name: 'removed-model' })).toBeNull()
expect(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })).toBeTruthy()
expect(screen.queryByText('Fast catalog description')).toBeNull()
expect(select).not.toHaveBeenCalled()
expect(directory.getSnapshot().current).toEqual({ provider: 'deepseek-official', model: 'removed-model' })
fireEvent.keyDown(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' }), { key: 'Escape' })
expect(screen.queryByRole('group', { name: '模型与推理等级' })).toBeNull()
})
it.each(['model', 'provider'])('keeps the saved id and effort when the selected %s disappears', (removed) => {
it.each(['model', 'provider'])('hides the saved id and effort when the selected %s disappears', (removed) => {
const directory = createSnapshotStore(state({ retainedEffort: 'High' }))
render(<ModelSelect locked={false} available directory={directory} load={vi.fn()} select={vi.fn()} t={t} />)
expect(screen.getByRole('button', { name: /选择模型,当前/ }).textContent).toContain('DeepSeek-V4-Flash')
@@ -175,14 +179,18 @@ describe('ModelSelect reasoning effort', () => {
snapshot.groups = removed === 'provider' ? [] : snapshot.groups.map(group => ({ ...group, models: [] }))
snapshot.routable = false
}) })
expect(screen.getByRole('button', { name: /选择模型,当前/ }).textContent)
.toMatchInlineSnapshot('"deepseek-official/deepseek-v4-flashHigh"')
const trigger = screen.getByRole('button', { name: '请选择模型' })
expect(trigger.textContent).toBe('请选择模型')
expect(directory.getSnapshot().current).toEqual(state().current)
fireEvent.click(trigger)
expect(screen.getByRole('status').textContent).toBe(zh['empty.models'])
fireEvent.keyDown(trigger, { key: 'Tab', shiftKey: true })
expect(screen.queryByRole('group', { name: '模型与推理等级' })).toBeNull()
})
it('shows loading until the catalog and Session projection are both ready', async () => {
it.each([null, state().current])('shows loading until the catalog and Session projection are both ready (%j)', async (current) => {
const directory = createSnapshotStore<ModelDirectoryState>(state({
current: null,
current,
routable: null,
groups: [],
status: 'loading',
@@ -494,7 +502,6 @@ describe('ModelSelect keyboard walk', () => {
/>)
const trigger = screen.getByRole('button', { name: /选择模型/ })
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ }))
expect(screen.queryByRole('searchbox')).toBeNull()
expect(document.activeElement).toBe(trigger)
@@ -506,12 +513,8 @@ describe('ModelSelect keyboard walk', () => {
retry.focus()
// A control that is not a row keeps the browser's traversal.
expect(fireEvent.keyDown(retry, { key: 'Tab' })).toBe(true)
// Escape still backs out of the pane and then closes the card.
fireEvent.keyDown(retry, { key: 'Escape' })
// Back on the root pane, whose only cell remains (no model means no effort row).
const cell = screen.getAllByRole('menuitem')[0]!
fireEvent.keyDown(cell, { key: 'Escape' })
expect(screen.queryByRole('menu')).toBeNull()
expect(screen.queryByRole('group', { name: '模型与推理等级' })).toBeNull()
})
it('focuses the checked model in a small catalog', () => {
@@ -557,7 +560,6 @@ describe('ModelSelect keyboard walk', () => {
t={t}
/>)
fireEvent.click(screen.getByRole('button', { name: /选择模型/ }))
fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ }))
const rows = screen.getAllByRole('menuitemradio')
expect(rows.every(row => row.getAttribute('aria-checked') === 'false')).toBe(true)
expect(screen.queryByRole('searchbox')).toBeNull()
@@ -573,7 +575,7 @@ describe('ModelSelect catalog size', () => {
load={vi.fn()} select={vi.fn()} t={t} />)
const trigger = screen.getByRole('button', { name: /选择模型/ })
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ }))
if (count > 0) fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ }))
const rows = screen.queryAllByRole('menuitemradio')
expect(rows).toHaveLength(count)
if (count > 4) {
@@ -608,7 +610,7 @@ describe('ModelSelect catalog size', () => {
const directory = createSnapshotStore(state({ groups: modelGroups(5), current }))
render(<ModelSelect locked={false} available directory={directory} load={vi.fn()} select={vi.fn()} t={t} />)
fireEvent.click(screen.getByRole('button', { name: /选择模型/ }))
fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ }))
if (checked) fireEvent.click(screen.getByRole('menuitem', { name: /^模型/ }))
const search = screen.getByRole('searchbox')
fireEvent.change(search, { target: { value: 'Model 5' } })
expect(screen.getAllByRole('menuitemradio')).toHaveLength(1)
@@ -852,13 +854,13 @@ describe('ModelSelect search', () => {
})
})
it('shows the unselected model control with the inherited effort', async () => {
it('hides the inherited effort until a listed model is selected', async () => {
const directory = createSnapshotStore<ModelDirectoryState>(state({ current: null, routable: false, retainedEffort: 'High' }))
render(<ModelSelect locked={false} available directory={directory} load={vi.fn()} select={vi.fn()} t={t} />)
const trigger = screen.getByRole('button', { name: '请选择模型' })
expect(trigger.hasAttribute('disabled')).toBe(false)
await expect(`${trigger.textContent}\n`).toMatchFileSnapshot('./expected/unselected-model.txt')
expect(trigger.textContent).toContain('High')
expect(trigger.textContent).not.toContain('High')
fireEvent.click(trigger)
expect(screen.queryByRole('menuitem', { name: /模型/ })).toBeNull()
expect(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })).toBeTruthy()
@@ -911,8 +913,7 @@ it('restores the account model name after login without changing the saved route
render(<ModelSelect locked={false} available directory={directory} load={vi.fn()} select={vi.fn()} t={t} />)
expect(screen.getByRole('button', { name: /选择模型,当前/ }).textContent).toBe('DeepSeek FlashHigh')
act(() => { directory.update((snapshot) => { snapshot.groups = []; snapshot.routable = false }) })
expect(screen.getByRole('button', { name: /选择模型,当前/ }).textContent)
.toMatchInlineSnapshot('"deepseek-account/deepseek-flashHigh"')
expect(screen.getByRole('button', { name: '请选择模型' }).textContent).toBe('请选择模型')
act(() => { directory.update((snapshot) => { snapshot.groups = groups; snapshot.routable = true }) })
expect(screen.getByRole('button', { name: /选择模型,当前/ }).textContent).toBe('DeepSeek FlashHigh')
expect(directory.getSnapshot().current).toEqual(selected)
@@ -15,8 +15,8 @@
en: 23386452a66b2fbd
zh: 4cf93c4bf17b3da2
/deepseek-ai-dsh-llm-deepseek-api-key/use-this-package:
en: ab3d8cc5be370482
zh: 8a8b9871d5afe1df
en: 10333177c9033b9e
zh: 069faa6a125cb6a9
/deepseek-ai-dsh-llm-deepseek-api-key/understand-the-implementation:
en: 002ac925beb4e75b
zh: 815d06f116604a09
+1 -1
View File
@@ -25,7 +25,7 @@ Authentication resolution returns the validated API key in `x-api-key` for both
<a id="use-this-package"></a>
## Use this package
`apiKeyEnv` defaults to `DEEPSEEK_API_KEY` and resolves per request. When the credentials service exists, its precedence applies; only compositions without that service read the launch environment directly. Requests with missing credentials fail with `MISSING_CREDENTIAL`; malformed credentials fail with `INVALID_CREDENTIAL`. Model discovery returns the configured catalog regardless of credentials.
`apiKeyEnv` defaults to `DEEPSEEK_API_KEY` and resolves per request and model discovery. When the credentials service exists, its precedence applies; only compositions without that service read the launch environment directly. Requests with missing credentials fail with `MISSING_CREDENTIAL`; malformed credentials fail with `INVALID_CREDENTIAL`. Model discovery returns an empty catalog when the credential is missing, hiding the route from model selectors while retaining its settings entry. Malformed credentials and other lookup failures propagate. Adding or removing a key affects the next discovery call.
```yaml
- id: llm-deepseek
@@ -25,7 +25,7 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用此包
`apiKeyEnv` 默认为 `DEEPSEEK_API_KEY`,在每次请求时解析凭据。已组合 credentials 服务时,按其优先级解析;只有未组合该服务时才直接读取启动环境。请求时凭据缺失以 `MISSING_CREDENTIAL` 失败;格式错误以 `INVALID_CREDENTIAL` 失败。模型发现始终返回配置的目录,不依赖凭据。
`apiKeyEnv` 默认为 `DEEPSEEK_API_KEY`,在每次请求和模型发现时解析凭据。已组合 credentials 服务时,按其优先级解析;只有未组合该服务时才直接读取启动环境。请求时凭据缺失以 `MISSING_CREDENTIAL` 失败;格式错误以 `INVALID_CREDENTIAL` 失败。模型发现在凭据缺失时返回空目录,在模型选择器中隐藏该路由,但保留其设置入口。凭据格式错误和其他读取失败继续上报。添加或删除密钥会影响下一次模型发现。
```yaml
- id: llm-deepseek
@@ -1,4 +1,4 @@
/** API-key authentication and discovery for the official DeepSeek route. */
/** API-key authentication and credential-gated discovery for the official DeepSeek route. */
import type { Context } from '@deepseek-ai/cordis'
import { assertUsableApiKey, LlmError } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/cordis-plugin-loader'
@@ -39,9 +39,14 @@ export function apply(ctx: Context, config: Config): void {
registerDeepSeekProvider(ctx, PROVIDER, {
options, providerName: 'DeepSeek',
resolveAuth: async connection => ({ headers: { 'x-api-key': await resolveApiKey(connection) } }),
discoverModels: (provider) => {
discoverModels: async (provider) => {
const connection = options()
return Promise.resolve(connection.models.map(model => catalogModelInfo(provider, model)))
try { await resolveApiKey(connection) }
catch (error) {
if (error instanceof LlmError && error.code === 'MISSING_CREDENTIAL') return []
throw error
}
return connection.models.map(model => catalogModelInfo(provider, model))
},
})
}
@@ -1,12 +1,25 @@
/** The configured model catalog is independent of request credentials. */
/** Model discovery requires the API-key route's own credential. */
import { Context } from '@deepseek-ai/cordis'
import LlmRuntime from '@deepseek-ai/dsh-llm'
import { expect, it, vi } from 'vitest'
import * as ApiKey from '../src/index.ts'
it.each(['', 'invalid\nheader'])('advertises configured models without a usable API key: %j', async (key) => {
it.each([undefined, ''])('hides models without an API key: %j', async (key) => {
vi.stubEnv('DEEPSEEK_API_KEY', key)
const ctx = new Context()
try {
await ctx.plugin(LlmRuntime)
await ctx.plugin(ApiKey, {})
expect(await ctx.llm.listModels('deepseek-official')).toEqual([])
} finally {
await ctx.fiber.dispose()
vi.unstubAllEnvs()
}
})
it('advertises configured models with an API key', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', 'fixture-api-key')
const ctx = new Context()
try {
await ctx.plugin(LlmRuntime)
await ctx.plugin(ApiKey, {})
@@ -18,3 +31,16 @@ it.each(['', 'invalid\nheader'])('advertises configured models without a usable
vi.unstubAllEnvs()
}
})
it('reports malformed credentials during discovery', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', 'invalid\nheader')
const ctx = new Context()
try {
await ctx.plugin(LlmRuntime)
await ctx.plugin(ApiKey, {})
await expect(ctx.llm.listModels('deepseek-official')).rejects.toMatchObject({ code: 'INVALID_CREDENTIAL' })
} finally {
await ctx.fiber.dispose()
vi.unstubAllEnvs()
}
})
@@ -220,16 +220,16 @@ it('cancels an account child without stopping its API-key parent', async () => {
await parent.whenIdle()
})
it('hides signed-out account models while retaining the official catalog without an API key', async () => {
it('lists each DeepSeek route only when its own credential is present', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const ctx = await harness()
let token: string | undefined
ctx.provide('deepseekAccount', { resolveToken: async (url: string) => new URL(url).origin === 'https://api.deepseek.com' ? token : undefined } as DeepSeekAccount)
expect(await ctx.llm.listModels('deepseek-account')).toEqual([])
expect(await ctx.llm.listModels('deepseek-official')).not.toHaveLength(0)
expect(await ctx.llm.listModels('deepseek-official')).toEqual([])
token = 'fixture-account-token'
expect(await ctx.llm.listModels('deepseek-account')).not.toHaveLength(0)
expect(await ctx.llm.listModels('deepseek-official')).not.toHaveLength(0)
expect(await ctx.llm.listModels('deepseek-official')).toEqual([])
vi.stubEnv('DEEPSEEK_API_KEY', 'fixture-key')
expect(await ctx.llm.listModels('deepseek-official')).not.toHaveLength(0)
token = undefined
@@ -125,6 +125,27 @@ async function loadComposition(
describe('llm-deepseek real dynamic composition', () => {
it('updates the model catalog when the stored API key is removed or added', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const { ctx } = await loadComposition({ withDynamic: true, baseURL: 'https://api.deepseek.com/anthropic' })
const credentials = ctx.get('credentials')!
const models = await ctx.llm.listModels('deepseek-official')
expect(models).not.toHaveLength(0)
await credentials.unset(KEY_REF)
expect(await ctx.llm.listModels('deepseek-official')).toEqual([])
expect(ctx.llm.listConfigurableProviders().map(row => row.provider)).toContain('deepseek-official')
await credentials.set(KEY_REF, 'stored-by-ui')
expect(await ctx.llm.listModels('deepseek-official')).toEqual(models)
const failure = new Error('credential lookup failed')
const resolve = vi.spyOn(credentials, 'resolve').mockRejectedValue(failure)
try {
await expect(ctx.llm.listModels('deepseek-official')).rejects.toBe(failure)
} finally { resolve.mockRestore() }
})
it('keeps package inventory on when the Loader composition disables session upload', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', 'entry-key')
const server = await mockServer([{ kind: 'sse', events: textEvents }])
@@ -2227,7 +2227,7 @@ describe('plugin registration and config', () => {
await ctx.plugin(LlmRuntime)
await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' })
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2)
await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([])
const first = await assemble(ctx, { model: 'deepseek-flash', messages: [] })
expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } })
// The guidance leads with the managed credential store.