feat(ui-models): drop the provider-scoped reasoning effort, and red-flag a bad route id
**Reasoning effort leaves the provider cards entirely.** It is a per-MODEL capability and the models under one provider disagree about which levels they accept: setting `anthropic` to `max` made six of its eight models throw UNSUPPORTED_REASONING_EFFORT, and because the catalog build catches per provider, the whole provider vanished from the picker behind one error row. A provider-scoped control can only ever be set to a value some of its models reject. The composer's model picker already offers each model its own levels, and a switch there now records provider, model, and effort together as the next session's default — so the setting has a better home at the right granularity. The profile field stays in `settings.yaml` for a deployment that knows its route; only the control is gone, from both cards and both adapter families. Two `components.spec` cases used the control as the vehicle for their op assertions and now use `baseURL`, which is what they were actually testing. **A rejected Provider ID now reads as a fault.** It shared the neutral hint paragraph with the field's guidance, so the copy telling the user what they got wrong looked like advice. Reuses the existing `.error` style, matching the split the key field already makes.
This commit is contained in:
12 files changed
+70
-178
No files matched your search
@@ -92,13 +92,12 @@ function wireNamespaces(): SettingsNamespaceView[] {
|
||||
value: {
|
||||
apiKeyEnv: 'DEEPSEEK_API_KEY',
|
||||
baseURL: 'https://base',
|
||||
reasoningEffort: 'high',
|
||||
defaultContextWindow: 1_000_000,
|
||||
maxTokens: 256_000,
|
||||
models: DEFAULT_DEEPSEEK_MODELS,
|
||||
},
|
||||
base: { defaultContextWindow: 1_000_000, maxTokens: 256_000, models: DEFAULT_DEEPSEEK_MODELS },
|
||||
user: { reasoningEffort: 'high' },
|
||||
user: { baseURL: 'https://base' },
|
||||
applies: 'live',
|
||||
secrets: [{ path: ['apiKey'], set: false }],
|
||||
revision: 0,
|
||||
@@ -729,16 +728,16 @@ describe('ModelsSection', () => {
|
||||
// user layer and replaced it wholesale, deleting any stored literal key.
|
||||
const { replace, update, mutate } = await mountSection()
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
const effort = screen.getByLabelText<HTMLSelectElement>(en.effort)
|
||||
expect(effort.value).toBe('high')
|
||||
fireEvent.change(effort, { target: { value: '' } })
|
||||
const url = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
|
||||
expect(url.value).toBe('https://base')
|
||||
fireEvent.change(url, { target: { value: '' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-deepseek',
|
||||
ops: [{ op: 'unset', path: ['reasoningEffort'] }],
|
||||
ops: [{ op: 'unset', path: ['baseURL'] }],
|
||||
expectedRevision: 0,
|
||||
})
|
||||
})
|
||||
@@ -795,17 +794,16 @@ describe('ModelsSection', () => {
|
||||
const urls = screen.getAllByLabelText<HTMLInputElement>(en.baseUrl)
|
||||
expect(urls).toHaveLength(2)
|
||||
expect((urls[1] as HTMLInputElement).value).toBe('https://proxy')
|
||||
const effort = screen.getAllByLabelText<HTMLSelectElement>(en.effort)
|
||||
fireEvent.change(effort[effort.length - 1] as HTMLSelectElement, { target: { value: 'xhigh' } })
|
||||
fireEvent.change(urls[1] as HTMLInputElement, { target: { value: 'https://proxy/v2' } })
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
// Only the edited field travels: apiKeyEnv, baseURL and headers were
|
||||
// already stored with these values, so no op restates them — and the
|
||||
// profile's stored literal apiKey, absent from the redacted view the card
|
||||
// read, is named by nothing at all.
|
||||
// Only the edited field travels: apiKeyEnv and headers were already stored
|
||||
// with these values, so no op restates them — and the profile's stored
|
||||
// literal apiKey, absent from the redacted view the card read, is named by
|
||||
// nothing at all.
|
||||
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-pi-ai',
|
||||
ops: [{ op: 'set', path: ['providers', 'openai', 'reasoning'], value: 'xhigh' }],
|
||||
ops: [{ op: 'set', path: ['providers', 'openai', 'baseURL'], value: 'https://proxy/v2' }],
|
||||
expectedRevision: 0,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -705,35 +705,24 @@ describe('hand-declared providers', () => {
|
||||
expect(set).toHaveBeenCalledWith({ ref: 'ACME_GATEWAY_API_KEY', value: 'gw-key' })
|
||||
})
|
||||
|
||||
it('offers no reasoning effort at all, in either card, for a hand-declared route', async () => {
|
||||
it('scopes each card to fields a provider can actually own', async () => {
|
||||
// Reasoning effort used to sit here. It is a per-MODEL capability and the
|
||||
// models under one provider disagree about it, so a provider-scoped
|
||||
// control could only be set to a value some of them reject — which took
|
||||
// the whole provider out of the picker. The composer's model picker owns
|
||||
// the choice, and a switch there records provider+model+effort together.
|
||||
const fields = () => [...document.querySelectorAll('input,select')]
|
||||
.map(el => el.getAttribute('aria-label')).filter(Boolean)
|
||||
|
||||
mountCard()
|
||||
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
||||
// A hand-declared model carries no reasoning capability — pi-ai's
|
||||
// installed catalog is what supplies one, and it ships nothing under this
|
||||
// route — so a profile effort makes `resolveModel` throw
|
||||
// UNSUPPORTED_REASONING_EFFORT for every model on it and drops the whole
|
||||
// provider out of the picker. Offering the control would be offering a way
|
||||
// to break the route.
|
||||
expect(screen.queryByLabelText(en.effort)).toBeNull()
|
||||
expect(fields()).toEqual([en.customRoute, en.customDisplayName, en.baseUrl, en.customApi, en.keyInput])
|
||||
cleanup()
|
||||
|
||||
// The editor card withholds it for the same route for the same reason...
|
||||
await mountSection({
|
||||
providers: { 'acme-gateway': { apiKeyEnv: 'ACME_GATEWAY_API_KEY', baseURL: 'https://acme.test/v1' } },
|
||||
declaredRoutes: ['acme-gateway'],
|
||||
})
|
||||
openEditor('acme-gateway')
|
||||
expect(screen.queryByLabelText(en.effort)).toBeNull()
|
||||
cleanup()
|
||||
|
||||
// ...and keeps it for a route the adapter actually ships, whose models do
|
||||
// carry the capability.
|
||||
await mountSection({ providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } })
|
||||
openEditor('openai')
|
||||
const select = screen.getByLabelText<HTMLSelectElement>(en.effort)
|
||||
expect([...select.options].map(option => option.value))
|
||||
.toEqual(['', 'off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
expect(fields()).toEqual([en.keyInput, en.baseUrl])
|
||||
})
|
||||
|
||||
it('retries only the key after the profile landed, and reports the provider on cancel', async () => {
|
||||
@@ -827,6 +816,20 @@ describe('hand-declared providers', () => {
|
||||
expect(screen.queryByText(en.customRouteInvalid)).toBeNull()
|
||||
})
|
||||
|
||||
it('styles a rejected route id as a fault and its guidance as a hint', () => {
|
||||
mountCard()
|
||||
const routeField = screen.getByLabelText(en.customRoute)
|
||||
// Same split the key field makes: what the user got wrong reads as a
|
||||
// fault, what they have yet to do reads as guidance.
|
||||
expect(screen.getByText(en.customRouteHint).className).toMatch(/advancedHint/)
|
||||
|
||||
fireEvent.change(routeField, { target: { value: '2' } })
|
||||
expect(screen.getByText(en.customRouteInvalid).className).toMatch(/error/)
|
||||
|
||||
fireEvent.change(routeField, { target: { value: 'openai' } })
|
||||
expect(screen.getByText(en.customRouteTaken).className).toMatch(/error/)
|
||||
})
|
||||
|
||||
it('derives a reference the credential seam accepts for every id it admits', () => {
|
||||
// The two rules have to stay in step; this is the relation, checked
|
||||
// directly rather than through the DOM.
|
||||
|
||||
Reference in New Issue
Block a user