Repository navigation
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
CompressionWorkerPool can be marked permanently broken (and drains queued jobs) on a structural spawn failure even when a healthy worker already exists, which can unnecessarily disable compression despite available capacity.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR fixes a production memory leak/jam class in the compression worker path by ensuring CompressionWorkerPool cannot strand queued jobs when worker spawning fails synchronously (e.g., Turbopack standalone MODULE_NOT_FOUND), and adds rate-limited observability for all fail-open paths (including LLMLingua worker failures).
Changes:
- Make
CompressionWorkerPoolfail-open and drain queued jobs on synchronous spawn errors, and short-circuit future runs when the pool is broken. - Add a shared, rate-limited fail-open notifier used by strategy selection, the pool, and the LLMLingua worker (with sanitization).
- Fix LLMLingua worker spawning on modern Node by passing a
URLobject (not afile://string) tonew Worker(...), and add regression tests.
File summaries
| File | Description |
|---|---|
| tests/unit/compression/compression-worker-pool-jam.test.ts | Adds regression tests covering synchronous spawn throws, queue draining, broken-pool behavior, notifier rate limiting, and LLMLingua worker specifier behavior. |
| stryker.conf.json | Includes the new unit test file in the mutation-test allowlist input set. |
| open-sse/services/compression/strategySelector.ts | Logs (rate-limited) when dynamic import / worker-path execution fails open, rather than silently swallowing. |
| open-sse/services/compression/failOpenNotifier.ts | Introduces the shared rate-limited + sanitized console.warn notifier used across compression worker fail-open paths. |
| open-sse/services/compression/engines/llmlingua/worker.ts | Uses a URL object specifier for Worker entry; adds logging on spawn/postMessage fail-open paths; adds test harness seam. |
| open-sse/services/compression/compressionWorkerPool.ts | Wraps spawn/postMessage failure paths to ensure queue draining and observability; adds test seam for worker construction. |
| changelog.d/fixes/3-compression-worker-pool-queue-jam.md | Documents the fix and related observability/LLMLingua worker specifier adjustment. |
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
🔵 Needs a closer look
The changelog fragment links to a fork (insoln/OmniRoute) instead of the canonical repository link pattern used elsewhere in changelog.d entries.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
changelog.d/fixes/3-compression-worker-pool-queue-jam.md:1
- The changelog fragment links to the
insoln/OmniRoutefork and PR#3, but existing changelog.d entries consistently link to the canonicaldiegosouzapw/OmniRouterepository. This will produce a broken/misleading reference when merged upstream; please update the link target (and PR/issue number if needed) to match the canonical repo’s URL pattern.
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Lite
|
Follow-up review addressed in f62ceff: the changelog now references canonical upstream PR cheaper-inference#12755 instead of fork PR #3. |
…cheaper-inference#13408) * test(coverage): clean stale coverage output before test:coverage runs * chore(changelog): add fragment for coverage pre-clean
…schemas (cheaper-inference#13561) Anthropic's Messages API rejects a tool whose `input_schema` carries a composition keyword at the root with: tools.N.custom.input_schema: input_schema does not support oneOf, allOf, or anyOf at the top level The refusal happens before inference, so a single MCP/agent tool carrying a root-level union fails every request that ships the catalog, and combo failover cannot recover from it. Both conversion paths that build a Claude `input_schema` from a client payload now flatten such a root union into a plain object schema: object-compatible branches contribute their `properties` (root wins on a name collision), the root is pinned to `type: "object"`, and only `allOf` contributes `required` — `anyOf`/`oneOf` branch requirements are alternatives and promoting them would refuse calls the original schema accepts. Nested unions are untouched and clean schemas pass through unchanged. Closes cheaper-inference#13552
…esolution (cheaper-inference#13564 cheaper-inference#13563) (cheaper-inference#13566) * fix(sse): release native Codex turn pin when pinned model is model-scoped unusable (cheaper-inference#13564) Long-running Codex sessions die with 400 NATIVE_CODEX_PINNED_MODEL_UNAVAILABLE whenever the model pinned to the current turn becomes model-scoped unusable mid-session (per-model quota lockout, connection cooldown, exhausted accounts). Claude Code has no equivalent pin and already falls back to the next healthy combo model; Codex now matches. Release the turn pin when all pinned provider+model targets are model-scoped unusable and fall through to full combo routing, re-pinning to whichever model succeeds. Preserve the pin on provider-wide outages (circuit breaker OPEN, provider cooldown) and when the pinned target is still healthy. Also prunes the stale ESLint suppression entry for combo.ts that this change orphaned (createPinnedModelUnavailableResponse import dropped; pre-existing getBootstrapLatencyMs remains the sole residual unused var). * fix(api): resolve codex-settings apiKey via canonical resolver instead of 400 (cheaper-inference#13563) Applying Codex settings from /dashboard/cli-code/codex always failed with 400 "baseUrl, apiKey and model are required" when the dashboard sent an empty apiKey (cloud mode with no management key selected) — baseUrl and model are already Zod-gated, so that response could only ever fire on the empty key. The codex-settings route had diverged from the sibling CLI tools (cline/forge/ openclaw/grok-build/jcode): an inline if(!apiKey) 400 guard plus a hand-rolled getApiKeyById lookup, instead of the shared resolveApiKey(keyId, apiKey) helper which resolves by keyId, falls back to the submitted apiKey, then to sk_omniroute. This change makes codex-settings use the canonical resolver, so: - empty apiKey + valid keyId -> the real DB key is written to auth.json - empty apiKey + no keyId -> sk_omniroute default (config still applies) - explicit apiKey -> written verbatim (unchanged) * docs(changelog): add fragments for Codex turn-pin fallback and codex-settings apiKey resolution
…aper-inference#13622) * fix(models): keep OpenRouter :batch variants out of chat routing ModelSync imported OpenRouter's Batch-API-only variants into the chat catalogue. A chat completion against one is rejected upstream with 404 This model is only available through the Batch API. Use the /api/beta/batches endpoint instead. which cheaper-inference#13596 measured 91 times in 41 hours, third by volume, plus the `model not found - locking mode` failover churn behind it. OpenRouter's /models carries no endpoint metadata that separates a batch variant from a chat one, so `classifyExplicitEndpoints` cannot decide it and the rule belongs in `modelEndpointPolicy`, beside the OpenAI image/video policy and for the same reason: the file exists so discovery, import and catalog projection agree on one answer. Matched as the exact `:batch` suffix, not "has a variant suffix" -- `:free`, `:nitro`, `:floor`, `:online`, `:extended` and `:thinking` are routing hints on the same chat model, and excluding them would silently shrink the routable catalogue. Applied unconditionally for this provider: there is no "batch" endpoint name an upstream could declare next to a chat one, and the already-stored rows carry the synthetic `["chat"]` default that re-imported them in the first place. Closes cheaper-inference#13596 * docs(changelog): add fragment for the OpenRouter batch-variant fix
…etime purchases (cheaper-inference#13689) * fix(providers): sync OpenRouter :free 1000/day tier from /credits lifetime purchases * chore: rename changelog fragment to PR number 13689
…quest (cheaper-inference#13738) Google validates every `functionDeclarations[].name` against one grammar and rejects the WHOLE GenerateContentRequest when any single one is invalid, so six `1c_*` tools in a 109-tool MCP catalog made every request 400, including requests that would never call them (cheaper-inference#13715). `normalizeGeminiToolName` removed invalid characters, collapsed underscores and stripped leading and trailing ones, none of which touches a leading digit. The strip is also why a leading underscore was not a workaround: the client's `_1c_probe` was normalized back to `1c_probe` and rejected for the same reason. The prefix goes inside the normalizer rather than at the call site, because that keeps the guarantee on the one value every path reads. `buildHashedGemini ToolName` builds its name from the normalized string and inherits its first character, so a fix applied later would hold for short names and fail silently for the long ones a 109-tool catalog is full of. It also runs before the collision check, so two names that newly collide are still separated by the existing hashed path. The reverse direction needs nothing: the sanitized name now differs from the client's, so `buildChangedToolNameMap` carries the original and the response translator restores the client's spelling on the model's functionCall. Six cells: no declared name starts with a digit, a leading underscore reaches Google letter-first, the reverse map returns the client's spelling, a letter-first name is untouched, an over-long digit-first name keeps the guarantee through the hash path, and two colliding digit-first names stay distinct. Four mutations, all killed.
…lectors (cheaper-inference#13740) * fix(api): type compatible-provider-node models in /v1/models by the node's apiType Model rows discovered from an OpenAI-compatible provider node rarely carry endpoint metadata — a TEI / Infinity / vLLM `/v1/models` listing is just ids — and the catalog defaulted such rows to `["chat"]`. An `embeddings`-typed node exposing `bge-m3` and a `rerank`-typed node exposing `bge-reranker-v2-m3` therefore both surfaced in GET /v1/models as untyped chat models: clients that build their picker from `type: "embedding"` / `type: "rerank"` never saw them, and chat pickers listed models that 400 on chat. - src/shared/constants/modelSupportedEndpoints.ts: add defaultEndpointsForProviderNodeApiType(apiType) — embeddings → ["embeddings"], rerank → ["rerank"], audio-* → themselves, images-generations → ["images"], chat/responses/unknown → ["chat"] (unchanged default). - src/app/api/v1/models/catalog.ts: build a node-id → apiType map next to the existing node-id → type map; the synced-model and custom-model loops fall back to the node's modality instead of ["chat"] when a row has no supportedEndpoints; the custom-overlay merge path also classifies `type`/`subtype` from the overlay's explicit supportedEndpoints, so a manual `["rerank"]` row layered on a discovered chat-default row is re-typed. Explicit supportedEndpoints on any row still take precedence, and chat / responses nodes keep the historical behavior. tests/unit/catalog-provider-node-apitype-endpoints.test.ts covers the helper and the catalog end-to-end for embeddings, rerank, mixed, chat, and overlay cases via getUnifiedModelsResponse(). * chore(changelog): name the cheaper-inference#13734 fragment * refactor(api): keep the provider-node modality helpers out of catalog.ts catalog.ts is frozen by the file-size gate (must not grow past 2075 lines) and the apiType fallback pushed it to 2093. Move the node apiType index, the endpoint fallback and the overlay type/subtype fields into catalogNodeModality.ts so catalog.ts ends one line shorter than the base; behaviour and tests are unchanged. * fix(api): give nodeModelEndpoints a string[] return so the catalog classifier typechecks The API-route typecheck gate flagged TS2345 at both classifyModelSupportedEndpoints() call sites: the helper returned `ModelSupportedEndpoint[] | unknown[]`, and unknown[] is not a readonly string[]. The base code only passed because the synced row's supportedEndpoints was untyped. Same pass-through cast overlayEndpoints() already uses; no behaviour change. * fix(memory): list provider-node models in the embedding and rerank selectors GET /api/memory/embedding-providers and GET /api/memory/rerank-providers appended local provider nodes by apiType alone and always with models: []. A node typed "embeddings" that also serves a rerank model — one TEI / Infinity / vLLM box hosting both bge-m3 and bge-reranker-v2-m3 is the common self-hosted layout — was filtered out of the Rerank selector entirely (apiType not in chat/responses/rerank) and showed up in the Embedding selector as a provider with nothing to pick. Typing prefix/model by hand worked because the request path resolves it directly; only the convenience layer was blind. Add src/lib/memory/embedding/nodeModalityListings.ts, which builds the listing from the node's synced + custom model rows, typing each row the way /v1/models does (explicit supportedEndpoints wins, otherwise the node's apiType via defaultEndpointsForProviderNodeApiType; a custom overlay re-types a discovered row). A node is listed for a modality when its apiType matches, when it is a generic chat/responses node (historical behaviour, kept so catalog-less nodes still appear), or when any of its rows is typed for the modality. Both endpoints use it; the curated registries stay first and win on prefix collisions. * chore(changelog): name the cheaper-inference#13740 fragment
…r-inference#13685) (cheaper-inference#13741) Co-authored-by: Goni Sulaiman <gonisulaimann@users.noreply.github.com>
…inference#13750) bug_report.yml and feature_request.yml prefilled `[BUG] ` / `[Feature] ` while CONTRIBUTING.md documents Conventional Commits with a scope list, so reporters who followed the template verbatim had their title renamed by triage. The templates now teach the convention instead: `fix(): ` and `feat(): `. Each form gains one intro line pointing at the commit-message section of CONTRIBUTING.md, where the scope list lives. `test_coverage_task.yml` is left untouched, as its `[Coverage] ` prefix is not part of the convention. Refs cheaper-inference#13688
cheaper-inference#13760) * fix(usage): preserve nested prompt cache reads * docs(changelog): add nested cache-read cost fix --------- Co-authored-by: 千乘妍 (Xiaoyaner) <xiaoyaner0201@users.noreply.github.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
…13809) Co-authored-by: ginettododo <117327638+ginettododo@users.noreply.github.com>
Co-authored-by: ginettododo <117327638+ginettododo@users.noreply.github.com>
… every PR (cheaper-inference#13814) * fix(ci): clear the tap.testFiles drift that reds the gate on every PR check-mutation-test-coverage --strict fails on a pristine checkout of release/v3.8.51 with no PR diff involved, so the mutation-test-coverage gate is red on every open PR regardless of what it changes. Six covering unit tests across four mutated modules were absent from stryker.conf.json tap.testFiles, which means their mutant kills were not being counted: accountFallback.ts daily-reset-tz-threading, noauth-model-lockout sse/services/auth.ts free-badge-provider-gate, noauth-model-lockout combo/comboPredicates.ts local-token-budget-429-skips-cooldown combo/rrState.ts daily-reset-tz-threading Four distinct files — two of them cover two modules each. Inserted into the alphabetical run, matching the file's existing convention; the list has a second unsorted appended group that is left alone. After: "No drift — every covering unit test is listed in tap.testFiles", exit 0. All four files pass (31 tests) so registering them does not introduce a failing mutation run. Noticed while reviewing cheaper-inference#13743, which targets a fifth file that has already been registered by 25bc16d. * fix(ci): drop a dangling tap.testFiles entry and guard against new ones Merging the release line in surfaced that stryker.conf.json still names tests/unit/plugin-sandbox-permissions.test.ts, which does not exist — one dangling path out of 428 entries, pre-existing on the base rather than introduced here. check-mutation-test-coverage already guards one direction: a test that covers a mutated module but is missing from tap.testFiles. The other direction was silent. Stryker resolves the list into its sandbox, so an entry left behind after its test file is deleted or renamed costs coverage without failing loudly — the same class of drift this PR is about, arriving from the opposite side.
…nce#13819) Co-authored-by: William Echo <175406538+qinghuanandejiangshi@users.noreply.github.com>
…nymously (cheaper-inference#13820) * feat(security): warn at boot when the inference server is exposed anonymously `GET /v1/models` follows the dashboard login posture (`isAuthRequired()` / `requireAuthForModels`) while the inference routes follow `REQUIRE_API_KEY`. On an instance with an admin password set and `REQUIRE_API_KEY=false`, `/v1/models` answers 401 while `/v1/responses` is open to anyone who can reach the port — so the most natural probe an operator runs reports the opposite of the truth. cheaper-inference#12568 added a boot warning for exactly this combination, but wired it only into the API bridge and the live dashboard WebSocket. The Next server that actually answers `/v1/chat/completions` and `/v1/responses` never reached it, and it is the one that binds every interface by default (`process.env.HOST || "0.0.0.0"`). Wire the existing guard into the Next boot hook, and document the split. Resolving the bound host needed care: two entrypoints bind that server and they read different variables. `run-next.mjs` honours `HOST`; the Docker entrypoint delegates to Next's generated `server.js`, which reads `HOSTNAME`. `run-next.mjs` now publishes what it actually binds as `OMNIROUTE_BOUND_HOST`, and the guard reads that, then `HOSTNAME`, then the shared `0.0.0.0` default. `HOST` is deliberately absent from the chain: the standalone server ignores it, so consulting it there would warn about an interface the server is not on — and one false warning teaches an operator to ignore the next one. Closes cheaper-inference#13695 * docs(changelog): add changelog.d entry for cheaper-inference#13820
…tation (cheaper-inference#13825) * fix(providers): refresh uncloseai free-model roster after upstream rotation hermes.ai.unturf.com rotated its lineup: /v1/models now serves exactly one model (Lorbus/Qwen3.6-27B-int4-AutoRound, vllm, max_model_len 65536) while every previously catalogued id (adamo1139/Hermes-3-Llama-3.1-8B-FP8-Dynamic, qwen3.6:27b, gemma4:31b) returns 404 on /v1/chat/completions. Requests routed through the static seed failed although the provider itself is healthy — a live completion against the new id succeeds. - registry seed: replace the three dead ids with the live one (+contextLength) - FREE_MODEL_BUDGETS: 3 rows -> 1 (catalog totals 443 -> 441; counts synced in README and free-tier-budget.svg) - noauth authHint: verified-live-model pointer updated; PROVIDER_REFERENCE.md regenerated - regression test pins the live id and forbids the retired ids in both the registry seed and the free catalog Verified live on 2026-09-15 against https://hermes.ai.unturf.com/v1/models and /v1/chat/completions. * docs(providers): sync free-tier entry count after uncloseai merge The uncloseai roster refresh (3 entries -> 1) dropped the live free-tier catalog total from 491 to 489 once merged with the current release tip. README.md and free-tier-budget.svg still quoted 491 after the merge; update both to the real count so check:docs-counts-sync stays green. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: anon <anon@users.noreply.github.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
…e#13837) Co-authored-by: Paco Cartones <pacocartones@users.noreply.github.com>
Co-authored-by: luwei.will <luwei.will@bytedance.com>
… OpenAI-format providers (cheaper-inference#13855) Built-in providers that speak the OpenAI Chat wire format skipped normalizeOpenAICompatibleTools(), which only ran for custom openai-compatible-* connections. A client tool whose type is not "function" (a named Claude server tool, a nameless hosted tool) was forwarded verbatim, and agentrouter's GLM backend rejected the whole request with 400 tools[0].type:type is illegal. Extract the gate into shouldNormalizeFunctionToolsOnly(): custom openai-compatible-* providers keep normalizing on every target, and a conservative allowlist of built-in providers (agentrouter first) normalizes on the OpenAI Chat target only. OpenAI itself stays off the list, so its custom tools pass through untouched. Closes cheaper-inference#13789
* fix(capabilities): align combo multimodal vision hints * test(capabilities): cover combo multimodal consistency * chore(changelog): note combo multimodal capability fix
…aper-inference#13923) Co-authored-by: Max <maxmad64@gmail.com>
…meters (cheaper-inference#14279) Gemini/antigravity returned a hard 400 ("Unknown name \"~optional\" ... Cannot find field") the moment a tool's parameter schema contained a `~optional` key, taking down every Gemini-family model in a combo's fallback chain at once -- confirmed live: this exact error killed 100% of a production combo's usable fallbacks for ~18 hours (the two remaining candidates ahead of it were OpenCode free-tier models, permanently inaccessible via API regardless). Root cause: `~`-prefixed keys are the Standard Schema convention (Zod 4+, Valibot, ArkType) for internal/vendor metadata, namespaced with a leading `~` specifically so it can never collide with a real schema property name. A tool built from one of those libraries leaked a literal `~optional` key into a property's subschema. GEMINI_UNSUPPORTED_SCHEMA_KEYS already listed the plain `"optional"` string, but `removeUnsupportedKeywords`'s exact-match check doesn't catch the tilde-prefixed form, so it survived sanitization and Gemini's OpenAPI 3.0 schema subset rejected the whole request. Fix: strip any `~`-prefixed key at every schema level, the same way `x-` vendor extensions are already stripped -- this covers `~optional` and any other Standard Schema metadata key the same libraries may emit, rather than only patching this one literal key. Testing: new regression test confirmed failing on unpatched code and passing after the fix; full existing Gemini/schema-stripping test suite (495 tests) still green.
…soning model aliases (cheaper-inference#14277) (cheaper-inference#14281) When selecting the first-class `codex-app-server` provider directly through the executor registry (`open-sse/executors/index.ts`), the lazy loader instantiated `CodexAppServerExecutor({}, "codex-app-server")` without supplying a WebSocket transport function, preventing any connection from opening. Additionally, `CodexAppServerExecutor.execute` passed the raw `input.model` directly to `turn/start` without stripping reasoning suffix aliases (e.g., `gpt-5.5-medium`), which caused upstream app-server turns to fail because the aliased name is not recognized as a valid model ID. Fixes: 1. In `open-sse/executors/index.ts`, wire the shared WebSocket transport (`codex.getCodexAppServerWebsocketTransport()`) into the `codex-app-server` executor factory. 2. In `open-sse/executors/codex-app-server.ts`, use `splitCodexReasoningSuffix` to send `baseModel` to `turn/start` while passing the selected effort separately, prioritizing explicit model suffix selection over request body defaults (cheaper-inference#2331). Also support `body.reasoning_effort` in `extractEffort`. Added regression tests in `tests/unit/codex-app-server.test.ts` verifying: - baseModel extraction and effort derivation from model suffixes on `turn/start` - precedence of suffix effort over body reasoning effort - forwarding of body reasoning effort when the model is unsuffixed - registry executor initialization with injected transport Co-authored-by: Goni Sulaiman <gonisulaimann@users.noreply.github.com> Co-authored-by: Aref Alapour <aref-alapour@users.noreply.github.com>
…s a projectId (cheaper-inference#14282) * fix(antigravity): never persist or send the manual-project sentinel as a projectId ensureAntigravityProjectAssigned() returns __REQUIRES_GCP_PROJECT__ when Google does not auto-provision a project (BYOP). Only the in-request path guarded it; token refresh, models discovery and the executor refresh persisted it as the connection's projectId. The connection then looked configured, was PREFERRED by preferAntigravityConnectionsWithStoredProject, and every request went upstream as projects/__REQUIRES_GCP_PROJECT__ (HTTP 400, no lockout, no account failover), so one poisoned account served nearly all traffic. Add isUsableAntigravityProjectId() (rejects blank + the sentinel) and use it in the persist helper, the stored-project selector, token refresh, models discovery and the executor. A row already poisoned now behaves as "no project": it takes the typed 422 GCP_PROJECT_REQUIRED path and is dropped by the selector when a healthy sibling exists. * chore(changelog): add fragment for cheaper-inference#14282
…r-inference#14284) * feat(kiro): expose the provider-native Opus 5 Max effort tier Advertise "<base>-max" for Kiro's claude-opus-5 in the Claude effort catalog, allow "max" in the Kiro effort values, and enable the adaptive-thinking envelope for claude-opus-5. The original change (the effortStandardization / adaptiveThinking edits and the kiro-opus-5-max-effort test) is by tarciorick. The scoped KIRO_OPUS_5_MAX_VARIANT_RE guard is an addition: the shared CLAUDE_EFFORT_SUFFIX_RE must stay byte-identical to its sibling copies (drift guard in claude-effort-variants.test.ts), so the synthesized -max id is excluded by a separate regex scoped to that one id. * chore(changelog): add fragment for cheaper-inference#14284
…e and usage resets (cheaper-inference#14268) (cheaper-inference#14285) When stacked prompt compression telemetry was added, per-engine breakdown logs were written to `compression_engine_breakdown`. While the parent `compression_analytics` table was regularly trimmed on an operator-configurable retention policy (default 30 days) and purged on usage resets, `compression_engine_breakdown` was omitted from both `cleanup.ts` and `RESET_TARGETS`. In long-running deployments (5+ months), this table accumulated 731,000+ unpruned rows without any deletion path. Wired `compression_engine_breakdown` into `cleanupCompressionEngineBreakdown()` with a 30-day cutoff, hooked it into nightly auto-cleanup, and added the table to `RESET_TARGETS` so manual purges clean it completely. Fixes cheaper-inference#14268 Co-authored-by: Goni Sulaiman <gonisulaimann@users.noreply.github.com> Co-authored-by: Koosha Paridehpour <KooshaPari@users.noreply.github.com>
…nference#14289) * fix(providers): fail closed for retired gemini-cli * docs(changelog): name fragment for PR 14289
…y and the real poll route (cheaper-inference#14300)
…stener is not read as free (cheaper-inference#14307) The cheaper-inference#4425 port guard waits for the listen port to free up before respawning a crashed child. It probed only 127.0.0.1, which does not detect the address `omniroute serve` actually binds by default: 0.0.0.0. Node sets SO_REUSEADDR on every listener it creates. On macOS/BSD that lets a specific-address bind coexist with an existing wildcard bind (Linux still rejects the overlap in LISTEN state), so binding 127.0.0.1:PORT succeeds while another process holds 0.0.0.0:PORT. The probe therefore reported "free", the wait resolved immediately, the respawned child failed with EADDRINUSE, and the crash/restart cascade cheaper-inference#4425 set out to fix continued — indefinitely under a `KeepAlive` supervisor such as launchd or systemd. Probe the wildcard and loopback (plus any caller-supplied host) and treat the port as busy if any of them is occupied. Verified on macOS 27.0 / Node 22.22.3: with a server holding 0.0.0.0:PORT, `isPortFree(PORT)` returned true before and now returns false, and a child rebind of 0.0.0.0:PORT fails with EADDRINUSE as expected. Tests: tests/unit/supervisor-policy-4425.test.ts Co-authored-by: Gery.MK Song <gery@macunzip.dev>
…stay eligible (cheaper-inference#14312) * feat(sse): forward the auto mode classifier beta to Anthropic-format upstreams * docs(changelog): fragment for the auto mode classifier beta pass-through * refactor(sse): move the client beta application into anthropicHeaders
…s API (cheaper-inference#14316) DeepSeek's primary public API is /chat/completions, not /responses. The previous default caused multi-turn tool-call requests to fail with: 400 The reasoning_text in the thinking mode must be passed back to the API because the Responses API requires the caller to echo reasoning_text on every turn, while the Chat Completions protocol does not. Changes: - format: "openai-responses" → "openai" - baseUrl: .../responses → .../chat/completions - Move openai-responses to alternateFormats ("Responses-compatible") so operators who explicitly need that path can still select it per-connection - Update unit test assertions to match the new default and alternate count
…ing (cheaper-inference#12474) * feat(providers): add Lyceum pay-per-use provider + credit quota Lyceum (lyceum.technology) is an OpenAI-compatible, usage-based inference provider. Registered as a first-class apikey provider mirroring the llmgateway/openrouter pattern: - registry/lyceum: buildOpenAiCompatibleRegistryEntry (base https://api.lyceum.technology/openai/v1, live /models discovery, passthrough). Generic DefaultExecutor handles chat/embeddings — no custom executor or translator needed. - apikey metadata card + AGGREGATOR_PROVIDER_IDS + PROVIDER_ENDPOINTS. - lyceumQuotaFetcher: reads the credit balance from GET /api/v2/external/billing/credits (pay-per-use), surfaced as a "credits" window in Dashboard > Limits and quota-aware preflight. Registered via quotaTrackersBatch (keeps chat.ts frozen). - usage leaf + dispatch + fetcher/supported/apikey-limits lists + label. - tests: registry-shape (wave1-c) + 16-case quota fetcher/usage suite. - docs: changelog fragment, regenerated PROVIDER_REFERENCE, provider count 354->355 across README/AGENTS/llm.txt (+42 i18n mirrors)/SVGs, file-size baseline bump for the gateways.ts catalog entry. * docs(providers): regenerate provider reference for Lyceum (359→360) Rebased onto release/v3.8.51 after cheaper-inference#12462 (LLM Gateway DevPass quota) merged and the base advanced. The live registry now totals 360 providers, so the auto-generated reference and the diagram/marketing counts are regenerated against the new base: - npm run gen:provider-reference → docs/reference/PROVIDER_REFERENCE.md - 359→360 in README.md, AGENTS.md, llm.txt (provider count), package.json description and the diagram SVGs (readme-hero, promise-pillars, comparison-table, cli-terminal, tier-flow-dark/light) - node scripts/i18n/sync-llm-mirrors.mjs → 65 locale mirrors Addresses the maintainer review asking to rerun gen-provider-reference.ts and push the doc files missing from the original diff, which left Docs Gates red. * test(providers): refresh count assertions + golden snapshot for the new Lyceum provider
…C-741) (cheaper-inference#13610) * feat(db): per-request cost ledger + per-key tpm/rpm/monthly quota (RIC-741) Add M3 cost transparency + team autonomy: - request_cost_ledger: one row per completed call with provider/model/token/ unit-price/amount breakdown, written from the existing recordCost paths. - api_key_quota_limits + api_key_quota_counters: KISS counter+threshold quota for tpm (tokens/minute) and rpm (requests/minute) via 2-bucket sliding window; monthly USD cap reads from the ledger month SUM. - checkKeyQuota/recordKeyQuotaUsage domain gate (fail-open B16/B29), wired into enforceApiKeyPolicy pre-request and the usage-record post hooks. - /api/usage/key-quota route + setKeyQuotaSchema for per-key config. - Migration 177; tests cover ledger traceability and tpm/rpm/monthly break. * fix(db): renumber cost-ledger/key-quota migration to 180 177 and 178 already landed on release/v3.8.51 by the time this branch was analyzed (177_provider_connection_synced_models_at.sql, 178_memory_fts_au_conditional.sql); 179 also landed since. Renumber to the next free slot and fix the file's own internal comment to match. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(db): use the canonical toNumber helper instead of local redefinitions ESLint no-restricted-syntax bars new local toNumber definitions in favor of @/shared/utils/numeric (cheaper-inference#7879, DRY extraction) — replace the 3 near-identical local copies in costLedger.ts, keyQuota.ts and costLedgerRecorder.ts. Also wires keyQuota.ts's getKeyQuotaStatus to reuse the already-defined toIsoWindowStart helper instead of duplicating the window-start math inline, which fixes the unused-var lint error on that function. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * refactor(sse): keep chatCore.ts under its frozen file-size ratchet The recordCost() wiring for the per-request cost ledger added ~24 lines to chatCore.ts, an already-frozen file (file-size-baseline.json caps it at 6146 lines). Extract the shared provider/model/tokens/serviceTier/requestId breakdown into buildCostCtx() and the apiKeyInfo?.id && estimatedCost > 0 guard into recordChatCallCost() (both in src/domain/costRules.ts), and the streaming ledger-details object into buildStreamLedgerDetails() in streamingCost.ts. No behavior change: same 18 cost-ledger/domain-cost-rules tests pass unmodified; net effect is chatCore.ts now at 6143 lines (3 under the frozen ceiling) instead of 6165. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * docs: bump the migration count to 178 after the new cost-ledger/key-quota migration check:docs-all (stale-migrations, STRICT) failed because README.md, AGENTS.md and llm.txt (plus its 65 docs/i18n/*/llm.txt mirrors, which must be exact body copies of the root file) still said "177 migrations" after this PR added migration 181_request_cost_ledger_and_key_quota.sql, bringing the real count to 178. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Ant Rich <ant@richants.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
The release tip went red on check:file-size after the 2026-09-22 merge wave: cheaper-inference#14179 grew open-sse/executors/opencode.ts past its frozen ceiling and the PR->release fast-gates do not run check:file-size, so every merge-train boarding afterwards inherits the red. Absorbed once at the tip under the owner-approved train-rebaseline policy. Measured clean: check:file-size passes on the tip with this entry.
…eneration footers (cheaper-inference#14441) Hard Rule cheaper-inference#16 had no automated enforcement: eight contributor commits reached release/v3.8.51 carrying `Co-Authored-By: Claude …`, `Codex <codex@openai.com>` and `Claude-Session:` trailers through squash-merge bodies (cheaper-inference#14436). The gate runs in three places: the husky commit-msg hook (local), the quality.yml fast-gates loop (PR→release/**) and a PR-only lint step in ci.yml (PR→main). It reads the event payload on PRs and no-ops elsewhere. Human co-authors are explicitly allowed — only AI/bot names, AI-owned e-mail domains and AI-generation footers are rejected. Refs cheaper-inference#14436.
…heaper-inference#14356) * feat(dashboard): let synced models carry a context-window override Models imported through a provider's /models import render as "imported" rows with no edit affordance, so the manual context-window override cheaper-inference#4125 added for custom models was unreachable for exactly the models that need it most: third -party OpenAI-compatible aggregators whose discovery metadata understates context length. The GUI offered no correction path at all. The backend already accepted the write. `PUT /api/provider-models` lists `contextWindowOverride` in its compatOnly branch, which is the path taken when no `customModels` row exists, and persists it through `setModelContextOverride` with `source="manual"` — the source the 24h reconciler never overwrites. The read path was the missing half. `GET /api/provider-models` attached `contextWindowOverride` only to custom-model rows, so a value stored against a synced model could be written and never read back: the UI had nothing to show and nothing to seed an editor with. The GET now returns the provider's overrides directly, and the row renders the same badge and inline editor as a custom row. cheaper-inference#4125 semantics are unchanged and now shared rather than duplicated: the parse helper moves to providerPageHelpers so "blank clears" and "zero is invalid" have one definition instead of two that can drift. Both new props are optional and the affordance renders only when the section supplies a handler, so a row that cannot take an override is byte-identical to before. Invalid input keeps the editor open and reports, rather than silently writing a wrong value or discarding the one typed. No new i18n keys: the contextWindowOverride* strings already ship in all 66 locales for the custom-model form. Mutation-tested 4/4 — rendering the pencil unconditionally, treating blank as invalid, accepting zero, and closing the editor on invalid input each fail the new tests. tests/unit/ui is 1458 pass / 94 fail against 1452 / 94 on a stashed tree: the same failures, plus my six. Does not implement the max-output-tokens half of the issue: unlike contextWindowOverride, no capability key is in the PUT compatOnly allowlist, so that needs its own backend change and belongs in its own PR. Closes cheaper-inference#14337 * fix(dashboard): drop the invalid Badge variant on the override badge `check:dashboard-typecheck` caught a real one: `Badge` has no `secondary` variant, so the override badge raised TS2322 under tsconfig.typecheck-dashboard.json. My local `tsc --noEmit -p tsconfig.json` never saw it — that config does not cover these files the same way, which is the whole reason the dashboard gate exists as a separate scoped check with a frozen baseline. Rendered as the same styled span CustomModelsSection already uses for this exact badge, rather than picking another variant: same visual treatment as the custom -model 🪟 badge, and one fewer component contract to get wrong. With this, the dashboard gate reports the same 5 pre-existing errors on this branch as on release/v3.8.51 — verified by running the check in a worktree on the base — so the branch adds none.
…nference#14412) Detected by a full-repo Grounded (reference-integrity) audit and confirmed with targeted tsc probes against the root tsconfig.json paths: - src/lib/db/discovery.ts: logger import "../../open-sse/utils/logger" cannot resolve from src/lib/db/ (TS2307); use the @omniroute/open-sse alias like the sibling DB modules. Masked in CI because typecheck:core compiles an empty include set. - scripts/ad-hoc/regen-opencode-config.ts: import and Usage header still pointed at the pre-move root scripts/ location (TS2307); fixed both so the script runs exactly as documented. - tests/unit/combo/recovery-hint.test.ts: import renamed the ghost type RecoveryAction -> ComboRecoveryAction (TS2305) and rebuilt the emptyDiag() fixture on the current ComboDiagnostics shape (post-cheaper-inference#12659); all 10 behavioral assertions unchanged. Validation: node --import tsx/esm --test tests/unit/combo/recovery-hint.test.ts (11/11 pass), scoped tsc probe (0 errors), eslint clean on changed files. Audit reference: https://github.com/gonisulaimann/Grounded Co-authored-by: Goni Sulaiman <gonisulaimann@users.noreply.github.com>
…sponse (cheaper-inference#14456) (cheaper-inference#14457) * fix(resilience): release admission lease on client disconnect (cheaper-inference#14456) releaseChatAdmissionWhenDone wired lease release into three consumer-driven paths only: pull-to-done, pull-throws, and cancel. A client that disconnects mid-stream may stop pulling and never cancel, so none of them run and the heavyweight slot is charged for the lifetime of the process. Once activeHeavy reaches OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT every heavy request is shed with 503 chat_admission_busy, with waiting=0 and no real load. Observe the request signal as a fallback release path and route all five call sites through it. Release is idempotent so a late abort after a clean finish cannot double-decrement. * refactor(admission): extract lease release into chatAdmissionRelease check:file-size freezes chatBodyAdmission.ts at 1206 lines and the fix pushed it to 1242. Move the release binding into its own module and re-export it from the original path, so every existing import site is unchanged. File is now 1159 lines; check:file-size passes. --------- Co-authored-by: xiaoyaner0201 <xiaoyaner0201@users.noreply.github.com>
…eaper-inference#14460) * fix(translator): keep a Claude image whose source is an HTTPS URL Claude accepts an image block as { source: { type: "url", url } } next to base64. claude-to-openai.ts took that in its image case (592ca9b, 05/04) but not in the tool_result branch below it, where image lifting was added for base64 only (7b139fd, cheaper-inference#5100, 27/06): a tool returning a URL image had the raw block JSON.stringify'd into the tool message. claude-to-gemini.ts had no URL branch at all — cheaper-inference#13335 lifted base64 tool_result images there and said so in its notes, leaving the URL half for a follow-up. This is it. Lift a URL tool_result image into the following user turn, as the base64 half already does, and send a URL image to Gemini as fileData { fileUri }, the mapping helpers/geminiHelper.ts uses for an OpenAI image_url that is a URL. HTTPS only, as validation/schemas/apiV1.ts already requires of every media URL and as Gemini documents for an external fileUri: an empty, http:, data: or file: url stays dropped instead of reaching Gemini as a fileUri it would reject. * docs(changelog): add fragment for cheaper-inference#14460 --------- Co-authored-by: datrixlab <325650023+datrixlab@users.noreply.github.com>
…nference#13999) (cheaper-inference#14439) * fix(api): settle logs export stream on mid-stream DB error (cheaper-inference#13999) * refactor(api): extract the log-export stream builder to clear the new-code complexity ratchet (cheaper-inference#13999)
…release:reconcile (cheaper-inference#14440) readFragments ran one `git log --diff-filter=A -- <file>` per fragment (≈4.5 s each on a loaded devbox → 364 fragments ≈ 30 min in the v3.8.51 round-3 pass) plus one `git show` per fragment. It now builds a path→oldest-adding-commit map from a single `git log --diff-filter=A --name-only` walk over changelog.d/ and reads every blob with one `git cat-file --batch`; the same fields (originHash/originPr/prefixPr/text) come out, phantom fragments keep their original origin. Measured: 5.3 s for the whole pass on the same box. Tests: tests/unit/reconcile-changelog-fragments.test.ts (temp git repos).
…cheaper-inference#14110) (cheaper-inference#14445) * fix(guardrails): fail closed on unknown-root paths in ambiguous prose (cheaper-inference#14110) PR cheaper-inference#13295 narrowed findUnquotedPathEnd's ambiguity fallback to failClosedAmbiguity only, but the redactUnquotedAbsolutePathSpans call site only set that flag for Windows/file-URI/known-POSIX-root candidates. An unquoted POSIX path whose root is not in POSIX_FILESYSTEM_ROOTS (e.g. /custom/internal in a container), followed by ambiguous prose, fell through unredacted -- regressing HR#12 (never leak err.message/paths). Per owner decision on cheaper-inference#14110 (fail-closed), extend mustFailClosed in redactUnquotedAbsolutePathSpans to also cover an unknown-root POSIX candidate (isPosixPath) once findUnquotedPathEnd returns -1. This intentionally also fail-closes a bare, unanchored API route in prose (route- and filesystem- shaped candidates are lexically indistinguishable with no second anchor) -- the cheaper-inference#13144 guarantee that a route immediately followed by another absolute- path span keeps its trailing prose is unaffected and re-asserted as a co-located regression test. Reactivates the cheaper-inference#14110-SUSPENDED assertion in error-public-boundaries-hardening.fixture.ts and folds in the proven repro. * chore(quality): register the cheaper-inference#14110 redaction test in stryker tap.testFiles
…cheaper-inference#14208) (cheaper-inference#14446) deepseek-web's tool-call parser only recognized tags literally named `tool`/`tool_call`. Some harness builds instead emit a well-formed grammar wrapped in doubled full-width-pipe "DSML" namespace markers (`<||DSML|| calls>`, `<||DSML|| invoke name="...">`, `<||DSML|| parameter name="...">`), which neither this parser, the generic single-pipe DSML normalizer, nor the canonical <tool> fallback recognized — the block was silently dropped and the raw DSML text leaked to the user. Add normalizeDsmlInvokeMarkup() to rewrite the DSML calls/invoke/parameter grammar (single- and double-pipe) into the canonical <tool>/<parameter> tags before tokenization, so parseDeepSeekToolCalls resolves it into a proper tool_calls entry.
…calls (cheaper-inference#14154) (cheaper-inference#14447) Codex MultiAgent V2 treats a collaboration function_call's arguments as backend ciphertext unless the item carries an explicit encrypted_function_args: [] plaintext-delivery marker (codex-rs ToolCall::direct_source). The vendored ChatGPT-web bridge already emitted this marker for spawn_agent/send_message/followup_task via its own local plaintextCollaborationFields() helper, but the three general Responses-API output emitters used for every non-OpenAI chat-format upstream never did: the streaming translator (translator/response/openai-responses.ts), the non-streaming client translator (handlers/chatCore/nonStreamingClientTranslate.ts), and the Responses transformer (transformer/responsesTransformer.ts). Extracts the marker into a shared translator/response/openai-responses/collaborationPlaintextMarker.ts helper (bridge.ts now imports it instead of keeping its own copy) and a functionCallIdentity.ts helper that composes the existing cheaper-inference#7936 identity restoration with the new marker for the streaming translator. Wires the Responses transformer with the same requestToolIdentityMap plumbing the other two emitters already had (it previously restored neither namespace nor the marker at all), threading the ledger through from handlers/responsesHandler.ts before handleChatCore deletes the side channel.
…ailed" (cheaper-inference#14309) (cheaper-inference#14463) open-sse/utils/proxyFetch.ts already computes a detailed transport diagnosis (DNS/socket error code, syscall, address) whenever a direct fetch fails on both the pooled undici dispatcher and the native-fetch fallback, attaching it to the thrown error as `.proxyFetchDetail`. But src/lib/providers/validation/transport.ts::toValidationErrorResult() only ever read `error.message` — always the generic "fetch failed" string by undici/native-fetch design — and never `error.cause` (where SafeOutboundFetchError's normalizeFetchFailure() stores the original error carrying `.proxyFetchDetail`). The computed diagnosis was silently discarded before it ever reached the dashboard. toValidationErrorResult() now walks one level of `error.cause` when the message is the generic "fetch failed" string and, if a `.proxyFetchDetail` is found there, surfaces it (still routed through sanitizeErrorMessage()) instead of the bare message. Also extracted the direct-path detail-string builder (and the existing redactProxyDetailsInMessage() redaction helper) out of proxyFetch.ts into a new open-sse/utils/proxyFetchRedaction.ts module, applying the same proxy-URL/credential redaction to the direct-path detail that the proxy-path message already had — closing a latent redaction gap between the two branches — without growing proxyFetch.ts past its frozen file-size baseline. This does not explain the reporter's own underlying transport failure (likely local network/DNS/firewall config on their machine); it makes that failure diagnosable instead of opaque.
…v3.8.51 vs main, api/v1 + image handlers) (cheaper-inference#14365) Fixes from the 2026-09-21 E2E run of /omni-code-review (15 lenses, 3 rounds): delete-completed keyState audit without the redundant re-validation, honest image_size logging with a warn on clamp, one shared fetchUntrustedRemoteImage and one shared stringifyImageErrorForLog for both image sinks, tests. Follow-ups: cheaper-inference#14332-cheaper-inference#14335.
Replays cheaper-inference#12755 on top of cheaper-inference#13637 so both fixes survive intact. A synchronous spawn failure left the queue stranded: nothing else calls dispatch() again, so queued jobs and their full request bodies were retained for the lifetime of the process. Queued jobs now reject as retryable when no worker is left to drain them, while a capacity-spawn failure with a healthy worker keeps the backlog for that worker. Preserve cheaper-inference#13637's reject-based runtime-fault contract: fast faults retry compression in-process, while a dispatch timeout degrades to the uncompressed body after spending the worker budget. Keep fail-open reporting, strip non-cloneable callbacks from wire jobs, and guard postMessage failures. Add an end-to-end regression proving the cheaper-inference#13423 shared-subobject shape remains worker-eligible and falls back to in-process compression after synchronous MODULE_NOT_FOUND.
286006e to
656e716
Compare
Summary
Fixes #2.
In the Turbopack standalone production build,
CompressionWorkerPooljams its job queue on the very first job:spawn()throws a synchronousMODULE_NOT_FOUND(Turbopack rewritesnew Worker(resolveWorkerFile())into its module-context map, which only contains.tskeys while the runtime value is a.jspath) before the job is dequeued. The rejected caller's promise is silently fail-opened bystrategySelector, but the queuedPendingJob— retaining the full parsed request body and its closures — stays inpool.queueforever. Under Claude Code traffic this leaked ~2.5–3 GB of main-isolate heap per hour until the heap-pressure guard began shedding with 503s.Changes
compressionWorkerPool.ts— the queue can no longer jam.dispatch()wrapsspawn()in try/catch; on a synchronous throw it fail-opens every queued job (queue.splice(0)→ resolve unchanged) and marks the poolbroken, so subsequentrun()calls resolve immediately instead of re-jamming. This kills the whole leak class regardless of which bundler/module-loader breaks the spawn.compressionWorkerPool.ts— test seam. The constructor accepts an optionalworkerFactory; tests inject a synchronously-throwing factory (exactly the production Turbopack failure shape).failOpenNotifier.ts+ callers — fail-open is no longer silent. Spawn failure, broken-pool short-circuit, strategy-level fallback, and LLMLingua spawn/post failures emit a rate-limitedconsole.warn. Repeated identical details are suppressed for 60 s, while a distinct failure mode is still logged immediately; tracked detail keys are bounded.engines/llmlingua/worker.ts—ERR_WORKER_PATH.new Worker(pathToFileURL(...).href, …)passed afile://string, which Node ≥ 21 rejects synchronously. ExtractedllmlinguaWorkerSpecifier()returns a URL object.What this PR deliberately does NOT fix
uuidinto the esbuild'd worker: those are build-pipeline changes; this PR guarantees they can never again leak memory when they break.Validation
Change type: performance/memory (fail-open semantics unchanged — every failure path still returns
{ body, compressed: false, stats: null }).tests/unit/compression/compression-worker-pool-jam.test.tsfails on the untouched release base (jobs hang → timeout) and passes after the fix:EMFILE,ERR_WORKER_INIT_FAILED) are retried on the next wave; structural ones (MODULE_NOT_FOUNDetc.) permanently break the poolpostMessagethrow, workererror/exit, per-job timeout andclose()all settle fail-open —run()never rejects and never strands a promisefile://stringnode --import tsx/esm --test tests/unit/compression/compression-worker-pool-jam.test.ts— 14 passedcompression-worker.test.ts,llmlingua-worker.test.ts,llmlingua-worker-resolution.test.ts,llmlingua-failopen.test.ts,compression-worker-file-resolution.test.ts,strategySelector.test.ts— 68 passednode scripts/check/check-mutation-test-coverage.mjs— the new test file is listed instryker.conf.jsontap.testFiles(the remaining drift,quotaScoring.ts, is pre-existing on the release base)npm run typecheck:coregit diff --checkcheck:file-size/check:test-file-sizeOK)Tests Added
tests/unit/compression/compression-worker-pool-jam.test.ts— the permanent regression guard for the jam/leak class.