components.spec.tsx 49 KB

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