api-proxy-models.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. /**
  2. * Web session model-directory and selection behavior: dynamic provider grouping,
  3. * provider-local catalog failures, logged-target restoration without stale
  4. * catalog injection, advisory pass-through models, and the prompt-assembly
  5. * boundary for a running selection change.
  6. */
  7. import { describe, expect, it } from 'vitest'
  8. import { Context } from 'cordis'
  9. import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
  10. import type { Agent } from '@deepseek-ai/dsh-agent'
  11. import LlmService, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
  12. import type {
  13. GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo,
  14. LlmResolvedModelInfo, StreamChunk,
  15. } from '@deepseek-ai/dsh-llm'
  16. import SessionStore from '@deepseek-ai/dsh-session'
  17. import type { SessionId } from '@deepseek-ai/dsh-session'
  18. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  19. import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
  20. import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
  21. import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
  22. import { createApiProxy } from '../src/api-proxy.ts'
  23. let nextRpc = 1
  24. function request<P>(payload: P): RpcRequest<P> {
  25. return { rpcId: RpcId(`models-${String(nextRpc++)}`), payload }
  26. }
  27. class CatalogAdapter extends LlmAdapter {
  28. constructor(
  29. private readonly name: string,
  30. private readonly models: readonly LlmModelInfo[] | Error,
  31. private readonly reasoning?: LlmModelReasoningInfo,
  32. private readonly exactError?: Error,
  33. ) {
  34. super()
  35. }
  36. override providerInfo(provider: string): LlmProviderInfo {
  37. return { id: provider, name: this.name }
  38. }
  39. override listModels(): Promise<readonly LlmModelInfo[]> {
  40. return this.models instanceof Error
  41. ? Promise.reject(this.models)
  42. : Promise.resolve(this.models)
  43. }
  44. override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
  45. if (this.exactError !== undefined) return Promise.reject(this.exactError)
  46. return Promise.resolve({
  47. provider,
  48. id: model,
  49. name: model,
  50. ...this.reasoning === undefined ? {} : { reasoning: this.reasoning },
  51. })
  52. }
  53. override async *stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
  54. // Catalog tests never enter provider streaming.
  55. }
  56. }
  57. const REASONING: LlmModelReasoningInfo = {
  58. efforts: [
  59. { id: ReasoningEffortId('off'), name: 'Off' },
  60. { id: ReasoningEffortId('high'), name: 'High' },
  61. { id: ReasoningEffortId('max'), name: 'Max' },
  62. ],
  63. defaultEffort: ReasoningEffortId('high'),
  64. }
  65. async function harness(logged?: {
  66. provider: string
  67. model: string
  68. reasoningEffort?: ReasoningEffortId
  69. }): Promise<{
  70. ctx: Context
  71. agent: Agent
  72. sessionId: SessionId
  73. }> {
  74. const ctx = new Context()
  75. await ctx.plugin(SessionStore)
  76. await ctx.plugin(SystemPrompt, { persona: '' })
  77. await ctx.plugin(LlmService)
  78. await ctx.plugin(UserInteractionService)
  79. await ctx.plugin(AgentRegistry)
  80. ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', [
  81. { provider: 'deepseek-official', id: 'deepseek-chat', name: 'DeepSeek Chat' },
  82. { provider: 'deepseek-official', id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', description: 'Reasoning model' },
  83. ], REASONING))
  84. ctx.llm.registerAdapter(['broken'], new CatalogAdapter('Broken Provider', new Error('catalog offline')))
  85. ctx.llm.registerAdapter(['metadata-broken'], new CatalogAdapter('Metadata Broken', [
  86. { provider: 'metadata-broken', id: 'listed', name: 'Listed' },
  87. ], undefined, new Error('reasoning metadata offline')))
  88. ctx.llm.registerAdapter(['empty'], new CatalogAdapter('Empty Provider', []))
  89. ctx.llm.registerAdapter(['duplicate'], new CatalogAdapter('Duplicate Provider', [
  90. { provider: 'duplicate', id: 'same', name: 'Same' },
  91. { provider: 'duplicate', id: 'same', name: 'Same Again' },
  92. ]))
  93. const session = ctx.sessions.create()
  94. if (logged !== undefined) {
  95. session.append('request/header', { header: { config: logged }, reason: 'initial' })
  96. }
  97. const agent = {
  98. id: session.id,
  99. session,
  100. status: 'running',
  101. ctx,
  102. } as Agent
  103. ctx.agents.register(agent)
  104. return { ctx, agent, sessionId: session.id }
  105. }
  106. function expectValue<T>(response: { result: { ok: true; value: T } | { ok: false } }): T {
  107. if (!response.result.ok) throw new Error('expected successful response')
  108. return response.result.value
  109. }
  110. describe('Web session model selection', () => {
  111. it('groups successful providers and leaves an unlisted current target out of the catalog', async () => {
  112. const { ctx, sessionId } = await harness({
  113. provider: 'deepseek-official',
  114. model: 'private-preview',
  115. reasoningEffort: ReasoningEffortId('max'),
  116. })
  117. const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', workspaceRoot: '/tmp' })
  118. const catalog = expectValue(await api.sessions.models(request({ sessionId })))
  119. expect(catalog.current).toEqual({
  120. provider: 'deepseek-official',
  121. model: 'private-preview',
  122. reasoningEffort: 'max',
  123. })
  124. expect(catalog.groups).toEqual([{
  125. id: 'deepseek-official',
  126. name: 'DeepSeek',
  127. models: [
  128. { id: 'deepseek-chat', name: 'DeepSeek Chat', reasoning: REASONING },
  129. {
  130. id: 'deepseek-reasoner',
  131. name: 'DeepSeek Reasoner',
  132. description: 'Reasoning model',
  133. reasoning: REASONING,
  134. },
  135. ],
  136. }])
  137. expect(catalog.failures).toEqual([
  138. { id: 'broken', name: 'Broken Provider', message: 'catalog offline' },
  139. { id: 'metadata-broken', name: 'Metadata Broken', message: 'reasoning metadata offline' },
  140. {
  141. id: 'duplicate',
  142. name: 'Duplicate Provider',
  143. message: 'adapter returned invalid or duplicate model metadata for provider "duplicate"',
  144. },
  145. ])
  146. await ctx.fiber.dispose()
  147. })
  148. it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => {
  149. const { ctx, agent, sessionId } = await harness()
  150. const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', workspaceRoot: '/tmp' })
  151. const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
  152. const signal = new AbortController().signal
  153. expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
  154. .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
  155. expect((await ctx.systemPrompt.assemble()).variables)
  156. .toMatchObject({ provider: 'deepseek-official', model: 'deepseek-chat' })
  157. const selected = expectValue(await api.sessions.selectModel(request({
  158. sessionId,
  159. provider: 'deepseek-official',
  160. model: 'private-preview',
  161. reasoningEffort: 'max',
  162. })))
  163. expect(selected.selected).toEqual({
  164. provider: 'deepseek-official',
  165. model: 'private-preview',
  166. reasoningEffort: 'max',
  167. })
  168. await expect(agentEvents(ctx, agent).waterfall(
  169. 'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed),
  170. )).resolves.toMatchObject({ provider: 'deepseek-official', model: 'deepseek-chat' })
  171. expect((await ctx.systemPrompt.assemble()).variables)
  172. .toMatchObject({ provider: 'deepseek-official', model: 'private-preview' })
  173. await expect(agentEvents(ctx, agent).waterfall(
  174. 'agent/request', { turn: 1, step: 1, signal }, () => Promise.resolve(seed),
  175. )).resolves.toMatchObject({
  176. provider: 'deepseek-official',
  177. model: 'private-preview',
  178. reasoningEffort: 'max',
  179. })
  180. const unsupported = await api.sessions.selectModel(request({
  181. sessionId,
  182. provider: 'deepseek-official',
  183. model: 'private-preview',
  184. reasoningEffort: 'medium',
  185. }))
  186. expect(unsupported.result).toMatchObject({
  187. ok: false,
  188. error: {
  189. code: 'model-unavailable',
  190. message: 'provider "deepseek-official" model "private-preview" does not support reasoning effort "medium"',
  191. },
  192. })
  193. const rejected = await api.sessions.selectModel(request({
  194. sessionId,
  195. provider: 'missing',
  196. model: 'model',
  197. }))
  198. expect(rejected.result).toEqual({
  199. ok: false,
  200. error: {
  201. code: 'model-unavailable',
  202. message: 'no adapter registered for provider "missing"',
  203. details: { provider: 'missing', model: 'model' },
  204. },
  205. })
  206. expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
  207. .toEqual({ provider: 'deepseek-official', model: 'private-preview', reasoningEffort: 'max' })
  208. await ctx.fiber.dispose()
  209. })
  210. it('reads the host default live for a session whose log names no route', async () => {
  211. const { ctx, sessionId } = await harness()
  212. let stored = { provider: 'deepseek-official', model: 'deepseek-chat' }
  213. const api = createApiProxy(ctx, {
  214. defaultTarget: () => stored,
  215. cwd: '/tmp',
  216. workspaceRoot: '/tmp',
  217. })
  218. expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
  219. .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
  220. // The default moving after the session exists still reaches it: New
  221. // Session reuses a blank session rather than minting another, so a seed
  222. // captured at creation would show the superseded model there.
  223. stored = { provider: 'deepseek-official', model: 'deepseek-reasoner' }
  224. expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
  225. .toEqual({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
  226. expect(expectValue(await api.host.describe(request({}))))
  227. .toMatchObject({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
  228. await ctx.fiber.dispose()
  229. })
  230. it('keeps a session that logged a route on it when the host default moves', async () => {
  231. const { ctx, sessionId } = await harness({
  232. provider: 'deepseek-official',
  233. model: 'deepseek-chat',
  234. })
  235. let stored = { provider: 'deepseek-official', model: 'deepseek-chat' }
  236. const api = createApiProxy(ctx, {
  237. defaultTarget: () => stored,
  238. cwd: '/tmp',
  239. workspaceRoot: '/tmp',
  240. })
  241. stored = { provider: 'duplicate', model: 'same' }
  242. expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
  243. .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
  244. await ctx.fiber.dispose()
  245. })
  246. it('saves an accepted selection as the default and survives a storage failure', async () => {
  247. const { ctx, sessionId } = await harness()
  248. const saved: unknown[] = []
  249. let reject = false
  250. const api = createApiProxy(ctx, {
  251. defaultTarget: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
  252. persistDefaultTarget: (target) => {
  253. saved.push(target)
  254. return reject ? Promise.reject(new Error('read-only document')) : Promise.resolve()
  255. },
  256. cwd: '/tmp',
  257. workspaceRoot: '/tmp',
  258. })
  259. expectValue(await api.sessions.selectModel(request({
  260. sessionId, provider: 'deepseek-official', model: 'deepseek-reasoner', reasoningEffort: 'max',
  261. })))
  262. expect(saved).toEqual([
  263. { provider: 'deepseek-official', model: 'deepseek-reasoner', reasoningEffort: 'max' },
  264. ])
  265. // A refused selection never becomes anyone's default.
  266. await api.sessions.selectModel(request({ sessionId, provider: 'missing', model: 'model' }))
  267. expect(saved).toHaveLength(1)
  268. // Storage failing is not the selection failing: the switch already applies
  269. // to this session, so the call still succeeds.
  270. reject = true
  271. const stillAccepted = expectValue(await api.sessions.selectModel(request({
  272. sessionId, provider: 'deepseek-official', model: 'deepseek-chat',
  273. })))
  274. expect(stillAccepted.selected).toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' })
  275. expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
  276. .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' })
  277. await ctx.fiber.dispose()
  278. })
  279. it('refuses a prompt no adapter can route, and reports it on the directory', async () => {
  280. const { ctx, sessionId } = await harness()
  281. const api = createApiProxy(ctx, {
  282. defaultTarget: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
  283. cwd: '/tmp',
  284. workspaceRoot: '/tmp',
  285. })
  286. // The client disabling its input is an affordance; this method stays
  287. // callable, so the refusal has to live here.
  288. const refused = await api.sessions.prompt(request({
  289. sessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'hi' }],
  290. }))
  291. expect(refused.result).toMatchObject({
  292. ok: false,
  293. error: { code: 'model-unavailable', details: { provider: 'deleted-gateway', model: 'deleted-model' } },
  294. })
  295. expect(expectValue(await api.sessions.models(request({ sessionId }))).routable).toBe(false)
  296. // An advisory-unlisted model on a live route is NOT this: the route
  297. // serves it, so the prompt goes through and nothing blocks.
  298. expectValue(await api.sessions.selectModel(request({
  299. sessionId, provider: 'deepseek-official', model: 'unlisted-but-served',
  300. })))
  301. const catalog = expectValue(await api.sessions.models(request({ sessionId })))
  302. expect(catalog.routable).toBe(true)
  303. expect(catalog.groups.flatMap(group => group.models.map(model => model.id)))
  304. .not.toContain('unlisted-but-served')
  305. await ctx.fiber.dispose()
  306. })
  307. it('serves a session and its catalog when the stored default names a route that is gone', async () => {
  308. const { ctx, sessionId } = await harness()
  309. const api = createApiProxy(ctx, {
  310. // What a Models-page removal leaves behind: the settings document still
  311. // names the route the user last picked, and nothing serves it.
  312. defaultTarget: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
  313. cwd: '/tmp',
  314. workspaceRoot: '/tmp',
  315. })
  316. const catalog = expectValue(await api.sessions.models(request({ sessionId })))
  317. // Passed through rather than repaired: matching no group is precisely what
  318. // makes the composer seat prompt for a selection instead of naming a model
  319. // the deployment cannot reach.
  320. expect(catalog.current).toEqual({ provider: 'deleted-gateway', model: 'deleted-model' })
  321. expect(catalog.groups.flatMap(group => group.models.map(model => `${group.id}/${model.id}`)))
  322. .not.toContain('deleted-gateway/deleted-model')
  323. await ctx.fiber.dispose()
  324. })
  325. })