| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911 |
- 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, {
- CONTEXT_WINDOW_EXCEEDED_CODE,
- errorChain,
- LlmError,
- ProviderRequestId,
- QUOTA_EXCEEDED_CODE,
- ReasoningEffortId,
- 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 } 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; headers?: Record<string, string> }
- | { kind: 'close-early'; events: string[] }
- interface MockServer {
- url: string
- /** Bodies of received requests, in order. */
- requests: unknown[]
- /** Header bags of received requests, in order (parallel to `requests`). */
- headers: IncomingMessage['headers'][]
- script: Behavior[]
- close(): Promise<void>
- }
- 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. */
- async function mockServer(script: Behavior[]): Promise<MockServer> {
- const requests: unknown[] = []
- const headers: IncomingMessage['headers'][] = []
- const server = createServer((request: IncomingMessage, response: ServerResponse) => {
- let body = ''
- request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') })
- request.on('end', () => {
- requests.push(JSON.parse(body))
- headers.push(request.headers)
- const behavior = script.shift()
- if (!behavior) {
- response.writeHead(500).end('mock script exhausted')
- return
- }
- if (behavior.kind === 'http-error') {
- response.writeHead(behavior.status, {
- 'content-type': behavior.contentType ?? 'application/json',
- ...behavior.headers,
- })
- response.end(behavior.body)
- return
- }
- response.writeHead(200, { 'content-type': 'text/event-stream' })
- const write = (index: number): void => {
- if (index >= behavior.events.length) {
- if (behavior.kind === 'sse') response.end()
- else response.destroy() // close-early: drop the socket mid-stream
- return
- }
- response.write(`data: ${behavior.events[index]}\n\n`)
- setTimeout(() => { write(index + 1) }, behavior.kind === 'sse' ? behavior.delayMs ?? 0 : 5)
- }
- write(0)
- })
- })
- servers.push(server)
- await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
- const address = server.address()
- if (address === null || typeof address === 'string') throw new Error('no port')
- return {
- url: `http://127.0.0.1:${address.port}`,
- requests,
- headers,
- script,
- close: () => new Promise(resolve => server.close(() => { resolve() })),
- }
- }
- const textEvents = [
- '{"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}',
- '{"choices":[{"delta":{"content":"hello"}}]}',
- '{"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
- '[DONE]',
- ]
- async function harness(baseURL: string, config: object = {}) {
- const ctx = new Context()
- await ctx.plugin(LlmService)
- await ctx.plugin(LlmDeepSeek, { apiKey: 'test-key', baseURL, ...config })
- return ctx
- }
- describe('DeepSeekAdapter against a mock server', () => {
- it('streams a text generation end to end through the assembler', async () => {
- const server = await mockServer([{ kind: 'sse', events: textEvents }])
- const ctx = await harness(server.url)
- const result = await assemble(ctx, {
- model: 'deepseek-v4-flash',
- messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
- })
- expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
- expect(result.finish).toEqual({ kind: 'stop' })
- expect(result.usage).toEqual({ inputTokens: 3, outputTokens: 1 })
- // The wire request carried the auth header contents we configured.
- expect(server.requests[0]).toMatchObject({
- model: 'deepseek-v4-flash',
- reasoning_effort: 'high',
- stream: true,
- stream_options: { include_usage: true },
- })
- // Attribution reaches the wire: the exact shared User-Agent, and no
- // provider-specific headers under the User-Agent-only contract.
- expect(server.headers[0]?.['user-agent']).toBe(userAgent())
- expect(server.headers[0]).not.toHaveProperty('http-referer')
- expect(server.headers[0]).not.toHaveProperty('x-openrouter-title')
- expect(server.headers[0]).not.toHaveProperty('x-openrouter-categories')
- expect(server.headers[0]).not.toHaveProperty('x-deepseek-harness-compact')
- })
- it('streams raw chunks through ctx.llm.stream', async () => {
- const server = await mockServer([{ kind: 'sse', events: textEvents, delayMs: 2 }])
- const ctx = await harness(server.url)
- 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' }] }],
- })) {
- kinds.push(chunk.type)
- }
- 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('marks the auxiliary compaction call on the wire', 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' }] }],
- purpose: 'compaction',
- })
- expect(server.headers[0]?.['x-deepseek-harness-compact']).toBe('1')
- })
- it('switches dynamically from the configured high default through off to max', async () => {
- const server = await mockServer([
- { kind: 'sse', events: textEvents },
- { kind: 'sse', events: textEvents },
- { kind: 'sse', events: textEvents },
- ])
- const ctx = await harness(server.url, { thinking: 'enabled', reasoningEffort: 'high' })
- await assemble(ctx,{
- model: 'deepseek-v4-flash',
- messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
- })
- await assemble(ctx,{
- model: 'deepseek-v4-flash',
- reasoningEffort: ReasoningEffortId('off'),
- messages: [{ role: 'user', content: [{ type: 'text', text: 'hi again' }] }],
- })
- await assemble(ctx,{
- model: 'deepseek-v4-flash',
- reasoningEffort: ReasoningEffortId('max'),
- messages: [{ role: 'user', content: [{ type: 'text', text: 'one more time' }] }],
- })
- expect(server.requests[0]).toMatchObject({
- thinking: { type: 'enabled' },
- reasoning_effort: 'high',
- })
- expect(server.requests[1]).toMatchObject({
- thinking: { type: 'disabled' },
- })
- expect(server.requests[1]).not.toHaveProperty('reasoning_effort')
- expect(server.requests[2]).toMatchObject({
- thinking: { type: 'enabled' },
- reasoning_effort: 'max',
- })
- })
- it('publishes only off and omits the wire effort when thinking is disabled', async () => {
- const server = await mockServer([{ kind: 'sse', events: textEvents }])
- const ctx = await harness(server.url, { thinking: 'disabled' })
- await assemble(ctx,{
- model: 'deepseek-v4-flash',
- messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
- })
- expect(server.requests[0]).toMatchObject({
- thinking: { type: 'disabled' },
- })
- expect(server.requests[0]).not.toHaveProperty('reasoning_effort')
- await expect(ctx.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
- .resolves.toMatchObject({
- reasoning: {
- efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }],
- defaultEffort: ReasoningEffortId('off'),
- },
- })
- })
- it('rejects a per-request effort before I/O when thinking is disabled', async () => {
- const server = await mockServer([])
- const ctx = await harness(server.url, { thinking: 'disabled' })
- await expect(assemble(ctx, {
- model: 'deepseek-v4-flash',
- reasoningEffort: ReasoningEffortId('high'),
- messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
- })).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' })
- expect(server.requests).toHaveLength(0)
- })
- it.each(['high', 'max'])(
- 'rejects direct adapter effort %s before I/O when thinking is disabled',
- async (effort) => {
- const server = await mockServer([])
- const adapter = new DeepSeekAdapter({
- apiKey: 'test-key',
- baseURL: server.url,
- defaults: { thinking: 'disabled' },
- })
- const stream = adapter.stream({
- provider: 'deepseek',
- model: 'deepseek-v4-flash',
- reasoningEffort: ReasoningEffortId(effort),
- messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
- })
- await expect(async () => {
- for await (const _chunk of stream) { /* drain */ }
- }).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' })
- expect(server.requests).toHaveLength(0)
- },
- )
- it.each([
- [401, 'AUTH'],
- [403, 'AUTH'],
- [429, 'RATE_LIMIT'],
- [400, 'INVALID_REQUEST'],
- [500, 'SERVER'],
- [503, 'SERVER'],
- ])('maps HTTP %d to LlmError code %s with the body message', async (status, code) => {
- const behavior: Behavior = {
- kind: 'http-error',
- status,
- body: JSON.stringify({ error: { message: `failed with ${status}`, type: 't', code: 'c' } }),
- }
- 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}`)
- await expect(
- assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
- .catch((error: unknown) => (error as LlmError).code),
- ).resolves.toBe(code)
- })
- 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 () => {
- const server = await mockServer([{ kind: 'http-error', status: 500, body: '{"error":{"type":"x"}}' }])
- const ctx = await harness(server.url)
- await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
- .rejects.toThrow(/HTTP 500/)
- })
- it('keeps the status-line message for non-JSON error bodies', async () => {
- const server = await mockServer([{ kind: 'http-error', status: 502, body: 'Bad Gateway', contentType: 'text/plain' }])
- const ctx = await harness(server.url)
- await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
- .rejects.toThrow(/HTTP 502/)
- })
- it('maps unusual statuses to HTTP_<status>', () => {
- 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(
- new Response(null, { status: 200 }),
- )
- try {
- const iterate = async (): Promise<void> => {
- for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ }
- }
- await expect(iterate()).rejects.toThrow(/no response body/)
- } finally {
- fetchSpy.mockRestore()
- }
- })
- 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)
- 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 () => {
- const server = await mockServer([{ kind: 'sse', events: textEvents, delayMs: 50 }])
- const ctx = await harness(server.url)
- const controller = new AbortController()
- const pending = (async () => {
- const chunks = []
- for await (const chunk of ctx.llm.stream({
- provider: 'deepseek',
- model: 'deepseek-v4-flash',
- messages: [],
- signal: controller.signal,
- })) {
- chunks.push(chunk)
- }
- return chunks
- })()
- setTimeout(() => { controller.abort() }, 30)
- 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('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,
- })
- expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
- await fiber.dispose()
- expect(ctx.llm.listProviders()).toEqual([])
- })
- 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.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' },
- ])
- await expect(ctx.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
- .resolves.toMatchObject({
- provider: 'deepseek',
- id: 'deepseek-v4-flash',
- name: 'deepseek-v4-flash',
- context: { contextWindow: 128_000 },
- reasoning: {
- efforts: [
- { id: ReasoningEffortId('off'), name: 'Off' },
- { id: ReasoningEffortId('high'), name: 'High' },
- { id: ReasoningEffortId('max'), name: 'Max' },
- ],
- defaultEffort: ReasoningEffortId('high'),
- },
- })
- })
- it.each(['off', 'max'] as const)('uses the configured %s reasoning default', async (effort) => {
- const ctx = new Context()
- await ctx.plugin(LlmService)
- await ctx.plugin(LlmDeepSeek, {
- apiKey: 'k',
- baseURL: 'http://127.0.0.1:1',
- reasoningEffort: effort,
- })
- await expect(ctx.llm.resolveModelInfo('deepseek', 'unlisted-pass-through'))
- .resolves.toMatchObject({
- reasoning: {
- efforts: [
- { id: ReasoningEffortId('off'), name: 'Off' },
- { id: ReasoningEffortId('high'), name: 'High' },
- { id: ReasoningEffortId('max'), name: 'Max' },
- ],
- defaultEffort: ReasoningEffortId(effort),
- },
- })
- })
- it('accepts off as the default when thinking is deployment-disabled', async () => {
- const ctx = new Context()
- await ctx.plugin(LlmService)
- await ctx.plugin(LlmDeepSeek, {
- apiKey: 'k',
- baseURL: 'http://127.0.0.1:1',
- thinking: 'disabled',
- reasoningEffort: 'off',
- })
- await expect(ctx.llm.resolveModelInfo('deepseek', 'unlisted-pass-through'))
- .resolves.toMatchObject({
- reasoning: {
- efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }],
- defaultEffort: ReasoningEffortId('off'),
- },
- })
- })
- it.each(['high', 'max'] as const)(
- 'rejects configured reasoning effort %s when thinking is disabled',
- async (reasoningEffort) => {
- const ctx = new Context()
- await ctx.plugin(LlmService)
- await expect(ctx.plugin(LlmDeepSeek, {
- apiKey: 'k',
- baseURL: 'http://127.0.0.1:1',
- thinking: 'disabled',
- reasoningEffort,
- })).rejects.toThrow(/only reasoningEffort "off"/)
- expect(ctx.llm.listProviders()).toEqual([])
- },
- )
- it.each(['high', 'max'] as const)(
- 'rejects disabled-thinking effort %s at the direct constructor boundary',
- (reasoningEffort) => {
- expect(() => new DeepSeekAdapter({
- apiKey: 'k',
- baseURL: 'http://127.0.0.1:1',
- defaults: { thinking: 'disabled', reasoningEffort },
- })).toThrow(/only reasoningEffort "off"/)
- },
- )
- it('accepts disabled thinking with off at the direct constructor boundary', async () => {
- const adapter = new DeepSeekAdapter({
- apiKey: 'k',
- baseURL: 'http://127.0.0.1:1',
- defaults: { thinking: 'disabled', reasoningEffort: 'off' },
- })
- await expect(adapter.resolveModel('deepseek', 'pass-through')).resolves.toMatchObject({
- reasoning: {
- efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }],
- defaultEffort: ReasoningEffortId('off'),
- },
- })
- })
- 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', contextWindow: 32_000 },
- {
- id: 'private-reasoner',
- name: 'Private Reasoner',
- description: 'Higher reasoning budget',
- contextWindow: 64_000,
- },
- ],
- })
- 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' },
- ])
- await expect(ctx.llm.resolveModelInfo('deepseek', 'private-fast'))
- .resolves.toMatchObject({ context: { contextWindow: 32_000 } })
- await expect(ctx.llm.resolveModelInfo('deepseek', 'private-reasoner'))
- .resolves.toMatchObject({
- name: 'Private Reasoner',
- description: 'Higher reasoning budget',
- })
- await expect(ctx.llm.resolveModelInfo('deepseek', 'arbitrary-unlisted'))
- .resolves.not.toHaveProperty('context')
- })
- it('uses exact model capacity before the adapter-wide default', async () => {
- const ctx = new Context()
- await ctx.plugin(LlmService)
- await ctx.plugin(LlmDeepSeek, {
- apiKey: 'k',
- baseURL: 'http://127.0.0.1:1',
- defaultContextWindow: 256_000,
- models: [
- { id: 'inherits-default' },
- { id: 'exact-override', contextWindow: 64_000 },
- ],
- })
- await expect(ctx.llm.resolveModelInfo('deepseek', 'inherits-default'))
- .resolves.toMatchObject({ context: { contextWindow: 256_000 } })
- await expect(ctx.llm.resolveModelInfo('deepseek', 'exact-override'))
- .resolves.toMatchObject({ context: { contextWindow: 64_000 } })
- await expect(ctx.llm.resolveModelInfo('deepseek', 'unlisted-pass-through'))
- .resolves.toMatchObject({ context: { contextWindow: 256_000 } })
- })
- 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', contextWindow: 0 }], /contextWindow/],
- [[{ id: 'm', contextWindow: 1.5 }], /contextWindow/],
- [[{ 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('rejects invalid context capacity when apply is called directly', async () => {
- const ctx = new Context()
- await ctx.plugin(LlmService)
- expect(() => {
- LlmDeepSeek.apply(ctx, {
- apiKey: 'k',
- baseURL: 'http://127.0.0.1:1',
- models: [{ id: 'invalid-context', contextWindow: 0 }],
- })
- }).toThrow(/contextWindow must be a positive integer/)
- expect(ctx.llm.listProviders()).toEqual([])
- })
- it.each([0, 1.5])(
- 'rejects invalid adapter-wide default context capacity %s',
- async (defaultContextWindow) => {
- expect(() => new DeepSeekAdapter({
- apiKey: 'k',
- baseURL: 'http://127.0.0.1:1',
- defaultContextWindow,
- })).toThrow(/defaultContextWindow must be a positive integer/)
- const ctx = new Context()
- await ctx.plugin(LlmService)
- await expect(ctx.plugin(LlmDeepSeek, {
- apiKey: 'k',
- baseURL: 'http://127.0.0.1:1',
- defaultContextWindow,
- })).rejects.toThrow(/defaultContextWindow/)
- expect(ctx.llm.listProviders()).toEqual([])
- },
- )
- it('falls back to DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL env vars', async () => {
- vi.stubEnv('DEEPSEEK_API_KEY', 'env-key')
- vi.stubEnv('DEEPSEEK_BASE_URL', 'http://127.0.0.1:1')
- const ctx = new Context()
- await ctx.plugin(LlmService)
- await ctx.plugin(LlmDeepSeek, {})
- expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
- })
- it('throws a clear error when no API key is available', async () => {
- vi.stubEnv('DEEPSEEK_API_KEY', '')
- const ctx = new Context()
- await ctx.plugin(LlmService)
- await expect(ctx.plugin(LlmDeepSeek, {}))
- .rejects.toThrow(/an API key is required/)
- expect(ctx.llm.listProviders()).toEqual([])
- })
- it('prefers explicit config over env for key and base URL', async () => {
- vi.stubEnv('DEEPSEEK_API_KEY', 'env-key')
- vi.stubEnv('DEEPSEEK_BASE_URL', 'http://env-host:1')
- const server = await mockServer([{ kind: 'sse', events: textEvents }])
- const ctx = await harness(server.url) // harness passes explicit config
- await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
- expect(server.requests).toHaveLength(1) // hit the explicit URL, not env
- })
- it('uses DEEPSEEK_BASE_URL when config omits baseURL', async () => {
- const server = await mockServer([{ kind: 'sse', events: textEvents }])
- vi.stubEnv('DEEPSEEK_BASE_URL', server.url)
- const ctx = new Context()
- await ctx.plugin(LlmService)
- await ctx.plugin(LlmDeepSeek, { apiKey: 'k' })
- await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
- expect(server.requests).toHaveLength(1)
- })
- it('defaults to the public base URL without config or env', async () => {
- vi.stubEnv('DEEPSEEK_API_KEY', 'k')
- vi.stubEnv('DEEPSEEK_BASE_URL', undefined)
- const ctx = new Context()
- await ctx.plugin(LlmService)
- // Registration succeeds; no call is made (would hit api.deepseek.com).
- await ctx.plugin(LlmDeepSeek, {})
- expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
- })
- 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/)
- })
- })
|