api-proxy-models.spec.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  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 } from '@deepseek-ai/dsh-agent'
  9. import type { Agent } 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 { createApiProxy } from '../src/api-proxy.ts'
  22. let nextRpc = 1
  23. function request<P>(payload: P): RpcRequest<P> {
  24. return { rpcId: RpcId(`models-${String(nextRpc++)}`), payload }
  25. }
  26. class CatalogAdapter extends LlmAdapter {
  27. constructor(
  28. private readonly name: string,
  29. private readonly models: readonly LlmModelInfo[] | Error,
  30. private readonly reasoning?: LlmModelReasoningInfo,
  31. private readonly exactError?: Error,
  32. ) {
  33. super()
  34. }
  35. override providerInfo(provider: string): LlmProviderInfo {
  36. return { id: provider, name: this.name }
  37. }
  38. override listModels(): Promise<readonly LlmModelInfo[]> {
  39. return this.models instanceof Error
  40. ? Promise.reject(this.models)
  41. : Promise.resolve(this.models)
  42. }
  43. override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
  44. if (this.exactError !== undefined) return Promise.reject(this.exactError)
  45. return Promise.resolve({
  46. provider,
  47. id: model,
  48. name: model,
  49. ...this.reasoning === undefined ? {} : { reasoning: this.reasoning },
  50. })
  51. }
  52. override async *stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
  53. // Catalog tests never enter provider streaming.
  54. }
  55. }
  56. const REASONING: LlmModelReasoningInfo = {
  57. efforts: [
  58. { id: ReasoningEffortId('off'), name: 'Off' },
  59. { id: ReasoningEffortId('high'), name: 'High' },
  60. { id: ReasoningEffortId('max'), name: 'Max' },
  61. ],
  62. defaultEffort: ReasoningEffortId('high'),
  63. }
  64. async function harness(logged?: {
  65. provider: string
  66. model: string
  67. reasoningEffort?: ReasoningEffortId
  68. }): Promise<{
  69. ctx: Context
  70. agent: Agent
  71. sessionId: SessionId
  72. }> {
  73. const ctx = new Context()
  74. await ctx.plugin(SessionStore)
  75. await ctx.plugin(SystemPrompt, { persona: '' })
  76. await ctx.plugin(LlmService)
  77. await ctx.plugin(UserInteractionService)
  78. await ctx.plugin(AgentRegistry)
  79. ctx.llm.registerAdapter(['deepseek'], new CatalogAdapter('DeepSeek', [
  80. { provider: 'deepseek', id: 'deepseek-chat', name: 'DeepSeek Chat' },
  81. { provider: 'deepseek', id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', description: 'Reasoning model' },
  82. ], REASONING))
  83. ctx.llm.registerAdapter(['broken'], new CatalogAdapter('Broken Provider', new Error('catalog offline')))
  84. ctx.llm.registerAdapter(['metadata-broken'], new CatalogAdapter('Metadata Broken', [
  85. { provider: 'metadata-broken', id: 'listed', name: 'Listed' },
  86. ], undefined, new Error('reasoning metadata offline')))
  87. ctx.llm.registerAdapter(['empty'], new CatalogAdapter('Empty Provider', []))
  88. ctx.llm.registerAdapter(['duplicate'], new CatalogAdapter('Duplicate Provider', [
  89. { provider: 'duplicate', id: 'same', name: 'Same' },
  90. { provider: 'duplicate', id: 'same', name: 'Same Again' },
  91. ]))
  92. const session = ctx.sessions.create()
  93. if (logged !== undefined) {
  94. session.append('request/header', { header: { config: logged }, reason: 'initial' })
  95. }
  96. const agent = {
  97. id: session.id,
  98. session,
  99. status: 'running',
  100. ctx,
  101. } as Agent
  102. ctx.agents.register(agent)
  103. return { ctx, agent, sessionId: session.id }
  104. }
  105. function expectValue<T>(response: { result: { ok: true; value: T } | { ok: false } }): T {
  106. if (!response.result.ok) throw new Error('expected successful response')
  107. return response.result.value
  108. }
  109. describe('Web session model selection', () => {
  110. it('rejects a second prompt image before attachment persistence', async () => {
  111. const { ctx, sessionId } = await harness()
  112. const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
  113. const image = { type: 'image' as const, mediaType: 'image/png' as const, data: 'AA==' }
  114. const response = await api.sessions.prompt(request({
  115. sessionId,
  116. mode: 'queue' as const,
  117. content: [image, image],
  118. }))
  119. expect(response.result).toEqual({
  120. ok: false,
  121. error: {
  122. code: 'attachment-error',
  123. message: 'A prompt may contain at most one image.',
  124. details: { reason: 'TOO_MANY_IMAGES' },
  125. },
  126. })
  127. await ctx.fiber.dispose()
  128. })
  129. it('groups successful providers, isolates failures, and preserves an unlisted current model', async () => {
  130. const { ctx, sessionId } = await harness({
  131. provider: 'deepseek',
  132. model: 'private-preview',
  133. reasoningEffort: ReasoningEffortId('max'),
  134. })
  135. const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
  136. const catalog = expectValue(await api.sessions.models(request({ sessionId })))
  137. expect(catalog.current).toEqual({
  138. provider: 'deepseek',
  139. model: 'private-preview',
  140. reasoningEffort: 'max',
  141. })
  142. expect(catalog.groups).toEqual([{
  143. id: 'deepseek',
  144. name: 'DeepSeek',
  145. models: [
  146. { id: 'deepseek-chat', name: 'DeepSeek Chat', reasoning: REASONING },
  147. {
  148. id: 'deepseek-reasoner',
  149. name: 'DeepSeek Reasoner',
  150. description: 'Reasoning model',
  151. reasoning: REASONING,
  152. },
  153. {
  154. id: 'private-preview',
  155. name: 'private-preview',
  156. unlisted: true,
  157. reasoning: REASONING,
  158. },
  159. ],
  160. }])
  161. expect(catalog.failures).toEqual([
  162. { id: 'broken', name: 'Broken Provider', message: 'catalog offline' },
  163. { id: 'metadata-broken', name: 'Metadata Broken', message: 'reasoning metadata offline' },
  164. {
  165. id: 'duplicate',
  166. name: 'Duplicate Provider',
  167. message: 'adapter returned invalid or duplicate model metadata for provider "duplicate"',
  168. },
  169. ])
  170. await ctx.fiber.dispose()
  171. })
  172. it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => {
  173. const { ctx, agent, sessionId } = await harness()
  174. const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
  175. const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
  176. const signal = new AbortController().signal
  177. expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
  178. .toEqual({ provider: 'deepseek', model: 'deepseek-chat' })
  179. expect((await ctx.systemPrompt.assemble()).variables)
  180. .toMatchObject({ provider: 'deepseek', model: 'deepseek-chat' })
  181. const selected = expectValue(await api.sessions.selectModel(request({
  182. sessionId,
  183. provider: 'deepseek',
  184. model: 'private-preview',
  185. reasoningEffort: 'max',
  186. })))
  187. expect(selected.selected).toEqual({
  188. provider: 'deepseek',
  189. model: 'private-preview',
  190. reasoningEffort: 'max',
  191. })
  192. await expect(agentEvents(ctx, agent).waterfall(
  193. 'agent/request', 1, 0, signal, () => Promise.resolve(seed),
  194. )).resolves.toMatchObject({ provider: 'deepseek', model: 'deepseek-chat' })
  195. expect((await ctx.systemPrompt.assemble()).variables)
  196. .toMatchObject({ provider: 'deepseek', model: 'private-preview' })
  197. await expect(agentEvents(ctx, agent).waterfall(
  198. 'agent/request', 1, 1, signal, () => Promise.resolve(seed),
  199. )).resolves.toMatchObject({
  200. provider: 'deepseek',
  201. model: 'private-preview',
  202. reasoningEffort: 'max',
  203. })
  204. const unsupported = await api.sessions.selectModel(request({
  205. sessionId,
  206. provider: 'deepseek',
  207. model: 'private-preview',
  208. reasoningEffort: 'medium',
  209. }))
  210. expect(unsupported.result).toMatchObject({
  211. ok: false,
  212. error: {
  213. code: 'model-unavailable',
  214. message: 'provider "deepseek" model "private-preview" does not support reasoning effort "medium"',
  215. },
  216. })
  217. const rejected = await api.sessions.selectModel(request({
  218. sessionId,
  219. provider: 'missing',
  220. model: 'model',
  221. }))
  222. expect(rejected.result).toEqual({
  223. ok: false,
  224. error: {
  225. code: 'model-unavailable',
  226. message: 'no adapter registered for provider "missing"',
  227. details: { provider: 'missing', model: 'model' },
  228. },
  229. })
  230. expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
  231. .toEqual({ provider: 'deepseek', model: 'private-preview', reasoningEffort: 'max' })
  232. await ctx.fiber.dispose()
  233. })
  234. it('refuses a text-only selection once the session log carries an image', async () => {
  235. const { ctx, sessionId, agent } = await harness()
  236. ctx.llm.registerAdapter(['text-only'], new class extends CatalogAdapter {
  237. override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
  238. return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] })
  239. }
  240. }('Text Only', []))
  241. ctx.llm.registerAdapter(['vision'], new class extends CatalogAdapter {
  242. override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
  243. return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text', 'image'] })
  244. }
  245. }('Vision', []))
  246. const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
  247. // Before any image lands, a text-only selection is legitimate.
  248. expect(expectValue(await api.sessions.selectModel(request({
  249. sessionId, provider: 'text-only', model: 'plain',
  250. }))).selected).toEqual({ provider: 'text-only', model: 'plain' })
  251. agent.session.append('user/message', {
  252. id: 'msg-image', role: 'user', source: { kind: 'user' },
  253. content: [{ type: 'image', attachment: { attachmentId: 'att-1', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } }],
  254. } as never, { surfaceOp: 'append' })
  255. // The log is immutable: a text-only route would fail every later turn.
  256. const stranded = await api.sessions.selectModel(request({
  257. sessionId, provider: 'text-only', model: 'plain',
  258. }))
  259. expect(stranded.result).toMatchObject({
  260. ok: false,
  261. error: { code: 'model-unavailable', message: expect.stringMatching(/history already contains images/) as unknown },
  262. })
  263. // Image-capable and modality-unknown routes stay selectable.
  264. expect(expectValue(await api.sessions.selectModel(request({
  265. sessionId, provider: 'vision', model: 'sees',
  266. }))).selected).toEqual({ provider: 'vision', model: 'sees' })
  267. expect(expectValue(await api.sessions.selectModel(request({
  268. sessionId, provider: 'deepseek', model: 'deepseek-chat',
  269. }))).selected).toEqual({ provider: 'deepseek', model: 'deepseek-chat', reasoningEffort: 'high' })
  270. await ctx.fiber.dispose()
  271. })
  272. it('refuses a text-only selection while an image prompt is still queued (not yet logged)', async () => {
  273. const { ctx, sessionId, agent } = await harness()
  274. ctx.llm.registerAdapter(['text-only'], new class extends CatalogAdapter {
  275. override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
  276. return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] })
  277. }
  278. }('Text Only', []))
  279. const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
  280. // The queued message enters the session log only when claimed — after a
  281. // model switch would already have landed. The pending-inbox mirror must
  282. // therefore gate the switch too.
  283. ctx.emit('agent/inbox/enqueue', agent, {
  284. id: 'q-1', role: 'user', source: { kind: 'user' },
  285. content: [{ type: 'image', attachment: { attachmentId: 'att-q', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } }],
  286. } as never, 'queued')
  287. const stranded = await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))
  288. expect(stranded.result.ok).toBe(false)
  289. // Claiming the message drains the mirror; the log now owns the decision.
  290. ctx.emit('agent/inbox/dequeue', agent, { id: 'q-1' } as never, 'queued')
  291. expect(expectValue(await api.sessions.selectModel(request({
  292. sessionId, provider: 'text-only', model: 'plain',
  293. }))).selected).toEqual({ provider: 'text-only', model: 'plain' })
  294. await ctx.fiber.dispose()
  295. })
  296. it('authorizes an attachment read referenced only from wrapped message content', async () => {
  297. const { ctx, sessionId, agent } = await harness()
  298. const ref = { attachmentId: 'att-w', mediaType: 'image/png' as const, bytes: 4, width: 1, height: 1 }
  299. ctx.provide('attachments', {
  300. readImage: () => Promise.resolve({ ref, data: new Uint8Array([1, 2, 3, 4]) }),
  301. } as never)
  302. const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
  303. // The only reference lives inside an assistant/message wrapper — the same
  304. // walk that gates model selection must authorize the read, or a real host
  305. // denies galleries the fixture (with its own authorization mirror) serves.
  306. agent.session.append('assistant/message', {
  307. turn: 1, step: 0,
  308. message: { id: 'a-1', role: 'assistant', source: { kind: 'model', provider: 'p', model: 'm' }, content: [{ type: 'image', attachment: ref }] },
  309. } as never, { surfaceOp: 'append' })
  310. const got = await api.sessions.attachment(request({ sessionId, attachmentId: 'att-w' as never }))
  311. expect(got.result).toMatchObject({ ok: true, value: { attachment: ref } })
  312. const denied = await api.sessions.attachment(request({ sessionId, attachmentId: 'att-other' as never }))
  313. expect(denied.result).toMatchObject({ ok: false, error: { details: { reason: 'ATTACHMENT_NOT_REFERENCED' } } })
  314. await ctx.fiber.dispose()
  315. })
  316. it('detects images on every replayed route: wrapped messages, streamed blocks, nested tool results', async () => {
  317. const image = { type: 'image', attachment: { attachmentId: 'att-x', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } }
  318. const cases: { label: string; append: (agent: Agent) => void }[] = [
  319. {
  320. label: 'steering message wrapper',
  321. append: (agent) => {
  322. agent.session.append('steering/message', {
  323. turn: 1, message: { id: 'st-1', role: 'user', source: { kind: 'user' }, content: [image] },
  324. } as never, { surfaceOp: 'append' })
  325. },
  326. },
  327. {
  328. label: 'streamed assistant block',
  329. append: (agent) => {
  330. agent.session.append('assistant/chunk', {
  331. turn: 1, step: 0, chunk: { type: 'block-end', index: 0, block: image },
  332. } as never)
  333. },
  334. },
  335. {
  336. label: 'nested tool-result content',
  337. append: (agent) => {
  338. agent.session.append('user/message', {
  339. id: 'tr-1', role: 'user', source: { kind: 'tool', callId: 'c1' },
  340. content: [{ type: 'tool-result', toolCallId: 'c1', content: [image], isError: false }],
  341. } as never, { surfaceOp: 'append' })
  342. },
  343. },
  344. ]
  345. for (const { label, append } of cases) {
  346. const { ctx, sessionId, agent } = await harness()
  347. ctx.llm.registerAdapter(['text-only'], new class extends CatalogAdapter {
  348. override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
  349. return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] })
  350. }
  351. }('Text Only', []))
  352. const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
  353. append(agent)
  354. const stranded = await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))
  355. expect(stranded.result.ok, label).toBe(false)
  356. await ctx.fiber.dispose()
  357. }
  358. })
  359. })