refactor(web): unify form controls, one notification rule, dedupe i18n copy (#54)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c7465625a4
commit
3f868ac385
50 files changed
+2391
-1190
No files matched your search
@@ -701,6 +701,35 @@ export function translateEvents(
|
||||
// Retry policy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A fuller error string than `err.message` for the LLM request outcome. Node's `fetch`
|
||||
* wraps the real transport failure as `TypeError: terminated` and puts the actual reason
|
||||
* on `err.cause` (a socket close, `ECONNRESET`, a provider stream abort, …); taking only
|
||||
* `.message` throws that away and leaves a bare, unactionable "terminated". This walks the
|
||||
* `cause` chain and appends each level's message and error `code`, so it surfaces as e.g.
|
||||
* "terminated: other side closed (UND_ERR_SOCKET)". Segments are de-duplicated and the
|
||||
* chain walk guards against cycles; a non-Error cause tail (string/number) is still kept.
|
||||
*/
|
||||
export function describeError(error: unknown): string {
|
||||
if (!(error instanceof Error)) return String(error);
|
||||
const parts: string[] = [];
|
||||
const seen = new Set<unknown>();
|
||||
let cur: unknown = error;
|
||||
while (cur instanceof Error && !seen.has(cur)) {
|
||||
seen.add(cur);
|
||||
const code = (cur as { code?: unknown }).code;
|
||||
let piece = cur.message || cur.name;
|
||||
if (typeof code === "string" && code && !piece.includes(code)) piece = `${piece} (${code})`;
|
||||
if (piece && !parts.includes(piece)) parts.push(piece);
|
||||
cur = (cur as { cause?: unknown }).cause;
|
||||
}
|
||||
if (cur != null && !(cur instanceof Error)) {
|
||||
const tail = String(cur);
|
||||
if (tail && !parts.includes(tail)) parts.push(tail);
|
||||
}
|
||||
return parts.join(": ") || error.message || String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether an error is an AgentHub / Provider "response delivered but unusable"
|
||||
* parse or validation error. Two shapes (@prismshadow/agenthub 0.4.x):
|
||||
@@ -906,7 +935,7 @@ export class GenerativeModel implements LLMInterface {
|
||||
} catch (err) {
|
||||
return {
|
||||
status: "failed",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
message: describeError(err),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -984,7 +1013,7 @@ export class GenerativeModel implements LLMInterface {
|
||||
// malformed to reconnect and retry — must not be classified as failed.
|
||||
outcome = {
|
||||
status: "malformed",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
message: describeError(error),
|
||||
};
|
||||
} else if (isRetryableError(error)) {
|
||||
outcome = { status: "timeout" }; // Network drop/network error -> needs reconnection
|
||||
@@ -993,7 +1022,7 @@ export class GenerativeModel implements LLMInterface {
|
||||
} else {
|
||||
outcome = {
|
||||
status: "failed",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
message: describeError(error),
|
||||
};
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -18,9 +18,9 @@
|
||||
* Scope: excludes deepseek-chat / deepseek-reasoner legacy aliases that AgentHub cannot
|
||||
* auto-route (deprecated 2026-07-24), glm-5v-turbo (image input unsupported by AgentHub's GLM
|
||||
* client), non-chat models (embedding / image generation / TTS), and Bedrock. Direct-vendor
|
||||
* ids are auto-routed by AgentHub and leave client_type unset; gateway entries (OpenRouter /
|
||||
* SiliconFlow / Qwen Token Plan) can't be auto-routed, so they set `client_type: "openai"`
|
||||
* and inline their preset base URL.
|
||||
* ids are auto-routed by AgentHub and leave client_type unset; the five gateway groups
|
||||
* (OpenRouter, Fireworks AI, SiliconFlow, Qwen Token Plan, Qwen Pay-As-You-Go) can't be
|
||||
* auto-routed, so they set `client_type: "openai"` and inline their preset base URL.
|
||||
*
|
||||
* This file imports no Node built-ins (type-only imports only), so it can be bundled directly
|
||||
* for the browser.
|
||||
@@ -74,8 +74,9 @@ const FIREWORKS_BASE_URL = "https://api.fireworks.ai/inference/v1";
|
||||
|
||||
/**
|
||||
* Provider list (web model page groups in this order): DeepSeek first (the default model's
|
||||
* provider), followed by the OpenRouter, SiliconFlow, and Qwen Token Plan gateways, then
|
||||
* Google Gemini before Anthropic; custom groups custom OpenAI-protocol models and comes last.
|
||||
* provider), followed by the five gateways (OpenRouter, Fireworks AI, SiliconFlow, Qwen Token
|
||||
* Plan, Qwen Pay-As-You-Go), then the first-party providers Google Gemini, Anthropic, OpenAI,
|
||||
* Z.AI (GLM) and Moonshot (Kimi); custom groups custom OpenAI-protocol models and comes last.
|
||||
*/
|
||||
export const MODEL_PROVIDERS: ModelProviderInfo[] = [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describeError } from "../src/llm/generative-model.js";
|
||||
|
||||
describe("describeError", () => {
|
||||
it("walks the cause chain, surfacing the real reason behind a wrapper like 'terminated'", () => {
|
||||
// Node's fetch throws TypeError("terminated") with the actual transport failure on cause.
|
||||
const cause = Object.assign(new Error("other side closed"), { code: "UND_ERR_SOCKET" });
|
||||
const err = new TypeError("terminated", { cause });
|
||||
expect(describeError(err)).toBe("terminated: other side closed (UND_ERR_SOCKET)");
|
||||
});
|
||||
|
||||
it("appends a top-level error code when not already in the message", () => {
|
||||
const err = Object.assign(new Error("connect failed"), { code: "ECONNREFUSED" });
|
||||
expect(describeError(err)).toBe("connect failed (ECONNREFUSED)");
|
||||
});
|
||||
|
||||
it("does not duplicate a code already present in the message", () => {
|
||||
const err = Object.assign(new Error("connect ECONNREFUSED 1.2.3.4:443"), {
|
||||
code: "ECONNREFUSED",
|
||||
});
|
||||
expect(describeError(err)).toBe("connect ECONNREFUSED 1.2.3.4:443");
|
||||
});
|
||||
|
||||
it("returns a plain message unchanged and stringifies non-Errors", () => {
|
||||
expect(describeError(new Error("boom"))).toBe("boom");
|
||||
expect(describeError("nope")).toBe("nope");
|
||||
});
|
||||
|
||||
it("keeps a non-Error cause tail and de-duplicates repeated segments", () => {
|
||||
expect(describeError(new Error("outer", { cause: "inner reason" }))).toBe(
|
||||
"outer: inner reason",
|
||||
);
|
||||
const a = new Error("same");
|
||||
(a as { cause?: unknown }).cause = new Error("same"); // duplicate message, dropped
|
||||
expect(describeError(a)).toBe("same");
|
||||
});
|
||||
|
||||
it("guards against a cyclic cause chain", () => {
|
||||
const a = new Error("a");
|
||||
const b = new Error("b");
|
||||
(a as { cause?: unknown }).cause = b;
|
||||
(b as { cause?: unknown }).cause = a; // cycle
|
||||
expect(describeError(a)).toBe("a: b");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user