diff --git a/open-sse/handlers/responseSanitizer.ts b/open-sse/handlers/responseSanitizer.ts index 77a126ed34..56835b9b45 100644 --- a/open-sse/handlers/responseSanitizer.ts +++ b/open-sse/handlers/responseSanitizer.ts @@ -576,15 +576,21 @@ function sanitizeResponsesUsage(usage: unknown): unknown { /** * Normalize response ID to use chatcmpl- prefix. + * Preserves numeric/short custom ids as their string form rather than + * regenerating them — a passthrough numeric id (e.g. `123`) must stay `"123"` + * so streaming clients can correlate chunks (#3427/#5776). Only a genuinely + * missing/empty id gets a fresh `chatcmpl-` token. */ function normalizeResponseId(id: unknown): string { - if (!id || typeof id !== "string") { + if (!id || (typeof id !== "string" && typeof id !== "number")) { return `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 29)}`; } - // Already correct format - if (id.startsWith("chatcmpl-")) return id; - // Keep custom IDs but don't break them - return id; + const str = String(id); + if (str === "") { + return `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 29)}`; + } + // Already correct format, or a custom/numeric id — keep it. + return str; } function normalizeResponsesId(id: unknown): string { diff --git a/open-sse/services/autoCombo/builtinCatalog.ts b/open-sse/services/autoCombo/builtinCatalog.ts index adaca208b3..2a197dcb9b 100644 --- a/open-sse/services/autoCombo/builtinCatalog.ts +++ b/open-sse/services/autoCombo/builtinCatalog.ts @@ -182,6 +182,16 @@ export async function createBuiltinAutoCombo(modelStr: string, suffix: string) { return virtualCombo; } + // Advertised `auto/*` ids whose template maps to no variant (auto/chat, + // auto/best-chat, auto/pro-chat) still materialize via the default + // (unconstrained) virtual combo rather than throwing "Unknown built-in". + if (Object.prototype.hasOwnProperty.call(AUTO_TEMPLATE_VARIANTS, modelStr)) { + const virtualCombo = await createVirtualAutoCombo(undefined); + virtualCombo.name = modelStr; + virtualCombo.id = modelStr; + return virtualCombo; + } + // #4235 Phase B: `auto/[:]` (e.g. auto/coding:fast, auto/vision). const parsed = parseAutoSuffix(suffix); if (parsed.valid) { diff --git a/open-sse/utils/proxyFetch.ts b/open-sse/utils/proxyFetch.ts index 634f30735b..40a75e5b3c 100644 --- a/open-sse/utils/proxyFetch.ts +++ b/open-sse/utils/proxyFetch.ts @@ -1096,9 +1096,12 @@ async function patchedFetch( continue; } tagProxyUnreachable(error); + const originalMsg = error instanceof Error ? error.message : String(error); const sanitized = sanitizeTransportError( error, - "Proxy request failed", + originalMsg + ? `Proxy request failed: ${originalMsg}` + : "Proxy request failed", "PROXY_REQUEST_FAILED" ); console.error( diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index ec73914a24..ecd2b51272 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -1640,7 +1640,8 @@ export function createSSEStream(options: StreamOptions = {}) { (parsed.choices.length === 1 && parsed.choices[0]?.delta && typeof parsed.choices[0].delta === "object" && - Object.keys(parsed.choices[0].delta).length === 0)) + Object.keys(parsed.choices[0].delta).length === 0 && + !parsed.choices[0]?.finish_reason)) ) { const emptyChoicesUsage = extractUsage(parsed) ?? parsed.usage; if (hasValidUsage(emptyChoicesUsage)) { @@ -1850,6 +1851,7 @@ export function createSSEStream(options: StreamOptions = {}) { passthroughSawFinishReason = true; } + if (isFinishChunk && passthroughHasToolCalls) { toolFinishTime = now; try { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 101f08d936..213c3304e5 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -4948,6 +4948,7 @@ "baseUrlHint": "Required. Provider API base URL.", "iconUrlLabel": "Icon URL", "iconUrlHint": "Optional. Image URL shown as this provider's icon.", + "iconUrlInvalid": "Invalid icon URL. Use an http(s):// or data:image/*;base64 URL.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", diff --git a/src/lib/services/ServiceSupervisor.ts b/src/lib/services/ServiceSupervisor.ts index fd7fd43dd9..9ec8e44f6a 100644 --- a/src/lib/services/ServiceSupervisor.ts +++ b/src/lib/services/ServiceSupervisor.ts @@ -46,6 +46,7 @@ export class ServiceSupervisor extends EventEmitter { private lastError: string | null = null; private childProcess: ChildProcess | null = null; private adopted: boolean = false; + private spawnFailed: boolean = false; private readonly buffer: RingBuffer; private readonly checker: HealthChecker; private operationLock: Promise = Promise.resolve(); @@ -158,6 +159,7 @@ export class ServiceSupervisor extends EventEmitter { child = spawn(command, args, buildServiceSpawnOptions(env, cwd)); } catch (err) { this.checker.stop(); + this.spawnFailed = true; const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); this.lastError = msg; this.setState("error"); @@ -195,6 +197,7 @@ export class ServiceSupervisor extends EventEmitter { // the health poller hammers the dead port every healthIntervalMs. child.once("error", (err) => { this.checker.stop(); + this.spawnFailed = true; const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); this.lastError = msg; this.setState("error"); @@ -206,6 +209,12 @@ export class ServiceSupervisor extends EventEmitter { await this.waitForHealthy(); + // A spawn failure flips state to "error" — surface the explicit error + // status instead of overriding it with "running". + if (this.state === "error") { + return this.getStatus(); + } + this.setState("running"); await setToolStatus(this.config.tool, "running", this.pid ?? undefined); @@ -260,13 +269,21 @@ export class ServiceSupervisor extends EventEmitter { while (Date.now() < deadline) { if (this.checker.getHealth() === "healthy") return; - if (this.state === "error") throw new Error(this.lastError ?? "Service failed to start"); + // A spawn failure (child 'error' event or sync throw) flips state to + // "error" — stop polling and let start() surface the explicit error + // status as a resolve. A health-probe failure is a different, harder + // condition and must reject (handled below). + if (this.state === "error") { + if (this.spawnFailed) return; + throw new Error(this.lastError ?? "Service failed to start"); + } await new Promise((r) => setTimeout(r, 1_000)); } // Timeout reached without a healthy probe. The health poller may have // flipped the state to "error" while we were waiting (FAILURE_THRESHOLD // consecutive failures) — surface that instead of a degraded marker. if (this.state === "error") { + if (this.spawnFailed) return; throw new Error(this.lastError ?? "Service failed to start"); } this.lastError = sanitizeErrorMessage( diff --git a/tests/unit/build/optional-transformers-dependency.test.ts b/tests/unit/build/optional-transformers-dependency.test.ts index 7ebd121e07..07bf0d8b69 100644 --- a/tests/unit/build/optional-transformers-dependency.test.ts +++ b/tests/unit/build/optional-transformers-dependency.test.ts @@ -9,7 +9,14 @@ function readJson>(relPath: string): T { return JSON.parse(readFileSync(join(repoRoot, relPath), "utf8")) as T; } -test("@huggingface/transformers is optional so onnxruntime CUDA install failures cannot abort OmniRoute install", () => { +test("@huggingface/transformers is a regular dependency so npm ci never skips it", () => { + // #9962 deliberately moved @huggingface/transformers out of optionalDependencies: + // as an optional dep, npm silently skipped the whole subtree on Node 24/26 (old + // pin dragged onnxruntime-node@1.21.0 whose NAN build no longer compiles), which + // broke `npm ci`/`next build` with "Can't resolve @huggingface/transformers" + // (lazy import in src/lib/memory/embedding/transformersLocal.ts). As a regular + // dep with onnxruntime-node@~1.24.3 (napi prebuilds, no node-gyp) it stays + // installable and the memory embedding path requires() cleanly. const pkg = readJson<{ dependencies?: Record; optionalDependencies?: Record; @@ -17,29 +24,37 @@ test("@huggingface/transformers is optional so onnxruntime CUDA install failures assert.equal( pkg.dependencies?.["@huggingface/transformers"], - undefined, - "transformers must not be a regular dependency because it pulls onnxruntime-node install scripts" + "^4.2.0", + "transformers must be a regular dependency (never optional) so npm ci cannot skip it" ); - assert.equal(pkg.optionalDependencies?.["@huggingface/transformers"], "3.5.2"); + assert.equal(pkg.optionalDependencies?.["@huggingface/transformers"], undefined); }); -test("package-lock marks transformers and its onnxruntime runtime as optional", () => { +test("transformers + onnxruntime-node are regular dependencies (not optional)", () => { + const pkg = readJson<{ + dependencies?: Record; + optionalDependencies?: Record; + }>("package.json"); + + assert.equal( + pkg.dependencies?.["onnxruntime-node"], + "~1.24.3", + "onnxruntime-node is a regular dep (napi prebuilds, installable on Node 24/26)" + ); + assert.equal(pkg.optionalDependencies?.["onnxruntime-node"], undefined); + const lock = readJson<{ packages: Record; optionalDependencies?: Record }>; }>("package-lock.json"); assert.equal( lock.packages[""]?.dependencies?.["@huggingface/transformers"], - undefined, - "root lock dependencies must not keep transformers as mandatory" + "^4.2.0", + "root lock dependencies must hold transformers as a regular (non-optional) dep" ); - assert.equal(lock.packages[""]?.optionalDependencies?.["@huggingface/transformers"], "3.5.2"); - - for (const packagePath of [ - "node_modules/@huggingface/transformers", - "node_modules/onnxruntime-node", - "node_modules/onnxruntime-common", - ]) { - assert.equal(lock.packages[packagePath]?.optional, true, `${packagePath} should be optional`); - } + // Optional flag is only written `true` for genuinely optional packages; + // regular deps leave it absent/null. Assert each is NOT optional. + assert.ok(!lock.packages["node_modules/@huggingface/transformers"]?.optional, "transformers must not be marked optional in the lockfile"); + assert.ok(!lock.packages["node_modules/onnxruntime-node"]?.optional, "onnxruntime-node must not be marked optional in the lockfile"); + assert.ok(!lock.packages["node_modules/onnxruntime-common"]?.optional, "onnxruntime-common must not be marked optional in the lockfile"); });