Skip to content

Commit efad356

Browse files
committed
feat: add LLM error handling with detailed logging and sanitization
1 parent 27c3115 commit efad356

5 files changed

Lines changed: 232 additions & 21 deletions

File tree

packages/core/src/common/error-logger.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as fs from "fs";
22
import * as path from "path";
33
import * as os from "os";
4+
import type { LlmErrorDetails } from "./llm-error";
45

56
const LOG_DIR = path.join(os.homedir(), ".deepcode", "logs");
67
const ERROR_LOG_PATH = path.join(LOG_DIR, "error.log");
@@ -78,11 +79,7 @@ export type ApiErrorLogEntry = {
7879
sessionId?: string;
7980
model?: string;
8081
baseURL?: string;
81-
error: {
82-
name: string;
83-
message: string;
84-
stack?: string;
85-
};
82+
error: LlmErrorDetails;
8683
request: Record<string, unknown>;
8784
response?: unknown;
8885
};
@@ -101,11 +98,7 @@ export function logApiError(entry: ApiErrorLogEntry): void {
10198
sessionId: entry.sessionId,
10299
model: entry.model,
103100
baseURL: entry.baseURL,
104-
error: {
105-
name: entry.error.name,
106-
message: maskSensitive(entry.error.message),
107-
stack: entry.error.stack ? maskSensitive(entry.error.stack) : undefined,
108-
},
101+
error: sanitizeError(entry.error),
109102
request: sanitizeRequestPayload(entry.request),
110103
};
111104

@@ -127,3 +120,12 @@ export function logApiError(entry: ApiErrorLogEntry): void {
127120
// Silently ignore logging failures to avoid disrupting the main flow
128121
}
129122
}
123+
124+
function sanitizeError(error: LlmErrorDetails): LlmErrorDetails {
125+
return {
126+
...error,
127+
message: maskSensitive(error.message),
128+
stack: error.stack ? maskSensitive(error.stack) : undefined,
129+
causes: error.causes?.map(sanitizeError),
130+
};
131+
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
const MAX_MESSAGE_LENGTH = 500;
2+
const MAX_CAUSE_DEPTH = 5;
3+
4+
type ErrorRecord = Record<string, unknown>;
5+
6+
export type LlmErrorDetails = {
7+
name: string;
8+
message: string;
9+
stack?: string;
10+
status?: number;
11+
code?: string;
12+
type?: string;
13+
param?: string;
14+
requestId?: string;
15+
traceId?: string;
16+
causes?: LlmErrorDetails[];
17+
};
18+
19+
/**
20+
* Produce a concise, credential-safe explanation from OpenAI-compatible API
21+
* errors and their underlying network causes.
22+
*/
23+
export function describeLlmError(error: unknown): string {
24+
const details = getLlmErrorDetails(error);
25+
const parts: string[] = [];
26+
27+
if (details.status !== undefined) {
28+
const message = getProviderMessage(error) ?? details.message;
29+
parts.push(`HTTP ${details.status}: ${message}`);
30+
if (details.code) {
31+
parts.push(`code: ${details.code}`);
32+
}
33+
if (details.type) {
34+
parts.push(`type: ${details.type}`);
35+
}
36+
if (details.param) {
37+
parts.push(`param: ${details.param}`);
38+
}
39+
if (details.requestId) {
40+
parts.push(`request ID: ${details.requestId}`);
41+
}
42+
if (details.traceId) {
43+
parts.push(`trace ID: ${details.traceId}`);
44+
}
45+
return formatErrorParts(parts);
46+
}
47+
48+
const causeMessage = findUsefulCauseMessage(details.causes ?? []);
49+
if (causeMessage && isGenericConnectionMessage(details.message)) {
50+
return `Connection error: ${causeMessage}`;
51+
}
52+
if (causeMessage && causeMessage !== details.message) {
53+
return `${details.message} (cause: ${causeMessage})`;
54+
}
55+
return details.message;
56+
}
57+
58+
/**
59+
* Extract serializable diagnostics for local logs without retaining the
60+
* original error object, which can contain circular references.
61+
*/
62+
export function getLlmErrorDetails(error: unknown): LlmErrorDetails {
63+
return getErrorDetails(error, 0, new Set<object>());
64+
}
65+
66+
function getErrorDetails(error: unknown, depth: number, seen: Set<object>): LlmErrorDetails {
67+
const record = isRecord(error) ? error : null;
68+
const name = safeText(record?.name) ?? (error instanceof Error ? error.name : "UnknownError");
69+
const message = safeText(record?.message) ?? safeText(error) ?? "Unknown error";
70+
const details: LlmErrorDetails = { name, message };
71+
72+
const status = record?.status;
73+
if (typeof status === "number" && Number.isFinite(status)) {
74+
details.status = status;
75+
}
76+
for (const key of ["code", "type", "param"] as const) {
77+
const value = safeText(record?.[key]);
78+
if (value) {
79+
details[key] = value;
80+
}
81+
}
82+
const requestId = safeText(record?.requestID) ?? getHeader(record?.headers, "x-request-id");
83+
if (requestId) {
84+
details.requestId = requestId;
85+
}
86+
const traceId = getHeader(record?.headers, "x-ds-trace-id");
87+
if (traceId) {
88+
details.traceId = traceId;
89+
}
90+
const stack = safeText(record?.stack, MAX_MESSAGE_LENGTH * 4);
91+
if (stack) {
92+
details.stack = stack;
93+
}
94+
95+
if (record && depth < MAX_CAUSE_DEPTH && !seen.has(record)) {
96+
seen.add(record);
97+
if (record.cause !== undefined) {
98+
details.causes = [getErrorDetails(record.cause, depth + 1, seen)];
99+
}
100+
}
101+
return details;
102+
}
103+
104+
function getProviderMessage(error: unknown): string | undefined {
105+
if (!isRecord(error) || !isRecord(error.error)) {
106+
return undefined;
107+
}
108+
return safeText(error.error.message);
109+
}
110+
111+
function getHeader(headers: unknown, name: string): string | undefined {
112+
if (headers && typeof (headers as { get?: unknown }).get === "function") {
113+
return safeText((headers as { get(name: string): unknown }).get(name));
114+
}
115+
if (isRecord(headers)) {
116+
const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === name);
117+
return entry ? safeText(entry[1]) : undefined;
118+
}
119+
return undefined;
120+
}
121+
122+
function findUsefulCauseMessage(causes: LlmErrorDetails[]): string | undefined {
123+
for (const cause of causes) {
124+
if (!isGenericConnectionMessage(cause.message)) {
125+
return cause.message;
126+
}
127+
const nested = findUsefulCauseMessage(cause.causes ?? []);
128+
if (nested) {
129+
return nested;
130+
}
131+
}
132+
return undefined;
133+
}
134+
135+
function isGenericConnectionMessage(message: string): boolean {
136+
const normalized = message.toLowerCase().replace(/\s+/g, " ").trim();
137+
return (
138+
normalized === "connection error" ||
139+
normalized === "connection error." ||
140+
normalized === "fetch failed" ||
141+
normalized === "request timed out." ||
142+
normalized === "request timed out"
143+
);
144+
}
145+
146+
function formatErrorParts(parts: string[]): string {
147+
const [first, ...metadata] = parts;
148+
return metadata.length > 0 ? `${first} [${metadata.join(", ")}]` : (first ?? "Unknown error");
149+
}
150+
151+
function safeText(value: unknown, maxLength = MAX_MESSAGE_LENGTH): string | undefined {
152+
if (typeof value !== "string" && typeof value !== "number") {
153+
return undefined;
154+
}
155+
const normalized = maskSensitive(String(value)).replace(/\s+/g, " ").trim();
156+
if (!normalized) {
157+
return undefined;
158+
}
159+
return normalized.length > maxLength ? `${normalized.slice(0, maxLength)}...` : normalized;
160+
}
161+
162+
function isRecord(value: unknown): value is ErrorRecord {
163+
return Boolean(value) && typeof value === "object";
164+
}
165+
166+
function maskSensitive(text: string): string {
167+
return text
168+
.replace(/(Authorization\s*[:=]\s*(?:Bearer\s+)?)[^\s,;]+/gi, "$1***MASKED***")
169+
.replace(/([?&](?:api[_-]?key|access[_-]?token|token)=)[^&\s]+/gi, "$1***MASKED***")
170+
.replace(/(["']?(?:api[_-]?key|access[_-]?token|secret)["']?\s*[:=]\s*["']?)[^",}\s]+/gi, "$1***MASKED***")
171+
.replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, "***MASKED***");
172+
}

packages/core/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ export { DEEPSEEK_V4_MODELS, supportsMultimodal, defaultsToThinkingMode } from "
105105
export { findGitBashPath, resolveShellPath, setShellIfWindows } from "./common/shell-utils";
106106
export { logApiError } from "./common/error-logger";
107107
export { logOpenAIChatCompletionDebug } from "./common/debug-logger";
108+
export { describeLlmError, getLlmErrorDetails } from "./common/llm-error";
109+
export type { LlmErrorDetails } from "./common/llm-error";
108110
export {
109111
clampBashTimeoutMs,
110112
DEFAULT_BASH_TIMEOUT_MS,

packages/core/src/session.ts

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { McpManager } from "./mcp/mcp-manager";
3232
import type { McpServerConfig, PermissionScope, PermissionSettings } from "./settings";
3333
import { logApiError } from "./common/error-logger";
3434
import { logOpenAIChatCompletionDebug, normalizeDebugError } from "./common/debug-logger";
35+
import { describeLlmError, getLlmErrorDetails } from "./common/llm-error";
3536
import { killProcessTree } from "./common/process-tree";
3637
import { GitFileHistory, type FileHistoryCheckpointResult } from "./common/file-history";
3738
import { clearSessionState, getSnippet, rebuildSessionStateFromHistory } from "./common/state";
@@ -537,11 +538,7 @@ export class SessionManager {
537538
requestId,
538539
sessionId,
539540
model: typeof request.model === "string" ? request.model : undefined,
540-
error: {
541-
name: error instanceof Error ? error.name : "UnknownError",
542-
message: error instanceof Error ? error.message : String(error),
543-
stack: error instanceof Error ? error.stack : undefined,
544-
},
541+
error: getLlmErrorDetails(error),
545542
request: streamRequest,
546543
});
547544
this.emitLlmStreamProgress(requestId, startedAt, estimatedTokens, "end", sessionId);
@@ -671,11 +668,7 @@ export class SessionManager {
671668
requestId,
672669
sessionId,
673670
model: typeof request.model === "string" ? request.model : undefined,
674-
error: {
675-
name: error instanceof Error ? error.name : "UnknownError",
676-
message: error instanceof Error ? error.message : String(error),
677-
stack: error instanceof Error ? error.stack : undefined,
678-
},
671+
error: getLlmErrorDetails(error),
679672
request: streamRequest,
680673
});
681674
throw error;
@@ -1509,7 +1502,7 @@ ${agentInstructions}
15091502
false
15101503
);
15111504
} catch (error) {
1512-
const errMessage = error instanceof Error ? error.message : String(error);
1505+
const errMessage = describeLlmError(error);
15131506
const aborted = this.isAbortLikeError(error) || sessionController.signal.aborted;
15141507
this.updateSessionEntry(sessionId, (entry) => ({
15151508
...entry,
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { test } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { describeLlmError, getLlmErrorDetails } from "../common/llm-error";
4+
5+
test("describeLlmError shows provider business errors with trace metadata", () => {
6+
const error = Object.assign(new Error("402 Insufficient Balance"), {
7+
status: 402,
8+
error: {
9+
message: "Insufficient Balance",
10+
},
11+
code: "invalid_request_error",
12+
type: "unknown_error",
13+
headers: new Headers({
14+
"x-request-id": "request-123",
15+
"x-ds-trace-id": "trace-456",
16+
}),
17+
});
18+
19+
assert.equal(
20+
describeLlmError(error),
21+
"HTTP 402: Insufficient Balance [code: invalid_request_error, type: unknown_error, request ID: request-123, trace ID: trace-456]"
22+
);
23+
});
24+
25+
test("describeLlmError unwraps underlying network causes", () => {
26+
const cause = new Error("getaddrinfo ENOTFOUND api.deepseek.com");
27+
const error = Object.assign(new Error("Connection error."), { cause });
28+
29+
assert.equal(describeLlmError(error), "Connection error: getaddrinfo ENOTFOUND api.deepseek.com");
30+
});
31+
32+
test("LLM error details stop at circular causes and redact credentials", () => {
33+
const first = Object.assign(new Error("Connection error."), { cause: undefined as unknown });
34+
const second = new Error("fetch failed: https://example.test?api_key=sk-secret-value");
35+
(first as Error & { cause: unknown }).cause = second;
36+
(second as Error & { cause: unknown }).cause = first;
37+
38+
const details = getLlmErrorDetails(first);
39+
assert.equal(details.causes?.[0]?.message, "fetch failed: https://example.test?api_key=***MASKED***");
40+
assert.equal(details.causes?.[0]?.causes?.[0]?.message, "Connection error.");
41+
assert.equal(details.causes?.[0]?.causes?.[0]?.causes, undefined);
42+
});

0 commit comments

Comments
 (0)