Review found five real defects in the configuration-source work, all confirmed against the code rather than argued: 1. The note claimed --config outranks settings.yaml. It does not: the settings seam registers a plugin's cordis entry config as the `base` layer and the user section layers over it, and the seam cannot tell a shipped value from a --config one. The note now states shipped reality and names --config-replace as the lever for a deployment that must win. Separately, a literal `apiKey` in settings outranked both the environment and .credentials.yaml — the field is removed, so configuration carries a reference and nothing else. 2. DEEPSEEK_SEARCH_BASE_URL was functionally deleted: the shipped inline went away without the provider learning to read it. It now resolves from the environment snapshot, as the README always claimed. 3. The bootstrap deny list missed the interpreter start-up hooks. BASH_ENV is the sharpest: `bash -c` sources it on every bash tool call, so a project .env could run a file of its choosing before every command. The list now covers BASH_ENV and its per-language siblings, the Git hook commands, and the remaining preload and CA variables, organised by what a variable does rather than which runtime owns it. 4. YAML parse errors quoted the offending source line — which in a credentials document is the secret — into boot stderr and the watcher's logger. Only the error code and position are reported now, in credentials-local and settings-local alike, pinned by a test that asserts the secret is absent. 5. 0600 governed only files the harness wrote. A hand-created 0644 document was read normally. POSIX now checks the mode before reading contents, at boot and on every reload; Windows has no mode to inspect and is skipped rather than faked. The project a session is launched in is trusted by default, with no prompt and no stored trust record: it may supply its own endpoint, ordinary variables, and a key ranked below the managed store. Trust stops at the harness itself — a discovered file still cannot set DSH_PERMISSION_MODE, PATH, BASH_ENV, or the rest, because those take effect with no user action, before any turn, outside the permission policy and the sandbox.
981 lines
38 KiB
TypeScript
981 lines
38 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { Context } from 'cordis'
|
|
import { createEnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
|
|
import LlmService, { createUserMessage,
|
|
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, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek'
|
|
import { httpErrorCode } from '../src/adapter.ts'
|
|
import { assemble } from './assemble.ts'
|
|
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
|
|
import type { Behavior } from './mock-server.ts'
|
|
|
|
afterEach(async () => {
|
|
await closeMockServers()
|
|
vi.unstubAllEnvs()
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
async function harness(baseURL: string, config: object = {}) {
|
|
// Configuration carries only the reference; the key comes from the
|
|
// environment, which is the whole credential plane without a mounted seam.
|
|
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
await ctx.plugin(LlmDeepSeek, { baseURL, ...config })
|
|
return ctx
|
|
}
|
|
|
|
/** Direct adapter over the plugin's real resolve step, with a static key. */
|
|
function adapterOf(config: Partial<LlmDeepSeek.Config> & { apiKey?: string } = {}): DeepSeekAdapter {
|
|
const { apiKey, ...rest } = config
|
|
return new DeepSeekAdapter({
|
|
options: () => resolveAdapterOptions(rest),
|
|
resolveApiKey: () => Promise.resolve(apiKey ?? 'k'),
|
|
})
|
|
}
|
|
|
|
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: [createUserMessage({
|
|
content: [{ type: 'text', text: 'hi' }],
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
})],
|
|
})
|
|
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',
|
|
max_tokens: 256_000,
|
|
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-official',
|
|
model: 'deepseek-v4-flash',
|
|
messages: [createUserMessage({
|
|
content: [{ type: 'text', text: 'hi' }],
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
})],
|
|
})) {
|
|
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: [createUserMessage({
|
|
content: [{ type: 'text', text: 'hi' }],
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
})],
|
|
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: [createUserMessage({
|
|
content: [{ type: 'text', text: 'hi' }],
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
})],
|
|
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: [createUserMessage({
|
|
content: [{ type: 'text', text: 'hi' }],
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
})],
|
|
})
|
|
await assemble(ctx,{
|
|
model: 'deepseek-v4-flash',
|
|
reasoningEffort: ReasoningEffortId('off'),
|
|
messages: [createUserMessage({
|
|
content: [{ type: 'text', text: 'hi again' }],
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
})],
|
|
})
|
|
await assemble(ctx,{
|
|
model: 'deepseek-v4-flash',
|
|
reasoningEffort: ReasoningEffortId('max'),
|
|
messages: [createUserMessage({
|
|
content: [{ type: 'text', text: 'one more time' }],
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
})],
|
|
})
|
|
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('uses the configured maxTokens default and preserves an explicit request cap', async () => {
|
|
const server = await mockServer([
|
|
{ kind: 'sse', events: textEvents },
|
|
{ kind: 'sse', events: textEvents },
|
|
])
|
|
const ctx = await harness(server.url, { maxTokens: 32_000 })
|
|
|
|
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
|
|
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [], maxTokens: 8_192 })
|
|
|
|
expect(server.requests[0]).toMatchObject({ max_tokens: 32_000 })
|
|
expect(server.requests[1]).toMatchObject({ max_tokens: 8_192 })
|
|
})
|
|
|
|
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: [createUserMessage({
|
|
content: [{ type: 'text', text: 'hi' }],
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
})],
|
|
})
|
|
expect(server.requests[0]).toMatchObject({
|
|
thinking: { type: 'disabled' },
|
|
})
|
|
expect(server.requests[0]).not.toHaveProperty('reasoning_effort')
|
|
await expect(ctx.llm.resolveModelInfo('deepseek-official', '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: [createUserMessage({
|
|
content: [{ type: 'text', text: 'hi' }],
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
})],
|
|
})).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 = adapterOf({ apiKey: 'test-key', baseURL: server.url, thinking: 'disabled' })
|
|
|
|
const stream = adapter.stream({
|
|
provider: 'deepseek-official',
|
|
model: 'deepseek-v4-flash',
|
|
reasoningEffort: ReasoningEffortId(effort),
|
|
messages: [createUserMessage({
|
|
content: [{ type: 'text', text: 'hi' }],
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
})],
|
|
})
|
|
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 = adapterOf({ 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-official', 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-official',
|
|
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 = adapterOf({ baseURL: 'https://example.invalid' })
|
|
try {
|
|
const drain = async (): Promise<void> => {
|
|
for await (const _chunk of adapter.stream({ provider: 'deepseek-official', 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 = adapterOf({ baseURL: 'https://example.invalid' })
|
|
try {
|
|
const drain = async (): Promise<void> => {
|
|
for await (const _chunk of adapter.stream({ provider: 'deepseek-official', 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 = adapterOf({ baseURL: 'https://example.invalid', streamIdleTimeoutMs: 100 })
|
|
try {
|
|
const drain = (async () => {
|
|
for await (const _chunk of adapter.stream({ provider: 'deepseek-official', 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, {
|
|
baseURL: server.url,
|
|
})
|
|
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
|
|
expect(ctx.llm.listConfigurableProviders()).toEqual([{
|
|
provider: 'deepseek-official',
|
|
displayName: 'DeepSeek',
|
|
settingsNs: 'llm-deepseek',
|
|
settingsPath: [],
|
|
}])
|
|
await fiber.dispose()
|
|
expect(ctx.llm.listProviders()).toEqual([])
|
|
expect(ctx.llm.listConfigurableProviders()).toEqual([])
|
|
})
|
|
|
|
it('registers retryPolicy from the provider config', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
await ctx.plugin(LlmDeepSeek, {
|
|
baseURL: 'http://127.0.0.1:1',
|
|
retryPolicy: {
|
|
mode: 'always',
|
|
backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 },
|
|
},
|
|
})
|
|
|
|
expect(ctx.llm.providerRetryPolicy('deepseek-official')).toEqual({
|
|
mode: 'always',
|
|
initialDelayMs: 25,
|
|
maxDelayMs: 100,
|
|
jitterRatio: 0.2,
|
|
})
|
|
})
|
|
|
|
it('owns the deepseek provider and advertises the default models', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' })
|
|
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
|
|
await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([
|
|
{ provider: 'deepseek-official', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash' },
|
|
{ provider: 'deepseek-official', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' },
|
|
])
|
|
await expect(ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-v4-flash'))
|
|
.resolves.toMatchObject({
|
|
provider: 'deepseek-official',
|
|
id: 'deepseek-v4-flash',
|
|
name: 'DeepSeek-V4-Flash',
|
|
context: { contextWindow: 1_000_000 },
|
|
defaultMaxTokens: 256_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, {
|
|
baseURL: 'http://127.0.0.1:1',
|
|
reasoningEffort: effort,
|
|
})
|
|
await expect(ctx.llm.resolveModelInfo('deepseek-official', '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, {
|
|
baseURL: 'http://127.0.0.1:1',
|
|
thinking: 'disabled',
|
|
reasoningEffort: 'off',
|
|
})
|
|
await expect(ctx.llm.resolveModelInfo('deepseek-official', '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, {
|
|
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 resolver boundary',
|
|
(reasoningEffort) => {
|
|
expect(() => resolveAdapterOptions({ thinking: 'disabled', reasoningEffort }))
|
|
.toThrow(/only reasoningEffort "off"/)
|
|
},
|
|
)
|
|
|
|
it('accepts disabled thinking with off at the resolver boundary', async () => {
|
|
const adapter = adapterOf({ thinking: 'disabled', reasoningEffort: 'off' })
|
|
await expect(adapter.resolveModel('deepseek-official', '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, { baseURL: 'http://127.0.0.1:1' })
|
|
await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([
|
|
{ provider: 'deepseek-official', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash' },
|
|
{ provider: 'deepseek-official', 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, {
|
|
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-official')).resolves.toEqual([
|
|
{ provider: 'deepseek-official', id: 'private-fast', name: 'private-fast' },
|
|
{ provider: 'deepseek-official', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget' },
|
|
])
|
|
await expect(ctx.llm.resolveModelInfo('deepseek-official', 'private-fast'))
|
|
.resolves.toMatchObject({ context: { contextWindow: 32_000 } })
|
|
await expect(ctx.llm.resolveModelInfo('deepseek-official', 'private-reasoner'))
|
|
.resolves.toMatchObject({
|
|
name: 'Private Reasoner',
|
|
description: 'Higher reasoning budget',
|
|
})
|
|
await expect(ctx.llm.resolveModelInfo('deepseek-official', 'arbitrary-unlisted'))
|
|
.resolves.toMatchObject({
|
|
context: { contextWindow: 1_000_000 },
|
|
defaultMaxTokens: 256_000,
|
|
})
|
|
})
|
|
|
|
it('uses exact model capacity before the adapter-wide default', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
await ctx.plugin(LlmDeepSeek, {
|
|
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-official', 'inherits-default'))
|
|
.resolves.toMatchObject({ context: { contextWindow: 256_000 } })
|
|
await expect(ctx.llm.resolveModelInfo('deepseek-official', 'exact-override'))
|
|
.resolves.toMatchObject({ context: { contextWindow: 64_000 } })
|
|
await expect(ctx.llm.resolveModelInfo('deepseek-official', '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, {
|
|
baseURL: 'http://127.0.0.1:1',
|
|
models: [],
|
|
})
|
|
await expect(ctx.llm.listModels('deepseek-official')).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, {
|
|
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, {
|
|
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(() => resolveAdapterOptions({ defaultContextWindow }))
|
|
.toThrow(/defaultContextWindow must be a positive integer/)
|
|
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
await expect(ctx.plugin(LlmDeepSeek, {
|
|
baseURL: 'http://127.0.0.1:1',
|
|
defaultContextWindow,
|
|
})).rejects.toThrow(/defaultContextWindow/)
|
|
expect(ctx.llm.listProviders()).toEqual([])
|
|
},
|
|
)
|
|
|
|
it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])(
|
|
'rejects invalid adapter-wide maxTokens %s',
|
|
async (maxTokens) => {
|
|
expect(() => resolveAdapterOptions({ maxTokens }))
|
|
.toThrow(/maxTokens must be a positive safe integer/)
|
|
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
await expect(ctx.plugin(LlmDeepSeek, {
|
|
baseURL: 'http://127.0.0.1:1',
|
|
maxTokens,
|
|
})).rejects.toThrow(/maxTokens/)
|
|
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-official', name: 'DeepSeek' }])
|
|
})
|
|
|
|
it('loads keyless, keeps the catalog browsable, and fails the request actionably', async () => {
|
|
vi.stubEnv('DEEPSEEK_API_KEY', '')
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' })
|
|
// First-boot onboarding: the route registers so models stay discoverable;
|
|
// only the request itself needs a key.
|
|
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
|
|
await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2)
|
|
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
|
|
.rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' })
|
|
// The guidance leads with the credential store — the path that keeps the
|
|
// secret out of configuration files — and mentions a literal key last.
|
|
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
|
|
.rejects.toThrow(/store DEEPSEEK_API_KEY through the credentials service.*export DEEPSEEK_API_KEY/s)
|
|
})
|
|
|
|
it('reads the ambient variable when no credentials seam is mounted', async () => {
|
|
// The plain cordis.yml composition: no credential provider, the key in
|
|
// the launching environment.
|
|
vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key')
|
|
const server = await mockServer([{ kind: 'sse', events: textEvents }])
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
await ctx.plugin(LlmDeepSeek, { baseURL: server.url })
|
|
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
|
|
expect(server.headers[0]?.authorization).toBe('Bearer ambient-key')
|
|
})
|
|
|
|
it('treats an empty ambient variable as no key when no credentials seam is mounted', async () => {
|
|
vi.stubEnv('DEEPSEEK_API_KEY', '')
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' })
|
|
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
|
|
.rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' })
|
|
})
|
|
|
|
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)
|
|
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
await ctx.plugin(LlmDeepSeek, {})
|
|
await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
|
|
expect(server.requests).toHaveLength(1)
|
|
})
|
|
|
|
|
|
it('takes DEEPSEEK_BASE_URL from any environment layer, with explicit config still on top', () => {
|
|
const trusted = createEnvironmentSnapshot([
|
|
{ source: 'user-env', path: '/home/.dsh/.env', values: { DEEPSEEK_BASE_URL: 'https://user.example' } },
|
|
])
|
|
expect(resolveAdapterOptions({}, trusted).baseURL).toBe('https://user.example')
|
|
// The product trusts the project it is launched in, so a checkout can
|
|
// point its own agent at the gateway that checkout is meant to use.
|
|
const project = createEnvironmentSnapshot([
|
|
{ source: 'project-env', path: '/work/.env', values: { DEEPSEEK_BASE_URL: 'https://project.example' } },
|
|
])
|
|
expect(resolveAdapterOptions({}, project).baseURL).toBe('https://project.example')
|
|
// An explicitly configured endpoint outranks every environment layer, so a
|
|
// stale shell value cannot rewrite a deployment's own gateway.
|
|
const shell = createEnvironmentSnapshot([
|
|
{ source: 'process', values: { DEEPSEEK_BASE_URL: 'https://stale.example' } },
|
|
])
|
|
expect(resolveAdapterOptions({ baseURL: 'https://gateway.internal' }, shell).baseURL).toBe('https://gateway.internal')
|
|
})
|
|
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-official', name: 'DeepSeek' }])
|
|
})
|
|
|
|
it('adapter is constructible directly for embedding over the shared resolver', async () => {
|
|
const adapter = adapterOf()
|
|
expect(adapter).toBeInstanceOf(DeepSeekAdapter)
|
|
// Direct embedding shares the plugin's one resolve step, so it advertises
|
|
// the same default catalog instead of a divergent empty one.
|
|
await expect(adapter.listModels('deepseek-official')).resolves.toHaveLength(2)
|
|
})
|
|
|
|
it('resolves connection facts and the credential exactly once per stream call', async () => {
|
|
const server = await mockServer([{ kind: 'sse', events: textEvents }])
|
|
const options = vi.fn(() => resolveAdapterOptions({ baseURL: server.url }))
|
|
const resolveApiKey = vi.fn(() => Promise.resolve('per-request-key'))
|
|
const adapter = new DeepSeekAdapter({ options, resolveApiKey })
|
|
|
|
for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ }
|
|
|
|
expect(options).toHaveBeenCalledTimes(1)
|
|
expect(resolveApiKey).toHaveBeenCalledTimes(1)
|
|
expect(server.headers[0]?.authorization).toBe('Bearer per-request-key')
|
|
})
|
|
|
|
it('rejects invalid idle watchdog bounds for direct and plugin composition', async () => {
|
|
expect(() => resolveAdapterOptions({ streamIdleTimeoutMs: Number.POSITIVE_INFINITY }))
|
|
.toThrow(/streamIdleTimeoutMs.*positive finite/)
|
|
expect(() => resolveAdapterOptions({ streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 }))
|
|
.toThrow(/streamIdleTimeoutMs.*no greater/)
|
|
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
await expect(ctx.plugin(LlmDeepSeek, {
|
|
baseURL: 'http://127.0.0.1:1',
|
|
streamIdleTimeoutMs: 0,
|
|
})).rejects.toThrow(/streamIdleTimeoutMs/)
|
|
await expect(ctx.plugin(LlmDeepSeek, {
|
|
baseURL: 'http://127.0.0.1:1',
|
|
streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1,
|
|
})).rejects.toThrow(/streamIdleTimeoutMs/)
|
|
})
|
|
|
|
it('rejects invalid nested retryPolicy before registering the provider', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
|
|
await expect(ctx.plugin(LlmDeepSeek, {
|
|
baseURL: 'http://127.0.0.1:1',
|
|
retryPolicy: { mode: 'normal', maxRetries: -1 },
|
|
})).rejects.toThrow(/retryPolicy/)
|
|
expect(ctx.llm.listProviders()).toEqual([])
|
|
})
|
|
})
|