|
|
@@ -1,6 +1,11 @@
|
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
+import { mkdtemp, rm } from 'node:fs/promises'
|
|
|
+import { tmpdir } from 'node:os'
|
|
|
+import { join } from 'node:path'
|
|
|
import { Context } from 'cordis'
|
|
|
import Loader from '@cordisjs/plugin-loader'
|
|
|
+import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
|
+import CredentialsLocal from '@deepseek-ai/dsh-credentials-local'
|
|
|
import WebService from '@deepseek-ai/dsh-web'
|
|
|
import {
|
|
|
DeepSeekSearchProvider,
|
|
|
@@ -156,10 +161,11 @@ describe('DeepSeekSearchProvider availability', () => {
|
|
|
})
|
|
|
|
|
|
describe('DeepSeekSearchProvider request mapping', () => {
|
|
|
- it('posts an Anthropic Messages request enabling the web_search server tool', async () => {
|
|
|
+ it('records and posts the same Anthropic Messages request with the web_search server tool', async () => {
|
|
|
const fetchMock = vi.fn(async () => jsonResponse(searchResponse()))
|
|
|
+ const recordRequest = vi.fn()
|
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
- await new DeepSeekSearchProvider(options).search({ query: 'hello' })
|
|
|
+ await new DeepSeekSearchProvider({ ...options, recordRequest }).search({ query: 'hello' })
|
|
|
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
|
|
|
expect(url).toBe('https://api.deepseek.test/anthropic/v1/messages')
|
|
|
expect(init).toMatchObject({ method: 'POST', redirect: 'error' })
|
|
|
@@ -167,12 +173,20 @@ describe('DeepSeekSearchProvider request mapping', () => {
|
|
|
expect(headers['x-api-key']).toBe('ds-key')
|
|
|
expect(headers['authorization']).toBe('Bearer ds-key')
|
|
|
expect(headers['anthropic-version']).toBe('2023-06-01')
|
|
|
- expect(JSON.parse(init.body as string)).toEqual({
|
|
|
+ const body = {
|
|
|
model: 'deepseek-chat',
|
|
|
max_tokens: 4096,
|
|
|
messages: [{ role: 'user', content: [{ type: 'text', text: 'Perform a web search for the query: hello' }] }],
|
|
|
tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: 5 }],
|
|
|
+ }
|
|
|
+ expect(JSON.parse(init.body as string)).toEqual(body)
|
|
|
+ expect(recordRequest).toHaveBeenCalledOnce()
|
|
|
+ expect(recordRequest).toHaveBeenCalledWith({
|
|
|
+ endpoint: url,
|
|
|
+ apiVersion: '2023-06-01',
|
|
|
+ body,
|
|
|
})
|
|
|
+ expect(recordRequest.mock.invocationCallOrder[0]).toBeLessThan(fetchMock.mock.invocationCallOrder[0] ?? 0)
|
|
|
})
|
|
|
|
|
|
it('forwards the abort signal', async () => {
|
|
|
@@ -186,6 +200,91 @@ describe('DeepSeekSearchProvider request mapping', () => {
|
|
|
})
|
|
|
|
|
|
describe('DeepSeekSearchProvider error handling', () => {
|
|
|
+ it('does not start credential resolution or dispatch for a pre-aborted call', async () => {
|
|
|
+ const resolveApiKey = vi.fn(async () => 'late-key')
|
|
|
+ const recordRequest = vi.fn()
|
|
|
+ const fetchMock = vi.fn()
|
|
|
+ vi.stubGlobal('fetch', fetchMock)
|
|
|
+ const controller = new AbortController()
|
|
|
+ controller.abort(new Error('caller stopped'))
|
|
|
+ await expect(new DeepSeekSearchProvider({
|
|
|
+ ...options,
|
|
|
+ apiKey: '',
|
|
|
+ resolveApiKey,
|
|
|
+ recordRequest,
|
|
|
+ }).search({ query: 'q' }, controller.signal))
|
|
|
+ .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
|
|
|
+ expect(resolveApiKey).not.toHaveBeenCalled()
|
|
|
+ expect(recordRequest).not.toHaveBeenCalled()
|
|
|
+ expect(fetchMock).not.toHaveBeenCalled()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('aborts while an uncooperative credential resolver remains pending', async () => {
|
|
|
+ const resolveApiKey = vi.fn(() => new Promise<string>(() => {}))
|
|
|
+ const recordRequest = vi.fn()
|
|
|
+ const fetchMock = vi.fn()
|
|
|
+ vi.stubGlobal('fetch', fetchMock)
|
|
|
+ const controller = new AbortController()
|
|
|
+ const search = new DeepSeekSearchProvider({
|
|
|
+ ...options,
|
|
|
+ apiKey: '',
|
|
|
+ resolveApiKey,
|
|
|
+ recordRequest,
|
|
|
+ }).search({ query: 'q' }, controller.signal)
|
|
|
+ controller.abort(new Error('deadline'))
|
|
|
+ await expect(search).rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
|
|
|
+ expect(resolveApiKey).toHaveBeenCalledOnce()
|
|
|
+ expect(recordRequest).not.toHaveBeenCalled()
|
|
|
+ expect(fetchMock).not.toHaveBeenCalled()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('resolves credentials under an active cancellation signal', async () => {
|
|
|
+ const fetchMock = vi.fn(async () => jsonResponse(searchResponse()))
|
|
|
+ vi.stubGlobal('fetch', fetchMock)
|
|
|
+ const controller = new AbortController()
|
|
|
+ await expect(new DeepSeekSearchProvider({
|
|
|
+ ...options,
|
|
|
+ apiKey: '',
|
|
|
+ resolveApiKey: async () => 'resolved-key',
|
|
|
+ }).search({ query: 'q' }, controller.signal)).resolves.toMatchObject({ truncated: false })
|
|
|
+ const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
|
|
|
+ expect((init.headers as Record<string, string>)['x-api-key']).toBe('resolved-key')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('maps a credential resolver rejection under an active signal to WEB_PROVIDER_ERROR', async () => {
|
|
|
+ const controller = new AbortController()
|
|
|
+ await expect(new DeepSeekSearchProvider({
|
|
|
+ ...options,
|
|
|
+ apiKey: '',
|
|
|
+ resolveApiKey: () => Promise.reject(new Error('credential backend failed')),
|
|
|
+ }).search({ query: 'q' }, controller.signal))
|
|
|
+ .rejects.toThrow(expect.objectContaining({
|
|
|
+ code: 'WEB_PROVIDER_ERROR',
|
|
|
+ message: 'DeepSeek search credential resolution failed: Error: credential backend failed',
|
|
|
+ }))
|
|
|
+ })
|
|
|
+
|
|
|
+ it('uses the default credential reference when no resolver is configured', async () => {
|
|
|
+ await expect(new DeepSeekSearchProvider({ ...options, apiKey: '' }).search({ query: 'q' }))
|
|
|
+ .rejects.toThrow('DeepSeek search has no API key for "DEEPSEEK_API_KEY"')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('observes cancellation triggered synchronously by credential resolution', async () => {
|
|
|
+ const controller = new AbortController()
|
|
|
+ const fetchMock = vi.fn()
|
|
|
+ vi.stubGlobal('fetch', fetchMock)
|
|
|
+ await expect(new DeepSeekSearchProvider({
|
|
|
+ ...options,
|
|
|
+ apiKey: '',
|
|
|
+ resolveApiKey: () => {
|
|
|
+ controller.abort(new Error('resolver cancelled caller'))
|
|
|
+ return Promise.resolve('unused-key')
|
|
|
+ },
|
|
|
+ }).search({ query: 'q' }, controller.signal))
|
|
|
+ .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
|
|
|
+ expect(fetchMock).not.toHaveBeenCalled()
|
|
|
+ })
|
|
|
+
|
|
|
it('maps an HTTP error to WEB_PROVIDER_ERROR with the provider message', async () => {
|
|
|
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: { message: 'rate limited' } }, { status: 429 })))
|
|
|
await expect(new DeepSeekSearchProvider(options).search({ query: 'q' }))
|
|
|
@@ -216,6 +315,17 @@ describe('DeepSeekSearchProvider error handling', () => {
|
|
|
.rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
|
|
|
})
|
|
|
|
|
|
+ it('maps a custom abort reason to WEB_ABORTED', async () => {
|
|
|
+ const controller = new AbortController()
|
|
|
+ vi.stubGlobal('fetch', vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) =>
|
|
|
+ await new Promise<Response>((_resolve, reject) => {
|
|
|
+ init?.signal?.addEventListener('abort', () => { reject(new Error('custom abort reason')) }, { once: true })
|
|
|
+ })))
|
|
|
+ const search = new DeepSeekSearchProvider(options).search({ query: 'q' }, controller.signal)
|
|
|
+ controller.abort(new Error('timeout reason'))
|
|
|
+ await expect(search).rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
|
|
|
+ })
|
|
|
+
|
|
|
it('maps an unparseable success body to WEB_PROVIDER_ERROR', async () => {
|
|
|
vi.stubGlobal('fetch', vi.fn(async () => new Response('not json', { status: 200 })))
|
|
|
await expect(new DeepSeekSearchProvider(options).search({ query: 'q' }))
|
|
|
@@ -323,28 +433,66 @@ describe('web-search-deepseek plugin registration', () => {
|
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
const ctx = new Context()
|
|
|
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
|
|
|
- const fiber = await ctx.plugin(deepseekPlugin, {})
|
|
|
+ deepseekPlugin.apply(ctx, {})
|
|
|
await ctx.web.search({ query: 'q' })
|
|
|
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
|
|
|
expect(url).toBe('https://api.deepseek.com/anthropic/v1/messages')
|
|
|
expect((init.headers as Record<string, string>)['x-api-key']).toBe('env-key')
|
|
|
expect(JSON.parse(init.body as string)).toMatchObject({ model: 'deepseek-v4-flash' })
|
|
|
- await fiber.dispose()
|
|
|
+ await ctx.fiber.dispose()
|
|
|
} finally {
|
|
|
if (prev === undefined) delete process.env.DEEPSEEK_API_KEY
|
|
|
else process.env.DEEPSEEK_API_KEY = prev
|
|
|
}
|
|
|
})
|
|
|
|
|
|
- it('is unavailable when neither config nor env supplies a key', async () => {
|
|
|
+ it('resolves the credential for each search so a stored or rotated key needs no restart', async () => {
|
|
|
+ const previous = process.env.DEEPSEEK_API_KEY
|
|
|
+ delete process.env.DEEPSEEK_API_KEY
|
|
|
+ const dir = await mkdtemp(join(tmpdir(), 'dsh-web-search-credentials-'))
|
|
|
+ const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => jsonResponse(searchResponse()))
|
|
|
+ vi.stubGlobal('fetch', fetchMock)
|
|
|
+ const ctx = new Context()
|
|
|
+ try {
|
|
|
+ await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
|
|
|
+ await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false })
|
|
|
+ await ctx.plugin(deepseekPlugin, { baseURL: 'https://api.deepseek.test/anthropic/v1' })
|
|
|
+
|
|
|
+ await expect(ctx.web.search({ query: 'missing' }))
|
|
|
+ .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CREDENTIAL_MISSING' }))
|
|
|
+
|
|
|
+ const ref = credentialRef('DEEPSEEK_API_KEY')
|
|
|
+ await ctx.credentials.set(ref, 'stored-key')
|
|
|
+ await ctx.web.search({ query: 'stored' })
|
|
|
+ await ctx.credentials.set(ref, 'rotated-key')
|
|
|
+ await ctx.web.search({ query: 'rotated' })
|
|
|
+
|
|
|
+ const headers = fetchMock.mock.calls.map(([, init]) => (init as RequestInit).headers as Record<string, string>)
|
|
|
+ expect(headers.map(value => value['x-api-key'])).toEqual(['stored-key', 'rotated-key'])
|
|
|
+ } finally {
|
|
|
+ await ctx.fiber.dispose()
|
|
|
+ await rm(dir, { recursive: true, force: true })
|
|
|
+ if (previous === undefined) delete process.env.DEEPSEEK_API_KEY
|
|
|
+ else process.env.DEEPSEEK_API_KEY = previous
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ it('reports an actionable credential error when neither config nor env supplies a key', async () => {
|
|
|
const prev = process.env.DEEPSEEK_API_KEY
|
|
|
delete process.env.DEEPSEEK_API_KEY
|
|
|
try {
|
|
|
const ctx = new Context()
|
|
|
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
|
|
|
await ctx.plugin(deepseekPlugin, {})
|
|
|
- await expect(ctx.web.search({ query: 'q' }))
|
|
|
- .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' }))
|
|
|
+ let caught: unknown
|
|
|
+ try {
|
|
|
+ await ctx.web.search({ query: 'q' })
|
|
|
+ } catch (error: unknown) {
|
|
|
+ caught = error
|
|
|
+ }
|
|
|
+ expect(caught).toMatchObject({ code: 'WEB_PROVIDER_CREDENTIAL_MISSING' })
|
|
|
+ if (!(caught instanceof Error)) throw new Error('search did not throw an Error')
|
|
|
+ expect(caught.message).toMatch(/store it through the credentials service.*Models page/s)
|
|
|
} finally {
|
|
|
if (prev !== undefined) process.env.DEEPSEEK_API_KEY = prev
|
|
|
}
|