Some checks failed
CI / windows node 24 / wine blocking (push) Has been skipped
CI / node 22.19 (push) Has been skipped
CI / node 26 (push) Has been skipped
CI / python 3.10 / keyless SDK (push) Has been skipped
CI / python runtime / release-shaped Linux x64 (push) Has been skipped
CI / wine apt cache (push) Successful in 7s
CI / serial / linux (push) Has been skipped
Deploy documentation / build (push) Failing after 1m25s
Deploy documentation / deploy (push) Has been skipped
Landlock Run / Matrix (push) Successful in 5s
Release (vendor) / Pack npm tarballs (push) Failing after 2m47s
Release (dsh) / Pack npm tarballs (push) Failing after 1m56s
Sandbox / sandbox e2e (landlock, ubuntu-24.04) (push) Failing after 1m57s
Sandbox / sandbox e2e (bwrap, ubuntu-latest) (push) Failing after 1m19s
Release (vendor) / Publish to npm (push) Has been skipped
Release (dsh) / Publish to npm (push) Has been skipped
CI / serial / windows (self-hosted standby) (push) Has been cancelled
CI / larger-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, typecheck) (push) Has been cancelled
Landlock Run / darwin (no platform package — degradation proof) (push) Has been cancelled
Landlock Run / ${{ matrix.platform }} (push) Has been cancelled
CI / node 24 / static (push) Has been cancelled
CI / node 24 / coverage (push) Has been cancelled
CI / node 24 / snapshots and artifacts (push) Has been cancelled
CI / windows node 24 / native complete (push) Has been cancelled
CI / serial / linux (self-hosted standby) (push) Has been cancelled
CI / serial / macos (push) Has been cancelled
CI / larger-runner-benchmark (16, windows, dsh-windows-2025-16core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (32, windows, dsh-windows-2025-32core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (4, windows, dsh-windows-2025-4core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (64, windows, dsh-windows-2025-64core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (8, windows, dsh-windows-2025-8core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (96, windows, dsh-windows-2025-96core, production-site) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, 16) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, windows, dsh-windows-2025-16core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, windows, dsh-windows-2025-32core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, 4) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, windows, dsh-windows-2025-4core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, windows, dsh-windows-2025-64core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, 8) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, windows, dsh-windows-2025-8core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, windows, dsh-windows-2025-96core, 2) (push) Has been cancelled
CI / all checks passed (push) Has been cancelled
Sandbox / sandbox e2e (seatbelt, macos-latest) (push) Has been cancelled
CI / larger-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, typecheck) (push) Has been cancelled
Sandbox / sandbox e2e (landlock, ubuntu-24.04-arm) (push) Has been cancelled
E2E (real DeepSeek API) / e2e (push) Failing after 1m24s
- new economy and maximum agent presets with three-role pipeline skill - new packages/extensions/tool-lab (home-lab ComfyUI/Docling/Whishper tools) - new packages/subagent/subagent-cursor provider - openrouter balance UI with on-demand refresh - session projection context-seed boundary fold - regenerate docs catalogs; keep local searxng benchmark scripts
215 lines
9.5 KiB
TypeScript
215 lines
9.5 KiB
TypeScript
/**
|
|
* 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 {
|
|
const usageRecord = usage as unknown as Record<string, unknown>
|
|
const rawCost = usageRecord['cost'] ?? usageRecord['total_cost']
|
|
const costUsd = typeof rawCost === 'number' && Number.isFinite(rawCost) && rawCost >= 0
|
|
? rawCost
|
|
: undefined
|
|
return {
|
|
inputTokens: usage.input,
|
|
outputTokens: usage.output,
|
|
...usage.cacheRead > 0 ? { cacheReadTokens: usage.cacheRead } : {},
|
|
...usage.cacheWrite > 0 ? { cacheWriteTokens: usage.cacheWrite } : {},
|
|
...costUsd !== undefined ? { costUsd } : {},
|
|
}
|
|
}
|
|
|
|
// XXX(pi-ai upstream): pi-ai flattens the caught error to `error.message`
|
|
// (api/anthropic-messages.js: `errorMessage = error instanceof Error ?
|
|
// error.message : JSON.stringify(error)`), discarding the original Error and its
|
|
// `cause` chain before it reaches us. undici carries the actionable transport
|
|
// detail on `cause` (e.g. `SocketError: other side closed`) but hands the fetch
|
|
// wrapper a bare `terminated`, so we are left pattern-matching terse words here.
|
|
// If pi-ai ever forwards the original Error (or a fetch/dispatcher hook that lets
|
|
// us capture the cause ourselves), classify on `code`/`cause` instead of text.
|
|
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'
|
|
// A stream truncated before the provider's terminal event: each pi-ai provider
|
|
// throws its own wording when the wire closes mid-response without a terminal
|
|
// event (`… stream ended before message_stop`, `… before a terminal response
|
|
// event`, `… ended without a terminal event`, `Stream ended without
|
|
// finish_reason`). The connection dropped mid-response, so this is a transport
|
|
// truncation, not a model-level error.
|
|
if (/stream ended (?:before|without)\b/i.test(message)) return 'TRANSPORT'
|
|
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)
|
|
// undici renders a mid-stream socket drop as a bare `terminated` (its
|
|
// `cause` — the real SocketError — was flattened away upstream); Node's
|
|
// stream layer says `Premature close`.
|
|
|| /\bterminated\b|premature close/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<AssistantMessageEvent>,
|
|
contextWindow?: number,
|
|
): AsyncGenerator<StreamChunk> {
|
|
// 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<number, { id: string; name: string }>()
|
|
|
|
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')
|
|
}
|