store.client.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. /** Page-store join: directory × namespaces × credentials, with last-good rows on failure. */
  2. import { describe, expect, it } from 'vitest'
  3. import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client'
  4. import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
  5. import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
  6. import { settingsSchema } from './settings-schema.client.ts'
  7. import { joinProviderDirectory, ModelsSettingsStore } from '../src/client/store.ts'
  8. it.each([false, true])('retains configuration diagnostics when the route is active: %s', (active) => {
  9. expect(joinProviderDirectory(active ? [{ id: 'openai', name: 'openai' }] : [], [{
  10. provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'],
  11. error: 'catalog unavailable',
  12. }])).toEqual([{
  13. provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'],
  14. active, error: 'catalog unavailable',
  15. }])
  16. })
  17. let nextRpc = 0
  18. function ok<T>(value: T): RpcResponse<T> {
  19. return { rpcId: `r-${nextRpc++}` as never, result: { ok: true, value } }
  20. }
  21. function fail<T>(message: string): RpcResponse<T> {
  22. return { rpcId: `r-${nextRpc++}` as never, result: { ok: false, error: { code: 'gateway/internal', message, details: {} } } }
  23. }
  24. /** Answers over the Remote carrier, which has no envelope. */
  25. type RemoteAnswer<T> =
  26. | { readonly ok: true; readonly value: T }
  27. | { readonly ok: false; readonly error: RemoteError }
  28. function remoteOk<T>(value: T): RemoteAnswer<T> {
  29. return { ok: true, value }
  30. }
  31. function remoteFail<T>(message: string): RemoteAnswer<T> {
  32. return { ok: false, error: new RemoteError('gateway/internal', message, {}) }
  33. }
  34. const DIRECTORY = [
  35. { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true },
  36. { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true },
  37. { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false },
  38. { provider: 'ghost', displayName: 'Ghost', settingsNs: '', settingsPath: [], active: true },
  39. ]
  40. const NAMESPACES = [
  41. {
  42. ns: 'llm-deepseek',
  43. schema: {},
  44. value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' },
  45. base: { baseURL: 'https://base' },
  46. applies: 'live' as const,
  47. secrets: [],
  48. revision: 0,
  49. },
  50. {
  51. ns: 'llm-pi-ai',
  52. schema: {},
  53. value: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } },
  54. user: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } },
  55. applies: 'live' as const,
  56. secrets: [],
  57. revision: 0,
  58. },
  59. ]
  60. function api(overrides: {
  61. providers?: () => Promise<RpcResponse<{ providers: typeof DIRECTORY }>>
  62. describeSettings?: () => Promise<RemoteAnswer<{ writable: boolean; hasDocument: boolean; namespaces: typeof NAMESPACES }>>
  63. describeCredentials?: (refs: readonly string[]) => Promise<RemoteAnswer<Record<string, unknown>>>
  64. } = {}) {
  65. const seenRefs: string[][] = []
  66. const providers = overrides.providers ?? (() => Promise.resolve(ok({ providers: DIRECTORY })))
  67. let providerBatch: Promise<RpcResponse<{ providers: typeof DIRECTORY }>> | undefined
  68. let providerBatchReads = 0
  69. const readProviderBatch = (): Promise<RpcResponse<{ providers: typeof DIRECTORY }>> => {
  70. providerBatch ??= providers()
  71. const current = providerBatch
  72. providerBatchReads += 1
  73. if (providerBatchReads % 2 === 0) providerBatch = undefined
  74. return current
  75. }
  76. const mapProviderBatch = async <T>(
  77. project: (rows: typeof DIRECTORY) => T,
  78. ): Promise<RemoteAnswer<T>> => {
  79. const response = await readProviderBatch()
  80. return response.result.ok
  81. ? remoteOk(project(response.result.value.providers))
  82. : remoteFail(response.result.error.message)
  83. }
  84. const face = {
  85. llm: {
  86. listProviders: () => mapProviderBatch(rows => rows
  87. .filter(row => row.active)
  88. .map(row => ({ id: row.provider, name: row.displayName }))),
  89. listConfigurableProviders: () => mapProviderBatch(rows => rows
  90. .filter(row => row.settingsNs !== '')
  91. .map(({ active: _active, ...row }) => row)),
  92. discoverModels: () => Promise.resolve(remoteOk([])),
  93. },
  94. settings: {
  95. describe: overrides.describeSettings
  96. ?? (() => Promise.resolve(remoteOk({ writable: true, hasDocument: false, namespaces: NAMESPACES }))),
  97. mutate: () => Promise.resolve(remoteFail('the store spec issues no writes')),
  98. },
  99. credentials: {
  100. describe: (refs: readonly string[]) => {
  101. seenRefs.push([...refs])
  102. return (overrides.describeCredentials ?? (asked => Promise.resolve(remoteOk(
  103. Object.fromEntries(asked.map(ref => [ref, { configured: ref === 'OPENAI_API_KEY', writable: true }])),
  104. ))))(refs)
  105. },
  106. set: () => Promise.resolve(remoteOk(undefined)),
  107. unset: () => Promise.resolve(remoteOk(undefined)),
  108. },
  109. }
  110. // The page plugin's context, scripted down to the namespaces it reaches.
  111. const ctx = { remote: face } as never
  112. return { ctx, face, mirror: new SettingsDescribeMirror(ctx), seenRefs }
  113. }
  114. describe('ModelsSettingsStore', () => {
  115. it('joins rows with configured, removable, and credential state', async () => {
  116. const { ctx, mirror, seenRefs } = api()
  117. const store = new ModelsSettingsStore(ctx, settingsSchema, mirror)
  118. await store.load()
  119. const state = store.store.getSnapshot()
  120. expect(state.status).toBe('ready')
  121. expect(state.writable).toBe(true)
  122. expect(state.credentialError).toBeNull()
  123. // Named references first (rows order), then the derived <ROUTE>_API_KEY
  124. // of every row whose profile names none — one batched describe.
  125. expect(seenRefs).toEqual([['DEEPSEEK_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'GHOST_API_KEY']])
  126. const byProvider = new Map(state.rows.map(row => [row.entry.provider, row]))
  127. expect(byProvider.get('deepseek-official')).toMatchObject({
  128. configured: true,
  129. removable: false,
  130. apiKeyEnv: 'DEEPSEEK_API_KEY',
  131. credential: { configured: false, writable: true },
  132. })
  133. expect(byProvider.get('openai')).toMatchObject({
  134. configured: true,
  135. removable: true,
  136. apiKeyEnv: 'OPENAI_API_KEY',
  137. credential: { configured: true },
  138. })
  139. expect(byProvider.get('anthropic')).toMatchObject({ configured: false, removable: false })
  140. expect(byProvider.get('anthropic')?.apiKeyEnv).toBeUndefined()
  141. expect(byProvider.get('ghost')).toMatchObject({ configured: false, removable: false })
  142. expect(state.namespaces.get('llm-pi-ai')?.ns).toBe('llm-pi-ai')
  143. })
  144. it('degrades the credential badge, not the page, when the credential domain fails', async () => {
  145. const { ctx, mirror } = api({ describeCredentials: () => Promise.resolve(remoteFail('no provider')) })
  146. const store = new ModelsSettingsStore(ctx, settingsSchema, mirror)
  147. await store.load()
  148. const state = store.store.getSnapshot()
  149. expect(state.status).toBe('ready')
  150. expect(state.credentialError).toBe('no provider')
  151. expect(state.rows.every(row => row.credential === undefined)).toBe(true)
  152. })
  153. it('surfaces a directory failure and keeps the last good rows', async () => {
  154. const { ctx, mirror } = api()
  155. const store = new ModelsSettingsStore(ctx, settingsSchema, mirror)
  156. await store.load()
  157. expect(store.store.getSnapshot().rows).toHaveLength(4)
  158. const broken = api({ providers: () => Promise.resolve(fail('directory down')) })
  159. const failing = new ModelsSettingsStore(broken.ctx, settingsSchema, broken.mirror)
  160. await failing.load()
  161. expect(failing.store.getSnapshot()).toMatchObject({ status: 'error', error: 'directory down' })
  162. // The first store's snapshot is untouched by the second's failure.
  163. expect(store.store.getSnapshot().status).toBe('ready')
  164. })
  165. it('surfaces a configurable-provider directory failure', async () => {
  166. const { ctx, face, mirror } = api()
  167. const llm = (face as unknown as {
  168. llm: { listConfigurableProviders: () => Promise<RemoteAnswer<never>> }
  169. }).llm
  170. llm.listConfigurableProviders = () => Promise.resolve(remoteFail<never>('configuration directory down'))
  171. const store = new ModelsSettingsStore(ctx, settingsSchema, mirror)
  172. await store.load()
  173. expect(store.store.getSnapshot()).toMatchObject({
  174. status: 'error', error: 'configuration directory down',
  175. })
  176. })
  177. it('lets the newest load win over a stale slow response', async () => {
  178. let release: (() => void) | undefined
  179. const gate = new Promise<void>((resolve) => { release = resolve })
  180. let call = 0
  181. const { ctx, mirror } = api({
  182. providers: async () => {
  183. call += 1
  184. if (call === 1) {
  185. await gate
  186. return fail('stale slow failure')
  187. }
  188. return ok({ providers: DIRECTORY })
  189. },
  190. })
  191. const store = new ModelsSettingsStore(ctx, settingsSchema, mirror)
  192. const first = store.load()
  193. const second = store.load()
  194. release?.()
  195. await Promise.all([first, second])
  196. expect(store.store.getSnapshot().status).toBe('ready')
  197. })
  198. })
  199. describe('edge joins', () => {
  200. it('treats a non-object profile as having no credential reference', async () => {
  201. const { ctx, mirror } = api({
  202. describeSettings: () => Promise.resolve(remoteOk({
  203. writable: true,
  204. hasDocument: false,
  205. namespaces: [{
  206. ns: 'llm-pi-ai',
  207. schema: {},
  208. value: { providers: { weird: 'oops' } },
  209. applies: 'live' as const,
  210. secrets: [],
  211. revision: 0,
  212. }] as never,
  213. })),
  214. providers: () => Promise.resolve(ok({
  215. providers: [
  216. { provider: 'weird', displayName: 'weird', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'weird'], active: false },
  217. ] as never,
  218. })),
  219. })
  220. const store = new ModelsSettingsStore(ctx, settingsSchema, mirror)
  221. await store.load()
  222. const state = store.store.getSnapshot()
  223. expect(state.rows[0]).toMatchObject({ configured: true, removable: false })
  224. expect(state.rows[0]?.apiKeyEnv).toBeUndefined()
  225. })
  226. it('describes the derived reference for a row whose profile names none', async () => {
  227. const { ctx, mirror, seenRefs } = api({
  228. describeSettings: () => Promise.resolve(remoteOk({
  229. writable: true,
  230. hasDocument: false,
  231. namespaces: [{ ns: 'llm-pi-ai', schema: {}, value: { providers: {} }, applies: 'live' as const, secrets: [], revision: 0 }] as never,
  232. })),
  233. providers: () => Promise.resolve(ok({
  234. providers: [
  235. { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false },
  236. ] as never,
  237. })),
  238. describeCredentials: refs => Promise.resolve(remoteOk(
  239. Object.fromEntries(refs.map(ref => [ref, { configured: true, writable: true }])),
  240. )),
  241. })
  242. const store = new ModelsSettingsStore(ctx, settingsSchema, mirror)
  243. await store.load()
  244. // The dormant row names no reference, so the join asks about the page's
  245. // own derived <ROUTE>_API_KEY — what the editor would display for it.
  246. expect(seenRefs).toEqual([['ANTHROPIC_API_KEY']])
  247. const state = store.store.getSnapshot()
  248. expect(state.status).toBe('ready')
  249. expect(state.rows[0]?.credential).toBeUndefined()
  250. expect(state.rows[0]?.derivedCredential).toMatchObject({ configured: true })
  251. })
  252. it('surfaces a settings describe failure', async () => {
  253. const { ctx, mirror } = api({ describeSettings: () => Promise.resolve(remoteFail('settings down')) })
  254. const store = new ModelsSettingsStore(ctx, settingsSchema, mirror)
  255. await store.load()
  256. expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'settings down' })
  257. })
  258. it('reports a terminally unavailable settings mirror precisely', async () => {
  259. const { ctx } = api()
  260. const store = new ModelsSettingsStore(
  261. ctx,
  262. settingsSchema,
  263. new SettingsDescribeMirror(ctx, 'memory'),
  264. )
  265. await store.load()
  266. expect(store.store.getSnapshot()).toMatchObject({
  267. status: 'error',
  268. error: 'settings are unavailable in this browser',
  269. })
  270. })
  271. it('reuses a held settings view after its refresh fails', async () => {
  272. let settingsCall = 0
  273. const { ctx, mirror } = api({
  274. describeSettings: () => {
  275. settingsCall += 1
  276. return Promise.resolve(settingsCall === 1
  277. ? remoteOk({ writable: true, hasDocument: false, namespaces: NAMESPACES })
  278. : remoteFail('settings refresh down'))
  279. },
  280. })
  281. const store = new ModelsSettingsStore(ctx, settingsSchema, mirror)
  282. await store.load()
  283. await mirror.load()
  284. expect(mirror.getSnapshot().error).toBe('settings refresh down')
  285. await store.load()
  286. expect(store.store.getSnapshot()).toMatchObject({ status: 'ready', error: null })
  287. expect(store.store.getSnapshot().rows).toHaveLength(4)
  288. })
  289. it('drops a stale successful response after a newer load finished', async () => {
  290. let release: (() => void) | undefined
  291. const gate = new Promise<void>((resolve) => { release = resolve })
  292. let call = 0
  293. const { ctx, mirror } = api({
  294. providers: async () => {
  295. call += 1
  296. if (call === 1) {
  297. await gate
  298. return ok({ providers: [] as never })
  299. }
  300. return ok({ providers: DIRECTORY })
  301. },
  302. })
  303. const store = new ModelsSettingsStore(ctx, settingsSchema, mirror)
  304. const first = store.load()
  305. const second = store.load()
  306. await second
  307. release?.()
  308. await first
  309. // The stale empty directory never overwrote the newer join.
  310. expect(store.store.getSnapshot().rows).toHaveLength(4)
  311. })
  312. })