components.client.spec.tsx 73 KB

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