Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # docs/architecture.i18n.yaml # docs/cordis-catalog/services.md # docs/core-data-structures/core.i18n.yaml # docs/module-graph.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/README.i18n.yaml # packages/llm/llm-pi-ai/README.i18n.yaml # packages/llm/llm-pi-ai/package.json # packages/llm/llm-pi-ai/src/adapter.ts # packages/llm/llm-pi-ai/src/index.ts # packages/llm/llm-pi-ai/tests/adapter.spec.ts # packages/llm/llm-pi-ai/tsconfig.json # packages/llm/llm/README.i18n.yaml # pnpm-lock.yaml # tsconfig.host.json
This commit is contained in:
@@ -23,12 +23,13 @@ async function harness(_model: string, config: Partial<PiAiProviderProfile> = {}
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmPiAi, {
|
||||
providers: [{
|
||||
provider: 'deepseek',
|
||||
...process.env.DEEPSEEK_API_KEY === undefined ? {} : { apiKey: process.env.DEEPSEEK_API_KEY },
|
||||
...process.env.DEEPSEEK_BASE_URL === undefined ? {} : { baseURL: process.env.DEEPSEEK_BASE_URL },
|
||||
...config,
|
||||
}],
|
||||
providers: {
|
||||
deepseek: {
|
||||
...process.env.DEEPSEEK_API_KEY === undefined ? {} : { apiKey: process.env.DEEPSEEK_API_KEY },
|
||||
...process.env.DEEPSEEK_BASE_URL === undefined ? {} : { baseURL: process.env.DEEPSEEK_BASE_URL },
|
||||
...config,
|
||||
},
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
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 { AttachmentId, AttachmentStore } from '@deepseek-ai/dsh-attachment'
|
||||
@@ -16,85 +14,13 @@ 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'
|
||||
|
||||
interface MockServer {
|
||||
url: string
|
||||
paths: string[]
|
||||
requests: unknown[]
|
||||
headers: IncomingMessage['headers'][]
|
||||
readonly closedResponses: number
|
||||
responseClosed: Promise<void>
|
||||
}
|
||||
|
||||
const servers: Server[] = []
|
||||
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs()
|
||||
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
|
||||
await closeMockServers()
|
||||
})
|
||||
|
||||
async function mockServer(script: {
|
||||
status?: number
|
||||
events?: string[]
|
||||
body?: string
|
||||
delayMs?: number
|
||||
headers?: Record<string, string>
|
||||
}[]): Promise<MockServer> {
|
||||
const paths: string[] = []
|
||||
const requests: unknown[] = []
|
||||
const headers: IncomingMessage['headers'][] = []
|
||||
let closedResponses = 0
|
||||
const responseClosed = Promise.withResolvers<undefined>()
|
||||
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
|
||||
response.on('close', () => {
|
||||
closedResponses += 1
|
||||
responseClosed.resolve(undefined)
|
||||
})
|
||||
let body = ''
|
||||
request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') })
|
||||
request.on('end', () => {
|
||||
paths.push(request.url ?? '')
|
||||
requests.push(body.length === 0 ? undefined : JSON.parse(body))
|
||||
headers.push(request.headers)
|
||||
const behavior = script.shift() ?? { status: 500, body: 'script exhausted' }
|
||||
if (behavior.status !== undefined && behavior.status !== 200) {
|
||||
response.writeHead(behavior.status, { 'content-type': 'application/json', ...behavior.headers })
|
||||
response.end(behavior.body ?? '{}')
|
||||
return
|
||||
}
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
let index = 0
|
||||
const writeNext = (): void => {
|
||||
const event = behavior.events?.[index++]
|
||||
if (event === undefined) { response.end(); return }
|
||||
response.write(`data: ${event}\n\n`)
|
||||
if (behavior.delayMs === undefined) writeNext()
|
||||
else setTimeout(writeNext, behavior.delayMs)
|
||||
}
|
||||
writeNext()
|
||||
})
|
||||
})
|
||||
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}`,
|
||||
paths,
|
||||
requests,
|
||||
headers,
|
||||
responseClosed: responseClosed.promise,
|
||||
get closedResponses() { return closedResponses },
|
||||
}
|
||||
}
|
||||
|
||||
const textEvents = [
|
||||
'{"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}',
|
||||
'{"choices":[{"delta":{"content":"hello"},"index":0,"finish_reason":null}]}',
|
||||
'{"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
|
||||
'[DONE]',
|
||||
]
|
||||
|
||||
const IMAGE_REF: ImageAttachmentRef = {
|
||||
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
|
||||
mediaType: 'image/png',
|
||||
@@ -107,11 +33,19 @@ async function harness(baseURL: string, overrides: Record<string, unknown> = {})
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmPiAi, {
|
||||
providers: [{ provider: 'deepseek', apiKey: 'test-key', baseURL, ...overrides }],
|
||||
providers: { deepseek: { apiKey: 'test-key', baseURL, ...overrides } },
|
||||
})
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Direct adapter over the real profile resolver, with literal-key resolution. */
|
||||
function adapterOf(providers: Record<string, LlmPiAi.PiAiProviderProfile>): PiAiAdapter {
|
||||
return new PiAiAdapter({
|
||||
profiles: () => resolveProfiles(providers),
|
||||
resolveApiKey: (_provider, profile) => Promise.resolve(profile.apiKey),
|
||||
})
|
||||
}
|
||||
|
||||
describe('PiAiAdapter provider routing', () => {
|
||||
it('resolves a catalog model dynamically and uses a private endpoint', async () => {
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
@@ -197,8 +131,8 @@ describe('PiAiAdapter provider routing', () => {
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['deepseek'], new PiAiAdapter({
|
||||
profiles: [{ provider: 'deepseek', apiKey: 'test-key', baseURL: server.url }],
|
||||
ctx.llm.registerAdapter(['deepseek'], adapterOf({
|
||||
deepseek: { apiKey: 'test-key', baseURL: server.url },
|
||||
}))
|
||||
|
||||
const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
|
||||
@@ -227,7 +161,7 @@ describe('PiAiAdapter provider routing', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmPiAi, {
|
||||
providers: [{ provider: 'openai', apiKey: 'test-key', baseURL: `${server.url}/v1` }],
|
||||
providers: { openai: { apiKey: 'test-key', baseURL: `${server.url}/v1` } },
|
||||
})
|
||||
const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] })
|
||||
expect(result.finish.kind).toBe('error')
|
||||
@@ -272,7 +206,7 @@ describe('PiAiAdapter provider routing', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmPiAi, {
|
||||
providers: [{ provider: 'openai', apiKey: 'test-key', baseURL: `${server.url}/v1` }],
|
||||
providers: { openai: { apiKey: 'test-key', baseURL: `${server.url}/v1` } },
|
||||
})
|
||||
await ctx.plugin(LateAttachmentStore)
|
||||
|
||||
@@ -303,7 +237,7 @@ describe('PiAiAdapter provider routing', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmPiAi, {
|
||||
providers: [{ provider: 'openai', apiKey: 'test-key', baseURL: `${server.url}/v1` }],
|
||||
providers: { openai: { apiKey: 'test-key', baseURL: `${server.url}/v1` } },
|
||||
})
|
||||
|
||||
const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] })
|
||||
@@ -317,12 +251,13 @@ describe('PiAiAdapter provider routing', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmPiAi, {
|
||||
providers: [{
|
||||
provider: 'openai',
|
||||
apiKey: 'test-key',
|
||||
baseURL: `${server.url}/api/projects/openai/openai/v1`,
|
||||
headers: { 'api-key': 'test-key', Authorization: '' },
|
||||
}],
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: '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')
|
||||
@@ -404,16 +339,15 @@ describe('provider profile lifecycle', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
const fiber = await ctx.plugin(LlmPiAi, {
|
||||
providers: [
|
||||
{
|
||||
provider: 'openai',
|
||||
providers: {
|
||||
openai: {
|
||||
retryPolicy: {
|
||||
mode: 'always',
|
||||
backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 },
|
||||
},
|
||||
},
|
||||
{ provider: 'anthropic' },
|
||||
],
|
||||
anthropic: {},
|
||||
},
|
||||
})
|
||||
expect(ctx.llm.listProviders()).toEqual([
|
||||
{ id: 'openai', name: 'openai' },
|
||||
@@ -436,7 +370,7 @@ describe('provider profile lifecycle', () => {
|
||||
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: [{ provider: 'openai' }] })
|
||||
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',
|
||||
@@ -451,7 +385,7 @@ describe('provider profile lifecycle', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmPiAi, {
|
||||
providers: [{ provider: 'deepseek' }, { provider: 'openai' }],
|
||||
providers: { deepseek: {}, openai: {} },
|
||||
})
|
||||
|
||||
await expect(ctx.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
|
||||
@@ -485,7 +419,7 @@ describe('provider profile lifecycle', () => {
|
||||
const supported = new Context()
|
||||
await supported.plugin(LlmService)
|
||||
await supported.plugin(LlmPiAi, {
|
||||
providers: [{ provider: 'deepseek', reasoning: 'max' }],
|
||||
providers: { deepseek: { reasoning: 'max' } },
|
||||
})
|
||||
await expect(supported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
|
||||
.resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('max') } })
|
||||
@@ -493,7 +427,7 @@ describe('provider profile lifecycle', () => {
|
||||
const unsupported = new Context()
|
||||
await unsupported.plugin(LlmService)
|
||||
await unsupported.plugin(LlmPiAi, {
|
||||
providers: [{ provider: 'deepseek', reasoning: 'medium' }],
|
||||
providers: { deepseek: { reasoning: 'medium' } },
|
||||
})
|
||||
await expect(unsupported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
|
||||
.rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' })
|
||||
@@ -501,7 +435,7 @@ describe('provider profile lifecycle', () => {
|
||||
const disabled = new Context()
|
||||
await disabled.plugin(LlmService)
|
||||
await disabled.plugin(LlmPiAi, {
|
||||
providers: [{ provider: 'deepseek', reasoning: 'off' }],
|
||||
providers: { deepseek: { reasoning: 'off' } },
|
||||
})
|
||||
await expect(disabled.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
|
||||
.resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('off') } })
|
||||
@@ -515,24 +449,53 @@ describe('provider profile lifecycle', () => {
|
||||
expect(server.headers[0]?.authorization).toBe('Bearer ambient-key')
|
||||
})
|
||||
|
||||
it('validates empty, duplicate, unknown, and explicitly blank profiles', () => {
|
||||
expect(() => resolveProfiles([])).toThrow(/at least one/)
|
||||
expect(() => resolveProfiles([{ provider: '' }])).toThrow(/non-empty/)
|
||||
expect(() => resolveProfiles([{ provider: 'not-real' }])).toThrow(/unknown/)
|
||||
expect(() => resolveProfiles([{ provider: 'openai' }, { provider: 'openai' }])).toThrow(/duplicate/)
|
||||
expect(() => resolveProfiles([{ provider: 'openai', apiKey: '' }])).toThrow(/empty apiKey/)
|
||||
expect(() => resolveProfiles([{ provider: 'openai', apiKey: ' ' }])).toThrow(/empty apiKey/)
|
||||
expect(() => resolveProfiles([{ provider: 'openai', baseURL: '' }])).toThrow(/empty baseURL/)
|
||||
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: { apiKey: '' } })).toThrow(/empty apiKey/)
|
||||
expect(() => resolveProfiles({ openai: { apiKey: ' ' } })).toThrow(/empty apiKey/)
|
||||
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 = { provider: 'openai', [field]: 2 }
|
||||
expect(() => resolveProfiles([legacy as never])).toThrow(/removed.*agent recovery/i)
|
||||
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: [legacy as never] }))
|
||||
await expect(ctx.plugin(LlmPiAi, { providers: { openai: legacy } }))
|
||||
.rejects.toThrow(/removed.*agent recovery/i)
|
||||
},
|
||||
)
|
||||
@@ -548,30 +511,26 @@ describe('provider profile lifecycle', () => {
|
||||
for (const entry of invalid) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await expect(ctx.plugin(LlmPiAi, { providers: [{ provider: 'openai', ...entry }] }))
|
||||
await expect(ctx.plugin(LlmPiAi, { providers: { openai: { ...entry } } }))
|
||||
.rejects.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects invalid nested retryPolicy at the provider-profile boundary', async () => {
|
||||
expect(() => resolveProfiles([{
|
||||
provider: 'openai',
|
||||
retryPolicy: { mode: 'always', backoff: { jitterRatio: -1 } },
|
||||
}])).toThrow(/retryPolicy\.backoff\.jitterRatio/)
|
||||
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: [{
|
||||
provider: 'openai',
|
||||
retryPolicy: { mode: 'normal', maxRetries: -1 },
|
||||
}],
|
||||
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 = new PiAiAdapter({ profiles: [{ provider: 'openai' }] })
|
||||
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' })
|
||||
@@ -584,9 +543,7 @@ describe('provider profile lifecycle', () => {
|
||||
})
|
||||
|
||||
it('rejects unsupported or unresolved image input before provider I/O', async () => {
|
||||
const adapter = new PiAiAdapter({
|
||||
profiles: [{ provider: 'openai' }, { provider: 'deepseek' }],
|
||||
})
|
||||
const adapter = adapterOf({ openai: {}, deepseek: {} })
|
||||
const drain = async (options: Parameters<PiAiAdapter['stream']>[0]): Promise<void> => {
|
||||
for await (const _chunk of adapter.stream(options)) { /* drain */ }
|
||||
}
|
||||
@@ -625,12 +582,12 @@ describe('provider profile lifecycle', () => {
|
||||
})).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' })
|
||||
})
|
||||
|
||||
it('validates direct-constructor profiles at the embedding boundary', () => {
|
||||
expect(() => new PiAiAdapter({
|
||||
profiles: [{ provider: 'openai', streamIdleTimeoutMs: 0 }],
|
||||
it('validates profiles at the shared resolver boundary', () => {
|
||||
expect(() => resolveProfiles({
|
||||
openai: { streamIdleTimeoutMs: 0 },
|
||||
})).toThrow(/streamIdleTimeoutMs.*positive finite/)
|
||||
expect(() => new PiAiAdapter({
|
||||
profiles: [{ provider: 'openai', streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 }],
|
||||
expect(() => resolveProfiles({
|
||||
openai: { streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 },
|
||||
})).toThrow(/streamIdleTimeoutMs.*no greater/)
|
||||
})
|
||||
})
|
||||
@@ -641,7 +598,7 @@ describe('abort wiring', () => {
|
||||
const message = Object.defineProperty({}, 'content', {
|
||||
get() { throw original },
|
||||
})
|
||||
const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] })
|
||||
const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } })
|
||||
const drain = async (): Promise<void> => {
|
||||
for await (const _chunk of adapter.stream({
|
||||
provider: 'deepseek',
|
||||
@@ -662,7 +619,7 @@ describe('abort wiring', () => {
|
||||
throw original
|
||||
},
|
||||
})
|
||||
const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] })
|
||||
const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } })
|
||||
const drain = async (): Promise<void> => {
|
||||
for await (const _chunk of adapter.stream({
|
||||
provider: 'deepseek',
|
||||
@@ -676,7 +633,7 @@ describe('abort wiring', () => {
|
||||
})
|
||||
|
||||
it('resolves catalog endpoints without an override before honoring pre-abort', async () => {
|
||||
const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] })
|
||||
const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } })
|
||||
const controller = new AbortController()
|
||||
controller.abort('already stopped')
|
||||
const chunks = []
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import LlmService, { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import { CredentialsLocal } from '@deepseek-ai/dsh-credentials-local'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { SettingsLocal } from '@deepseek-ai/dsh-settings-local'
|
||||
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
|
||||
import { assemble } from './assemble.ts'
|
||||
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
|
||||
|
||||
const NS = settingsNamespace('llm-pi-ai')
|
||||
|
||||
/** Minimal foreign adapter: only needs to own a route the pi-ai plugin then wants. */
|
||||
class StubAdapter extends LlmAdapter {
|
||||
|
||||
override async * stream(): AsyncIterable<never> {
|
||||
throw new Error('stub adapter must never stream')
|
||||
}
|
||||
}
|
||||
|
||||
const cleanups: Array<() => Promise<void>> = []
|
||||
|
||||
afterEach(async () => {
|
||||
while (cleanups.length > 0) await cleanups.pop()!()
|
||||
await closeMockServers()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
async function home(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-pi-dynamic-'))
|
||||
cleanups.push(() => rm(dir, { recursive: true, force: true }))
|
||||
return dir
|
||||
}
|
||||
|
||||
/** Real dynamic composition mirroring the deepseek twin's harness. */
|
||||
async function boot(dir: string, config: LlmPiAi.Config): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
cleanups.push(async () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SettingsLocal, { path: join(dir, 'settings.yaml'), watch: false })
|
||||
await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false })
|
||||
await ctx.plugin(LlmPiAi, config)
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('request-level dynamic profiles', () => {
|
||||
it('mounts bare and dormant, then registers routes the moment settings supply providers', async () => {
|
||||
vi.stubEnv('PI_DYNAMIC_KEY', '')
|
||||
const dir = await home()
|
||||
await writeFile(join(dir, '.env'), 'PI_DYNAMIC_KEY=pk-from-settings\n')
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
// The exact product posture: `- id: llm-pi-ai` with no config at all.
|
||||
const ctx = await boot(dir, {})
|
||||
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
await ctx.settings.update(NS, {
|
||||
providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } },
|
||||
})
|
||||
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek'])
|
||||
await expect(ctx.llm.listModels('deepseek')).resolves.not.toHaveLength(0)
|
||||
|
||||
const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })
|
||||
expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
|
||||
expect(server.headers[0]?.authorization).toBe('Bearer pk-from-settings')
|
||||
|
||||
// Emptying the user layer returns the adapter to its dormant state.
|
||||
await ctx.settings.replace(NS, {})
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
})
|
||||
|
||||
it('adds a provider route from settings and drops it when the user layer resets', async () => {
|
||||
const dir = await home()
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
const ctx = await boot(dir, {
|
||||
providers: { openai: { apiKey: 'k', baseURL: 'http://127.0.0.1:1/v1' } },
|
||||
})
|
||||
|
||||
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai'])
|
||||
await ctx.settings.update(NS, {
|
||||
providers: { deepseek: { apiKey: 'live-key', baseURL: server.url } },
|
||||
})
|
||||
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai', 'deepseek'])
|
||||
|
||||
const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })
|
||||
expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
|
||||
expect(server.headers[0]?.authorization).toBe('Bearer live-key')
|
||||
|
||||
// Reset the user layer: the settings-born route unregisters, the
|
||||
// composition route stays.
|
||||
await ctx.settings.replace(NS, {})
|
||||
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai'])
|
||||
await expect(assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }))
|
||||
.rejects.toMatchObject({ code: 'NO_ADAPTER' })
|
||||
})
|
||||
|
||||
it('rotates the per-request credential referenced by apiKeyEnv', async () => {
|
||||
vi.stubEnv('PI_DYNAMIC_KEY', '')
|
||||
const dir = await home()
|
||||
await writeFile(join(dir, '.env'), 'PI_DYNAMIC_KEY=pk-one\n')
|
||||
const server = await mockServer([{ events: textEvents }, { events: textEvents }])
|
||||
const ctx = await boot(dir, {
|
||||
providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } },
|
||||
})
|
||||
|
||||
await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })
|
||||
expect(server.headers[0]?.authorization).toBe('Bearer pk-one')
|
||||
|
||||
await ctx.credentials.set(credentialRef('PI_DYNAMIC_KEY'), 'pk-two')
|
||||
await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })
|
||||
expect(server.headers[1]?.authorization).toBe('Bearer pk-two')
|
||||
})
|
||||
|
||||
it('re-registers routes in place when a captured retry policy changes', async () => {
|
||||
const dir = await home()
|
||||
const ctx = await boot(dir, { providers: { openai: {} } })
|
||||
|
||||
await ctx.settings.update(NS, {
|
||||
providers: {
|
||||
openai: {
|
||||
retryPolicy: { mode: 'always', backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 } },
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(ctx.llm.providerRetryPolicy('openai')).toEqual({
|
||||
mode: 'always',
|
||||
initialDelayMs: 25,
|
||||
maxDelayMs: 100,
|
||||
jitterRatio: 0.2,
|
||||
})
|
||||
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai'])
|
||||
})
|
||||
|
||||
it('keeps the last good profiles when a settings snapshot names an unknown provider', async () => {
|
||||
const dir = await home()
|
||||
const ctx = await boot(dir, { providers: { openai: {} } })
|
||||
|
||||
// Schema-valid but catalog-invalid: the resolver rejects it and the
|
||||
// last good route set keeps serving.
|
||||
await ctx.settings.update(NS, { providers: { 'not-a-real-provider': {} } })
|
||||
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai'])
|
||||
})
|
||||
|
||||
it('keeps serving its routes when a settings-born route collides with another adapter', async () => {
|
||||
const dir = await home()
|
||||
const server = await mockServer([{ events: textEvents }, { events: textEvents }])
|
||||
const ctx = await boot(dir, { providers: { openai: { apiKey: 'pk', baseURL: `${server.url}/v1` } } })
|
||||
// Another adapter owns `anthropic`; the registry must refuse to hand it over.
|
||||
ctx.llm.registerAdapter(['anthropic'], new StubAdapter())
|
||||
|
||||
await ctx.settings.update(NS, {
|
||||
providers: {
|
||||
openai: { apiKey: 'pk', baseURL: `${server.url}/v1` },
|
||||
anthropic: { apiKey: 'other' },
|
||||
},
|
||||
})
|
||||
|
||||
// The conflicting swap was refused whole: the previous route set still
|
||||
// owns openai (an eager dispose would have dropped it), and anthropic
|
||||
// still belongs to its original adapter.
|
||||
expect(ctx.llm.listProviders().map(provider => provider.id).sort()).toEqual(['anthropic', 'openai'])
|
||||
const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] })
|
||||
expect(result.finish.kind).toBe('error')
|
||||
expect(server.paths).toEqual(['/v1/responses'])
|
||||
|
||||
// Reverting to the working configuration re-applies, even though its
|
||||
// facts equal the ones the registry already holds.
|
||||
await ctx.settings.replace(NS, {})
|
||||
expect(ctx.llm.listProviders().map(provider => provider.id).sort()).toEqual(['anthropic', 'openai'])
|
||||
await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] })
|
||||
expect(server.paths).toEqual(['/v1/responses', '/v1/responses'])
|
||||
})
|
||||
|
||||
it('ignores a settings document that merely reorders its provider keys', async () => {
|
||||
const dir = await home()
|
||||
const ctx = await boot(dir, { providers: { openai: {}, anthropic: {} } })
|
||||
const before = ctx.llm.listProviders().map(provider => provider.id)
|
||||
|
||||
// Same routes, different YAML key order: nothing about the registration
|
||||
// changed, so no swap should happen at all.
|
||||
await ctx.settings.update(NS, { providers: { anthropic: {}, openai: {} } })
|
||||
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(before)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Real-composition guard for the dormant pi-ai posture: LlmService,
|
||||
* settings-local, credentials-local, and a bare `llm-pi-ai` row boot from a
|
||||
* test-only cordis.yml through the actual Loader + Include path, an external
|
||||
* edit of settings.yaml registers the route live, and the next request
|
||||
* carries the credential the .env supplies. A hand-mounted `ctx.plugin` cannot
|
||||
* catch Loader export-shape failures, which is why the twin adapter has the
|
||||
* same guard.
|
||||
*/
|
||||
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include from '@cordisjs/plugin-include'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import CredentialsLocal from '@deepseek-ai/dsh-credentials-local'
|
||||
import SettingsLocal from '@deepseek-ai/dsh-settings-local'
|
||||
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
|
||||
import { assemble } from './assemble.ts'
|
||||
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
|
||||
|
||||
let root: string | undefined
|
||||
let context: Context | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await context?.fiber.dispose()
|
||||
context = undefined
|
||||
if (root !== undefined) await rm(root, { recursive: true, force: true })
|
||||
root = undefined
|
||||
await closeMockServers()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
/** Boot the dormant composition: a bare `llm-pi-ai` row with no config at all. */
|
||||
async function loadComposition(): Promise<{ ctx: Context; settingsPath: string }> {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-pi-composition-'))
|
||||
const settingsPath = join(root, 'settings.yaml')
|
||||
await writeFile(settingsPath, '# personal settings\n')
|
||||
await writeFile(join(root, '.env'), 'PI_COMPOSITION_KEY=key-from-store\n')
|
||||
|
||||
const configPath = join(root, 'cordis.yml')
|
||||
await writeFile(configPath, [
|
||||
'- id: llm',
|
||||
" name: 'test-llm-service'",
|
||||
'- id: settings',
|
||||
" name: '@deepseek-ai/dsh-settings-local'",
|
||||
' config:',
|
||||
` path: ${JSON.stringify(settingsPath)}`,
|
||||
' debounceMs: 10',
|
||||
'- id: credentials',
|
||||
" name: '@deepseek-ai/dsh-credentials-local'",
|
||||
' config:',
|
||||
` path: ${JSON.stringify(join(root, '.env'))}`,
|
||||
' debounceMs: 10',
|
||||
'- id: llm-pi-ai',
|
||||
" name: '@deepseek-ai/dsh-llm-pi-ai'",
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
const ctx = new Context()
|
||||
context = ctx
|
||||
ctx.baseUrl = pathToFileURL(root).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
const modules = new Map<string, unknown>([
|
||||
['test-llm-service', LlmService],
|
||||
['@deepseek-ai/dsh-settings-local', SettingsLocal],
|
||||
['@deepseek-ai/dsh-credentials-local', CredentialsLocal],
|
||||
['@deepseek-ai/dsh-llm-pi-ai', LlmPiAi],
|
||||
])
|
||||
ctx.loader.internal = {
|
||||
version: 'v2',
|
||||
async import(specifier: string) {
|
||||
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
|
||||
return modules.get(specifier)
|
||||
},
|
||||
} as unknown as NonNullable<typeof ctx.loader.internal>
|
||||
await ctx.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: { path: pathToFileURL(configPath).href },
|
||||
})
|
||||
await ctx.loader.await()
|
||||
return { ctx, settingsPath }
|
||||
}
|
||||
|
||||
describe('llm-pi-ai real dormant composition', () => {
|
||||
it('boots with zero routes and registers one the moment settings supply a profile', async () => {
|
||||
vi.stubEnv('PI_COMPOSITION_KEY', '')
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
const { ctx, settingsPath } = await loadComposition()
|
||||
|
||||
// The shipped posture: the adapter exists, no route does.
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
|
||||
// Exactly what the web Models page leaves on disk.
|
||||
await writeFile(settingsPath, [
|
||||
'llm-pi-ai:',
|
||||
' providers:',
|
||||
' deepseek:',
|
||||
' apiKeyEnv: PI_COMPOSITION_KEY',
|
||||
` baseURL: ${server.url}`,
|
||||
'',
|
||||
].join('\n'))
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek'])
|
||||
}, { timeout: 5000 })
|
||||
|
||||
const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })
|
||||
expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
|
||||
expect(server.headers[0]?.authorization).toBe('Bearer key-from-store')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { createServer } from 'node:http'
|
||||
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
|
||||
|
||||
export interface MockServer {
|
||||
url: string
|
||||
paths: string[]
|
||||
requests: unknown[]
|
||||
headers: IncomingMessage['headers'][]
|
||||
readonly closedResponses: number
|
||||
responseClosed: Promise<void>
|
||||
}
|
||||
|
||||
const servers: Server[] = []
|
||||
|
||||
/** Close every server opened since the last call; run from each spec's afterEach. */
|
||||
export async function closeMockServers(): Promise<void> {
|
||||
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
|
||||
}
|
||||
|
||||
/** A minimal complete text generation in pi-ai's chat-completions shape. */
|
||||
export const textEvents = [
|
||||
'{"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}',
|
||||
'{"choices":[{"delta":{"content":"hello"},"index":0,"finish_reason":null}]}',
|
||||
'{"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
|
||||
'[DONE]',
|
||||
]
|
||||
|
||||
/** Local provider stand-in: replays scripted behaviors per request. */
|
||||
export async function mockServer(script: {
|
||||
status?: number
|
||||
events?: string[]
|
||||
body?: string
|
||||
delayMs?: number
|
||||
headers?: Record<string, string>
|
||||
}[]): Promise<MockServer> {
|
||||
const paths: string[] = []
|
||||
const requests: unknown[] = []
|
||||
const headers: IncomingMessage['headers'][] = []
|
||||
let closedResponses = 0
|
||||
const responseClosed = Promise.withResolvers<undefined>()
|
||||
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
|
||||
response.on('close', () => {
|
||||
closedResponses += 1
|
||||
responseClosed.resolve(undefined)
|
||||
})
|
||||
let body = ''
|
||||
request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') })
|
||||
request.on('end', () => {
|
||||
paths.push(request.url ?? '')
|
||||
requests.push(body.length === 0 ? undefined : JSON.parse(body))
|
||||
headers.push(request.headers)
|
||||
const behavior = script.shift() ?? { status: 500, body: 'script exhausted' }
|
||||
if (behavior.status !== undefined && behavior.status !== 200) {
|
||||
response.writeHead(behavior.status, { 'content-type': 'application/json', ...behavior.headers })
|
||||
response.end(behavior.body ?? '{}')
|
||||
return
|
||||
}
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
let index = 0
|
||||
const writeNext = (): void => {
|
||||
const event = behavior.events?.[index++]
|
||||
if (event === undefined) { response.end(); return }
|
||||
response.write(`data: ${event}\n\n`)
|
||||
if (behavior.delayMs === undefined) writeNext()
|
||||
else setTimeout(writeNext, behavior.delayMs)
|
||||
}
|
||||
writeNext()
|
||||
})
|
||||
})
|
||||
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}`,
|
||||
paths,
|
||||
requests,
|
||||
headers,
|
||||
responseClosed: responseClosed.promise,
|
||||
get closedResponses() { return closedResponses },
|
||||
}
|
||||
}
|
||||
@@ -56,12 +56,11 @@ async function harness(image?: StoredImageAttachment): Promise<Context> {
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmPiAi, {
|
||||
providers: providerCases.map(profile => ({
|
||||
provider: profile.provider,
|
||||
providers: Object.fromEntries(providerCases.map(profile => [profile.provider, {
|
||||
...profile.apiKey === undefined ? {} : { apiKey: profile.apiKey },
|
||||
...profile.baseURL === undefined ? {} : { baseURL: profile.baseURL },
|
||||
...profile.headers === undefined ? {} : { headers: profile.headers },
|
||||
})),
|
||||
}])),
|
||||
})
|
||||
if (image !== undefined) {
|
||||
const fixture = image
|
||||
|
||||
@@ -10,6 +10,7 @@ vi.mock('@earendil-works/pi-ai/compat', async (importOriginal) => {
|
||||
})
|
||||
|
||||
import { PiAiAdapter } from '../src/adapter.ts'
|
||||
import { resolveProfiles } from '../src/config.ts'
|
||||
|
||||
afterEach(() => { streamSimple.mockReset() })
|
||||
|
||||
@@ -21,7 +22,10 @@ describe('pi-ai SDK retry boundary', () => {
|
||||
throw failure
|
||||
},
|
||||
})
|
||||
const adapter = new PiAiAdapter({ profiles: [{ provider: 'openai', apiKey: 'test-key' }] })
|
||||
const adapter = new PiAiAdapter({
|
||||
profiles: () => resolveProfiles({ openai: { apiKey: 'test-key' } }),
|
||||
resolveApiKey: () => Promise.resolve('test-key'),
|
||||
})
|
||||
const drain = async (): Promise<void> => {
|
||||
for await (const _chunk of adapter.stream({
|
||||
provider: 'openai',
|
||||
|
||||
Reference in New Issue
Block a user