|
@@ -33,6 +33,7 @@ export type OnStreamProgress = (progress: StreamProgress) => void;
|
|
|
|
|
|
|
|
const INKOS_USER_AGENT = "InkOS/1.3.5";
|
|
const INKOS_USER_AGENT = "InkOS/1.3.5";
|
|
|
const UNKNOWN_MODEL_FALLBACK_MAX_TOKENS = 8192 * 3;
|
|
const UNKNOWN_MODEL_FALLBACK_MAX_TOKENS = 8192 * 3;
|
|
|
|
|
+const TRANSIENT_LLM_RETRIES = 2;
|
|
|
|
|
|
|
|
function mergeUserAgent(headers?: Record<string, string>): Record<string, string> {
|
|
function mergeUserAgent(headers?: Record<string, string>): Record<string, string> {
|
|
|
return { "User-Agent": INKOS_USER_AGENT, ...(headers ?? {}) };
|
|
return { "User-Agent": INKOS_USER_AGENT, ...(headers ?? {}) };
|
|
@@ -366,7 +367,17 @@ function wrapLLMError(error: unknown, context?: { readonly baseUrl?: string; rea
|
|
|
`API 返回 429 (请求过多)。请稍后重试,或检查 API 配额。${ctxLine}`,
|
|
`API 返回 429 (请求过多)。请稍后重试,或检查 API 配额。${ctxLine}`,
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
- if (msg.includes("Connection error") || msg.includes("ECONNREFUSED") || msg.includes("ENOTFOUND") || msg.includes("fetch failed")) {
|
|
|
|
|
|
|
+ if (
|
|
|
|
|
+ msg.includes("Connection error")
|
|
|
|
|
+ || msg.includes("ECONNREFUSED")
|
|
|
|
|
+ || msg.includes("ENOTFOUND")
|
|
|
|
|
+ || msg.includes("fetch failed")
|
|
|
|
|
+ || msg.includes("terminated")
|
|
|
|
|
+ || msg.includes("UND_ERR_SOCKET")
|
|
|
|
|
+ || msg.includes("ECONNRESET")
|
|
|
|
|
+ || msg.includes("ETIMEDOUT")
|
|
|
|
|
+ || msg.includes("EPIPE")
|
|
|
|
|
+ ) {
|
|
|
return new Error(
|
|
return new Error(
|
|
|
`无法连接到 API 服务。可能原因:\n` +
|
|
`无法连接到 API 服务。可能原因:\n` +
|
|
|
` 1. baseUrl 地址不正确(当前:${context?.baseUrl ?? "未知"})\n` +
|
|
` 1. baseUrl 地址不正确(当前:${context?.baseUrl ?? "未知"})\n` +
|
|
@@ -399,6 +410,61 @@ function wrapLLMError(error: unknown, context?: { readonly baseUrl?: string; rea
|
|
|
return error instanceof Error ? error : new Error(msg);
|
|
return error instanceof Error ? error : new Error(msg);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function collectErrorText(error: unknown, depth = 0): string {
|
|
|
|
|
+ if (depth > 4 || error === null || error === undefined) return "";
|
|
|
|
|
+ const parts = [String(error)];
|
|
|
|
|
+ if (error instanceof Error) {
|
|
|
|
|
+ parts.push(error.name, error.message);
|
|
|
|
|
+ const cause = (error as Error & { cause?: unknown }).cause;
|
|
|
|
|
+ if (cause) parts.push(collectErrorText(cause, depth + 1));
|
|
|
|
|
+ } else if (typeof error === "object") {
|
|
|
|
|
+ const err = error as { code?: unknown; cause?: unknown; message?: unknown; name?: unknown };
|
|
|
|
|
+ if (err.name) parts.push(String(err.name));
|
|
|
|
|
+ if (err.message) parts.push(String(err.message));
|
|
|
|
|
+ if (err.code) parts.push(String(err.code));
|
|
|
|
|
+ if (err.cause) parts.push(collectErrorText(err.cause, depth + 1));
|
|
|
|
|
+ }
|
|
|
|
|
+ return parts.join("\n");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function isTransientLLMTransportError(error: unknown): boolean {
|
|
|
|
|
+ const text = collectErrorText(error);
|
|
|
|
|
+ return [
|
|
|
|
|
+ "terminated",
|
|
|
|
|
+ "UND_ERR_SOCKET",
|
|
|
|
|
+ "ECONNRESET",
|
|
|
|
|
+ "ETIMEDOUT",
|
|
|
|
|
+ "EPIPE",
|
|
|
|
|
+ "socket hang up",
|
|
|
|
|
+ "other side closed",
|
|
|
|
|
+ "network socket disconnected",
|
|
|
|
|
+ ].some((needle) => text.includes(needle));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function withTransientLLMRetry<T>(
|
|
|
|
|
+ run: () => Promise<T>,
|
|
|
|
|
+ options?: { readonly enabled?: boolean },
|
|
|
|
|
+): Promise<T> {
|
|
|
|
|
+ const enabled = options?.enabled ?? true;
|
|
|
|
|
+ let lastError: unknown;
|
|
|
|
|
+ for (let attempt = 0; attempt <= TRANSIENT_LLM_RETRIES; attempt++) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ return await run();
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ lastError = error;
|
|
|
|
|
+ if (
|
|
|
|
|
+ !enabled
|
|
|
|
|
+ || attempt >= TRANSIENT_LLM_RETRIES
|
|
|
|
|
+ || error instanceof PartialResponseError
|
|
|
|
|
+ || !isTransientLLMTransportError(error)
|
|
|
|
|
+ ) {
|
|
|
|
|
+ throw error;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ throw lastError;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function shouldUseNativeCustomTransport(client: LLMClient): boolean {
|
|
function shouldUseNativeCustomTransport(client: LLMClient): boolean {
|
|
|
return client.configSource === "studio"
|
|
return client.configSource === "studio"
|
|
|
&& client.service === "custom"
|
|
&& client.service === "custom"
|
|
@@ -904,10 +970,16 @@ export async function chatCompletion(
|
|
|
const errorCtx = { baseUrl: client._piModel?.baseUrl ?? "(unknown)", model };
|
|
const errorCtx = { baseUrl: client._piModel?.baseUrl ?? "(unknown)", model };
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
- if (shouldUseNativeCustomTransport(client)) {
|
|
|
|
|
- return await chatCompletionViaCustomOpenAICompatible(client, model, messages, resolved, onStreamProgress, onTextDelta);
|
|
|
|
|
- }
|
|
|
|
|
- return await chatCompletionViaPiAi(client, model, messages, resolved, onStreamProgress, onTextDelta);
|
|
|
|
|
|
|
+ return await withTransientLLMRetry(
|
|
|
|
|
+ async () => {
|
|
|
|
|
+ if (shouldUseNativeCustomTransport(client)) {
|
|
|
|
|
+ return chatCompletionViaCustomOpenAICompatible(client, model, messages, resolved, onStreamProgress, onTextDelta);
|
|
|
|
|
+ }
|
|
|
|
|
+ return chatCompletionViaPiAi(client, model, messages, resolved, onStreamProgress, onTextDelta);
|
|
|
|
|
+ },
|
|
|
|
|
+ // Retrying after UI text deltas have been emitted can duplicate visible text.
|
|
|
|
|
+ { enabled: !onTextDelta },
|
|
|
|
|
+ );
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
// Stream interrupted but partial content is usable — return truncated response
|
|
// Stream interrupted but partial content is usable — return truncated response
|
|
|
if (error instanceof PartialResponseError) {
|
|
if (error instanceof PartialResponseError) {
|