fix(web): edit a declared provider's name and protocol
The Models editor card curated its pi-ai fields by what every route has, so `displayName` and `api` — the two a hand-declared route names for itself — were asked for at creation and then reachable only through settings.yaml. The editor now renders both for a route the directory reports as declared, from the same namespace schema the create card reads. A catalog route gets neither: it defaults its name from its catalog entry, and its models each carry their own protocol, so a route-level one could only override all of them. Clearing the name unsets it and the route falls back to its id, which is what the field's placeholder shows; storing the empty string would be refused by the adapter. A declared profile naming no protocol selects nothing rather than reading as if it had picked the first choice. The Provider ID stays fixed: it is the settings dict key, it is referenced from `agent-default-model` and every logged request header, and it is the stem of a credential reference the page can never read back to move. Fixes #2204
This commit is contained in:
@@ -719,10 +719,103 @@ describe('hand-declared providers', () => {
|
||||
expect(fields()).toEqual([en.customRoute, en.customDisplayName, en.baseUrl, en.customApi, en.keyInput])
|
||||
cleanup()
|
||||
|
||||
// A shipped route's models each carry their own protocol, so its editor
|
||||
// offers no route-level protocol to override them with.
|
||||
await mountSection({ providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } })
|
||||
openEditor('openai')
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
expect(fields()).toEqual([en.keyInput, en.baseUrl])
|
||||
cleanup()
|
||||
|
||||
// A hand-declared route named its own protocol at creation, so editing it
|
||||
// reaches the same field the create card asked for.
|
||||
await mountSection({
|
||||
providers: { 'acme-gateway': { api: 'openai-completions', baseURL: 'https://gateway.acme.example/v1' } },
|
||||
declaredRoutes: ['acme-gateway'],
|
||||
})
|
||||
openEditor('acme-gateway')
|
||||
expect(fields()).toEqual([en.keyInput, en.customDisplayName, en.baseUrl, en.customApi])
|
||||
})
|
||||
|
||||
it('renames a declared route and falls back to its id when the name is cleared', async () => {
|
||||
const { mutate } = await mountSection({
|
||||
providers: {
|
||||
'acme-gateway': { displayName: 'Acme Gateway', api: 'openai-completions', baseURL: 'https://acme.test/v1' },
|
||||
},
|
||||
declaredRoutes: ['acme-gateway'],
|
||||
})
|
||||
openEditor('acme-gateway')
|
||||
|
||||
const name = screen.getByLabelText<HTMLInputElement>(en.customDisplayName)
|
||||
expect(name.value).toBe('Acme Gateway')
|
||||
// The route id, not the stored name: it is what the route will be called
|
||||
// the moment the field is cleared.
|
||||
expect(name.placeholder).toBe('acme-gateway')
|
||||
fireEvent.change(name, { target: { value: 'Acme 网关' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
expect(firstMutate(mutate).ops)
|
||||
.toEqual([{ op: 'set', path: ['providers', 'acme-gateway', 'displayName'], value: 'Acme 网关' }])
|
||||
})
|
||||
|
||||
it('drops the stored name rather than storing an empty one the adapter refuses', async () => {
|
||||
// `llm-pi-ai` rejects an empty displayName outright, so clearing the field
|
||||
// must unset it — which is also what the user means: use the route id.
|
||||
const { mutate } = await mountSection({
|
||||
providers: { 'acme-gateway': { displayName: 'Acme Gateway', api: 'openai-completions' } },
|
||||
declaredRoutes: ['acme-gateway'],
|
||||
})
|
||||
openEditor('acme-gateway')
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.customDisplayName), { target: { value: ' ' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
expect(firstMutate(mutate).ops)
|
||||
.toEqual([{ op: 'unset', path: ['providers', 'acme-gateway', 'displayName'] }])
|
||||
})
|
||||
|
||||
it('edits the protocol a declared route was created with', async () => {
|
||||
const { mutate } = await mountSection({
|
||||
providers: {
|
||||
'acme-gateway': {
|
||||
apiKeyEnv: 'ACME_GATEWAY_API_KEY',
|
||||
api: 'openai-completions',
|
||||
baseURL: 'https://gateway.acme.example/v1',
|
||||
models: [{ id: 'acme-large' }],
|
||||
},
|
||||
},
|
||||
declaredRoutes: ['acme-gateway'],
|
||||
})
|
||||
openEditor('acme-gateway')
|
||||
|
||||
const protocol = screen.getByLabelText<HTMLSelectElement>(en.customApi)
|
||||
expect(protocol.value).toBe('openai-completions')
|
||||
fireEvent.change(protocol, { target: { value: 'anthropic-messages' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
// Only the protocol travels: every other stored field is unchanged, so no
|
||||
// op restates it.
|
||||
expect(firstMutate(mutate)).toEqual({
|
||||
ns: 'llm-pi-ai',
|
||||
ops: [{ op: 'set', path: ['providers', 'acme-gateway', 'api'], value: 'anthropic-messages' }],
|
||||
expectedRevision: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it('selects nothing for a declared route whose profile names no protocol', async () => {
|
||||
// A route hand-written into settings.yaml with no model needs no protocol
|
||||
// to resolve, so the card can be opened over one. The select must not read
|
||||
// as if that route had picked its first choice.
|
||||
await mountSection({
|
||||
providers: { 'acme-gateway': { baseURL: 'https://gateway.acme.example/v1' } },
|
||||
declaredRoutes: ['acme-gateway'],
|
||||
})
|
||||
openEditor('acme-gateway')
|
||||
|
||||
expect(screen.getByLabelText<HTMLSelectElement>(en.customApi).value).toBe('')
|
||||
})
|
||||
|
||||
it('retries only the key after the profile landed, and reports the provider on cancel', async () => {
|
||||
|
||||
Reference in New Issue
Block a user