diff --git a/docs/ops/VM_DEPLOYMENT_GUIDE.md b/docs/ops/VM_DEPLOYMENT_GUIDE.md index 3a885626b0..c0e64c74e7 100644 --- a/docs/ops/VM_DEPLOYMENT_GUIDE.md +++ b/docs/ops/VM_DEPLOYMENT_GUIDE.md @@ -429,6 +429,7 @@ For deployments on small VPS instances (1 GB RAM or less): - **Disable background services** — set `OMNIROUTE_DISABLE_BACKGROUND_SERVICES=1` to skip scheduler, MCP server, and periodic maintenance tasks. See `docs/reference/ENVIRONMENT.md`. - **Use SQLite WAL mode** — enabled by default, reduces peak memory during concurrent reads. -- **Limit connection concurrency** — reduce `OMNIROUTE_MAX_POOL_SIZE` and `OMNIROUTE_DB_POOL_SIZE` in your environment. +- **Cap the V8 heap** — set `OMNIROUTE_MEMORY_MB` (e.g. `512`) so the runtime does not calibrate a ceiling larger than the VM. See `docs/reference/ENVIRONMENT.md`. +- **Limit concurrent heavy requests** — lower `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` (default `1`); excess requests get a retryable `503` with `Retry-After` instead of competing for memory. - **Avoid `next build` on the VPS** — build locally and deploy the standalone output (`.next/standalone/`). - **Monitor with `top` / `free -m`** — OmniRoute typically uses 200-400 MB RSS at idle on a 1 GB VM. diff --git a/open-sse/services/accountSemaphore.ts b/open-sse/services/accountSemaphore.ts index ddb629e12e..2d3a1f50b8 100644 --- a/open-sse/services/accountSemaphore.ts +++ b/open-sse/services/accountSemaphore.ts @@ -54,8 +54,21 @@ export function buildAccountSemaphoreKey({ return `${String(provider)}:${String(accountKey)}`; } +/** + * Effective positive cap, or null when the semaphore is bypassed (unset/<=0). + * + * Narrowing companion of {@link isBypassed}: that one returns a plain boolean, so + * TypeScript cannot narrow `number | null` to `number` in its else-branch (a + * `x is null | undefined` predicate would be unsound — 0 bypasses too). Callers + * that need the VALUE after the guard go through here instead of casting. + */ +function resolveActiveCap(maxConcurrency?: number | null): number | null { + if (maxConcurrency == null || maxConcurrency <= 0) return null; + return maxConcurrency; +} + function isBypassed(maxConcurrency?: number | null): boolean { - return maxConcurrency == null || maxConcurrency <= 0; + return resolveActiveCap(maxConcurrency) === null; } function createNoopReleaseFn(): () => void { @@ -192,7 +205,8 @@ export function acquire( maxQueueSize = DEFAULT_MAX_QUEUE_SIZE, }: AcquireAccountSemaphoreOptions = {} ): Promise<() => void> { - if (isBypassed(maxConcurrency)) { + const activeCap = resolveActiveCap(maxConcurrency); + if (activeCap === null) { return Promise.resolve(createNoopReleaseFn()); } @@ -200,7 +214,7 @@ export function acquire( return Promise.reject(makeAbortError(signal)); } - const gate = ensureGate(semaphoreKey, maxConcurrency); + const gate = ensureGate(semaphoreKey, activeCap); clearCleanupTimer(gate); if (gate.running < gate.maxConcurrency && !isBlocked(gate)) { diff --git a/open-sse/services/combo/comboStructure.ts b/open-sse/services/combo/comboStructure.ts index dd121d7e0d..176f41a99f 100644 --- a/open-sse/services/combo/comboStructure.ts +++ b/open-sse/services/combo/comboStructure.ts @@ -137,7 +137,9 @@ function normalizeRuntimeStep( : {}), weight, label, - prompt: step.prompt || null, + // `prompt` is a per-step pipeline input and only exists on a model step — + // #8894 widened the union with ComboProviderWildcardStep, which has no prompt. + prompt: (step.kind === "model" ? step.prompt : null) || null, } satisfies ResolvedComboTarget; } diff --git a/open-sse/services/combo/fusionPanel.ts b/open-sse/services/combo/fusionPanel.ts index 6397c5120c..a0f3d36d30 100644 --- a/open-sse/services/combo/fusionPanel.ts +++ b/open-sse/services/combo/fusionPanel.ts @@ -10,7 +10,7 @@ * literal `auto/*` string panel member already behaves via the single- * dispatch safety net in src/sse/handlers/chat.ts. */ -import { normalizeComboStep } from "../../../src/lib/combos/steps.ts"; +import { getComboModelString, normalizeComboStep } from "../../../src/lib/combos/steps.ts"; import { executeComboRefUnit } from "./runtimeUnits.ts"; import type { ComboCollectionLike, @@ -51,7 +51,11 @@ export function extractFusionPanelSpec( panel.push(step.comboName); return; } - panel.push(step.model); + // #8894 widened ComboStep with ComboProviderWildcardStep, which carries a + // modelPattern instead of a model. getComboModelString() already resolves any + // step shape (and returns null for the ones with no concrete model id). + const modelStr = getComboModelString(step); + if (modelStr) panel.push(modelStr); }); return { panel, comboRefUnits }; } diff --git a/open-sse/services/compression/engines/ccr/index.ts b/open-sse/services/compression/engines/ccr/index.ts index e854666182..d0b5ba10e7 100644 --- a/open-sse/services/compression/engines/ccr/index.ts +++ b/open-sse/services/compression/engines/ccr/index.ts @@ -292,7 +292,8 @@ function rehydrateEntry(hash: string, principalId: string, now: number): CcrEntr // Re-admit through the same budgets a fresh store would face. If the block no longer // fits, it stays on disk and is served straight from the row instead of being cached. - if (enforcePrincipalBudget(entry.principalId, entry.bytes) && enforceGlobalBudget(entry.bytes)) { + const { principalId: owner, bytes } = entry; + if (enforcePrincipalBudget(owner, bytes) && enforceGlobalBudget(owner, bytes)) { const key = buildStoreKey(hash, principalId === ANON ? undefined : principalId); ccrStore.set(key, entry); ccrTotalBytes += entry.bytes; diff --git a/open-sse/services/firecrawlQuotaFetcher.ts b/open-sse/services/firecrawlQuotaFetcher.ts index 9f0784fa06..021ccedfc9 100644 --- a/open-sse/services/firecrawlQuotaFetcher.ts +++ b/open-sse/services/firecrawlQuotaFetcher.ts @@ -110,7 +110,8 @@ export function getFirecrawlBaseUrl(connection?: Record): strin return envBase.replace(/\/+$/, ""); } const providerData = toRecord(connection?.providerSpecificData); - const connBase = typeof connection?.baseUrl === "string" ? connection.baseUrl : providerData?.baseUrl; + const connBase = + typeof connection?.baseUrl === "string" ? connection.baseUrl : providerData?.baseUrl; if (typeof connBase === "string" && connBase.trim() && !connBase.includes("api.firecrawl.dev")) { return connBase.trim().replace(/\/+$/, ""); } @@ -120,7 +121,11 @@ export function getFirecrawlBaseUrl(connection?: Record): strin export async function fetchFirecrawlQuota( connectionId: string, connection?: Record -): Promise { + // FirecrawlQuota, not the base QuotaInfo: every return here is a full credit + // breakdown (remainingCredits / planCredits / extraCreditsInferred / overPlan), + // and the narrower annotation made the custom-base literal below an excess- + // property error. FirecrawlQuota extends QuotaInfo, so callers are unaffected. +): Promise { const cached = quotaCache.get(connectionId); if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { return cached.quota; diff --git a/tests/unit/ccr-durable-store-9061.test.ts b/tests/unit/ccr-durable-store-9061.test.ts index dec112749e..bc2929ebae 100644 --- a/tests/unit/ccr-durable-store-9061.test.ts +++ b/tests/unit/ccr-durable-store-9061.test.ts @@ -119,6 +119,32 @@ describe("CCR engine survives losing its in-memory map (#9061)", () => { ); }); + it("re-admits the disk row into the fresh map (the enforceGlobalBudget arity bug)", async () => { + // The restart path re-admits a disk-served block through the same budgets a fresh + // store would face. #9061 called enforceGlobalBudget(entry.bytes) with ONE argument + // against a (owner, bytes) signature: `bytes` arrived undefined, `ccrTotalBytes + + // undefined` is NaN, and `NaN <= MAX` is false — so the re-admit never happened and + // the map stayed empty, re-reading from disk on every single retrieve. Typecheck + // caught the arity; this pins the observable behaviour. + const text = "z".repeat(2_000); + const stored = ccr.tryStoreBlock(text, "principal-readmit"); + assert.equal(stored.stored, true); + await ccr.flushCcrDurableWrites(); + + const restarted = await import(`${ccrPath}?restart=9061-readmit`); + assert.equal( + restarted.getCcrStoreStats("principal-readmit").entries, + 0, + "a fresh instance starts with an empty map" + ); + assert.equal(restarted.retrieveBlock(stored.hash, "principal-readmit"), text); + assert.equal( + restarted.getCcrStoreStats("principal-readmit").entries, + 1, + "the disk-served block must be re-admitted into the map, not re-read every time" + ); + }); + it("keeps the principal boundary across the restart", async () => { const text = "y".repeat(2_000); const stored = ccr.tryStoreBlock(text, "principal-a");