components.client.spec.tsx 73 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621
  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(screen.getByText(en.apply))
  601. await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
  602. expect(mutate.mock.calls[0]).toEqual([
  603. 'llm-deepseek',
  604. [{
  605. op: 'set',
  606. path: ['models'],
  607. value: [
  608. ...DEFAULT_DEEPSEEK_MODELS,
  609. { id: 'private-preview', name: 'Private Preview', contextWindow: 131_072 },
  610. ],
  611. }],
  612. 0,
  613. ])
  614. })
  615. it('edits the shared DeepSeek card while preserving the YAML protocol selection', async () => {
  616. const namespace: SettingsNamespaceView = {
  617. ...wireNamespaces()[0]!,
  618. ns: 'llm-deepseek',
  619. value: { protocol: 'messages', apiKeyEnv: 'DEEPSEEK_API_KEY', models: DEFAULT_DEEPSEEK_MODELS },
  620. user: {},
  621. }
  622. const { face, mutate, set } = scriptedFace({
  623. mutate: vi.fn(() => Promise.resolve(remoteOk(namespace))),
  624. })
  625. const { ProviderEditor } = await import('../src/client/ProviderEditor.tsx')
  626. render(<ProviderEditor
  627. provider="deepseek-official"
  628. displayName="DeepSeek"
  629. namespace={namespace}
  630. schema={settingsSchema}
  631. settingsPath={[]}
  632. operations={operationsWith(face)}
  633. t={t}
  634. readOnly={false}
  635. onClose={vi.fn()}
  636. />)
  637. fireEvent.click(screen.getByText(en.customized))
  638. expect(screen.getByLabelText<HTMLInputElement>(en.baseUrl).placeholder)
  639. .toBe('https://api.deepseek.com/anthropic')
  640. expect(screen.queryByLabelText(en.customApi)).toBeNull()
  641. expect(screen.getByText(en.deepSeekEndpointHint)).toBeTruthy()
  642. fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-messages-test' } })
  643. fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://messages.example/anthropic' } })
  644. fireEvent.change(screen.getByLabelText(`${en.modelName} 1`), { target: { value: 'Messages Flash' } })
  645. fireEvent.click(screen.getByText(en.apply))
  646. await waitFor(() => { expect(set).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'sk-messages-test') })
  647. expect(mutate.mock.calls).toEqual([[
  648. 'llm-deepseek',
  649. [
  650. { op: 'set', path: ['baseURL'], value: 'https://messages.example/anthropic' },
  651. { op: 'set', path: ['models'], value: [
  652. { ...DEFAULT_DEEPSEEK_MODELS[0], name: 'Messages Flash' },
  653. DEFAULT_DEEPSEEK_MODELS[1],
  654. ] },
  655. ],
  656. 0,
  657. ]])
  658. })
  659. it('rejects duplicate DeepSeek model ids before writing', async () => {
  660. const { mutate } = await mountDeepSeekCard()
  661. fireEvent.click(screen.getByText(en.customized))
  662. fireEvent.click(screen.getByText(en.addModel))
  663. const ids = screen.getAllByLabelText(new RegExp(en.modelId))
  664. fireEvent.change(ids[2] as HTMLInputElement, { target: { value: 'deepseek-v4-flash' } })
  665. fireEvent.click(screen.getByText(en.apply))
  666. await screen.findByText(`Model 3: ${en.modelIdDuplicate}`)
  667. expect(mutate).not.toHaveBeenCalled()
  668. })
  669. it('validates every adapter-owned model catalog invariant', () => {
  670. expect(modelDrafts(undefined)).toEqual([])
  671. expect(modelDrafts([null, 'bad', { id: 'ok' }])).toEqual([{}, {}, { id: 'ok' }])
  672. expect(validateDeepSeekModels([{}])).toEqual({ index: 0, key: 'modelIdRequired' })
  673. expect(validateDeepSeekModels([{ id: 'same' }, { id: 'same' }]))
  674. .toEqual({ index: 1, key: 'modelIdDuplicate' })
  675. expect(validateDeepSeekModels([{ id: 'model', name: '' }]))
  676. .toEqual({ index: 0, key: 'modelNameInvalid' })
  677. expect(validateDeepSeekModels([{ id: 'model', contextWindow: null }]))
  678. .toEqual({ index: 0, key: 'modelContextInvalid' })
  679. expect(validateDeepSeekModels([{ id: 'model', contextWindow: 1.5 }]))
  680. .toEqual({ index: 0, key: 'modelContextInvalid' })
  681. expect(validateDeepSeekModels([{ id: 'model', contextWindow: 0 }]))
  682. .toEqual({ index: 0, key: 'modelContextInvalid' })
  683. expect(validateDeepSeekModels([{ id: 'model', contextWindow: 1 }])).toBeUndefined()
  684. expect(validateDeepSeekModels([{ id: 'model', maxTokens: null }]))
  685. .toEqual({ index: 0, key: 'modelMaxTokensInvalid' })
  686. expect(validateDeepSeekModels([{ id: 'model', maxTokens: 1.5 }]))
  687. .toEqual({ index: 0, key: 'modelMaxTokensInvalid' })
  688. expect(validateDeepSeekModels([{ id: 'model', maxTokens: 0 }]))
  689. .toEqual({ index: 0, key: 'modelMaxTokensInvalid' })
  690. expect(validateDeepSeekModels([{ id: 'model', maxTokens: 8192 }])).toBeUndefined()
  691. })
  692. it('reads context windows written as counts, thousands, or millions', () => {
  693. expect(parseCapacity('')).toBeUndefined()
  694. expect(parseCapacity(' ')).toBeUndefined()
  695. expect(parseCapacity('131072')).toBe(131_072)
  696. expect(parseCapacity(' 256K ')).toBe(256_000)
  697. expect(parseCapacity('256k')).toBe(256_000)
  698. expect(parseCapacity('1M')).toBe(1_000_000)
  699. expect(parseCapacity('1m')).toBe(1_000_000)
  700. // 1M is 1000K, not 1024K: capacities are quoted in decimal.
  701. expect(parseCapacity('1M')).toBe(parseCapacity('1000K'))
  702. // 2.3 * 1e6 is a few ULPs high in binary floating point; an integral
  703. // intent must not become a fractional count the validator rejects.
  704. expect(parseCapacity('2.3M')).toBe(2_300_000)
  705. expect(Number.isInteger(parseCapacity('1.5M'))).toBe(true)
  706. // A genuinely fractional count survives as one, for the validator to reject.
  707. expect(parseCapacity('0.0001K')).toBeCloseTo(0.1)
  708. expect(parseCapacity('abc')).toBeNaN()
  709. expect(parseCapacity('1G')).toBeNaN()
  710. expect(parseCapacity('1M1')).toBeNaN()
  711. })
  712. it('spells a stored count in the shortest form that round-trips', () => {
  713. expect(formatCapacity(1_000_000)).toBe('1M')
  714. expect(formatCapacity(256_000)).toBe('256K')
  715. expect(formatCapacity(1_500_000)).toBe('1500K')
  716. expect(formatCapacity(131_072)).toBe('131072')
  717. // Values the validator will reject are shown as-is rather than dressed up.
  718. expect(formatCapacity(Number.NaN)).toBe('NaN')
  719. expect(formatCapacity(0)).toBe('0')
  720. for (const text of ['1M', '256K', '131072', '1500K']) {
  721. expect(formatCapacity(parseCapacity(text) as number)).toBe(text)
  722. }
  723. })
  724. it('accepts a suffixed context window and stores the plain count', async () => {
  725. const { mutate } = await mountDeepSeekCard({
  726. mutate: vi.fn(() => Promise.resolve(remoteOk(wireNamespaces()[0]))),
  727. })
  728. fireEvent.click(screen.getByText(en.customized))
  729. expandRow(1)
  730. expandRow(2)
  731. const windows = capacityInputs(en.contextWindow)
  732. // The inherited 1000000 reads back short.
  733. expect((windows[0] as HTMLInputElement).value).toBe('1M')
  734. // Keystrokes stay verbatim while the row has focus, so typing `1000` does
  735. // not rewrite itself to `1K` mid-word.
  736. fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1000' } })
  737. expect((windows[0] as HTMLInputElement).value).toBe('1000')
  738. fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1000K' } })
  739. expect((windows[0] as HTMLInputElement).value).toBe('1000K')
  740. // Blur settles the row to the canonical spelling of the same count.
  741. fireEvent.blur(windows[0] as HTMLInputElement)
  742. expect((windows[0] as HTMLInputElement).value).toBe('1M')
  743. fireEvent.change(windows[1] as HTMLInputElement, { target: { value: '256K' } })
  744. fireEvent.blur(windows[1] as HTMLInputElement)
  745. fireEvent.click(screen.getByText(en.apply))
  746. await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
  747. expect(mutate.mock.calls[0]).toEqual([
  748. 'llm-deepseek',
  749. [{
  750. op: 'set',
  751. path: ['models'],
  752. value: [
  753. { ...DEFAULT_DEEPSEEK_MODELS[0], contextWindow: 1_000_000 },
  754. { ...DEFAULT_DEEPSEEK_MODELS[1], contextWindow: 256_000 },
  755. ],
  756. }],
  757. 0,
  758. ])
  759. })
  760. it('keeps unreadable context-window text on screen and refuses the write', async () => {
  761. const { mutate } = await mountDeepSeekCard()
  762. fireEvent.click(screen.getByText(en.customized))
  763. expandRow(1)
  764. expandRow(2)
  765. const windows = capacityInputs(en.contextWindow)
  766. fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1 gazillion' } })
  767. // Blurring a row that is not the edited one leaves the buffer alone.
  768. fireEvent.blur(windows[1] as HTMLInputElement)
  769. fireEvent.blur(windows[0] as HTMLInputElement)
  770. // The text the user typed is still there to correct.
  771. expect((windows[0] as HTMLInputElement).value).toBe('1 gazillion')
  772. fireEvent.click(screen.getByText(en.apply))
  773. await screen.findByText(`Model 1: ${en.modelContextInvalid}`)
  774. expect(mutate).not.toHaveBeenCalled()
  775. })
  776. it.each([
  777. ['the schema default', undefined],
  778. ['the composition entry', { models: [{ id: 'pinned-by-deployment' }] }],
  779. ])('restores %s the moment the override is dropped, not after a reload', async (_label, base) => {
  780. // The regression: reset read the EFFECTIVE value, which still carries the
  781. // stored override until the unset is applied — so the rows did not change
  782. // and the catalog only looked restored after reopening the card.
  783. const { face } = scriptedFace()
  784. const stored = { models: [{ id: 'user-only-model', name: 'User Only' }] }
  785. const overridden: SettingsNamespaceView = {
  786. ns: 'llm-deepseek',
  787. schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as JsonValue,
  788. value: { ...stored, defaultContextWindow: 1_000_000 },
  789. ...base === undefined ? {} : { base },
  790. user: stored,
  791. applies: 'live',
  792. secrets: [],
  793. revision: 0,
  794. }
  795. const { ProviderEditor } = await import('../src/client/ProviderEditor.tsx')
  796. render(<ProviderEditor
  797. provider="deepseek-official"
  798. displayName="DeepSeek"
  799. namespace={overridden}
  800. schema={settingsSchema}
  801. settingsPath={[]}
  802. operations={operationsWith(face)}
  803. t={t}
  804. readOnly={false}
  805. onClose={() => {}}
  806. />)
  807. fireEvent.click(screen.getByText(en.customized))
  808. expect(screen.getByText(en.modelsCustomized)).toBeTruthy()
  809. expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value))
  810. .toEqual(['user-only-model'])
  811. fireEvent.click(screen.getByText(en.resetModels))
  812. expect(screen.getByText(en.modelsInherited)).toBeTruthy()
  813. expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value))
  814. .toEqual(base === undefined ? ['deepseek-v4-flash', 'deepseek-v4-pro'] : ['pinned-by-deployment'])
  815. })
  816. it('keeps every row\'s unreadable text, not just the last one edited', async () => {
  817. // The regression: one active buffer meant editing a second row displaced
  818. // the first, which then fell back to rendering its stored NaN as `NaN` —
  819. // losing the text the user was told they could still correct.
  820. await mountDeepSeekCard()
  821. fireEvent.click(screen.getByText(en.customized))
  822. expandRow(1)
  823. expandRow(2)
  824. const windows = capacityInputs(en.contextWindow)
  825. fireEvent.change(windows[0] as HTMLInputElement, { target: { value: 'not a number' } })
  826. fireEvent.blur(windows[0] as HTMLInputElement)
  827. fireEvent.change(windows[1] as HTMLInputElement, { target: { value: '2M' } })
  828. expect((windows[0] as HTMLInputElement).value).toBe('not a number')
  829. expect((windows[1] as HTMLInputElement).value).toBe('2M')
  830. })
  831. it('re-keys the typed text around a removed row', async () => {
  832. await mountDeepSeekCard()
  833. fireEvent.click(screen.getByText(en.customized))
  834. const windows = (): HTMLInputElement[] => capacityInputs(en.contextWindow)
  835. const removeRow = (at: number): void => {
  836. fireEvent.click(screen.getAllByLabelText(new RegExp(en.removeModel))[at] as HTMLElement)
  837. }
  838. // Three rows, with text parked on the outer two.
  839. fireEvent.click(screen.getByText(en.addModel))
  840. expandRow(1)
  841. expandRow(2)
  842. expandRow(3)
  843. fireEvent.change(windows()[0] as HTMLInputElement, { target: { value: 'top text' } })
  844. fireEvent.blur(windows()[0] as HTMLInputElement)
  845. fireEvent.change(windows()[2] as HTMLInputElement, { target: { value: 'bottom text' } })
  846. fireEvent.blur(windows()[2] as HTMLInputElement)
  847. // Dropping the middle row leaves the row above untouched and carries the
  848. // row below down with its own text, rather than stranding it.
  849. removeRow(1)
  850. expect(windows()).toHaveLength(2)
  851. expect((windows()[0] as HTMLInputElement).value).toBe('top text')
  852. expect((windows()[1] as HTMLInputElement).value).toBe('bottom text')
  853. // Dropping a row that holds text takes that text with it; the survivor
  854. // keeps its own rather than inheriting the deleted row's.
  855. removeRow(0)
  856. expect(windows()).toHaveLength(1)
  857. expect((windows()[0] as HTMLInputElement).value).toBe('bottom text')
  858. })
  859. it('drops the typed text when reset replaces the rows it annotated', async () => {
  860. // The regression: reset removed the override but left the buffer, so an
  861. // inherited row displayed text no settings layer stores — and because an
  862. // unreadable buffer never settles, it stayed there indefinitely.
  863. const { mutate } = await mountDeepSeekCard({
  864. mutate: vi.fn(() => Promise.resolve(remoteOk(wireNamespaces()[0]))),
  865. })
  866. fireEvent.click(screen.getByText(en.customized))
  867. expandRow(1)
  868. const windows = capacityInputs(en.contextWindow)
  869. fireEvent.change(windows[0] as HTMLInputElement, { target: { value: 'garbage' } })
  870. fireEvent.blur(windows[0] as HTMLInputElement)
  871. fireEvent.click(screen.getByText(en.resetModels))
  872. // Reset collapses every row, so the restored capacity needs opening again.
  873. expandRow(1)
  874. const restored = capacityInputs(en.contextWindow)
  875. expect((restored[0] as HTMLInputElement).value).toBe('1M')
  876. // Reset put the draft back where it started, so Apply writes nothing at
  877. // all rather than persisting whatever the stale text had parsed to.
  878. fireEvent.click(screen.getByText(en.apply))
  879. await waitFor(() => { expect(screen.queryByText(en.apply)).toBeNull() })
  880. expect(mutate).not.toHaveBeenCalled()
  881. })
  882. it('edits an output cap per model and carries its text across a removal', async () => {
  883. const { mutate } = await mountDeepSeekCard({
  884. mutate: vi.fn(() => Promise.resolve(remoteOk(wireNamespaces()[0]))),
  885. })
  886. fireEvent.click(screen.getByText(en.customized))
  887. expandRow(1)
  888. expandRow(2)
  889. // The profile's own cap is the placeholder both rows inherit.
  890. expect(capacityInputs(en.maxTokens).map(input => input.placeholder)).toEqual(['256K', '256K'])
  891. fireEvent.change(screen.getByLabelText(`${en.maxTokens} 2`), { target: { value: '64K' } })
  892. fireEvent.blur(screen.getByLabelText(`${en.maxTokens} 2`))
  893. expect(screen.getByLabelText<HTMLInputElement>(`${en.maxTokens} 2`).value).toBe('64K')
  894. // Dropping the row above carries the cap text down with its own row.
  895. fireEvent.click(screen.getAllByLabelText(new RegExp(en.removeModel))[0] as HTMLElement)
  896. expect(screen.getByLabelText<HTMLInputElement>(`${en.maxTokens} 1`).value).toBe('64K')
  897. // The disclosure closes on a second press.
  898. expandRow(1)
  899. expect(screen.queryByLabelText(`${en.maxTokens} 1`)).toBeNull()
  900. fireEvent.click(screen.getByText(en.apply))
  901. await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
  902. expect(mutate.mock.calls[0]).toEqual([
  903. 'llm-deepseek',
  904. [{
  905. op: 'set',
  906. path: ['models'],
  907. value: [{ ...DEFAULT_DEEPSEEK_MODELS[1], maxTokens: 64_000 }],
  908. }],
  909. 0,
  910. ])
  911. })
  912. it('settles a pasted id and refuses whitespace that would never match', async () => {
  913. await mountDeepSeekCard()
  914. fireEvent.click(screen.getByText(en.customized))
  915. const ids = screen.getAllByLabelText<HTMLInputElement>(new RegExp(en.modelId))
  916. fireEvent.change(ids[0] as HTMLInputElement, { target: { value: ' deepseek-v4-flash ' } })
  917. fireEvent.blur(ids[0] as HTMLInputElement)
  918. expect((ids[0] as HTMLInputElement).value).toBe('deepseek-v4-flash')
  919. // A settled id needs no second trim.
  920. fireEvent.blur(ids[0] as HTMLInputElement)
  921. expect((ids[0] as HTMLInputElement).value).toBe('deepseek-v4-flash')
  922. // An id that is only whitespace is as absent as an empty one, and a padded
  923. // id is a duplicate of its trimmed twin.
  924. expect(validateDeepSeekModels([{ id: ' ' }])).toEqual({ index: 0, key: 'modelIdRequired' })
  925. expect(validateDeepSeekModels([{ id: 'model' }, { id: 'model ' }]))
  926. .toEqual({ index: 1, key: 'modelIdDuplicate' })
  927. })
  928. it('renders malformed draft fallbacks without inventing catalog values', () => {
  929. render(<DeepSeekModelsEditor
  930. models={[{}]}
  931. overridden={false}
  932. defaultContextWindow={undefined}
  933. defaultMaxTokens={undefined}
  934. t={t}
  935. disabled={true}
  936. onChange={vi.fn()}
  937. onReset={vi.fn()}
  938. />)
  939. expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 1`).value).toBe('')
  940. expandRow(1)
  941. expect(screen.getByLabelText<HTMLInputElement>(`${en.contextWindow} 1`).placeholder)
  942. .toBe(en.contextWindowPlaceholder)
  943. expect(screen.getByLabelText<HTMLInputElement>(`${en.maxTokens} 1`).placeholder)
  944. .toBe(en.maxTokensPlaceholder)
  945. })
  946. it('can empty and reset the model override, then clear optional fields without dropping hidden data', async () => {
  947. const { mutate } = await mountDeepSeekCard({
  948. mutate: vi.fn(() => Promise.resolve(remoteOk(wireNamespaces()[0]))),
  949. })
  950. fireEvent.click(screen.getByText(en.customized))
  951. fireEvent.click(screen.getAllByLabelText(new RegExp(en.removeModel))[0] as HTMLElement)
  952. fireEvent.click(screen.getByLabelText(new RegExp(en.removeModel)))
  953. expect(screen.getByText(en.modelsEmpty)).toBeTruthy()
  954. fireEvent.click(screen.getByText(en.resetModels))
  955. expect(screen.getByText(en.modelsInherited)).toBeTruthy()
  956. const names = screen.getAllByLabelText(new RegExp(en.modelName))
  957. expandRow(1)
  958. const windows = capacityInputs(en.contextWindow)
  959. fireEvent.change(names[0] as HTMLInputElement, { target: { value: '' } })
  960. fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '' } })
  961. fireEvent.click(screen.getByText(en.apply))
  962. await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
  963. expect(mutate.mock.calls[0]).toEqual([
  964. 'llm-deepseek',
  965. [{
  966. op: 'set',
  967. path: ['models'],
  968. value: [
  969. { id: 'deepseek-v4-flash', description: 'Preserved hidden detail' },
  970. DEFAULT_DEEPSEEK_MODELS[1],
  971. ],
  972. }],
  973. 0,
  974. ])
  975. })
  976. it('clears an inherited override with an unset op, never a whole-section replace', async () => {
  977. // A whole-section replace would clobber sibling overrides to clear one field.
  978. const { mutate } = await mountDeepSeekCard()
  979. fireEvent.click(screen.getByText(en.customized))
  980. const url = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
  981. expect(url.value).toBe('https://base')
  982. fireEvent.change(url, { target: { value: '' } })
  983. fireEvent.click(screen.getByText(en.apply))
  984. await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
  985. // This editor clears one field through an unset op so it cannot clobber
  986. // sibling overrides with a whole-section replacement.
  987. expect(mutate.mock.calls[0]).toEqual([
  988. 'llm-deepseek',
  989. [{ op: 'unset', path: ['baseURL'] }],
  990. 0,
  991. ])
  992. })
  993. it('pins the deepseek placeholder and clears typed input back to inherited', async () => {
  994. const { face } = scriptedFace()
  995. const bare: SettingsNamespaceView = {
  996. ns: 'llm-deepseek',
  997. schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as JsonValue,
  998. value: {},
  999. applies: 'live',
  1000. secrets: [],
  1001. revision: 0,
  1002. }
  1003. const { ProviderEditor } = await import('../src/client/ProviderEditor.tsx')
  1004. render(<ProviderEditor
  1005. provider="deepseek-official"
  1006. displayName="DeepSeek"
  1007. namespace={bare}
  1008. schema={settingsSchema}
  1009. settingsPath={[]}
  1010. operations={operationsWith(face)}
  1011. t={t}
  1012. readOnly={false}
  1013. onClose={() => {}}
  1014. />)
  1015. fireEvent.click(screen.getByText(en.customized))
  1016. const baseURL = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
  1017. expect(baseURL.placeholder).toBe('https://api.deepseek.com')
  1018. fireEvent.change(baseURL, { target: { value: 'https://x' } })
  1019. expect(baseURL.value).toBe('https://x')
  1020. fireEvent.change(baseURL, { target: { value: '' } })
  1021. expect(baseURL.value).toBe('')
  1022. })
  1023. it('rejects an invalid draft before writing', async () => {
  1024. const { mutate } = await mountDeepSeekCard()
  1025. fireEvent.click(screen.getByText(en.customized))
  1026. fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'not-a-url' } })
  1027. fireEvent.click(screen.getByText(en.apply))
  1028. await screen.findByText(/baseURL/)
  1029. expect(mutate).not.toHaveBeenCalled()
  1030. })
  1031. it('edits a pi-ai profile with the curated fields only', async () => {
  1032. const { mutate } = await mountSection()
  1033. fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) }))
  1034. // The configured credential shows as the stored placeholder.
  1035. const editorKey = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
  1036. await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyStored) })
  1037. // pi-ai carries Base URL too: the stored override shows as the value and
  1038. // the effective profile endpoint as its placeholder source.
  1039. fireEvent.click(screen.getByText(en.customized))
  1040. const url = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
  1041. expect(url.value).toBe('https://proxy')
  1042. fireEvent.change(url, { target: { value: 'https://proxy/v2' } })
  1043. fireEvent.click(screen.getByText(en.apply))
  1044. await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
  1045. // Only the edited field travels: apiKeyEnv and headers were already stored
  1046. // with these values, so no op restates them.
  1047. expect(mutate.mock.calls[0]).toEqual([
  1048. 'llm-pi-ai',
  1049. [{ op: 'set', path: ['providers', 'openai', 'baseURL'], value: 'https://proxy/v2' }],
  1050. 0,
  1051. ])
  1052. })
  1053. it('adds a dormant provider with a derived reference and stores its key', async () => {
  1054. const { mutate, set } = await mountSection()
  1055. fireEvent.click(screen.getByText(en.add))
  1056. const pick = await screen.findByLabelText<HTMLSelectElement>(en.provider)
  1057. expect([...pick.options].map(option => option.value)).toEqual(['anthropic', 'broken', 'plain'])
  1058. expect(pick.value).toBe('anthropic')
  1059. // A dormant profile has no endpoint anywhere: the pi-ai placeholder
  1060. // falls back to the provider-default wording.
  1061. fireEvent.click(screen.getByText(en.customized))
  1062. expect(screen.getByLabelText<HTMLInputElement>(en.baseUrl).placeholder).toBe(en.baseUrlDefault)
  1063. const addKey = screen.getByLabelText<HTMLInputElement>(en.keyInput)
  1064. expect(addKey.placeholder).toBe(en.keyPlaceholderNative)
  1065. fireEvent.change(addKey, { target: { value: 'sk-ant' } })
  1066. fireEvent.click(screen.getByText(en.apply))
  1067. await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
  1068. expect(mutate.mock.calls[0]).toEqual([
  1069. 'llm-pi-ai',
  1070. [{ op: 'set', path: ['providers', 'anthropic', 'apiKeyEnv'], value: 'ANTHROPIC_API_KEY' }],
  1071. 0,
  1072. ])
  1073. await waitFor(() => { expect(set).toHaveBeenCalledWith('ANTHROPIC_API_KEY', 'sk-ant') })
  1074. })
  1075. it('keeps pi-ai provider-native authentication when no key is entered', async () => {
  1076. const { mutate, set } = await mountSection()
  1077. fireEvent.click(screen.getByText(en.add))
  1078. await screen.findByLabelText(en.provider)
  1079. fireEvent.click(screen.getByText(en.apply))
  1080. await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
  1081. expect(mutate.mock.calls[0]).toEqual([
  1082. 'llm-pi-ai',
  1083. [{ op: 'set', path: ['providers', 'anthropic'], value: {} }],
  1084. 0,
  1085. ])
  1086. expect(set).not.toHaveBeenCalled()
  1087. })
  1088. it('retries only the credential after refreshed settings already committed', async () => {
  1089. const committed = wireNamespaces()[2]!
  1090. const afterSettings: SettingsNamespaceView = {
  1091. ...committed,
  1092. value: { providers: {
  1093. ...(committed.value as { providers: object }).providers,
  1094. anthropic: { apiKeyEnv: 'ANTHROPIC_API_KEY' },
  1095. } },
  1096. user: { providers: {
  1097. ...(committed.user as { providers: object }).providers,
  1098. anthropic: { apiKeyEnv: 'ANTHROPIC_API_KEY' },
  1099. } },
  1100. revision: 1,
  1101. }
  1102. const mutate = vi.fn(() => Promise.resolve(remoteOk(afterSettings)))
  1103. const set = vi.fn()
  1104. .mockResolvedValueOnce(remoteFail('credential store unavailable'))
  1105. .mockResolvedValueOnce(remoteOk(undefined))
  1106. const { face, controller, mirror } = await mountSection({ mutate, set })
  1107. fireEvent.click(screen.getByText(en.add))
  1108. await screen.findByLabelText(en.provider)
  1109. fireEvent.change(screen.getByLabelText<HTMLInputElement>(en.keyInput), { target: { value: 'sk-ant' } })
  1110. fireEvent.click(screen.getByText(en.apply))
  1111. await screen.findByText('credential store unavailable')
  1112. expect(mutate).toHaveBeenCalledOnce()
  1113. face.settings.describe.mockResolvedValue(remoteOk({
  1114. writable: true,
  1115. hasDocument: false,
  1116. namespaces: wireNamespaces().map(namespace => namespace.ns === 'llm-pi-ai' ? afterSettings : namespace),
  1117. }))
  1118. // The refreshed settings answer reaches the page through the mirror's own
  1119. // refresh (the document commit's invalidation in production).
  1120. await act(async () => {
  1121. await mirror.load()
  1122. await controller.load()
  1123. })
  1124. expect(controller.store.getSnapshot().namespaces.get('llm-pi-ai')?.revision).toBe(1)
  1125. fireEvent.click(screen.getByText(en.apply))
  1126. await waitFor(() => { expect(set).toHaveBeenCalledTimes(2) })
  1127. expect(mutate).toHaveBeenCalledOnce()
  1128. expect(set).toHaveBeenLastCalledWith('ANTHROPIC_API_KEY', 'sk-ant')
  1129. })
  1130. it('switches the add card target and degrades unknown or broken targets loudly', async () => {
  1131. await mountSection()
  1132. fireEvent.click(screen.getByText(en.add))
  1133. const pick = await screen.findByLabelText<HTMLSelectElement>(en.provider)
  1134. fireEvent.change(pick, { target: { value: 'broken' } })
  1135. await screen.findByText(/unresolvable settings path/)
  1136. fireEvent.change(pick, { target: { value: 'plain' } })
  1137. await waitFor(() => {
  1138. expect(screen.getAllByText(content => content.includes(en.advancedHint)).length).toBeGreaterThan(0)
  1139. })
  1140. // The hint-only card cannot apply anything, and offers no key field.
  1141. expect(screen.getByText<HTMLButtonElement>(en.apply).disabled).toBe(true)
  1142. expect(screen.queryAllByLabelText(en.keyInput)).toHaveLength(0)
  1143. })
  1144. it('surfaces a rejected settings write and never stores the key after it', async () => {
  1145. const { set } = await mountSection({
  1146. mutate: vi.fn(() => Promise.resolve(remoteFail('llm-pi-ai: unknown pi-ai provider "bogus"', 'settings/rejected'))),
  1147. })
  1148. fireEvent.click(screen.getByText(en.add))
  1149. await screen.findByLabelText(en.provider)
  1150. fireEvent.change(screen.getByLabelText<HTMLInputElement>(en.keyInput), { target: { value: 'sk-x' } })
  1151. fireEvent.click(screen.getByText(en.apply))
  1152. await screen.findByText(/unknown pi-ai provider/)
  1153. expect(set).not.toHaveBeenCalled()
  1154. })
  1155. it('renders the card without the stored-key hint when the credential probe is refused', async () => {
  1156. const { face } = scriptedFace()
  1157. face.credentials.describe = vi.fn(() => Promise.resolve(remoteFail('no credential provider')))
  1158. const controller = new ModelsSettingsStore(ctxWith(face), settingsSchema, new SettingsDescribeMirror(ctxWith(face)))
  1159. await controller.load()
  1160. render(<ModelsSection
  1161. controller={controller}
  1162. useSnapshot={bindSnapshotSelector(controller.store)}
  1163. operations={operationsWith(face)}
  1164. schema={settingsSchema}
  1165. t={t}
  1166. renderSlot={() => null}
  1167. />)
  1168. const key = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
  1169. expect(key.placeholder).toBe(en.keyPlaceholder)
  1170. })
  1171. it('tells the user to reopen when another writer moved the namespace first', async () => {
  1172. // The stale-draft overwrite: two tabs open the same card, the other saves,
  1173. // and this one must be refused rather than replay its opening snapshot.
  1174. const { set } = await mountDeepSeekCard({
  1175. mutate: vi.fn(() => Promise.resolve(remoteFail('changed since it was read', 'settings/conflict'))),
  1176. })
  1177. fireEvent.click(screen.getByText(en.customized))
  1178. fireEvent.change(screen.getByLabelText<HTMLInputElement>(en.baseUrl), { target: { value: 'https://mine' } })
  1179. fireEvent.click(screen.getByText(en.apply))
  1180. await screen.findByText(en.conflict)
  1181. expect(set).not.toHaveBeenCalled()
  1182. })
  1183. it('keeps the card usable after a refused write', async () => {
  1184. await mountDeepSeekCard({
  1185. mutate: vi.fn(() => Promise.resolve(remoteFail('the host refused', 'settings/rejected'))),
  1186. })
  1187. fireEvent.click(screen.getByText(en.customized))
  1188. fireEvent.change(screen.getByLabelText<HTMLInputElement>(en.baseUrl), { target: { value: 'https://next' } })
  1189. fireEvent.click(screen.getByText(en.apply))
  1190. await screen.findByText('the host refused')
  1191. // Not stuck in `applying…`: the finally cleared busy, so Apply is live again.
  1192. expect(screen.getByText(en.apply)).toBeTruthy()
  1193. })
  1194. it('surfaces a shadowed credential write on the card', async () => {
  1195. await mountFirstRun({
  1196. set: vi.fn(() => Promise.resolve(remoteFail('credentials: DEEPSEEK_API_KEY is shadowed by the read-only environment'))),
  1197. })
  1198. const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
  1199. fireEvent.change(key, { target: { value: 'sk-live' } })
  1200. fireEvent.click(screen.getByText(en.apply))
  1201. await screen.findByText(/shadowed by the read-only environment/)
  1202. expect(screen.queryByRole('status')).toBeNull()
  1203. })
  1204. it('locks the key input when the launch environment provides the credential', async () => {
  1205. const { face } = await mountSection()
  1206. face.credentials.describe.mockImplementation((refs: string[]) => Promise.resolve(remoteOk(
  1207. Object.fromEntries(refs.map(ref => [ref, {
  1208. configured: ref === 'OPENAI_API_KEY', source: 'env', writable: false,
  1209. }])),
  1210. )))
  1211. fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) }))
  1212. const editorKey = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
  1213. await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyEnvLocked) })
  1214. expect(editorKey.disabled).toBe(true)
  1215. })
  1216. it('keeps a failed credential describe silent and the input usable', async () => {
  1217. const { face, set } = await mountSection()
  1218. face.credentials.describe.mockImplementation(() => Promise.resolve(remoteFail('down', 'gateway/internal')))
  1219. fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) }))
  1220. const editorKey = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
  1221. expect(editorKey.placeholder).toBe(en.keyPlaceholderNative)
  1222. fireEvent.change(editorKey, { target: { value: 'sk-live' } })
  1223. fireEvent.click(screen.getByText(en.apply))
  1224. await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) })
  1225. })
  1226. it('requires confirmation before removing a user-added provider', async () => {
  1227. const { mutate, unset } = await mountSection()
  1228. fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) }))
  1229. const dialog = screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })
  1230. expect(dialog.textContent).toContain(openaiCopy(en.deleteDescriptionWithCredential))
  1231. expect(document.activeElement).toBe(within(dialog).getByRole('button', { name: en.cancel }))
  1232. expect(unset).not.toHaveBeenCalled()
  1233. expect(mutate).not.toHaveBeenCalled()
  1234. fireEvent.click(within(dialog).getByRole('button', { name: en.cancel }))
  1235. expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
  1236. expect(mutate).not.toHaveBeenCalled()
  1237. fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) }))
  1238. fireEvent.click(within(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) }))
  1239. .getByRole('button', { name: en.close }))
  1240. expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
  1241. expect(mutate).not.toHaveBeenCalled()
  1242. fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) }))
  1243. fireEvent.click(within(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) }))
  1244. .getByRole('button', { name: openaiCopy(en.deleteConfirm) }))
  1245. await waitFor(() => { expect(unset).toHaveBeenCalledWith('OPENAI_API_KEY') })
  1246. await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
  1247. expect(unset.mock.invocationCallOrder[0]).toBeLessThan(mutate.mock.invocationCallOrder[0] as number)
  1248. expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
  1249. expect(mutate.mock.calls[0]).toEqual([
  1250. 'llm-pi-ai',
  1251. [{ op: 'unset', path: ['providers', 'openai'] }],
  1252. undefined,
  1253. ])
  1254. })
  1255. it('blocks duplicate deletion while the confirmed removal is pending', async () => {
  1256. let resolveRemoval!: (response: { ok: true; value: SettingsNamespaceView }) => void
  1257. const mutate = vi.fn(() => new Promise<{ ok: true; value: SettingsNamespaceView }>((resolve) => {
  1258. resolveRemoval = resolve
  1259. }))
  1260. await mountSection({ mutate })
  1261. fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) }))
  1262. const dialog = screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })
  1263. const confirm = within(dialog).getByRole<HTMLButtonElement>('button', { name: openaiCopy(en.deleteConfirm) })
  1264. fireEvent.click(confirm)
  1265. fireEvent.click(confirm)
  1266. await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
  1267. expect(confirm.disabled).toBe(true)
  1268. expect(within(dialog).getByRole<HTMLButtonElement>('button', { name: en.cancel }).disabled).toBe(true)
  1269. expect(within(dialog).getByRole('button', { name: openaiCopy(en.deleting) })).toBe(confirm)
  1270. fireEvent.click(within(dialog).getByRole('button', { name: en.close }))
  1271. expect(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBe(dialog)
  1272. expect(mutate).toHaveBeenCalledOnce()
  1273. await act(async () => { resolveRemoval(remoteOk(wireNamespaces()[2]!)) })
  1274. await waitFor(() => {
  1275. expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
  1276. })
  1277. })
  1278. it('renders the load failure with a retry control', async () => {
  1279. const face = scriptedFace()
  1280. face.face.llm.listProviders = vi.fn(() => Promise.resolve(remoteFail('directory down', 'gateway/internal'))) as never
  1281. const controller = new ModelsSettingsStore(
  1282. ctxWith(face.face), settingsSchema, new SettingsDescribeMirror(ctxWith(face.face)))
  1283. await controller.load()
  1284. render(<ModelsSection
  1285. controller={controller}
  1286. useSnapshot={bindSnapshotSelector(controller.store)}
  1287. operations={operationsWith(face.face)}
  1288. schema={settingsSchema}
  1289. t={t}
  1290. renderSlot={() => null}
  1291. />)
  1292. expect(screen.getByText(/directory down/)).toBeTruthy()
  1293. fireEvent.click(screen.getByText(en.retry))
  1294. await waitFor(() => { expect(screen.queryByText(/directory down/)).toBeNull() })
  1295. })
  1296. it('shows the read-only notice and disables mutations for a read-only provider', async () => {
  1297. const { face } = await mountSection()
  1298. face.settings.describe.mockImplementation(() => Promise.resolve(remoteOk({
  1299. writable: false,
  1300. hasDocument: false,
  1301. namespaces: wireNamespaces(),
  1302. })))
  1303. const controller = new ModelsSettingsStore(ctxWith(face), settingsSchema, new SettingsDescribeMirror(ctxWith(face)))
  1304. await controller.load()
  1305. cleanup()
  1306. render(<ModelsSection
  1307. controller={controller}
  1308. useSnapshot={bindSnapshotSelector(controller.store)}
  1309. operations={operationsWith(face)}
  1310. schema={settingsSchema}
  1311. t={t}
  1312. renderSlot={() => null}
  1313. />)
  1314. expect(screen.getByText(en.readOnly)).toBeTruthy()
  1315. expect(screen.getAllByText<HTMLButtonElement>(en.remove).every(button => button.disabled)).toBe(true)
  1316. expect(screen.getByText<HTMLButtonElement>(en.add).disabled).toBe(true)
  1317. })
  1318. it('toggles the row editor closed on a second edit click and on cancel', async () => {
  1319. const { mutate } = await mountSection()
  1320. const edit = screen.getByRole('button', { name: openaiCopy(en.editProvider) })
  1321. fireEvent.click(edit)
  1322. await waitFor(() => { expect(screen.queryAllByLabelText(en.keyInput).length).toBe(1) })
  1323. fireEvent.click(edit)
  1324. expect(screen.queryAllByLabelText(en.keyInput)).toHaveLength(0)
  1325. fireEvent.click(edit)
  1326. await waitFor(() => { expect(screen.queryAllByLabelText(en.keyInput).length).toBe(1) })
  1327. fireEvent.click(screen.getByText(en.cancel))
  1328. expect(screen.queryAllByLabelText(en.keyInput)).toHaveLength(0)
  1329. expect(mutate).not.toHaveBeenCalled()
  1330. })
  1331. it('cancels the add card back to the add button', async () => {
  1332. await mountSection()
  1333. fireEvent.click(screen.getByText(en.add))
  1334. await screen.findByLabelText(en.provider)
  1335. fireEvent.click(screen.getByText(en.cancel))
  1336. await screen.findByText(en.add)
  1337. expect(screen.queryByLabelText(en.provider)).toBeNull()
  1338. })
  1339. it('collapses the setup card on cancel without disturbing another open card', async () => {
  1340. // The regression: the setup card shared the row/add/declare close handler,
  1341. // so cancelling it discarded the add card's draft while staying open itself.
  1342. await mountFirstRun()
  1343. expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
  1344. fireEvent.click(screen.getByText(en.add))
  1345. await screen.findByLabelText(en.provider)
  1346. expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(2)
  1347. // The setup card is the first one on the page, above the add block.
  1348. fireEvent.click(screen.getAllByText(en.cancel)[0] as HTMLElement)
  1349. // The add card kept its draft…
  1350. expect(screen.getByLabelText(en.provider)).toBeTruthy()
  1351. // …and DeepSeek collapsed to an ordinary row carrying the missing-key dot.
  1352. expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
  1353. expect(screen.getAllByRole('img', { name: en.credentialMissing })
  1354. .some(dot => dot.closest('li')?.textContent?.includes('DeepSeek') === true)).toBe(true)
  1355. // Its card reopens through Edit, which closes the add card as any row does.
  1356. fireEvent.click(screen.getByRole('button', { name: deepSeekCopy(en.editProvider) }))
  1357. expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
  1358. expect(screen.queryByLabelText(en.provider)).toBeNull()
  1359. })
  1360. it('loads on first render of an idle controller', async () => {
  1361. const { face } = scriptedFace()
  1362. const controller = new ModelsSettingsStore(ctxWith(face), settingsSchema, new SettingsDescribeMirror(ctxWith(face)))
  1363. render(<ModelsSection
  1364. controller={controller}
  1365. useSnapshot={bindSnapshotSelector(controller.store)}
  1366. operations={operationsWith(face)}
  1367. schema={settingsSchema}
  1368. t={t}
  1369. renderSlot={() => null}
  1370. />)
  1371. await screen.findByText('DeepSeek')
  1372. })
  1373. it('removes by unsetting the profile path, never by rebuilding the section', async () => {
  1374. // The page only needs to name the profile path; rebuilding the section
  1375. // would widen the write for no benefit.
  1376. const { face, mutate, controller } = await mountSection()
  1377. await removeProviderProfile(
  1378. operationsWith(face),
  1379. controller,
  1380. { settingsNs: 'llm-plain', settingsPath: ['ghost-profile'] },
  1381. )
  1382. expect(mutate.mock.calls[0]).toEqual([
  1383. 'llm-plain',
  1384. [{ op: 'unset', path: ['ghost-profile'] }],
  1385. undefined,
  1386. ])
  1387. })
  1388. it('keeps the snapshot untouched and reports the message when a removal write is refused', async () => {
  1389. const { face, controller } = await mountSection({
  1390. mutate: vi.fn(() => Promise.resolve(remoteFail('read-only', 'settings/rejected'))),
  1391. })
  1392. const before = controller.store.getSnapshot().rows
  1393. const failure = await removeProviderProfile(
  1394. operationsWith(face),
  1395. controller,
  1396. { settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] },
  1397. )
  1398. expect(failure).toBe('read-only')
  1399. expect(controller.store.getSnapshot().rows).toBe(before)
  1400. })
  1401. it('keeps a failed identified deletion recoverable in its confirmation dialog', async () => {
  1402. const mutate = vi.fn()
  1403. .mockResolvedValueOnce(remoteFail('the host refused', 'settings/rejected'))
  1404. .mockResolvedValueOnce(remoteOk(wireNamespaces()[2]!))
  1405. const { unset } = await mountSection({ mutate })
  1406. fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) }))
  1407. const dialog = screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })
  1408. const confirm = within(dialog).getByRole('button', { name: openaiCopy(en.deleteConfirm) })
  1409. fireEvent.click(confirm)
  1410. await within(dialog).findByText('the host refused')
  1411. expect(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBe(dialog)
  1412. expect(unset).toHaveBeenCalledOnce()
  1413. expect(mutate).toHaveBeenCalledOnce()
  1414. fireEvent.click(confirm)
  1415. await waitFor(() => { expect(unset).toHaveBeenCalledTimes(2) })
  1416. await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(2) })
  1417. await waitFor(() => {
  1418. expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
  1419. })
  1420. })
  1421. it('retains credentials that are not identified as page-managed', async () => {
  1422. const { unset, mutate } = await mountSection()
  1423. const target = { provider: 'zombie', displayName: 'zombie' }
  1424. fireEvent.click(screen.getByRole('button', { name: providerCopy(en.removeProvider, target) }))
  1425. const dialog = screen.getByRole('dialog', { name: providerCopy(en.deleteTitle, target) })
  1426. expect(dialog.textContent).toContain(providerCopy(en.deleteDescription, target))
  1427. fireEvent.click(within(dialog).getByRole('button', { name: providerCopy(en.deleteConfirm, target) }))
  1428. await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
  1429. expect(unset).not.toHaveBeenCalled()
  1430. expect(mutate.mock.calls[0]).toEqual([
  1431. 'llm-pi-ai',
  1432. [{ op: 'unset', path: ['providers', 'zombie'] }],
  1433. undefined,
  1434. ])
  1435. })
  1436. it('does not remove provider settings when its managed credential removal is refused', async () => {
  1437. const { face, controller, mutate } = await mountSection({
  1438. unset: vi.fn(() => Promise.resolve(remoteFail('credential is read-only'))),
  1439. })
  1440. const failure = await removeProviderProfile(
  1441. operationsWith(face),
  1442. controller,
  1443. {
  1444. settingsNs: 'llm-pi-ai',
  1445. settingsPath: ['providers', 'openai'],
  1446. credentialRef: 'OPENAI_API_KEY',
  1447. },
  1448. )
  1449. expect(failure).toBe('credential is read-only')
  1450. expect(mutate).not.toHaveBeenCalled()
  1451. })
  1452. })
  1453. describe('apiKeyFailure', () => {
  1454. it('treats a blank field as no failure — it means keep the stored key', () => {
  1455. expect(apiKeyFailure('')).toBeUndefined()
  1456. })
  1457. it.each([
  1458. ['a printable-ASCII key', 'sk-0123456789'],
  1459. ['a padded key, which the caller trims', ' sk-abc '],
  1460. ['the printable-ASCII boundary characters', '!~'],
  1461. ['a hyphenated key carrying an equals sign', 'sk-ABC=xyz'],
  1462. ['an all-upper-case key ending in base64 padding', 'ABCD=='],
  1463. ['an all-upper-case key ending in one padding character', 'MNOPQRST='],
  1464. ])('accepts %s', (_label, draft) => {
  1465. expect(apiKeyFailure(draft)).toBeUndefined()
  1466. })
  1467. it.each([
  1468. ['spaces', ' '],
  1469. ['a tab', '\t'],
  1470. ])('fails a field holding only %s instead of silently dropping it', (_label, draft) => {
  1471. expect(apiKeyFailure(draft)).toBe('keyBlank')
  1472. })
  1473. it.each([
  1474. ['an emoji', 'sk-\u{1F600}'],
  1475. ['CJK text', 'sk-你好'],
  1476. ['full-width punctuation', 'sk-abc,'],
  1477. ['an interior space', 'sk-abc def'],
  1478. ['a C0 control character', 'sk-abc\x01'],
  1479. ['a latin-1 character', 'sk-café'],
  1480. ])('fails %s as illegal characters', (_label, draft) => {
  1481. expect(apiKeyFailure(draft)).toBe('keyIllegalCharacters')
  1482. })
  1483. it.each([
  1484. ['a pasted environment line', 'DEEPSEEK_API_KEY=sk-abc'],
  1485. ['double quotes', '"sk-abc"'],
  1486. ['single quotes', '\'sk-abc\''],
  1487. ['backticks', '`sk-abc`'],
  1488. ])('fails %s as a format failure', (_label, draft) => {
  1489. expect(apiKeyFailure(draft)).toBe('keyIllegalCharacters')
  1490. })
  1491. it('needs a matching closing quote before it calls a value wrapped', () => {
  1492. // A lone quote and an unbalanced one are legal printable ASCII, so the
  1493. // heuristic leaves them alone rather than guessing at a paste error.
  1494. expect(apiKeyFailure('"')).toBeUndefined()
  1495. expect(apiKeyFailure('"a')).toBeUndefined()
  1496. })
  1497. })