diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index aef0ac63e2..5c9c571148 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -3313,10 +3313,16 @@ export async function handleChatCore({ // Update model in body — use resolved alias so the provider gets the correct model ID (#472) // Strip provider/alias prefix if it exactly matches the routing prefix so upstream receives the raw model name (#1261) let finalModelToUpstream = effectiveModel; - if (finalModelToUpstream.startsWith(`${provider}/`)) { - finalModelToUpstream = finalModelToUpstream.slice(provider.length + 1); - } else if (alias && finalModelToUpstream.startsWith(`${alias}/`)) { - finalModelToUpstream = finalModelToUpstream.slice(alias.length + 1); + // Defense-in-depth: only string-strip when effectiveModel is actually a string. + // The API guards `model` via Zod (z.string()), but internal callers could pass a + // non-string and a bare `.startsWith` would crash with `startsWith is not a + // function` (same class as #2359 / #2463). Mirrors 9router's `?.startsWith?.()`. + if (typeof finalModelToUpstream === "string") { + if (finalModelToUpstream.startsWith(`${provider}/`)) { + finalModelToUpstream = finalModelToUpstream.slice(provider.length + 1); + } else if (alias && finalModelToUpstream.startsWith(`${alias}/`)) { + finalModelToUpstream = finalModelToUpstream.slice(alias.length + 1); + } } translatedBody.model = finalModelToUpstream; diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 61a49264db..84e6849b9e 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -3579,8 +3579,19 @@ export async function handleComboChat({ const structuredError = rawError && typeof rawError === "object" ? { - code: (rawError as Record).code as string, - type: (rawError as Record).type as string, + // Upstream JSON may carry a numeric `code`/`type` (e.g. {"code":40001}). + // Coerce to string if present instead of discarding, so downstream string + // ops (.toLowerCase, .startsWith) can run safely without type crashes. + code: + (rawError as Record).code !== undefined && + (rawError as Record).code !== null + ? String((rawError as Record).code) + : undefined, + type: + (rawError as Record).type !== undefined && + (rawError as Record).type !== null + ? String((rawError as Record).type) + : undefined, } : undefined; const fallbackResult = checkFallbackError( @@ -4115,8 +4126,19 @@ async function handleRoundRobinCombo({ const structuredError = rawError && typeof rawError === "object" ? { - code: (rawError as Record).code as string, - type: (rawError as Record).type as string, + // Upstream JSON may carry a numeric `code`/`type` (e.g. {"code":40001}). + // Coerce to string if present instead of discarding, so downstream string + // ops (.toLowerCase, .startsWith) can run safely without type crashes. + code: + (rawError as Record).code !== undefined && + (rawError as Record).code !== null + ? String((rawError as Record).code) + : undefined, + type: + (rawError as Record).type !== undefined && + (rawError as Record).type !== null + ? String((rawError as Record).type) + : undefined, } : undefined; const fallbackResult = checkFallbackError( diff --git a/open-sse/services/model.ts b/open-sse/services/model.ts index 6b64c846a6..83eac8100c 100644 --- a/open-sse/services/model.ts +++ b/open-sse/services/model.ts @@ -287,7 +287,11 @@ export function resolveCanonicalProviderModel( * Supports [1m] suffix for extended 1M context window (e.g. "claude-sonnet-4-6[1m]") */ export function parseModel(modelStr: string | null | undefined): ParsedModel { - if (!modelStr) { + // Guard truthy non-strings (object/number/array), not just falsy values — a + // malformed combo `modelStr` or providerSpecificData saved as an object would + // otherwise reach `cleanStr.endsWith("[1m]")` and crash with + // `endsWith is not a function`. Same class as #2359 / #2463. + if (!modelStr || typeof modelStr !== "string") { return { provider: null, model: null, diff --git a/open-sse/utils/proxyFetch.ts b/open-sse/utils/proxyFetch.ts index c8b4deb482..1e402cd480 100644 --- a/open-sse/utils/proxyFetch.ts +++ b/open-sse/utils/proxyFetch.ts @@ -317,7 +317,7 @@ async function patchedFetch( msg.includes("fetch failed") || errCode === "ECONNREFUSED" || msg.includes("ECONNREFUSED") || - (errCode !== undefined && errCode.startsWith("UND_ERR")) || + (typeof errCode === "string" && errCode.startsWith("UND_ERR")) || msg.includes("UND_ERR") ) { if (attempt === 0 && maxAttempts > 1) { diff --git a/scripts/ad-hoc/nvidia-startswith-diag.ts b/scripts/ad-hoc/nvidia-startswith-diag.ts new file mode 100644 index 0000000000..acf4a8b5b5 --- /dev/null +++ b/scripts/ad-hoc/nvidia-startswith-diag.ts @@ -0,0 +1,183 @@ +/** + * Diagnóstico NVIDIA NIM — `s.startsWith is not a function` (issue #2463 / report 3.8.5+) + * + * Como rodar (a chave NUNCA é commitada — vem por env): + * NVIDIA_API_KEY="nvapi-..." node --import tsx/esm scripts/ad-hoc/nvidia-startswith-diag.ts + * + * Opcional — apontar para outra base/model: + * NVIDIA_BASE_URL="https://integrate.api.nvidia.com/v1/chat/completions" + * NVIDIA_MODEL="openai/gpt-oss-120b" + * + * O que ele faz: + * Parte A — Validação real via validateProviderApiKey() (caminho do botão "testar conexão"). + * Parte B — Sanidade do upstream: POST direto na NVIDIA (isola a chave/model do nosso pipeline). + * Parte C — Probes de type-crash SEM chave: alimenta model malformado em resolveModelAlias + + * replica o strip de prefixo de chatCore.ts:3316 e parseModel(), capturando o stack + * NÃO-minificado (rodamos contra a fonte TS) para cravar a linha exata do startsWith. + * + * Parte C não precisa de chave — prova quais linhas são vulneráveis hoje. + */ + +const KEY = process.env.NVIDIA_API_KEY ?? ""; +const BASE_URL = process.env.NVIDIA_BASE_URL || "https://integrate.api.nvidia.com/v1/chat/completions"; +const MODEL = process.env.NVIDIA_MODEL || "openai/gpt-oss-120b"; + +const line = (s = "") => console.log(s); +const hr = () => line("─".repeat(72)); + +function show(label: string, value: unknown) { + line(` ${label}: ${typeof value === "string" ? value : JSON.stringify(value)}`); +} + +// ────────────────────────────────────────────────────────────────────────── +// Parte A — validateProviderApiKey (caminho de validação/teste de conexão) +// ────────────────────────────────────────────────────────────────────────── +async function partA() { + hr(); + line("PARTE A — validateProviderApiKey({ provider: 'nvidia' })"); + hr(); + if (!KEY) { + line(" ⏭ pulada — defina NVIDIA_API_KEY para rodar."); + return; + } + try { + const { validateProviderApiKey } = await import("../../src/lib/providers/validation.ts"); + const providerSpecificData = { baseUrl: BASE_URL }; + const result = await validateProviderApiKey({ + provider: "nvidia", + apiKey: KEY, + providerSpecificData, + }); + line(" ✅ validateProviderApiKey retornou (sem crash):"); + show("resultado", result); + if (typeof (result as any)?.error === "string" && (result as any).error.includes("startsWith")) { + line(" ⚠️ A mensagem de erro contém 'startsWith' → crash CAPTURADO dentro do try/catch da validação."); + } + } catch (err: any) { + line(" ❌ validateProviderApiKey LANÇOU (crash não tratado):"); + line(` ${err?.message}`); + line(err?.stack ?? String(err)); + } +} + +// ────────────────────────────────────────────────────────────────────────── +// Parte B — sanidade do upstream NVIDIA (isola chave/model do nosso pipeline) +// ────────────────────────────────────────────────────────────────────────── +async function partB() { + hr(); + line("PARTE B — POST direto no upstream NVIDIA (sanidade da chave/model)"); + hr(); + if (!KEY) { + line(" ⏭ pulada — defina NVIDIA_API_KEY para rodar."); + return; + } + const url = BASE_URL.endsWith("/chat/completions") ? BASE_URL : `${BASE_URL}/chat/completions`; + show("url", url); + show("model", MODEL); + try { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${KEY}` }, + body: JSON.stringify({ + model: MODEL, + messages: [{ role: "user", content: "ping" }], + max_tokens: 1, + }), + }); + const text = await res.text(); + show("status", res.status); + line(` body (256): ${text.slice(0, 256)}`); + if (res.ok) line(" ✅ upstream OK — chave e model válidos."); + else if (res.status === 401 || res.status === 403) line(" ❌ chave inválida (401/403)."); + else line(" ⚠️ não-OK não-auth — chave provavelmente válida, ver corpo."); + } catch (err: any) { + line(` ❌ fetch falhou: ${err?.message}`); + } +} + +// ────────────────────────────────────────────────────────────────────────── +// Parte C — probes de type-crash (sem chave) — crava a linha do startsWith +// ────────────────────────────────────────────────────────────────────────── +async function partC() { + hr(); + line("PARTE C — probes de type-crash (resolveModelAlias + strip chatCore:3316 + parseModel)"); + hr(); + + const { resolveModelAlias } = await import("../../open-sse/services/modelDeprecation.ts"); + const { parseModel } = await import("../../open-sse/services/model.ts"); + + // Replica EXATA do trecho de chatCore.ts:3315-3320 (feature #1261), sem guard. + function stripPrefixLikeChatCore(effectiveModel: any, provider: string, alias?: string) { + let finalModelToUpstream = effectiveModel; + if (finalModelToUpstream.startsWith(`${provider}/`)) { + finalModelToUpstream = finalModelToUpstream.slice(provider.length + 1); + } else if (alias && finalModelToUpstream.startsWith(`${alias}/`)) { + finalModelToUpstream = finalModelToUpstream.slice(alias.length + 1); + } + return finalModelToUpstream; + } + + const inputs: Array<{ label: string; model: any }> = [ + { label: "string normal (multi-barra NVIDIA)", model: "nvidia/openai/gpt-oss-120b" }, + { label: "objeto {} (UI bug / providerSpecificData mal salvo)", model: {} }, + { label: "objeto {id: '...'}", model: { id: "openai/gpt-oss-120b" } }, + { label: "number", model: 123 }, + { label: "array", model: ["openai/gpt-oss-120b"] }, + { label: "null", model: null }, + { label: "undefined", model: undefined }, + ]; + + for (const { label, model } of inputs) { + line(""); + line(` ▶ input: ${label} (typeof=${typeof model})`); + + // 1) resolveModelAlias — deixa não-string passar? (if (!modelId) return modelId) + let effective: any; + try { + effective = resolveModelAlias(model as any); + line(` resolveModelAlias → ${typeof effective} ${JSON.stringify(effective)}`); + } catch (err: any) { + line(` resolveModelAlias THROW: ${err?.message}`); + effective = model; + } + + // 2) strip de prefixo (chatCore:3316) — captura o stack EXATO + try { + const out = stripPrefixLikeChatCore(effective, "nvidia", "nvidia"); + line(` chatCore strip → ${JSON.stringify(out)} ✅ sem crash`); + } catch (err: any) { + line(` ❌ chatCore:3316 strip THROW: ${err?.message}`); + const at = (err?.stack ?? "").split("\n").find((l: string) => l.includes(".ts")); + if (at) line(` ${at.trim()}`); + } + + // 3) parseModel (model.ts:315) — captura o stack EXATO + try { + const parsed = parseModel(model as any); + line(` parseModel → ${JSON.stringify(parsed)} ✅ sem crash`); + } catch (err: any) { + line(` ❌ model.ts parseModel THROW: ${err?.message}`); + const at = (err?.stack ?? "").split("\n").find((l: string) => l.includes("model.ts")); + if (at) line(` ${at.trim()}`); + } + } +} + +async function main() { + line(""); + line("NVIDIA NIM — diagnóstico `startsWith is not a function`"); + show("NVIDIA_API_KEY presente", KEY ? `sim (${KEY.slice(0, 6)}…)` : "não"); + show("BASE_URL", BASE_URL); + show("MODEL", MODEL); + line(""); + await partA(); + await partB(); + await partC(); + hr(); + line("FIM."); +} + +main().catch((e) => { + console.error("erro fatal no diagnóstico:", e); + process.exit(1); +}); diff --git a/tests/unit/parse-model-defensive.test.ts b/tests/unit/parse-model-defensive.test.ts new file mode 100644 index 0000000000..e817cb61b3 --- /dev/null +++ b/tests/unit/parse-model-defensive.test.ts @@ -0,0 +1,32 @@ +/** + * #2463 — parseModel must not crash on a non-string truthy input. + * + * The NVIDIA NIM investigation (Part C) showed parseModel({}) threw + * `cleanStr.endsWith is not a function`: the `if (!modelStr)` guard only catches + * falsy values (null/undefined/""), so a truthy non-string (object/number/array + * — e.g. a malformed combo `modelStr` or providerSpecificData saved as an object + * by a UI bug) reached `cleanStr.endsWith("[1m]")` and crashed. Same class of bug + * as #2359 (combo modelStr) and the proxyFetch errCode crash. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { parseModel } from "../../open-sse/services/model.ts"; + +test("#2463 — parseModel returns a null result (no throw) for an object", () => { + assert.doesNotThrow(() => parseModel({} as never)); + const parsed = parseModel({} as never); + assert.equal(parsed.provider, null); + assert.equal(parsed.model, null); + assert.equal(parsed.isAlias, false); +}); + +test("#2463 — parseModel does not throw for number / array inputs", () => { + assert.doesNotThrow(() => parseModel(123 as never)); + assert.doesNotThrow(() => parseModel(["nvidia/foo"] as never)); +}); + +test("parseModel still parses a normal provider/model string unchanged", () => { + const parsed = parseModel("nvidia/openai/gpt-oss-120b"); + assert.equal(parsed.provider, "nvidia"); + assert.equal(parsed.model, "openai/gpt-oss-120b"); +}); diff --git a/tests/unit/proxyfetch-undici-retry.test.ts b/tests/unit/proxyfetch-undici-retry.test.ts index e78e9acd40..aef4e7cecc 100644 --- a/tests/unit/proxyfetch-undici-retry.test.ts +++ b/tests/unit/proxyfetch-undici-retry.test.ts @@ -78,6 +78,47 @@ test("retry-succeeds: undici fails once then succeeds, native fallback is NOT in assert.equal(await res.text(), "undici-retry-success"); }); +test("#2463 — undici error with a NON-STRING code must not crash on errCode.startsWith (NVIDIA NIM)", async () => { + // Real-world: the undici v8 dispatcher can throw an error whose `.code` is a + // number (system errno) rather than a string. The fallback guard checked only + // `errCode !== undefined` before calling `errCode.startsWith("UND_ERR")`, so a + // numeric code crashed with `errCode.startsWith is not a function` — surfaced to + // the user as `s.startsWith is not a function` and, crucially, the crash fired + // BEFORE the native fallback could run, so NVIDIA NIM failed "all the time". + // + // The message here contains "UND_ERR" (a retryable dispatcher error) but the + // `||` short-circuit only reaches the `msg.includes("UND_ERR")` clause if the + // earlier `errCode.startsWith(...)` clause does not throw first. + let undiciCalls = 0; + let nativeCalls = 0; + + const mockUndici = async (_input: RequestInfo | URL, _init?: RequestInit): Promise => { + undiciCalls++; + const err = new Error("UND_ERR_SOCKET: other side closed") as Error & { code?: unknown }; + (err as { code?: unknown }).code = -111; // numeric code (e.g. ECONNREFUSED errno) + throw err; + }; + + const mockNative = async (_input: RequestInfo | URL, _init?: RequestInit): Promise => { + nativeCalls++; + return new Response("native-fallback-body", { status: 200 }); + }; + + const res = await proxyFetch( + "https://integrate.api.nvidia.com/v1/chat/completions", + { method: "POST" }, + { undiciFetch: mockUndici, nativeFetch: mockNative } + ); + + assert.equal(undiciCalls, 2, "undici must retry once before falling back (UND_ERR is retryable)"); + assert.equal( + nativeCalls, + 1, + "native fallback must fire — a numeric error code must not crash on errCode.startsWith" + ); + assert.equal(await res.text(), "native-fallback-body"); +}); + test("does not retry when body is a ReadableStream (non-replayable body)", async () => { let undiciCalls = 0; let nativeCalls = 0;