store.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  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-client-connection/client'
  4. import { messageOf, ModelsSettingsStore } from '../src/client/store.ts'
  5. let nextRpc = 0
  6. function ok<T>(value: T): RpcResponse<T> {
  7. return { rpcId: `r-${nextRpc++}` as never, result: { ok: true, value } }
  8. }
  9. function fail<T>(message: string): RpcResponse<T> {
  10. return { rpcId: `r-${nextRpc++}` as never, result: { ok: false, error: { code: 'internal', message, details: {} } } }
  11. }
  12. const DIRECTORY = [
  13. { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true },
  14. { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true },
  15. { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false },
  16. { provider: 'ghost', displayName: 'Ghost', settingsNs: '', settingsPath: [], active: true },
  17. ]
  18. const NAMESPACES = [
  19. {
  20. ns: 'llm-deepseek',
  21. schema: {},
  22. value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' },
  23. base: { baseURL: 'https://base' },
  24. applies: 'live' as const,
  25. secrets: [{ path: ['apiKey'], set: false }],
  26. revision: 0,
  27. },
  28. {
  29. ns: 'llm-pi-ai',
  30. schema: {},
  31. value: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } },
  32. user: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } },
  33. applies: 'live' as const,
  34. secrets: [],
  35. revision: 0,
  36. },
  37. ]
  38. function api(overrides: {
  39. providers?: () => Promise<RpcResponse<{ providers: typeof DIRECTORY }>>
  40. describeSettings?: () => Promise<RpcResponse<{ writable: boolean; namespaces: typeof NAMESPACES }>>
  41. describeCredentials?: (refs: string[]) => Promise<RpcResponse<{ credentials: Record<string, unknown> }>>
  42. } = {}) {
  43. const seenRefs: string[][] = []
  44. const face = {
  45. llm: {
  46. providers: overrides.providers ?? (() => Promise.resolve(ok({ providers: DIRECTORY }))),
  47. models: () => Promise.resolve(ok({ groups: [], failures: [] })),
  48. },
  49. settings: {
  50. describe: overrides.describeSettings ?? (() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: NAMESPACES }))),
  51. update: () => Promise.resolve(fail('unused')),
  52. replace: () => Promise.resolve(fail('unused')),
  53. },
  54. credentials: {
  55. describe: (payload: { refs: string[] }) => {
  56. seenRefs.push(payload.refs)
  57. return (overrides.describeCredentials ?? (refs => Promise.resolve(ok({
  58. credentials: Object.fromEntries(refs.map(ref => [ref, { configured: ref === 'OPENAI_API_KEY', writable: true }])),
  59. }))))(payload.refs)
  60. },
  61. set: () => Promise.resolve(ok({})),
  62. unset: () => Promise.resolve(ok({})),
  63. },
  64. }
  65. return { face: face as never, seenRefs }
  66. }
  67. describe('ModelsSettingsStore', () => {
  68. it('joins rows with configured, removable, and credential state', async () => {
  69. const { face, seenRefs } = api()
  70. const store = new ModelsSettingsStore(face)
  71. await store.load()
  72. const state = store.store.getSnapshot()
  73. expect(state.status).toBe('ready')
  74. expect(state.writable).toBe(true)
  75. expect(state.credentialError).toBeNull()
  76. expect(seenRefs).toEqual([['DEEPSEEK_API_KEY', 'OPENAI_API_KEY']])
  77. const byProvider = new Map(state.rows.map(row => [row.entry.provider, row]))
  78. expect(byProvider.get('deepseek-official')).toMatchObject({
  79. configured: true,
  80. removable: false,
  81. apiKeyEnv: 'DEEPSEEK_API_KEY',
  82. credential: { configured: false, writable: true },
  83. literalApiKeyConfigured: false,
  84. })
  85. expect(byProvider.get('openai')).toMatchObject({
  86. configured: true,
  87. removable: true,
  88. apiKeyEnv: 'OPENAI_API_KEY',
  89. credential: { configured: true },
  90. })
  91. expect(byProvider.get('anthropic')).toMatchObject({ configured: false, removable: false })
  92. expect(byProvider.get('anthropic')?.apiKeyEnv).toBeUndefined()
  93. expect(byProvider.get('ghost')).toMatchObject({ configured: false, removable: false })
  94. expect(state.namespaces.get('llm-pi-ai')?.ns).toBe('llm-pi-ai')
  95. })
  96. it('degrades the credential badge, not the page, when the credential domain fails', async () => {
  97. const { face } = api({ describeCredentials: () => Promise.resolve(fail('no provider')) })
  98. const store = new ModelsSettingsStore(face)
  99. await store.load()
  100. const state = store.store.getSnapshot()
  101. expect(state.status).toBe('ready')
  102. expect(state.credentialError).toBe('no provider')
  103. expect(state.rows.every(row => row.credential === undefined)).toBe(true)
  104. })
  105. it('settles a credential transport rejection without leaving the store loading', async () => {
  106. const { face } = api({
  107. describeCredentials: () => Promise.reject(new Error('credential transport down')),
  108. })
  109. const store = new ModelsSettingsStore(face)
  110. await expect(store.load()).resolves.toBeUndefined()
  111. expect(store.store.getSnapshot()).toMatchObject({
  112. status: 'ready',
  113. credentialError: 'credential transport down',
  114. })
  115. })
  116. it('stringifies a non-Error credential transport rejection', async () => {
  117. const { face } = api({
  118. // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario
  119. describeCredentials: () => Promise.reject('credential transport refusal'),
  120. })
  121. const store = new ModelsSettingsStore(face)
  122. await expect(store.load()).resolves.toBeUndefined()
  123. expect(store.store.getSnapshot().credentialError).toBe('credential transport refusal')
  124. })
  125. it('joins a configured literal key from the redacted secret sidecar', async () => {
  126. const { face } = api({
  127. describeSettings: () => Promise.resolve(ok({
  128. writable: true,
  129. hasDocument: false,
  130. namespaces: [{
  131. ...NAMESPACES[0],
  132. secrets: [
  133. { path: ['apiKey', 'nested'], set: true },
  134. { path: ['different'], set: true },
  135. { path: ['apiKey'], set: true },
  136. ],
  137. }] as never,
  138. })),
  139. providers: () => Promise.resolve(ok({ providers: [DIRECTORY[0]] as never })),
  140. })
  141. const store = new ModelsSettingsStore(face)
  142. await store.load()
  143. expect(store.store.getSnapshot().rows[0]).toMatchObject({
  144. literalApiKeyConfigured: true,
  145. apiKeyEnv: 'DEEPSEEK_API_KEY',
  146. })
  147. })
  148. it('surfaces a directory failure and keeps the last good rows', async () => {
  149. const { face } = api()
  150. const store = new ModelsSettingsStore(face)
  151. await store.load()
  152. expect(store.store.getSnapshot().rows).toHaveLength(4)
  153. const broken = api({ providers: () => Promise.resolve(fail('directory down')) })
  154. const failing = new ModelsSettingsStore(broken.face)
  155. await failing.load()
  156. expect(failing.store.getSnapshot()).toMatchObject({ status: 'error', error: 'directory down' })
  157. // The first store's snapshot is untouched by the second's failure.
  158. expect(store.store.getSnapshot().status).toBe('ready')
  159. })
  160. it('lets the newest load win over a stale slow response', async () => {
  161. let release: (() => void) | undefined
  162. const gate = new Promise<void>((resolve) => { release = resolve })
  163. let call = 0
  164. const { face } = api({
  165. providers: async () => {
  166. call += 1
  167. if (call === 1) {
  168. await gate
  169. return fail('stale slow failure')
  170. }
  171. return ok({ providers: DIRECTORY })
  172. },
  173. })
  174. const store = new ModelsSettingsStore(face)
  175. const first = store.load()
  176. const second = store.load()
  177. release?.()
  178. await Promise.all([first, second])
  179. expect(store.store.getSnapshot().status).toBe('ready')
  180. })
  181. })
  182. describe('edge joins', () => {
  183. it('treats a non-object profile as having no credential reference', async () => {
  184. const { face } = api({
  185. describeSettings: () => Promise.resolve(ok({
  186. writable: true,
  187. hasDocument: false,
  188. namespaces: [{
  189. ns: 'llm-pi-ai',
  190. schema: {},
  191. value: { providers: { weird: 'oops' } },
  192. applies: 'live' as const,
  193. secrets: [],
  194. revision: 0,
  195. }] as never,
  196. })),
  197. providers: () => Promise.resolve(ok({
  198. providers: [
  199. { provider: 'weird', displayName: 'weird', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'weird'], active: false },
  200. ] as never,
  201. })),
  202. })
  203. const store = new ModelsSettingsStore(face)
  204. await store.load()
  205. const state = store.store.getSnapshot()
  206. expect(state.rows[0]).toMatchObject({ configured: true, removable: false })
  207. expect(state.rows[0]?.apiKeyEnv).toBeUndefined()
  208. })
  209. it('skips the credential describe entirely when no row names a reference', async () => {
  210. const { face, seenRefs } = api({
  211. describeSettings: () => Promise.resolve(ok({
  212. writable: true,
  213. hasDocument: false,
  214. namespaces: [{ ns: 'llm-pi-ai', schema: {}, value: { providers: {} }, applies: 'live' as const, secrets: [], revision: 0 }] as never,
  215. })),
  216. providers: () => Promise.resolve(ok({
  217. providers: [
  218. { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false },
  219. ] as never,
  220. })),
  221. })
  222. const store = new ModelsSettingsStore(face)
  223. await store.load()
  224. expect(seenRefs).toEqual([])
  225. expect(store.store.getSnapshot().status).toBe('ready')
  226. })
  227. it('surfaces a settings describe failure', async () => {
  228. const { face } = api({ describeSettings: () => Promise.resolve(fail('settings down')) })
  229. const store = new ModelsSettingsStore(face)
  230. await store.load()
  231. expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'settings down' })
  232. })
  233. it('stringifies a non-Error load failure', async () => {
  234. // The wire can surface non-Error throwables; the store must stringify them.
  235. // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario
  236. const { face } = api({ providers: () => Promise.reject('plain refusal') })
  237. const store = new ModelsSettingsStore(face)
  238. await store.load()
  239. expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'plain refusal' })
  240. })
  241. it('drops a stale successful response after a newer load finished', async () => {
  242. let release: (() => void) | undefined
  243. const gate = new Promise<void>((resolve) => { release = resolve })
  244. let call = 0
  245. const { face } = api({
  246. providers: async () => {
  247. call += 1
  248. if (call === 1) {
  249. await gate
  250. return ok({ providers: [] as never })
  251. }
  252. return ok({ providers: DIRECTORY })
  253. },
  254. })
  255. const store = new ModelsSettingsStore(face)
  256. const first = store.load()
  257. const second = store.load()
  258. await second
  259. release?.()
  260. await first
  261. // The stale empty directory never overwrote the newer join.
  262. expect(store.store.getSnapshot().rows).toHaveLength(4)
  263. })
  264. })
  265. describe('messageOf', () => {
  266. it('reads an Error message, and stringifies anything else a rejection may carry', () => {
  267. // The wire layer rejects with an Error, but a host or a runtime can reject
  268. // with any value, and the page still has to render something.
  269. expect(messageOf(new Error('connection lost'))).toBe('connection lost')
  270. expect(messageOf('the host refused')).toBe('the host refused')
  271. expect(messageOf(undefined)).toBe('undefined')
  272. })
  273. })