list-models.spec.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. import { describe, expect, it, vi } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import LlmRuntime, {
  4. ToolCallId,
  5. LlmAdapter,
  6. ReasoningEffortId,
  7. } from '@deepseek-ai/dsh-llm'
  8. import type {
  9. GenerateOptions,
  10. LlmModelInfo,
  11. LlmResolvedModelInfo,
  12. StreamChunk,
  13. } from '@deepseek-ai/dsh-llm'
  14. import ToolRuntime from '@deepseek-ai/dsh-tools'
  15. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  16. import SubagentRuntime from '@deepseek-ai/dsh-subagent'
  17. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  18. import * as tool from '../src/index.ts'
  19. import { registerListSubagentModels } from '../src/list-models.ts'
  20. import { testToolSignal, text } from './harness.ts'
  21. class CatalogAdapter extends LlmAdapter {
  22. constructor(private readonly empty = false) {
  23. super()
  24. }
  25. override providerInfo(provider: string) {
  26. return { id: provider, name: `${provider.toUpperCase()} API` }
  27. }
  28. override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
  29. if (this.empty) return Promise.resolve([])
  30. return Promise.resolve([
  31. { provider, id: 'fast', name: 'Fast', description: 'Focused work.' },
  32. { provider, id: 'plain', name: 'Plain' },
  33. ])
  34. }
  35. override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
  36. if (model === 'plain') return Promise.resolve({ provider, id: model, name: 'Plain' })
  37. return Promise.resolve({
  38. provider,
  39. id: model,
  40. name: 'Fast',
  41. description: 'Focused work.',
  42. reasoning: {
  43. efforts: [
  44. { id: ReasoningEffortId('low'), name: 'Low' },
  45. { id: ReasoningEffortId('high'), name: 'High', description: 'Quality first.' },
  46. ],
  47. defaultEffort: ReasoningEffortId('high'),
  48. },
  49. })
  50. }
  51. stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
  52. return (async function* () { yield { type: 'finish' as const, reason: { kind: 'stop' as const } } })()
  53. }
  54. }
  55. async function setupListTool(routes = [
  56. { provider: 'alpha', model: 'fast' },
  57. { provider: 'alpha', model: 'plain' },
  58. { provider: 'beta', model: 'fast' },
  59. { provider: 'beta', model: 'plain' },
  60. ]) {
  61. const ctx = new Context()
  62. await ctx.plugin(LlmRuntime)
  63. await ctx.plugin(SystemPrompt)
  64. await ctx.plugin(ToolRuntime)
  65. registerListSubagentModels(ctx, { routes })
  66. return ctx
  67. }
  68. async function setupAllowedListTool() {
  69. const ctx = new Context()
  70. await ctx.plugin(LlmRuntime)
  71. await ctx.plugin(SystemPrompt)
  72. await ctx.plugin(ToolRuntime)
  73. registerListSubagentModels(ctx, {
  74. routes: [
  75. { provider: 'alpha', model: 'fast' },
  76. { provider: 'alpha', model: 'unlisted' },
  77. { provider: 'missing', model: 'hidden' },
  78. ],
  79. })
  80. return ctx
  81. }
  82. let counter = 0
  83. function call(ctx: Context, args: unknown) {
  84. return ctx.tools.execute({
  85. signal: testToolSignal,
  86. callId: ToolCallId(`list-models-${++counter}`),
  87. name: 'list_subagent_models',
  88. arguments: args,
  89. })
  90. }
  91. describe('list_subagent_models', () => {
  92. it('is omitted unless its delegation-tool instance owns discovery', async () => {
  93. const ctx = new Context()
  94. await ctx.plugin(SessionProjectionRegistry)
  95. await ctx.plugin(LlmRuntime)
  96. await ctx.plugin(SystemPrompt)
  97. await ctx.plugin(ToolRuntime)
  98. await ctx.plugin(SubagentRuntime)
  99. await ctx.plugin(tool, { provider: 'unused' })
  100. expect(ctx.tools.get('list_subagent_models')).toBeUndefined()
  101. })
  102. it('stays registered without the optional LLM service and rejects discovery calls', async () => {
  103. const ctx = new Context()
  104. await ctx.plugin(SystemPrompt)
  105. await ctx.plugin(ToolRuntime)
  106. await ctx.plugin(SubagentRuntime)
  107. registerListSubagentModels(ctx, { routes: [{ provider: 'alpha', model: 'fast' }] })
  108. const result = await call(ctx, {})
  109. expect(result.isError).toBe(true)
  110. expect(text(result)).toContain('`llm` service is unavailable')
  111. })
  112. it('rejects two discovery-owning instances in one tool scope', async () => {
  113. const ctx = await setupListTool()
  114. expect(() => {
  115. registerListSubagentModels(ctx, { routes: [{ provider: 'alpha', model: 'fast' }] })
  116. }).toThrow('tool "list_subagent_models" is already registered')
  117. })
  118. it('lists registered providers and follows live registration changes', async () => {
  119. const ctx = await setupListTool()
  120. const empty = await call(ctx, {})
  121. expect(empty.isError).toBe(false)
  122. expect(text(empty)).toBe('(no LLM providers)')
  123. const registration = ctx.llm.registerAdapter(['alpha'], new CatalogAdapter())
  124. const providers = await call(ctx, {})
  125. expect(providers.isError).toBe(false)
  126. expect(text(providers)).toBe('alpha — ALPHA API')
  127. registration.replace(['beta'])
  128. const changed = await call(ctx, {})
  129. expect(text(changed)).toBe('beta — BETA API')
  130. const tools = ctx.tools
  131. await ctx.fiber.dispose()
  132. expect(tools.get('list_subagent_models')).toBeUndefined()
  133. })
  134. it('lists one provider\'s advertised models without treating the catalog as a whitelist', async () => {
  135. const ctx = await setupListTool()
  136. ctx.llm.registerAdapter(['alpha'], new CatalogAdapter())
  137. const result = await call(ctx, { provider: 'alpha' })
  138. expect(result.isError).toBe(false)
  139. expect(text(result)).toBe('alpha/fast — Fast: Focused work.\nalpha/plain — Plain')
  140. })
  141. it('intersects provider and model discovery with the Session allowlist', async () => {
  142. const ctx = await setupAllowedListTool()
  143. ctx.llm.registerAdapter(['alpha', 'beta'], new CatalogAdapter())
  144. expect(text(await call(ctx, {}))).toBe('alpha — ALPHA API')
  145. expect(text(await call(ctx, { provider: 'alpha' }))).toBe('alpha/fast — Fast: Focused work.')
  146. expect(text(await call(ctx, { provider: 'alpha', model: 'unlisted' })))
  147. .toContain('alpha/unlisted — Fast')
  148. const denied = await call(ctx, { provider: 'alpha', model: 'plain' })
  149. expect(denied.isError).toBe(true)
  150. expect(text(denied)).toContain('is not allowed for this Session')
  151. })
  152. it('rejects an unauthorized provider before calling its adapter catalog', async () => {
  153. const ctx = await setupListTool([{ provider: 'alpha', model: 'fast' }])
  154. const adapter = new CatalogAdapter()
  155. const listModels = vi.spyOn(adapter, 'listModels')
  156. ctx.llm.registerAdapter(['alpha', 'secret'], adapter)
  157. const result = await call(ctx, { provider: 'secret' })
  158. expect(result.isError).toBe(true)
  159. expect(text(result)).toContain('provider "secret" is not allowed for this Session')
  160. expect(listModels).not.toHaveBeenCalled()
  161. })
  162. it('renders an empty advertised model list', async () => {
  163. const ctx = await setupListTool([{ provider: 'alpha', model: 'fast' }])
  164. ctx.llm.registerAdapter(['alpha'], new CatalogAdapter(true))
  165. const result = await call(ctx, { provider: 'alpha' })
  166. expect(result.isError).toBe(false)
  167. expect(text(result)).toBe('(no advertised models for alpha)')
  168. })
  169. it('inspects exact-model efforts, descriptions, and defaults', async () => {
  170. const ctx = await setupListTool()
  171. ctx.llm.registerAdapter(['alpha', 'secret'], new CatalogAdapter())
  172. const result = await call(ctx, { provider: 'alpha', model: 'fast' })
  173. expect(result.isError).toBe(false)
  174. expect(text(result)).toBe(
  175. 'alpha/fast — Fast: Focused work.\nReasoning efforts:\n'
  176. + 'low — Low\nhigh (default) — High: Quality first.',
  177. )
  178. })
  179. it('renders exact models without reasoning metadata', async () => {
  180. const ctx = await setupListTool()
  181. ctx.llm.registerAdapter(['alpha'], new CatalogAdapter())
  182. const result = await call(ctx, { provider: 'alpha', model: 'plain' })
  183. expect(result.isError).toBe(false)
  184. expect(text(result)).toBe('alpha/plain — Plain\nReasoning efforts:\n(no advertised reasoning efforts)')
  185. })
  186. it.each([
  187. { args: { model: 'fast' }, expected: '`model` requires `provider`' },
  188. { args: { provider: '' }, expected: '`provider` must be non-empty' },
  189. { args: { provider: 'missing' }, expected: 'is not allowed for this Session' },
  190. ])('rejects incomplete or unavailable provider requests', async ({ args, expected }) => {
  191. const ctx = await setupListTool()
  192. const result = await call(ctx, args)
  193. expect(result.isError).toBe(true)
  194. expect(text(result)).toContain(expected)
  195. })
  196. it('rejects an empty exact model after resolving the provider', async () => {
  197. const ctx = await setupListTool()
  198. ctx.llm.registerAdapter(['alpha'], new CatalogAdapter())
  199. const result = await call(ctx, { provider: 'alpha', model: '' })
  200. expect(result.isError).toBe(true)
  201. expect(text(result)).toContain('`model` must be non-empty')
  202. })
  203. it('reports registered alternatives for an unavailable provider', async () => {
  204. const ctx = await setupListTool([
  205. { provider: 'alpha', model: 'fast' },
  206. { provider: 'missing', model: 'fast' },
  207. ])
  208. ctx.llm.registerAdapter(['alpha'], new CatalogAdapter())
  209. const result = await call(ctx, { provider: 'missing' })
  210. expect(result.isError).toBe(true)
  211. expect(text(result)).toContain('available providers: alpha')
  212. expect(text(result)).not.toContain('secret')
  213. })
  214. it('reports no available provider when the authorized registry intersection is empty', async () => {
  215. const ctx = await setupListTool([{ provider: 'missing', model: 'fast' }])
  216. const result = await call(ctx, { provider: 'missing' })
  217. expect(result.isError).toBe(true)
  218. expect(text(result)).toContain('available providers: (none)')
  219. })
  220. })