fix(llm-pi-ai): preserve harness adapter contract
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
|
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
|
||||||
import type { GenerateResult, Message, ToolSchema } from '@deepseek-ai/dsh-llm'
|
import type { GenerateResult, Message, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||||
@@ -13,14 +13,20 @@ import type { Config } from '@deepseek-ai/dsh-llm-deepseek'
|
|||||||
|
|
||||||
const FLASH = 'deepseek-v4-flash'
|
const FLASH = 'deepseek-v4-flash'
|
||||||
const PRO = 'deepseek-v4-pro'
|
const PRO = 'deepseek-v4-pro'
|
||||||
|
const contexts: Context[] = []
|
||||||
|
|
||||||
async function harness(model: string, config: Partial<Config> = {}) {
|
async function harness(model: string, config: Partial<Config> = {}) {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
|
contexts.push(ctx)
|
||||||
await ctx.plugin(LlmService)
|
await ctx.plugin(LlmService)
|
||||||
await ctx.plugin(LlmDeepSeek, { models: [model], ...config })
|
await ctx.plugin(LlmDeepSeek, { models: [model], ...config })
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
|
||||||
|
})
|
||||||
|
|
||||||
function ask(text: string): Message[] {
|
function ask(text: string): Message[] {
|
||||||
return [{ role: 'user', content: [{ type: 'text', text }] }]
|
return [{ role: 'user', content: [{ type: 'text', text }] }]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ DeepSeek adapter for the harness LLM seam backed by [`@earendil-works/pi-ai`](ht
|
|||||||
|
|
||||||
`@deepseek-ai/dsh-llm-deepseek` already talks to the same endpoint. This package is its **design-verification twin**: same models, same wire protocol, completely different internals — a unified LLM library with its own event vocabulary versus hand-rolled fetch/SSE. Anything the harness `StreamChunk` protocol cannot express for BOTH implementations is a core-vocabulary bug. The differences it exercised on purpose:
|
`@deepseek-ai/dsh-llm-deepseek` already talks to the same endpoint. This package is its **design-verification twin**: same models, same wire protocol, completely different internals — a unified LLM library with its own event vocabulary versus hand-rolled fetch/SSE. Anything the harness `StreamChunk` protocol cannot express for BOTH implementations is a core-vocabulary bug. The differences it exercised on purpose:
|
||||||
|
|
||||||
- pi-ai hands back tool-call `arguments` as **parsed objects**; the harness keeps raw JSON strings (re-stringified at `block-end`).
|
- pi-ai hands tool-call `arguments` around as **parsed objects**; the harness keeps raw JSON strings. The adapter patches replay payloads back to the original raw strings before sending them, and re-stringifies parsed output tool calls at `block-end`.
|
||||||
- pi-ai reports failures as **in-stream error events** (it never throws mid-stream); these map to `finish {kind:'error'|'aborted'}` chunks — the protocol's other sanctioned error path besides throwing (which llm-deepseek uses).
|
- pi-ai reports failures as **in-stream error events** (it never throws mid-stream); these map to `finish {kind:'error'|'aborted'}` chunks — the protocol's other sanctioned error path besides throwing (which llm-deepseek uses).
|
||||||
- pi-ai folds reasoning tokens into `usage.output`; there is no separate reasoning count to map.
|
- pi-ai folds reasoning tokens into `usage.output`; there is no separate reasoning count to map.
|
||||||
- pi-ai's options omit stop sequences; `GenerateOptions.stop` is injected via its `onPayload` hook.
|
- pi-ai's options omit some DeepSeek/OpenAI-compatible details; the adapter uses its `onPayload` hook to preserve the harness contract (`stop`, per-tool `strict`, omitted reasoning effort, raw replayed tool arguments).
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import { stream as piStream } from '@earendil-works/pi-ai'
|
import { stream as piStream } from '@earendil-works/pi-ai'
|
||||||
import type { Model } from '@earendil-works/pi-ai'
|
import type { Model } from '@earendil-works/pi-ai'
|
||||||
import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
|
import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
|
||||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
import type { GenerateOptions, StreamChunk, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||||
import { toPiContext, toStreamChunks } from './convert.ts'
|
import { toPiContext, toStreamChunks } from './convert.ts'
|
||||||
|
|
||||||
/** Reasoning levels surfaced by this adapter (DeepSeek wire: high|max). */
|
/** Reasoning levels surfaced by this adapter (DeepSeek wire: high|max). */
|
||||||
@@ -59,12 +59,71 @@ export function buildModel(modelId: string, options: PiAiAdapterOptions): Model<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Payload = {
|
||||||
|
tools?: { function?: { name?: unknown; strict?: unknown } }[]
|
||||||
|
messages?: {
|
||||||
|
role?: unknown
|
||||||
|
tool_calls?: { id?: unknown; function?: { arguments?: unknown } }[]
|
||||||
|
}[]
|
||||||
|
reasoning_effort?: unknown
|
||||||
|
stop?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
function rawToolArguments(options: GenerateOptions): Map<string, string> {
|
||||||
|
const raw = new Map<string, string>()
|
||||||
|
for (const message of options.messages) {
|
||||||
|
if (message.role !== 'assistant') continue
|
||||||
|
for (const block of message.content) {
|
||||||
|
if (block.type === 'tool-call') raw.set(block.id, block.arguments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
function strictByToolName(tools: ToolSchema[] | undefined): Map<string, boolean | undefined> {
|
||||||
|
return new Map((tools ?? []).map(tool => [tool.name, tool.strict]))
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchPayload(payload: unknown, options: GenerateOptions, reasoning: PiAiReasoning | undefined): unknown {
|
||||||
|
if (typeof payload !== 'object' || payload === null) return payload
|
||||||
|
const body = payload as Payload
|
||||||
|
|
||||||
|
if (reasoning === undefined) {
|
||||||
|
delete body.reasoning_effort
|
||||||
|
}
|
||||||
|
if (options.stop !== undefined) {
|
||||||
|
body.stop = options.stop
|
||||||
|
}
|
||||||
|
|
||||||
|
const strictByName = strictByToolName(options.tools)
|
||||||
|
for (const tool of body.tools ?? []) {
|
||||||
|
const name = tool.function?.name
|
||||||
|
if (typeof name !== 'string') continue
|
||||||
|
const strict = strictByName.get(name)
|
||||||
|
if (strict === undefined) delete tool.function?.strict
|
||||||
|
else if (tool.function !== undefined) tool.function.strict = strict
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawById = rawToolArguments(options)
|
||||||
|
for (const message of body.messages ?? []) {
|
||||||
|
if (message.role !== 'assistant') continue
|
||||||
|
for (const call of message.tool_calls ?? []) {
|
||||||
|
if (typeof call.id !== 'string') continue
|
||||||
|
const raw = rawById.get(call.id)
|
||||||
|
if (raw !== undefined && call.function !== undefined) call.function.arguments = raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* pi-ai-backed adapter. One instance serves every registered model name.
|
* pi-ai-backed adapter. One instance serves every registered model name.
|
||||||
*
|
*
|
||||||
* Implementation notes:
|
* Implementation notes:
|
||||||
* - `GenerateOptions.stop` is injected via pi-ai's `onPayload` hook (its
|
* - `onPayload` patches provider payload details pi-ai cannot express directly:
|
||||||
* public options omit stop sequences).
|
* stop sequences, per-tool strict, omitted reasoning effort, and raw replayed
|
||||||
|
* tool-call arguments.
|
||||||
* - `prefill` throws UNSUPPORTED (same contract as dsh-llm-deepseek).
|
* - `prefill` throws UNSUPPORTED (same contract as dsh-llm-deepseek).
|
||||||
* - pi-ai reports request failures as in-stream error events; convert.ts
|
* - pi-ai reports request failures as in-stream error events; convert.ts
|
||||||
* maps them to `finish {kind:'error'|'aborted'}` chunks rather than
|
* maps them to `finish {kind:'error'|'aborted'}` chunks rather than
|
||||||
@@ -86,8 +145,9 @@ export class PiAiAdapter extends LlmAdapter {
|
|||||||
const model = buildModel(options.model, this.options)
|
const model = buildModel(options.model, this.options)
|
||||||
// Undefined config means "provider default" (DeepSeek: thinking ENABLED),
|
// Undefined config means "provider default" (DeepSeek: thinking ENABLED),
|
||||||
// matching llm-deepseek's omission semantics. pi-ai derives the wire
|
// matching llm-deepseek's omission semantics. pi-ai derives the wire
|
||||||
// thinking toggle from whether reasoningEffort is passed, so undefined
|
// thinking toggle from whether reasoningEffort is passed, so undefined maps
|
||||||
// maps to 'high' here; only an explicit 'off' disables thinking.
|
// internally to 'high' to get `thinking: enabled`; patchPayload then removes
|
||||||
|
// `reasoning_effort` so the provider chooses its default effort.
|
||||||
const reasoning = this.options.reasoning ?? 'high'
|
const reasoning = this.options.reasoning ?? 'high'
|
||||||
|
|
||||||
// pi-ai's event stream has no iterator-return cancellation hook: if our
|
// pi-ai's event stream has no iterator-return cancellation hook: if our
|
||||||
@@ -106,13 +166,7 @@ export class PiAiAdapter extends LlmAdapter {
|
|||||||
...options.maxTokens !== undefined ? { maxTokens: options.maxTokens } : {},
|
...options.maxTokens !== undefined ? { maxTokens: options.maxTokens } : {},
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
...reasoning !== 'off' ? { reasoningEffort: reasoning } : {},
|
...reasoning !== 'off' ? { reasoningEffort: reasoning } : {},
|
||||||
...options.stop !== undefined ? {
|
onPayload: payload => patchPayload(payload, options, this.options.reasoning),
|
||||||
// pi-ai's options omit stop sequences; inject them into the raw body.
|
|
||||||
onPayload: (payload: unknown) => {
|
|
||||||
(payload as Record<string, unknown>).stop = options.stop
|
|
||||||
return payload
|
|
||||||
},
|
|
||||||
} : {},
|
|
||||||
maxRetries: 0,
|
maxRetries: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
* exists — an independent implementation stress-tests the StreamChunk
|
* exists — an independent implementation stress-tests the StreamChunk
|
||||||
* protocol):
|
* protocol):
|
||||||
* - pi-ai tool-call `arguments` are PARSED OBJECTS; the harness keeps the
|
* - pi-ai tool-call `arguments` are PARSED OBJECTS; the harness keeps the
|
||||||
* raw JSON string. We parse on the way in and re-stringify on the way out.
|
* raw JSON string. We parse on the way into pi-ai, patch provider payloads
|
||||||
|
* back to the original raw string in the adapter, and re-stringify on output.
|
||||||
* - pi-ai reports errors as in-stream `error` events (it never throws
|
* - pi-ai reports errors as in-stream `error` events (it never throws
|
||||||
* mid-stream); the harness expresses those as `finish {kind:'error'}` /
|
* mid-stream); the harness expresses those as `finish {kind:'error'}` /
|
||||||
* `{kind:'aborted'}` chunks.
|
* `{kind:'aborted'}` chunks.
|
||||||
@@ -17,7 +18,7 @@
|
|||||||
* @module dsh-llm-pi-ai/convert
|
* @module dsh-llm-pi-ai/convert
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
import { CallId, LlmError } from '@deepseek-ai/dsh-llm'
|
||||||
import type { FinishReason, GenerateOptions, Message, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
|
import type { FinishReason, GenerateOptions, Message, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||||
import type {
|
import type {
|
||||||
AssistantMessage,
|
AssistantMessage,
|
||||||
@@ -168,6 +169,14 @@ export function mapUsage(usage: PiUsage): TokenUsage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function classifyPiAiError(message: string): string {
|
||||||
|
if (/\b(?:401|403)\b/.test(message)) return 'AUTH'
|
||||||
|
if (/\b429\b|rate.?limit/i.test(message)) return 'RATE_LIMIT'
|
||||||
|
if (/\b400\b|invalid.?request/i.test(message)) return 'INVALID_REQUEST'
|
||||||
|
if (/\b5\d\d\b/.test(message)) return 'SERVER'
|
||||||
|
return 'PI_AI_ERROR'
|
||||||
|
}
|
||||||
|
|
||||||
/** Map a terminal pi-ai event to the harness finish reason. */
|
/** Map a terminal pi-ai event to the harness finish reason. */
|
||||||
export function mapStopReason(message: AssistantMessage): FinishReason {
|
export function mapStopReason(message: AssistantMessage): FinishReason {
|
||||||
switch (message.stopReason) {
|
switch (message.stopReason) {
|
||||||
@@ -175,10 +184,9 @@ export function mapStopReason(message: AssistantMessage): FinishReason {
|
|||||||
case 'length': return { kind: 'max-tokens' }
|
case 'length': return { kind: 'max-tokens' }
|
||||||
case 'toolUse': return { kind: 'tool-calls' }
|
case 'toolUse': return { kind: 'tool-calls' }
|
||||||
case 'aborted': return { kind: 'aborted' }
|
case 'aborted': return { kind: 'aborted' }
|
||||||
case 'error': return {
|
case 'error': {
|
||||||
kind: 'error',
|
const text = message.errorMessage ?? 'pi-ai stream error'
|
||||||
message: message.errorMessage ?? 'pi-ai stream error',
|
return { kind: 'error', message: text, code: classifyPiAiError(text) }
|
||||||
code: 'PI_AI_ERROR',
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -264,4 +272,5 @@ export async function* toStreamChunks(events: AsyncIterable<AssistantMessageEven
|
|||||||
// when one is added (switch covers all current variants).
|
// when one is added (switch covers all current variants).
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
throw new LlmError('pi-ai event stream ended without done/error', 'STREAM_CLOSED')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
|
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
|
||||||
import type { GenerateResult, Message, ToolSchema } 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 FLASH = 'deepseek-v4-flash'
|
||||||
const PRO = 'deepseek-v4-pro'
|
const PRO = 'deepseek-v4-pro'
|
||||||
|
const contexts: Context[] = []
|
||||||
|
|
||||||
async function harness(model: string, config: Partial<Config> = {}) {
|
async function harness(model: string, config: Partial<Config> = {}) {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
|
contexts.push(ctx)
|
||||||
await ctx.plugin(LlmService)
|
await ctx.plugin(LlmService)
|
||||||
await ctx.plugin(LlmPiAi, { models: [model], ...config })
|
await ctx.plugin(LlmPiAi, { models: [model], ...config })
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
|
||||||
|
})
|
||||||
|
|
||||||
function ask(text: string): Message[] {
|
function ask(text: string): Message[] {
|
||||||
return [{ role: 'user', content: [{ type: 'text', text }] }]
|
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
|
// same block KINDS in the same order for a deterministic prompt — the
|
||||||
// cross-implementation check that the StreamChunk design holds.
|
// cross-implementation check that the StreamChunk design holds.
|
||||||
const deepseekCtx = new Context()
|
const deepseekCtx = new Context()
|
||||||
|
contexts.push(deepseekCtx)
|
||||||
await deepseekCtx.plugin(LlmService)
|
await deepseekCtx.plugin(LlmService)
|
||||||
await deepseekCtx.plugin(LlmDeepSeek, { models: [FLASH], thinking: 'disabled' })
|
await deepseekCtx.plugin(LlmDeepSeek, { models: [FLASH], thinking: 'disabled' })
|
||||||
|
|
||||||
|
|||||||
@@ -148,6 +148,44 @@ describe('PiAiAdapter against a mock server', () => {
|
|||||||
expect(server.requests[0]).toMatchObject({ stop: ['END'] })
|
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 () => {
|
it('maps HTTP errors to error finish chunks (pi-ai in-stream style)', async () => {
|
||||||
const server = await mockServer([{
|
const server = await mockServer([{
|
||||||
status: 401,
|
status: 401,
|
||||||
@@ -155,10 +193,20 @@ describe('PiAiAdapter against a mock server', () => {
|
|||||||
}])
|
}])
|
||||||
const ctx = await harness(server.url)
|
const ctx = await harness(server.url)
|
||||||
const result = await ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] })
|
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/)
|
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 () => {
|
it('rejects prefill with UNSUPPORTED', async () => {
|
||||||
const ctx = await harness('http://127.0.0.1:1')
|
const ctx = await harness('http://127.0.0.1:1')
|
||||||
await expect(ctx.llm.generate({
|
await expect(ctx.llm.generate({
|
||||||
@@ -263,10 +311,9 @@ describe('review fixes', () => {
|
|||||||
const server = await mockServer([{ events: textEvents }])
|
const server = await mockServer([{ events: textEvents }])
|
||||||
const ctx = await harness(server.url) // no reasoning key at all
|
const ctx = await harness(server.url) // no reasoning key at all
|
||||||
await ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] })
|
await ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] })
|
||||||
expect(server.requests[0]).toMatchObject({
|
const request = server.requests[0] as Record<string, unknown>
|
||||||
thinking: { type: 'enabled' },
|
expect(request.thinking).toEqual({ type: 'enabled' })
|
||||||
reasoning_effort: 'high',
|
expect('reasoning_effort' in request).toBe(false)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('replays reasoning_content on assistant tool-call turns (passback rule)', async () => {
|
it('replays reasoning_content on assistant tool-call turns (passback rule)', async () => {
|
||||||
|
|||||||
@@ -271,6 +271,11 @@ describe('toStreamChunks', () => {
|
|||||||
const chunks = await collect(toStreamChunks(feed({ type: 'error', reason: 'aborted', error })))
|
const chunks = await collect(toStreamChunks(feed({ type: 'error', reason: 'aborted', error })))
|
||||||
expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'aborted' } })
|
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', () => {
|
describe('mapStopReason / mapUsage', () => {
|
||||||
@@ -288,6 +293,15 @@ describe('mapStopReason / mapUsage', () => {
|
|||||||
.toEqual({ kind: 'error', message: 'pi-ai stream error', code: 'PI_AI_ERROR' })
|
.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', () => {
|
it('maps cache fields only when nonzero', () => {
|
||||||
expect(mapUsage(usage(10, 5, 8, 2))).toEqual({
|
expect(mapUsage(usage(10, 5, 8, 2))).toEqual({
|
||||||
inputTokens: 10,
|
inputTokens: 10,
|
||||||
|
|||||||
Reference in New Issue
Block a user