feat: Enhance CLI output handling with structured Intermediate Representation (IR)

- Introduced `CliOutputUnit` and `IOutputParser` interfaces for unified output processing.
- Implemented `PlainTextParser` and `JsonLinesParser` for parsing raw CLI output into structured units.
- Updated `executeCliTool` to utilize output parsers and handle structured output.
- Added `flattenOutputUnits` utility for extracting clean output from structured data.
- Enhanced `ConversationTurn` and `ExecutionRecord` interfaces to include structured output.
- Created comprehensive documentation for CLI Output Converter usage and integration.
- Improved error handling and type mapping for various output formats.
This commit is contained in:
catlog22
2026-01-08 17:26:40 +08:00
parent b86cdd6644
commit d0523684e5
22 changed files with 1618 additions and 111 deletions

View File

@@ -13,6 +13,7 @@ export interface CliToolConfig {
enabled: boolean;
primaryModel: string; // For CLI endpoint calls (ccw cli -p)
secondaryModel: string; // For internal calls (llm_enhancer, generate_module_docs)
tags?: string[]; // User-defined tags/labels for the tool
}
export interface CliConfig {
@@ -204,7 +205,8 @@ export function updateToolConfig(
const updatedToolConfig: CliToolConfig = {
enabled: updates.enabled !== undefined ? updates.enabled : currentToolConfig.enabled,
primaryModel: updates.primaryModel || currentToolConfig.primaryModel,
secondaryModel: updates.secondaryModel || currentToolConfig.secondaryModel
secondaryModel: updates.secondaryModel || currentToolConfig.secondaryModel,
tags: updates.tags !== undefined ? updates.tags : currentToolConfig.tags
};
// Save updated config

View File

@@ -10,6 +10,12 @@ import { validatePath } from '../utils/path-resolver.js';
import { escapeWindowsArg } from '../utils/shell-escape.js';
import { buildCommand, checkToolAvailability, clearToolCache, debugLog, errorLog, type NativeResumeConfig, type ToolAvailability } from './cli-executor-utils.js';
import type { ConversationRecord, ConversationTurn, ExecutionOutput, ExecutionRecord } from './cli-executor-state.js';
import {
createOutputParser,
type CliOutputUnit,
type IOutputParser,
flattenOutputUnits
} from './cli-output-converter.js';
import {
buildMergedPrompt,
buildMultiTurnPrompt,
@@ -110,6 +116,7 @@ const ParamsSchema = z.object({
category: z.enum(['user', 'internal', 'insight']).default('user'), // Execution category for tracking
parentExecutionId: z.string().optional(), // Parent execution ID for fork/retry scenarios
stream: z.boolean().default(false), // false = cache full output (default), true = stream output via callback
outputFormat: z.enum(['text', 'json-lines']).optional().default('json-lines'), // Output parsing format (default: json-lines for type badges)
});
type Params = z.infer<typeof ParamsSchema>;
@@ -127,14 +134,14 @@ function assertNonEmptyArray<T>(items: T[], message: string): asserts items is N
*/
async function executeCliTool(
params: Record<string, unknown>,
onOutput?: ((data: { type: string; data: string }) => void) | null
onOutput?: ((unit: CliOutputUnit) => void) | null
): Promise<ExecutionOutput> {
const parsed = ParamsSchema.safeParse(params);
if (!parsed.success) {
throw new Error(`Invalid params: ${parsed.error.message}`);
}
const { tool, prompt, mode, format, model, cd, includeDirs, timeout, resume, id: customId, noNative, category, parentExecutionId } = parsed.data;
const { tool, prompt, mode, format, model, cd, includeDirs, timeout, resume, id: customId, noNative, category, parentExecutionId, outputFormat } = parsed.data;
// Validate and determine working directory early (needed for conversation lookup)
let workingDir: string;
@@ -155,7 +162,11 @@ async function executeCliTool(
if (endpoint) {
// Route to LiteLLM executor
if (onOutput) {
onOutput({ type: 'stderr', data: `[Routing to LiteLLM endpoint: ${model}]\n` });
onOutput({
type: 'stderr',
content: `[Routing to LiteLLM endpoint: ${model}]\n`,
timestamp: new Date().toISOString()
});
}
const result = await executeLiteLLMEndpoint({
@@ -363,7 +374,11 @@ async function executeCliTool(
if (resumeDecision) {
const modeDesc = getResumeModeDescription(resumeDecision);
if (onOutput) {
onOutput({ type: 'stderr', data: `[Resume mode: ${modeDesc}]\n` });
onOutput({
type: 'stderr',
content: `[Resume mode: ${modeDesc}]\n`,
timestamp: new Date().toISOString()
});
}
}
@@ -381,6 +396,10 @@ async function executeCliTool(
nativeResume: nativeResumeConfig
});
// Create output parser and IR storage
const parser = createOutputParser(outputFormat);
const allOutputUnits: CliOutputUnit[] = [];
const startTime = Date.now();
debugLog('EXEC', `Starting CLI execution`, {
@@ -390,7 +409,8 @@ async function executeCliTool(
conversationId,
promptLength: finalPrompt.length,
hasResume: !!resume,
hasCustomId: !!customId
hasCustomId: !!customId,
outputFormat
});
return new Promise((resolve, reject) => {
@@ -436,20 +456,36 @@ async function executeCliTool(
let timedOut = false;
// Handle stdout
child.stdout!.on('data', (data) => {
child.stdout!.on('data', (data: Buffer) => {
const text = data.toString();
stdout += text;
// Parse into IR units
const units = parser.parse(data, 'stdout');
allOutputUnits.push(...units);
if (onOutput) {
onOutput({ type: 'stdout', data: text });
// Send each IR unit to callback
for (const unit of units) {
onOutput(unit);
}
}
});
// Handle stderr
child.stderr!.on('data', (data) => {
child.stderr!.on('data', (data: Buffer) => {
const text = data.toString();
stderr += text;
// Parse into IR units
const units = parser.parse(data, 'stderr');
allOutputUnits.push(...units);
if (onOutput) {
onOutput({ type: 'stderr', data: text });
// Send each IR unit to callback
for (const unit of units) {
onOutput(unit);
}
}
});
@@ -464,6 +500,15 @@ async function executeCliTool(
// Clear current child process reference
currentChildProcess = null;
// Flush remaining buffer from parser
const remainingUnits = parser.flush();
allOutputUnits.push(...remainingUnits);
if (onOutput) {
for (const unit of remainingUnits) {
onOutput(unit);
}
}
const endTime = Date.now();
const duration = endTime - startTime;
@@ -472,7 +517,8 @@ async function executeCliTool(
duration: `${duration}ms`,
timedOut,
stdoutLength: stdout.length,
stderrLength: stderr.length
stderrLength: stderr.length,
outputUnitsCount: allOutputUnits.length
});
// Determine status - prioritize output content over exit code
@@ -524,7 +570,8 @@ async function executeCliTool(
truncated: stdout.length > 10240 || stderr.length > 2048,
cached: shouldCache,
stdout_full: shouldCache ? stdout : undefined,
stderr_full: shouldCache ? stderr : undefined
stderr_full: shouldCache ? stderr : undefined,
structured: allOutputUnits // Save structured IR units
};
// Determine base turn number for merge scenarios
@@ -677,13 +724,16 @@ async function executeCliTool(
id: conversationId,
timestamp: new Date(startTime).toISOString(),
tool,
model: model || 'default',
model: effectiveModel || 'default',
mode,
prompt,
status,
exit_code: code,
duration_ms: duration,
output: newTurnOutput
output: newTurnOutput,
parsedOutput: flattenOutputUnits(allOutputUnits, {
excludeTypes: ['stderr', 'progress', 'metadata', 'system']
})
};
resolve({
@@ -691,7 +741,8 @@ async function executeCliTool(
execution,
conversation,
stdout,
stderr
stderr,
parsedOutput: execution.parsedOutput
});
});

View File

@@ -5,6 +5,7 @@
import type { HistoryIndexEntry } from './cli-history-store.js';
import { StoragePaths, ensureStorageDir } from '../config/storage-paths.js';
import type { CliOutputUnit } from './cli-output-converter.js';
// Lazy-loaded SQLite store module
let sqliteStoreModule: typeof import('./cli-history-store.js') | null = null;
@@ -44,6 +45,10 @@ export interface ConversationTurn {
stdout: string;
stderr: string;
truncated: boolean;
cached?: boolean;
stdout_full?: string;
stderr_full?: string;
structured?: CliOutputUnit[]; // Structured IR sequence for advanced parsing
};
}
@@ -79,6 +84,7 @@ export interface ExecutionRecord {
stderr: string;
truncated: boolean;
};
parsedOutput?: string; // Extracted clean text from structured output units
}
interface HistoryIndex {

View File

@@ -8,6 +8,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync,
import { join } from 'path';
import { parseSessionFile, formatConversation, extractConversationPairs, type ParsedSession, type ParsedTurn } from './session-content-parser.js';
import { StoragePaths, ensureStorageDir, getProjectId } from '../config/storage-paths.js';
import type { CliOutputUnit } from './cli-output-converter.js';
// Types
export interface ConversationTurn {
@@ -26,6 +27,7 @@ export interface ConversationTurn {
cached?: boolean;
stdout_full?: string;
stderr_full?: string;
structured?: CliOutputUnit[]; // Structured IR sequence for advanced parsing
};
}

View File

@@ -0,0 +1,474 @@
/**
* CLI Output Converter
* Converts raw CLI tool output into structured Intermediate Representation (IR)
*
* Purpose: Decouple output parsing from consumption scenarios (View, Storage, Resume)
* Supports: Plain text, JSON Lines, and other structured formats
*/
// ========== Type Definitions ==========
/**
* Unified output unit types for the intermediate representation layer
*/
export type CliOutputUnitType =
| 'stdout' // Standard output text
| 'stderr' // Standard error text
| 'thought' // AI reasoning/thinking
| 'code' // Code block content
| 'file_diff' // File modification diff
| 'progress' // Progress updates
| 'metadata' // Session/execution metadata
| 'system'; // System events/messages
/**
* Intermediate Representation unit
* Common structure for all CLI output chunks
*/
export interface CliOutputUnit<T = any> {
type: CliOutputUnitType;
content: T;
timestamp: string; // ISO 8601 format
}
// ========== Parser Interface ==========
/**
* Parser interface for converting raw output into IR
*/
export interface IOutputParser {
/**
* Parse a chunk of data from stdout/stderr stream
* @param chunk - Raw buffer from stream
* @param streamType - Source stream (stdout or stderr)
* @returns Array of parsed output units
*/
parse(chunk: Buffer, streamType: 'stdout' | 'stderr'): CliOutputUnit[];
/**
* Flush any remaining buffered data
* Called when stream ends to ensure no data is lost
* @returns Array of remaining output units
*/
flush(): CliOutputUnit[];
}
// ========== Plain Text Parser ==========
/**
* PlainTextParser - Converts plain text output to IR
* Simply wraps text in appropriate type envelope
*/
export class PlainTextParser implements IOutputParser {
parse(chunk: Buffer, streamType: 'stdout' | 'stderr'): CliOutputUnit[] {
const text = chunk.toString('utf8');
if (!text) {
return [];
}
return [{
type: streamType,
content: text,
timestamp: new Date().toISOString()
}];
}
/**
* Flush any remaining buffered data
* Called when stream ends to ensure no data is lost
*
* Note: PlainTextParser does not buffer data internally, so this method
* always returns an empty array. Other parsers (e.g., JsonLinesParser)
* may have buffered incomplete lines that need to be flushed.
*
* @returns Array of remaining output units (always empty for PlainTextParser)
*/
flush(): CliOutputUnit[] {
// Plain text parser has no internal buffer
return [];
}
}
// ========== JSON Lines Parser ==========
/**
* JsonLinesParser - Parses newline-delimited JSON output
*
* Features:
* - Handles incomplete lines across chunks
* - Maps JSON events to appropriate IR types
* - Falls back to stdout for unparseable lines
* - Robust error handling for malformed JSON
*/
export class JsonLinesParser implements IOutputParser {
private buffer: string = '';
parse(chunk: Buffer, streamType: 'stdout' | 'stderr'): CliOutputUnit[] {
const text = chunk.toString('utf8');
this.buffer += text;
const units: CliOutputUnit[] = [];
const lines = this.buffer.split('\n');
// Keep the last incomplete line in buffer
this.buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
// Try to parse as JSON
let parsed: any;
try {
parsed = JSON.parse(trimmed);
} catch {
// Not valid JSON, treat as plain text
units.push({
type: streamType,
content: line,
timestamp: new Date().toISOString()
});
continue;
}
// Map JSON structure to IR type
const unit = this.mapJsonToIR(parsed, streamType);
if (unit) {
units.push(unit);
}
}
return units;
}
flush(): CliOutputUnit[] {
const units: CliOutputUnit[] = [];
if (this.buffer.trim()) {
// Try to parse remaining buffer
try {
const parsed = JSON.parse(this.buffer.trim());
const unit = this.mapJsonToIR(parsed, 'stdout');
if (unit) {
units.push(unit);
}
} catch {
// Not valid JSON, return as plain text
units.push({
type: 'stdout',
content: this.buffer,
timestamp: new Date().toISOString()
});
}
}
this.buffer = '';
return units;
}
/**
* Map parsed JSON object to appropriate IR type
* Handles various JSON event formats from different CLI tools
*/
private mapJsonToIR(json: any, fallbackStreamType: 'stdout' | 'stderr'): CliOutputUnit | null {
const timestamp = json.timestamp || new Date().toISOString();
// Detect type from JSON structure
if (json.type) {
switch (json.type) {
case 'thought':
case 'thinking':
case 'reasoning':
return {
type: 'thought',
content: json.content || json.text || json.message,
timestamp
};
case 'code':
case 'code_block':
return {
type: 'code',
content: json.content || json.code,
timestamp
};
case 'diff':
case 'file_diff':
case 'file_change':
return {
type: 'file_diff',
content: {
path: json.path || json.file,
diff: json.diff || json.content,
action: json.action || 'modify'
},
timestamp
};
case 'progress':
case 'status':
return {
type: 'progress',
content: {
message: json.message || json.content,
progress: json.progress,
total: json.total
},
timestamp
};
case 'metadata':
case 'session_meta':
return {
type: 'metadata',
content: json.payload || json.data || json,
timestamp
};
case 'system':
case 'event':
return {
type: 'system',
content: json.message || json.content || json,
timestamp
};
}
}
// Check for Codex JSONL format
if (json.type === 'response_item' && json.payload) {
const payloadType = json.payload.type;
if (payloadType === 'message') {
// User or assistant message
const content = json.payload.content
?.map((c: any) => c.text || '')
.filter((t: string) => t)
.join('\n') || '';
return {
type: 'stdout',
content,
timestamp
};
}
if (payloadType === 'reasoning') {
return {
type: 'thought',
content: json.payload.summary || json.payload.content,
timestamp
};
}
if (payloadType === 'function_call' || payloadType === 'function_call_output') {
return {
type: 'system',
content: json.payload,
timestamp
};
}
}
// Check for Gemini/Qwen message format
if (json.role === 'user' || json.role === 'assistant') {
return {
type: 'stdout',
content: json.content || json.text || '',
timestamp
};
}
if (json.thoughts && Array.isArray(json.thoughts)) {
return {
type: 'thought',
content: json.thoughts.map((t: any) =>
typeof t === 'string' ? t : `${t.subject}: ${t.description}`
).join('\n'),
timestamp
};
}
// Default: treat as stdout/stderr based on fallback
if (json.content || json.message || json.text) {
return {
type: fallbackStreamType,
content: json.content || json.message || json.text,
timestamp
};
}
// Unrecognized structure, return as metadata
return {
type: 'metadata',
content: json,
timestamp
};
}
}
// ========== Factory Function ==========
/**
* Create an output parser instance based on format
* @param format - Output format type
* @returns Parser instance
*/
export function createOutputParser(format: 'text' | 'json-lines'): IOutputParser {
switch (format) {
case 'json-lines':
return new JsonLinesParser();
case 'text':
default:
return new PlainTextParser();
}
}
// ========== Utility Functions ==========
/**
* Flatten output units into plain text string
* Useful for Resume scenario where we need concatenated context
*
* @param units - Array of output units to flatten
* @param options - Filtering and formatting options
* @returns Concatenated text content
*/
export function flattenOutputUnits(
units: CliOutputUnit[],
options?: {
includeTypes?: CliOutputUnitType[];
excludeTypes?: CliOutputUnitType[];
includeTimestamps?: boolean;
separator?: string;
}
): string {
const {
includeTypes,
excludeTypes,
includeTimestamps = false,
separator = '\n'
} = options || {};
// Filter units by type
let filtered = units;
if (includeTypes && includeTypes.length > 0) {
filtered = filtered.filter(u => includeTypes.includes(u.type));
}
if (excludeTypes && excludeTypes.length > 0) {
filtered = filtered.filter(u => !excludeTypes.includes(u.type));
}
// Convert to text
const lines = filtered.map(unit => {
let text = '';
if (includeTimestamps) {
text += `[${unit.timestamp}] `;
}
// Extract text content based on type
if (typeof unit.content === 'string') {
text += unit.content;
} else if (typeof unit.content === 'object' && unit.content !== null) {
// Handle structured content with type-specific formatting
switch (unit.type) {
case 'file_diff':
// Format file diff with path and diff content
text += `File: ${unit.content.path}\n\`\`\`diff\n${unit.content.diff}\n\`\`\``;
break;
case 'code':
// Format code block with language
const lang = unit.content.language || '';
const code = unit.content.code || unit.content;
text += `\`\`\`${lang}\n${typeof code === 'string' ? code : JSON.stringify(code)}\n\`\`\``;
break;
case 'thought':
// Format thought/reasoning content
text += `[Thought] ${typeof unit.content === 'string' ? unit.content : JSON.stringify(unit.content)}`;
break;
case 'progress':
// Format progress updates
if (unit.content.message) {
text += unit.content.message;
if (unit.content.progress !== undefined && unit.content.total !== undefined) {
text += ` (${unit.content.progress}/${unit.content.total})`;
}
} else {
text += JSON.stringify(unit.content);
}
break;
case 'metadata':
case 'system':
// Metadata and system events are typically excluded from prompt context
// Include minimal representation if they passed filtering
text += JSON.stringify(unit.content);
break;
default:
// Fallback for unknown structured types
text += JSON.stringify(unit.content);
}
} else {
text += String(unit.content);
}
return text;
});
return lines.join(separator);
}
/**
* Extract specific content type from units
* Convenience helper for common extraction patterns
*/
export function extractContent(
units: CliOutputUnit[],
type: CliOutputUnitType
): string[] {
return units
.filter(u => u.type === type)
.map(u => typeof u.content === 'string' ? u.content : JSON.stringify(u.content));
}
/**
* Get statistics about output units
* Useful for debugging and analytics
*/
export function getOutputStats(units: CliOutputUnit[]): {
total: number;
byType: Record<CliOutputUnitType, number>;
firstTimestamp?: string;
lastTimestamp?: string;
} {
const byType: Record<string, number> = {};
let firstTimestamp: string | undefined;
let lastTimestamp: string | undefined;
for (const unit of units) {
byType[unit.type] = (byType[unit.type] || 0) + 1;
if (!firstTimestamp || unit.timestamp < firstTimestamp) {
firstTimestamp = unit.timestamp;
}
if (!lastTimestamp || unit.timestamp > lastTimestamp) {
lastTimestamp = unit.timestamp;
}
}
return {
total: units.length,
byType: byType as Record<CliOutputUnitType, number>,
firstTimestamp,
lastTimestamp
};
}

View File

@@ -4,10 +4,59 @@
*/
import type { ConversationRecord, ConversationTurn } from './cli-executor-state.js';
import { flattenOutputUnits, type CliOutputUnit, type CliOutputUnitType } from './cli-output-converter.js';
// Prompt concatenation format types
export type PromptFormat = 'plain' | 'yaml' | 'json';
/**
* Extract clean AI output content from ConversationTurn
* Prioritizes structured IR data, falls back to raw stdout
*
* This function performs noise filtering to extract only meaningful content
* for use in prompt context, excluding progress updates, metadata, and system events.
*
* @param turn - Conversation turn containing output
* @param options - Extraction options
* @param options.includeThoughts - Whether to include AI reasoning/thinking in output (default: false)
* @returns Clean output string suitable for prompt context
*
* @example
* ```typescript
* const cleanOutput = extractCleanOutput(turn, { includeThoughts: true });
* // Returns: stdout + code + file_diff + thought (excludes: progress, metadata, system)
* ```
*/
function extractCleanOutput(
turn: ConversationTurn,
options?: {
includeThoughts?: boolean;
}
): string {
// Priority 1: Use structured IR if available (clean, noise-filtered)
if (turn.output?.structured && turn.output.structured.length > 0) {
const includeTypes: CliOutputUnitType[] = ['stdout', 'code', 'file_diff'];
// Optionally include thought processes
if (options?.includeThoughts) {
includeTypes.push('thought');
}
return flattenOutputUnits(turn.output.structured, {
includeTypes,
excludeTypes: ['progress', 'metadata', 'system']
});
}
// Priority 2: Use full output if available
if (turn.output?.stdout_full) {
return turn.output.stdout_full;
}
// Priority 3: Fall back to truncated stdout
return turn.output?.stdout || '[No output]';
}
/**
* Merge multiple conversations into a unified context
* Returns merged turns sorted by timestamp with source tracking
@@ -166,7 +215,11 @@ export class PromptConcatenator {
timestamp: turn.timestamp,
source_id: sourceId
});
this.addAssistantTurn(turn.output.stdout || '[No output]', {
// Use extractCleanOutput to get noise-filtered content
const cleanOutput = extractCleanOutput(turn);
this.addAssistantTurn(cleanOutput, {
turn: turn.turn * 2,
timestamp: turn.timestamp,
status: turn.status,

View File

@@ -10,6 +10,7 @@ import {
getProviderWithResolvedEnvVars,
} from '../config/litellm-api-config-manager.js';
import type { CustomEndpoint, ProviderCredential } from '../types/litellm-api-config.js';
import type { CliOutputUnit } from './cli-output-converter.js';
export interface LiteLLMExecutionOptions {
prompt: string;
@@ -18,7 +19,7 @@ export interface LiteLLMExecutionOptions {
cwd?: string; // Working directory for file resolution
includeDirs?: string[]; // Additional directories for @patterns
enableCache?: boolean; // Override endpoint cache setting
onOutput?: (data: { type: string; data: string }) => void;
onOutput?: (unit: CliOutputUnit) => void;
/** Number of retries after the initial attempt (default: 0) */
maxRetries?: number;
/** Base delay for exponential backoff in milliseconds (default: 1000) */
@@ -105,7 +106,11 @@ export async function executeLiteLLMEndpoint(
const patterns = extractPatterns(prompt);
if (patterns.length > 0) {
if (onOutput) {
onOutput({ type: 'stderr', data: `[Context cache: Found ${patterns.length} @patterns]\n` });
onOutput({
type: 'stderr',
content: `[Context cache: Found ${patterns.length} @patterns]\n`,
timestamp: new Date().toISOString()
});
}
// Pack files into cache
@@ -124,7 +129,8 @@ export async function executeLiteLLMEndpoint(
if (onOutput) {
onOutput({
type: 'stderr',
data: `[Context cache: Packed ${pack.files_packed} files, ${pack.total_bytes} bytes]\n`,
content: `[Context cache: Packed ${pack.files_packed} files, ${pack.total_bytes} bytes]\n`,
timestamp: new Date().toISOString()
});
}
@@ -143,12 +149,20 @@ export async function executeLiteLLMEndpoint(
cachedFiles = pack.files_packed ? Array(pack.files_packed).fill('...') : [];
if (onOutput) {
onOutput({ type: 'stderr', data: `[Context cache: Applied to prompt]\n` });
onOutput({
type: 'stderr',
content: `[Context cache: Applied to prompt]\n`,
timestamp: new Date().toISOString()
});
}
}
} else if (packResult.error) {
if (onOutput) {
onOutput({ type: 'stderr', data: `[Context cache warning: ${packResult.error}]\n` });
onOutput({
type: 'stderr',
content: `[Context cache warning: ${packResult.error}]\n`,
timestamp: new Date().toISOString()
});
}
}
}
@@ -159,7 +173,8 @@ export async function executeLiteLLMEndpoint(
if (onOutput) {
onOutput({
type: 'stderr',
data: `[LiteLLM: Calling ${provider.type}/${endpoint.model}]\n`,
content: `[LiteLLM: Calling ${provider.type}/${endpoint.model}]\n`,
timestamp: new Date().toISOString()
});
}
@@ -195,7 +210,11 @@ export async function executeLiteLLMEndpoint(
);
if (onOutput) {
onOutput({ type: 'stdout', data: response });
onOutput({
type: 'stdout',
content: response,
timestamp: new Date().toISOString()
});
}
return {
@@ -209,7 +228,11 @@ export async function executeLiteLLMEndpoint(
} catch (error) {
const errorMsg = (error as Error).message;
if (onOutput) {
onOutput({ type: 'stderr', data: `[LiteLLM error: ${errorMsg}]\n` });
onOutput({
type: 'stderr',
content: `[LiteLLM error: ${errorMsg}]\n`,
timestamp: new Date().toISOString()
});
}
return {
@@ -279,7 +302,7 @@ async function callWithRetries(
options: {
maxRetries: number;
baseDelayMs: number;
onOutput?: (data: { type: string; data: string }) => void;
onOutput?: (unit: CliOutputUnit) => void;
rateLimitKey: string;
},
): Promise<string> {
@@ -301,7 +324,8 @@ async function callWithRetries(
if (onOutput) {
onOutput({
type: 'stderr',
data: `[LiteLLM retry ${attempt + 1}/${maxRetries}: waiting ${delayMs}ms] ${errorMessage}\n`,
content: `[LiteLLM retry ${attempt + 1}/${maxRetries}: waiting ${delayMs}ms] ${errorMessage}\n`,
timestamp: new Date().toISOString()
});
}