fix(llm-pi-ai): preserve harness adapter contract

This commit is contained in:
Tianyi Cui
2026-06-17 21:25:56 +08:00
parent 6fdd048123
commit 922e2f913e
7 changed files with 164 additions and 27 deletions

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
import type { GenerateResult, Message, ToolSchema } from '@deepseek-ai/dsh-llm'
@@ -15,14 +15,20 @@ import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
const FLASH = 'deepseek-v4-flash'
const PRO = 'deepseek-v4-pro'
const contexts: Context[] = []
async function harness(model: string, config: Partial<Config> = {}) {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, { models: [model], ...config })
return ctx
}
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
})
function ask(text: string): Message[] {
return [{ role: 'user', content: [{ type: 'text', text }] }]
}
@@ -114,6 +120,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-pi-ai e2e (real API)', () =>
// same block KINDS in the same order for a deterministic prompt — the
// cross-implementation check that the StreamChunk design holds.
const deepseekCtx = new Context()
contexts.push(deepseekCtx)
await deepseekCtx.plugin(LlmService)
await deepseekCtx.plugin(LlmDeepSeek, { models: [FLASH], thinking: 'disabled' })

View File

@@ -148,6 +148,44 @@ describe('PiAiAdapter against a mock server', () => {
expect(server.requests[0]).toMatchObject({ stop: ['END'] })
})
it('preserves per-tool strict exactly through onPayload', async () => {
const server = await mockServer([{ events: textEvents }])
const ctx = await harness(server.url)
await ctx.llm.generate({
model: 'deepseek-v4-flash',
messages: [],
tools: [
{ name: 'strict_true', description: 'true', parameters: {}, strict: true },
{ name: 'strict_false', description: 'false', parameters: {}, strict: false },
{ name: 'strict_omitted', description: 'omitted', parameters: {} },
],
})
const request = server.requests[0] as { tools: { function: { name: string; strict?: boolean } }[] }
expect(request.tools.map(tool => [tool.function.name, tool.function.strict])).toEqual([
['strict_true', true],
['strict_false', false],
['strict_omitted', undefined],
])
expect('strict' in request.tools[2]!.function).toBe(false)
})
it('preserves raw replayed tool-call arguments in the provider payload', async () => {
const server = await mockServer([{ events: textEvents }])
const ctx = await harness(server.url)
await ctx.llm.generate({
model: 'deepseek-v4-flash',
messages: [{
role: 'assistant',
content: [{ type: 'tool-call', id: CallId('broken'), name: 'f', arguments: '{broken' }],
}],
})
const request = server.requests[0] as { messages: { role: string; tool_calls?: { id: string; function: { arguments: string } }[] }[] }
const assistant = request.messages.find(message => message.role === 'assistant')
expect(assistant?.tool_calls?.[0]?.function.arguments).toBe('{broken')
})
it('maps HTTP errors to error finish chunks (pi-ai in-stream style)', async () => {
const server = await mockServer([{
status: 401,
@@ -155,10 +193,20 @@ describe('PiAiAdapter against a mock server', () => {
}])
const ctx = await harness(server.url)
const result = await ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] })
expect(result.finish.kind).toBe('error')
expect(result.finish).toMatchObject({ kind: 'error', code: 'AUTH' })
expect((result.finish as { message: string }).message).toMatch(/bad key|401/)
})
it.each([
[429, 'RATE_LIMIT'],
[500, 'SERVER'],
] as const)('maps HTTP %s to stable error code %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 ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] })
expect(result.finish).toMatchObject({ kind: 'error', code })
})
it('rejects prefill with UNSUPPORTED', async () => {
const ctx = await harness('http://127.0.0.1:1')
await expect(ctx.llm.generate({
@@ -263,10 +311,9 @@ describe('review fixes', () => {
const server = await mockServer([{ events: textEvents }])
const ctx = await harness(server.url) // no reasoning key at all
await ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] })
expect(server.requests[0]).toMatchObject({
thinking: { type: 'enabled' },
reasoning_effort: 'high',
})
const request = server.requests[0] as Record<string, unknown>
expect(request.thinking).toEqual({ type: 'enabled' })
expect('reasoning_effort' in request).toBe(false)
})
it('replays reasoning_content on assistant tool-call turns (passback rule)', async () => {

View File

@@ -271,6 +271,11 @@ describe('toStreamChunks', () => {
const chunks = await collect(toStreamChunks(feed({ type: 'error', reason: 'aborted', error })))
expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'aborted' } })
})
it('rejects a stream that ends without done or error', async () => {
await expect(collect(toStreamChunks(feed({ type: 'start', partial: assistant() }))))
.rejects.toThrow(/without done\/error/)
})
})
describe('mapStopReason / mapUsage', () => {
@@ -288,6 +293,15 @@ describe('mapStopReason / mapUsage', () => {
.toEqual({ kind: 'error', message: 'pi-ai stream error', code: 'PI_AI_ERROR' })
})
it('maps routable HTTP-ish error messages to stable codes', () => {
expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 401: bad key' })))
.toMatchObject({ kind: 'error', code: 'AUTH' })
expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 429: rate limit' })))
.toMatchObject({ kind: 'error', code: 'RATE_LIMIT' })
expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 500: backend down' })))
.toMatchObject({ kind: 'error', code: 'SERVER' })
})
it('maps cache fields only when nonzero', () => {
expect(mapUsage(usage(10, 5, 8, 2))).toEqual({
inputTokens: 10,