590b76a7f0
Two had real security consequences: The bootstrap rejection ran on npm dotenv's parser while process.loadEnvFile applied the file with Node's own. Two independently maintained dialects meant the check and the thing it guards could disagree: a name Node accepts but the checker misses would reach process.env unchecked, and BASH_ENV there runs a file of the project's choosing on every `bash -c` the bash tool issues. Parse once with node:util's parseEnv — the same engine loadEnvFile uses — and assign the entries already checked, which also drops the dotenv dependency. llm-pi-ai still returned a literal profile.apiKey ahead of everything, and it registers a settings namespace, so the defect removed from llm-deepseek survived intact in its design twin. The field is gone from the profile schema, the resolution path, and the tests. The rest are consistency and documentation defects the review named: - verify-config-source-ownership did not scan the Python runtime's bundled cordis.yml, which still inlined apiKey and baseURL. Both are covered now, and the line-anchored INLINE_DENY documents that it is a tripwire, not a parser. - The deny list missed NODE_TLS_REJECT_UNAUTHORIZED, the askpass hooks, the GIT_CONFIG_* redirections, and PYTHONHOME — all implied by its own stated rule about what a variable does. - Snapshot lookups folded case on Windows, where environment names are case-insensitive and an exact-match Map could miss a higher-ranked layer. - The credentials note claimed a read-time permission check was "not taken" while this PR implemented it; the credentials-local README still described two layers, live process.env reads, dotenv-era limitations, and a renamed anchor; the llm-deepseek README still advertised the removed literal apiKey; and web.ts and base.cordis.yml kept personal-overlay wording. - The ownership note's literal-apiKey claim now names its scope: the web-search providers keep a literal field but register no settings namespace, so nothing can shadow a stored credential through them.
578 lines
23 KiB
TypeScript
578 lines
23 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { Context } from 'cordis'
|
|
import LlmService, { createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, ReasoningEffortId, userAgent } from '@deepseek-ai/dsh-llm'
|
|
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
|
|
import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai'
|
|
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
|
import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all'
|
|
import { resolveProfiles } from '../src/config.ts'
|
|
import { assemble } from './assemble.ts'
|
|
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
|
|
|
|
afterEach(async () => {
|
|
vi.unstubAllEnvs()
|
|
await closeMockServers()
|
|
})
|
|
|
|
async function harness(baseURL: string, overrides: Record<string, unknown> = {}): Promise<Context> {
|
|
vi.stubEnv('PI_TEST_KEY', 'test-key')
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
await ctx.plugin(LlmPiAi, {
|
|
providers: { deepseek: { apiKeyEnv: 'PI_TEST_KEY', baseURL, ...overrides } },
|
|
})
|
|
return ctx
|
|
}
|
|
|
|
/** Direct adapter over the real profile resolver, with a fixed key per call. */
|
|
function adapterOf(
|
|
providers: Record<string, LlmPiAi.PiAiProviderProfile>,
|
|
apiKey: string | undefined = 'test-key',
|
|
): PiAiAdapter {
|
|
return new PiAiAdapter({
|
|
profiles: () => resolveProfiles(providers),
|
|
resolveApiKey: () => Promise.resolve(apiKey),
|
|
})
|
|
}
|
|
|
|
beforeEach(() => {
|
|
// Configuration carries only the reference; these mounts resolve it from
|
|
// the environment, which is the whole credential plane without a seam.
|
|
vi.stubEnv('PI_TEST_KEY', 'test-key')
|
|
})
|
|
|
|
describe('PiAiAdapter provider routing', () => {
|
|
it('resolves a catalog model dynamically and uses a private endpoint', async () => {
|
|
const server = await mockServer([{ 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 })
|
|
expect(server.paths).toEqual(['/chat/completions'])
|
|
})
|
|
|
|
it('merges profile headers with Harness attribution winning', async () => {
|
|
const server = await mockServer([{ events: textEvents }])
|
|
const ctx = await harness(server.url, {
|
|
headers: { 'x-company': 'private', 'User-Agent': 'wrong' },
|
|
})
|
|
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
|
|
expect(server.headers[0]?.['x-company']).toBe('private')
|
|
expect(server.headers[0]?.['user-agent']).toBe(userAgent())
|
|
})
|
|
|
|
it('forwards common stream options and profile reasoning', async () => {
|
|
const server = await mockServer([{ events: textEvents }])
|
|
const ctx = await harness(server.url, {
|
|
reasoning: 'max',
|
|
cacheRetention: 'none',
|
|
transport: 'sse',
|
|
timeoutMs: 5000,
|
|
websocketConnectTimeoutMs: 3000,
|
|
streamIdleTimeoutMs: 10_000,
|
|
thinkingBudgets: { high: 2048 },
|
|
})
|
|
await assemble(ctx, {
|
|
model: 'deepseek-v4-flash',
|
|
messages: [],
|
|
temperature: 0.2,
|
|
maxTokens: 77,
|
|
sessionId: 'session-for-pi' as never,
|
|
})
|
|
expect(server.requests[0]).toMatchObject({
|
|
model: 'deepseek-v4-flash',
|
|
temperature: 0.2,
|
|
max_completion_tokens: 77,
|
|
thinking: { type: 'enabled' },
|
|
reasoning_effort: 'max',
|
|
})
|
|
})
|
|
|
|
it('uses a dynamic request effort and rejects unsupported efforts before network I/O', async () => {
|
|
const server = await mockServer([{ events: textEvents }, { events: textEvents }])
|
|
const ctx = await harness(server.url, { reasoning: 'max' })
|
|
|
|
await assemble(ctx, {
|
|
model: 'deepseek-v4-flash',
|
|
reasoningEffort: ReasoningEffortId('high'),
|
|
messages: [],
|
|
})
|
|
expect(server.requests[0]).toMatchObject({ reasoning_effort: 'high' })
|
|
|
|
await assemble(ctx, {
|
|
model: 'deepseek-v4-flash',
|
|
reasoningEffort: ReasoningEffortId('off'),
|
|
messages: [],
|
|
})
|
|
expect(server.requests[1]).toMatchObject({ thinking: { type: 'disabled' } })
|
|
expect(server.requests[1]).not.toHaveProperty('reasoning_effort')
|
|
|
|
await expect(assemble(ctx, {
|
|
model: 'deepseek-v4-flash',
|
|
reasoningEffort: ReasoningEffortId('xhigh'),
|
|
messages: [],
|
|
})).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' })
|
|
expect(server.requests).toHaveLength(2)
|
|
})
|
|
|
|
it('preserves omitted profile options when constructing the adapter directly', async () => {
|
|
const server = await mockServer([{ events: textEvents }])
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
ctx.llm.registerAdapter(['deepseek'], adapterOf({
|
|
deepseek: { apiKeyEnv: 'PI_TEST_KEY', baseURL: server.url },
|
|
}))
|
|
|
|
const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
|
|
|
|
expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
|
|
})
|
|
|
|
it('rejects stop sequences rather than silently ignoring them', async () => {
|
|
const server = await mockServer([])
|
|
const ctx = await harness(server.url)
|
|
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [], stop: ['END'] }))
|
|
.rejects.toMatchObject({ code: 'UNSUPPORTED_OPTION' })
|
|
expect(server.requests).toEqual([])
|
|
})
|
|
|
|
it('rejects unknown catalog models before network I/O', async () => {
|
|
const server = await mockServer([])
|
|
const ctx = await harness(server.url)
|
|
await expect(assemble(ctx, { model: 'not-in-the-catalog', messages: [] }))
|
|
.rejects.toMatchObject({ code: 'UNKNOWN_MODEL' })
|
|
expect(server.requests).toEqual([])
|
|
})
|
|
|
|
it('uses the catalog API implementation, including OpenAI Responses', async () => {
|
|
const server = await mockServer([{ status: 401, body: JSON.stringify({ error: { message: 'expected mock failure' } }) }])
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
await ctx.plugin(LlmPiAi, {
|
|
providers: { openai: { apiKeyEnv: 'PI_TEST_KEY', baseURL: `${server.url}/v1` } },
|
|
})
|
|
const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] })
|
|
expect(result.finish.kind).toBe('error')
|
|
expect(server.paths).toEqual(['/v1/responses'])
|
|
})
|
|
|
|
it('forces one wire request for an SDK-retryable provider failure', async () => {
|
|
const server = await mockServer([
|
|
{
|
|
status: 429,
|
|
headers: { 'retry-after-ms': '1' },
|
|
body: JSON.stringify({ error: { message: 'retryable provider failure' } }),
|
|
},
|
|
{ status: 500, body: JSON.stringify({ error: { message: 'hidden SDK retry' } }) },
|
|
{ status: 500, body: JSON.stringify({ error: { message: 'second hidden SDK retry' } }) },
|
|
])
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
await ctx.plugin(LlmPiAi, {
|
|
providers: { openai: { apiKeyEnv: 'PI_TEST_KEY', baseURL: `${server.url}/v1` } },
|
|
})
|
|
|
|
const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] })
|
|
|
|
expect(result.finish).toMatchObject({ kind: 'error' })
|
|
expect(server.paths).toEqual(['/v1/responses'])
|
|
})
|
|
|
|
it('uses OpenAI Responses against an Azure project v1 path with its API key header', async () => {
|
|
const server = await mockServer([{ status: 401, body: JSON.stringify({ error: { message: 'expected mock failure' } }) }])
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
await ctx.plugin(LlmPiAi, {
|
|
providers: {
|
|
openai: {
|
|
apiKeyEnv: 'PI_TEST_KEY',
|
|
baseURL: `${server.url}/api/projects/openai/openai/v1`,
|
|
headers: { 'api-key': 'test-key', Authorization: '' },
|
|
},
|
|
},
|
|
})
|
|
const result = await assemble(ctx, { provider: 'openai', model: 'gpt-5.5', messages: [] })
|
|
expect(result.finish.kind).toBe('error')
|
|
expect(server.paths).toEqual(['/api/projects/openai/openai/v1/responses'])
|
|
expect(server.headers[0]?.['api-key']).toBe('test-key')
|
|
expect(server.headers[0]?.authorization).toBe('')
|
|
})
|
|
|
|
it.each([
|
|
[401, 'AUTH'],
|
|
[400, 'INVALID_REQUEST'],
|
|
[429, 'RATE_LIMIT'],
|
|
[500, 'SERVER'],
|
|
] as const)('maps HTTP %s failures to %s', async (status, code) => {
|
|
const server = await mockServer([{ status, body: JSON.stringify({ error: { message: `provider ${status}` } }) }])
|
|
const ctx = await harness(server.url)
|
|
const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
|
|
expect(result.finish).toMatchObject({ kind: 'error', failure: { code } })
|
|
expect(server.paths).toEqual(['/chat/completions'])
|
|
})
|
|
|
|
it('uses the resolved catalog context window for usage-based overflow detection', async () => {
|
|
const model = getBuiltinModels('deepseek').find(candidate => candidate.id === 'deepseek-v4-flash')
|
|
if (model === undefined) throw new Error('deepseek-v4-flash missing from pi-ai test catalog')
|
|
const events = [
|
|
'{"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}',
|
|
JSON.stringify({
|
|
choices: [{ delta: {}, index: 0, finish_reason: 'stop' }],
|
|
usage: { prompt_tokens: model.contextWindow + 1, completion_tokens: 0 },
|
|
}),
|
|
'[DONE]',
|
|
]
|
|
const server = await mockServer([{ events }])
|
|
const ctx = await harness(server.url)
|
|
|
|
const result = await assemble(ctx, { model: model.id, messages: [] })
|
|
|
|
expect(result.finish).toEqual({
|
|
kind: 'error',
|
|
failure: {
|
|
message: `pi-ai detected context overflow for model "${model.id}"`,
|
|
code: CONTEXT_WINDOW_EXCEEDED_CODE,
|
|
},
|
|
})
|
|
})
|
|
|
|
it('stops the SDK request when the adapter idle watchdog expires', async () => {
|
|
const server = await mockServer([{ events: textEvents, delayMs: 200 }])
|
|
const ctx = await harness(server.url, { streamIdleTimeoutMs: 20 })
|
|
|
|
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
|
|
.rejects.toMatchObject({ code: 'TIMEOUT' })
|
|
await Promise.race([
|
|
server.responseClosed,
|
|
new Promise<never>((_resolve, reject) => {
|
|
setTimeout(() => { reject(new Error('SDK request did not close after idle timeout')) }, 100)
|
|
}),
|
|
])
|
|
|
|
expect(server.paths).toEqual(['/chat/completions'])
|
|
expect(server.closedResponses).toBe(1)
|
|
})
|
|
})
|
|
|
|
describe('provider profile lifecycle', () => {
|
|
it('keeps adapter helpers off the package root', () => {
|
|
for (const helper of [
|
|
'resolveProfiles',
|
|
'toPiContext',
|
|
'toPiReplayState',
|
|
'toPiAssistant',
|
|
'mapStopReason',
|
|
'mapUsage',
|
|
'toStreamChunks',
|
|
]) expect(LlmPiAi).not.toHaveProperty(helper)
|
|
})
|
|
|
|
it('registers every profile atomically and unregisters on dispose', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
const fiber = await ctx.plugin(LlmPiAi, {
|
|
providers: {
|
|
openai: {
|
|
retryPolicy: {
|
|
mode: 'always',
|
|
backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 },
|
|
},
|
|
},
|
|
anthropic: {},
|
|
},
|
|
})
|
|
expect(ctx.llm.listProviders()).toEqual([
|
|
{ id: 'openai', name: 'openai' },
|
|
{ id: 'anthropic', name: 'anthropic' },
|
|
])
|
|
expect(ctx.llm.providerRetryPolicy('openai')).toEqual({
|
|
mode: 'always',
|
|
initialDelayMs: 25,
|
|
maxDelayMs: 100,
|
|
jitterRatio: 0.2,
|
|
})
|
|
expect(ctx.llm.providerRetryPolicy('anthropic')).toMatchObject({
|
|
mode: 'normal',
|
|
maxRetries: 2,
|
|
})
|
|
await fiber.dispose()
|
|
expect(ctx.llm.listProviders()).toEqual([])
|
|
})
|
|
|
|
it('exposes the installed pi-ai model catalog through provider-neutral metadata', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
await ctx.plugin(LlmPiAi, { providers: { openai: {} } })
|
|
const models = await ctx.llm.listModels('openai')
|
|
expect(models.find(model => model.id === 'gpt-4.1')).toEqual({
|
|
provider: 'openai', id: 'gpt-4.1', name: 'GPT-4.1',
|
|
})
|
|
expect(models.every(model => model.provider === 'openai')).toBe(true)
|
|
const info = await ctx.llm.resolveModelInfo('openai', 'gpt-4.1')
|
|
expect(typeof info.context?.contextWindow).toBe('number')
|
|
})
|
|
|
|
it('exposes pi-ai model thinking levels verbatim without inventing a provider default', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
await ctx.plugin(LlmPiAi, {
|
|
providers: { deepseek: {}, openai: {} },
|
|
})
|
|
|
|
await expect(ctx.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
|
|
.resolves.toMatchObject({
|
|
reasoning: {
|
|
efforts: [
|
|
{ id: ReasoningEffortId('off'), name: 'Off' },
|
|
{ id: ReasoningEffortId('high'), name: 'High' },
|
|
{ id: ReasoningEffortId('max'), name: 'Max' },
|
|
],
|
|
},
|
|
})
|
|
const extended = await ctx.llm.resolveModelInfo('openai', 'gpt-5.6-sol')
|
|
expect(extended.reasoning?.efforts.map(effort => effort.id)).toEqual([
|
|
ReasoningEffortId('off'),
|
|
ReasoningEffortId('low'),
|
|
ReasoningEffortId('medium'),
|
|
ReasoningEffortId('high'),
|
|
ReasoningEffortId('xhigh'),
|
|
ReasoningEffortId('max'),
|
|
])
|
|
await expect(ctx.llm.resolveModelInfo('openai', 'gpt-4.1'))
|
|
.resolves.toMatchObject({
|
|
reasoning: {
|
|
efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }],
|
|
},
|
|
})
|
|
})
|
|
|
|
it('uses a supported profile reasoning value as the model default and rejects an unsupported one', async () => {
|
|
const supported = new Context()
|
|
await supported.plugin(LlmService)
|
|
await supported.plugin(LlmPiAi, {
|
|
providers: { deepseek: { reasoning: 'max' } },
|
|
})
|
|
await expect(supported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
|
|
.resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('max') } })
|
|
|
|
const unsupported = new Context()
|
|
await unsupported.plugin(LlmService)
|
|
await unsupported.plugin(LlmPiAi, {
|
|
providers: { deepseek: { reasoning: 'medium' } },
|
|
})
|
|
await expect(unsupported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
|
|
.rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' })
|
|
|
|
const disabled = new Context()
|
|
await disabled.plugin(LlmService)
|
|
await disabled.plugin(LlmPiAi, {
|
|
providers: { deepseek: { reasoning: 'off' } },
|
|
})
|
|
await expect(disabled.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
|
|
.resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('off') } })
|
|
})
|
|
|
|
it('accepts absent credentials for pi-ai ambient authentication', async () => {
|
|
vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key')
|
|
const server = await mockServer([{ events: textEvents }])
|
|
// A profile that names no reference at all is the one case that defers to
|
|
// pi-ai's own provider-native discovery.
|
|
const ctx = await harness(server.url, { apiKeyEnv: undefined })
|
|
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
|
|
expect(server.headers[0]?.authorization).toBe('Bearer ambient-key')
|
|
})
|
|
|
|
it('falls back to the ambient environment for apiKeyEnv without the credentials seam', async () => {
|
|
vi.stubEnv('PI_CUSTOM_REF_KEY', 'custom-ref-key')
|
|
const server = await mockServer([{ events: textEvents }])
|
|
const ctx = await harness(server.url, { apiKey: undefined, apiKeyEnv: 'PI_CUSTOM_REF_KEY' })
|
|
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
|
|
expect(server.headers[0]?.authorization).toBe('Bearer custom-ref-key')
|
|
})
|
|
|
|
it('fails a named-but-missing apiKeyEnv instead of using another ambient key', async () => {
|
|
// The exact confusion this guards: the named reference is empty while an
|
|
// unrelated provider key sits in the environment. Deferring to pi-ai's own
|
|
// discovery here would authenticate as another tenant.
|
|
vi.stubEnv('PI_CUSTOM_REF_KEY', '')
|
|
vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key')
|
|
const server = await mockServer([{ events: textEvents }])
|
|
const ctx = await harness(server.url, { apiKey: undefined, apiKeyEnv: 'PI_CUSTOM_REF_KEY' })
|
|
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
|
|
.rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' })
|
|
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
|
|
.rejects.toThrow(/provider route "deepseek".*PI_CUSTOM_REF_KEY/s)
|
|
expect(server.requests).toHaveLength(0)
|
|
})
|
|
|
|
it('validates empty, unknown, legacy-shaped, and explicitly blank profiles', () => {
|
|
// Empty and omitted dicts are the dormant zero-route posture, not errors.
|
|
expect(resolveProfiles({}).size).toBe(0)
|
|
expect(resolveProfiles(undefined).size).toBe(0)
|
|
expect(() => resolveProfiles({ '': {} })).toThrow(/non-empty/)
|
|
expect(() => resolveProfiles({ 'not-real': {} })).toThrow(/unknown/)
|
|
// The pre-release array shape and its per-profile provider field fail
|
|
// loud with migration directions instead of half-working.
|
|
expect(() => resolveProfiles([{ provider: 'openai' }] as never)).toThrow(/dict keyed by provider/)
|
|
expect(() => resolveProfiles({ openai: { provider: 'openai' } as never })).toThrow(/moved to the providers dict key/)
|
|
expect(() => resolveProfiles({ openai: { baseURL: '' } })).toThrow(/empty baseURL/)
|
|
expect(() => resolveProfiles({ openai: { apiKeyEnv: 'not-a-var!' } })).toThrow(/must match/)
|
|
})
|
|
|
|
it.each(['maxRetries', 'maxRetryDelayMs'] as const)(
|
|
'rejects removed profile field %s instead of silently restoring hidden SDK retries',
|
|
async (field) => {
|
|
const legacy = { [field]: 2 }
|
|
expect(() => resolveProfiles({ openai: legacy })).toThrow(/removed.*agent recovery/i)
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
await expect(ctx.plugin(LlmPiAi, { providers: { openai: legacy } }))
|
|
.rejects.toThrow(/removed.*agent recovery/i)
|
|
},
|
|
)
|
|
|
|
it('rejects invalid stream tunables at plugin load', async () => {
|
|
const invalid = [
|
|
{ timeoutMs: -1 },
|
|
{ websocketConnectTimeoutMs: -1 },
|
|
{ streamIdleTimeoutMs: 0 },
|
|
{ streamIdleTimeoutMs: Number.NaN },
|
|
{ streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 },
|
|
]
|
|
for (const entry of invalid) {
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
await expect(ctx.plugin(LlmPiAi, { providers: { openai: { ...entry } } }))
|
|
.rejects.toThrow()
|
|
}
|
|
})
|
|
|
|
it('rejects invalid nested retryPolicy at the provider-profile boundary', async () => {
|
|
expect(() => resolveProfiles({
|
|
openai: { retryPolicy: { mode: 'always', backoff: { jitterRatio: -1 } } },
|
|
})).toThrow(/retryPolicy\.backoff\.jitterRatio/)
|
|
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
await expect(ctx.plugin(LlmPiAi, {
|
|
providers: { openai: { retryPolicy: { mode: 'normal', maxRetries: -1 } } },
|
|
})).rejects.toThrow(/retryPolicy/)
|
|
expect(ctx.llm.listProviders()).toEqual([])
|
|
})
|
|
|
|
it('constructs the adapter directly and rejects routes it does not own', async () => {
|
|
const adapter = adapterOf({ openai: {} })
|
|
await expect(adapter.listModels('anthropic')).rejects.toMatchObject({ code: 'NO_ADAPTER' })
|
|
await expect(adapter.resolveModel('anthropic', 'claude-sonnet-4'))
|
|
.rejects.toMatchObject({ code: 'NO_ADAPTER' })
|
|
await expect(adapter.resolveModel('openai', 'not-a-catalog-model'))
|
|
.rejects.toMatchObject({ code: 'UNKNOWN_MODEL' })
|
|
await expect((async () => {
|
|
for await (const _chunk of adapter.stream({ provider: 'anthropic', model: 'claude-sonnet-4', messages: [] })) { /* drain */ }
|
|
})()).rejects.toMatchObject({ code: 'NO_ADAPTER' })
|
|
expect(new LlmError('x', 'X')).toBeInstanceOf(Error)
|
|
})
|
|
|
|
it('validates profiles at the shared resolver boundary', () => {
|
|
expect(() => resolveProfiles({
|
|
openai: { streamIdleTimeoutMs: 0 },
|
|
})).toThrow(/streamIdleTimeoutMs.*positive finite/)
|
|
expect(() => resolveProfiles({
|
|
openai: { streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 },
|
|
})).toThrow(/streamIdleTimeoutMs.*no greater/)
|
|
})
|
|
})
|
|
|
|
describe('abort wiring', () => {
|
|
it('preserves an unknown pre-dispatch adapter Error exactly', async () => {
|
|
const original = new Error('SDK context conversion exploded')
|
|
const message = Object.defineProperty({}, 'role', {
|
|
get() { throw original },
|
|
})
|
|
const adapter = adapterOf({ deepseek: {} })
|
|
const drain = async (): Promise<void> => {
|
|
for await (const _chunk of adapter.stream({
|
|
provider: 'deepseek',
|
|
model: 'deepseek-v4-flash',
|
|
messages: [message as never],
|
|
})) { /* drain */ }
|
|
}
|
|
|
|
await expect(drain()).rejects.toBe(original)
|
|
})
|
|
|
|
it('lets a concurrent caller abort classify a pre-dispatch adapter failure', async () => {
|
|
const controller = new AbortController()
|
|
const original = new Error('conversion lost its caller')
|
|
const message = Object.defineProperty({}, 'role', {
|
|
get() {
|
|
controller.abort('caller cancelled during conversion')
|
|
throw original
|
|
},
|
|
})
|
|
const adapter = adapterOf({ deepseek: {} })
|
|
const drain = async (): Promise<void> => {
|
|
for await (const _chunk of adapter.stream({
|
|
provider: 'deepseek',
|
|
model: 'deepseek-v4-flash',
|
|
messages: [message as never],
|
|
signal: controller.signal,
|
|
})) { /* drain */ }
|
|
}
|
|
|
|
await expect(drain()).rejects.toMatchObject({ code: 'ABORTED', cause: original })
|
|
})
|
|
|
|
it('resolves catalog endpoints without an override before honoring pre-abort', async () => {
|
|
const adapter = adapterOf({ deepseek: {} })
|
|
const controller = new AbortController()
|
|
controller.abort('already stopped')
|
|
const chunks = []
|
|
for await (const chunk of adapter.stream({
|
|
provider: 'deepseek',
|
|
model: 'deepseek-v4-flash',
|
|
messages: [],
|
|
signal: controller.signal,
|
|
})) chunks.push(chunk)
|
|
expect(chunks.at(-1)).toMatchObject({ type: 'finish', reason: { kind: 'aborted' } })
|
|
})
|
|
|
|
it('honors a pre-aborted caller signal', async () => {
|
|
const server = await mockServer([{ events: textEvents, delayMs: 20 }])
|
|
const ctx = await harness(server.url)
|
|
const controller = new AbortController()
|
|
controller.abort('already stopped')
|
|
const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [], signal: controller.signal })
|
|
expect(result.finish.kind).toBe('aborted')
|
|
})
|
|
|
|
it('forwards an abort that arrives while provider streaming is active', async () => {
|
|
const server = await mockServer([{ events: textEvents, delayMs: 30 }])
|
|
const ctx = await harness(server.url)
|
|
const controller = new AbortController()
|
|
const resultPromise = assemble(ctx, {
|
|
model: 'deepseek-v4-flash', messages: [], signal: controller.signal,
|
|
})
|
|
setTimeout(() => { controller.abort('stopped during stream') }, 10)
|
|
const result = await resultPromise
|
|
expect(result.finish.kind).toBe('aborted')
|
|
})
|
|
|
|
it('aborts upstream when a consumer stops early', async () => {
|
|
const server = await mockServer([{ events: textEvents, delayMs: 30 }])
|
|
const ctx = await harness(server.url)
|
|
for await (const chunk of ctx.llm.stream({ provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })) {
|
|
if (chunk.type === 'block-start') break
|
|
}
|
|
await new Promise(resolve => setTimeout(resolve, 20))
|
|
expect(server.requests).toHaveLength(1)
|
|
})
|
|
})
|