openpencil/apps/web/server/api/ai/provider-url.ts
Kayshen-X 4ed1203bf1 fix(ai): unwrap fetch error.cause for actionable network failures
Custom OpenAI-compatible providers surfaced Node's opaque
`TypeError: fetch failed` whenever the upstream HTTP call failed
(#121) — DNS, TLS handshake, connection refused, timeout — all
collapsed to the same useless string. The actual reason was already
on `error.cause` as a SystemError but never reached the user.

Add `formatFetchError()` that walks the cause chain (including
AggregateError emitted when undici tries multiple A records and each
attempt fails) and prefixes the SystemError code so users see
`ENOTFOUND: getaddrinfo ENOTFOUND api.foo.com` or
`ECONNREFUSED: connect ECONNREFUSED 127.0.0.1:443` instead of
`fetch failed`. Wire it into the model-list proxy (most common
trigger from the AI Settings dialog) and the builtin chat stream.

Closes #121
2026-04-26 19:20:32 +08:00

107 lines
4 KiB
TypeScript

export function normalizeBaseURL(baseURL: string): string {
return baseURL.trim().replace(/\/+$/, '');
}
export function normalizeOptionalBaseURL(baseURL?: string): string | undefined {
if (!baseURL) return undefined;
const normalized = normalizeBaseURL(baseURL);
return normalized.length > 0 ? normalized : undefined;
}
export function normalizeOpenAICompatBaseURL(baseURL?: string): string | undefined {
const normalized = normalizeOptionalBaseURL(baseURL);
if (!normalized) return undefined;
switch (normalized) {
case 'https://ark.cn-beijing.volces.com/api/v3/v1':
return 'https://ark.cn-beijing.volces.com/api/v3';
case 'https://ark.cn-beijing.volces.com/api/coding/v3/v1':
return 'https://ark.cn-beijing.volces.com/api/coding/v3';
case 'https://open.bigmodel.cn/api/paas/v4/v1':
return 'https://open.bigmodel.cn/api/paas/v4';
case 'https://api.z.ai/api/paas/v4/v1':
case 'https://open.z.ai/api/paas/v4/v1':
return 'https://api.z.ai/api/paas/v4';
case 'https://open.bigmodel.cn/api/coding/paas/v4/v1':
return 'https://open.bigmodel.cn/api/coding/paas/v4';
case 'https://api.z.ai/api/coding/paas/v4/v1':
case 'https://open.z.ai/api/coding/paas/v4/v1':
return 'https://api.z.ai/api/coding/paas/v4';
case 'https://generativelanguage.googleapis.com/v1beta/openai/v1':
return 'https://generativelanguage.googleapis.com/v1beta/openai';
default:
return normalized;
}
}
export function requireOpenAICompatBaseURL(baseURL?: string): string {
const normalized = normalizeOpenAICompatBaseURL(baseURL);
if (!normalized) {
throw new Error('OpenAI-compatible provider requires baseURL');
}
return normalized;
}
/**
* Normalize a team-member's baseURL. Throws if an openai-compat member has no baseURL.
*/
export function normalizeMemberBaseURL(
memberId: string,
providerType: string,
baseURL?: string,
): string | undefined {
const normalized =
providerType === 'openai-compat'
? normalizeOpenAICompatBaseURL(baseURL)
: normalizeOptionalBaseURL(baseURL);
if (providerType === 'openai-compat' && !normalized) {
throw new Error(`Member "${memberId}" (openai-compat) requires baseURL`);
}
return normalized;
}
export function buildProviderModelsURL(baseURL: string): string {
return `${normalizeOpenAICompatBaseURL(baseURL) ?? normalizeBaseURL(baseURL)}/models`;
}
/**
* Node's `fetch` (undici) collapses every network-level failure — DNS, TLS,
* refused connection, timeout — into a single opaque `TypeError: fetch failed`.
* The real reason lives on `error.cause` as a SystemError with `code`/`syscall`/
* `hostname`. Surface that so users can act on it ("ENOTFOUND api.foo.com",
* "self-signed certificate", "ECONNREFUSED 127.0.0.1:443") instead of staring
* at "fetch failed".
*
* Walks the `cause` chain (some failures wrap multiple times) and unwraps
* AggregateError (undici emits one when DNS returns multiple A records and
* every connect attempt fails) so each leaf reason makes it to the user.
*/
export function formatFetchError(error: unknown): string {
const reasons = collectFetchErrorReasons(error);
if (reasons.length === 0) {
return error instanceof Error ? error.message || 'Unknown error' : 'Unknown error';
}
return Array.from(new Set(reasons)).join('; ');
}
function collectFetchErrorReasons(error: unknown, depth = 0): string[] {
if (depth > 5 || !(error instanceof Error)) return [];
const aggregate = (error as { errors?: unknown }).errors;
if (Array.isArray(aggregate) && aggregate.length > 0) {
return aggregate.flatMap((sub) => collectFetchErrorReasons(sub, depth + 1));
}
const cause = (error as { cause?: unknown }).cause;
if (cause instanceof Error) {
return collectFetchErrorReasons(cause, depth + 1);
}
const code = (error as { code?: string }).code;
const message = error.message?.trim();
if (code && message && !message.includes(code)) return [`${code}: ${message}`];
if (message) return [message];
if (code) return [code];
return [];
}