Compare commits

...
1 Commits
Author SHA1 Message Date
esengine ee577995d0 fix(errors): actionable messages for 401/402/422 + V4 context-overflow hint (0.5.23)
Before: DeepSeek auth/balance/parameter errors surfaced as raw
"DeepSeek 401: {\"error\":{\"message\":\"...\"}}" JSON blobs. Users
saw a wall of JSON and had to know to parse it themselves to learn
their API key was bad or they were out of credit.

formatLoopError now recognizes the per-code cases:

  401 → "Authentication failed (DeepSeek 401): <inner msg>. Your API
         key is rejected. Fix with `reasonix setup` or
         `export DEEPSEEK_API_KEY=sk-...`. Get one at
         https://platform.deepseek.com/api_keys."
  402 → "Out of balance (DeepSeek 402): <inner msg>. Top up at
         https://platform.deepseek.com/top_up — the panel header
         shows your balance once it's non-zero."
  422 → "Invalid parameter (DeepSeek 422): <inner msg>"
  400 → "Bad request (DeepSeek 400): <inner msg>"

Each unwraps `{"error":{"message":"..."}}` so the reader sees prose,
not JSON. Fallback keeps the raw body if parsing fails.

Context-overflow text also refreshed: mentions V4's 1M window AND
the legacy 131k for the chat/reasoner aliases, and drops the stale
"v0.3.0-alpha.6+" reference that's long-since rolled into the 0.5
release line. The "8k tokens per tool result" cap is current
(src/mcp/registry.ts DEFAULT_MAX_RESULT_TOKENS).

429/500/502/503/504/408 retries unchanged — fully absorbed by
retry.ts before they reach formatLoopError.

1013 tests (+6). Lint / typecheck / build clean.
2026-04-23 21:22:14 -07:00
3 changed files with 115 additions and 12 deletions

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "reasonix",
"version": "0.5.22",
"version": "0.5.23",
"description": "DeepSeek-native agent framework: cache-first loop, R1 thought harvesting, tool-call repair.",
"type": "module",
"bin": {
+56 -8
View File
@@ -1511,22 +1511,70 @@ export function healLoadedMessagesByTokens(
}
/**
* Annotate the `DeepSeek 400: … maximum context length …` error the API
* returns when a session's history has grown past 131,072 tokens. The
* raw message is a JSON blob; we surface a short actionable hint on top
* so the user knows to `/forget` or `/clear` rather than parsing the
* JSON themselves. Other errors pass through unchanged — the loop's
* error channel already formats them well enough.
* Turn raw `DeepSeek NNN: {json}` errors into short actionable hints.
* Client code throws these verbatim from the HTTP layer (see client.ts);
* this is the one place the UI text layer reads to decide what the user
* actually needs to do about it.
*
* Covered codes (per DeepSeek's error-code doc):
* - 400 + "maximum context length" → context-overflow, point at /forget
* - 400 generic → strip the JSON, show inner message
* - 401 → API key rejected, point at `reasonix setup`
* - 402 → balance depleted, link to top-up page
* - 422 → param error, show inner message (usually explains which field)
*
* 429/500/502/503/504 are swallowed by retry.ts before they reach here;
* if they DO reach here (all retries exhausted), the raw string already
* says "DeepSeek 503: server busy" etc. which is informative enough.
*/
export function formatLoopError(err: Error): string {
const msg = err.message ?? "";
if (msg.includes("maximum context length")) {
// Pull the "requested X tokens" figure out of the JSON for scale.
const reqMatch = msg.match(/requested\s+(\d+)\s+tokens/);
const requested = reqMatch
? `${Number(reqMatch[1]).toLocaleString()} tokens`
: "too many tokens";
return `Context overflow (DeepSeek 400): session history is ${requested}, past the 131,072-token limit. Usually this means a single tool call returned a huge payload. v0.3.0-alpha.6+ caps new tool results at 32k chars, AND auto-heals oversized history on session load — restart Reasonix and this session should come back trimmed. If it still overflows, run /forget (delete the session) or /clear (drop the displayed history) to start fresh.`;
return `Context overflow (DeepSeek 400): session history is ${requested}, past the model's prompt limit (V4: 1M tokens; legacy chat/reasoner: 131k). Usually a single tool result grew too big. Reasonix caps new tool results at 8k tokens and auto-heals oversized history on session load — a restart often clears it. If it still overflows, run /forget (delete the session) or /clear (drop the displayed history) to start fresh.`;
}
const m = /^DeepSeek (\d{3}):\s*([\s\S]*)$/.exec(msg);
if (!m) return msg;
const status = m[1] ?? "";
const body = m[2] ?? "";
const inner = extractDeepSeekErrorMessage(body);
if (status === "401") {
return `Authentication failed (DeepSeek 401): ${inner}. Your API key is rejected. Fix with \`reasonix setup\` or \`export DEEPSEEK_API_KEY=sk-...\`. Get one at https://platform.deepseek.com/api_keys.`;
}
if (status === "402") {
return `Out of balance (DeepSeek 402): ${inner}. Top up at https://platform.deepseek.com/top_up — the panel header shows your balance once it's non-zero.`;
}
if (status === "422") {
return `Invalid parameter (DeepSeek 422): ${inner}`;
}
if (status === "400") {
return `Bad request (DeepSeek 400): ${inner}`;
}
return msg;
}
/**
* Pull the human-readable message out of a DeepSeek error response body
* (`{"error":{"message":"..."}}`). Falls back to the raw body when
* parsing fails — anything is better than eating the clue entirely.
*/
function extractDeepSeekErrorMessage(body: string): string {
const trimmed = body.trim();
if (!trimmed) return "(no message)";
try {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed === "object") {
const obj = parsed as { error?: { message?: unknown }; message?: unknown };
if (obj.error && typeof obj.error.message === "string") return obj.error.message;
if (typeof obj.message === "string") return obj.message;
}
} catch {
/* not JSON — fall through */
}
return trimmed;
}
+58 -3
View File
@@ -21,9 +21,48 @@ describe("formatLoopError", () => {
expect(out).toMatch(/929,452 tokens/); // pretty-printed from the raw JSON
});
it("leaves non-overflow errors unchanged", () => {
const raw = new Error("DeepSeek 401: invalid api key");
expect(formatLoopError(raw)).toBe("DeepSeek 401: invalid api key");
it("401 → authentication hint with setup/env var fix", () => {
const raw = new Error(
'DeepSeek 401: {"error":{"message":"Authentication Fails, Your api key is invalid"}}',
);
const out = formatLoopError(raw);
expect(out).toMatch(/Authentication failed/);
expect(out).toMatch(/reasonix setup/);
expect(out).toMatch(/DEEPSEEK_API_KEY/);
// Inner error.message survives the unwrap
expect(out).toContain("Your api key is invalid");
});
it("402 → balance hint with top-up URL", () => {
const raw = new Error('DeepSeek 402: {"error":{"message":"Insufficient Balance"}}');
const out = formatLoopError(raw);
expect(out).toMatch(/Out of balance/);
expect(out).toMatch(/top_up/);
expect(out).toContain("Insufficient Balance");
});
it("422 → invalid parameter with the server's reason", () => {
const raw = new Error(
'DeepSeek 422: {"error":{"message":"Invalid value for `temperature`: must be between 0 and 2"}}',
);
const out = formatLoopError(raw);
expect(out).toMatch(/Invalid parameter/);
expect(out).toContain("temperature");
});
it("400 (non-overflow) → extracts the inner error message, drops the JSON wrapping", () => {
const raw = new Error(
'DeepSeek 400: {"error":{"message":"request body malformed at messages[3].role"}}',
);
const out = formatLoopError(raw);
expect(out).toMatch(/Bad request/);
expect(out).toContain("messages[3].role");
expect(out).not.toContain("{"); // JSON wrapping is gone
});
it("leaves non-DeepSeek-shaped errors untouched", () => {
const raw = new Error("socket hang up");
expect(formatLoopError(raw)).toBe("socket hang up");
});
it("tolerates an overflow error without a requested-tokens figure", () => {
@@ -32,6 +71,22 @@ describe("formatLoopError", () => {
expect(out).toMatch(/Context overflow/);
expect(out).toMatch(/too many tokens/);
});
it("context-overflow message mentions both the 1M V4 limit and the legacy 131k", () => {
const raw = new Error(
'DeepSeek 400: {"error":{"message":"This model\'s maximum context length is 131072 tokens. However, you requested 200000 tokens."}}',
);
const out = formatLoopError(raw);
expect(out).toMatch(/1M/);
expect(out).toMatch(/131k/);
});
it("tolerates an empty or malformed JSON body on the error payload", () => {
const raw = new Error("DeepSeek 500: ");
const out = formatLoopError(raw);
// 500 isn't in the remapped set, so it passes through as-is
expect(out).toBe("DeepSeek 500: ");
});
});
describe("healLoadedMessages", () => {