a2874c0426
When Claude produces assistant content (output_tokens > 0) but the stream ends without a result event, classify the run as truncated mid-stream rather than falling through to the generic "did not produce a result — check API credentials" message. The misleading hint pointed operators at auth/model config when the real cause was pod termination, OOMKill, or CLI crash. Co-Authored-By: Paperclip <noreply@paperclip.ing>
224 lines
8.0 KiB
TypeScript
224 lines
8.0 KiB
TypeScript
import type { UsageSummary } from "@paperclipai/adapter-utils";
|
|
import { asString, asNumber, parseObject, parseJson } from "@paperclipai/adapter-utils/server-utils";
|
|
|
|
const CLAUDE_AUTH_REQUIRED_RE = /(?:not\s+logged\s+in|please\s+log\s+in|please\s+run\s+`?claude\s+login`?|login\s+required|requires\s+login|unauthorized|authentication\s+required)/i;
|
|
const URL_RE = /(https?:\/\/[^\s'"`<>()[\]{};,!?]+[^\s'"`<>()[\]{};,!.?:]+)/gi;
|
|
|
|
export function parseClaudeStreamJson(stdout: string) {
|
|
let sessionId: string | null = null;
|
|
let model = "";
|
|
let finalResult: Record<string, unknown> | null = null;
|
|
const assistantTexts: string[] = [];
|
|
// Belt-and-braces dedup: key by (message.id, textIndex) so a session that
|
|
// legitimately emits the same text twice in different turns isn't collapsed
|
|
// (finding #11, FAR-15). The log-dedup filter handles reconnect overlaps
|
|
// at the line level; this guard only needs to protect against the same
|
|
// message block being parsed twice.
|
|
const seenBlocks = new Set<string>();
|
|
// Set when we see stop_reason:null + output_tokens:0 on an assistant event
|
|
// with no subsequent result event — indicates the upstream LLM API returned
|
|
// an empty/malformed response (e.g. MiniMax degraded performance).
|
|
let llmApiEmptyResponse = false;
|
|
// Set when an assistant event with output_tokens > 0 was seen but no result
|
|
// event arrived — indicates the run was truncated mid-stream (pod terminated,
|
|
// OOMKill, or claude CLI crash after producing content).
|
|
let assistantContentSeen = false;
|
|
|
|
for (const rawLine of stdout.split(/\r?\n/)) {
|
|
const line = rawLine.trim();
|
|
if (!line) continue;
|
|
const event = parseJson(line);
|
|
if (!event) continue;
|
|
|
|
const type = asString(event.type, "");
|
|
if (type === "system" && asString(event.subtype, "") === "init") {
|
|
sessionId = asString(event.session_id, sessionId ?? "") || sessionId;
|
|
model = asString(event.model, model);
|
|
continue;
|
|
}
|
|
|
|
if (type === "assistant") {
|
|
sessionId = asString(event.session_id, sessionId ?? "") || sessionId;
|
|
const message = parseObject(event.message);
|
|
const messageId = asString(message.id, "");
|
|
const content = Array.isArray(message.content) ? message.content : [];
|
|
|
|
// Detect empty LLM API response: stop_reason:null with zero output tokens.
|
|
// output_tokens may appear directly on message or nested under message.usage.
|
|
const stopReason = message.stop_reason;
|
|
const usageObj = parseObject(message.usage as Record<string, unknown>);
|
|
const outputTokens = typeof message.output_tokens === "number"
|
|
? message.output_tokens
|
|
: asNumber(usageObj.output_tokens, -1);
|
|
if (stopReason === null && outputTokens === 0) {
|
|
llmApiEmptyResponse = true;
|
|
}
|
|
if (outputTokens > 0) {
|
|
assistantContentSeen = true;
|
|
}
|
|
|
|
for (let i = 0; i < content.length; i++) {
|
|
const entry = content[i];
|
|
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
|
|
const block = entry as Record<string, unknown>;
|
|
if (asString(block.type, "") === "text") {
|
|
const text = asString(block.text, "");
|
|
if (!text) continue;
|
|
// Prefer (messageId, index) when the message has an id; fall back
|
|
// to text content when it doesn't (legacy/partial events).
|
|
const key = messageId ? `${messageId}:${i}` : `text:${text}`;
|
|
if (!seenBlocks.has(key)) {
|
|
seenBlocks.add(key);
|
|
assistantTexts.push(text);
|
|
}
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (type === "result") {
|
|
finalResult = event;
|
|
llmApiEmptyResponse = false; // result event means Claude completed normally
|
|
assistantContentSeen = false; // result event means stream was not truncated
|
|
sessionId = asString(event.session_id, sessionId ?? "") || sessionId;
|
|
}
|
|
}
|
|
|
|
if (!finalResult) {
|
|
return {
|
|
sessionId,
|
|
model,
|
|
costUsd: null as number | null,
|
|
usage: null as UsageSummary | null,
|
|
summary: assistantTexts.join("\n\n").trim(),
|
|
resultJson: null as Record<string, unknown> | null,
|
|
llmApiEmptyResponse,
|
|
truncatedMidStream: assistantContentSeen,
|
|
};
|
|
}
|
|
|
|
const usageObj = parseObject(finalResult.usage);
|
|
const usage: UsageSummary = {
|
|
inputTokens: asNumber(usageObj.input_tokens, 0),
|
|
cachedInputTokens: asNumber(usageObj.cache_read_input_tokens, 0),
|
|
outputTokens: asNumber(usageObj.output_tokens, 0),
|
|
};
|
|
const costRaw = finalResult.total_cost_usd;
|
|
const costUsd = typeof costRaw === "number" && Number.isFinite(costRaw) ? costRaw : null;
|
|
const summary = asString(finalResult.result, assistantTexts.join("\n\n")).trim();
|
|
|
|
return {
|
|
sessionId,
|
|
model,
|
|
costUsd,
|
|
usage,
|
|
summary,
|
|
resultJson: finalResult,
|
|
llmApiEmptyResponse: false,
|
|
truncatedMidStream: false,
|
|
};
|
|
}
|
|
|
|
function extractClaudeErrorMessages(parsed: Record<string, unknown>): string[] {
|
|
const raw = Array.isArray(parsed.errors) ? parsed.errors : [];
|
|
const messages: string[] = [];
|
|
|
|
for (const entry of raw) {
|
|
if (typeof entry === "string") {
|
|
const msg = entry.trim();
|
|
if (msg) messages.push(msg);
|
|
continue;
|
|
}
|
|
|
|
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
continue;
|
|
}
|
|
|
|
const obj = entry as Record<string, unknown>;
|
|
const msg = asString(obj.message, "") || asString(obj.error, "") || asString(obj.code, "");
|
|
if (msg) {
|
|
messages.push(msg);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
messages.push(JSON.stringify(obj));
|
|
} catch {
|
|
// skip non-serializable entry
|
|
}
|
|
}
|
|
|
|
return messages;
|
|
}
|
|
|
|
export function extractClaudeLoginUrl(text: string): string | null {
|
|
const match = text.match(URL_RE);
|
|
if (!match || match.length === 0) return null;
|
|
for (const rawUrl of match) {
|
|
const cleaned = rawUrl.replace(/[\])}.!,?;:'\"]+$/g, "");
|
|
if (cleaned.includes("claude") || cleaned.includes("anthropic") || cleaned.includes("auth")) {
|
|
return cleaned;
|
|
}
|
|
}
|
|
return match[0]?.replace(/[\])}.!,?;:'\"]+$/g, "") ?? null;
|
|
}
|
|
|
|
export function detectClaudeLoginRequired(input: {
|
|
parsed: Record<string, unknown> | null;
|
|
stdout: string;
|
|
stderr: string;
|
|
}): { requiresLogin: boolean; loginUrl: string | null } {
|
|
const resultText = asString(input.parsed?.result, "").trim();
|
|
const messages = [resultText, ...extractClaudeErrorMessages(input.parsed ?? {}), input.stdout, input.stderr]
|
|
.join("\n")
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
|
|
const requiresLogin = messages.some((line) => CLAUDE_AUTH_REQUIRED_RE.test(line));
|
|
return {
|
|
requiresLogin,
|
|
loginUrl: extractClaudeLoginUrl([input.stdout, input.stderr].join("\n")),
|
|
};
|
|
}
|
|
|
|
export function describeClaudeFailure(parsed: Record<string, unknown>): string | null {
|
|
const subtype = asString(parsed.subtype, "");
|
|
const resultText = asString(parsed.result, "").trim();
|
|
const errors = extractClaudeErrorMessages(parsed);
|
|
|
|
let detail = resultText;
|
|
if (!detail && errors.length > 0) {
|
|
detail = errors[0] ?? "";
|
|
}
|
|
|
|
const parts = ["Claude run failed"];
|
|
if (subtype) parts.push(`subtype=${subtype}`);
|
|
if (detail) parts.push(detail);
|
|
return parts.length > 1 ? parts.join(": ") : null;
|
|
}
|
|
|
|
export function isClaudeMaxTurnsResult(parsed: Record<string, unknown> | null | undefined): boolean {
|
|
if (!parsed) return false;
|
|
|
|
const subtype = asString(parsed.subtype, "").trim().toLowerCase();
|
|
if (subtype === "error_max_turns") return true;
|
|
|
|
const stopReason = asString(parsed.stop_reason, "").trim().toLowerCase();
|
|
if (stopReason === "max_turns") return true;
|
|
|
|
const resultText = asString(parsed.result, "").trim();
|
|
return /max(?:imum)?\s+turns?/i.test(resultText);
|
|
}
|
|
|
|
export function isClaudeUnknownSessionError(parsed: Record<string, unknown>): boolean {
|
|
const resultText = asString(parsed.result, "").trim();
|
|
const allMessages = [resultText, ...extractClaudeErrorMessages(parsed)]
|
|
.map((msg) => msg.trim())
|
|
.filter(Boolean);
|
|
|
|
return allMessages.some((msg) =>
|
|
/no conversation found with session id|unknown session|session .* not found/i.test(msg),
|
|
);
|
|
}
|