|
|
|
|
@@ -2,15 +2,25 @@ import { createServer } from 'node:http'
|
|
|
|
|
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
|
|
|
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
|
import { Context } from 'cordis'
|
|
|
|
|
import LlmService, { LlmError, userAgent } from '@deepseek-ai/dsh-llm'
|
|
|
|
|
import LlmService, {
|
|
|
|
|
CONTEXT_WINDOW_EXCEEDED_CODE,
|
|
|
|
|
errorChain,
|
|
|
|
|
LlmError,
|
|
|
|
|
ProviderRequestId,
|
|
|
|
|
QUOTA_EXCEEDED_CODE,
|
|
|
|
|
userAgent,
|
|
|
|
|
} from '@deepseek-ai/dsh-llm'
|
|
|
|
|
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
|
|
|
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
|
|
|
|
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
|
|
|
|
import { DeepSeekAdapter, httpErrorCode } from '@deepseek-ai/dsh-llm-deepseek'
|
|
|
|
|
import { DeepSeekAdapter } from '@deepseek-ai/dsh-llm-deepseek'
|
|
|
|
|
import { httpErrorCode } from '../src/adapter.ts'
|
|
|
|
|
import { assemble } from './assemble.ts'
|
|
|
|
|
|
|
|
|
|
/** One scripted behavior for the next request the mock server receives. */
|
|
|
|
|
type Behavior =
|
|
|
|
|
| { kind: 'sse'; events: string[]; delayMs?: number }
|
|
|
|
|
| { kind: 'http-error'; status: number; body: string; contentType?: string }
|
|
|
|
|
| { kind: 'http-error'; status: number; body: string; contentType?: string; headers?: Record<string, string> }
|
|
|
|
|
| { kind: 'close-early'; events: string[] }
|
|
|
|
|
|
|
|
|
|
interface MockServer {
|
|
|
|
|
@@ -28,6 +38,7 @@ const servers: Server[] = []
|
|
|
|
|
afterEach(async () => {
|
|
|
|
|
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
|
|
|
|
|
vi.unstubAllEnvs()
|
|
|
|
|
vi.useRealTimers()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
/** Local chat-completions stand-in: replays scripted behaviors per request. */
|
|
|
|
|
@@ -46,7 +57,10 @@ async function mockServer(script: Behavior[]): Promise<MockServer> {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (behavior.kind === 'http-error') {
|
|
|
|
|
response.writeHead(behavior.status, { 'content-type': behavior.contentType ?? 'application/json' })
|
|
|
|
|
response.writeHead(behavior.status, {
|
|
|
|
|
'content-type': behavior.contentType ?? 'application/json',
|
|
|
|
|
...behavior.headers,
|
|
|
|
|
})
|
|
|
|
|
response.end(behavior.body)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
@@ -86,7 +100,7 @@ const textEvents = [
|
|
|
|
|
async function harness(baseURL: string, config: object = {}) {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(LlmService)
|
|
|
|
|
await ctx.plugin(LlmDeepSeek, { apiKey: 'test-key', baseURL, models: ['deepseek-v4-flash'], ...config })
|
|
|
|
|
await ctx.plugin(LlmDeepSeek, { apiKey: 'test-key', baseURL, ...config })
|
|
|
|
|
return ctx
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -123,6 +137,7 @@ describe('DeepSeekAdapter against a mock server', () => {
|
|
|
|
|
|
|
|
|
|
const kinds: string[] = []
|
|
|
|
|
for await (const chunk of ctx.llm.stream({
|
|
|
|
|
provider: 'deepseek',
|
|
|
|
|
model: 'deepseek-v4-flash',
|
|
|
|
|
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
|
|
|
|
|
})) {
|
|
|
|
|
@@ -131,6 +146,19 @@ describe('DeepSeekAdapter against a mock server', () => {
|
|
|
|
|
expect(kinds).toEqual(['block-start', 'text-delta', 'block-end', 'usage', 'finish'])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('forwards the harness session id for host-side trajectory routing', async () => {
|
|
|
|
|
const server = await mockServer([{ kind: 'sse', events: textEvents }])
|
|
|
|
|
const ctx = await harness(server.url)
|
|
|
|
|
|
|
|
|
|
await assemble(ctx, {
|
|
|
|
|
model: 'deepseek-v4-flash',
|
|
|
|
|
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
|
|
|
|
|
sessionId: SessionId('child-session'),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(server.headers[0]?.['x-deepseek-harness-session-id']).toBe('child-session')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('forwards thinking config onto the wire', async () => {
|
|
|
|
|
const server = await mockServer([{ kind: 'sse', events: textEvents }])
|
|
|
|
|
const ctx = await harness(server.url, { thinking: 'disabled', reasoningEffort: 'high' })
|
|
|
|
|
@@ -158,7 +186,7 @@ describe('DeepSeekAdapter against a mock server', () => {
|
|
|
|
|
status,
|
|
|
|
|
body: JSON.stringify({ error: { message: `failed with ${status}`, type: 't', code: 'c' } }),
|
|
|
|
|
}
|
|
|
|
|
const server = await mockServer([behavior, behavior, behavior])
|
|
|
|
|
const server = await mockServer([behavior, behavior])
|
|
|
|
|
const ctx = await harness(server.url)
|
|
|
|
|
await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
|
|
|
|
|
.rejects.toThrow(`failed with ${status}`)
|
|
|
|
|
@@ -166,11 +194,116 @@ describe('DeepSeekAdapter against a mock server', () => {
|
|
|
|
|
assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
|
|
|
|
|
.catch((error: unknown) => (error as LlmError).code),
|
|
|
|
|
).resolves.toBe(code)
|
|
|
|
|
// The numeric HTTP status is carried on the error for explicit handling.
|
|
|
|
|
await expect(
|
|
|
|
|
assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
|
|
|
|
|
.catch((error: unknown) => (error as LlmError).status),
|
|
|
|
|
).resolves.toBe(status)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('classifies a thrown HTTP context-window rejection with the canonical code', async () => {
|
|
|
|
|
const server = await mockServer([{
|
|
|
|
|
kind: 'http-error',
|
|
|
|
|
status: 400,
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
error: {
|
|
|
|
|
message: 'This model maximum context length is 128000 tokens; your input exceeds that limit.',
|
|
|
|
|
type: 'invalid_request_error',
|
|
|
|
|
code: 'context_length_exceeded',
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
}])
|
|
|
|
|
const ctx = await harness(server.url)
|
|
|
|
|
const code = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
|
|
|
|
|
.catch((error: unknown) => (error as LlmError).code)
|
|
|
|
|
expect(code).toBe(CONTEXT_WINDOW_EXCEEDED_CODE)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('retains status, Retry-After seconds, and provider request id as structured facts', async () => {
|
|
|
|
|
const server = await mockServer([{
|
|
|
|
|
kind: 'http-error',
|
|
|
|
|
status: 429,
|
|
|
|
|
body: JSON.stringify({ error: { message: 'slow down' } }),
|
|
|
|
|
headers: { 'retry-after': '2', 'x-request-id': 'req-429' },
|
|
|
|
|
}])
|
|
|
|
|
const ctx = await harness(server.url)
|
|
|
|
|
let thrown: unknown
|
|
|
|
|
try {
|
|
|
|
|
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
thrown = error
|
|
|
|
|
}
|
|
|
|
|
expect(thrown).toBeInstanceOf(LlmError)
|
|
|
|
|
expect((thrown as LlmError).failure).toEqual({
|
|
|
|
|
message: 'slow down',
|
|
|
|
|
code: 'RATE_LIMIT',
|
|
|
|
|
status: 429,
|
|
|
|
|
providerRetryAfterMs: 2_000,
|
|
|
|
|
requestId: ProviderRequestId('req-429'),
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('parses a future Retry-After HTTP date and the DeepSeek request-id fallback', async () => {
|
|
|
|
|
const now = 1_800_000_000_000
|
|
|
|
|
const dateNow = vi.spyOn(Date, 'now').mockReturnValue(now)
|
|
|
|
|
try {
|
|
|
|
|
const server = await mockServer([{
|
|
|
|
|
kind: 'http-error',
|
|
|
|
|
status: 503,
|
|
|
|
|
body: JSON.stringify({ error: { message: 'come back later' } }),
|
|
|
|
|
headers: {
|
|
|
|
|
'retry-after': new Date(now + 3_000).toUTCString(),
|
|
|
|
|
'x-deepseek-request-id': 'deepseek-503',
|
|
|
|
|
},
|
|
|
|
|
}])
|
|
|
|
|
const ctx = await harness(server.url)
|
|
|
|
|
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
|
|
|
|
|
.rejects.toMatchObject({
|
|
|
|
|
failure: {
|
|
|
|
|
message: 'come back later',
|
|
|
|
|
code: 'SERVER',
|
|
|
|
|
status: 503,
|
|
|
|
|
providerRetryAfterMs: 3_000,
|
|
|
|
|
requestId: ProviderRequestId('deepseek-503'),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
} finally {
|
|
|
|
|
dateNow.mockRestore()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('omits zero, non-finite, invalid, and past Retry-After values', async () => {
|
|
|
|
|
const values = [
|
|
|
|
|
'0',
|
|
|
|
|
'9'.repeat(400),
|
|
|
|
|
'not-a-date',
|
|
|
|
|
new Date(0).toUTCString(),
|
|
|
|
|
]
|
|
|
|
|
for (const value of values) {
|
|
|
|
|
const server = await mockServer([{
|
|
|
|
|
kind: 'http-error',
|
|
|
|
|
status: 429,
|
|
|
|
|
body: JSON.stringify({ error: { message: 'retry later' } }),
|
|
|
|
|
headers: { 'retry-after': value },
|
|
|
|
|
}])
|
|
|
|
|
const ctx = await harness(server.url)
|
|
|
|
|
let thrown: LlmError | undefined
|
|
|
|
|
try {
|
|
|
|
|
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
if (error instanceof LlmError) thrown = error
|
|
|
|
|
}
|
|
|
|
|
expect(thrown?.failure).toEqual({ message: 'retry later', code: 'RATE_LIMIT', status: 429 })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('classifies only context-capacity HTTP 400 details as context overflow', () => {
|
|
|
|
|
expect(httpErrorCode(400, { message: 'request too large for model context' }))
|
|
|
|
|
.toBe(CONTEXT_WINDOW_EXCEEDED_CODE)
|
|
|
|
|
expect(httpErrorCode(400, { message: 'invalid input: temperature exceeds maximum allowed value' }))
|
|
|
|
|
.toBe('INVALID_REQUEST')
|
|
|
|
|
expect(httpErrorCode(413, { code: 'context_length_exceeded' })).toBe('HTTP_413')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('distinguishes terminal quota exhaustion from transient HTTP 429 throttling', () => {
|
|
|
|
|
expect(httpErrorCode(429, { code: 'insufficient_quota', message: 'account credits exhausted' }))
|
|
|
|
|
.toBe(QUOTA_EXCEEDED_CODE)
|
|
|
|
|
expect(httpErrorCode(429, { message: 'request rate limit exceeded' })).toBe('RATE_LIMIT')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('keeps the status-line message for JSON error bodies without a message', async () => {
|
|
|
|
|
@@ -191,6 +324,40 @@ describe('DeepSeekAdapter against a mock server', () => {
|
|
|
|
|
expect(httpErrorCode(418)).toBe('HTTP_418')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('wraps a transport failure in TRANSPORT with the fetch cause chain in the message', async () => {
|
|
|
|
|
// Port 1 is reserved/unbound: fetch rejects with `TypeError: fetch failed`
|
|
|
|
|
// whose actionable detail (ECONNREFUSED) lives on `cause`.
|
|
|
|
|
const ctx = await harness('http://127.0.0.1:1')
|
|
|
|
|
let caught: unknown
|
|
|
|
|
try {
|
|
|
|
|
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
caught = error
|
|
|
|
|
}
|
|
|
|
|
expect(caught).toBeInstanceOf(LlmError)
|
|
|
|
|
const llmError = caught as LlmError
|
|
|
|
|
expect(llmError.code).toBe('TRANSPORT')
|
|
|
|
|
expect(llmError.message).toContain('http://127.0.0.1:1')
|
|
|
|
|
expect(llmError.cause).toBeInstanceOf(TypeError)
|
|
|
|
|
// The chain renderer reaches the transport diagnosis through the cause.
|
|
|
|
|
expect(errorChain(llmError)).toMatch(/ECONNREFUSED|EADDRNOTAVAIL|bad port/)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('classifies an aborted request without losing the transport rejection', async () => {
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
controller.abort()
|
|
|
|
|
const ctx = await harness('http://127.0.0.1:1')
|
|
|
|
|
let caught: unknown
|
|
|
|
|
try {
|
|
|
|
|
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [], signal: controller.signal })
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
caught = error
|
|
|
|
|
}
|
|
|
|
|
expect(caught).toBeInstanceOf(LlmError)
|
|
|
|
|
expect(caught).toMatchObject({ code: 'ABORTED' })
|
|
|
|
|
expect((caught as LlmError).cause).toMatchObject({ name: 'AbortError' })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('throws EMPTY_RESPONSE when the response has no body', async () => {
|
|
|
|
|
const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
|
|
|
|
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
|
|
|
@@ -198,7 +365,7 @@ describe('DeepSeekAdapter against a mock server', () => {
|
|
|
|
|
)
|
|
|
|
|
try {
|
|
|
|
|
const iterate = async (): Promise<void> => {
|
|
|
|
|
for await (const _chunk of adapter.stream({ model: 'm', messages: [] })) { /* drain */ }
|
|
|
|
|
for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ }
|
|
|
|
|
}
|
|
|
|
|
await expect(iterate()).rejects.toThrow(/no response body/)
|
|
|
|
|
} finally {
|
|
|
|
|
@@ -206,14 +373,20 @@ describe('DeepSeekAdapter against a mock server', () => {
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects with STREAM_CLOSED when the server drops mid-stream', async () => {
|
|
|
|
|
it('classifies an abrupt body close as TRANSPORT and retains its cause', async () => {
|
|
|
|
|
const server = await mockServer([{
|
|
|
|
|
kind: 'close-early',
|
|
|
|
|
events: ['{"choices":[{"delta":{"content":"par"}}]}'],
|
|
|
|
|
}])
|
|
|
|
|
const ctx = await harness(server.url)
|
|
|
|
|
await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
|
|
|
|
|
.rejects.toThrow(/terminated|socket|without \[DONE\]/)
|
|
|
|
|
let caught: unknown
|
|
|
|
|
try {
|
|
|
|
|
await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
caught = error
|
|
|
|
|
}
|
|
|
|
|
expect(caught).toMatchObject({ code: 'TRANSPORT' })
|
|
|
|
|
expect(errorChain(caught)).toMatch(/terminated|socket|without \[DONE\]/)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('aborts mid-stream via the request signal', async () => {
|
|
|
|
|
@@ -224,6 +397,7 @@ describe('DeepSeekAdapter against a mock server', () => {
|
|
|
|
|
const pending = (async () => {
|
|
|
|
|
const chunks = []
|
|
|
|
|
for await (const chunk of ctx.llm.stream({
|
|
|
|
|
provider: 'deepseek',
|
|
|
|
|
model: 'deepseek-v4-flash',
|
|
|
|
|
messages: [],
|
|
|
|
|
signal: controller.signal,
|
|
|
|
|
@@ -234,30 +408,168 @@ describe('DeepSeekAdapter against a mock server', () => {
|
|
|
|
|
})()
|
|
|
|
|
|
|
|
|
|
setTimeout(() => { controller.abort() }, 30)
|
|
|
|
|
await expect(pending).rejects.toThrow()
|
|
|
|
|
await expect(pending).rejects.toMatchObject({ code: 'ABORTED' })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('maps connection failures to TRANSPORT without losing the cause', async () => {
|
|
|
|
|
const cause = new TypeError('connection refused')
|
|
|
|
|
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(cause)
|
|
|
|
|
const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'https://example.invalid' })
|
|
|
|
|
try {
|
|
|
|
|
const drain = async (): Promise<void> => {
|
|
|
|
|
for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ }
|
|
|
|
|
}
|
|
|
|
|
await expect(drain()).rejects.toMatchObject({ code: 'TRANSPORT', cause })
|
|
|
|
|
} finally {
|
|
|
|
|
fetchSpy.mockRestore()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('renders a non-Error transport rejection without losing its cause', async () => {
|
|
|
|
|
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(() => {
|
|
|
|
|
const failed = Promise.withResolvers<Response>()
|
|
|
|
|
failed.reject('offline')
|
|
|
|
|
return failed.promise
|
|
|
|
|
})
|
|
|
|
|
const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'https://example.invalid' })
|
|
|
|
|
try {
|
|
|
|
|
const drain = async (): Promise<void> => {
|
|
|
|
|
for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ }
|
|
|
|
|
}
|
|
|
|
|
await expect(drain()).rejects.toMatchObject({
|
|
|
|
|
message: 'DeepSeek API request to https://example.invalid failed',
|
|
|
|
|
code: 'TRANSPORT',
|
|
|
|
|
cause: 'offline',
|
|
|
|
|
})
|
|
|
|
|
} finally {
|
|
|
|
|
fetchSpy.mockRestore()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('aborts the underlying body when the stream stays idle past its watchdog', async () => {
|
|
|
|
|
vi.useFakeTimers()
|
|
|
|
|
let stopped = false
|
|
|
|
|
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) => {
|
|
|
|
|
const signal = init?.signal
|
|
|
|
|
const body = new ReadableStream<Uint8Array>({
|
|
|
|
|
start(controller) {
|
|
|
|
|
signal?.addEventListener('abort', () => {
|
|
|
|
|
stopped = true
|
|
|
|
|
controller.error(signal.reason)
|
|
|
|
|
}, { once: true })
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
return Promise.resolve(new Response(body, { status: 200 }))
|
|
|
|
|
})
|
|
|
|
|
const adapter = new DeepSeekAdapter({
|
|
|
|
|
apiKey: 'k',
|
|
|
|
|
baseURL: 'https://example.invalid',
|
|
|
|
|
streamIdleTimeoutMs: 100,
|
|
|
|
|
})
|
|
|
|
|
try {
|
|
|
|
|
const drain = (async () => {
|
|
|
|
|
for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ }
|
|
|
|
|
})()
|
|
|
|
|
const rejected = expect(drain).rejects.toMatchObject({ code: 'TIMEOUT' })
|
|
|
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
|
|
|
await vi.advanceTimersByTimeAsync(100)
|
|
|
|
|
await rejected
|
|
|
|
|
expect(stopped).toBe(true)
|
|
|
|
|
} finally {
|
|
|
|
|
fetchSpy.mockRestore()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('plugin registration and config', () => {
|
|
|
|
|
it('registers the configured models and unregisters on dispose (HMR safety)', async () => {
|
|
|
|
|
it('keeps wire helpers off the package root', () => {
|
|
|
|
|
for (const helper of [
|
|
|
|
|
'httpErrorCode',
|
|
|
|
|
'serializeMessages',
|
|
|
|
|
'serializeRequest',
|
|
|
|
|
'DONE',
|
|
|
|
|
'parseSse',
|
|
|
|
|
'mapFinishReason',
|
|
|
|
|
'mapUsage',
|
|
|
|
|
'translate',
|
|
|
|
|
]) expect(LlmDeepSeek).not.toHaveProperty(helper)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('registers the deepseek provider and unregisters on dispose (HMR safety)', async () => {
|
|
|
|
|
const server = await mockServer([])
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(LlmService)
|
|
|
|
|
const fiber = await ctx.plugin(LlmDeepSeek, {
|
|
|
|
|
apiKey: 'k',
|
|
|
|
|
baseURL: server.url,
|
|
|
|
|
models: ['deepseek-v4-flash', 'deepseek-v4-pro'],
|
|
|
|
|
})
|
|
|
|
|
expect(ctx.llm.models().sort()).toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
|
|
|
|
|
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
|
|
|
|
await fiber.dispose()
|
|
|
|
|
expect(ctx.llm.models()).toEqual([])
|
|
|
|
|
expect(ctx.llm.listProviders()).toEqual([])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('defaults the model list', async () => {
|
|
|
|
|
it('owns the deepseek provider and advertises the default models', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(LlmService)
|
|
|
|
|
await ctx.plugin(LlmDeepSeek, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
|
|
|
|
expect(ctx.llm.models().sort()).toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
|
|
|
|
|
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
|
|
|
|
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
|
|
|
|
|
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' },
|
|
|
|
|
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'deepseek-v4-pro' },
|
|
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('uses the default model catalog when apply is called directly', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(LlmService)
|
|
|
|
|
LlmDeepSeek.apply(ctx, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
|
|
|
|
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
|
|
|
|
|
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' },
|
|
|
|
|
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'deepseek-v4-pro' },
|
|
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('advertises configured models without restricting arbitrary request ids', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(LlmService)
|
|
|
|
|
await ctx.plugin(LlmDeepSeek, {
|
|
|
|
|
apiKey: 'k',
|
|
|
|
|
baseURL: 'http://127.0.0.1:1',
|
|
|
|
|
models: [
|
|
|
|
|
{ id: 'private-fast' },
|
|
|
|
|
{ id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget' },
|
|
|
|
|
],
|
|
|
|
|
})
|
|
|
|
|
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
|
|
|
|
|
{ provider: 'deepseek', id: 'private-fast', name: 'private-fast' },
|
|
|
|
|
{ provider: 'deepseek', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget' },
|
|
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('allows an explicit empty model catalog', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(LlmService)
|
|
|
|
|
await ctx.plugin(LlmDeepSeek, {
|
|
|
|
|
apiKey: 'k',
|
|
|
|
|
baseURL: 'http://127.0.0.1:1',
|
|
|
|
|
models: [],
|
|
|
|
|
})
|
|
|
|
|
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it.each([
|
|
|
|
|
[[{ id: '' }], /ids must be non-empty/],
|
|
|
|
|
[[{ id: 'm', name: '' }], /empty name/],
|
|
|
|
|
[[{ id: 'm' }, { id: 'm' }], /duplicate catalog model/],
|
|
|
|
|
] as const)('rejects invalid advisory model config', async (models, message) => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(LlmService)
|
|
|
|
|
await expect(ctx.plugin(LlmDeepSeek, {
|
|
|
|
|
apiKey: 'k',
|
|
|
|
|
baseURL: 'http://127.0.0.1:1',
|
|
|
|
|
models: [...models],
|
|
|
|
|
})).rejects.toThrow(message)
|
|
|
|
|
expect(ctx.llm.listProviders()).toEqual([])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('falls back to DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL env vars', async () => {
|
|
|
|
|
@@ -266,7 +578,7 @@ describe('plugin registration and config', () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(LlmService)
|
|
|
|
|
await ctx.plugin(LlmDeepSeek, {})
|
|
|
|
|
expect(ctx.llm.models().length).toBeGreaterThan(0)
|
|
|
|
|
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('throws a clear error when no API key is available', async () => {
|
|
|
|
|
@@ -275,7 +587,7 @@ describe('plugin registration and config', () => {
|
|
|
|
|
await ctx.plugin(LlmService)
|
|
|
|
|
await expect(ctx.plugin(LlmDeepSeek, {}))
|
|
|
|
|
.rejects.toThrow(/an API key is required/)
|
|
|
|
|
expect(ctx.llm.models()).toEqual([])
|
|
|
|
|
expect(ctx.llm.listProviders()).toEqual([])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('prefers explicit config over env for key and base URL', async () => {
|
|
|
|
|
@@ -292,7 +604,7 @@ describe('plugin registration and config', () => {
|
|
|
|
|
vi.stubEnv('DEEPSEEK_BASE_URL', server.url)
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(LlmService)
|
|
|
|
|
await ctx.plugin(LlmDeepSeek, { apiKey: 'k', models: ['deepseek-v4-flash'] })
|
|
|
|
|
await ctx.plugin(LlmDeepSeek, { apiKey: 'k' })
|
|
|
|
|
await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
|
|
|
|
|
expect(server.requests).toHaveLength(1)
|
|
|
|
|
})
|
|
|
|
|
@@ -304,11 +616,38 @@ describe('plugin registration and config', () => {
|
|
|
|
|
await ctx.plugin(LlmService)
|
|
|
|
|
// Registration succeeds; no call is made (would hit api.deepseek.com).
|
|
|
|
|
await ctx.plugin(LlmDeepSeek, {})
|
|
|
|
|
expect(ctx.llm.models().length).toBeGreaterThan(0)
|
|
|
|
|
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('adapter is constructible directly for embedding', () => {
|
|
|
|
|
it('adapter is constructible directly for embedding', async () => {
|
|
|
|
|
const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
|
|
|
|
expect(adapter).toBeInstanceOf(DeepSeekAdapter)
|
|
|
|
|
await expect(adapter.listModels('deepseek')).resolves.toEqual([])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects invalid idle watchdog bounds for direct and plugin composition', async () => {
|
|
|
|
|
expect(() => new DeepSeekAdapter({
|
|
|
|
|
apiKey: 'k',
|
|
|
|
|
baseURL: 'http://127.0.0.1:1',
|
|
|
|
|
streamIdleTimeoutMs: Number.POSITIVE_INFINITY,
|
|
|
|
|
})).toThrow(/streamIdleTimeoutMs.*positive finite/)
|
|
|
|
|
expect(() => new DeepSeekAdapter({
|
|
|
|
|
apiKey: 'k',
|
|
|
|
|
baseURL: 'http://127.0.0.1:1',
|
|
|
|
|
streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1,
|
|
|
|
|
})).toThrow(/streamIdleTimeoutMs.*no greater/)
|
|
|
|
|
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(LlmService)
|
|
|
|
|
await expect(ctx.plugin(LlmDeepSeek, {
|
|
|
|
|
apiKey: 'k',
|
|
|
|
|
baseURL: 'http://127.0.0.1:1',
|
|
|
|
|
streamIdleTimeoutMs: 0,
|
|
|
|
|
})).rejects.toThrow(/streamIdleTimeoutMs/)
|
|
|
|
|
await expect(ctx.plugin(LlmDeepSeek, {
|
|
|
|
|
apiKey: 'k',
|
|
|
|
|
baseURL: 'http://127.0.0.1:1',
|
|
|
|
|
streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1,
|
|
|
|
|
})).rejects.toThrow(/streamIdleTimeoutMs/)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|