fix(ui-models): three faults the running app surfaced

**A hand-declared route must not offer a reasoning effort.** The earlier
commit read the create card's missing control as drift and added one. It
is the other way round: such a model has no reasoning capability — pi-ai's
installed catalog is what supplies one, and it ships nothing under the
route — so `resolveModel` throws UNSUPPORTED_REASONING_EFFORT for every
model on it and the whole provider drops out of the picker. Verified
against the adapter, not inferred. The create card no longer offers it and
the editor withholds it on the directory's `declared` bit, which is the
real bug: that control has always been wrong for these routes.

**A blocked composer locked the way out of the block.** Reusing the
no-workspace inert posture disabled the model seat along with everything
else, so the bar asked for a model while preventing the one control that
picks one. A block now rides its own `blocked` owner prop: the textarea,
send, commands, plan seat, and access chip all lock, and the model seat
alone stays live.

**A Provider ID could derive an illegal credential reference.** The card
accepted a digit-leading id, whose derived `123_API_KEY` then failed at
the credential seam with a raw regular expression the user cannot act on.
The id must now start with a letter, and a test pins the relation between
the two rules rather than the regex.
This commit is contained in:
Yichen Jiang
2026-08-07 16:45:50 +08:00
parent d03d3ab70b
commit 5a90eb41fb
21 changed files with 179 additions and 91 deletions

View File

@@ -153,6 +153,15 @@ describe('web e2e: the composer model switch is the default for later sessions',
},
})
expect(refused.result).toMatchObject({ ok: false, error: { code: 'model-unavailable' } })
// The way out stays open. Locking the model seat with everything else
// would leave the composer asking for the one thing it prevents.
const seat = page.getByRole('button', { name: /^选择模型/ })
expect(await seat.isEnabled()).toBe(true)
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([])
}, 60_000)
})

View File

@@ -175,7 +175,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('declares a route the adapter does not ship, with its own reasoning effort', async () => {
it('declares a route the adapter does not ship, without a reasoning control', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-declare'))
const dialog = page.getByRole('dialog', { name: '设置' })
const declare = dialog.getByRole('button', { name: '添加自定义提供方' })
@@ -184,10 +184,10 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
await dialog.getByLabel('Provider ID').fill('acme-gateway')
await dialog.getByLabel('显示名称').fill('Acme Gateway')
await dialog.getByLabel('API 地址').fill('https://gateway.acme.example/v1')
// The create card offers the same provider-level effort the editor card
// does for this namespace; a route declared without it would gain the
// control only on reopening.
await dialog.getByLabel('推理强度').selectOption('high')
// No reasoning effort anywhere for a hand-declared route: its models carry
// no reasoning capability, so a profile effort would make every model on
// the route fail to resolve and drop the provider out of the picker.
expect(await dialog.getByLabel('推理强度').count()).toBe(0)
await dialog.getByRole('button', { name: '添加模型' }).click()
await dialog.getByLabel('模型 ID 1').fill('acme-large')
await dialog.getByRole('button', { name: '创建提供方', exact: true }).click()
@@ -196,7 +196,6 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
await row.waitFor({ timeout: 10_000 })
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(document).toContain('acme-gateway:')
expect(document).toContain('reasoning: high')
// The tag follows the adapter's installed catalog: this route is in no
// catalog, while minimax-cn is — even though both now have profiles.