components.client.spec.tsx 66 KB

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