/** * pi-ai assistant event translation into the Harness streaming protocol. * * pi-ai tool-call arguments are parsed objects while the Harness keeps their * raw JSON representation. pi-ai also reports failures as terminal stream * events, which this module maps into Harness finish chunks. * * @module dsh-llm-pi-ai/stream */ import { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, isContextWindowExceededError, isQuotaExceededError, LlmError, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm' import type { FinishReason, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm' import { isContextOverflow } from '@earendil-works/pi-ai' import type { AssistantMessage, AssistantMessageEvent, Usage as PiUsage } from '@earendil-works/pi-ai' import { toPiReplayState } from './replay.ts' /** * Map pi-ai usage (reasoning folded into output by pi-ai). * @param usage - cumulative usage from the terminal pi-ai event. * @returns harness counts; cache fields appear only when non-zero (pi-ai reports zeros, not absence). */ export function mapUsage(usage: PiUsage): TokenUsage { return { inputTokens: usage.input, outputTokens: usage.output, ...usage.cacheRead > 0 ? { cacheReadTokens: usage.cacheRead } : {}, ...usage.cacheWrite > 0 ? { cacheWriteTokens: usage.cacheWrite } : {}, } } function classifyPiAiError(message: string): string { if (/\b(?:401|403)\b/.test(message)) return 'AUTH' if (isQuotaExceededError(message)) return QUOTA_EXCEEDED_CODE 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' if (/\btime(?:d)?\s*out\b|timeout/i.test(message)) return 'TIMEOUT' if (/\b(?:network|connection|socket|fetch)\b|\bECONN[A-Z]+\b/i.test(message) || /\b(?:other side closed|HTTP2 request did not get a response|WebSocket closed unexpectedly)\b/i.test(message)) { return 'TRANSPORT' } return 'PI_AI_ERROR' } /** * Map a terminal pi-ai event to the harness finish reason. * @param message - the assistant message carried by the `done` or `error` event. * @param contextWindow - resolved catalog capacity for usage-based overflow detection. * @returns the mapped harness reason. Recognized error text, `stop` usage above * `contextWindow`, and zero-output `length` usage that fills the window map * to `CONTEXT_WINDOW_EXCEEDED`; a `stop` with no content blocks maps to an * `EMPTY_RESPONSE` error. */ export function mapStopReason(message: AssistantMessage, contextWindow?: number): FinishReason { const piAiOverflow = isContextOverflow(message, contextWindow) const harnessOverflow = message.stopReason === 'error' && message.errorMessage !== undefined && isContextWindowExceededError(message.errorMessage) if (piAiOverflow || harnessOverflow) { return { kind: 'error', failure: { message: message.errorMessage ?? `pi-ai detected context overflow for model "${message.model}"`, code: CONTEXT_WINDOW_EXCEEDED_CODE, }, } } switch (message.stopReason) { case 'stop': // A terminal stop that produced no content blocks is a degenerate // provider completion, not a successful (empty) assistant message. if (message.content.length === 0) { return { kind: 'error', failure: { message: `model "${message.model}" returned a completed response with no content`, code: EMPTY_RESPONSE_CODE, }, } } return { kind: 'stop' } case 'length': return { kind: 'max-tokens' } case 'toolUse': return { kind: 'tool-calls' } case 'aborted': return { kind: 'aborted', failure: { message: message.errorMessage ?? 'pi-ai stream aborted', code: 'ABORTED' }, } case 'error': { const text = message.errorMessage ?? 'pi-ai stream error' return { kind: 'error', failure: { message: text, code: classifyPiAiError(text) } } } } } /** * Translate the pi-ai event stream into StreamChunks. pi-ai never throws * mid-stream — failures arrive as `error` events, which become error/aborted * `finish` chunks (the harness protocol's other error-delivery style). * @param events - one assistant turn's pi-ai event stream. * @param contextWindow - resolved catalog capacity for usage-based overflow detection. * @returns the harness chunks, ending with `usage` then `finish`; throws * `LlmError` (`STREAM_CLOSED`) if the source ends without a terminal event. */ export async function* toStreamChunks( events: AsyncIterable, contextWindow?: number, ): AsyncGenerator { // pi-ai contentIndex ↔ our block index map 1:1 (both count blocks from 0 // in stream order), but we track ids per index for tool calls. const toolIds = new Map() for await (const event of events) { switch (event.type) { case 'start': break case 'text_start': yield { type: 'block-start', index: event.contentIndex, blockType: 'text' } break case 'text_delta': yield { type: 'text-delta', index: event.contentIndex, text: event.delta } break case 'text_end': yield { type: 'block-end', index: event.contentIndex, block: { type: 'text', text: event.content } } break case 'thinking_start': yield { type: 'block-start', index: event.contentIndex, blockType: 'reasoning' } break case 'thinking_delta': yield { type: 'reasoning-delta', index: event.contentIndex, text: event.delta } break case 'thinking_end': yield { type: 'block-end', index: event.contentIndex, block: { type: 'reasoning', text: event.content } } break case 'toolcall_start': { // The id/name live on the partial's content at this index. const partial = event.partial.content[event.contentIndex] const id = partial?.type === 'toolCall' ? partial.id : '' const name = partial?.type === 'toolCall' ? partial.name : '' toolIds.set(event.contentIndex, { id, name }) yield { type: 'block-start', index: event.contentIndex, blockType: 'tool-call' } break } case 'toolcall_delta': { const known = toolIds.get(event.contentIndex) yield { type: 'tool-call-delta', index: event.contentIndex, id: CallId(known?.id ?? ''), ...known?.name !== undefined && known.name.length > 0 ? { name: known.name } : {}, argumentsDelta: event.delta, } break } case 'toolcall_end': yield { type: 'block-end', index: event.contentIndex, block: { type: 'tool-call', id: CallId(event.toolCall.id), name: event.toolCall.name, // pi-ai hands back the PARSED arguments; the harness vocabulary // keeps the raw string. arguments: JSON.stringify(event.toolCall.arguments), }, } break case 'done': yield { type: 'usage', usage: mapUsage(event.message.usage) } yield { type: 'finish', reason: mapStopReason(event.message, contextWindow), replayState: toPiReplayState(event.message), } return case 'error': // In-stream error delivery (pi-ai's style) → error finish chunk // (the harness's other sanctioned error path besides throwing). yield { type: 'usage', usage: mapUsage(event.error.usage) } yield { type: 'finish', reason: mapStopReason(event.error, contextWindow) } return // no default: AssistantMessageEvent is pi-ai's closed union; a new // event type should fail compilation here via tsc's exhaustiveness // when one is added (switch covers all current variants). } } throw new LlmError('pi-ai event stream ended without done/error', 'STREAM_CLOSED') }