components.spec.tsx 57 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267
  1. // @vitest-environment jsdom
  2. /** Section, setup-card, and hand-written editor behavior over a scripted wire face. */
  3. import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
  4. import { afterEach, describe, expect, it, vi } from 'vitest'
  5. import Schema from '@deepseek-ai/schemastery'
  6. import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
  7. import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
  8. import {
  9. ModelsSection, needsSetup, providerCopy, providerTargetLabel, removeProviderProfile,
  10. } from '../src/client/ModelsSection.tsx'
  11. import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx'
  12. import { pathOps } from '../src/client/ProviderEditor.tsx'
  13. import {
  14. DeepSeekModelsEditor, formatCapacity, modelDrafts, parseCapacity, validateDeepSeekModels,
  15. } from '../src/client/DeepSeekModelsEditor.tsx'
  16. import { apiKeyFailure } from '../src/client/apiKey.ts'
  17. import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts'
  18. import type { ProviderRow } from '../src/client/store.ts'
  19. import { en } from '../src/client/locales.ts'
  20. afterEach(cleanup)
  21. const t: ModelsSectionInjected['t'] = key => en[key]
  22. const OPENAI_TARGET = { provider: 'openai', displayName: 'openai' }
  23. const openaiCopy = (template: string): string => providerCopy(template, OPENAI_TARGET)
  24. /** Open one row's capacity disclosure (1-based, as the labels read). */
  25. function expandRow(position: number): void {
  26. fireEvent.click(screen.getByLabelText(`${en.modelAdvanced} ${String(position)}`))
  27. }
  28. /** The capacity inputs of every open row, in row order. */
  29. function capacityInputs(label: string): HTMLInputElement[] {
  30. return screen.getAllByLabelText<HTMLInputElement>(new RegExp(label))
  31. }
  32. const PiAiConfig = Schema.object({
  33. providers: Schema.dict(Schema.object({
  34. apiKeyEnv: Schema.string().role('credential-ref'),
  35. baseURL: Schema.string(),
  36. reasoning: Schema.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']),
  37. headers: Schema.dict(Schema.string()),
  38. })),
  39. })
  40. const DeepSeekConfig = Schema.object({
  41. apiKeyEnv: Schema.string().role('credential-ref'),
  42. baseURL: Schema.string().pattern(/^https:\/\//),
  43. reasoningEffort: Schema.union(['off', 'high', 'max']),
  44. defaultContextWindow: Schema.number().step(1).min(1),
  45. models: Schema.array(Schema.object({
  46. id: Schema.string().required(),
  47. name: Schema.string(),
  48. description: Schema.string(),
  49. contextWindow: Schema.number().step(1).min(1),
  50. // The adapter declares its catalog as a schema default rather than a
  51. // composition entry, which is what the restore-defaults path has to read.
  52. })).default([
  53. {
  54. id: 'deepseek-v4-flash',
  55. name: 'DeepSeek-V4-Flash',
  56. description: '',
  57. contextWindow: 1_000_000,
  58. },
  59. {
  60. id: 'deepseek-v4-pro',
  61. name: 'DeepSeek-V4-Pro',
  62. description: '',
  63. contextWindow: 1_000_000,
  64. },
  65. ]),
  66. })
  67. const DEFAULT_DEEPSEEK_MODELS = [
  68. {
  69. id: 'deepseek-v4-flash',
  70. name: 'DeepSeek-V4-Flash',
  71. description: 'Preserved hidden detail',
  72. contextWindow: 1_000_000,
  73. },
  74. { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: 1_000_000 },
  75. ]
  76. function wireNamespaces(): SettingsNamespaceView[] {
  77. return [
  78. {
  79. ns: 'llm-deepseek',
  80. schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
  81. value: {
  82. apiKeyEnv: 'DEEPSEEK_API_KEY',
  83. baseURL: 'https://base',
  84. defaultContextWindow: 1_000_000,
  85. maxTokens: 256_000,
  86. models: DEFAULT_DEEPSEEK_MODELS,
  87. },
  88. base: { defaultContextWindow: 1_000_000, maxTokens: 256_000, models: DEFAULT_DEEPSEEK_MODELS },
  89. user: { baseURL: 'https://base' },
  90. applies: 'live',
  91. secrets: [],
  92. revision: 0,
  93. },
  94. {
  95. ns: 'llm-plain',
  96. schema: JSON.parse(JSON.stringify(Schema.object({
  97. profiles: Schema.dict(Schema.object({ note: Schema.string() })),
  98. }).toJSON())) as unknown,
  99. value: {},
  100. applies: 'live',
  101. secrets: [],
  102. revision: 0,
  103. },
  104. {
  105. ns: 'llm-pi-ai',
  106. schema: JSON.parse(JSON.stringify(PiAiConfig.toJSON())) as unknown,
  107. value: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } },
  108. user: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } },
  109. applies: 'live',
  110. secrets: [],
  111. revision: 0,
  112. },
  113. ]
  114. }
  115. let nextRpc = 0
  116. function ok<T>(value: T): RpcResponse<T> {
  117. return { rpcId: `r-${nextRpc++}` as never, result: { ok: true, value } }
  118. }
  119. function fail<T>(message: string, code = 'settings-rejected'): RpcResponse<T> {
  120. return {
  121. rpcId: `r-${nextRpc++}` as never,
  122. result: { ok: false, error: { code, message, details: { ns: 'x' } } as never },
  123. }
  124. }
  125. function scriptedFace(overrides: {
  126. update?: ReturnType<typeof vi.fn>
  127. replace?: ReturnType<typeof vi.fn>
  128. mutate?: ReturnType<typeof vi.fn>
  129. set?: ReturnType<typeof vi.fn>
  130. unset?: ReturnType<typeof vi.fn>
  131. } = {}) {
  132. const update = overrides.update ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
  133. const replace = overrides.replace ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
  134. const mutate = overrides.mutate ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
  135. const set = overrides.set ?? vi.fn(() => Promise.resolve(ok({})))
  136. const unset = overrides.unset ?? vi.fn(() => Promise.resolve(ok({})))
  137. const face = {
  138. llm: {
  139. providers: vi.fn(() => Promise.resolve(ok({
  140. providers: [
  141. { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true },
  142. { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true },
  143. { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false },
  144. { provider: 'zombie', displayName: 'zombie', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'zombie'], active: false },
  145. { provider: 'broken', displayName: 'broken', settingsNs: 'llm-pi-ai', settingsPath: ['nope', 'x'], active: false },
  146. { provider: 'plain', displayName: 'plain', settingsNs: 'llm-plain', settingsPath: ['profiles', 'plain'], active: false },
  147. ],
  148. }))),
  149. models: vi.fn(() => Promise.resolve(ok({ groups: [], failures: [] }))),
  150. },
  151. settings: {
  152. describe: vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: wireNamespaces() }))),
  153. update,
  154. replace,
  155. mutate,
  156. },
  157. credentials: {
  158. describe: vi.fn((payload: { refs: string[] }) => Promise.resolve(ok({
  159. credentials: Object.fromEntries(payload.refs.map(ref => [ref, {
  160. configured: ref === 'OPENAI_API_KEY',
  161. ...ref === 'OPENAI_API_KEY' ? { source: 'file' } : {},
  162. writable: true,
  163. }])),
  164. }))),
  165. set,
  166. unset,
  167. },
  168. }
  169. return { face, update, replace, mutate, set, unset }
  170. }
  171. type WireFace = ConstructorParameters<typeof ModelsSettingsStore>[0]
  172. async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {}) {
  173. const { face, update, replace, mutate, set, unset } = scriptedFace(overrides)
  174. const controller = new ModelsSettingsStore(face as unknown as WireFace)
  175. await controller.load()
  176. const injected: ModelsSectionInjected = {
  177. controller,
  178. useSnapshot: bindSnapshotSelector(controller.store),
  179. api: face as never,
  180. t,
  181. }
  182. const view = render(<ModelsSection {...injected} />)
  183. return { view, face, update, replace, mutate, set, unset, controller }
  184. }
  185. describe('ModelsSection', () => {
  186. it('renders nothing before the slot injects its dependencies', () => {
  187. const uninjected = {} as ModelsSectionProps
  188. render(<ModelsSection {...uninjected} />)
  189. expect(document.body.textContent).toBe('')
  190. })
  191. it('renders the unkeyed whole-section provider as an open setup card beside the rows', async () => {
  192. await mountSection()
  193. // DeepSeek has no configured credential and no stored apiKey → setup card.
  194. expect(screen.getByText('DeepSeek')).toBeTruthy()
  195. expect(screen.getByLabelText(en.keyInput)).toBeTruthy()
  196. expect(screen.getByText('openai')).toBeTruthy()
  197. expect(screen.queryByText('Active')).toBeNull()
  198. expect(screen.queryByText('Inactive')).toBeNull()
  199. const configured = screen.getByRole('img', { name: en.credentialConfigured })
  200. expect(configured.getAttribute('title')).toBe(en.credentialConfigured)
  201. expect(configured.className).toContain('credentialDotConfigured')
  202. expect(configured.closest('li')?.textContent).toContain('openai')
  203. expect(screen.queryByRole('img', { name: en.credentialMissing })).toBeNull()
  204. expect(screen.getByText(en.add)).toBeTruthy()
  205. })
  206. it('marks only a confirmed missing reference and leaves native or unavailable state unmarked', async () => {
  207. const { face } = scriptedFace()
  208. face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
  209. credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: false, writable: true }])),
  210. })))
  211. const controller = new ModelsSettingsStore(face as unknown as WireFace)
  212. await controller.load()
  213. render(<ModelsSection
  214. controller={controller}
  215. useSnapshot={bindSnapshotSelector(controller.store)}
  216. api={face as never}
  217. t={t}
  218. />)
  219. const missing = screen.getByRole('img', { name: en.credentialMissing })
  220. expect(missing.getAttribute('title')).toBe(en.credentialMissing)
  221. expect(missing.className).toContain('credentialDotMissing')
  222. expect(missing.closest('li')?.textContent).toContain('openai')
  223. expect(screen.queryByRole('img', { name: en.credentialConfigured })).toBeNull()
  224. expect(screen.getByText('zombie').closest('li')?.querySelector('[role="img"]')).toBeNull()
  225. })
  226. it('turns the setup card into a row once the credential reports configured', async () => {
  227. const { face } = await mountSection()
  228. face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
  229. credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: true, writable: true }])),
  230. })))
  231. const controller = new ModelsSettingsStore(face as unknown as WireFace)
  232. await controller.load()
  233. cleanup()
  234. render(<ModelsSection
  235. controller={controller}
  236. useSnapshot={bindSnapshotSelector(controller.store)}
  237. api={face as never}
  238. t={t}
  239. />)
  240. // Now a row with an Edit button, not an open card.
  241. expect(screen.getAllByText(en.edit).length).toBeGreaterThan(1)
  242. expect(screen.queryByLabelText(en.keyInput)).toBeNull()
  243. })
  244. it('decides setup need from the joined credential state', () => {
  245. const entry = { provider: 'p', displayName: 'p', settingsNs: 'llm-deepseek', settingsPath: [], active: true }
  246. const row = (credential: ProviderRow['credential']): ProviderRow => ({
  247. entry,
  248. configured: true,
  249. removable: false,
  250. apiKeyEnv: 'X',
  251. credential,
  252. })
  253. expect(needsSetup(row(undefined))).toBe(true)
  254. expect(needsSetup(row({ configured: true, writable: true }))).toBe(false)
  255. const nested = { ...row(undefined), entry: { ...entry, settingsPath: ['providers', 'x'] } }
  256. expect(needsSetup(nested)).toBe(false)
  257. })
  258. it('derives conventional credential references from route ids', () => {
  259. expect(deriveKeyRef('anthropic')).toBe('ANTHROPIC_API_KEY')
  260. expect(deriveKeyRef('minimax-cn')).toBe('MINIMAX_CN_API_KEY')
  261. })
  262. it('uses one stable provider identity in action copy', () => {
  263. const target = { provider: 'deepseek-official', displayName: 'DeepSeek' }
  264. expect(providerTargetLabel(target)).toBe('DeepSeek (deepseek-official)')
  265. expect(providerCopy(en.deleteTitle, target)).toBe('Delete DeepSeek (deepseek-official)?')
  266. expect(providerTargetLabel(OPENAI_TARGET)).toBe('openai')
  267. })
  268. it('names only changed fields instead of rebuilding the section', () => {
  269. expect(pathOps(['providers', 'openai'], { baseURL: 'https://old', reasoning: 'high' }, { reasoning: 'high' }))
  270. .toEqual([{ op: 'unset', path: ['providers', 'openai', 'baseURL'] }])
  271. expect(pathOps([], { b: 1 }, { b: 2, d: 3 }))
  272. .toEqual([{ op: 'set', path: ['b'], value: 2 }, { op: 'set', path: ['d'], value: 3 }])
  273. expect(pathOps([], undefined, {})).toEqual([])
  274. expect(pathOps([], { a: 1 }, { a: 1 })).toEqual([])
  275. })
  276. it('stores a typed key write-only from the setup card without touching settings', async () => {
  277. const { set, update, face } = await mountSection()
  278. const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
  279. fireEvent.change(key, { target: { value: ' sk-live ' } })
  280. fireEvent.click(screen.getByText(en.apply))
  281. await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'sk-live' }) })
  282. expect(update).not.toHaveBeenCalled()
  283. await waitFor(() => { expect(face.settings.describe.mock.calls.length).toBeGreaterThan(1) })
  284. expect((await screen.findByRole('status')).textContent).toBe(
  285. providerCopy(en.savedProvider, { provider: 'deepseek-official', displayName: 'DeepSeek' }),
  286. )
  287. fireEvent.click(screen.getByText(en.add))
  288. expect(screen.queryByRole('status')).toBeNull()
  289. })
  290. it('applies customized deepseek fields as path ops', async () => {
  291. const { mutate } = await mountSection({
  292. mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
  293. })
  294. fireEvent.click(screen.getByText(en.customized))
  295. const baseURL = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
  296. // The deepseek placeholder is pinned to the public endpoint, not the
  297. // effective value (which may reflect a launch-environment override).
  298. expect(baseURL.placeholder).toBe('https://api.deepseek.com')
  299. fireEvent.change(baseURL, { target: { value: 'https://next2' } })
  300. fireEvent.click(screen.getByText(en.apply))
  301. await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
  302. // Only the field that actually changed: reasoningEffort was already
  303. // 'high' in the loaded profile, so it produces no op.
  304. expect(mutate.mock.calls[0]?.[0]).toEqual({
  305. ns: 'llm-deepseek',
  306. ops: [{ op: 'set', path: ['baseURL'], value: 'https://next2' }],
  307. expectedRevision: 0,
  308. })
  309. })
  310. it('materializes inherited models and adds an arbitrary DeepSeek id', async () => {
  311. const { mutate } = await mountSection({
  312. mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
  313. })
  314. fireEvent.click(screen.getByText(en.customized))
  315. expect(screen.getByText(en.modelsInherited)).toBeTruthy()
  316. expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value))
  317. .toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
  318. fireEvent.click(screen.getByText(en.addModel))
  319. const ids = screen.getAllByLabelText(new RegExp(en.modelId))
  320. const names = screen.getAllByLabelText(new RegExp(en.modelName))
  321. expandRow(3)
  322. fireEvent.change(ids[2] as HTMLInputElement, { target: { value: 'private-preview' } })
  323. fireEvent.change(names[2] as HTMLInputElement, { target: { value: 'Private Preview' } })
  324. // Only row 3 is open, so its capacity is addressed by its own label.
  325. fireEvent.change(screen.getByLabelText(`${en.contextWindow} 3`), { target: { value: '131072' } })
  326. fireEvent.click(screen.getByText(en.apply))
  327. await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
  328. expect(mutate.mock.calls[0]?.[0]).toEqual({
  329. ns: 'llm-deepseek',
  330. ops: [{
  331. op: 'set',
  332. path: ['models'],
  333. value: [
  334. ...DEFAULT_DEEPSEEK_MODELS,
  335. { id: 'private-preview', name: 'Private Preview', contextWindow: 131_072 },
  336. ],
  337. }],
  338. expectedRevision: 0,
  339. })
  340. })
  341. it('rejects duplicate DeepSeek model ids before writing', async () => {
  342. const { mutate } = await mountSection()
  343. fireEvent.click(screen.getByText(en.customized))
  344. fireEvent.click(screen.getByText(en.addModel))
  345. const ids = screen.getAllByLabelText(new RegExp(en.modelId))
  346. fireEvent.change(ids[2] as HTMLInputElement, { target: { value: 'deepseek-v4-flash' } })
  347. fireEvent.click(screen.getByText(en.apply))
  348. await screen.findByText(`Model 3: ${en.modelIdDuplicate}`)
  349. expect(mutate).not.toHaveBeenCalled()
  350. })
  351. it('validates every adapter-owned model catalog invariant', () => {
  352. expect(modelDrafts(undefined)).toEqual([])
  353. expect(modelDrafts([null, 'bad', { id: 'ok' }])).toEqual([{}, {}, { id: 'ok' }])
  354. expect(validateDeepSeekModels([{}])).toEqual({ index: 0, key: 'modelIdRequired' })
  355. expect(validateDeepSeekModels([{ id: 'same' }, { id: 'same' }]))
  356. .toEqual({ index: 1, key: 'modelIdDuplicate' })
  357. expect(validateDeepSeekModels([{ id: 'model', name: '' }]))
  358. .toEqual({ index: 0, key: 'modelNameInvalid' })
  359. expect(validateDeepSeekModels([{ id: 'model', contextWindow: null }]))
  360. .toEqual({ index: 0, key: 'modelContextInvalid' })
  361. expect(validateDeepSeekModels([{ id: 'model', contextWindow: 1.5 }]))
  362. .toEqual({ index: 0, key: 'modelContextInvalid' })
  363. expect(validateDeepSeekModels([{ id: 'model', contextWindow: 0 }]))
  364. .toEqual({ index: 0, key: 'modelContextInvalid' })
  365. expect(validateDeepSeekModels([{ id: 'model', contextWindow: 1 }])).toBeUndefined()
  366. expect(validateDeepSeekModels([{ id: 'model', maxTokens: null }]))
  367. .toEqual({ index: 0, key: 'modelMaxTokensInvalid' })
  368. expect(validateDeepSeekModels([{ id: 'model', maxTokens: 1.5 }]))
  369. .toEqual({ index: 0, key: 'modelMaxTokensInvalid' })
  370. expect(validateDeepSeekModels([{ id: 'model', maxTokens: 0 }]))
  371. .toEqual({ index: 0, key: 'modelMaxTokensInvalid' })
  372. expect(validateDeepSeekModels([{ id: 'model', maxTokens: 8192 }])).toBeUndefined()
  373. })
  374. it('reads context windows written as counts, thousands, or millions', () => {
  375. expect(parseCapacity('')).toBeUndefined()
  376. expect(parseCapacity(' ')).toBeUndefined()
  377. expect(parseCapacity('131072')).toBe(131_072)
  378. expect(parseCapacity(' 256K ')).toBe(256_000)
  379. expect(parseCapacity('256k')).toBe(256_000)
  380. expect(parseCapacity('1M')).toBe(1_000_000)
  381. expect(parseCapacity('1m')).toBe(1_000_000)
  382. // 1M is 1000K, not 1024K: capacities are quoted in decimal.
  383. expect(parseCapacity('1M')).toBe(parseCapacity('1000K'))
  384. // 2.3 * 1e6 is a few ULPs high in binary floating point; an integral
  385. // intent must not become a fractional count the validator rejects.
  386. expect(parseCapacity('2.3M')).toBe(2_300_000)
  387. expect(Number.isInteger(parseCapacity('1.5M'))).toBe(true)
  388. // A genuinely fractional count survives as one, for the validator to reject.
  389. expect(parseCapacity('0.0001K')).toBeCloseTo(0.1)
  390. expect(parseCapacity('abc')).toBeNaN()
  391. expect(parseCapacity('1G')).toBeNaN()
  392. expect(parseCapacity('1M1')).toBeNaN()
  393. })
  394. it('spells a stored count in the shortest form that round-trips', () => {
  395. expect(formatCapacity(1_000_000)).toBe('1M')
  396. expect(formatCapacity(256_000)).toBe('256K')
  397. expect(formatCapacity(1_500_000)).toBe('1500K')
  398. expect(formatCapacity(131_072)).toBe('131072')
  399. // Values the validator will reject are shown as-is rather than dressed up.
  400. expect(formatCapacity(Number.NaN)).toBe('NaN')
  401. expect(formatCapacity(0)).toBe('0')
  402. for (const text of ['1M', '256K', '131072', '1500K']) {
  403. expect(formatCapacity(parseCapacity(text) as number)).toBe(text)
  404. }
  405. })
  406. it('accepts a suffixed context window and stores the plain count', async () => {
  407. const { mutate } = await mountSection({
  408. mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
  409. })
  410. fireEvent.click(screen.getByText(en.customized))
  411. expandRow(1)
  412. expandRow(2)
  413. const windows = capacityInputs(en.contextWindow)
  414. // The inherited 1000000 reads back short.
  415. expect((windows[0] as HTMLInputElement).value).toBe('1M')
  416. // Keystrokes stay verbatim while the row has focus, so typing `1000` does
  417. // not rewrite itself to `1K` mid-word.
  418. fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1000' } })
  419. expect((windows[0] as HTMLInputElement).value).toBe('1000')
  420. fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1000K' } })
  421. expect((windows[0] as HTMLInputElement).value).toBe('1000K')
  422. // Blur settles the row to the canonical spelling of the same count.
  423. fireEvent.blur(windows[0] as HTMLInputElement)
  424. expect((windows[0] as HTMLInputElement).value).toBe('1M')
  425. fireEvent.change(windows[1] as HTMLInputElement, { target: { value: '256K' } })
  426. fireEvent.blur(windows[1] as HTMLInputElement)
  427. fireEvent.click(screen.getByText(en.apply))
  428. await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
  429. expect(mutate.mock.calls[0]?.[0]).toEqual({
  430. ns: 'llm-deepseek',
  431. ops: [{
  432. op: 'set',
  433. path: ['models'],
  434. value: [
  435. { ...DEFAULT_DEEPSEEK_MODELS[0], contextWindow: 1_000_000 },
  436. { ...DEFAULT_DEEPSEEK_MODELS[1], contextWindow: 256_000 },
  437. ],
  438. }],
  439. expectedRevision: 0,
  440. })
  441. })
  442. it('keeps unreadable context-window text on screen and refuses the write', async () => {
  443. const { mutate } = await mountSection()
  444. fireEvent.click(screen.getByText(en.customized))
  445. expandRow(1)
  446. expandRow(2)
  447. const windows = capacityInputs(en.contextWindow)
  448. fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1 gazillion' } })
  449. // Blurring a row that is not the edited one leaves the buffer alone.
  450. fireEvent.blur(windows[1] as HTMLInputElement)
  451. fireEvent.blur(windows[0] as HTMLInputElement)
  452. // The text the user typed is still there to correct.
  453. expect((windows[0] as HTMLInputElement).value).toBe('1 gazillion')
  454. fireEvent.click(screen.getByText(en.apply))
  455. await screen.findByText(`Model 1: ${en.modelContextInvalid}`)
  456. expect(mutate).not.toHaveBeenCalled()
  457. })
  458. it.each([
  459. ['the schema default', undefined],
  460. ['the composition entry', { models: [{ id: 'pinned-by-deployment' }] }],
  461. ])('restores %s the moment the override is dropped, not after a reload', async (_label, base) => {
  462. // The regression: reset read the EFFECTIVE value, which still carries the
  463. // stored override until the unset is applied — so the rows did not change
  464. // and the catalog only looked restored after reopening the card.
  465. const { face } = scriptedFace()
  466. const stored = { models: [{ id: 'user-only-model', name: 'User Only' }] }
  467. const overridden: SettingsNamespaceView = {
  468. ns: 'llm-deepseek',
  469. schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
  470. value: { ...stored, defaultContextWindow: 1_000_000 },
  471. ...base === undefined ? {} : { base },
  472. user: stored,
  473. applies: 'live',
  474. secrets: [],
  475. revision: 0,
  476. }
  477. const { ProviderEditor } = await import('../src/client/ProviderEditor.tsx')
  478. render(<ProviderEditor
  479. provider="deepseek-official"
  480. displayName="DeepSeek"
  481. namespace={overridden}
  482. settingsPath={[]}
  483. api={face as never}
  484. t={t}
  485. readOnly={false}
  486. onClose={() => {}}
  487. />)
  488. fireEvent.click(screen.getByText(en.customized))
  489. expect(screen.getByText(en.modelsCustomized)).toBeTruthy()
  490. expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value))
  491. .toEqual(['user-only-model'])
  492. fireEvent.click(screen.getByText(en.resetModels))
  493. expect(screen.getByText(en.modelsInherited)).toBeTruthy()
  494. expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value))
  495. .toEqual(base === undefined ? ['deepseek-v4-flash', 'deepseek-v4-pro'] : ['pinned-by-deployment'])
  496. })
  497. it('keeps every row\'s unreadable text, not just the last one edited', async () => {
  498. // The regression: one active buffer meant editing a second row displaced
  499. // the first, which then fell back to rendering its stored NaN as `NaN` —
  500. // losing the text the user was told they could still correct.
  501. await mountSection()
  502. fireEvent.click(screen.getByText(en.customized))
  503. expandRow(1)
  504. expandRow(2)
  505. const windows = capacityInputs(en.contextWindow)
  506. fireEvent.change(windows[0] as HTMLInputElement, { target: { value: 'not a number' } })
  507. fireEvent.blur(windows[0] as HTMLInputElement)
  508. fireEvent.change(windows[1] as HTMLInputElement, { target: { value: '2M' } })
  509. expect((windows[0] as HTMLInputElement).value).toBe('not a number')
  510. expect((windows[1] as HTMLInputElement).value).toBe('2M')
  511. })
  512. it('re-keys the typed text around a removed row', async () => {
  513. await mountSection()
  514. fireEvent.click(screen.getByText(en.customized))
  515. const windows = (): HTMLInputElement[] => capacityInputs(en.contextWindow)
  516. const removeRow = (at: number): void => {
  517. fireEvent.click(screen.getAllByLabelText(new RegExp(en.removeModel))[at] as HTMLElement)
  518. }
  519. // Three rows, with text parked on the outer two.
  520. fireEvent.click(screen.getByText(en.addModel))
  521. expandRow(1)
  522. expandRow(2)
  523. expandRow(3)
  524. fireEvent.change(windows()[0] as HTMLInputElement, { target: { value: 'top text' } })
  525. fireEvent.blur(windows()[0] as HTMLInputElement)
  526. fireEvent.change(windows()[2] as HTMLInputElement, { target: { value: 'bottom text' } })
  527. fireEvent.blur(windows()[2] as HTMLInputElement)
  528. // Dropping the middle row leaves the row above untouched and carries the
  529. // row below down with its own text, rather than stranding it.
  530. removeRow(1)
  531. expect(windows()).toHaveLength(2)
  532. expect((windows()[0] as HTMLInputElement).value).toBe('top text')
  533. expect((windows()[1] as HTMLInputElement).value).toBe('bottom text')
  534. // Dropping a row that holds text takes that text with it; the survivor
  535. // keeps its own rather than inheriting the deleted row's.
  536. removeRow(0)
  537. expect(windows()).toHaveLength(1)
  538. expect((windows()[0] as HTMLInputElement).value).toBe('bottom text')
  539. })
  540. it('drops the typed text when reset replaces the rows it annotated', async () => {
  541. // The regression: reset removed the override but left the buffer, so an
  542. // inherited row displayed text no settings layer stores — and because an
  543. // unreadable buffer never settles, it stayed there indefinitely.
  544. const { mutate } = await mountSection({
  545. mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
  546. })
  547. fireEvent.click(screen.getByText(en.customized))
  548. expandRow(1)
  549. const windows = capacityInputs(en.contextWindow)
  550. fireEvent.change(windows[0] as HTMLInputElement, { target: { value: 'garbage' } })
  551. fireEvent.blur(windows[0] as HTMLInputElement)
  552. fireEvent.click(screen.getByText(en.resetModels))
  553. // Reset collapses every row, so the restored capacity needs opening again.
  554. expandRow(1)
  555. const restored = capacityInputs(en.contextWindow)
  556. expect((restored[0] as HTMLInputElement).value).toBe('1M')
  557. // Reset put the draft back where it started, so Apply writes nothing at
  558. // all rather than persisting whatever the stale text had parsed to.
  559. fireEvent.click(screen.getByText(en.apply))
  560. await waitFor(() => { expect(screen.getByText(en.apply)).toBeTruthy() })
  561. expect(mutate).not.toHaveBeenCalled()
  562. })
  563. it('edits an output cap per model and carries its text across a removal', async () => {
  564. const { mutate } = await mountSection({
  565. mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
  566. })
  567. fireEvent.click(screen.getByText(en.customized))
  568. expandRow(1)
  569. expandRow(2)
  570. // The profile's own cap is the placeholder both rows inherit.
  571. expect(capacityInputs(en.maxTokens).map(input => input.placeholder)).toEqual(['256K', '256K'])
  572. fireEvent.change(screen.getByLabelText(`${en.maxTokens} 2`), { target: { value: '64K' } })
  573. fireEvent.blur(screen.getByLabelText(`${en.maxTokens} 2`))
  574. expect(screen.getByLabelText<HTMLInputElement>(`${en.maxTokens} 2`).value).toBe('64K')
  575. // Dropping the row above carries the cap text down with its own row.
  576. fireEvent.click(screen.getAllByLabelText(new RegExp(en.removeModel))[0] as HTMLElement)
  577. expect(screen.getByLabelText<HTMLInputElement>(`${en.maxTokens} 1`).value).toBe('64K')
  578. // The disclosure closes on a second press.
  579. expandRow(1)
  580. expect(screen.queryByLabelText(`${en.maxTokens} 1`)).toBeNull()
  581. fireEvent.click(screen.getByText(en.apply))
  582. await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
  583. expect(mutate.mock.calls[0]?.[0]).toEqual({
  584. ns: 'llm-deepseek',
  585. ops: [{
  586. op: 'set',
  587. path: ['models'],
  588. value: [{ ...DEFAULT_DEEPSEEK_MODELS[1], maxTokens: 64_000 }],
  589. }],
  590. expectedRevision: 0,
  591. })
  592. })
  593. it('settles a pasted id and refuses whitespace that would never match', async () => {
  594. await mountSection()
  595. fireEvent.click(screen.getByText(en.customized))
  596. const ids = screen.getAllByLabelText<HTMLInputElement>(new RegExp(en.modelId))
  597. fireEvent.change(ids[0] as HTMLInputElement, { target: { value: ' deepseek-v4-flash ' } })
  598. fireEvent.blur(ids[0] as HTMLInputElement)
  599. expect((ids[0] as HTMLInputElement).value).toBe('deepseek-v4-flash')
  600. // A settled id needs no second trim.
  601. fireEvent.blur(ids[0] as HTMLInputElement)
  602. expect((ids[0] as HTMLInputElement).value).toBe('deepseek-v4-flash')
  603. // An id that is only whitespace is as absent as an empty one, and a padded
  604. // id is a duplicate of its trimmed twin.
  605. expect(validateDeepSeekModels([{ id: ' ' }])).toEqual({ index: 0, key: 'modelIdRequired' })
  606. expect(validateDeepSeekModels([{ id: 'model' }, { id: 'model ' }]))
  607. .toEqual({ index: 1, key: 'modelIdDuplicate' })
  608. })
  609. it('renders malformed draft fallbacks without inventing catalog values', () => {
  610. render(<DeepSeekModelsEditor
  611. models={[{}]}
  612. overridden={false}
  613. defaultContextWindow={undefined}
  614. defaultMaxTokens={undefined}
  615. t={t}
  616. disabled={true}
  617. onChange={vi.fn()}
  618. onReset={vi.fn()}
  619. />)
  620. expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 1`).value).toBe('')
  621. expandRow(1)
  622. expect(screen.getByLabelText<HTMLInputElement>(`${en.contextWindow} 1`).placeholder)
  623. .toBe(en.contextWindowPlaceholder)
  624. expect(screen.getByLabelText<HTMLInputElement>(`${en.maxTokens} 1`).placeholder)
  625. .toBe(en.maxTokensPlaceholder)
  626. })
  627. it('can empty and reset the model override, then clear optional fields without dropping hidden data', async () => {
  628. const { mutate } = await mountSection({
  629. mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
  630. })
  631. fireEvent.click(screen.getByText(en.customized))
  632. fireEvent.click(screen.getAllByLabelText(new RegExp(en.removeModel))[0] as HTMLElement)
  633. fireEvent.click(screen.getByLabelText(new RegExp(en.removeModel)))
  634. expect(screen.getByText(en.modelsEmpty)).toBeTruthy()
  635. fireEvent.click(screen.getByText(en.resetModels))
  636. expect(screen.getByText(en.modelsInherited)).toBeTruthy()
  637. const names = screen.getAllByLabelText(new RegExp(en.modelName))
  638. expandRow(1)
  639. const windows = capacityInputs(en.contextWindow)
  640. fireEvent.change(names[0] as HTMLInputElement, { target: { value: '' } })
  641. fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '' } })
  642. fireEvent.click(screen.getByText(en.apply))
  643. await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
  644. expect(mutate.mock.calls[0]?.[0]).toEqual({
  645. ns: 'llm-deepseek',
  646. ops: [{
  647. op: 'set',
  648. path: ['models'],
  649. value: [
  650. { id: 'deepseek-v4-flash', description: 'Preserved hidden detail' },
  651. DEFAULT_DEEPSEEK_MODELS[1],
  652. ],
  653. }],
  654. expectedRevision: 0,
  655. })
  656. })
  657. it('clears an inherited override with an unset op, never a whole-section replace', async () => {
  658. // A whole-section replace would clobber sibling overrides to clear one field.
  659. const { replace, update, mutate } = await mountSection()
  660. fireEvent.click(screen.getByText(en.customized))
  661. const url = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
  662. expect(url.value).toBe('https://base')
  663. fireEvent.change(url, { target: { value: '' } })
  664. fireEvent.click(screen.getByText(en.apply))
  665. await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
  666. expect(replace).not.toHaveBeenCalled()
  667. expect(update).not.toHaveBeenCalled()
  668. expect(mutate.mock.calls[0]?.[0]).toEqual({
  669. ns: 'llm-deepseek',
  670. ops: [{ op: 'unset', path: ['baseURL'] }],
  671. expectedRevision: 0,
  672. })
  673. })
  674. it('pins the deepseek placeholder and clears typed input back to inherited', async () => {
  675. const { face } = scriptedFace()
  676. const bare: SettingsNamespaceView = {
  677. ns: 'llm-deepseek',
  678. schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
  679. value: {},
  680. applies: 'live',
  681. secrets: [],
  682. revision: 0,
  683. }
  684. const { ProviderEditor } = await import('../src/client/ProviderEditor.tsx')
  685. render(<ProviderEditor
  686. provider="deepseek-official"
  687. displayName="DeepSeek"
  688. namespace={bare}
  689. settingsPath={[]}
  690. api={face as never}
  691. t={t}
  692. readOnly={false}
  693. onClose={() => {}}
  694. />)
  695. fireEvent.click(screen.getByText(en.customized))
  696. const baseURL = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
  697. expect(baseURL.placeholder).toBe('https://api.deepseek.com')
  698. fireEvent.change(baseURL, { target: { value: 'https://x' } })
  699. expect(baseURL.value).toBe('https://x')
  700. fireEvent.change(baseURL, { target: { value: '' } })
  701. expect(baseURL.value).toBe('')
  702. })
  703. it('rejects an invalid draft before writing', async () => {
  704. const { update } = await mountSection()
  705. fireEvent.click(screen.getByText(en.customized))
  706. fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'not-a-url' } })
  707. fireEvent.click(screen.getByText(en.apply))
  708. await screen.findByText(/baseURL/)
  709. expect(update).not.toHaveBeenCalled()
  710. })
  711. it('edits a pi-ai profile with the curated fields only', async () => {
  712. const { mutate } = await mountSection()
  713. fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
  714. // The configured credential shows as the stored placeholder.
  715. const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
  716. const editorKey = keys[keys.length - 1] as HTMLInputElement
  717. await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyStored) })
  718. // pi-ai carries Base URL too: the stored override shows as the value and
  719. // the effective profile endpoint as its placeholder source.
  720. fireEvent.click(screen.getAllByText(en.customized)[1] as HTMLElement)
  721. const urls = screen.getAllByLabelText<HTMLInputElement>(en.baseUrl)
  722. expect(urls).toHaveLength(2)
  723. expect((urls[1] as HTMLInputElement).value).toBe('https://proxy')
  724. fireEvent.change(urls[1] as HTMLInputElement, { target: { value: 'https://proxy/v2' } })
  725. fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
  726. await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
  727. // Only the edited field travels: apiKeyEnv and headers were already stored
  728. // with these values, so no op restates them.
  729. expect(mutate.mock.calls[0]?.[0]).toEqual({
  730. ns: 'llm-pi-ai',
  731. ops: [{ op: 'set', path: ['providers', 'openai', 'baseURL'], value: 'https://proxy/v2' }],
  732. expectedRevision: 0,
  733. })
  734. })
  735. it('adds a dormant provider with a derived reference and stores its key', async () => {
  736. const { mutate, set } = await mountSection()
  737. fireEvent.click(screen.getByText(en.add))
  738. const pick = await screen.findByLabelText<HTMLSelectElement>(en.provider)
  739. expect([...pick.options].map(option => option.value)).toEqual(['anthropic', 'broken', 'plain'])
  740. expect(pick.value).toBe('anthropic')
  741. // A dormant profile has no endpoint anywhere: the pi-ai placeholder
  742. // falls back to the provider-default wording.
  743. fireEvent.click(screen.getAllByText(en.customized)[1] as HTMLElement)
  744. const urls = screen.getAllByLabelText<HTMLInputElement>(en.baseUrl)
  745. expect((urls[1] as HTMLInputElement).placeholder).toBe(en.baseUrlDefault)
  746. const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
  747. const addKey = keys[keys.length - 1] as HTMLInputElement
  748. expect(addKey.placeholder).toBe(en.keyPlaceholderNative)
  749. fireEvent.change(addKey, { target: { value: 'sk-ant' } })
  750. fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
  751. await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
  752. expect(mutate.mock.calls[0]?.[0]).toEqual({
  753. ns: 'llm-pi-ai',
  754. ops: [{ op: 'set', path: ['providers', 'anthropic', 'apiKeyEnv'], value: 'ANTHROPIC_API_KEY' }],
  755. expectedRevision: 0,
  756. })
  757. await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' }) })
  758. })
  759. it('keeps pi-ai provider-native authentication when no key is entered', async () => {
  760. const { mutate, set } = await mountSection()
  761. fireEvent.click(screen.getByText(en.add))
  762. await screen.findByLabelText(en.provider)
  763. fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
  764. await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
  765. expect(mutate.mock.calls[0]?.[0]).toEqual({
  766. ns: 'llm-pi-ai',
  767. ops: [{ op: 'set', path: ['providers', 'anthropic'], value: {} }],
  768. expectedRevision: 0,
  769. })
  770. expect(set).not.toHaveBeenCalled()
  771. })
  772. it('retries only the credential after refreshed settings already committed', async () => {
  773. const committed = wireNamespaces()[2]!
  774. const afterSettings: SettingsNamespaceView = {
  775. ...committed,
  776. value: { providers: {
  777. ...(committed.value as { providers: object }).providers,
  778. anthropic: { apiKeyEnv: 'ANTHROPIC_API_KEY' },
  779. } },
  780. user: { providers: {
  781. ...(committed.user as { providers: object }).providers,
  782. anthropic: { apiKeyEnv: 'ANTHROPIC_API_KEY' },
  783. } },
  784. revision: 1,
  785. }
  786. const mutate = vi.fn(() => Promise.resolve(ok(afterSettings)))
  787. const set = vi.fn()
  788. .mockResolvedValueOnce(fail('credential store unavailable', 'credential-rejected'))
  789. .mockResolvedValueOnce(ok({}))
  790. const { face, controller } = await mountSection({ mutate, set })
  791. fireEvent.click(screen.getByText(en.add))
  792. await screen.findByLabelText(en.provider)
  793. const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
  794. fireEvent.change(keys[keys.length - 1] as HTMLInputElement, { target: { value: 'sk-ant' } })
  795. fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
  796. await screen.findByText('credential store unavailable')
  797. expect(mutate).toHaveBeenCalledOnce()
  798. face.settings.describe.mockResolvedValue(ok({
  799. writable: true,
  800. hasDocument: false,
  801. namespaces: wireNamespaces().map(namespace => namespace.ns === 'llm-pi-ai' ? afterSettings : namespace),
  802. }))
  803. await act(async () => { await controller.load() })
  804. expect(controller.store.getSnapshot().namespaces.get('llm-pi-ai')?.revision).toBe(1)
  805. fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
  806. await waitFor(() => { expect(set).toHaveBeenCalledTimes(2) })
  807. expect(mutate).toHaveBeenCalledOnce()
  808. expect(set).toHaveBeenLastCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' })
  809. })
  810. it('switches the add card target and degrades unknown or broken targets loudly', async () => {
  811. await mountSection()
  812. fireEvent.click(screen.getByText(en.add))
  813. const pick = await screen.findByLabelText<HTMLSelectElement>(en.provider)
  814. fireEvent.change(pick, { target: { value: 'broken' } })
  815. await screen.findByText(/unresolvable settings path/)
  816. fireEvent.change(pick, { target: { value: 'plain' } })
  817. await waitFor(() => {
  818. expect(screen.getAllByText(content => content.includes(en.advancedHint)).length).toBeGreaterThan(0)
  819. })
  820. // The hint-only card cannot apply anything.
  821. const applies = screen.getAllByText<HTMLButtonElement>(en.apply)
  822. expect((applies[applies.length - 1] as HTMLButtonElement).disabled).toBe(true)
  823. expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
  824. })
  825. it('surfaces a rejected settings write and never stores the key after it', async () => {
  826. const { set } = await mountSection({
  827. mutate: vi.fn(() => Promise.resolve(fail('llm-pi-ai: unknown pi-ai provider "bogus"'))),
  828. })
  829. fireEvent.click(screen.getByText(en.add))
  830. await screen.findByLabelText(en.provider)
  831. const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
  832. fireEvent.change(keys[keys.length - 1] as HTMLInputElement, { target: { value: 'sk-x' } })
  833. fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
  834. await screen.findByText(/unknown pi-ai provider/)
  835. expect(set).not.toHaveBeenCalled()
  836. })
  837. it('renders the card without the stored-key hint when the credential probe rejects', async () => {
  838. // The probe is a placeholder hint, not a precondition: an escaping
  839. // rejection would surface in the browser as an unhandled rejection.
  840. const { face } = scriptedFace()
  841. face.credentials.describe = vi.fn(() => Promise.reject(new Error('connection lost')))
  842. const unhandled = vi.fn()
  843. process.on('unhandledRejection', unhandled)
  844. try {
  845. const controller = new ModelsSettingsStore(face as unknown as WireFace)
  846. await controller.load()
  847. render(<ModelsSection
  848. controller={controller}
  849. useSnapshot={bindSnapshotSelector(controller.store)}
  850. api={face as never}
  851. t={t}
  852. />)
  853. const key = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
  854. expect(key.placeholder).toBe(en.keyPlaceholder)
  855. await new Promise(resolve => setTimeout(resolve, 10))
  856. expect(unhandled).not.toHaveBeenCalled()
  857. } finally {
  858. process.off('unhandledRejection', unhandled)
  859. }
  860. })
  861. it('tells the user to reopen when another writer moved the namespace first', async () => {
  862. // The stale-draft overwrite: two tabs open the same card, the other saves,
  863. // and this one must be refused rather than replay its opening snapshot.
  864. const { set } = await mountSection({
  865. mutate: vi.fn(() => Promise.resolve(fail('changed since it was read', 'settings-conflict'))),
  866. })
  867. fireEvent.click(screen.getByText(en.customized))
  868. fireEvent.change(screen.getByLabelText<HTMLInputElement>(en.baseUrl), { target: { value: 'https://mine' } })
  869. fireEvent.click(screen.getByText(en.apply))
  870. await screen.findByText(en.conflict)
  871. expect(set).not.toHaveBeenCalled()
  872. })
  873. it('keeps the card usable when the write rejects instead of answering', async () => {
  874. // A transport failure (disconnect, or the 403 a non-loopback browser now
  875. // gets on the whole configuration plane) rejects rather than returning a
  876. // failed envelope: without a catch the card would stay busy forever.
  877. await mountSection({ mutate: vi.fn(() => Promise.reject(new Error('connection lost'))) })
  878. fireEvent.click(screen.getByText(en.customized))
  879. fireEvent.change(screen.getByLabelText<HTMLInputElement>(en.baseUrl), { target: { value: 'https://next' } })
  880. fireEvent.click(screen.getByText(en.apply))
  881. await screen.findByText('connection lost')
  882. // Not stuck in `applying…`: the finally cleared busy, so Apply is live again.
  883. expect(screen.getByText(en.apply)).toBeTruthy()
  884. })
  885. it('surfaces a shadowed credential write on the card', async () => {
  886. await mountSection({
  887. set: vi.fn(() => Promise.resolve(fail('credentials: DEEPSEEK_API_KEY is shadowed by the read-only environment', 'credential-rejected'))),
  888. })
  889. const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
  890. fireEvent.change(key, { target: { value: 'sk-live' } })
  891. fireEvent.click(screen.getByText(en.apply))
  892. await screen.findByText(/shadowed by the read-only environment/)
  893. expect(screen.queryByRole('status')).toBeNull()
  894. })
  895. it('locks the key input when the launch environment provides the credential', async () => {
  896. const { face } = await mountSection()
  897. face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
  898. credentials: Object.fromEntries(payload.refs.map(ref => [ref, {
  899. configured: ref === 'OPENAI_API_KEY', source: 'env', writable: false,
  900. }])),
  901. })))
  902. fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
  903. const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
  904. const editorKey = keys[keys.length - 1] as HTMLInputElement
  905. await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyEnvLocked) })
  906. expect(editorKey.disabled).toBe(true)
  907. })
  908. it('keeps a failed credential describe silent and the input usable', async () => {
  909. const { face, set } = await mountSection()
  910. face.credentials.describe.mockImplementation(() => Promise.resolve(fail('down', 'internal')) as never)
  911. fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
  912. const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
  913. const editorKey = keys[keys.length - 1] as HTMLInputElement
  914. expect(editorKey.placeholder).toBe(en.keyPlaceholderNative)
  915. fireEvent.change(editorKey, { target: { value: 'sk-live' } })
  916. fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
  917. await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) })
  918. })
  919. it('requires confirmation before removing a user-added provider', async () => {
  920. const { replace, mutate, unset } = await mountSection()
  921. fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) }))
  922. const dialog = screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })
  923. expect(dialog.textContent).toContain(openaiCopy(en.deleteDescriptionWithCredential))
  924. expect(document.activeElement).toBe(within(dialog).getByRole('button', { name: en.cancel }))
  925. expect(unset).not.toHaveBeenCalled()
  926. expect(mutate).not.toHaveBeenCalled()
  927. fireEvent.click(within(dialog).getByRole('button', { name: en.cancel }))
  928. expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
  929. expect(mutate).not.toHaveBeenCalled()
  930. fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) }))
  931. fireEvent.click(within(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) }))
  932. .getByRole('button', { name: en.close }))
  933. expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
  934. expect(mutate).not.toHaveBeenCalled()
  935. fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) }))
  936. fireEvent.click(within(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) }))
  937. .getByRole('button', { name: openaiCopy(en.deleteConfirm) }))
  938. await waitFor(() => { expect(unset).toHaveBeenCalledWith({ ref: 'OPENAI_API_KEY' }) })
  939. await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
  940. expect(unset.mock.invocationCallOrder[0]).toBeLessThan(mutate.mock.invocationCallOrder[0] as number)
  941. expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
  942. expect(replace).not.toHaveBeenCalled()
  943. expect(mutate.mock.calls[0]?.[0]).toEqual({
  944. ns: 'llm-pi-ai',
  945. ops: [{ op: 'unset', path: ['providers', 'openai'] }],
  946. })
  947. })
  948. it('blocks duplicate deletion while the confirmed removal is pending', async () => {
  949. let resolveRemoval!: (response: RpcResponse<SettingsNamespaceView>) => void
  950. const mutate = vi.fn(() => new Promise<RpcResponse<SettingsNamespaceView>>((resolve) => {
  951. resolveRemoval = resolve
  952. }))
  953. await mountSection({ mutate })
  954. fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) }))
  955. const dialog = screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })
  956. const confirm = within(dialog).getByRole<HTMLButtonElement>('button', { name: openaiCopy(en.deleteConfirm) })
  957. fireEvent.click(confirm)
  958. fireEvent.click(confirm)
  959. await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
  960. expect(confirm.disabled).toBe(true)
  961. expect(within(dialog).getByRole<HTMLButtonElement>('button', { name: en.cancel }).disabled).toBe(true)
  962. expect(within(dialog).getByRole('button', { name: openaiCopy(en.deleting) })).toBe(confirm)
  963. fireEvent.click(within(dialog).getByRole('button', { name: en.close }))
  964. expect(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBe(dialog)
  965. expect(mutate).toHaveBeenCalledOnce()
  966. await act(async () => { resolveRemoval(ok(wireNamespaces()[2]!)) })
  967. await waitFor(() => {
  968. expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
  969. })
  970. })
  971. it('renders the load failure with a retry control', async () => {
  972. const face = scriptedFace()
  973. face.face.llm.providers = vi.fn(() => Promise.resolve(fail('directory down', 'internal'))) as never
  974. const controller = new ModelsSettingsStore(face.face as unknown as WireFace)
  975. await controller.load()
  976. render(<ModelsSection
  977. controller={controller}
  978. useSnapshot={bindSnapshotSelector(controller.store)}
  979. api={face.face as never}
  980. t={t}
  981. />)
  982. expect(screen.getByText(/directory down/)).toBeTruthy()
  983. fireEvent.click(screen.getByText(en.retry))
  984. await waitFor(() => { expect(screen.queryByText(/directory down/)).toBeNull() })
  985. })
  986. it('shows the read-only notice and disables mutations for a read-only provider', async () => {
  987. const { face } = await mountSection()
  988. face.settings.describe.mockImplementation(() => Promise.resolve(ok({
  989. writable: false,
  990. hasDocument: false,
  991. namespaces: wireNamespaces(),
  992. })))
  993. const controller = new ModelsSettingsStore(face as unknown as WireFace)
  994. await controller.load()
  995. cleanup()
  996. render(<ModelsSection
  997. controller={controller}
  998. useSnapshot={bindSnapshotSelector(controller.store)}
  999. api={face as never}
  1000. t={t}
  1001. />)
  1002. expect(screen.getByText(en.readOnly)).toBeTruthy()
  1003. expect(screen.getAllByText<HTMLButtonElement>(en.remove).every(button => button.disabled)).toBe(true)
  1004. expect(screen.getByText<HTMLButtonElement>(en.add).disabled).toBe(true)
  1005. })
  1006. it('toggles the row editor closed on a second edit click and on cancel', async () => {
  1007. const { update } = await mountSection()
  1008. const edit = screen.getAllByText(en.edit)[0] as HTMLElement
  1009. fireEvent.click(edit)
  1010. await waitFor(() => { expect(screen.getAllByLabelText(en.keyInput).length).toBe(2) })
  1011. fireEvent.click(edit)
  1012. expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
  1013. fireEvent.click(edit)
  1014. await waitFor(() => { expect(screen.getAllByLabelText(en.keyInput).length).toBe(2) })
  1015. fireEvent.click(screen.getAllByText(en.cancel)[1] as HTMLElement)
  1016. expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
  1017. expect(update).not.toHaveBeenCalled()
  1018. })
  1019. it('cancels the add card back to the add button', async () => {
  1020. await mountSection()
  1021. fireEvent.click(screen.getByText(en.add))
  1022. await screen.findByLabelText(en.provider)
  1023. fireEvent.click(screen.getAllByText(en.cancel)[1] as HTMLElement)
  1024. await screen.findByText(en.add)
  1025. expect(screen.queryByLabelText(en.provider)).toBeNull()
  1026. })
  1027. it('loads on first render of an idle controller', async () => {
  1028. const { face } = scriptedFace()
  1029. const controller = new ModelsSettingsStore(face as unknown as WireFace)
  1030. render(<ModelsSection
  1031. controller={controller}
  1032. useSnapshot={bindSnapshotSelector(controller.store)}
  1033. api={face as never}
  1034. t={t}
  1035. />)
  1036. await screen.findByText('DeepSeek')
  1037. })
  1038. it('removes by unsetting the profile path, never by rebuilding the section', async () => {
  1039. // The page only needs to name the profile path; rebuilding the section
  1040. // would widen the write for no benefit.
  1041. const { face, mutate, replace, controller } = await mountSection()
  1042. await removeProviderProfile(
  1043. face as unknown as Parameters<typeof removeProviderProfile>[0],
  1044. controller,
  1045. { settingsNs: 'llm-plain', settingsPath: ['ghost-profile'] },
  1046. )
  1047. expect(mutate.mock.calls[0]?.[0]).toEqual({
  1048. ns: 'llm-plain',
  1049. ops: [{ op: 'unset', path: ['ghost-profile'] }],
  1050. })
  1051. expect(replace).not.toHaveBeenCalled()
  1052. })
  1053. it('keeps the snapshot untouched and reports the message when a removal write is refused', async () => {
  1054. const { face, controller } = await mountSection({
  1055. mutate: vi.fn(() => Promise.resolve(fail('read-only'))),
  1056. })
  1057. const before = controller.store.getSnapshot().rows
  1058. const failure = await removeProviderProfile(
  1059. face as unknown as Parameters<typeof removeProviderProfile>[0],
  1060. controller,
  1061. { settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] },
  1062. )
  1063. expect(failure).toBe('read-only')
  1064. expect(controller.store.getSnapshot().rows).toBe(before)
  1065. })
  1066. it('keeps a failed identified deletion recoverable in its confirmation dialog', async () => {
  1067. const mutate = vi.fn()
  1068. .mockResolvedValueOnce(fail('the host refused'))
  1069. .mockResolvedValueOnce(ok(wireNamespaces()[2]!))
  1070. const { unset } = await mountSection({ mutate })
  1071. fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) }))
  1072. const dialog = screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })
  1073. const confirm = within(dialog).getByRole('button', { name: openaiCopy(en.deleteConfirm) })
  1074. fireEvent.click(confirm)
  1075. await within(dialog).findByText('the host refused')
  1076. expect(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBe(dialog)
  1077. expect(unset).toHaveBeenCalledOnce()
  1078. expect(mutate).toHaveBeenCalledOnce()
  1079. fireEvent.click(confirm)
  1080. await waitFor(() => { expect(unset).toHaveBeenCalledTimes(2) })
  1081. await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(2) })
  1082. await waitFor(() => {
  1083. expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
  1084. })
  1085. })
  1086. it('retains credentials that are not identified as page-managed', async () => {
  1087. const { unset, mutate } = await mountSection()
  1088. const target = { provider: 'zombie', displayName: 'zombie' }
  1089. fireEvent.click(screen.getByRole('button', { name: providerCopy(en.removeProvider, target) }))
  1090. const dialog = screen.getByRole('dialog', { name: providerCopy(en.deleteTitle, target) })
  1091. expect(dialog.textContent).toContain(providerCopy(en.deleteDescription, target))
  1092. fireEvent.click(within(dialog).getByRole('button', { name: providerCopy(en.deleteConfirm, target) }))
  1093. await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
  1094. expect(unset).not.toHaveBeenCalled()
  1095. expect(mutate.mock.calls[0]?.[0]).toEqual({
  1096. ns: 'llm-pi-ai',
  1097. ops: [{ op: 'unset', path: ['providers', 'zombie'] }],
  1098. })
  1099. })
  1100. it('does not remove provider settings when its managed credential removal is refused', async () => {
  1101. const { face, controller, mutate } = await mountSection({
  1102. unset: vi.fn(() => Promise.resolve(fail('credential is read-only', 'credential-rejected'))),
  1103. })
  1104. const failure = await removeProviderProfile(
  1105. face as unknown as Parameters<typeof removeProviderProfile>[0],
  1106. controller,
  1107. {
  1108. settingsNs: 'llm-pi-ai',
  1109. settingsPath: ['providers', 'openai'],
  1110. credentialRef: 'OPENAI_API_KEY',
  1111. },
  1112. )
  1113. expect(failure).toBe('credential is read-only')
  1114. expect(mutate).not.toHaveBeenCalled()
  1115. })
  1116. it('reports a transport rejection instead of failing the removal silently', async () => {
  1117. const { face, controller } = await mountSection({
  1118. mutate: vi.fn(() => Promise.reject(new Error('connection lost'))),
  1119. })
  1120. const failure = await removeProviderProfile(
  1121. face as unknown as Parameters<typeof removeProviderProfile>[0],
  1122. controller,
  1123. { settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] },
  1124. )
  1125. expect(failure).toBe('connection lost')
  1126. })
  1127. })
  1128. describe('apiKeyFailure', () => {
  1129. it('treats a blank field as no failure — it means keep the stored key', () => {
  1130. expect(apiKeyFailure('')).toBeUndefined()
  1131. })
  1132. it.each([
  1133. ['a printable-ASCII key', 'sk-0123456789'],
  1134. ['a padded key, which the caller trims', ' sk-abc '],
  1135. ['the printable-ASCII boundary characters', '!~'],
  1136. ['a hyphenated key carrying an equals sign', 'sk-ABC=xyz'],
  1137. ['an all-upper-case key ending in base64 padding', 'ABCD=='],
  1138. ['an all-upper-case key ending in one padding character', 'MNOPQRST='],
  1139. ])('accepts %s', (_label, draft) => {
  1140. expect(apiKeyFailure(draft)).toBeUndefined()
  1141. })
  1142. it.each([
  1143. ['spaces', ' '],
  1144. ['a tab', '\t'],
  1145. ])('fails a field holding only %s instead of silently dropping it', (_label, draft) => {
  1146. expect(apiKeyFailure(draft)).toBe('keyBlank')
  1147. })
  1148. it.each([
  1149. ['an emoji', 'sk-\u{1F600}'],
  1150. ['CJK text', 'sk-你好'],
  1151. ['full-width punctuation', 'sk-abc,'],
  1152. ['an interior space', 'sk-abc def'],
  1153. ['a C0 control character', 'sk-abc\x01'],
  1154. ['a latin-1 character', 'sk-café'],
  1155. ])('fails %s as illegal characters', (_label, draft) => {
  1156. expect(apiKeyFailure(draft)).toBe('keyIllegalCharacters')
  1157. })
  1158. it.each([
  1159. ['a pasted environment line', 'DEEPSEEK_API_KEY=sk-abc'],
  1160. ['double quotes', '"sk-abc"'],
  1161. ['single quotes', '\'sk-abc\''],
  1162. ['backticks', '`sk-abc`'],
  1163. ])('fails %s as a format failure', (_label, draft) => {
  1164. expect(apiKeyFailure(draft)).toBe('keyIllegalCharacters')
  1165. })
  1166. it('needs a matching closing quote before it calls a value wrapped', () => {
  1167. // A lone quote and an unbalanced one are legal printable ASCII, so the
  1168. // heuristic leaves them alone rather than guessing at a paste error.
  1169. expect(apiKeyFailure('"')).toBeUndefined()
  1170. expect(apiKeyFailure('"a')).toBeUndefined()
  1171. })
  1172. })