**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.
1212 lines
54 KiB
TypeScript
1212 lines
54 KiB
TypeScript
// @vitest-environment jsdom
|
|
/** Model-list editing, endpoint interrogation, and hand-declared provider creation. */
|
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import Schema from 'schemastery'
|
|
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
|
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
|
|
import { ModelsSection } from '../src/client/ModelsSection.tsx'
|
|
import type { ModelsSectionInjected } from '../src/client/ModelsSection.tsx'
|
|
import { CustomProviderCard } from '../src/client/CustomProviderCard.tsx'
|
|
import { formatCapacity, parseCapacity } from '../src/client/DeepSeekModelsEditor.tsx'
|
|
import { ModelsSettingsStore, deriveKeyRef, protocolChoices } from '../src/client/store.ts'
|
|
import { en } from '../src/client/locales.ts'
|
|
|
|
afterEach(cleanup)
|
|
|
|
const t: ModelsSectionInjected['t'] = key => en[key]
|
|
|
|
const PROTOCOLS = ['openai-completions', 'openai-responses', 'anthropic-messages']
|
|
|
|
/** The pi-ai profile shape as the host serializes it, including the layer-1 fields. */
|
|
const PiAiConfig = Schema.object({
|
|
providers: Schema.dict(Schema.object({
|
|
apiKey: Schema.string().role('secret'),
|
|
apiKeyEnv: Schema.string().role('credential-ref'),
|
|
displayName: Schema.string(),
|
|
api: Schema.union(PROTOCOLS),
|
|
baseURL: Schema.string(),
|
|
models: Schema.array(Schema.object({
|
|
id: Schema.string().required(),
|
|
name: Schema.string(),
|
|
contextWindow: Schema.number(),
|
|
maxTokens: Schema.number(),
|
|
})),
|
|
reasoning: Schema.union(['off', 'high']),
|
|
})),
|
|
})
|
|
|
|
let nextRpc = 0
|
|
function ok<T>(value: T): RpcResponse<T> {
|
|
return { rpcId: `r-${nextRpc++}` as never, result: { ok: true, value } }
|
|
}
|
|
function fail<T>(message: string, code: string): RpcResponse<T> {
|
|
return { rpcId: `r-${nextRpc++}` as never, result: { ok: false, error: { code, message, details: {} } as never } }
|
|
}
|
|
|
|
function piAiNamespace(
|
|
providers: Record<string, unknown>,
|
|
userProviders: Record<string, unknown> = providers,
|
|
): SettingsNamespaceView {
|
|
return {
|
|
ns: 'llm-pi-ai',
|
|
schema: JSON.parse(JSON.stringify(PiAiConfig.toJSON())) as unknown,
|
|
// `value` is the effective section; `user` is only the layer this page
|
|
// writes. They differ whenever a composition `base` supplies something.
|
|
value: { providers },
|
|
base: {},
|
|
user: { providers: userProviders },
|
|
applies: 'live',
|
|
secrets: [],
|
|
revision: 3,
|
|
}
|
|
}
|
|
|
|
function scriptedFace(options: {
|
|
providers?: Record<string, unknown>
|
|
/** User layer, when it differs from the effective section. */
|
|
userProviders?: Record<string, unknown>
|
|
/** Routes the adapter reports as hand-declared; the rest come back as shipped. */
|
|
declaredRoutes?: readonly string[]
|
|
discover?: ReturnType<typeof vi.fn>
|
|
mutate?: ReturnType<typeof vi.fn>
|
|
set?: ReturnType<typeof vi.fn>
|
|
} = {}) {
|
|
const providers = options.providers ?? {
|
|
openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy.example/v1' },
|
|
}
|
|
const namespace = piAiNamespace(providers, options.userProviders ?? providers)
|
|
const discover = options.discover ?? vi.fn(() => Promise.resolve(ok({ models: [] })))
|
|
const mutate = options.mutate ?? vi.fn(() => Promise.resolve(ok(namespace)))
|
|
const set = options.set ?? vi.fn(() => Promise.resolve(ok({})))
|
|
const face = {
|
|
llm: {
|
|
providers: vi.fn(() => Promise.resolve(ok({
|
|
providers: Object.keys(providers).map(provider => ({
|
|
provider,
|
|
displayName: provider,
|
|
settingsNs: 'llm-pi-ai',
|
|
settingsPath: ['providers', provider],
|
|
active: true,
|
|
declared: options.declaredRoutes?.includes(provider) ?? false,
|
|
})),
|
|
}))),
|
|
models: vi.fn(() => Promise.resolve(ok({ groups: [], failures: [] }))),
|
|
discoverModels: discover,
|
|
},
|
|
settings: {
|
|
describe: vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [namespace] }))),
|
|
update: vi.fn(),
|
|
replace: vi.fn(),
|
|
mutate,
|
|
},
|
|
credentials: {
|
|
describe: vi.fn((payload: { refs: string[] }) => Promise.resolve(ok({
|
|
credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: false, writable: true }])),
|
|
}))),
|
|
set,
|
|
unset: vi.fn(),
|
|
},
|
|
}
|
|
return { face, discover, mutate, set, namespace }
|
|
}
|
|
|
|
type WireFace = ConstructorParameters<typeof ModelsSettingsStore>[0]
|
|
|
|
/** The settings write one card produced, as the scripted face recorded it. */
|
|
interface MutateCall {
|
|
ns: string
|
|
expectedRevision?: number
|
|
ops: { op: string; path: string[]; value?: unknown }[]
|
|
}
|
|
|
|
/** The first interrogation payload; fails the case when nothing was asked. */
|
|
function firstProbe(discover: ReturnType<typeof vi.fn>): unknown {
|
|
const call = (discover.mock.calls as unknown as [unknown][])[0]?.[0]
|
|
if (call === undefined) throw new Error('no interrogation was recorded')
|
|
return call
|
|
}
|
|
|
|
/** The first recorded settings write; fails the case when nothing was written. */
|
|
function firstMutate(mutate: ReturnType<typeof vi.fn>): MutateCall {
|
|
const call = mutate.mock.calls[0]?.[0] as MutateCall | undefined
|
|
if (call === undefined) throw new Error('no settings write was recorded')
|
|
return call
|
|
}
|
|
|
|
async function mountSection(options: Parameters<typeof scriptedFace>[0] = {}) {
|
|
const scripted = scriptedFace(options)
|
|
const controller = new ModelsSettingsStore(scripted.face as unknown as WireFace)
|
|
await controller.load()
|
|
const injected: ModelsSectionInjected = {
|
|
controller,
|
|
useSnapshot: bindSnapshotSelector(controller.store),
|
|
api: scripted.face as never,
|
|
t,
|
|
}
|
|
render(<ModelsSection {...injected} />)
|
|
return { ...scripted, controller }
|
|
}
|
|
|
|
/** Open the editor of one configured row and expand its customized fold. */
|
|
function openEditor(provider: string): void {
|
|
const row = screen.getByText(provider).closest('li')
|
|
if (row === null) throw new Error(`no row for ${provider}`)
|
|
fireEvent.click(within_(row, en.edit))
|
|
const summary = document.querySelector('summary')
|
|
if (summary === null) throw new Error('no customized fold')
|
|
fireEvent.click(summary)
|
|
}
|
|
|
|
/** Open one model row's advanced fold, where the capacities live. */
|
|
function expandModel(index: number): void {
|
|
fireEvent.click(screen.getByLabelText(`${en.modelAdvanced} ${index}`))
|
|
}
|
|
|
|
/** The button carrying `label`, typed so its disabled/title state is readable. */
|
|
function buttonNamed(label: string): HTMLButtonElement {
|
|
const found = screen.getByText(label)
|
|
if (!(found instanceof HTMLButtonElement)) throw new Error(`"${label}" is not a button`)
|
|
return found
|
|
}
|
|
|
|
/** Click the button with `label` inside `scope`. */
|
|
function within_(scope: HTMLElement, label: string): HTMLElement {
|
|
const found = [...scope.querySelectorAll('button')].find(button => button.textContent === label)
|
|
if (found === undefined) throw new Error(`no "${label}" button`)
|
|
return found
|
|
}
|
|
|
|
describe('protocolChoices', () => {
|
|
it('reads the protocols out of the namespace schema and nothing else', async () => {
|
|
const { namespace } = scriptedFace()
|
|
expect(protocolChoices(namespace)).toEqual(PROTOCOLS)
|
|
expect(protocolChoices(undefined)).toEqual([])
|
|
const plain = { ...namespace, schema: JSON.parse(JSON.stringify(Schema.object({}).toJSON())) as unknown }
|
|
expect(protocolChoices(plain)).toEqual([])
|
|
await Promise.resolve()
|
|
})
|
|
})
|
|
|
|
describe('model list editing', () => {
|
|
it('adds, edits, and removes rows without storing emptied optional fields', async () => {
|
|
const { mutate } = await mountSection()
|
|
openEditor('openai')
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
|
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
|
|
expandModel(1)
|
|
fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} 1`), { target: { value: '65536' } })
|
|
fireEvent.change(screen.getByLabelText(`${en.modelName} 1`), { target: { value: 'Acme' } })
|
|
// Clearing an optional field must drop it rather than store an empty value.
|
|
fireEvent.change(screen.getByLabelText(`${en.modelName} 1`), { target: { value: '' } })
|
|
fireEvent.click(screen.getByText(en.apply))
|
|
|
|
await waitFor(() => { expect(mutate).toHaveBeenCalled() })
|
|
expect(firstMutate(mutate)).toMatchObject({
|
|
ns: 'llm-pi-ai',
|
|
expectedRevision: 3,
|
|
ops: [{ op: 'set', path: ['providers', 'openai', 'models'], value: [{ id: 'acme-large', contextWindow: 65_536 }] }],
|
|
})
|
|
})
|
|
|
|
it('names a duplicate model id in the edit flow too', async () => {
|
|
const { mutate } = await mountSection({
|
|
providers: { openai: { baseURL: 'https://proxy.example/v1', models: [{ id: 'dup' }] } },
|
|
})
|
|
openEditor('openai')
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
|
fireEvent.change(screen.getByLabelText(`${en.modelId} 2`), { target: { value: 'dup' } })
|
|
|
|
// The create card refuses this in place; an edited route must not have to
|
|
// learn it from the host's refusal instead.
|
|
expect(screen.getByText(`${en.model} 2: ${en.modelIdDuplicate}`)).toBeTruthy()
|
|
expect(buttonNamed(en.apply).disabled).toBe(true)
|
|
expect(mutate).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('reads K and M suffixes and keeps the text the user typed', async () => {
|
|
const { mutate } = await mountSection()
|
|
openEditor('openai')
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
|
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
|
|
expandModel(1)
|
|
fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} 1`), { target: { value: '1M' } })
|
|
fireEvent.change(screen.getByLabelText(`${en.modelMaxTokens} 1`), { target: { value: '32K' } })
|
|
|
|
// The field keeps the spelling rather than snapping to the expansion, and
|
|
// a plain count is not rewritten into a suffix mid-word either.
|
|
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelContextWindow} 1`).value).toBe('1M')
|
|
fireEvent.change(screen.getByLabelText(`${en.modelMaxTokens} 1`), { target: { value: '1000' } })
|
|
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelMaxTokens} 1`).value).toBe('1000')
|
|
|
|
fireEvent.click(screen.getByText(en.apply))
|
|
await waitFor(() => { expect(mutate).toHaveBeenCalled() })
|
|
// What lands in settings is always a plain token count.
|
|
expect(firstMutate(mutate).ops[0]?.value)
|
|
.toEqual([{ id: 'm', contextWindow: 1_000_000, maxTokens: 1000 }])
|
|
})
|
|
|
|
it('refuses to apply while a capacity is unreadable', async () => {
|
|
const { mutate } = await mountSection()
|
|
openEditor('openai')
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
|
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
|
|
expandModel(1)
|
|
fireEvent.change(screen.getByLabelText(`${en.modelMaxTokens} 1`), { target: { value: 'abc' } })
|
|
|
|
// Silently dropping it would store a route sized differently from what the
|
|
// field shows, so the text stays put and the write is refused instead.
|
|
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelMaxTokens} 1`).value).toBe('abc')
|
|
expect(screen.getByText(`${en.model} 1: ${en.modelMaxTokensInvalid}`)).toBeTruthy()
|
|
expect(buttonNamed(en.apply).disabled).toBe(true)
|
|
expect(mutate).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('spells a stored capacity back the way it is typed', async () => {
|
|
await mountSection({
|
|
providers: {
|
|
openai: {
|
|
baseURL: 'https://proxy.example/v1',
|
|
models: [{ id: 'kept', contextWindow: 1_000_000, maxTokens: 256_000 }],
|
|
},
|
|
},
|
|
})
|
|
openEditor('openai')
|
|
expandModel(1)
|
|
|
|
// Opening a row reads the stored counts, which are plain integers; showing
|
|
// them as such would make an already-configured route look unlike one the
|
|
// user just typed, and re-applying would rewrite the field it read.
|
|
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelContextWindow} 1`).value).toBe('1M')
|
|
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelMaxTokens} 1`).value).toBe('256K')
|
|
})
|
|
|
|
it('edits one row of several and lets a cleared capacity leave the profile', async () => {
|
|
const { mutate } = await mountSection({
|
|
providers: { openai: { baseURL: 'https://proxy.example/v1', models: [{ id: 'first' }, { id: 'second' }] } },
|
|
})
|
|
openEditor('openai')
|
|
|
|
expandModel(2)
|
|
fireEvent.change(screen.getByLabelText(`${en.modelMaxTokens} 2`), { target: { value: '2048' } })
|
|
fireEvent.change(screen.getByLabelText(`${en.modelName} 2`), { target: { value: 'Second' } })
|
|
fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} 2`), { target: { value: '4096' } })
|
|
// Clearing it back to empty must drop the field, not store a zero.
|
|
fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} 2`), { target: { value: '' } })
|
|
fireEvent.click(screen.getByText(en.apply))
|
|
|
|
await waitFor(() => { expect(mutate).toHaveBeenCalled() })
|
|
expect(firstMutate(mutate).ops[0]?.value).toEqual([
|
|
{ id: 'first' },
|
|
{ id: 'second', name: 'Second', maxTokens: 2048 },
|
|
])
|
|
})
|
|
|
|
it('shows the adapter defaults as inherited until an edit takes them over', async () => {
|
|
await mountSection({ providers: { openai: { baseURL: 'https://proxy.example/v1' } } })
|
|
openEditor('openai')
|
|
|
|
// The user layer names no models, so the list belongs to the adapter and
|
|
// says so; taking it over is an explicit act, not a side effect of opening.
|
|
expect(screen.getByText(en.modelsInherited)).toBeTruthy()
|
|
expect(screen.queryByText(en.resetModels)).toBeNull()
|
|
})
|
|
|
|
|
|
it('keeps expansion on the row it belongs to after an earlier one is removed', async () => {
|
|
await mountSection({
|
|
providers: {
|
|
openai: {
|
|
baseURL: 'https://proxy.example/v1',
|
|
models: [{ id: 'first' }, { id: 'second' }, { id: 'third' }],
|
|
},
|
|
},
|
|
})
|
|
openEditor('openai')
|
|
|
|
// Expansion is keyed by position, so removing an earlier row shifts the
|
|
// rest down; without reindexing, row 3 would inherit row 2's open state.
|
|
expandModel(2)
|
|
fireEvent.click(screen.getByLabelText(`${en.removeModel} 1`))
|
|
|
|
// 'second' now sits at position 1 and keeps its capacities open; 'third'
|
|
// moved to position 2 and stays folded.
|
|
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 1`).value).toBe('second')
|
|
expect(screen.queryByLabelText(`${en.modelContextWindow} 1`)).not.toBeNull()
|
|
expect(screen.queryByLabelText(`${en.modelContextWindow} 2`)).toBeNull()
|
|
})
|
|
|
|
it('leaves an earlier row expanded and forgets the removed row\u2019s own state', async () => {
|
|
await mountSection({
|
|
providers: {
|
|
openai: {
|
|
baseURL: 'https://proxy.example/v1',
|
|
models: [{ id: 'first' }, { id: 'second' }, { id: 'third' }],
|
|
},
|
|
},
|
|
})
|
|
openEditor('openai')
|
|
|
|
// A row before the removal keeps its own position and stays open.
|
|
expandModel(1)
|
|
fireEvent.click(screen.getByLabelText(`${en.removeModel} 2`))
|
|
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 1`).value).toBe('first')
|
|
expect(screen.queryByLabelText(`${en.modelContextWindow} 1`)).not.toBeNull()
|
|
|
|
// Removing the expanded row itself drops that state rather than handing it
|
|
// to whichever row slides into the position.
|
|
fireEvent.click(screen.getByLabelText(`${en.removeModel} 1`))
|
|
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 1`).value).toBe('third')
|
|
expect(screen.queryByLabelText(`${en.modelContextWindow} 1`)).toBeNull()
|
|
})
|
|
|
|
it('separates emptying the list from restoring the adapter defaults', async () => {
|
|
const { mutate } = await mountSection({
|
|
providers: { openai: { baseURL: 'https://proxy.example/v1', models: [{ id: 'kept' }] } },
|
|
})
|
|
openEditor('openai')
|
|
|
|
// An empty override is a route that serves no models — a different intent
|
|
// from handing the catalog back, which is what the reset affordance does.
|
|
expect(screen.getByText(en.modelsCustomized)).toBeTruthy()
|
|
fireEvent.click(screen.getByText(en.resetModels))
|
|
fireEvent.click(screen.getByText(en.apply))
|
|
await waitFor(() => { expect(mutate).toHaveBeenCalled() })
|
|
expect(firstMutate(mutate).ops)
|
|
.toContainEqual({ op: 'unset', path: ['providers', 'openai', 'models'] })
|
|
})
|
|
|
|
})
|
|
|
|
describe('capacity spellings', () => {
|
|
it.each([
|
|
['', undefined],
|
|
['65536', 65_536],
|
|
['256K', 256_000],
|
|
['1m', 1_000_000],
|
|
// A decimal multiple is exact in intent but not in binary floating point,
|
|
// so an integral result snaps back instead of landing a few ULPs high.
|
|
['2.3M', 2_300_000],
|
|
// Not an integral count: kept as written rather than silently rounded.
|
|
['1.0005K', 1000.5],
|
|
])('reads %j as %j', (text, expected) => {
|
|
expect(parseCapacity(text)).toBe(expected)
|
|
})
|
|
|
|
it.each(['abc', '12x', '1 000', '-5', ''])('refuses %j rather than guessing', (text) => {
|
|
const parsed = parseCapacity(text)
|
|
expect(parsed === undefined || Number.isNaN(parsed)).toBe(true)
|
|
})
|
|
|
|
it.each([
|
|
[1_000_000, '1M'],
|
|
[256_000, '256K'],
|
|
[65_536, '65536'],
|
|
// Never a spelling that would not survive being read back.
|
|
[0, '0'],
|
|
[1.5, '1.5'],
|
|
])('spells %j as %j', (value, expected) => {
|
|
expect(formatCapacity(value)).toBe(expected)
|
|
})
|
|
|
|
it('round-trips every spelling it produces', () => {
|
|
for (const value of [1_000_000, 256_000, 65_536, 4096, 1000]) {
|
|
expect(parseCapacity(formatCapacity(value))).toBe(value)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('endpoint interrogation', () => {
|
|
it('asks the endpoint the form shows, with a key that is not yet stored', async () => {
|
|
const discover = vi.fn(() => Promise.resolve(ok({ models: [{ id: 'acme-large', contextWindow: 65_536 }] })))
|
|
await mountSection({ discover })
|
|
openEditor('openai')
|
|
|
|
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'typed-not-saved' } })
|
|
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://edited.example/v1' } })
|
|
fireEvent.click(screen.getByText(en.fetchModels))
|
|
|
|
await waitFor(() => { expect(discover).toHaveBeenCalled() })
|
|
expect(firstProbe(discover)).toEqual({
|
|
settingsNs: 'llm-pi-ai',
|
|
// The route is named, so an adapter that already describes it answers
|
|
// from its own registry rather than the endpoint.
|
|
provider: 'openai',
|
|
baseURL: 'https://edited.example/v1',
|
|
apiKey: 'typed-not-saved',
|
|
})
|
|
})
|
|
|
|
it('carries the protocol the profile already names', async () => {
|
|
const discover = vi.fn(() => Promise.resolve(ok({ models: [] })))
|
|
await mountSection({
|
|
discover,
|
|
providers: { openai: { baseURL: 'https://proxy.example/v1', api: 'openai-responses' } },
|
|
})
|
|
openEditor('openai')
|
|
|
|
fireEvent.click(screen.getByText(en.fetchModels))
|
|
|
|
await waitFor(() => { expect(discover).toHaveBeenCalled() })
|
|
expect(firstProbe(discover)).toEqual({
|
|
settingsNs: 'llm-pi-ai',
|
|
provider: 'openai',
|
|
baseURL: 'https://proxy.example/v1',
|
|
api: 'openai-responses',
|
|
})
|
|
})
|
|
|
|
it('adopts only the picked candidates, keeping a row the user already tuned', async () => {
|
|
const discover = vi.fn(() => Promise.resolve(ok({
|
|
models: [{ id: 'kept', contextWindow: 999 }, { id: 'fresh', contextWindow: 4096, name: 'Fresh' }],
|
|
})))
|
|
const { mutate } = await mountSection({
|
|
discover,
|
|
providers: { openai: { baseURL: 'https://proxy.example/v1', models: [{ id: 'kept', contextWindow: 111 }] } },
|
|
})
|
|
openEditor('openai')
|
|
|
|
fireEvent.click(screen.getByText(en.fetchModels))
|
|
await screen.findByText(en.fetchTitle)
|
|
// The already-configured row starts unchecked; the new one starts checked.
|
|
const boxes = [...document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')]
|
|
expect(boxes.map(box => box.checked)).toEqual([false, true])
|
|
fireEvent.click(screen.getByText(en.fetchAdopt))
|
|
|
|
fireEvent.click(screen.getByText(en.apply))
|
|
await waitFor(() => { expect(mutate).toHaveBeenCalled() })
|
|
expect(firstMutate(mutate).ops[0]?.value).toEqual([
|
|
{ id: 'kept', contextWindow: 111 },
|
|
{ id: 'fresh', contextWindow: 4096, name: 'Fresh' },
|
|
])
|
|
})
|
|
|
|
it('keeps the rows editable when the provider cannot be interrogated', async () => {
|
|
const discover = vi.fn(() => Promise.resolve(
|
|
fail('https://proxy.example/v1/models answered 401; check the API key', 'model-discovery-failed'),
|
|
))
|
|
await mountSection({ discover })
|
|
openEditor('openai')
|
|
|
|
fireEvent.click(screen.getByText(en.fetchModels))
|
|
|
|
await screen.findByText(/answered 401; check the API key/)
|
|
// The failure is a detour, not a dead end: hand-entry is still offered.
|
|
expect(screen.getByRole('button', { name: en.addModel })).toBeTruthy()
|
|
})
|
|
|
|
it('reports an empty listing and a rejected transport', async () => {
|
|
const empty = vi.fn(() => Promise.resolve(ok({ models: [] })))
|
|
await mountSection({ discover: empty })
|
|
openEditor('openai')
|
|
fireEvent.click(screen.getByText(en.fetchModels))
|
|
await screen.findByText(en.fetchEmpty)
|
|
cleanup()
|
|
|
|
const rejected = vi.fn(() => Promise.reject(new Error('carrier down')))
|
|
await mountSection({ discover: rejected })
|
|
openEditor('openai')
|
|
fireEvent.click(screen.getByText(en.fetchModels))
|
|
await screen.findByText('carrier down')
|
|
})
|
|
|
|
it('can be asked for a configured route even with no endpoint', async () => {
|
|
const discover = vi.fn(() => Promise.resolve(ok({ models: [{ id: 'from-registry' }] })))
|
|
await mountSection({ discover, providers: { openai: {} } })
|
|
openEditor('openai')
|
|
|
|
// A route the adapter already describes needs no endpoint at all.
|
|
expect(buttonNamed(en.fetchModels).disabled).toBe(false)
|
|
fireEvent.click(screen.getByText(en.fetchModels))
|
|
|
|
await waitFor(() => { expect(discover).toHaveBeenCalled() })
|
|
expect(firstProbe(discover)).toEqual({ settingsNs: 'llm-pi-ai', provider: 'openai' })
|
|
})
|
|
|
|
it('keeps the create card asking only once it has an endpoint', () => {
|
|
// A provider being declared has no route yet, so the endpoint is the only
|
|
// thing an interrogation could go on.
|
|
const scripted = scriptedFace()
|
|
render(
|
|
<CustomProviderCard
|
|
taken={[]} protocols={PROTOCOLS} revision={7} api={scripted.face as never}
|
|
t={t} readOnly={false} onClose={vi.fn()}
|
|
/>,
|
|
)
|
|
expect(buttonNamed(en.fetchModels).disabled).toBe(true)
|
|
expect(buttonNamed(en.fetchModels).title).toBe(en.fetchNeedsBaseUrl)
|
|
|
|
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
|
expect(buttonNamed(en.fetchModels).disabled).toBe(false)
|
|
fireEvent.click(screen.getByText(en.fetchModels))
|
|
|
|
// A provider being declared names no route, so only the endpoint travels.
|
|
expect(firstProbe(scripted.discover)).toEqual({
|
|
settingsNs: 'llm-pi-ai',
|
|
baseURL: 'https://acme.test/v1',
|
|
api: 'openai-completions',
|
|
})
|
|
})
|
|
|
|
it('folds a row\u2019s capacities away until they are asked for', async () => {
|
|
await mountSection({
|
|
providers: { openai: { baseURL: 'https://proxy.example/v1', models: [{ id: 'only' }] } },
|
|
})
|
|
openEditor('openai')
|
|
|
|
// The row shows what identifies a model; capacities are the exception.
|
|
expect(screen.queryByLabelText(`${en.modelContextWindow} 1`)).toBeNull()
|
|
expandModel(1)
|
|
expect(screen.getByLabelText(`${en.modelContextWindow} 1`)).toBeTruthy()
|
|
expandModel(1)
|
|
expect(screen.queryByLabelText(`${en.modelContextWindow} 1`)).toBeNull()
|
|
})
|
|
|
|
it('closes the picker without adopting anything on cancel', async () => {
|
|
const discover = vi.fn(() => Promise.resolve(ok({ models: [{ id: 'fresh' }] })))
|
|
const { mutate } = await mountSection({ discover })
|
|
openEditor('openai')
|
|
|
|
fireEvent.click(screen.getByText(en.fetchModels))
|
|
const dialog = await screen.findByRole('dialog')
|
|
// The editor card carries a Cancel of its own; this one is the dialog's.
|
|
fireEvent.click(within_(dialog, en.cancel))
|
|
|
|
await waitFor(() => { expect(screen.queryByText(en.fetchTitle)).toBeNull() })
|
|
expect(mutate).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('toggles a candidate off and back on before adopting', async () => {
|
|
const discover = vi.fn(() => Promise.resolve(ok({
|
|
models: [{ id: 'a' }, { id: 'b', maxTokens: 2048 }],
|
|
})))
|
|
const { mutate } = await mountSection({ discover })
|
|
openEditor('openai')
|
|
|
|
fireEvent.click(screen.getByText(en.fetchModels))
|
|
await screen.findByText(en.fetchTitle)
|
|
const boxes = [...document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')]
|
|
const first = boxes[0] as HTMLInputElement
|
|
fireEvent.click(first)
|
|
fireEvent.click(first)
|
|
fireEvent.click(screen.getByText(en.fetchAdopt))
|
|
fireEvent.click(screen.getByText(en.apply))
|
|
|
|
await waitFor(() => { expect(mutate).toHaveBeenCalled() })
|
|
// A disclosed output cap rides along with the candidate that has one.
|
|
expect(firstMutate(mutate).ops[0]?.value).toEqual([{ id: 'a' }, { id: 'b', maxTokens: 2048 }])
|
|
})
|
|
})
|
|
|
|
describe('provider rows', () => {
|
|
it('tags the routes the adapter declared, and only those', async () => {
|
|
await mountSection({
|
|
providers: {
|
|
openai: { apiKeyEnv: 'OPENAI_API_KEY' },
|
|
'acme-gateway': { apiKeyEnv: 'ACME_GATEWAY_API_KEY', baseURL: 'https://acme.test/v1' },
|
|
},
|
|
declaredRoutes: ['acme-gateway'],
|
|
})
|
|
|
|
const rowOf = (provider: string): HTMLElement => {
|
|
const row = screen.getByText(provider).closest('li')
|
|
if (row === null) throw new Error(`no row for ${provider}`)
|
|
return row
|
|
}
|
|
expect(rowOf('acme-gateway').textContent).toContain(en.customTag)
|
|
// `openai` carries a stored profile too — the tag follows the adapter's
|
|
// catalog, not the presence of settings, so it stays off here.
|
|
expect(rowOf('openai').textContent).not.toContain(en.customTag)
|
|
})
|
|
|
|
it('shows no tag when the adapter draws no catalog distinction', async () => {
|
|
const scripted = scriptedFace({ providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } })
|
|
scripted.face.llm.providers = vi.fn(() => Promise.resolve(ok({
|
|
providers: [{
|
|
provider: 'openai',
|
|
displayName: 'openai',
|
|
settingsNs: 'llm-pi-ai',
|
|
settingsPath: ['providers', 'openai'],
|
|
active: true,
|
|
}],
|
|
}))) as never
|
|
const controller = new ModelsSettingsStore(scripted.face as unknown as WireFace)
|
|
await controller.load()
|
|
render(<ModelsSection
|
|
controller={controller}
|
|
useSnapshot={bindSnapshotSelector(controller.store)}
|
|
api={scripted.face as never}
|
|
t={t}
|
|
/>)
|
|
|
|
// Absent is "unknown", never "shipped": an adapter that answers nothing
|
|
// must not have its routes labelled either way.
|
|
expect(screen.queryByText(en.customTag)).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('hand-declared providers', () => {
|
|
function mountCard(
|
|
overrides: Partial<Parameters<typeof CustomProviderCard>[0]> = {},
|
|
wire: Parameters<typeof scriptedFace>[0] = {},
|
|
) {
|
|
const scripted = scriptedFace(wire)
|
|
const onClose = vi.fn()
|
|
render(
|
|
<CustomProviderCard
|
|
taken={['openai']}
|
|
protocols={PROTOCOLS}
|
|
revision={7}
|
|
api={scripted.face as never}
|
|
t={t}
|
|
readOnly={false}
|
|
onClose={onClose}
|
|
{...overrides}
|
|
/>,
|
|
)
|
|
return { ...scripted, onClose }
|
|
}
|
|
|
|
it('writes the whole profile and the key under the derived reference', async () => {
|
|
const { mutate, set, onClose } = mountCard()
|
|
|
|
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } })
|
|
fireEvent.change(screen.getByLabelText(en.customDisplayName), { target: { value: 'Acme Gateway' } })
|
|
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.acme.example/v1' } })
|
|
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'gw-key' } })
|
|
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
|
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
|
|
expandModel(1)
|
|
fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} 1`), { target: { value: '65536' } })
|
|
fireEvent.click(screen.getByText(en.create))
|
|
|
|
await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) })
|
|
expect(firstMutate(mutate)).toEqual({
|
|
ns: 'llm-pi-ai',
|
|
ops: [{
|
|
op: 'set',
|
|
path: ['providers', 'acme-gateway'],
|
|
value: {
|
|
displayName: 'Acme Gateway',
|
|
apiKeyEnv: 'ACME_GATEWAY_API_KEY',
|
|
api: 'openai-completions',
|
|
baseURL: 'https://gateway.acme.example/v1',
|
|
models: [{ id: 'acme-large', contextWindow: 65_536 }],
|
|
},
|
|
}],
|
|
// The section this card was drafted over: a route another tab declared
|
|
// meanwhile makes this a conflict rather than an overwrite.
|
|
expectedRevision: 7,
|
|
})
|
|
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 () => {
|
|
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()
|
|
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'])
|
|
})
|
|
|
|
it('retries only the key after the profile landed, and reports the provider on cancel', async () => {
|
|
const set = vi.fn()
|
|
.mockResolvedValueOnce(fail('credential store is read-only', 'credential-rejected'))
|
|
.mockResolvedValueOnce(ok({}))
|
|
const { mutate, onClose } = mountCard({}, { set })
|
|
|
|
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
|
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
|
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: ' gw-key ' } })
|
|
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
|
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
|
|
fireEvent.click(screen.getByText(en.create))
|
|
|
|
// The profile landed; only the key failed. The card says so and stays open.
|
|
await waitFor(() => { expect(screen.getByText('credential store is read-only')).toBeTruthy() })
|
|
expect(onClose).not.toHaveBeenCalled()
|
|
expect(mutate).toHaveBeenCalledTimes(1)
|
|
// The key is stored trimmed, matching the editor.
|
|
expect(set).toHaveBeenNthCalledWith(1, { ref: 'ACME_API_KEY', value: 'gw-key' })
|
|
|
|
// The provider exists now, so the fields describing it are settled and
|
|
// only the key can still be corrected.
|
|
expect(screen.getByLabelText<HTMLInputElement>(en.customRoute).disabled).toBe(true)
|
|
expect(screen.getByLabelText<HTMLInputElement>(en.baseUrl).disabled).toBe(true)
|
|
expect(screen.getByLabelText<HTMLInputElement>(en.keyInput).disabled).toBe(false)
|
|
|
|
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'gw-key-2' } })
|
|
fireEvent.click(screen.getByText(en.create))
|
|
await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) })
|
|
// Re-running the profile write would carry the revision this card's own
|
|
// first write superseded, so the Host would answer settings-conflict and
|
|
// the key could never be stored from here at all.
|
|
expect(mutate).toHaveBeenCalledTimes(1)
|
|
expect(set).toHaveBeenNthCalledWith(2, { ref: 'ACME_API_KEY', value: 'gw-key-2' })
|
|
})
|
|
|
|
it('reports the created provider when cancelled after its profile landed', async () => {
|
|
const set = vi.fn().mockResolvedValue(fail('nope', 'credential-rejected'))
|
|
const { onClose } = mountCard({}, { set })
|
|
|
|
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
|
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
|
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'gw-key' } })
|
|
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
|
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
|
|
fireEvent.click(screen.getByText(en.create))
|
|
await waitFor(() => { expect(screen.getByText('nope')).toBeTruthy() })
|
|
|
|
// Walking away leaves a real provider behind; reporting no change would
|
|
// leave the page without the row it now has.
|
|
fireEvent.click(screen.getByText(en.cancel))
|
|
expect(onClose).toHaveBeenCalledWith(true)
|
|
})
|
|
|
|
it('refuses a route id whose derived credential reference would be illegal', () => {
|
|
mountCard()
|
|
const routeField = screen.getByLabelText(en.customRoute)
|
|
fireEvent.change(routeField, { target: { value: 'https://acme.test/v1' } })
|
|
|
|
// A digit-leading id used to pass every check this card makes and then
|
|
// fail at the credential seam with a raw regular expression: the
|
|
// reference derives as `123_API_KEY`, and a credential reference is a
|
|
// POSIX shell identifier, which cannot start with a digit.
|
|
fireEvent.change(routeField, { target: { value: '123' } })
|
|
expect(screen.getByText(en.customRouteInvalid)).toBeTruthy()
|
|
expect(buttonNamed(en.create).disabled).toBe(true)
|
|
|
|
fireEvent.change(routeField, { target: { value: 'a1' } })
|
|
expect(screen.queryByText(en.customRouteInvalid)).toBeNull()
|
|
})
|
|
|
|
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.
|
|
const CREDENTIAL_REF = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
for (const id of ['a', 'ds', 'a1', 'acme-gateway', 'x-1-y', 'zz9']) {
|
|
expect(CREDENTIAL_REF.test(deriveKeyRef(id))).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('names the blocked gate under the form, and nothing once it is satisfied', () => {
|
|
mountCard()
|
|
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
|
|
|
// Endpoint first: the gate names the one thing standing in the way.
|
|
expect(screen.getByText(en.customNeedsBaseUrl)).toBeTruthy()
|
|
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
|
expect(screen.getByText(en.customNeedsModels)).toBeTruthy()
|
|
|
|
// Satisfied: the shared line disappears rather than rendering empty.
|
|
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
|
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
|
|
expect(screen.queryByText(en.customNeedsBaseUrl)).toBeNull()
|
|
expect(screen.queryByText(en.customNeedsModels)).toBeNull()
|
|
expect(buttonNamed(en.create).disabled).toBe(false)
|
|
})
|
|
|
|
it('refuses to create while a capacity is unreadable', () => {
|
|
mountCard()
|
|
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
|
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
|
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
|
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
|
|
expandModel(1)
|
|
fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} 1`), { target: { value: '64 KiB' } })
|
|
|
|
expect(screen.getByText(`${en.model} 1: ${en.modelContextInvalid}`)).toBeTruthy()
|
|
expect(buttonNamed(en.create).disabled).toBe(true)
|
|
})
|
|
|
|
it('keeps each half-typed capacity with its own row across a removal', () => {
|
|
mountCard()
|
|
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
|
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
|
for (const [at, id] of [[1, 'first'], [2, 'second'], [3, 'third']] as const) {
|
|
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
|
fireEvent.change(screen.getByLabelText(`${en.modelId} ${String(at)}`), { target: { value: id } })
|
|
expandModel(at)
|
|
// Deliberately mid-word: the buffer exists so text like this survives.
|
|
fireEvent.change(screen.getByLabelText(`${en.modelContextWindow} ${String(at)}`),
|
|
{ target: { value: `${String(at)}.` } })
|
|
}
|
|
|
|
// Removing the middle row: the one before keeps its position and text, the
|
|
// one after moves down carrying its own, and the removed row's text goes.
|
|
fireEvent.click(screen.getByLabelText(`${en.removeModel} 2`))
|
|
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 1`).value).toBe('first')
|
|
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelContextWindow} 1`).value).toBe('1.')
|
|
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 2`).value).toBe('third')
|
|
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelContextWindow} 2`).value).toBe('3.')
|
|
})
|
|
|
|
it('refuses two models sharing one id', () => {
|
|
mountCard()
|
|
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
|
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
|
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
|
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
|
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'same' } })
|
|
fireEvent.change(screen.getByLabelText(`${en.modelId} 2`), { target: { value: 'same' } })
|
|
|
|
// The adapter refuses a duplicate outright, so the form must not offer to
|
|
// write one.
|
|
expect(screen.getByText(`${en.model} 2: ${en.modelIdDuplicate}`)).toBeTruthy()
|
|
expect(buttonNamed(en.create).disabled).toBe(true)
|
|
|
|
fireEvent.change(screen.getByLabelText(`${en.modelId} 2`), { target: { value: 'other' } })
|
|
expect(buttonNamed(en.create).disabled).toBe(false)
|
|
})
|
|
|
|
it('creates a model with no capacities, which the route\u2019s fallbacks size', async () => {
|
|
const { mutate, onClose } = mountCard()
|
|
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
|
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
|
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
|
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'bare' } })
|
|
|
|
// A listing that discloses nothing but ids is enough to create a working
|
|
// provider; the adapter sizes what configuration leaves out.
|
|
expect(buttonNamed(en.create).disabled).toBe(false)
|
|
fireEvent.click(screen.getByText(en.create))
|
|
|
|
await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) })
|
|
expect(firstMutate(mutate).ops[0]?.value).toMatchObject({ models: [{ id: 'bare' }] })
|
|
})
|
|
|
|
it('refuses to create until the route, endpoint, and a model are usable', () => {
|
|
mountCard()
|
|
expect(buttonNamed(en.create).disabled).toBe(true)
|
|
|
|
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'Acme Gateway' } })
|
|
expect(screen.getByText(en.customRouteInvalid)).toBeTruthy()
|
|
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'openai' } })
|
|
expect(screen.getByText(en.customRouteTaken)).toBeTruthy()
|
|
|
|
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
|
expect(screen.getByText(en.customNeedsBaseUrl)).toBeTruthy()
|
|
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
|
expect(screen.getByText(en.customNeedsModels)).toBeTruthy()
|
|
expect(buttonNamed(en.create).disabled).toBe(true)
|
|
|
|
// A model row with no id is not a model.
|
|
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
|
expect(buttonNamed(en.create).disabled).toBe(true)
|
|
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
|
|
expect(buttonNamed(en.create).disabled).toBe(false)
|
|
})
|
|
|
|
it('surfaces a refused write and a rejected transport without closing', async () => {
|
|
const refused = vi.fn(() => Promise.resolve(fail('read-only settings', 'settings-rejected')))
|
|
const { onClose } = mountCard({ api: { ...scriptedFace({ mutate: refused }).face } as never })
|
|
|
|
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
|
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
|
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
|
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
|
|
fireEvent.click(screen.getByText(en.create))
|
|
|
|
await screen.findByText('read-only settings')
|
|
expect(onClose).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('surfaces a rejected transport during create', async () => {
|
|
const rejecting = vi.fn(() => Promise.reject(new Error('carrier down')))
|
|
const { onClose } = mountCard({ api: { ...scriptedFace({ mutate: rejecting }).face } as never })
|
|
|
|
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
|
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
|
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
|
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
|
|
fireEvent.click(screen.getByText(en.create))
|
|
|
|
await screen.findByText('carrier down')
|
|
expect(onClose).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('reports a stored profile whose key write was refused', async () => {
|
|
const set = vi.fn(() => Promise.resolve(fail('credential is read-only', 'credential-rejected')))
|
|
const { onClose } = mountCard({ api: { ...scriptedFace({ set }).face } as never })
|
|
|
|
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
|
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
|
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'k' } })
|
|
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
|
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
|
|
fireEvent.click(screen.getByText(en.create))
|
|
|
|
await screen.findByText('credential is read-only')
|
|
expect(onClose).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('creates with the chosen protocol and no display name', async () => {
|
|
const { mutate, onClose } = mountCard()
|
|
|
|
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
|
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
|
fireEvent.change(screen.getByLabelText(en.customApi), { target: { value: 'anthropic-messages' } })
|
|
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
|
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
|
|
fireEvent.click(screen.getByText(en.create))
|
|
|
|
await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) })
|
|
// No display name configured means none stored; the route id is the name.
|
|
// No key typed means no reference either, matching the editor: the route
|
|
// keeps its provider-native auth path instead of resolving a reference
|
|
// nothing ever sets. The with-key case is covered above.
|
|
expect(firstMutate(mutate).ops[0]?.value).toEqual({
|
|
api: 'anthropic-messages',
|
|
baseURL: 'https://acme.test/v1',
|
|
models: [{ id: 'm' }],
|
|
})
|
|
})
|
|
|
|
it('offers no protocol when the namespace declares none', () => {
|
|
mountCard({ protocols: [] })
|
|
expect(screen.getByLabelText<HTMLSelectElement>(en.customApi).value).toBe('')
|
|
})
|
|
|
|
it('closes without writing on cancel, and honors a read-only deployment', () => {
|
|
const { onClose, mutate } = mountCard()
|
|
fireEvent.click(screen.getByText(en.cancel))
|
|
expect(onClose).toHaveBeenCalledWith(false)
|
|
expect(mutate).not.toHaveBeenCalled()
|
|
cleanup()
|
|
|
|
mountCard({ readOnly: true })
|
|
expect(screen.getByLabelText<HTMLInputElement>(en.customRoute).disabled).toBe(true)
|
|
expect(buttonNamed(en.create).disabled).toBe(true)
|
|
})
|
|
|
|
it('closes the create card when an existing row is opened for editing', async () => {
|
|
await mountSection({ providers: { openai: { baseURL: 'https://proxy.example/v1' } } })
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: en.customAdd }))
|
|
expect(screen.getByText(en.customTitle)).toBeTruthy()
|
|
|
|
// Two cards at once would each be closable by the other: whichever one is
|
|
// dismissed clears the shared state and discards the other's draft.
|
|
openEditor('openai')
|
|
expect(screen.queryByText(en.customTitle)).toBeNull()
|
|
})
|
|
|
|
it('reaches the card from the section and returns to the button on cancel', async () => {
|
|
await mountSection()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: en.customAdd }))
|
|
expect(screen.getByText(en.customTitle)).toBeTruthy()
|
|
|
|
fireEvent.click(screen.getByText(en.cancel))
|
|
await waitFor(() => { expect(screen.queryByText(en.customTitle)).toBeNull() })
|
|
expect(screen.getByRole('button', { name: en.customAdd })).toBeTruthy()
|
|
})
|
|
|
|
it('refuses an unusable key on the field and blocks creation', () => {
|
|
const { mutate, set } = mountCard()
|
|
|
|
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } })
|
|
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.acme.example/v1' } })
|
|
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
|
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
|
|
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-\u{1F600}' } })
|
|
|
|
// A hand-declared route reaches the same judgement as an edited one, so a
|
|
// key that no header can carry never becomes a profile plus a bad secret.
|
|
expect(screen.getByText(en.keyIllegalCharacters)).toBeTruthy()
|
|
expect(buttonNamed(en.create).disabled).toBe(true)
|
|
expect(mutate).not.toHaveBeenCalled()
|
|
expect(set).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('stays silent about the other gates when only the key is refused', () => {
|
|
mountCard()
|
|
|
|
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } })
|
|
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.acme.example/v1' } })
|
|
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
|
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
|
|
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-\u{1F600}' } })
|
|
|
|
// Route, endpoint, and models are all satisfied, so answering with the
|
|
// next unmet gate would print a second, false fault beside the real one.
|
|
expect(screen.getByText(en.keyIllegalCharacters)).toBeTruthy()
|
|
expect(screen.queryByText(en.customNeedsModels)).toBeNull()
|
|
expect(screen.queryByText(en.customNeedsBaseUrl)).toBeNull()
|
|
})
|
|
|
|
it('tells a whitespace-only key what a blank field means on a create card', () => {
|
|
const { mutate } = mountCard()
|
|
|
|
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } })
|
|
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.acme.example/v1' } })
|
|
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
|
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
|
|
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: ' ' } })
|
|
|
|
// There is no stored key to keep here, so the blank case says the thing
|
|
// that is true of a route being declared: it may authenticate elsewhere.
|
|
expect(screen.getByText(en.keyBlankNew)).toBeTruthy()
|
|
expect(screen.queryByText(en.keyBlank)).toBeNull()
|
|
expect(buttonNamed(en.fetchModels).title).toBe(en.keyBlankNew)
|
|
expect(buttonNamed(en.create).disabled).toBe(true)
|
|
expect(mutate).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('creates without a key when the route authenticates some other way', async () => {
|
|
const { set, onClose } = mountCard()
|
|
|
|
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'ambient-gateway' } })
|
|
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.acme.example/v1' } })
|
|
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
|
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
|
|
fireEvent.click(screen.getByText(en.create))
|
|
|
|
await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) })
|
|
expect(set).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('API key field', () => {
|
|
it('submits with a blank key field without writing a credential', async () => {
|
|
const { mutate, set } = await mountSection()
|
|
openEditor('openai')
|
|
|
|
// The field opens empty even for a provider whose key is stored, where it
|
|
// means "keep that one" — so editing anything else must not require it.
|
|
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://moved.example/v1' } })
|
|
expect(buttonNamed(en.apply).disabled).toBe(false)
|
|
fireEvent.click(screen.getByText(en.apply))
|
|
|
|
await waitFor(() => { expect(mutate).toHaveBeenCalled() })
|
|
expect(set).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('clears a whitespace-only base URL instead of writing the spaces', async () => {
|
|
const { mutate } = await mountSection()
|
|
openEditor('openai')
|
|
|
|
// The field renders this as empty, so the draft must agree: storing the
|
|
// spaces would hand both adapters a non-empty string they accept as a URL.
|
|
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: ' ' } })
|
|
fireEvent.click(screen.getByText(en.apply))
|
|
|
|
await waitFor(() => { expect(mutate).toHaveBeenCalled() })
|
|
const ops = firstMutate(mutate).ops
|
|
expect(ops.some(op => op.op === 'set' && op.path.includes('baseURL'))).toBe(false)
|
|
expect(ops.some(op => op.op === 'unset' && op.path.includes('baseURL'))).toBe(true)
|
|
})
|
|
|
|
it('blocks submit and names the field when the key holds only whitespace', async () => {
|
|
const { mutate, set } = await mountSection()
|
|
openEditor('openai')
|
|
|
|
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: ' ' } })
|
|
|
|
expect(screen.getByText(en.keyBlank)).toBeTruthy()
|
|
expect(buttonNamed(en.apply).disabled).toBe(true)
|
|
expect(mutate).not.toHaveBeenCalled()
|
|
expect(set).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('blocks submit when the key contains characters no header can carry', async () => {
|
|
const { set } = await mountSection()
|
|
openEditor('openai')
|
|
|
|
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-\u{1F600}' } })
|
|
|
|
expect(screen.getByText(en.keyIllegalCharacters)).toBeTruthy()
|
|
expect(buttonNamed(en.apply).disabled).toBe(true)
|
|
expect(set).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('blocks submit when a whole NAME=value line was pasted', async () => {
|
|
await mountSection()
|
|
openEditor('openai')
|
|
|
|
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'OPENAI_API_KEY=sk-abc' } })
|
|
|
|
expect(screen.getByText(en.keyIllegalCharacters)).toBeTruthy()
|
|
expect(buttonNamed(en.apply).disabled).toBe(true)
|
|
})
|
|
|
|
it('trims a padded key before storing it', async () => {
|
|
const { set } = await mountSection()
|
|
openEditor('openai')
|
|
|
|
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: ' sk-abc ' } })
|
|
expect(buttonNamed(en.apply).disabled).toBe(false)
|
|
fireEvent.click(screen.getByText(en.apply))
|
|
|
|
await waitFor(() => { expect(set).toHaveBeenCalled() })
|
|
expect((set.mock.calls[0]?.[0] as { value: string }).value).toBe('sk-abc')
|
|
})
|
|
|
|
it('blocks the interrogation too, rather than spending a round trip on a refused key', async () => {
|
|
const { discover } = await mountSection()
|
|
openEditor('openai')
|
|
|
|
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-\u{1F600}' } })
|
|
|
|
// The host would refuse this before building the header anyway; asking is
|
|
// a round trip to be told what the field already says.
|
|
expect(buttonNamed(en.fetchModels).disabled).toBe(true)
|
|
expect(buttonNamed(en.fetchModels).title).toBe(en.keyIllegalCharacters)
|
|
expect(discover).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('carries the trimmed key into an interrogation, not the padded draft', async () => {
|
|
const { discover } = await mountSection()
|
|
openEditor('openai')
|
|
|
|
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: ' sk-abc ' } })
|
|
fireEvent.click(screen.getByRole('button', { name: en.fetchModels }))
|
|
|
|
await waitFor(() => { expect(discover).toHaveBeenCalled() })
|
|
expect(firstProbe(discover)).toMatchObject({ apiKey: 'sk-abc' })
|
|
})
|
|
|
|
it('reloads the section after creating a hand-declared provider', async () => {
|
|
const { controller, mutate } = await mountSection()
|
|
const load = vi.spyOn(controller, 'load')
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: en.customAdd }))
|
|
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
|
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
|
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
|
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
|
|
fireEvent.click(screen.getByText(en.create))
|
|
|
|
await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
|
await waitFor(() => { expect(load).toHaveBeenCalledOnce() })
|
|
expect(screen.queryByText(en.customTitle)).toBeNull()
|
|
})
|
|
})
|