api-proxy-models.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. /**
  2. * Web session model-directory and selection behavior: dynamic provider grouping,
  3. * provider-local catalog failures, logged-target restoration, advisory unlisted
  4. * models, and the prompt-assembly boundary for a running selection change.
  5. */
  6. import { describe, expect, it } from 'vitest'
  7. import { Context } from 'cordis'
  8. import AgentRegistry, { agentEvents, installAgentLlmTarget } from '@deepseek-ai/dsh-agent'
  9. import type { Agent, AgentLlmTargetRef } from '@deepseek-ai/dsh-agent'
  10. import LlmService, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
  11. import type {
  12. GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo,
  13. LlmResolvedModelInfo, StreamChunk,
  14. } from '@deepseek-ai/dsh-llm'
  15. import SessionStore from '@deepseek-ai/dsh-session'
  16. import type { SessionId } from '@deepseek-ai/dsh-session'
  17. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  18. import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
  19. import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
  20. import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
  21. import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
  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. context: { contextWindow: model === 'private-preview' ? 128_000 : 64_000 },
  51. ...this.reasoning === undefined ? {} : { reasoning: this.reasoning },
  52. })
  53. }
  54. override async *stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
  55. // Catalog tests never enter provider streaming.
  56. }
  57. }
  58. const REASONING: LlmModelReasoningInfo = {
  59. efforts: [
  60. { id: ReasoningEffortId('off'), name: 'Off' },
  61. { id: ReasoningEffortId('high'), name: 'High' },
  62. { id: ReasoningEffortId('max'), name: 'Max' },
  63. ],
  64. defaultEffort: ReasoningEffortId('high'),
  65. }
  66. async function hostContext(): Promise<Context> {
  67. const ctx = new Context()
  68. await ctx.plugin(SessionStore)
  69. await ctx.plugin(SystemPrompt, { persona: '' })
  70. await ctx.plugin(LlmService)
  71. await ctx.plugin(UserInteractionService)
  72. await ctx.plugin(AgentRegistry)
  73. ctx.llm.registerAdapter(['deepseek'], new CatalogAdapter('DeepSeek', [
  74. { provider: 'deepseek', id: 'deepseek-chat', name: 'DeepSeek Chat' },
  75. { provider: 'deepseek', id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', description: 'Reasoning model' },
  76. ], REASONING))
  77. ctx.llm.registerAdapter(['broken'], new CatalogAdapter('Broken Provider', new Error('catalog offline')))
  78. ctx.llm.registerAdapter(['metadata-broken'], new CatalogAdapter('Metadata Broken', [
  79. { provider: 'metadata-broken', id: 'listed', name: 'Listed' },
  80. ], undefined, new Error('reasoning metadata offline')))
  81. ctx.llm.registerAdapter(['empty'], new CatalogAdapter('Empty Provider', []))
  82. ctx.llm.registerAdapter(['duplicate'], new CatalogAdapter('Duplicate Provider', [
  83. { provider: 'duplicate', id: 'same', name: 'Same' },
  84. { provider: 'duplicate', id: 'same', name: 'Same Again' },
  85. ]))
  86. return ctx
  87. }
  88. async function harness(logged?: {
  89. provider: string
  90. model: string
  91. reasoningEffort?: ReasoningEffortId
  92. }): Promise<{
  93. ctx: Context
  94. agent: Agent
  95. sessionId: SessionId
  96. }> {
  97. const ctx = await hostContext()
  98. const session = ctx.sessions.create()
  99. if (logged !== undefined) {
  100. session.append('request/header', { header: { config: logged }, reason: 'initial' })
  101. }
  102. const agent = {
  103. id: session.id,
  104. session,
  105. status: 'running',
  106. ctx,
  107. } as Agent
  108. ctx.agents.register(agent)
  109. return { ctx, agent, sessionId: session.id }
  110. }
  111. function expectValue<T>(response: { result: { ok: true; value: T } | { ok: false } }): T {
  112. if (!response.result.ok) throw new Error('expected successful response')
  113. return response.result.value
  114. }
  115. async function nextMetrics(
  116. iterator: AsyncIterator<RpcRequest<MuxFrame>>,
  117. ): Promise<Extract<MuxFrame, { type: 'session/metrics' }>['metrics']> {
  118. for (;;) {
  119. const next = await iterator.next()
  120. if (next.done) throw new Error('mux ended before a metrics frame')
  121. if (next.value.payload.type === 'session/metrics') return next.value.payload.metrics
  122. }
  123. }
  124. describe('Web session model selection', () => {
  125. it('groups successful providers, isolates failures, and preserves an unlisted current model', async () => {
  126. const { ctx, sessionId } = await harness({
  127. provider: 'deepseek',
  128. model: 'private-preview',
  129. reasoningEffort: ReasoningEffortId('max'),
  130. })
  131. const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
  132. const catalog = expectValue(await api.sessions.models(request({ sessionId })))
  133. expect(catalog.current).toEqual({
  134. provider: 'deepseek',
  135. model: 'private-preview',
  136. reasoningEffort: 'max',
  137. })
  138. expect(catalog.groups).toEqual([{
  139. id: 'deepseek',
  140. name: 'DeepSeek',
  141. models: [
  142. { id: 'deepseek-chat', name: 'DeepSeek Chat', reasoning: REASONING },
  143. {
  144. id: 'deepseek-reasoner',
  145. name: 'DeepSeek Reasoner',
  146. description: 'Reasoning model',
  147. reasoning: REASONING,
  148. },
  149. {
  150. id: 'private-preview',
  151. name: 'private-preview',
  152. unlisted: true,
  153. reasoning: REASONING,
  154. },
  155. ],
  156. }])
  157. expect(catalog.failures).toEqual([
  158. { id: 'broken', name: 'Broken Provider', message: 'catalog offline' },
  159. { id: 'metadata-broken', name: 'Metadata Broken', message: 'reasoning metadata offline' },
  160. {
  161. id: 'duplicate',
  162. name: 'Duplicate Provider',
  163. message: 'adapter returned invalid or duplicate model metadata for provider "duplicate"',
  164. },
  165. ])
  166. await ctx.fiber.dispose()
  167. })
  168. it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => {
  169. const { ctx, agent, sessionId } = await harness()
  170. const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
  171. const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
  172. const signal = new AbortController().signal
  173. expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
  174. .toEqual({ provider: 'deepseek', model: 'deepseek-chat' })
  175. expect((await ctx.systemPrompt.assemble()).variables)
  176. .toMatchObject({ provider: 'deepseek', model: 'deepseek-chat' })
  177. const selected = expectValue(await api.sessions.selectModel(request({
  178. sessionId,
  179. provider: 'deepseek',
  180. model: 'private-preview',
  181. reasoningEffort: 'max',
  182. })))
  183. expect(selected.selected).toEqual({
  184. provider: 'deepseek',
  185. model: 'private-preview',
  186. reasoningEffort: 'max',
  187. })
  188. await expect(agentEvents(ctx, agent).waterfall(
  189. 'agent/request', 1, 0, signal, () => Promise.resolve(seed),
  190. )).resolves.toMatchObject({ provider: 'deepseek', model: 'deepseek-chat' })
  191. expect((await ctx.systemPrompt.assemble()).variables)
  192. .toMatchObject({ provider: 'deepseek', model: 'private-preview' })
  193. await expect(agentEvents(ctx, agent).waterfall(
  194. 'agent/request', 1, 1, signal, () => Promise.resolve(seed),
  195. )).resolves.toMatchObject({
  196. provider: 'deepseek',
  197. model: 'private-preview',
  198. reasoningEffort: 'max',
  199. })
  200. const unsupported = await api.sessions.selectModel(request({
  201. sessionId,
  202. provider: 'deepseek',
  203. model: 'private-preview',
  204. reasoningEffort: 'medium',
  205. }))
  206. expect(unsupported.result).toMatchObject({
  207. ok: false,
  208. error: {
  209. code: 'model-unavailable',
  210. message: 'provider "deepseek" model "private-preview" does not support reasoning effort "medium"',
  211. },
  212. })
  213. const rejected = await api.sessions.selectModel(request({
  214. sessionId,
  215. provider: 'missing',
  216. model: 'model',
  217. }))
  218. expect(rejected.result).toEqual({
  219. ok: false,
  220. error: {
  221. code: 'model-unavailable',
  222. message: 'no adapter registered for provider "missing"',
  223. details: { provider: 'missing', model: 'model' },
  224. },
  225. })
  226. expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
  227. .toEqual({ provider: 'deepseek', model: 'private-preview', reasoningEffort: 'max' })
  228. await ctx.fiber.dispose()
  229. })
  230. it('publishes unknown capacity immediately on selection, then the exact selected route capacity', async () => {
  231. const { ctx, sessionId } = await harness()
  232. const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
  233. expectValue(await api.sessions.models(request({ sessionId })))
  234. const controller = new AbortController()
  235. const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
  236. expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
  237. expect((await nextMetrics(iterator)).contextWindow).toBe(64_000)
  238. expectValue(await api.sessions.selectModel(request({
  239. sessionId,
  240. provider: 'deepseek',
  241. model: 'private-preview',
  242. })))
  243. expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
  244. expect((await nextMetrics(iterator)).contextWindow).toBe(128_000)
  245. controller.abort()
  246. await iterator.return?.()
  247. await ctx.fiber.dispose()
  248. })
  249. it('uses logged capacity without installing Web routing while scheduling foreign metrics', async () => {
  250. const ctx = await hostContext()
  251. const api = createApiProxy(ctx, {
  252. provider: 'deepseek',
  253. model: 'deepseek-chat',
  254. cwd: '/tmp',
  255. workspaceRoot: '/tmp',
  256. })
  257. const controller = new AbortController()
  258. const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
  259. const initialMetrics = nextMetrics(iterator)
  260. const session = ctx.sessions.create()
  261. expect((await initialMetrics).contextWindow).toBeUndefined()
  262. session.append('request/header', {
  263. header: { config: { provider: 'deepseek', model: 'private-preview' } },
  264. reason: 'change',
  265. })
  266. const foreign = {
  267. id: session.id,
  268. session,
  269. status: 'running',
  270. ctx,
  271. } as Agent
  272. const foreignTarget: AgentLlmTargetRef = {
  273. current: { provider: 'foreign', model: 'foreign-model' },
  274. assembled: undefined,
  275. }
  276. const disposeForeignTarget = installAgentLlmTarget(foreign.ctx, foreignTarget)
  277. const scheduledMetrics = nextMetrics(iterator)
  278. ctx.agents.register(foreign)
  279. expect((await scheduledMetrics).contextWindow).toBeUndefined()
  280. expect((await nextMetrics(iterator)).contextWindow).toBe(128_000)
  281. expect((await ctx.systemPrompt.assemble()).variables)
  282. .toMatchObject({ provider: 'foreign', model: 'foreign-model' })
  283. const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
  284. const signal = new AbortController().signal
  285. await expect(agentEvents(ctx, foreign).waterfall(
  286. 'agent/request', 1, 0, signal, () => Promise.resolve(seed),
  287. )).resolves.toMatchObject({ provider: 'foreign', model: 'foreign-model' })
  288. disposeForeignTarget()
  289. expect((await ctx.systemPrompt.assemble()).variables).not.toHaveProperty('provider')
  290. await expect(agentEvents(ctx, foreign).waterfall(
  291. 'agent/request', 1, 1, signal, () => Promise.resolve(seed),
  292. )).resolves.toBe(seed)
  293. controller.abort()
  294. await iterator.return?.()
  295. await ctx.fiber.dispose()
  296. })
  297. })