store.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  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, 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. namespaces: [{
  130. ...NAMESPACES[0],
  131. secrets: [
  132. { path: ['apiKey', 'nested'], set: true },
  133. { path: ['different'], set: true },
  134. { path: ['apiKey'], set: true },
  135. ],
  136. }] as never,
  137. })),
  138. providers: () => Promise.resolve(ok({ providers: [DIRECTORY[0]] as never })),
  139. })
  140. const store = new ModelsSettingsStore(face)
  141. await store.load()
  142. expect(store.store.getSnapshot().rows[0]).toMatchObject({
  143. literalApiKeyConfigured: true,
  144. apiKeyEnv: 'DEEPSEEK_API_KEY',
  145. })
  146. })
  147. it('surfaces a directory failure and keeps the last good rows', async () => {
  148. const { face } = api()
  149. const store = new ModelsSettingsStore(face)
  150. await store.load()
  151. expect(store.store.getSnapshot().rows).toHaveLength(4)
  152. const broken = api({ providers: () => Promise.resolve(fail('directory down')) })
  153. const failing = new ModelsSettingsStore(broken.face)
  154. await failing.load()
  155. expect(failing.store.getSnapshot()).toMatchObject({ status: 'error', error: 'directory down' })
  156. // The first store's snapshot is untouched by the second's failure.
  157. expect(store.store.getSnapshot().status).toBe('ready')
  158. })
  159. it('lets the newest load win over a stale slow response', async () => {
  160. let release: (() => void) | undefined
  161. const gate = new Promise<void>((resolve) => { release = resolve })
  162. let call = 0
  163. const { face } = api({
  164. providers: async () => {
  165. call += 1
  166. if (call === 1) {
  167. await gate
  168. return fail('stale slow failure')
  169. }
  170. return ok({ providers: DIRECTORY })
  171. },
  172. })
  173. const store = new ModelsSettingsStore(face)
  174. const first = store.load()
  175. const second = store.load()
  176. release?.()
  177. await Promise.all([first, second])
  178. expect(store.store.getSnapshot().status).toBe('ready')
  179. })
  180. })
  181. describe('edge joins', () => {
  182. it('treats a non-object profile as having no credential reference', async () => {
  183. const { face } = api({
  184. describeSettings: () => Promise.resolve(ok({
  185. writable: true,
  186. namespaces: [{
  187. ns: 'llm-pi-ai',
  188. schema: {},
  189. value: { providers: { weird: 'oops' } },
  190. applies: 'live' as const,
  191. secrets: [],
  192. revision: 0,
  193. }] as never,
  194. })),
  195. providers: () => Promise.resolve(ok({
  196. providers: [
  197. { provider: 'weird', displayName: 'weird', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'weird'], active: false },
  198. ] as never,
  199. })),
  200. })
  201. const store = new ModelsSettingsStore(face)
  202. await store.load()
  203. const state = store.store.getSnapshot()
  204. expect(state.rows[0]).toMatchObject({ configured: true, removable: false })
  205. expect(state.rows[0]?.apiKeyEnv).toBeUndefined()
  206. })
  207. it('skips the credential describe entirely when no row names a reference', async () => {
  208. const { face, seenRefs } = api({
  209. describeSettings: () => Promise.resolve(ok({
  210. writable: true,
  211. namespaces: [{ ns: 'llm-pi-ai', schema: {}, value: { providers: {} }, applies: 'live' as const, secrets: [], revision: 0 }] as never,
  212. })),
  213. providers: () => Promise.resolve(ok({
  214. providers: [
  215. { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false },
  216. ] as never,
  217. })),
  218. })
  219. const store = new ModelsSettingsStore(face)
  220. await store.load()
  221. expect(seenRefs).toEqual([])
  222. expect(store.store.getSnapshot().status).toBe('ready')
  223. })
  224. it('surfaces a settings describe failure', async () => {
  225. const { face } = api({ describeSettings: () => Promise.resolve(fail('settings down')) })
  226. const store = new ModelsSettingsStore(face)
  227. await store.load()
  228. expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'settings down' })
  229. })
  230. it('stringifies a non-Error load failure', async () => {
  231. // The wire can surface non-Error throwables; the store must stringify them.
  232. // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario
  233. const { face } = api({ providers: () => Promise.reject('plain refusal') })
  234. const store = new ModelsSettingsStore(face)
  235. await store.load()
  236. expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'plain refusal' })
  237. })
  238. it('drops a stale successful response after a newer load finished', async () => {
  239. let release: (() => void) | undefined
  240. const gate = new Promise<void>((resolve) => { release = resolve })
  241. let call = 0
  242. const { face } = api({
  243. providers: async () => {
  244. call += 1
  245. if (call === 1) {
  246. await gate
  247. return ok({ providers: [] as never })
  248. }
  249. return ok({ providers: DIRECTORY })
  250. },
  251. })
  252. const store = new ModelsSettingsStore(face)
  253. const first = store.load()
  254. const second = store.load()
  255. await second
  256. release?.()
  257. await first
  258. // The stale empty directory never overwrote the newer join.
  259. expect(store.store.getSnapshot().rows).toHaveLength(4)
  260. })
  261. })
  262. describe('messageOf', () => {
  263. it('reads an Error message, and stringifies anything else a rejection may carry', () => {
  264. // The wire layer rejects with an Error, but a host or a runtime can reject
  265. // with any value, and the page still has to render something.
  266. expect(messageOf(new Error('connection lost'))).toBe('connection lost')
  267. expect(messageOf('the host refused')).toBe('the host refused')
  268. expect(messageOf(undefined)).toBe('undefined')
  269. })
  270. })