list-models.spec.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import LlmRuntime, {
  4. CallId,
  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 * as tool from '../src/index.ts'
  18. import { testToolSignal, text } from './harness.ts'
  19. class CatalogAdapter extends LlmAdapter {
  20. constructor(private readonly empty = false) {
  21. super()
  22. }
  23. override providerInfo(provider: string) {
  24. return { id: provider, name: `${provider.toUpperCase()} API` }
  25. }
  26. override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
  27. if (this.empty) return Promise.resolve([])
  28. return Promise.resolve([
  29. { provider, id: 'fast', name: 'Fast', description: 'Focused work.' },
  30. { provider, id: 'plain', name: 'Plain' },
  31. ])
  32. }
  33. override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
  34. if (model === 'plain') return Promise.resolve({ provider, id: model, name: 'Plain' })
  35. return Promise.resolve({
  36. provider,
  37. id: model,
  38. name: 'Fast',
  39. description: 'Focused work.',
  40. reasoning: {
  41. efforts: [
  42. { id: ReasoningEffortId('low'), name: 'Low' },
  43. { id: ReasoningEffortId('high'), name: 'High', description: 'Quality first.' },
  44. ],
  45. defaultEffort: ReasoningEffortId('high'),
  46. },
  47. })
  48. }
  49. stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
  50. return (async function* () { yield { type: 'finish' as const, reason: { kind: 'stop' as const } } })()
  51. }
  52. }
  53. async function setupListTool() {
  54. const ctx = new Context()
  55. await ctx.plugin(LlmRuntime)
  56. await ctx.plugin(SystemPrompt)
  57. await ctx.plugin(ToolRuntime)
  58. await ctx.plugin(SubagentRuntime)
  59. const fiber = await ctx.plugin(tool, { provider: 'unused', enableModelSelection: true })
  60. return { ctx, fiber }
  61. }
  62. let counter = 0
  63. function call(ctx: Context, args: unknown) {
  64. return ctx.tools.execute({
  65. signal: testToolSignal,
  66. callId: CallId(`list-models-${++counter}`),
  67. name: 'list_subagent_models',
  68. arguments: args,
  69. })
  70. }
  71. describe('list_subagent_models', () => {
  72. it('is omitted unless its delegation-tool instance owns discovery', async () => {
  73. const ctx = new Context()
  74. await ctx.plugin(LlmRuntime)
  75. await ctx.plugin(SystemPrompt)
  76. await ctx.plugin(ToolRuntime)
  77. await ctx.plugin(SubagentRuntime)
  78. await ctx.plugin(tool, { provider: 'unused' })
  79. expect(ctx.tools.get('list_subagent_models')).toBeUndefined()
  80. })
  81. it('stays registered without the optional LLM service and rejects discovery calls', async () => {
  82. const ctx = new Context()
  83. await ctx.plugin(SystemPrompt)
  84. await ctx.plugin(ToolRuntime)
  85. await ctx.plugin(SubagentRuntime)
  86. await ctx.plugin(tool, { provider: 'unused', enableModelSelection: true })
  87. const result = await call(ctx, {})
  88. expect(result.isError).toBe(true)
  89. expect(text(result)).toContain('`llm` service is unavailable')
  90. })
  91. it('rejects two discovery-owning instances in one tool scope', async () => {
  92. const { ctx } = await setupListTool()
  93. await expect(ctx.plugin(tool, {
  94. provider: 'another-unused',
  95. toolName: 'subagent_other',
  96. enableModelSelection: true,
  97. }).then(() => undefined)).rejects.toThrow('tool "list_subagent_models" is already registered')
  98. })
  99. it('lists registered providers and follows live registration changes', async () => {
  100. const { ctx, fiber } = await setupListTool()
  101. const empty = await call(ctx, {})
  102. expect(empty.isError).toBe(false)
  103. expect(text(empty)).toBe('(no LLM providers)')
  104. const registration = ctx.llm.registerAdapter(['alpha'], new CatalogAdapter())
  105. const providers = await call(ctx, {})
  106. expect(providers.isError).toBe(false)
  107. expect(text(providers)).toBe('alpha — ALPHA API')
  108. registration.replace(['beta'])
  109. const changed = await call(ctx, {})
  110. expect(text(changed)).toBe('beta — BETA API')
  111. await fiber.dispose()
  112. expect(ctx.tools.get('list_subagent_models')).toBeUndefined()
  113. })
  114. it('lists one provider\'s advertised models without treating the catalog as a whitelist', async () => {
  115. const { ctx } = await setupListTool()
  116. ctx.llm.registerAdapter(['alpha'], new CatalogAdapter())
  117. const result = await call(ctx, { provider: 'alpha' })
  118. expect(result.isError).toBe(false)
  119. expect(text(result)).toBe('alpha/fast — Fast: Focused work.\nalpha/plain — Plain')
  120. })
  121. it('renders an empty advertised model list', async () => {
  122. const { ctx } = await setupListTool()
  123. ctx.llm.registerAdapter(['alpha'], new CatalogAdapter(true))
  124. const result = await call(ctx, { provider: 'alpha' })
  125. expect(result.isError).toBe(false)
  126. expect(text(result)).toBe('(no advertised models for alpha)')
  127. })
  128. it('inspects exact-model efforts, descriptions, and defaults', async () => {
  129. const { ctx } = await setupListTool()
  130. ctx.llm.registerAdapter(['alpha'], new CatalogAdapter())
  131. const result = await call(ctx, { provider: 'alpha', model: 'fast' })
  132. expect(result.isError).toBe(false)
  133. expect(text(result)).toBe(
  134. 'alpha/fast — Fast: Focused work.\nReasoning efforts:\n'
  135. + 'low — Low\nhigh (default) — High: Quality first.',
  136. )
  137. })
  138. it('renders exact models without reasoning metadata', async () => {
  139. const { ctx } = await setupListTool()
  140. ctx.llm.registerAdapter(['alpha'], new CatalogAdapter())
  141. const result = await call(ctx, { provider: 'alpha', model: 'plain' })
  142. expect(result.isError).toBe(false)
  143. expect(text(result)).toBe('alpha/plain — Plain\nReasoning efforts:\n(no advertised reasoning efforts)')
  144. })
  145. it.each([
  146. { args: { model: 'fast' }, expected: '`model` requires `provider`' },
  147. { args: { provider: '' }, expected: '`provider` must be non-empty' },
  148. { args: { provider: 'missing' }, expected: 'available providers: (none)' },
  149. ])('rejects incomplete or unavailable provider requests', async ({ args, expected }) => {
  150. const { ctx } = await setupListTool()
  151. const result = await call(ctx, args)
  152. expect(result.isError).toBe(true)
  153. expect(text(result)).toContain(expected)
  154. })
  155. it('rejects an empty exact model after resolving the provider', async () => {
  156. const { ctx } = await setupListTool()
  157. ctx.llm.registerAdapter(['alpha'], new CatalogAdapter())
  158. const result = await call(ctx, { provider: 'alpha', model: '' })
  159. expect(result.isError).toBe(true)
  160. expect(text(result)).toContain('`model` must be non-empty')
  161. })
  162. it('reports registered alternatives for an unavailable provider', async () => {
  163. const { ctx } = await setupListTool()
  164. ctx.llm.registerAdapter(['alpha'], new CatalogAdapter())
  165. const result = await call(ctx, { provider: 'missing' })
  166. expect(result.isError).toBe(true)
  167. expect(text(result)).toContain('available providers: alpha')
  168. })
  169. })