Compare commits

...

58 Commits

Author SHA1 Message Date
Diego Rodrigues de Sa e Souza
ff2e8313a0 Merge branch 'release/v3.8.50' into fix/9304-qwen-web-stale-hardcoded-spa 2026-08-05 13:22:31 -03:00
diegosouzapw
9fcefcce9f fix(quality): tighten eslintWarnings baseline to the gate's real measurement (0)
The 2026-08-05 TS7 rebaseline wrote 5000 measured WITHOUT the suppressions
file, but the PR gate (quality.yml lint:json + quality:collect) measures WITH
suppressions applied and reads 0 - so require-tighten failed every code PR
with delta 5000 > slack. Measured 0 on the pure tip ed122b2caf after the
stale-suppression prune (#9509). TS7 debt remains tracked in
config/quality/eslint-suppressions.json; any NEW warning outside it is an
immediate red, which is the policy.
2026-08-05 13:19:56 -03:00
Bob.Hou
ed122b2caf fix(quality): prune a stale entry from the ESLint suppressions baseline (#9509)
release/v3.8.50 fails its own "No new ESLint warnings" gate right now,
independent of what any PR changes. Measured directly: a worktree
checked out at the current tip alone, no PR merged in, exits 2 with
"There are suppressions left that do not occur anymore." Cross-checked
against two unrelated open PRs (#9499, #9497) hitting the identical
failure, ruling out anything content-specific.

The mass-freeze commit that regenerated config/quality/eslint-suppressions.json
for the TypeScript 7 migration left one entry pointing at a violation
that no longer exists: src/lib/usage/providerLimits.ts no longer
triggers no-restricted-imports, but the suppression entry for it does.
ESLint's own suppression bookkeeping treats an unmatched entry as a
hard failure, separate from and in addition to real unsuppressed
errors.

--prune-suppressions removes exactly that one entry. It also drops the
informal "_comment" key documenting the freeze's origin, since ESLint's
suppression writer only round-trips file-keyed entries it manages
itself -- that context is not lost, it is still readable at the
mass-freeze commit (6b0e11e37) in git history.

This is one of two independent problems behind the same gate failure,
not the whole fix. Two files (tests/unit/issue-9407-gemini-web-validation-false-positive.test.ts,
tests/unit/v1-models-auth-leak-9320.test.ts) carry real, currently
unsuppressed no-explicit-any errors with no entry covering them at
all -- pruning cannot add what was never there. #9484 fixes those at
the source. Verified here that after this change alone, the gate
moves from exit 2 (stale suppressions) to the ordinary exit 1 those
two remaining errors cause -- both this and #9484 need to land before
the gate is green again.

Signed-off-by: Minxi Hou <houminxi@gmail.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
2026-08-05 12:53:15 -03:00
Diego Rodrigues de Sa e Souza
7d5e8235da fix(quality): add base-relative file-size check so inherited drift does not red innocent PRs (#8522) (#9355)
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-08-05 12:53:05 -03:00
Diego Rodrigues de Sa e Souza
3022df548e fix(docs): add required MDX frontmatter to AGENTROUTER_WAF.md (#9503)
Missing title/version/lastUpdated frontmatter broke the production build
(fumadocs-mdx requires title on every docs/**/*.md file).

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-08-05 11:36:22 -03:00
Diego Rodrigues de Sa e Souza
ef3f554665 fix(tests): clear the two base-reds on release/v3.8.50 (#9488)
* fix(tests): clear the two base-reds on release/v3.8.50

Both sat on the release itself and turned every open PR red as soon as it
merged the release, independently of the PR's own content.

- tests/snapshots/provider/translate-path.json: #9064 (b0501642dd) added the
  code-execution-2025-08-25 and skills-2025-10-02 beta flags to the Anthropic
  header but did not regenerate the golden. provider-translate-path-golden
  failed on the bare release tip — 2 pass / 1 fail with zero PRs boarded.
  Regenerated; the diff is 24 lines, all the same header in 6 variants.

- tests/unit/v1-models-auth-leak-9320.test.ts:83: shipped a (k: any) in a file
  new enough that eslint-suppressions.json does not cover it. With
  @typescript-eslint/no-explicit-any as error under tests/, that one cast
  failed 'No new ESLint warnings' for every PR. The callback parameter infers
  correctly, so the cast was redundant.

Verified on the fix branch: eslint exit 0, typecheck:core exit 0, and both
test files green (5/5).

* fix(tests): drop a third unsuppressed any (gemini-web validation test)

A full-repo lint on this branch surfaced one more file in the same class:
tests/unit/issue-9407-gemini-web-validation-false-positive.test.ts:50 casts
(executor as any).testConnection. The file entered the release at f1ea77fd04
(23:28), newer than the frozen eslint-suppressions.json, so the cast is not
covered — and it alone kept 'No new ESLint warnings' red on this very PR.

testConnection is a declared public method on GeminiWebExecutor
(open-sse/executors/gemini-web.ts:359), so the cast was redundant rather than
load-bearing; removed outright.

eslint exit 0, typecheck:core exit 0, 15/15 across the three touched tests.
2026-08-05 11:36:19 -03:00
diegosouzapw
6b0e11e378 refactor: update quality baseline and test masking allowlist
- Updated the quality baseline to set eslintWarnings value to 5000, reflecting the migration to TypeScript 7 and the new warning thresholds.
- Modified the test masking allowlist to account for removed tests and sources, ensuring proper tracking of deprecated features.
- Enhanced ESLint configuration to ignore additional directories containing non-source files.
- Removed the .npmignore file as its contents are now managed in package.json.
- Adjusted KimiWeb model configuration to correctly map K3 to the K2D5 scenario, reflecting changes in the underlying logic.
- Updated artifact packing policy to prevent nested node_modules from being published, ensuring a leaner package size.
- Added tests to verify the exclusion of node_modules from published artifacts and to ensure the integrity of the package.json files array.
2026-08-05 08:46:22 -03:00
diegosouzapw
a549db7dee feat(infra): add systemd autostart unit for Linux (#8635) 2026-08-05 08:45:00 -03:00
diegosouzapw
f4e93f339d docs: add management authentication terminology guide (#7786) 2026-08-05 08:45:00 -03:00
Xiangzhe
2c966c28af test(mutation): include adaptive admission coverage 2026-08-05 08:45:00 -03:00
Xiangzhe
8ca40e7971 feat(api): wire shared admission across LLM routes
Acquire admission once after API-key policy, preserve lazy raw-request snapshots, and bind lease settlement to JSON, SSE, abort, deadline, and failure lifecycles. Expose a low-cardinality health summary and preserve non-SSE Ollama errors unchanged.
2026-08-05 08:44:59 -03:00
Xiangzhe
a61020153c feat(admission): add adaptive overload and pressure controls
Add bounded weighted admission with fair queuing, deadline and cancellation handling, exact lease accounting, and a default-shadow runtime. Keep asynchronous resource-pressure shedding as an independent safety fuse and bound request feature estimation.
2026-08-05 08:32:38 -03:00
Xiangzhe
ce764bc6f3 fix(combo): classify local target timeouts as gateway timeouts
Return a typed HTTP 504 for OmniRoute's per-target timer, keep fallback active, and classify the local timeout as request-scoped so it cannot degrade provider connection health.
2026-08-05 08:32:38 -03:00
Diego Rodrigues de Sa e Souza
2cb7567d66 fix(providers): treat claude-web 429 as unhealthy and forward Retry-After (#9406) 2026-08-04 23:28:41 -03:00
Diego Rodrigues de Sa e Souza
b840628de8 fix(providers): add tool_use handling to claude-web stream parser (#9408) 2026-08-04 23:28:37 -03:00
Diego Rodrigues de Sa e Souza
d969555417 fix(security): require explicit tool envelope to prevent bare JSON tool_calls (#9343) 2026-08-04 23:28:33 -03:00
Diego Rodrigues de Sa e Souza
f1ea77fd04 fix(providers): detect expired gemini-web sessions and add testConnection (#9407) 2026-08-04 23:28:30 -03:00
Diego Rodrigues de Sa e Souza
85f30d4da8 fix(api): fall back to slugified provider name when prefix is empty (#9416) 2026-08-04 23:28:26 -03:00
Diego Rodrigues de Sa e Souza
ab560cce7b fix(providers): map kimi-web/K3 to K2D5 scenario to fix resource_exhausted (#9338) 2026-08-04 23:28:21 -03:00
Diego Rodrigues de Sa e Souza
6b531fbacd fix(claude): remove unconditional always-mode return in claudeClassifierCompat (#9276) 2026-08-04 21:36:54 -03:00
Diego Rodrigues de Sa e Souza
7d6a64b054 fix(mcp): break circular import between googApiKeyAuth.ts and auth.ts (#9297) 2026-08-04 21:36:47 -03:00
Diego Rodrigues de Sa e Souza
b07182c72a fix(security): require auth for /v1/models when management auth is configured (#9320) 2026-08-04 21:36:41 -03:00
Diego Rodrigues de Sa e Souza
7e55abbc41 fix(vision-bridge): do not select unreachable describe-model when no vision provider is connected (#8430) 2026-08-04 21:36:34 -03:00
Diego Rodrigues de Sa e Souza
b0501642dd fix(providers): anthropic strips code-execution/skills beta flag, causing container rejection (#9064) 2026-08-04 21:36:18 -03:00
Diego Rodrigues de Sa e Souza
7d46d4039f fix(perplexity-web): update catalog to use 'copilot' mode and fix model IDs (#8989) 2026-08-04 21:36:13 -03:00
Diego Rodrigues de Sa e Souza
d502f144b9 fix(providers): copilot-m365-web enterprise turns send disconnectBehavior=continue (#8971) 2026-08-04 21:36:09 -03:00
Diego Rodrigues de Sa e Souza
eaea0347ac fix(executor): guard claude/anthropic buildHeaders against empty credentials and extend dual-Bearer parity for third-party baseUrls (#8653) 2026-08-04 21:36:04 -03:00
Diego Rodrigues de Sa e Souza
37edd74f2d fix(proxy-health): include credentials in proxy health check URLs (#8853) 2026-08-04 21:36:00 -03:00
Diego Rodrigues de Sa e Souza
0b70a14a3b fix(auth): setting first dashboard login password no longer fails with HTTP 400 PASSWORD_REQUIRED (#8950) 2026-08-04 21:35:29 -03:00
Diego Rodrigues de Sa e Souza
28a1f4d1b6 fix(deps): bump transitive deps for 20 Dependabot CVE alerts
Bumps ip-address, hono, fast-uri, socket.io-parser, undici (v6+v7), protobufjs, tar via targeted package.json overrides. Closes 20 Dependabot alerts (2026-08-04). npm audit → 0 vulnerabilities.
2026-08-04 19:08:34 -03:00
diegosouzapw
ed2c4dbab3 fix(deps): bump transitive deps for 20 Dependabot CVE alerts
Bumps ip-address, hono, fast-uri, socket.io-parser, undici (v6+v7),
protobufjs, and tar via targeted package.json overrides.

All patches are lockfile-only (no code change, range already covers).
Verified: npm audit → 0 vulnerabilities.
Note: brace-expansion NOT in overrides (separate major lines need
different patches; each resolved within its parent range).

Co-authored-by: wgordon17 <22222756+wgordon17@users.noreply.github.com>
2026-08-04 18:51:49 -03:00
Diego Rodrigues de Sa e Souza
0965b041fa chore(ci): stop dependabot from grouping ioredis majors with routine bumps (#9425)
* chore(ci): stop dependabot from grouping ioredis majors with routine bumps

ioredis is loaded through a dynamic import in the distributed quota store, so a
breaking major passes build, typecheck and both test suites and only surfaces at
runtime for operators running Redis-backed quota. #9310 grouped ioredis 5.10.1 to
6.0.0 with 9 unrelated production bumps; majors get their own PR from now on.

* docs(changelog): add fragment for #9425

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-08-04 18:07:16 -03:00
Nick Sullivan
7b8055c7f8 fix(resilience): count STREAM_EARLY_EOF as a provider failure in combo routing (#9251)
* fix(resilience): count STREAM_EARLY_EOF as a provider failure in combo routing

A STREAM_EARLY_EOF is an upstream that accepted the request (HTTP 200), opened
the SSE stream, then closed it without emitting a single non-ping event. The
combo path classified it together with STREAM_READINESS_TIMEOUT through
isStreamReadinessFailureErrorBody(), and the readiness exemption in
shouldRecordProviderBreakerFailure meant the whole-provider circuit breaker
never saw it.

During a provider-wide outage that makes the breaker blind. Over a 7-day window
on our router we recorded 311 of these events, 302 of them on one model, 265
inside the upstream's published incident window — and the provider breaker sat
at CLOSED / failure_count=0 the entire time. Every request kept being dispatched
to the failing provider instead of shedding to the next combo target.

The two codes are different signals. The readiness probe is a pre-flight
liveness check on a connection we have not committed to, so failing it means
"this connection looks stale". An early EOF means the provider took the request
and then failed to serve it. The single-model path already treats it that way:
shouldTripProviderBreakerForResult has no readiness exemption, so a 502 early
EOF trips the breaker there. This makes the combo path consistent.

isStreamReadinessFailureErrorBody keeps matching both codes, because the
transient-retry and round-robin semaphore-cooldown paths in combo.ts do want
identical treatment for both. Only the breaker needs to tell them apart, so the
distinction is added as a narrow predicate and an optional argument rather than
by changing the shared classifier. Omitting the new argument reproduces the
previous behaviour exactly.

Follows the additive-override pattern established by the isProxyUnreachable
work, and leaves the existing exclusions for client aborts and plain 429s
untouched.

* test: register stream-early-eof-breaker in stryker tap.testFiles

The mutation test-coverage gate (check:mutation-test-coverage --strict)
detects unit tests that cover a mutated module but are missing from
stryker.conf.json tap.testFiles, so their mutant kills would not count.

comboPredicates.ts is one of the mutated modules, and the new
stream-early-eof-breaker.test.ts covers it, so the gate correctly flagged
the omission. 8376-econnrefused-breaker.test.ts -- the test this one is
modeled on -- is already registered; this just brings the new file in line.

No production code change.

---------

Co-authored-by: Nick Sullivan <nick@technick.ai>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-04 18:07:09 -03:00
Xiangzhe
3440c118e0 feat(usage): show Grok Build billing limits (#9205)
* feat(usage): show Grok Build billing limits

* test(usage): keep Grok quota reset fixture in the future

* fix(i18n): add Grok billing labels to pt-BR

* fix(i18n): add Grok billing labels to Vietnamese
2026-08-04 18:07:02 -03:00
nguyenha935
712910612b fix(db): bundle and verify the sql.js fallback (#9044)
Co-authored-by: nguyenha935 <208228297+nguyenha935@users.noreply.github.com>
2026-08-04 18:06:55 -03:00
Dizzle
0538eec05e test(dashboard): drop stale next-intl mock breaking ProviderDetailPageClient smoke (#9150)
The local vi.mock("next-intl") predates the #7935 global polyfill and returns a
useTranslations without .rich, crashing t.rich() in ProviderParamFilterSection:199.
The global polyfill (backed by the real createTranslator) now covers this file;
assertions only check DOM/fetch, never translated text.

Co-authored-by: Max <maxmad64@gmail.com>
2026-08-04 18:06:46 -03:00
Dizzle
0ca25d61f4 fix(dashboard): apply provider Auto Sync per connection and fan out the master toggle (#9149)
* fix(dashboard): add per-connection autoSync toggle handler

* fix(dashboard): render per-connection autoSync toggle in ConnectionRow

* fix(dashboard): wire canAutoSync into ConnectionsListPanel

* fix(dashboard): wire per-connection autoSync toggle into provider page

* fix(dashboard): make master autoSync toggle all-on with fan-out

* docs(dashboard): add changelog fragment for per-connection autoSync

* fix(dashboard): correct disable toast and assert fan-out classification

* test(dashboard): pin fan-out classification branches symmetrically

* docs(dashboard): fill changelog fragment with PR number

* fix(dashboard): port autoSync i18n keys to vi and pt-BR locales

* fix(dashboard): localize autoSync keys across all 43 locales

---------

Co-authored-by: Max <maxmad64@gmail.com>
2026-08-04 18:06:35 -03:00
Bob.Hou
8027c60726 test(sse): expect the trailing period in the no-credentials message (#9392)
#9275 started appending a candidate-alias hint to the zero-active-credentials
error and terminated the provider name with a period, so the two sentences read
as one message. The two vscode tokenized-route tests still assert the old
unterminated string and now fail on every pull request opened against this
branch.

The Quality Gates workflow only runs on pull_request to release/**, never on
push, so the branch itself never re-runs these shards and the drift stayed
invisible after the merge.

Assert what the handler actually produces. Keeping the comparison exact rather
than loosening it to a prefix match is deliberate -- the exact form is what
caught the drift.

Signed-off-by: Minxi Hou <houminxi@gmail.com>
2026-08-04 17:08:14 -03:00
Diego Rodrigues de Sa e Souza
4a3dcf6b0b fix(routing): only let Codex-native bare ids preempt a provider when codex is active (#9447)
* fix(routing): only let Codex-native bare ids preempt a provider when codex is active

#9275 widened CODEX_NATIVE_UNPREFIXED_MODELS from a single id to gpt-5.5 plus the
gpt-5.6-sol/terra/luna tiers, so bare Codex CLI ids would reach the ChatGPT
subscription instead of fanning out to whichever provider won the inference race.
The early return it added never consulted the active-provider set, which made the
codex-only guard 30 lines below unreachable for every id in the set:

  if (CODEX_NATIVE_UNPREFIXED_MODELS.has(modelId)) return { provider: "codex", ... }

An OpenAI-only install therefore had bare gpt-5.5 routed to codex and failed with
'no active credentials for provider: codex' on a model OpenAI serves, and an install
whose codex connection was merely inactive failed identically. This also silently
reverted #5887's compatibility boundary.

The preference now only PREEMPTS another provider when a codex connection is active.
Ids that no other provider catalogs (codex-auto-review) still resolve to codex with no
connection at all — there is nothing to preempt and 'no codex credentials' is the
honest error. With codex active the preference still beats OpenAI, which is the point
of #9275, and an explicit openai/ prefix overrides it either way.

Tests: the three assertions that encode the intended #9275 change now expect codex
(plus a new one pinning the explicit-prefix override); the rest were already correct
and pass again untouched. Adds a regression test for the OpenAI-only case.

* docs(changelog): correct fragment id to #9447

* test(routing): seed an active codex connection in the bare-precedence guards

The two files #9275 added assert that bare gpt-5.5 / gpt-5.6-sol reach codex, but
they ran against an empty database — so they also pinned 'codex wins with no codex
connection at all', which is the regression #9447 removes. That put them in direct
contradiction with plan3-p0 / chat-helpers / codex-gpt55-routing-5887, which assert
openai for the very same input: no implementation could satisfy both, which is why
the release could not go green.

Seeding an active codex connection keeps the contract these files were written to
guard (codex beats openai for a Codex-native bare id) while dropping the accidental
'even with no codex configured' half. Cases that need no connection are left as they
were: the tier-only ids and codex-auto-review have no alternative provider to preempt,
and the explicit-prefix overrides are unaffected.

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-08-04 17:08:08 -03:00
Diego Rodrigues de Sa e Souza
16ed707148 feat(providers): filter detail connections server-side (#9247)
* feat(providers): filter detail connections server-side

Filter provider detail requests at the database boundary while preserving
the full per-provider connection set needed by search, pagination, and bulk
actions. Alias-backed provider pages keep their existing aggregate behavior.

Co-authored-by: RobertsXML <RobertsXML@proton.me>
Inspired-by: https://github.com/decolua/9router/pull/2998

* chore(changelog): fragment for #9247

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: RobertsXML <RobertsXML@proton.me>
2026-08-04 14:34:22 -03:00
Dizzle
8b97ef99aa fix(db): persist account egress IP into proxy_logs (#9291)
* fix(db): persist account egress IP into proxy_logs

The account egress IP (outbound IP the upstream saw, resolved via proxyEgress.ts
echo-IP probe with 5-min cache) was computed and surfaced in the proxy_logs
console and ring buffer, but never persisted: proxy_logs.egress_ip did not
exist, so the value was lost on restart and real traffic could not be
attributed to the node/IP active at that instant.

- migration 134 adds proxy_logs.egress_ip (nullable, backward-compatible)
- schemaColumns.ensureProxyLogsColumns() idempotent reconciler
- proxyLogger self-heals the schema in loadFromDb(), persists egress_ip on
  INSERT, and matches it in search
Follows the session_tag (#8249) migration + schemaColumns reconciler pattern;
base SCHEMA_SQL untouched.

* docs(changelog): add 9291 fragment for proxy_logs egress_ip

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
2026-08-04 14:34:17 -03:00
Dizzle
e50f2329dc fix(lib): memoize catalog pricing/capability lookups to fix cold /v1/models freeze (#8697) (#8987)
Root cause: a cold GET /v1/models catalog rebuild froze the entire server 41-54s.
node --prof profiling found a systemic missing-memoization pattern — a per-model
function rescanning a static or synced data structure with Object.entries()/
Object.keys() (or hitting SQLite) on every call instead of once per rebuild. Fixed
6 instances of the same pattern, found by iteratively re-profiling the full catalog
sweep after each fix (plus a whitebox review pass) until no further hotspot of this
shape remained:

1. getModelsDevPricing() (modelsDevSync.ts) — re-ran a synchronous SQLite query and
   re-JSON.parse'd ~180 blobs on every call (up to ~6091x instead of once per
   request). Memoized via the existing modelCatalogCacheVersion invalidation signal
   (same pattern as getCachedRawProviderConnections/getCachedProviderNodes in
   db/readCache.ts). Dominant cost of the original 41-54s freeze.

2. findInsensitive() (modelMetadataRegistry.ts, resolveCatalogPricing) — rebuilt a
   full Object.entries() scan on every case-insensitive lookup miss, twice per
   model. Replaced with a lowercase-key index built once per distinct pricing
   object and cached by identity (WeakMap). Warns once at index-build time on a
   case-insensitive key collision instead of silently discarding the second value.

3. getSyncedCapability() (modelsDevSync.ts) — ran a per-model SQLite SELECT on cold
   cache instead of self-warming the whole-table cache; no caller in the
   /v1/models build path ever primed it, so a cold rebuild ran one SQLite
   round-trip per model per call site. Now self-warms via the existing bulk
   getSyncedCapabilities() on first miss. Measured as the dominant remaining cost
   after fixes 1-2 (~70% of a full catalog sweep).

4. getCanonicalModelSpecId() (shared/constants/modelSpecs.ts) — up to 3 separate
   linear scans over the static MODEL_SPECS table per call (exact ci, alias ci,
   prefix). Replaced with a lazy, lowercase-key index built once (MODEL_SPECS never
   changes at runtime); prefix-match iteration order preserved exactly so
   resolution outcomes are unchanged.

5. getStaticSpecCanonicalModelId() (modelCapabilities.ts) — duplicated the same
   exact+alias scan as (4) in a second, separate rescan. Now reuses the shared
   index via a new exported helper (findModelSpecIdByExactOrAlias) instead of
   maintaining a second cache over the same static table.
   reverseModelsDevProviders() (modelCapabilities.ts) — rescanned
   Object.entries(MODELS_DEV_PROVIDER_MAP) (also static) on every call; memoized
   by provider key. Result is frozen (readonly) since it is now shared across
   calls instead of freshly allocated each time.

6. resolveModelAlias() (shared/constants/modelSpecs.ts) — rescanned
   Object.entries(MODEL_SPECS) unconditionally once per model (verified 1:1 call
   ratio, no short-circuit). Case-sensitive exact match (Array.includes(), no
   .toLowerCase()) — uses a dedicated exact-match index, deliberately not the
   case-insensitive alias index from fix 4/5 (would silently broaden matches).

Measured on a 1940-pair real-catalog sample (static PROVIDER_MODELS registry):
cold sweep 828ms -> 356ms after fixes 3-5 on top of 1-2, extrapolating to roughly
1s on the real ~6091-model catalog, down from the original 41-54s freeze.

Complementary to the stale-serve fix in #8801 (upstream) — neither alone
eliminates the freeze.

Tests: call-count regression guards for every fix (DB prepare / Object.entries /
Object.keys call counts staying constant instead of scaling with iteration count),
plus correctness coverage for case-insensitive/case-sensitive resolution. All
pre-existing consumer suites re-verified passing (96 tests total across 19 files).

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-08-04 14:34:10 -03:00
Xiangzhe
455906c181 fix(reasoning): forward Ollama Cloud thinking (#9290) 2026-08-04 14:34:02 -03:00
Bob.Hou
b6bcc491bc fix(token-refresh): exempt transient errors from exponential backoff (#9242)
* fix(token-refresh): exempt transient errors from exponential backoff

A refresh that failed on a network timeout was treated exactly like one
that failed on a revoked token: the streak incremented and the circuit
backed off exponentially, up to four hours. A brief upstream blip could
therefore park a healthy account for the rest of the day.

Transient failures now take a flat two-minute retry window instead of
advancing the streak. Classification checks structured signals first
(err.name for AbortError/TimeoutError, then err.code and err.cause.code)
and only falls back to matching the message text, so it does not depend
on upstream wording. Everything else keeps the existing exponential path.

Two properties worth preserving on sight:

  - A transient failure never shortens a longer permanent backoff. The
    new window is only adopted when the existing one is not already
    further out.
  - testStatus is preserved on both paths, so a connection whose access
    token is still valid keeps serving requests while its refresh
    retries.

Only a successful refresh clears the circuit. A successful request does
not, because requests do not refresh tokens.

* chore(quality): rebaseline file-size for tokenHealthCheck.ts

src/lib/tokenHealthCheck.ts lands at 1021 lines, above the 1000 cap. The
file consolidates token-refresh health checking that was previously split
across auth.ts and tokenRefresh.ts, and the refresh circuit state machine
does not divide cleanly, so splitting it to satisfy the cap would cost
more than it buys.

Scoped to this file only. Baseline entries for files this branch does not
touch are left at their upstream values.
2026-08-04 14:33:54 -03:00
NOXX - Commiter
45d375aa0b fix(api): defer media body size limits to providers (#8843)
Image and video payloads vary by provider and base64 encoding adds substantial overhead. Exempt media routes from OmniRoute's global request-body cap so provider-specific validation determines whether a request is too large. Keep finite body limits for non-media routes and cover both header and streamed-body admission paths.
2026-08-04 14:33:48 -03:00
Diego Rodrigues de Sa e Souza
f11d883f22 fix(cli-tools): enable Apply for compatible providers (#9250)
* fix(cli-tools): resolve models for compatible providers

Keep the CLI tools Apply flow usable when a dynamic OpenAI-compatible or
Anthropic-compatible connection has no static catalog entry. Resolve its
public prefix, connection default model, and prefix-backed catalog entries
before gating the cards.

Co-authored-by: lazysaltyfish <7127935+lazysaltyfish@users.noreply.github.com>
Inspired-by: https://github.com/decolua/9router/pull/2995

* chore(changelog): fragment for #9250

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: lazysaltyfish <7127935+lazysaltyfish@users.noreply.github.com>
2026-08-04 10:06:37 -03:00
Diego Rodrigues de Sa e Souza
2cb77bbca7 fix(translator): harden Claude format detection for model validation (#9253)
* fix(translator): harden Claude format detection for model validation

Co-authored-by: Ervareza Naurian <rianskp644@gmail.com>
Inspired-by: https://github.com/decolua/9router/pull/2949

* chore(changelog): fragment for #9253

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: Ervareza Naurian <rianskp644@gmail.com>
2026-08-04 10:06:31 -03:00
Shixi Li
a8216c92fe fix(sse): preserve error-only stream diagnostics (#9022)
* fix(sse): preserve error-only stream diagnostics

* test(ci): register stream readiness mutation coverage

* chore(changelog): finalize PR 9022 fragment
2026-08-04 10:06:25 -03:00
ikelvingo
c790b57af8 fix(translator): pass output_config.effort=max through verbatim (#9053)
The claude->openai translator was unconditionally rewriting max to xhigh, which broke any OpenAI-shape upstream that accepts max literally (e.g. ollama-cloud, opencode-go deepseek, moonshot k3, native Claude). Provider-aware effort policy is owned by sanitizeReasoningEffortForProvider in the executor; the translator should only do form conversion.

Regression guard: tests/unit/base-executor-sanitize-effort.test.ts end-to-end case (claude -> ollama-cloud preserves max).
2026-08-04 10:06:18 -03:00
dependabot[bot]
9acf79f04f chore(deps): bump github/codeql-action from 4 to 4.37.3 (#9082)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4 to 4.37.3.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v4...v4.37.3)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.37.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 10:06:10 -03:00
dependabot[bot]
09665ab455 chore(deps): bump docker/login-action from 4 to 4.5.2 (#9081)
Bumps [docker/login-action](https://github.com/docker/login-action) from 4 to 4.5.2.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v4...v4.5.2)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.5.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 10:06:03 -03:00
Bob.Hou
9ee6435f0e fix(classify): recognize Modal 'usage limit reached' as quota exhausted (#9079)
Modal-hosted OpenAI-compatible endpoints (self-hosted Kimi K3 via
Modal free tier) return HTTP 429 with body {"error":"usage limit
reached"} when the account's credit is exhausted. Previously no
QUOTA_PATTERNS regex matched this bare-string error shape, so the 429
fell through to rate_limit (60s short cooldown). Combined with combo
round-robin's per-conversation session stickiness (#3825), this kept
re-targeting the same exhausted connection every turn instead of
locking it out and failing over to an account with remaining credit.

Add a substring pattern matching the JSON key/value pair
"error":"usage limit reached" with tolerance for trailing
punctuation and whitespace. Only the exact "error" key matches;
different keys or qualified transient messages like "Per-minute usage
limit reached" stay classified as rate_limit.

Signed-off-by: Minxi Hou <houminxi@gmail.com>
2026-08-04 10:05:56 -03:00
Bob.Hou
edd9b0d664 fix(combos): include id column in getCombos query (#8905)
getCombos() SELECT was missing the id column, so returned combo objects
had their id come only from the JSON data blob. If the data blob lacked
an id field, callers (including the Dashboard) saw null — making the
combo appear to have no primary key and impossible to delete.

Add id to the SELECT so the database column value is always available.

Signed-off-by: Minxi Hou <houminxi@gmail.com>
2026-08-04 10:05:48 -03:00
Jade Guo
ba353aa3d6 docs(db): specify MySQL conformance semantics (#8947)
* docs(db): specify MySQL conformance semantics

* docs(db): deepen MySQL conformance specification

* docs(db): close MySQL conformance gaps
2026-08-04 10:05:41 -03:00
Bob.Hou
224bc0a5a5 docs(guides): add Antigravity (Google One AI) onboarding guide (#8904)
Signed-off-by: Minxi Hou <houminxi@gmail.com>
2026-08-04 10:05:35 -03:00
MumuTW
2e5854906d docs: slim AGENTS.md (#8839) 2026-08-04 10:05:29 -03:00
Will Gordon
2e4268003a fix(ci): merge-queue tolerance for Build (advisory), drops paid-tier batching (#9233)
* fix(ci): restores dast-smoke queue tolerance, drops batching

PR #7329 (an unrelated cliproxy feature PR) silently reverted two prior
Mergify fixes when it touched .mergify.yml from a stale branch:

- #7225's tolerance for the advisory dast-smoke check, which hangs
  recurrently on GitHub-hosted runners (issue #7226) and had been
  dequeuing every queue attempt it touched.
- #7220's removal of batch_size/batch_max_wait_time, which is a paid
  Mergify tier feature this repo's free plan does not have (the queue
  command fails outright with it set).

Restores both fixes verbatim. No PR has used the queue label since
#7329 landed two weeks ago, so this had gone unnoticed.

* fix(ci): restores the auto-enqueue merge_protections_settings block

The first pass of this fix missed a second piece #7329 clobbered in
the same diff hunk: merge_protections_settings.auto_merge_conditions,
the actual mechanism that puts a queue-labeled PR into the queue (the
older rules-based autoqueue path it replaced is EOL). Without it, the
queue label was a no-op even after restoring the check-failure
tolerance and dropping batching.

.mergify.yml now matches commit 9875ccf4e (the last known-good state
before #7329) byte-for-byte, confirmed via sha256.

* fix(ci): retargets queue tolerance from dast-smoke to Build (advisory)

Evidence review found the prior fix's dast-smoke exception is stale:
dast-smoke has failed only twice ever, none since 2026-07-13 (0/30 in
the last ~3.3h across many PRs). Meanwhile Build (advisory), added to
quality.yml 2026-07-27, has a 100% failure rate on every sampled PR
since — confirmed via job logs to be the same class of runner hang
(dies mid "Creating an optimized production build", never a real
compile error), just in a check dast-smoke's tolerance never covered.

Retargets the merge_conditions exception accordingly so the queue can
actually tolerate the failure mode it faces today, instead of one
that's been dormant for weeks.
2026-08-04 08:57:11 -03:00
diegosouzapw
41757b8aa9 fix(providers): bump qwen-web SPA version header from 0.2.66 to 0.2.81 (#9304) 2026-08-04 06:22:40 -03:00
293 changed files with 20057 additions and 2742 deletions

View File

@@ -119,11 +119,10 @@ omnirouteSite/
# 4. Diretorios de dados / runtime locais (storage, env, secrets, scratch)
# ─────────────────────────────────────────────────────────────────────────────
data/
src/lib/env/
src/app/api/agent-skills/coverage/
src/app/api/cloud/
src/app/api/sync/cloud/
src/app/api/system/env/
# NOTA: src/lib/env/, src/app/api/{cloud,sync/cloud,system/env,agent-skills/coverage}/
# foram removidos daqui (2026-08-05). Os nomes sugerem dados/segredos locais, mas os
# 8 arquivos sao route handlers e modulos rastreados no git — escondia-los do grafo
# criava pontos cegos em buscas e em analise de impacto.
tests/golden-set/data/
# Logs e saida de teste
@@ -142,6 +141,10 @@ obsidian-plugin/node_modules/
# 6. Diretorios de documentacao interna / workflow
# ─────────────────────────────────────────────────────────────────────────────
docs/superpowers/
# Docs traduzidas: 1.215 arquivos / 94 MB (inclui 20+ copias do CHANGELOG).
# Sao traducoes do tree em ingles, ja indexado — no grafo so geram ruido em
# search_code e consomem o auto_index_limit.
docs/i18n/
# ─────────────────────────────────────────────────────────────────────────────
# 7. Arquivos especificos (nao diretorios inteiros)
@@ -188,8 +191,9 @@ audit-report.json
scripts/i18n/_audit.json
scripts/i18n/_pending-keys.json
# Cli binario local (scratch)
bin/omniroute.mjs
# NOTA: bin/omniroute.mjs foi removido daqui (2026-08-05). Estava marcado como
# "scratch", mas e o entrypoint real do CLI publicado (package.json -> bin.omniroute)
# e consta em PACK_ARTIFACT_REQUIRED_PATHS. Precisa estar no grafo.
# Deploy / docker backups
deploy.sh

View File

@@ -7,7 +7,13 @@
**/.vscode
# Dependencies and build output
# `node_modules` alone matches the ROOT only — Docker's matcher does not cross
# `/` like .gitignore does. Without the `**/` form, nested installs ship in the
# build context (e.g. @omniroute/opencode-provider/node_modules, ~79 MB of
# devDependencies). Both forms are kept: the bare one is the documented root
# rule, the `**/` one covers every nested package.
node_modules
**/node_modules
.next
.build
out
@@ -37,6 +43,17 @@ tests
test-results
playwright-report
blob-report
output
.playwright-cli
.playwright-mcp
.stryker-tmp
reports/mutation
# Local caches and quality-gate artifacts (all gitignored). `_*` does not match
# dot-prefixed names, so these need explicit entries.
.artifacts
.eslintcache
.eslintcache-complexity
# Documentation
# Issue #2348: The Dashboard Docs viewer reads markdown from `/app/docs` at
@@ -49,6 +66,10 @@ blob-report
# (English) sources at runtime, so translations are not required in the
# container image.
docs/i18n/**
# Internal planning artifacts (gitignored). `*.md` above only matches the root,
# so without this rule these land in /app/docs and become readable through the
# dashboard's Docs viewer at runtime.
docs/superpowers/**
docs/diagrams/**/*.png
docs/diagrams/**/*.jpg
docs/diagrams/**/*.jpeg

View File

@@ -39,6 +39,17 @@ updates:
# the duplication gate — migrate the gate intentionally, not via dependabot.
- dependency-name: "jscpd"
update-types: ["version-update:semver-major"]
# ioredis is a SOFT/optional dependency loaded through a dynamic import
# (src/lib/quota/redisQuotaStore.ts — "Redis driver requires ioredis package"),
# so a breaking major never fails at build or typecheck time: the only consumers
# are the distributed quota store (redisQuotaStore.ts, storeFactory.ts) and the
# `import type Redis` in src/shared/utils/rateLimiter.ts. Nothing in the unit or
# vitest suites exercises a live Redis connection, so a v5→v6 API break would ship
# green and only surface at runtime for operators running distributed quota — the
# exact users least able to absorb it. #9310 grouped that major with 9 harmless
# bumps; majors here need their own PR and a deliberate migration review.
- dependency-name: "ioredis"
update-types: ["version-update:semver-major"]
# @huggingface/transformers is HARD-PINNED at 3.5.2 (exact, no caret) — FROZEN.
# It is load-bearing for the LLMLingua ONNX compression engine (open-sse/services/
# compression/engines/llmlingua/ — worker.ts pins @huggingface/transformers@3.5.2)

View File

@@ -155,13 +155,13 @@ jobs:
uses: docker/setup-buildx-action@v4
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v4.5.2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v4
uses: docker/login-action@v4.5.2
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -255,13 +255,13 @@ jobs:
uses: docker/setup-buildx-action@v4
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v4.5.2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v4
uses: docker/login-action@v4.5.2
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -390,7 +390,7 @@ jobs:
- name: Upload Trivy SARIF to Security tab
if: needs.prepare.outputs.version != 'main'
continue-on-error: true
uses: github/codeql-action/upload-sarif@v4
uses: github/codeql-action/upload-sarif@v4.37.3
with:
sarif_file: trivy-results.sarif
category: trivy-image

View File

@@ -155,7 +155,18 @@ jobs:
- run: npm run check:fetch-targets
# docs-all / openapi-routes / docs-symbols live in docs-gates (path-filtered).
- run: npm run check:deps
- run: npm run check:file-size
# #8522: --base-ref mode for PR events — compare against max(frozen, base) so
# inherited drift (base already over frozen cap) doesn't red an innocent PR.
# workflow_dispatch (no PR base) falls back to absolute comparison.
- name: File-size ratchet (base-relative on PR)
env:
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
if [ -n "$PR_BASE_SHA" ]; then
npm run check:file-size -- --base-ref "$PR_BASE_SHA"
else
npm run check:file-size
fi
- run: npm run check:error-helper
- run: npm run check:migration-numbering
- run: npm run check:public-creds

9
.gitignore vendored
View File

@@ -235,7 +235,10 @@ omniroute.md
# mise configuration
mise.toml
_artifacts/ # release-green artifacts
# release-green artifacts (.gitignore has no inline comments — a trailing
# `# ...` becomes part of the pattern, so it must sit on its own line).
# Already covered by /_*/ above; kept explicit for discoverability.
_artifacts/
.claude-flow/
# ESLint file cache (npm run lint --cache / complexity ratchets)
@@ -253,3 +256,7 @@ tests/homolog/ui/.auth/
homolog-report/
docker-compose.yml.bak
.playwright-cli/
# Playwright screenshot/log output. Today every artifact happens to land inside
# output/**/.playwright-cli/ (covered above), but anything written directly to
# output/ would otherwise show up as untracked.
/output/

View File

@@ -17,6 +17,13 @@
# • Fallback path if Mergify misbehaves or the OSS plan changes: the manual
# merge-train runbook (docs/ops/MERGE_TRAIN.md) — remove labels, proceed by hand.
# Auto-enqueue (current Mergify model, 2026): auto_merge_conditions in
# merge_protections_settings — the rules-based queue action / autoqueue path is
# deprecated (EOL 2026-07-16). The owner-applied `queue` label IS the approval.
merge_protections_settings:
auto_merge_conditions:
- label = queue
queue_rules:
- name: release
# Any current or future release branch — the reason GitHub's native queue was
@@ -34,14 +41,26 @@ queue_rules:
# is intentionally NOT a condition here: the owner-applied `queue` label IS the
# approval in this repo's single-maintainer model (see governance header).
merge_conditions:
- "#check-failure=0"
# "Zero failures" — EXCEPT the advisory "Build (advisory)" job (quality.yml):
# continue-on-error by design, and its GH-hosted Turbopack build hangs
# recurrently mid-"Creating an optimized production build" (100% failure rate
# across every sampled PR since the job was added 2026-07-27, always killed by
# a runner timeout/shutdown signal, never a real compile error). Any OTHER
# failure still blocks (anti-fail-open kept). The prior dast-smoke exception
# (#7225) was dropped here: dast-smoke's hang (#7226) has been dormant for
# weeks (0 failures in the last 30 runs; 2 all-time, none since 2026-07-13) —
# carrying its tolerance forward would mask problems it no longer causes.
- or:
- "#check-failure=0"
- and:
- "#check-failure=1"
- check-failure=Build (advisory)
- "#check-pending=0"
- "#check-success>=1"
- check-success=Merge integrity (changelog + generated skills)
# Batching: validate up to 10 queued PRs together (the manual train's sweet spot);
# don't hold a lone PR hostage waiting for siblings.
batch_size: 10
batch_max_wait_time: 5 min
# NO batching: 'Merge Queue Batch' requires a paid Mergify tier (live finding
# 2026-07-15 — the queue command fails with "Cannot use Merge Queue batch" on
# the free plan). Serial queue (1 PR at a time) still automates the train.
# Squash keeps the one-commit-per-PR history the CHANGELOG reconciliation expects.
merge_method: squash

View File

@@ -4,11 +4,14 @@ data/
**/db.json
# VS Code extension test runtime (large binary, not needed in npm package)
app/vscode-extension/
**/data/
**/db.json
# Source code (pre-built app/ is published instead)
# Source code (pre-built dist/ is published instead)
#
# NOTA (2026-08-05): as entradas `app/*` foram removidas — o diretorio `app/`
# foi renomeado para `dist/` na Layer 1 e nao existe mais. Elas sugeriam um
# layout que ja nao e o do projeto.
#
# NOTE (#3578 / #3821-review): package.json "files" is the source of truth for what
# ships. It now allowlists the backend source closure the MCP server needs at runtime
@@ -49,8 +52,6 @@ scripts/
.vscode/
.agents/
.env*
app/.env
app/.env*
eslint.config.mjs
prettier.config.mjs
postcss.config.mjs
@@ -82,8 +83,6 @@ bun.lock
*.deb
*.rpm
electron/
app/electron/
app/vscode-extension/
# Subprojects
clipr/
@@ -93,10 +92,6 @@ vscode-extension/
# Root-level underscore-prefixed directories (private/draft — never publish)
/_*/
app/_*/
app/coverage/
app/logs/
app/tests/
# Consistent with .gitignore and .dockerignore
.DS_Store

View File

@@ -1,6 +1,11 @@
# Long reference tables are manually aligned; formatting the whole file causes noisy diffs.
docs/reference/ENVIRONMENT.md
# Generated by `npm run gen:provider-reference`; the generator aligns the tables and
# is their formatter of record. Without this, lint-staged reformats the file whenever
# it is staged and the next generator run reverts it — a diff ping-pong.
docs/reference/PROVIDER_REFERENCE.md
# Dense auto-generated free-tier budget rows (one object per line) — prettier multi-line expand blows past file-size cap 800.
open-sse/config/freeModelCatalog.data.ts

695
AGENTS.md
View File

@@ -1,600 +1,117 @@
# omniroute — Agent Guidelines
# OmniRoute agent guide
## Project
Unified AI proxy/router — route any LLM through one endpoint. Multi-provider support
with **290 provider entries** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks,
Cohere, NVIDIA, Cerebras, Pollinations, Puter, Cloudflare AI, HuggingFace, DeepInfra,
SambaNova, Meta Llama API, Moonshot AI, AI21 Labs, Databricks, Snowflake, and many more)
with **MCP Server** (104 tools), **A2A v0.3 Protocol**, and **Electron desktop app**.
> **Live counts (v3.8.49)**: providers 290 · MCP tools 104 · MCP scopes 30 · A2A skills 6 ·
> open-sse services 134 · routing strategies 17 · auto-combo scoring factors 12 ·
> DB modules 95 · DB migrations 110 · base tables 17 · search providers 11 ·
> i18n locales 42. **Refresh with `npm run check:docs-all`.**
## Doc Accuracy Discipline (read before writing any doc)
> **If `grep -rn "name" src/ open-sse/ bin/` returns nothing, the name does not exist. Do not document it.**
The recurring failure mode in AI-generated docs is _plausible-but-unverified specifics_.
Every claim in a `.md` file under `docs/` should be verifiable against the source.
**Rules (enforced by `npm run check:fabricated-docs`):**
1. **Never state an API name, endpoint, path, CLI command, or env var without grepping for it first.**
```bash
grep -rn "theName" src/ open-sse/ bin/
# 0 hits → do not document
```
2. **Never write a line count, file size, migration count, provider count, or strategy count from memory.**
```bash
wc -l <file> # exact line count
ls <dir>/*.ts | wc -l # file count
```
3. **Every code example should be copy-pasted from real usage or actually run** — not synthesized.
Link to a real call site (`path:line`) instead of inventing a signature.
4. **Prefer citing real source (`file.ts:line`) over paraphrasing behavior** — verifiable and self-correcting.
5. **A shorter doc that is 100% accurate beats a comprehensive one with fabrications.**
Wrong docs cost more than missing docs, because people trust and act on them.
The script `scripts/check/check-fabricated-docs.mjs` extracts every route path, env var, hook
name, function name, and file reference from `docs/**/*.md` and verifies each one against the
codebase. Run it locally before pushing docs; it runs in CI via `npm run check:docs-all`.
## Stack
- **Runtime**: Next.js 16 (App Router), Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Language**: TypeScript 6.0 (`src/`) + JavaScript (`open-sse/`, `electron/`)
- **Database**: better-sqlite3 (SQLite) — `DATA_DIR` configurable, default `~/.omniroute/`
- **Streaming**: SSE via `open-sse` internal workspace package
- **Styling**: Tailwind CSS v4
- **i18n**: next-intl with 42 locales (`src/i18n/messages/`) — refresh with `ls src/i18n/messages/*.json | wc -l`
- **Desktop**: Electron (cross-platform: Windows, macOS, Linux)
- **Schemas**: Zod v4 for all API / MCP input validation
---
## Build, Lint, and Test Commands
| Command | Description |
| ----------------------------------- | ------------------------------------------------------------------ |
| `npm run dev` | Start Next.js dev server |
| `npm run build` | Production build: `next build` → `.build/next/` + assemble `dist/` |
| `npm run build:release` | Clean rebuild + HEAD sentinel (`dist/BUILD_SHA`) — use for deploy |
| `npm run start` | Run production build |
| `npm run build:cli` | Build CLI package |
| `npm run lint` | ESLint on all source files |
| `npm run typecheck:core` | TypeScript core type checking |
| `npm run typecheck:noimplicit:core` | Strict checking (no implicit any) |
| `npm run check` | Run lint + test |
| `npm run check:cycles` | Check for circular dependencies |
| `npm run electron:dev` | Run Electron app in dev mode |
| `npm run electron:build` | Build Electron app for current OS |
**Build output layout:**
| Directory | Purpose | Gitignored |
| --------- | -------------------------------------------------- | ---------- |
| `src/` | Application source (TypeScript / TSX) | No |
| `.build/` | Build intermediates (`distDir = .build/next`) | Yes |
| `dist/` | Shippable bundle assembled by `assembleStandalone` | Yes |
The pipeline is a single `next build` pass — intermediates land in `.build/next/`, the
assembled bundle in `dist/`. VPS deploys rsync `dist/` into the remote
`/usr/lib/node_modules/omniroute/app/` directory (VPS image path is unchanged).
### Running Tests
```bash
# All tests (unit + vitest + ecosystem + e2e)
npm run test:all
# Single test file (Node.js native test runner — most tests use this)
node --import tsx/esm --test tests/unit/your-file.test.ts
node --import tsx/esm --test tests/unit/plan3-p0.test.ts
node --import tsx/esm --test tests/unit/fixes-p1.test.ts
node --import tsx/esm --test tests/unit/security-fase01.test.ts
# Integration tests
node --import tsx/esm --test tests/integration/*.test.ts
# Vitest (MCP server, autoCombo)
npm run test:vitest
# E2E with Playwright
npm run test:e2e
# Protocol clients E2E (MCP transports, A2A)
npm run test:protocols:e2e
# Ecosystem compatibility tests
npm run test:ecosystem
# Coverage (see CONTRIBUTING.md)
npm run test:coverage
```
**For authoritative coverage requirements, test execution, and PR gates, see [`CONTRIBUTING.md`](CONTRIBUTING.md#running-tests).**
---
## Code Style Guidelines
### Formatting (Prettier — enforced via lint-staged)
2 spaces · semicolons required · double quotes (`"`) · 100 char width · es5 trailing commas.
Always run `prettier --write` on changed files.
### TypeScript
- **Target**: ES2022 · **Module**: `esnext` · **Resolution**: `bundler`
- `strict: false` — prefer explicit types, don't rely on inference
- Path aliases: `@/*` → `src/`, `@omniroute/open-sse` → `open-sse/`, `@omniroute/open-sse/*` → `open-sse/*`
### ESLint Rules
- **Security (error, everywhere)**: `no-eval`, `no-implied-eval`, `no-new-func`
- **Relaxed in `open-sse/` and `tests/`**: `@typescript-eslint/no-explicit-any` = warn
- React hooks rules and `@next/next/no-assign-module-variable` disabled in `open-sse/` and `tests/`
### Naming
| Element | Convention | Example |
| ------------------- | -------------------------------- | ------------------------------------ |
| Files | camelCase / kebab-case | `chatCore.ts`, `tokenHealthCheck.ts` |
| React components | PascalCase | `Dashboard.tsx`, `ProviderCard.tsx` |
| Functions/variables | camelCase | `getHealth()`, `switchCombo()` |
| Constants | UPPER_SNAKE | `MAX_RETRIES`, `DEFAULT_TIMEOUT` |
| Interfaces | PascalCase (`I` prefix optional) | `ProviderConfig` |
| Enums | PascalCase (members too) | `LogLevel.Error` |
### Imports
- **Order**: external → internal (`@/`, `@omniroute/open-sse`) → relative (`./`, `../`)
- **No barrel imports** from `localDb.ts` — import from the specific `db/` module instead
### Error Handling
- try/catch with specific error types; always log with context (pino logger)
- Never silently swallow errors in SSE streams — use abort signals for cleanup
- Return proper HTTP status codes (4xx client, 5xx server)
### Security
- **NEVER** commit API keys, secrets, or credentials
- Validate all user inputs with Zod schemas
- Auth middleware required on all API routes
- Never log SQLite encryption keys
- Sanitize user content (dompurify for HTML)
- **Public upstream OAuth identifiers** (Gemini / Antigravity / Windsurf-style client_id/secret + Firebase Web keys extracted from public CLIs): use `resolvePublicCred()` from `open-sse/utils/publicCreds.ts`, **never** as string literals. Full pattern in `docs/security/PUBLIC_CREDS.md`.
- **Error responses** (HTTP / SSE / executor / MCP): use `buildErrorBody()` or `sanitizeErrorMessage()` from `open-sse/utils/error.ts`, **never** put raw `err.stack` / `err.message` in a Response body. Full pattern in `docs/security/ERROR_SANITIZATION.md`.
- **`exec()` / `spawn()` with runtime values**: pass via the `env` option, **never** string-interpolate paths/values into the script body. Reference: `src/mitm/cert/install.ts::updateNssDatabases`.
- Prefer secure-by-default libraries when available — see [tldrsec/awesome-secure-defaults](https://github.com/tldrsec/awesome-secure-defaults) for the curated list (Helmet.js, DOMPurify, ssrf-req-filter, safe-regex, Google Tink, etc.).
---
## Architecture
### Data Layer (`src/lib/db/`)
All persistence uses SQLite through **95 domain-specific modules** in `src/lib/db/`. Top modules:
- Core: `core.ts`, `migrationRunner.ts`, `encryption.ts`, `stateReset.ts`
- Providers / catalog: `providers.ts`, `models.ts`, `providerLimits.ts`, `compressionAnalytics.ts`
- Routing: `combos.ts`, `modelComboMappings.ts`, `domainState.ts`, `commandCodeAuth.ts`
- Auth: `apiKeys.ts`, `secrets.ts`, `registeredKeys.ts`, `sessionAccountAffinity.ts`
- Usage / billing: `quotaSnapshots.ts`, `creditBalance.ts`, `usage*.ts`, `compressionCacheStats.ts`
- Storage: `backup.ts`, `cleanup.ts`, `jsonMigration.ts`, `healthCheck.ts`, `databaseSettings.ts`
- Extension modules: `evals.ts`, `webhooks.ts`, `reasoningCache.ts`, `readCache.ts`, `tierConfig.ts`, `compressionCombos.ts`, `compressionScheduler.ts`, `batches.ts`, `files.ts`, `syncTokens.ts`, `proxies.ts`, `oneproxy.ts`, `upstreamProxy.ts`, `versionManager.ts`, `cliToolState.ts`, `prompts.ts`, `detailedLogs.ts`, `contextHandoffs.ts`, `compression.ts`, `stats.ts`
Live count: `ls src/lib/db/*.ts | wc -l` (currently 95). Drift detection: `npm run check:docs-counts`.
Schema migrations live in `db/migrations/` (**110 files** as of v3.8.43) and run via `migrationRunner.ts`.
`src/lib/localDb.ts` is a **re-export layer only** — never add logic there.
#### DB Internals
- **`core.ts`**: `getDbInstance()` returns a singleton `better-sqlite3` instance with WAL
journaling. `SCHEMA_SQL` defines **17 base tables** (verify with `grep -c "CREATE TABLE" src/lib/db/core.ts` minus 1 for the bookkeeping `_omniroute_migrations` table). Helpers: `rowToCamel`, `encryptConnectionFields`.
- **`migrationRunner.ts`**: Applies versioned SQL files from `db/migrations/` inside transactions.
Tracks applied migrations in `_omniroute_migrations` table.
- **Migrations**: 110 files (`001_initial_schema.sql` → `110_*.sql`).
Each migration is idempotent and runs in a transaction. Live count: `ls src/lib/db/migrations/*.sql | wc -l`.
- **Domain modules** import `getDbInstance()` from `core.ts` for all CRUD operations.
Each module owns a specific table/set of tables (e.g., `providers.ts` → `provider_connections`,
`combos.ts` → `combos`). Encryption helpers protect sensitive fields at rest.
- **`localDb.ts`** re-exports all domain modules — consumers import from here for convenience.
### API Route Layer (`src/app/api/v1/`)
Next.js App Router routes — each follows a consistent pattern:
```
Route → CORS preflight → Body validation (Zod) → Optional auth (extractApiKey/isValidApiKey)
→ API key policy enforcement (enforceApiKeyPolicy) → Handler delegation (open-sse)
```
| Route | Handler | Notes |
| ------------------------------- | ------------------------- | ------------------------------------------------------------- |
| `chat/completions/route.ts` | `handleChat()` | + prompt injection guard (clones request) |
| `responses/route.ts` | `handleChat()` (unified) | Responses API format |
| `embeddings/route.ts` | `handleEmbedding()` | Model listing + creation |
| `images/generations/route.ts` | `handleImageGeneration()` | Model listing + creation |
| `audio/transcriptions/route.ts` | audio handler | Multipart form data |
| `audio/speech/route.ts` | TTS handler | Binary audio response |
| `videos/generations/route.ts` | video handler | ComfyUI/SD WebUI |
| `music/generations/route.ts` | music handler | ComfyUI workflows |
| `moderations/route.ts` | moderation handler | Content safety |
| `rerank/route.ts` | rerank handler | Document relevance |
| `search/route.ts` | search handler | Web search (12 providers per `open-sse/handlers/search.ts:6`) |
**No global Next.js middleware file** — interception is route-specific. Auth is optional
(controlled by `REQUIRE_API_KEY` env). Prompt injection guard is unique to chat completions.
### Request Pipeline (`open-sse/`)
The `open-sse/` workspace is the core streaming engine. Full request flow:
```
Client Request
→ src/app/api/v1/.../route.ts (Next.js route)
→ open-sse/handlers/chatCore.ts::handleChatCore()
→ Semantic/signature cache check
→ Rate limit check (rateLimitManager)
→ Combo routing? → open-sse/services/combo.ts::handleComboChat()
→ resolveComboTargets() → ordered ResolvedComboTarget[]
→ For each target: handleSingleModel() (wraps chatCore)
→ translateRequest() (open-sse/translator/)
→ Convert source format (e.g., OpenAI) → target format (e.g., Claude)
→ getExecutor() → provider-specific executor instance
→ executor.execute() (BaseExecutor → DefaultExecutor or provider-specific)
→ buildUrl() + buildHeaders() + transformRequest()
→ fetch() to upstream provider
→ Retry logic with exponential backoff
→ Response translation back to client format
→ If Responses API: responsesTransformer.ts TransformStream
→ SSE stream or JSON response to client
```
**Handlers** (`open-sse/handlers/`): `chatCore.ts`, `responsesHandler.ts`, `embeddings.ts`,
`imageGeneration.ts`, `videoGeneration.ts`, `musicGeneration.ts`, `audioSpeech.ts`,
`audioTranscription.ts`, `moderations.ts`, `rerank.ts`, `search.ts`.
**Upstream headers**: merged after default auth; same header name replaces executor value.
**T5 intra-family fallback** recomputes headers using only the fallback model id.
Forbidden header names: `src/shared/constants/upstreamHeaders.ts` — keep sanitize,
Zod schemas, and unit tests aligned when editing.
### Provider Categories
- **Free** (2): Qoder AI, Kiro AI
- **OAuth** (13): Claude Code, Antigravity, Codex, GitHub Copilot, Cursor, Kimi Coding, Kilo Code, Cline, Kiro, Qoder, Gemini, Windsurf (v3.8), GitLab Duo (v3.8)
- **API Key** (120+): OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Perplexity,
Together, Fireworks, Cerebras, Cohere, NVIDIA, Nebius, SiliconFlow, Hyperbolic,
HuggingFace, OpenRouter, Vertex AI, Cloudflare AI, Scaleway, AI/ML API, Pollinations,
Puter, Longcat, Alibaba, Kimi, Minimax, Blackbox, Synthetic, Kilo Gateway,
Z.AI, GLM, Deepgram, AssemblyAI, ElevenLabs, Cartesia, PlayHT, Inworld,
NanoBanana, SD WebUI, ComfyUI, Ollama Cloud, Perplexity Search, Serper, Brave, Exa,
Tavily, OpenCode Zen/Go, Bailian Coding Plan, DeepInfra, Vercel AI Gateway,
Lambda AI, SambaNova, nScale, OVHcloud AI, Baseten, PublicAI, Moonshot AI,
Meta Llama API, v0 (Vercel), Morph, Featherless AI, FriendliAI, LlamaGate,
Galadriel, Weights & Biases Inference, Volcengine, AI21 Labs, Venice.ai,
Codestral, Upstage, Maritalk, Xiaomi MiMo, Inference.net, NanoGPT, Predibase,
Bytez, Heroku AI, Databricks, Snowflake Cortex, GigaChat (Sber), CrofAI,
AgentRouter, ChatGPT Web, Baidu Qianfan, AWS Polly, RunwayML, GitLab Duo,
Amazon Q, Empower, Poe, and many more.
- **Self-Hosted** (8+): LM Studio, vLLM, Lemonade, Llamafile, Triton, Docker Model Runner, Xinference, Oobabooga
- **Custom**: OpenAI-compatible (`openai-compatible-*`) and Anthropic-compatible (`anthropic-compatible-*`) prefixes
Providers are registered in `src/shared/constants/providers.ts` with Zod validation at module load.
### Executors (`open-sse/executors/`)
Provider-specific request executors: `base.ts`, `default.ts`, `cursor.ts`, `codex.ts`,
`antigravity.ts`, `github.ts`, `kiro.ts`, `qoder.ts`, `vertex.ts`,
`cloudflare-ai.ts`, `opencode.ts`, `pollinations.ts`, `puter.ts`.
#### Executor Internals
- **`base.ts`** (`BaseExecutor`): Abstract base with `buildUrl()`, `buildHeaders()`,
`transformRequest()`, retry logic (exponential backoff), and `execute()`. Subclasses
override URL/header/transform methods for provider-specific behavior.
- **`default.ts`** (`DefaultExecutor extends BaseExecutor`): Handles most OpenAI-compatible
providers. Reads provider config from `providerRegistry.ts` to resolve base URL, auth
header format, and request transformations.
- **`getExecutor()`** (`executors/index.ts`): Factory that returns the correct executor
instance based on provider ID. Provider-specific executors (Cursor, Codex, Vertex, etc.)
override only what differs from the default.
### Translator (`open-sse/translator/`)
Translates between API formats (OpenAI-format ↔ Anthropic, Gemini, etc.).
Includes request/response translators with helpers for image handling.
#### Translator Internals
- **`translator/index.ts`**: Exports `translateRequest()` and format constants. Called by
`chatCore.ts` before executor dispatch.
- **Flow**: `translateRequest(body, sourceFormat, targetFormat)` → detects source format
(OpenAI, Anthropic, Gemini) → applies the matching translator module → returns
transformed body ready for the target provider.
- **Response translation** runs in reverse after upstream response, converting back to
the client's expected format.
### Transformer (`open-sse/transformer/`)
`responsesTransformer.ts` — transforms Responses API format to/from Chat Completions format.
#### Transformer Internals
- **`createResponsesApiTransformStream()`**: Returns a `TransformStream` that converts
Chat Completions SSE chunks (`data: {"choices":[...]}`) into Responses API SSE events
(`response.output_item.added`, `response.output_text.delta`, etc.).
- Used when the client sends a Responses API request: the request is internally converted
to Chat Completions format, dispatched normally, and the response is piped through this
transform stream before reaching the client.
### Services (`open-sse/services/`)
134 service modules in `open-sse/services/` (top-level only; more including sub-dirs like `autoCombo/` and `compression/`). Refresh: `ls open-sse/services/*.ts | wc -l`. Key modules:
`combo.ts` (routing engine), `usage.ts`, `tokenRefresh.ts`,
`rateLimitManager.ts`, `accountFallback.ts`, `sessionManager.ts`, `wildcardRouter.ts`,
`autoCombo/`, `intentClassifier.ts`, `taskAwareRouter.ts`, `thinkingBudget.ts`,
`contextManager.ts`, `modelDeprecation.ts`, `modelFamilyFallback.ts`,
`emergencyFallback.ts`, `workflowFSM.ts`, `backgroundTaskDetector.ts`, `ipFilter.ts`,
`signatureCache.ts`, `volumeDetector.ts`, `contextHandoff.ts`, `compression/` (prompt
compression pipeline), and more.
#### Prompt Compression Pipeline (`compression/`)
Modular prompt compression that runs proactively before the existing reactive context manager.
- **`strategySelector.ts`**: Selects compression mode based on config, compression combo assignments,
combo overrides, auto-trigger thresholds, and defaults. Priority: assigned compression combo >
combo override > auto-trigger > default mode > off.
- **`lite.ts`**: 5 lite-mode techniques: `collapseWhitespace`, `dedupSystemPrompt`,
`compressToolResults`, `removeRedundantContent`, `replaceImageUrls`. Target: 10-15% savings at
<1ms latency.
- **`caveman.ts` / `cavemanRules.ts`**: Caveman-style semantic condensation backed by built-in
rules plus file-loaded language packs under `compression/rules/`.
- **`engines/rtk/`**: Rule-based terminal/tool-output compression inspired by RTK patterns. Detects
command output classes, applies JSON filter packs, deduplicates repeated lines, strips ANSI/code
noise, and preserves errors/actionable context. The RTK JSON DSL supports replace,
match-output short-circuit, strip/keep, per-line truncation, head/tail/max-line truncation,
inline tests, trust-gated project/global custom filters, and optional redacted raw-output
retention for authenticated recovery.
- **`engines/registry.ts`**: Registers engines (`caveman`, `rtk`) and powers stacked pipelines.
- **`stats.ts`**: Per-request compression stats tracking (original tokens, compressed tokens,
savings %, techniques used, engine breakdown, compression combo id).
- **`types.ts`**: `CompressionMode` (off/lite/standard/aggressive/ultra/rtk/stacked),
`CompressionConfig`, `CompressionStats`, `CompressionResult`.
- DB settings in `src/lib/db/compression.ts`, compression combos in
`src/lib/db/compressionCombos.ts`, API routes under `src/app/api/settings/compression/`,
`src/app/api/context/*`, and preview/language-pack routes under `src/app/api/compression/*`.
#### Combo Routing Engine (`combo.ts`)
- **`handleComboChat()`**: Entry point for combo-routed requests. Receives the combo config
and iterates through targets in order until one succeeds or all fail.
- **`resolveComboTargets()`**: Expands a combo configuration into an ordered array of
`ResolvedComboTarget[]`, each specifying provider + model + account + credentials.
- **Strategies** (17): priority, weighted, fill-first, round-robin, P2C, random, least-used, reset-aware (v3.8),
reset-window, cost-optimized, strict-random, auto, lkgp, context-optimized, context-relay, headroom, fusion. Source: `ROUTING_STRATEGY_VALUES` in `src/shared/constants/routingStrategies.ts`.
- Each target calls **`handleSingleModel()`** which wraps `handleChatCore()` with
per-target error handling and circuit breaker checks.
### Domain Layer (`src/domain/`)
Policy engine modules: `policyEngine.ts`, `comboResolver.ts`, `costRules.ts`,
`degradation.ts`, `fallbackPolicy.ts`, `lockoutPolicy.ts`, `modelAvailability.ts`,
`providerExpiration.ts`, `quotaCache.ts`, `responses.ts`, `configAudit.ts`.
### MCP Server (`open-sse/mcp-server/`)
**104 tools** total (`TOTAL_MCP_TOOL_COUNT`, `open-sse/mcp-server/server.ts`): a 42-entry base registry (`MCP_TOOLS` in `schemas/tools.ts`, bundling the core / cache / compression / 1proxy / advanced tools) **plus** standalone module sets — memory (3), skill (4), agentSkill (3), pool (6), gamification (8), plugin (8), notion (6), obsidian (22). 3 transports (stdio / SSE / Streamable HTTP). Scoped auth (31 scopes — see `OMNIROUTE_MCP_SCOPES`), Zod schemas. See [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md).
**Core tools** (20): get_health, list_combos, get_combo_metrics, switch_combo, check_quota,
route_request, cost_report, list_models_catalog, web_search, simulate_route, set_budget_guard,
set_routing_strategy, set_resilience_profile, test_combo, get_provider_metrics,
best_combo_for_task, explain_route, get_session_snapshot, db_health_check, sync_pricing.
**Cache tools** (2): cache_stats, cache_flush.
**Compression tools** (5): compression_status, compression_configure, set_compression_engine,
list_compression_combos, compression_combo_stats.
**1proxy tools** (3): oneproxy_fetch, oneproxy_rotate, oneproxy_stats.
**Memory tools** (3): memory_search, memory_add, memory_clear.
**Skill tools** (4): skills_list, skills_enable, skills_execute, skills_executions.
**Agent-skill tools** (3): A2A skill discovery / invocation bridges.
**Gamification tools** (8): levels, badges, leaderboard, and community-federation queries.
**Plugin tools** (8): plugin marketplace listing, install/enable/disable, and runtime inspection.
**Notion tools** (6) + **Obsidian tools** (22): knowledge-base read/write integrations (the largest tool family — vault search, note CRUD, WebDAV-backed file ops).
#### MCP Internals
- **Tool registration**: Each tool is an object with `{ name, description, inputSchema: ZodSchema,
handler: async (args) => {...} }`. Zod validates inputs before the handler fires.
- **`createMcpServer()`** and **`startMcpStdio()`** exported from `mcp-server/index.ts`.
`createMcpServer()` wires all tool sets; `startMcpStdio()` launches the stdio transport.
- **Transports**: stdio (CLI `omniroute --mcp`), SSE (`/api/mcp/sse`), Streamable HTTP
(`/api/mcp/stream`). All share the same tool/scope engine.
- **Scopes** (30): Control which tool categories an API key can access. Enforcement happens
before handler dispatch.
- **Audit**: Every tool invocation is logged to SQLite (`mcp_audit` table) with tool name,
args, success/failure, API key attribution, and timestamp.
### A2A Server (`src/lib/a2a/`)
JSON-RPC 2.0, SSE streaming, Task Manager with TTL cleanup.
Agent Card at `/.well-known/agent.json`.
Skills (6): `smartRouting.ts`, `quotaManagement.ts`, `providerDiscovery.ts`, `costAnalysis.ts`, `healthReport.ts`, `listCapabilities.ts`.
#### A2A Internals
- **`taskManager.ts`**: State machine lifecycle for tasks: `submitted → working →
completed | failed | canceled`. Tasks have TTL and are cleaned up automatically.
- **JSON-RPC methods**: `message/send` (sync), `message/stream` (SSE), `tasks/get`,
`tasks/cancel`. Dispatched via `POST /a2a`.
- **Skills**: Registered in a DB-backed registry. Each skill receives task context
(messages, metadata) and returns structured results. `quotaManagement.ts` summarizes
quota; `smartRouting.ts` recommends routing decisions.
- **Agent Card**: `/.well-known/agent.json` exposes capabilities, skills, and metadata
for client auto-discovery.
### ACP Module (`src/lib/acp/`)
Agent Communication Protocol registry and manager.
### Memory System (`src/lib/memory/`)
Extraction, injection, retrieval, summarization, and store modules for persistent
conversational memory across sessions.
### Skills System (`src/lib/skills/`)
Extensible skill framework: registry, executor, sandbox, built-in skills,
custom skill support, interception, and injection.
#### Skills Internals
- **`registry.ts`**: DB-backed skill registration and discovery. Skills have metadata
(name, description, version, enabled status) stored in SQLite.
- **`executor.ts`**: Execution engine with configurable timeout and retry logic.
Receives skill name + input, looks up the skill, runs it in the sandbox.
- **`sandbox.ts`**: Isolation layer for custom (user-provided) skills. Limits resource
access and execution time.
- **Built-in skills**: Ship with OmniRoute (e.g., quota management, routing). Located
alongside the registry.
- **Interception/Injection**: Skills can intercept requests in the pipeline (pre/post
processing) or inject context into prompts.
### Compliance (`src/lib/compliance/`)
Policy index for compliance enforcement.
### MITM Proxy (`src/mitm/`)
MITM proxy capability with certificate management, DNS handling, and target routing.
### Middleware (`src/middleware/`)
Request middleware including `promptInjectionGuard.ts`.
### Guardrails (`src/lib/guardrails/`)
Hot-reloadable guardrails framework (3 built-in: pii-masker, prompt-injection, vision-bridge). Fail-open. The `pii-masker` guardrail is registered and runs on every request, but its data-mutating logic is **opt-in** and OFF by default — it only redacts when `PII_REDACTION_ENABLED` (request) / `PII_RESPONSE_SANITIZATION` (response + streaming) are enabled (both `defaultValue: "false"`); with them off, payloads pass through untouched. A request can additionally opt OUT of any guardrail via header (`x-omniroute-disabled-guardrails`). Never make PII default-on (Hard Rule #20). See [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md).
### Cloud Agents (`src/lib/cloudAgent/`)
`CloudAgentBase` abstract class + 3 agents (codex-cloud, devin, jules). Tasks persisted in `cloud_agent_tasks`; management auth required. See [`docs/frameworks/CLOUD_AGENT.md`](docs/frameworks/CLOUD_AGENT.md).
### Evals (`src/lib/evals/`)
Generic eval framework: `evalRunner.ts`, `runtime.ts`. Targets: combo / model / suite-default. See [`docs/frameworks/EVALS.md`](docs/frameworks/EVALS.md).
### Webhooks (`src/lib/webhookDispatcher.ts`)
HMAC-signed delivery, exponential backoff, auto-disable after 10 failures. 7 event types. See [`docs/frameworks/WEBHOOKS.md`](docs/frameworks/WEBHOOKS.md).
### Authorization Pipeline (`src/server/authz/`)
`classify → policies → enforce`. 3 route classes (PUBLIC / CLIENT_API / MANAGEMENT). See [`docs/architecture/AUTHZ_GUIDE.md`](docs/architecture/AUTHZ_GUIDE.md).
### Reasoning Replay (`src/lib/db/reasoningCache.ts` + `open-sse/services/reasoningCache.ts`)
Hybrid in-memory + SQLite cache for `reasoning_content`. Re-injects on multi-turn for strict providers (DeepSeek V4, Kimi K2, Qwen-Thinking, GLM, xiaomi-mimo). See [`docs/routing/REASONING_REPLAY.md`](docs/routing/REASONING_REPLAY.md).
### Tunnels (`src/lib/{cloudflaredTunnel,ngrokTunnel}.ts` + `src/app/api/tunnels/`)
Cloudflare Quick/Named, ngrok, Tailscale Funnel. See [`docs/ops/TUNNELS_GUIDE.md`](docs/ops/TUNNELS_GUIDE.md).
### Adding a New Provider
1. Register in `src/shared/constants/providers.ts`
2. Add executor in `open-sse/executors/` (if custom logic needed)
3. Add translator in `open-sse/translator/` (if non-OpenAI format)
4. Add OAuth config in `src/lib/oauth/constants/oauth.ts` (if OAuth-based)
5. Add models in `open-sse/config/providerRegistry.ts`
---
## Subdirectory AGENTS.md Files
- **[`src/lib/db/AGENTS.md`](src/lib/db/AGENTS.md)** — SQLite persistence, domain modules, migrations
- **[`open-sse/services/AGENTS.md`](open-sse/services/AGENTS.md)** — Routing engine, combo resolution, strategy selection
## Reference Documentation (docs/)
For any non-trivial change, read the matching deep-dive first:
| Area | Doc |
| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
| Repo navigation | [`docs/architecture/REPOSITORY_MAP.md`](docs/architecture/REPOSITORY_MAP.md) |
| Architecture | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
| Engineering reference | [`docs/architecture/CODEBASE_DOCUMENTATION.md`](docs/architecture/CODEBASE_DOCUMENTATION.md) |
| Auto-Combo (12-factor, 18 strategies) | [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md) |
| Resilience (3 layers) | [`docs/architecture/RESILIENCE_GUIDE.md`](docs/architecture/RESILIENCE_GUIDE.md) |
| Skills | [`docs/frameworks/SKILLS.md`](docs/frameworks/SKILLS.md) |
| Memory | [`docs/frameworks/MEMORY.md`](docs/frameworks/MEMORY.md) |
| Cloud agents | [`docs/frameworks/CLOUD_AGENT.md`](docs/frameworks/CLOUD_AGENT.md) |
| Guardrails | [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md) |
| Evals | [`docs/frameworks/EVALS.md`](docs/frameworks/EVALS.md) |
| Compliance | [`docs/security/COMPLIANCE.md`](docs/security/COMPLIANCE.md) |
| Webhooks | [`docs/frameworks/WEBHOOKS.md`](docs/frameworks/WEBHOOKS.md) |
| Authz | [`docs/architecture/AUTHZ_GUIDE.md`](docs/architecture/AUTHZ_GUIDE.md) |
| Stealth | [`docs/security/STEALTH_GUIDE.md`](docs/security/STEALTH_GUIDE.md) |
| Reasoning replay | [`docs/routing/REASONING_REPLAY.md`](docs/routing/REASONING_REPLAY.md) |
| Agent protocols (A2A / ACP / Cloud) | [`docs/frameworks/AGENT_PROTOCOLS_GUIDE.md`](docs/frameworks/AGENT_PROTOCOLS_GUIDE.md) |
| MCP server | [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md) |
| A2A server | [`docs/frameworks/A2A-SERVER.md`](docs/frameworks/A2A-SERVER.md) |
| API reference | [`docs/reference/API_REFERENCE.md`](docs/reference/API_REFERENCE.md) + [`docs/openapi.yaml`](docs/openapi.yaml) |
| Provider catalog (auto-generated) | [`docs/reference/PROVIDER_REFERENCE.md`](docs/reference/PROVIDER_REFERENCE.md) |
| Tunnels | [`docs/ops/TUNNELS_GUIDE.md`](docs/ops/TUNNELS_GUIDE.md) |
| Electron desktop | [`docs/guides/ELECTRON_GUIDE.md`](docs/guides/ELECTRON_GUIDE.md) |
| Release flow | [`docs/ops/RELEASE_CHECKLIST.md`](docs/ops/RELEASE_CHECKLIST.md) |
| Quality gates (35 gates, allowlist policy) | [`docs/architecture/QUALITY_GATES.md`](docs/architecture/QUALITY_GATES.md) |
| Cluster opt-in profiles (memory, bifrost) | [`docs/architecture/cluster-decisions.md`](docs/architecture/cluster-decisions.md) |
---
## Fork / Upstream Workflow
This repository is a fork of `diegosouzapw/OmniRoute`. Keep fork-only operational
changes (for example GHCR image publishing, personal deployment workflows, or local
automation) out of upstream contribution PRs.
When preparing a PR for upstream, always start the work branch from the upstream
**default branch** — the active `release/vX.Y.Z` line (today `release/v3.8.49`).
Never branch from `main`: `main` only receives release squash-merges, so a branch
cut there is weeks behind and produces conflict-heavy PRs
(see `CONTRIBUTING.md` and `docs/ops/BRANCHING_MODEL.md`):
OmniRoute is a unified AI proxy/router. The repository contains the Next.js application
(`src/`), streaming engine workspace (`open-sse/`), Electron desktop app (`electron/`),
CLI (`bin/`), and tests (`tests/`).
## Setup and focused checks
- Runtime: Node.js `>=22.22.3 <23` or `>=24.0.0 <27`; npm 10+.
- Install dependencies: `npm install`.
- Start development: `npm run dev`.
- Build: `npm run build`; release build: `npm run build:release`.
- Lint: `npm run lint`.
- Core type check: `npm run typecheck:core`.
- Run the most focused test for changed code first:
`node --import tsx/esm --test tests/unit/<file>.test.ts`.
- Other suites: `npm run test:vitest`, `npm run test:e2e`,
`npm run test:protocols:e2e`, and `npm run test:ecosystem`.
- Run `npm run check:docs-all` after changing documentation.
For the complete test matrix, coverage requirements, and pull-request gates, read
[`CONTRIBUTING.md`](CONTRIBUTING.md#running-tests).
## Documentation accuracy
Documentation must describe verified behavior, not plausible behavior.
1. Before documenting an API name, endpoint, path, CLI command, or environment variable,
search for it: `rg -n "name" src/ open-sse/ bin/`. If it has no source match, do not
document it.
2. Measure mutable counts instead of writing them from memory: use `wc -l <file>` or a
directory-specific count command.
3. Copy code examples from working usage or run them. Prefer a source link such as
`path/to/file.ts:line` to an invented signature.
4. Run `npm run check:docs-all` for edits under `docs/`; it includes the fabricated-docs
validation.
## Code conventions
- Format with Prettier: two spaces, semicolons, double quotes, 100-character line width,
and ES5 trailing commas. Run Prettier on changed files.
- TypeScript target is ES2022 with bundler module resolution. Prefer explicit types.
- Import order: external, internal (`@/` and `@omniroute/open-sse`), then relative.
- Do not add logic to `src/lib/localDb.ts`; import from the owning `src/lib/db/` module.
- Use specific errors and contextual logging. Do not silently swallow SSE-stream failures;
use abort signals for cleanup and return appropriate HTTP status codes.
## Security requirements
- Never commit credentials or log SQLite encryption keys.
- Validate API inputs with Zod and use the route's required authentication path.
- Sanitize user HTML with DOMPurify.
- Use `resolvePublicCred()` for public upstream OAuth identifiers; never add them as string
literals. See [`docs/security/PUBLIC_CREDS.md`](docs/security/PUBLIC_CREDS.md).
- Use `buildErrorBody()` or `sanitizeErrorMessage()` for HTTP, SSE, executor, and MCP errors;
do not return raw `err.stack` or `err.message`. See
[`docs/security/ERROR_SANITIZATION.md`](docs/security/ERROR_SANITIZATION.md).
- Pass runtime values to `exec()` or `spawn()` through `env`, not interpolation into a script.
## Repository map
Read the nearest `AGENTS.md` and the linked deep-dive before making a non-trivial change.
| Area | Location | Start here |
| ---------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| API routes | `src/app/api/v1/` | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
| Streaming request handling | `open-sse/handlers/` | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
| Provider execution and translation | `open-sse/executors/`, `open-sse/translator/` | [`docs/architecture/CODEBASE_DOCUMENTATION.md`](docs/architecture/CODEBASE_DOCUMENTATION.md) |
| Routing and resilience | `open-sse/services/` | [`open-sse/services/AGENTS.md`](open-sse/services/AGENTS.md), [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md) |
| Database and migrations | `src/lib/db/`, `db/migrations/` | [`src/lib/db/AGENTS.md`](src/lib/db/AGENTS.md) |
| Domain policy | `src/domain/` | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
| MCP and A2A | `open-sse/mcp-server/`, `src/lib/a2a/` | [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md), [`docs/frameworks/A2A-SERVER.md`](docs/frameworks/A2A-SERVER.md) |
| Agent features | `src/lib/{acp,memory,skills,cloudAgent}/` | [`docs/frameworks/AGENT_PROTOCOLS_GUIDE.md`](docs/frameworks/AGENT_PROTOCOLS_GUIDE.md), [`docs/frameworks/SKILLS.md`](docs/frameworks/SKILLS.md) |
| Safety and governance | `src/lib/{guardrails,compliance}/`, `src/server/authz/` | [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md), [`docs/architecture/AUTHZ_GUIDE.md`](docs/architecture/AUTHZ_GUIDE.md) |
| Operations | `src/mitm/`, tunnel modules, `electron/` | [`docs/ops/TUNNELS_GUIDE.md`](docs/ops/TUNNELS_GUIDE.md), [`docs/guides/ELECTRON_GUIDE.md`](docs/guides/ELECTRON_GUIDE.md) |
## Review focus
- Keep database operations in `src/lib/db/`; do not issue raw SQL from routes.
- Send provider requests through `open-sse/handlers/`.
- Keep MCP and A2A pages as tabs inside `/dashboard/endpoint`.
- Preserve SSE cleanup, rate-limit header parsing, Zod validation, and provider-schema
validation.
- Treat Memory and Skills as cross-cutting changes that can affect MCP tools, the request
pipeline, and A2A skills.
- Do not close a contributor pull request after using its code; merge it through GitHub so
the contributor receives credit.
## Upstream contributions
This checkout is a fork of `diegosouzapw/OmniRoute`. Keep fork-only deployment and personal
automation changes out of upstream PRs.
Start upstream work from the active upstream default branch, not `main`:
```bash
git fetch upstream
# the default branch is the active release line, e.g. release/v3.8.49
git switch -c <branch-name> upstream/release/vX.Y.Z
git switch -c <branch-name> upstream/<default-branch>
```
Only cherry-pick or reapply the changes intended for the upstream PR.
Target that same release branch in the pull request. Stage only the intended files, run the
focused checks, and use a Conventional Commit message (for example, `docs: slim AGENTS.md`).
---
## Reference documentation
## Review Focus
Use the source of truth for the area you are changing:
- **DB ops** go through `src/lib/db/` modules, never raw SQL in routes
- **Provider requests** flow through `open-sse/handlers/`
- **MCP/A2A pages** are tabs inside `/dashboard/endpoint`, not standalone routes
- **No memory leaks** in SSE streams (abort signals, cleanup)
- **Rate limit headers** must be parsed correctly
- All API inputs validated with **Zod schemas**
- **Provider constants** validated at module load via Zod (`src/shared/validation/providerSchema.ts`)
- **Pricing data** syncs from LiteLLM via `src/lib/pricingSync.ts`
- **Memory/Skills** are cross-cutting: affect MCP tools, request pipeline, and A2A skills
- **⛔ NEVER close a contributor's PR** after using their code — always merge via GitHub so they get credit. See `.agents/workflows/review-prs.md` for full policy.
| Area | Reference |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Repository navigation and architecture | [`docs/architecture/REPOSITORY_MAP.md`](docs/architecture/REPOSITORY_MAP.md), [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
| API and providers | [`docs/reference/API_REFERENCE.md`](docs/reference/API_REFERENCE.md), [`docs/reference/PROVIDER_REFERENCE.md`](docs/reference/PROVIDER_REFERENCE.md), [`docs/openapi.yaml`](docs/openapi.yaml) |
| Routing, resilience, and reasoning | [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md), [`docs/architecture/RESILIENCE_GUIDE.md`](docs/architecture/RESILIENCE_GUIDE.md), [`docs/routing/REASONING_REPLAY.md`](docs/routing/REASONING_REPLAY.md) |
| Security | [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md), [`docs/security/COMPLIANCE.md`](docs/security/COMPLIANCE.md), [`docs/security/STEALTH_GUIDE.md`](docs/security/STEALTH_GUIDE.md) |
| Platform features | [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md), [`docs/frameworks/A2A-SERVER.md`](docs/frameworks/A2A-SERVER.md), [`docs/frameworks/SKILLS.md`](docs/frameworks/SKILLS.md), [`docs/frameworks/MEMORY.md`](docs/frameworks/MEMORY.md) |
| Releases and quality | [`docs/ops/RELEASE_CHECKLIST.md`](docs/ops/RELEASE_CHECKLIST.md), [`docs/architecture/QUALITY_GATES.md`](docs/architecture/QUALITY_GATES.md) |

View File

@@ -0,0 +1 @@
- **feat(providers):** filter provider detail connections server-side while preserving full-page search and pagination. (thanks @RobertsXML)

View File

@@ -0,0 +1,3 @@
- fix(vision-bridge): describe-model no longer returns unreachable "openai/gpt-4o-mini" when every vision-capable provider is unreachable on the instance — returns null instead and surfaces a clear error (#8430)
- fix(vision-bridge): validate fixedModel against usable credentials before short-circuiting in getBestVisionModel, so the default "openai/gpt-4o-mini" is not unconditionally selected when no OpenAI connection exists (#8430)
- fix(vision-bridge): in the combo describe path, replace raw images with an error text stub when all describe attempts fail, instead of forwarding images to a confirmed non-vision backend that would reject them with an opaque serde error (#8430)

View File

@@ -0,0 +1 @@
- fix(quality): add base-relative file-size check so inherited drift does not red innocent PRs (#8522)

View File

@@ -0,0 +1 @@
- fix(executor): guard claude/anthropic buildHeaders against empty credentials and extend dual-Bearer parity for third-party baseUrls (#8653)

View File

@@ -0,0 +1 @@
- **fix(api):** Let image and video providers enforce their own request-size limits instead of rejecting media payloads at OmniRoute's 10 MB global default ([#8843](https://github.com/diegosouzapw/OmniRoute/pull/8843)) — thanks @artickc

View File

@@ -0,0 +1 @@
- fix(proxy-health): include credentials in proxy health check URLs (#8853)

View File

@@ -0,0 +1 @@
- fix(auth): setting first dashboard login password no longer fails with HTTP 400 PASSWORD_REQUIRED (#8950)

View File

@@ -0,0 +1 @@
- fix(providers): copilot-m365-web enterprise turns send disconnectBehavior=continue (#8971)

View File

@@ -0,0 +1 @@
- **fix(sse):** error-only streams now preserve sanitized executor diagnostics for operators without changing stream-readiness fallback classification ([#9022](https://github.com/diegosouzapw/OmniRoute/pull/9022)) — thanks @shixi-li

View File

@@ -0,0 +1 @@
- **fix(translator):** pass `output_config.effort="max"` through verbatim instead of unconditionally rewriting it to `xhigh`, so Anthropic → OpenAI-shape upstream calls reach `sanitizeReasoningEffortForProvider` with the carrier intact and providers that accept `max` literally (Ollama Cloud, opencode-go DeepSeek, Moonshot K3, native Claude) no longer 400 on `invalid reasoning value: 'xhigh'`. Regression guard: end-to-end test in `tests/unit/base-executor-sanitize-effort.test.ts`.

View File

@@ -0,0 +1 @@
- fix(providers): anthropic strips code-execution/skills beta flag, causing container rejection (#9064)

View File

@@ -0,0 +1 @@
- **fix(dashboard):** the provider "Auto Sync" toggle now applies to every active connection and each connection gets its own Auto Sync toggle — previously only the lowest-priority connection was updated. ([#9149](https://github.com/diegosouzapw/OmniRoute/pull/9149))

View File

@@ -0,0 +1 @@
- fix(management): authorize mcp:connect-only keys on loopback/LAN when requireLogin is enabled (#9159)

View File

@@ -0,0 +1 @@
- **fix(cli-tools):** keep Apply enabled for active OpenAI-compatible and Anthropic-compatible providers without static catalog entries. (thanks @lazysaltyfish)

View File

@@ -0,0 +1 @@
- **fix(translator):** harden Claude format detection for relative message endpoints and kebab-case version metadata. (thanks @ervareza)

View File

@@ -0,0 +1 @@
- fix(claude): remove unconditional "always" return in claudeClassifierCompat so normal chat requests are not swallowed (#9276)

View File

@@ -0,0 +1 @@
- **fix(db):** persist the account egress IP into `proxy_logs.egress_ip` (migration 134 + schema reconciler) so real traffic stays attributable to the actual node/IP even after restart — the egress IP was previously computed and logged but silently dropped from persistence ([#9291](https://github.com/diegosouzapw/OmniRoute/pull/9291)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- fix(mcp): break circular import between googApiKeyAuth.ts and auth.ts to fix esbuild SyntaxError in MCP server bundle (#9297)

View File

@@ -0,0 +1 @@
- fix(providers): bump qwen-web SPA version header from 0.2.66 to 0.2.81 (#9304)

View File

@@ -0,0 +1 @@
- fix(security): require auth for /v1/models when management auth is configured (#9320)

View File

@@ -0,0 +1 @@
- fix(providers): map kimi-web/K3 to K2D5 scenario instead of OK Computer premium mode to fix resource_exhausted on non-subscriber accounts (#9338)

View File

@@ -0,0 +1 @@
- fix(security): require explicit tool envelope to prevent bare JSON from being promoted to real tool_calls (#9343)

View File

@@ -0,0 +1,2 @@
- fix(providers): treat claude-web 429 as unhealthy and forward upstream Retry-After header (#9406)
- fix(providers): treat muse-spark-web 429 as unhealthy (#9406)

View File

@@ -0,0 +1 @@
- fix(providers): detect expired gemini-web sessions via ServiceLogin redirect and add testConnection override (#9407)

View File

@@ -0,0 +1 @@
- fix(providers): add tool_use block handling to claude-web stream parser for OpenAI tool_calls projection (#9408)

View File

@@ -0,0 +1 @@
- fix(api): fall back to slugified provider name when prefix is empty to prevent UUID leak in /v1/models (#9416)

View File

@@ -0,0 +1 @@
- **fix(routing):** a Codex-native bare model id (`gpt-5.5`, the `gpt-5.6-sol`/`terra`/`luna` tiers) no longer routes to `codex` when no codex connection is active — an OpenAI-only install was getting `no active credentials for provider: codex` for a model OpenAI serves, and an install whose codex connection was merely inactive failed the same way. With codex active the Codex preference still wins over OpenAI, and ids only codex catalogs (`codex-auto-review`) still resolve to codex with no connection at all ([#9447](https://github.com/diegosouzapw/OmniRoute/pull/9447))

View File

@@ -0,0 +1 @@
- **chore(ci):** stopped dependabot from grouping `ioredis` majors with routine production bumps — the package is resolved through a dynamic import in the distributed quota store, so a breaking major passes build, typecheck and both test suites and only surfaces at runtime for operators running Redis-backed quota ([#9425](https://github.com/diegosouzapw/OmniRoute/pull/9425))

View File

@@ -0,0 +1 @@
- **chore(tests):** cleared two base-reds sitting on `release/v3.8.50` itself, both of which turned every open PR red the moment it merged the release. `tests/snapshots/provider/translate-path.json` was stale: #9064 added the `code-execution-2025-08-25` and `skills-2025-10-02` beta flags to the Anthropic header without regenerating the golden, so `provider-translate-path-golden` failed on the bare release tip (2 pass / 1 fail with zero PRs boarded). And `tests/unit/v1-models-auth-leak-9320.test.ts:83` shipped a `(k: any)` in a file new enough that `config/quality/eslint-suppressions.json` does not cover it — with `@typescript-eslint/no-explicit-any` set to `error` under `tests/`, that single cast failed the `No new ESLint warnings` gate repo-wide. Same class in `tests/unit/issue-9407-gemini-web-validation-false-positive.test.ts:50` (`(executor as any).testConnection`), which entered at `f1ea77fd04``testConnection` is a declared public method on `GeminiWebExecutor`, so that cast was redundant too. Regenerating the snapshot and dropping both casts restores the gates.

View File

@@ -0,0 +1 @@
- fix(quality): tighten eslintWarnings baseline 5000->0 to match the gate's suppressions-applied measurement (unblocks require-tighten on every code PR)

File diff suppressed because it is too large Load Diff

View File

@@ -283,6 +283,7 @@
"_rebaseline_2026_07_27_3850_relax_filesize_cap_v2_20pct": "OWNER-APPROVED TEMPORARY relax for v3.8.50-3.8.54 PREPARE phase (docs/ROADMAP.md). v1 was cap 800->900 / testCap 800->900 on 2026-07-27; v2 = v1 +20% buffer = cap 900->1000 (+100), testCap 900->1000 (+100). Justification: same as complexity v2 — the v3.8.50 release cut coincides with high-merge activity; owner accepted enlarging the headroom to cover the entire PREPARE phase (5 minor cycles .50-.54) without per-PR rebaseline noise. Targets: decompose-existing-frozen unchanged (frozen still only-shrink — see frozen[] entries and the 105 files >900 that still need structural decomposition regardless of cap); this only relaxes the cap for NEW files in the decompose/extract-while-PREPARE phase (.51='executor registry in-place' and .52='combo.ts decomposition' create new leaf modules above 800). RE-TIGHTENING MANDATORY in v3.8.51: cap target 850 = 850 once decomposition wave stabilizes (gives 150 units of post-tighten headroom vs the new 1000 ceiling). Tracked via same roadmap issue as complexity v2. Window: v3.8.50 (release cut) → v3.8.54 close (RE-TIGHTEN at v3.8.51 prep merge per ROADMAP.md). Last entry unless measured regression. v1 entry retained below for audit trail.",
"_rebaseline_2026_07_27_3850_relax_filesize_cap": "OWNER-APPROVED TEMPORARY relax for v3.8.50-3.8.54 PREPARE phase (docs/ROADMAP.md). cap 800->900 (+100), testCap 800->900 (+100). Targets: decompose-existing-frozen unchanged (frozen still only-shrink); this only relaxes the cap for NEW files in the decompose/extract-while-PREPARE phase (.51='executor registry in-place' and .52='combo.ts decomposition' create new leaf modules above 800). RE-TIGHTENING MANDATORY in v3.8.51: cap target 850 = 850 once decomposition wave stabilizes. SUPERSEDED by _rebaseline_2026_07_27_3850_relax_filesize_cap_v2_20pct (v1 +20% buffer) — retained for audit. Tracked via same roadmap issue.",
"_rebaseline_2026_07_27_v3849_train1h": "Merge-train 1H (31 PRs) — owner-approved 2026-07-27. Two distinct causes, kept separate on purpose: (1) GENUINE irreducible growth at existing chokepoints — providerLimits/auth (#8632 Kimi quota-reset recovery), rateLimitManager (#8616 idle wedged limiters), models-catalog-route.test (#8610 OpenCode Go effort aliases); (2) COLLISION with #8585, which banked shrinks measured on the pre-train release tip while 30 sibling PRs in the SAME train grew those files again — chat/accountFallback (#8628), chatCore (#8613), videoGeneration (#8581), imageGeneration. The zero-headroom frozen entries cannot absorb either. Ceilings re-pinned to the post-merge tip; #8612 (also in this train) automates shrink-banking so this self-inflicted drift stops recurring. Detail: src/lib/usage/providerLimits.ts 1006->1013 (#8632); src/sse/services/auth.ts 2492->2508 (#8632); open-sse/services/rateLimitManager.ts 1014->1060 (#8616); src/sse/handlers/chat.ts 1842->1845 (#8628); open-sse/handlers/chatCore.ts 4939->4955 (#8613); open-sse/handlers/imageGeneration.ts 3100->3101 ((sem PR — teto do #8585)); open-sse/handlers/videoGeneration.ts 1038->1063 (#8581); open-sse/services/accountFallback.ts 1965->1966 (#8628); tests/unit/models-catalog-route.test.ts 1608->1636 (#8610)",
"_rebaseline_2026_08_02_9242_token_health_transient": "PR #9242 (fix/refresh-circuit-transient): src/lib/tokenHealthCheck.ts 1021 (new file, above cap 1000). The file consolidates token-refresh health checking logic that was previously scattered across auth.ts and tokenRefresh.ts. Cohesive single-responsibility module for refresh circuit state management; not extractable without splitting the refresh state machine. Covered by tests/unit/tokenHealthCheck-transient.test.ts.",
"frozen": {
"_rebaseline_2026_06_22_4644_deepseek_web_tools": "PR #4644 (BugsBag/robust deepseek-web tool-call parsing): open-sse/executors/deepseek-web.ts 1117->1125 (+8). The new agentic tool-call path emits surrounding text + reasoning before tool_calls and swaps to the dedicated deepseekWebTools.ts parser; the +8 lines are cohesive wiring at the existing transformSSE chokepoint (the parser itself lives in the new deepseekWebTools.ts file, already under cap). The PR's own fast-gate (PR->release) does not run check:file-size, so this surfaced only at release reconcile. Covered by tests/unit/deepseek-web-tools-variants.test.ts + deepseek-web-tools-execute.test.ts.",
"_rebaseline_2026_06_23_4712_deepseek_web_tool_results": "PR for #4712 (deepseek-web drops role:tool): open-sse/executors/deepseek-web.ts 1125->1148 (+23). messagesToPrompt() now folds role:\"tool\" results into the single-prompt transcript (recovering the tool name from the preceding assistant tool_calls by tool_call_id) instead of silently dropping them; the lines are cohesive wiring inside the existing function. Covered by tests/unit/deepseek-web-tool-result-prompt-4712.test.ts.",
@@ -388,6 +389,7 @@
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1109,
"src/app/api/providers/[id]/models/route.ts": 2250,
"src/app/api/v1/models/catalog.ts": 1549,
"src/lib/tokenHealthCheck.ts": 1021,
"src/lib/db/apiKeys.ts": 1529,
"src/lib/db/core.ts": 1637,
"src/lib/db/migrationRunner.ts": 1077,

View File

@@ -3,23 +3,8 @@
"metrics": {
"eslintWarnings": {
"value": 0,
"_rebaseline_2026_07_03_v3844_residual_release_green": "4270->4279 (+9). v3.8.44 residual drift on release tip 716041223 (moving target: eslint 4270->4279 as the branch advanced past the prior rebaseline). Inherited from parallel-session merges (Quality Ratchet not on PR->release fast-gates).",
"_rebaseline_2026_07_03_v3844_ipfilter_release_green": "4256->4270 (+14). v3.8.44 cycle drift measured on release tip 32e4c906e during the #6131/#5975 release-green rebaseline. Inherited from the merge burst (Quality Ratchet does not run on PR->release fast-gates). route-edge-coverage +7 is my #5975 test comment; the rest is parallel-session drift. Tighten via --update next cycle.",
"_rebaseline_2026_07_03_v3844_review_prs_fix_batch": "4199->4256 (+57). Inherited v3.8.44 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings accrue unmeasured across the cycle). 4256 = measured by `node scripts/quality/collect-metrics.mjs` on the release tip 72ee80649 during the /review-prs fix-batch round. The round's own merges (#5958 SSE-accept, #5988 deepseek-web, #6013/#5974 retry-after-json, #5975 embeddings-proxy, #5973 non-json-guard) plus the parallel-session merge burst into release/v3.8.44 account for the delta; all `any`-warn-allowed in open-sse/ + tests/. Cyclomatic is already green (2012 < baseline 2015) and needs no bump. Tighten via --require-tighten next cycle.",
"_rebaseline_2026_07_02_v3843_release_close": "4158->4199 (+41). v3.8.43 release-close drift measured by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings accrued unmeasured across the ~120 commits merged after the mid-cycle 4158 rebaseline — the compression T02/T05/T06/T07/T08/T10 engine families, memory typed decay, provider adds Ollama/SenseNova, ~55 SSE/translator/kiro/oauth/dashboard fixes, and the god-file decomposition wave). Trust-but-verify: measured 4199 via `npm run lint` on the release-finalize working tree INCLUDING my changes (CHANGELOG/i18n/README docs + kiro pricing data entry + the 3 base-red CODE fixes: opencode fabrication removal, resolveEffectiveKey type-widen, openai-to-claude claudeFinishEmitted flag + 4 test-alignment files + golden snapshot regen) — the code fixes NET-REMOVE lines and add no `any`/unused, and lint reported 4199 both before and after them, so all +41 is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle.",
"direction": "down",
"_rebaseline_2026_07_01_v3843_release": "4121->4158 (+37). v3.8.43 cycle drift surfaced by the release-green pre-flight; the Quality Ratchet does NOT run on PR->release fast-gates, so warnings accrued unmeasured across this cycle. 4158 = the value measured by the CI Quality Ratchet on the release tip fce85136c (release PR #5609). Trust-but-verify: the fix/release-v3843-ci-reds branch touches only test files (rtk-mcp-tools de-flake, compression-studio e2e anchor, oauth-error-linkify hardening test) + src/shared/utils/linkify.ts (eslint-clean, 0 warnings) + stryker.conf.json + this baseline -> 0 new warnings, so all +37 is inherited cycle drift (any warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle.",
"_rebaseline_2026_06_30_v3842_release": "4116->4121 (+5). v3.8.42 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings accrued unmeasured across this cycle's 90 commits — chatgpt-web PoW sha3-512 BoringSSL fix #5540, provider baseUrl/i18n umbrella #5511, proxy union proxyUrlMap+acct.proxy #5521, dead-code + duplication waves #5468-#5495, tls-options packaging #5503, release-freeze + .npmrc fetch-retries #5506, dast-smoke spawn-prefix client-safe extraction #5546, plus ~30 SSE/translator/combo/dashboard fixes). Trust-but-verify: measured 4121 via `npm run check:release-green` on the working tree INCLUDING my reconciliation (CHANGELOG/i18n/golden snapshot + file-size baseline) — those touch only config JSON + a provider snapshot (eslint-ignored) and contribute 0 warnings; all +5 is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle.",
"_rebaseline_2026_06_29_v3841_release": "4103->4116 (+13). v3.8.41 cycle drift surfaced by the release-green collect (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings accrued unmeasured across this cycle's 52 commits — relay backend #5315, gemini catalog #5337, services dashboard #5299, empty-Claude-messages guard #5342, thinking-budget/redacted-replay + marker opt-out #5312/#5352/#5367, opencode proxy-pool + observability #5217/#5370/#5351, cors + HTTPS-serve #5242/#5360/#5361, grok cf_clearance #5350/#5358, oauth/chatgpt-web/routing/cli/dashboard/rerank #5326/#5240/#5239/#5238/#5264/#5332, partially offset by the dead-code sweep #5321-#5371). Trust-but-verify: measured 4116 via `npm run quality:collect` on the working tree INCLUDING my reconciliation (CHANGELOG/i18n/README/env docs + baselines) AND the lint-fix in useServiceLogs.ts — that fix REMOVES a setState-in-effect ERROR (eslintErrors stays 0) and adds an `open` listener with no `any`/unused, contributing 0 warnings; all +13 is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle.",
"_rebaseline_2026_06_29_v3840_release": "4090->4103 (+13). v3.8.40 cycle drift surfaced by the release-green pre-flight + the release PR Quality Ratchet (the ratchet does NOT run on PR->release fast-gates, so warnings accrued unmeasured across this cycle's ~57 commits — compression roadmap relevance/hard-budget/memoization/transparency/saliency/splitter/tool_search/RTK/QuantumLock #5289/#5288/#5286/#5284/#5285/#5283/#5269/#5268/#5260, ~20 SSE/translator/combo fixes #5248/#5250/#5254/#5261/#5255/#5273/#5258, M365 Copilot provider #5302, public-origin centralization #5278). Trust-but-verify: measured 4103 locally via `npm run quality:collect` on the release tip INCLUDING my reconciliation commits (CHANGELOG + main merge + the 2 regression test fixes 165c823f5) — the test fixes add 0 `any`/warnings (health-autopilot added a NextRequest import + asserts; chat-pipeline changed one Accept string + a comment), so all +13 is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle.",
"_rebaseline_2026_06_28_v3839_release": "4002->4090 (+88). v3.8.39 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings accrued unmeasured across this cycle's 40 commits — antigravity remote-login + quota-family #5203/#5180/#5193, compression CCR-retrieve + TOON encoder #5187/#5163, ~20 SSE/translator/responses fixes #5156/#5154/#5197/#5204/#5158/#5123/#5166, proxy/health hardening #5202/#5208/#5209/#5201 from @KooshaPari, combo quota-share/context-relay E2E tests #5179/#5168/#5195). Trust-but-verify: this release-finalize working tree touches ONLY CHANGELOG.md, docs/i18n/*/CHANGELOG.md mirrors, README.md and these baselines — 0 production-code change, so all +88 is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle.",
"_rebaseline_2026_06_27_v3838_release": "3987->4002 (+15). v3.8.38 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings accrued unmeasured across this cycle's ~78 commits — provider adds Factory/Grok-Build/ZenMux-Free/Alibaba-video, ~30 SSE/translator/diagnostics fixes, compression fidelity-gate + playground #5080/#5143, Fusion editor #5074, salvage batches #5138/#5141). Trust-but-verify: this release-finalize working tree touches ONLY CHANGELOG.md, docs/i18n/*/CHANGELOG.md mirrors, README.md and these baselines — 0 production-code change, so all +15 is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle.",
"_rebaseline_2026_06_25_v3836_release": "v3.8.36 cycle drift surfaced by the post-merge fix PR #5029 (the Quality Ratchet was SKIPPED on the release PR #4854 itself, and does NOT run on the PR→release fast-gates, so warnings accrued unmeasured across this cycle's 137 commits — Quota-Share Fase 2/3 features, god-file decomposition #3501/#4811-#4956, 14 external contributor PRs). 3912→3970 (+58), the exact value measured by the CI Quality Ratchet on #5029. Trust-but-verify: this fix PR touches ONLY scripts/build/pack-artifact-policy.ts (a string-literal allowlist array, scripts/ is eslint-light) and tests/integration/resilience-http-e2e.test.ts (2 string keys, no `any`) — 0 new warnings, so all +58 is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Same precedent as _rebaseline_2026_06_23_v3835_release. Tighten via --require-tighten next cycle.",
"_rebaseline_2026_06_23_v3835_release": "v3.8.35 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR→release fast-gates, so warnings accrued across this cycle's parallel-session merges — Compression Phase 4 #4694/#4707/#4716/#4720, chatCore #3501 leaf extractions, contributor PRs #4726/#4753/#4774/#4781/#4783/#4793, etc.). 3907→3912 (+5). Verified my release-finalize working tree touches ONLY docs/*.md (THREAT_MODEL), CHANGELOG.md, baselines, and 1 string line in scripts/check/check-fabricated-docs.mjs — 0 production-code change, so all +5 is inherited contributor drift. No coverage/openapi/i18n regressions.",
"_rebaseline_2026_06_22_v3834_release": "v3.8.34 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR→release fast-gates, so warnings accrued across this cycle's parallel-session merges — #4583-4586/#4588-4593/#4606-4621/#4644/#4647/#4696/etc.). 3900→3907 (+7). Verified my release-finalize working tree touches ONLY CHANGELOG.md (git status: 0 code changes), so all +7 is inherited contributor drift. No coverage/openapi/i18n regressions.",
"_rebaseline_2026_06_22_v3833_release": "Cumulative cycle drift surfaced by the release PR full CI. 3867→3900 (+33).",
"_rebaseline_2026_06_26_v3837_release": "3970->3987. v3.8.37 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings/complexity accrued unmeasured across this cycle's 76 commits — provider adds DGrid/Pioneer/xAI, headroom proxy lifecycle #4649, ~50 SSE/translator fixes, Engine Combos #5062). Trust-but-verify: this release-finalize working tree touches ONLY CHANGELOG.md, docs/i18n/*/CHANGELOG.md mirrors, and these baselines — 0 production-code change, so all drift is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle.",
"_rebaseline_2026_07_04_pacote4_no_new_warnings": "4279->0. Pacote 4 do plano mestre testes+CI: a divida pre-existente (4279 warnings + violacoes das 3 regras promovidas a error em src/**) foi CONGELADA em config/quality/eslint-suppressions.json (ESLint bulk suppressions nativo) e passa a ser bloqueada NO PR que a introduziria (job lint-guard no quality.yml + npm run lint + lint-staged, todos suppressions-aware; fork = report-only, Principio Zero). collect-metrics agora mede sob o baseline congelado -> a metrica vira 'divida liquida NOVA' (~0 em regime). O aperto do ESTOQUE congelado acontece via `npx eslint . --prune-suppressions --suppressions-location config/quality/eslint-suppressions.json` na reconciliacao da release. Fim das rebaselines-surpresa de +41/+88 por ciclo."
"_rebaseline_2026_08_05_post_prune": "Apertado 5000->0 em 2026-08-05: o gate mede via lint:json COM as suppressions aplicadas (config/quality/eslint-suppressions.json congela a divida da migracao TS7), entao a contagem real do gate e 0. O 5000 anterior foi medido SEM suppressions (4139 brutos) e fazia o require-tighten reprovar todo PR de codigo (delta 5000>slack). Divida TS7 continua rastreada nas suppressions; warning NOVO (fora delas) agora e red imediato, que e a politica."
},
"eslintErrors": {
"value": 0,

View File

@@ -64,6 +64,18 @@
"tests/unit/ui/provider-plan-config.test.tsx": {
"replacement": "tests/unit/quota-plans-route-retired.test.ts",
"reason": "v3.8.49 #7127: fix(tests) suíte vitest UI de volta ao verde — a rota Plans e o ProviderPlanConfigClient foram APOSENTADOS; o replacement inverte a asserção e guarda a aposentadoria (o arquivo da rota e o ProviderPlanConfigClient não existem mais, costs-quota-plans saiu do sidebarVisibility e da navegação)."
},
"tests/unit/plugin-sandbox-permissions.test.ts": {
"sourceRemoved": [
"src/lib/plugins/pluginWorker.ts",
"src/lib/plugins/sandbox.ts",
"src/lib/plugins/signing.ts"
],
"reason": "v3.8.50 #9126 (commit 8fac6bcd48): pluginWorker.ts, sandbox.ts e signing.ts foram removidos por completo (\"zero importers confirmed\") — o subsistema de sandbox de plugins com worker-thread nunca foi ligado a nenhum consumidor. O teste era source-scan sobre pluginWorker.ts (ver docstring do arquivo deletado); sem o arquivo-fonte não há mais o que testar. OMNIROUTE_PLUGINS_ALLOW_EXEC também foi removido de .env.example e da doc na mesma release. Sem substituto porque a feature foi extinta, não migrada."
},
"tests/unit/plugins-sandbox.test.ts": {
"sourceRemoved": ["src/lib/plugins/sandbox.ts"],
"reason": "v3.8.50 #9126 (commit 8fac6bcd48): sandbox.ts foi removido por completo junto com pluginWorker.ts e signing.ts (\"zero importers confirmed\", subsistema de sandbox de plugins nunca ligado a nenhum consumidor). O teste cobria SandboxLevel/getSandboxLabel exportados por sandbox.ts; sem o arquivo-fonte não há mais símbolo a testar. Mesma causa-raiz de tests/unit/plugin-sandbox-permissions.test.ts nesta entrada."
}
},
"tests/unit/catalog-updates-v3x.test.ts": "v3.8.45 #6248: fix(providers) remove deprecated MiMo V2 entries — os 5 asserts removidos pinavam specs de modelos mimo-v2-* que deixaram de existir no catálogo (54→49). Asserts seguem a remoção dos modelos, não enfraquecimento. Verificado legítimo. Prune após v3.8.45 mergear para main.",
@@ -96,5 +108,6 @@
"tests/unit/usage-providers.test.ts": "v3.8.49 #7866: o case \"qwen\" saiu de getUsageForProvider (não há mais case \"qwen\" no switch de open-sse/services/usage.ts); o teste cobria esse ramo extinto (net 20→19). Verificado legítimo. Prune após v3.8.49 mergear para main.",
"tests/unit/usage-service-hardening.test.ts": "v3.8.49 #7866/#8565/#8013: qwen removido (3 asserts); o Kimi/Kiro builder-id (uso profileless) passou a ter SUCESSO real em vez de erro de ARN — supportsProfilelessKiroUsage(\"builder-id\") retorna true —, trocando 1 assert de regex de erro por 3 asserts de valor; e os ids de bucket de quota do Antigravity foram atualizados para o catálogo atual. Rodado no HEAD: 23/23 passam. Net 210→209. Verificado legítimo. Prune após v3.8.49 mergear para main.",
"tests/unit/virtual-auto-combo.test.ts": "v3.8.49 #7928/#8183: o pooling de contas passou a agrupar conexões web-session do mesmo provider numa entrada lógica com allowedConnectionIds (campo confirmado em open-sse/services/autoCombo/virtualFactory.ts), e o pool no-auth virou uma allowlist fixa (AUTO_COMBO_NOAUTH_ALLOWLIST = opencode, felo-web) — os testes antigos esperavam duplicatas e a inclusão de duckduckgo-web/theoldllm/chipotle, que hoje são corretamente excluídos. Guard dedicado em noauth-autocombo-allowlist.test.ts. Rodado no HEAD: 10/10 passam. Net 39→31. Verificado legítimo. Prune após v3.8.49 mergear para main.",
"open-sse/services/__tests__/tierResolver.test.ts": "v3.8.49 #7866: refactor(qwen) remove o provider OAuth legado — o teste \"classifies Qwen as free\" e a entrada de qwen na lista do batch saíram junto com o provider, e os índices do batch desceram de 10 para 9 elementos (net 61→59). Superfície extinta, não enfraquecimento. Verificado legítimo. Prune após v3.8.49 mergear para main."
"open-sse/services/__tests__/tierResolver.test.ts": "v3.8.49 #7866: refactor(qwen) remove o provider OAuth legado — o teste \"classifies Qwen as free\" e a entrada de qwen na lista do batch saíram junto com o provider, e os índices do batch desceram de 10 para 9 elementos (net 61→59). Superfície extinta, não enfraquecimento. Verificado legítimo. Prune após v3.8.49 mergear para main.",
"tests/unit/plugins-welcome-banner-e2e.test.ts": "v3.8.50 #9126 (commit 8fac6bcd48): o teste único 'BUILTIN_EVENTS has all 14 events' (13 asserts .ok/.equal) foi reestruturado em 3 testes mais específicos — 'contains only emitted/public events' (assert.deepEqual da lista completa), 'does not advertise dead events' (7 asserts .equal(false) para eventos sem emissor real: onModelSelect/onComboResolve/onRateLimit/onQuotaExhaust/onProviderError/onStreamStart/onStreamEnd) e 'lifecycle events remain represented' (4 asserts .ok). Contrato mais forte (agora também nega presença dos eventos mortos), não mais fraco — a contagem líquida cai (73→61) porque o assert.deepEqual único substitui múltiplos assert.ok redundantes com a mesma cobertura. Asserts restruturados, não removidos sem substituição. Verificado legítimo."
}

View File

@@ -0,0 +1,916 @@
---
title: "MySQL conformance semantics and failure-mode matrix"
status: proposed-test-specification
lastUpdated: 2026-07-30
---
# MySQL conformance semantics and failure-mode matrix
- **Tracking issue:** [#8075](https://github.com/diegosouzapw/OmniRoute/issues/8075)
- **Governing proposal:** [Pluggable persistence boundary](persistence-backend-boundary.md)
- **Measured baseline:** [SQLite coupling inventory](sqlite-coupling-inventory.md)
- **Target:** MySQL 8.0 with InnoDB
- **Runtime impact:** None. This document adds no driver, dependency, configuration, schema,
migration, or support claim.
## 1. Purpose and normative language
The persistence-boundary ADR requires conformance tests to compare observable behavior, not only
repository method signatures. This document turns the MySQL/InnoDB differences that can change
OmniRoute behavior into an implementation-ready specification. It provides:
- a required server and session profile;
- evidence from the current SQLite implementation;
- minimal SQL probes that reviewers can reproduce independently;
- a backend-neutral error and retry taxonomy;
- normative decisions that a repository contract must make;
- executable acceptance specifications for a future shared conformance harness;
- a focused acceptance profile for combo definitions and model-to-combo mappings.
The terms **MUST**, **MUST NOT**, **SHOULD**, and **MAY** are normative. A proposed MySQL adapter is
not conformant merely because its SQL succeeds. It is conformant only when the same repository
fixture produces the same domain result, durable state, atomicity, ordering, and classified failure
as the SQLite implementation.
## 2. Scope and non-goals
### 2.1 In scope
This specification covers portable durable-state behavior for:
- create, read, update, delete, and missing-row results;
- uniqueness, collation, case and accent sensitivity, and `NULL`;
- stable ordering and pagination;
- no-op writes and affected-row reporting;
- insert, identity-preserving upsert, and replacement;
- IDs, JSON, exact numerics, and timestamps;
- transactions, deadlocks, lock waits, disconnects, and retry boundaries;
- foreign keys and atomic related-record changes;
- migration ownership, implicit DDL commits, recovery, and readiness.
### 2.2 Out of scope
This specification does not:
- approve PostgreSQL or MySQL runtime support;
- select a Node.js MySQL driver or pool;
- define a public environment variable or configuration UI;
- define final TypeScript repository interfaces;
- add physical MySQL schema or migration files;
- make SQLite maintenance, FTS5, `sqlite-vec`, backup files, or WAL portable;
- replace domain-specific acceptance criteria;
- permit runtime work while the governing ADR remains unapproved.
## 3. Evidence from the current repository
The current implementation establishes behavior that a portable contract must either preserve or
explicitly revise. These are source-backed observations, not proposed MySQL schema.
### 3.1 Combo identity and lookup
`src/lib/db/migrations/001_initial_schema.sql` defines `combos.id` as the primary key and
`combos.name` as unique. `src/lib/db/combos.ts` currently:
- generates UUIDs in the application;
- generates timestamps with `new Date().toISOString()`;
- performs exact name lookup first;
- provides a separate `COLLATE NOCASE` fallback lookup;
- lists by `sort_order ASC, name COLLATE NOCASE ASC`;
- treats an update of a missing ID as `null`;
- treats deletion of a missing ID as `false`;
- updates the JSON payload and deduplicated columns together;
- reorders all selected rows in one SQLite transaction.
Those choices imply that a future MySQL slice does not need database-generated numeric IDs for
combos, but it must still define Unicode collation, complete tie-breakers, update/delete results, and
reorder concurrency.
### 3.2 Model-to-combo mapping behavior
`src/lib/db/migrations/010_model_combo_mappings.sql` defines a foreign key from
`model_combo_mappings.combo_id` to `combos.id` with `ON DELETE CASCADE`.
`src/lib/db/modelComboMappings.ts` currently:
- generates mapping UUIDs and ISO timestamps in the application;
- lists by `priority DESC, created_at ASC`;
- returns a separate total count for paginated results;
- maps integer `0`/`1` values to booleans;
- treats a missing update as `null` and a missing delete as `false`;
- resolves the first enabled matching pattern;
- skips malformed combo JSON rather than failing resolution.
The current list and resolution order lacks a unique final tie-breaker. The MySQL implementation
MUST NOT preserve that accidental nondeterminism. Before portability is claimed, the contract must
add `id ASC` (or another unique stable key) after `created_at ASC` and the SQLite implementation
must adopt the same order.
### 3.3 Existing SQLite-specific signals
The measured SQLite coupling inventory records widespread use of synchronous prepared statements,
`INSERT OR REPLACE`, `lastInsertRowid`, SQLite transactions, and SQLite lifecycle operations. A
future adapter must not translate those tokens mechanically. In particular:
- `INSERT OR REPLACE` is delete-then-insert conflict handling, not an update;
- `changes` is a driver result, not a portable domain result;
- `COLLATE NOCASE` is not equivalent to a modern MySQL Unicode collation;
- SQLite numbered migration SQL is not reusable as MySQL migration SQL.
## 4. Required MySQL deployment and session profile
A conformance run MUST fail during backend initialization if the effective profile is outside the
supported envelope. Silently inheriting server defaults would make behavior depend on an operator's
installation history.
| Property | Required profile | Verification | Failure class |
| ------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | --------------------- |
| Server family | Oracle MySQL 8.0.x until another family passes the same suite | `SELECT VERSION()` and server metadata | `unsupported` |
| Storage engine | `InnoDB` for every portable table | `information_schema.tables` | `schema_incompatible` |
| Character set | `utf8mb4` for schema, tables, and portable text columns | `information_schema.schemata`, `tables`, and `columns` | `schema_incompatible` |
| Identity collation | Explicit per identity column; never inherited | `information_schema.columns.collation_name` | `schema_incompatible` |
| SQL mode | Strict mode and the engine-substitution guard; adapter records the effective value | `SELECT @@SESSION.sql_mode` | `unsupported` |
| Transaction isolation | Explicitly selected and verified by the backend | `SELECT @@SESSION.transaction_isolation` | `unsupported` |
| Session time zone | UTC | `SELECT @@SESSION.time_zone` | `unsupported` |
| Autocommit | Known pool default; repository transactions set boundaries explicitly | `SELECT @@SESSION.autocommit` | `unsupported` |
| Connection character set | `utf8mb4` | `SELECT @@character_set_client, @@character_set_connection, @@character_set_results` | `unsupported` |
| Found-rows behavior | One fixed pool setting, but repository results remain independent of it | Driver/pool configuration plus conformance probe | `unsupported` |
| Foreign-key checks | Enabled for normal runtime and conformance tests | `SELECT @@SESSION.foreign_key_checks` | `unsupported` |
| InnoDB page size | Recorded before validating indexed key lengths | `SELECT @@innodb_page_size` | `schema_incompatible` |
The backend readiness report SHOULD expose the verified profile without credentials. It MUST NOT
log connection strings or secrets.
### 4.1 Initialization probe
The adapter acceptance suite should run an equivalent of the following read-only probe on a newly
leased connection:
```sql
SELECT
VERSION() AS server_version,
@@SESSION.sql_mode AS sql_mode,
@@SESSION.transaction_isolation AS transaction_isolation,
@@SESSION.time_zone AS time_zone,
@@SESSION.autocommit AS autocommit,
@@SESSION.foreign_key_checks AS foreign_key_checks,
@@character_set_client AS character_set_client,
@@character_set_connection AS character_set_connection,
@@character_set_results AS character_set_results,
@@innodb_page_size AS innodb_page_size;
```
A pool MUST apply and verify session settings on every newly created physical connection. Applying
settings only to the first connection is insufficient.
## 5. Normative semantic matrix
### 5.0 Observable SQLite/MySQL difference summary
This table is the review index for the detailed rules below. It distinguishes current or common
backend behavior from the portable result the repository must expose. The MySQL column describes
InnoDB under the verified session profile; it must not be read as permission to inherit an
unverified server default.
| Concern | SQLite-shaped behavior | MySQL/InnoDB behavior | Required repository contract |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
| Text identity | Binary comparison by default; current code opts into ASCII-oriented `NOCASE` for selected reads and sorts | Equality, uniqueness, and sort order follow the selected column/expression collation | Declare byte-exact identity separately from named insensitive lookup and display order |
| Nullable unique key | Multiple SQL `NULL` values can pass a plain unique constraint | Multiple SQL `NULL` values can pass a plain unique index | Enforce any "one logical null" invariant atomically outside a plain unique key |
| Unordered/tied results | No total order without a complete `ORDER BY` | No total order without a complete `ORDER BY` | Define `NULL` position and a unique final tie-breaker for every portable list |
| No-op update | Driver change count reflects SQLite's statement behavior | Changed-row count differs from matched-row mode for identical assignments | Return domain outcomes independently of raw affected-row counts |
| Conflict write | `INSERT OR REPLACE` can delete then insert | Duplicate-key upsert updates one selected conflict | Classify every operation as insert-only, identity-preserving upsert, or replacement |
| Generated identity | SQLite row IDs and driver-local last-insert state are connection-bound | Generated IDs and last-insert state are connection-bound | Retrieve identity in the insert operation/lease and use stable idempotency identity on retry |
| JSON | Existing combo payloads are text and malformed legacy text can be observed | Native `JSON` validates and normalizes its representation | Choose text or typed JSON deliberately and compare the declared domain representation |
| Exact values/time | Current modules commonly serialize JavaScript values and ISO UTC text | Driver conversion can lose large integers/decimals; temporal types depend on type and session zone | Fix exact representations, UTC policy, and precision across backends |
| Concurrency/isolation | Deferred transactions and a database-wide single-writer model shape conflicts; read visibility depends on transaction mode and WAL state | InnoDB defaults to `REPEATABLE READ`, uses MVCC snapshots for consistent reads, and permits concurrent writers on different locked records | Select and verify isolation, then test domain-visible reads, conflicts, and retry boundaries rather than relying on either default |
| DDL/migrations | SQLite migration sequences can be wrapped according to SQLite transaction rules | DDL commonly commits implicitly; one atomic DDL statement does not make a multi-step migration atomic | Use distributed ownership, durable phase checkpoints, postcondition inspection, and readiness gating |
### 5.1 Text identity, collation, and uniqueness
MySQL equality and unique indexes use the effective collation of the indexed expression. A `_ci`
collation is case-insensitive; an `_ai` collation is also accent-insensitive. SQLite's default text
comparison and `COLLATE NOCASE` do not provide an equivalent Unicode contract.
| Concern | SQLite-shaped risk | Required portable decision | MySQL implementation rule |
| ---------------- | -------------------------------------------------------- | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| IDs | Text IDs can inherit an unintended collation | IDs are byte-exact and case-sensitive | Use an explicit binary collation or binary representation |
| Combo names | Exact lookup and insensitive fallback are separate today | Exact lookup remains exact; insensitive lookup is a named operation | Exact and insensitive queries use explicit, different collations or normalized keys |
| Unique names | A server default can collapse case or accents | The domain declares whether case/accent variants conflict | Unique index uses the declared collation, never the database default |
| Pattern text | Pattern matching occurs in application code | Stored pattern bytes round-trip unchanged | Store with an explicit case-sensitive collation |
| User-facing sort | SQLite `NOCASE` order is not portable Unicode order | List order is defined by a normalized sort key or explicit collation policy | Schema and query use the selected policy and a unique tie-breaker |
Minimum probe:
```sql
CREATE TEMPORARY TABLE conformance_text (
id VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin PRIMARY KEY,
name VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci UNIQUE
) ENGINE=InnoDB;
INSERT INTO conformance_text (id, name) VALUES ('A', 'Résumé');
-- The next statement conflicts under utf8mb4_0900_ai_ci.
INSERT INTO conformance_text (id, name) VALUES ('a', 'resume');
```
The harness MUST repeat the probe for the exact collation selected by the eventual schema; the
example collation above is evidence, not an approval for combo names.
### 5.2 `NULL`, missing rows, and nullable unique keys
MySQL unique indexes permit multiple `NULL` values. SQLite does likewise for unique columns.
However, neither behavior implements a domain invariant such as "only one active row may have no
owner."
Repository contracts MUST distinguish:
- no row found;
- a row found with a nullable field set to SQL `NULL`;
- a JSON document containing JSON `null`;
- a missing JSON member.
Minimum probe:
```sql
CREATE TEMPORARY TABLE conformance_null (
id VARCHAR(64) PRIMARY KEY,
optional_key VARCHAR(64) NULL,
UNIQUE KEY uq_optional_key (optional_key)
) ENGINE=InnoDB;
INSERT INTO conformance_null VALUES ('one', NULL), ('two', NULL);
SELECT COUNT(*) AS row_count FROM conformance_null;
-- Expected: 2.
```
If a domain allows at most one logical `NULL`, it MUST use an explicit atomic invariant rather than
rely on a plain unique index.
### 5.3 Ordering, ties, and pagination
Without `ORDER BY`, result order is undefined. With a non-unique `ORDER BY`, tied rows still have an
undefined relative order. Offset pagination can therefore duplicate or omit records if the complete
order is not stable.
Every portable list MUST specify:
1. every user-visible sort expression;
2. the position of `NULL` values;
3. a unique final tie-breaker;
4. the cursor comparison tuple, if cursor pagination is used;
5. the snapshot/concurrency expectation across pages.
For the proposed combo/mapping slice:
```sql
-- Combo list contract candidate.
ORDER BY sort_order ASC, normalized_name ASC, id ASC
-- Mapping list and resolution contract candidate.
ORDER BY priority DESC, created_at ASC, id ASC
```
The exact `normalized_name` representation remains a contract decision. It MUST NOT be implemented
by relying on an unspecified database default.
For nullable values, use an explicit sort key rather than a backend default:
```sql
ORDER BY nullable_column IS NULL ASC, nullable_column ASC, id ASC
```
### 5.4 Update, no-op, delete, and affected rows
MySQL `UPDATE` reports rows actually changed by default. With the C API found-rows connection flag,
it reports rows matched. `INSERT ... ON DUPLICATE KEY UPDATE` reports 1 for insert, 2 for an actual
update, and 0 for an update to identical values; the found-rows flag changes the last value to 1.
These numbers MUST NOT become repository semantics.
| Repository outcome | Required meaning | Forbidden implementation shortcut |
| ------------------ | ------------------------------------------------------ | --------------------------------------------- |
| `updated` | Target existed and the operation's postcondition holds | `affectedRows > 0` alone |
| `unchanged` | Target existed and already satisfied the postcondition | Treating 0 changed rows as missing |
| `not_found` | Target identity did not exist | Treating every 0 count as unchanged |
| `conflict` | Compare/update version or invariant failed | Returning generic `false` |
| delete `true` | A row existed and was deleted | Assuming a successful statement deleted a row |
| delete `false` | No row existed | Throwing a backend-specific error |
Minimum probe, run once with each supported connection mode:
```sql
CREATE TEMPORARY TABLE conformance_update (
id VARCHAR(64) PRIMARY KEY,
value_text VARCHAR(64) NOT NULL,
version_no BIGINT NOT NULL
) ENGINE=InnoDB;
INSERT INTO conformance_update VALUES ('row', 'same', 1);
UPDATE conformance_update SET value_text = 'same' WHERE id = 'row';
UPDATE conformance_update SET value_text = 'changed' WHERE id = 'row';
UPDATE conformance_update SET value_text = 'missing' WHERE id = 'missing';
```
The harness asserts repository results and final rows, not raw driver counts. A versioned
compare/update SHOULD use a predicate such as `WHERE id = ? AND version_no = ?`, then distinguish a
missing identity from a stale version according to the domain contract.
### 5.5 Insert, upsert, and replacement
SQLite `INSERT OR REPLACE` deletes rows that conflict with a unique or primary key before inserting
the new row. MySQL `INSERT ... ON DUPLICATE KEY UPDATE` updates one conflicting row. The two forms
differ in foreign-key cascades, triggers, omitted columns, IDs, timestamps, and affected-row counts.
Every write method MUST be classified as exactly one of:
1. **insert-only:** duplicate identity returns `unique_violation`;
2. **identity-preserving upsert:** duplicate identity updates an explicit allowlist of mutable fields;
3. **replacement:** old identity is deleted and a new row is inserted, with cascade effects included
in the contract.
A generic helper MUST NOT choose among these behaviors based on SQL convenience.
Minimum difference probe. This uses ordinary InnoDB tables because MySQL temporary tables cannot
serve as the parent/child foreign-key fixture. Run it in an isolated conformance schema; cleanup is
included so the probe is repeatable:
```sql
DROP TABLE IF EXISTS conformance_child;
DROP TABLE IF EXISTS conformance_parent;
CREATE TABLE conformance_parent (
id VARCHAR(64) PRIMARY KEY,
immutable_value VARCHAR(64) NOT NULL,
mutable_value VARCHAR(64) NOT NULL
) ENGINE=InnoDB;
CREATE TABLE conformance_child (
id VARCHAR(64) PRIMARY KEY,
parent_id VARCHAR(64) NOT NULL,
CONSTRAINT fk_conformance_child_parent
FOREIGN KEY (parent_id) REFERENCES conformance_parent(id) ON DELETE CASCADE
) ENGINE=InnoDB;
INSERT INTO conformance_parent VALUES ('p', 'keep', 'old');
INSERT INTO conformance_child VALUES ('c', 'p');
INSERT INTO conformance_parent (id, immutable_value, mutable_value)
VALUES ('p', 'replacement', 'new')
ON DUPLICATE KEY UPDATE mutable_value = VALUES(mutable_value);
SELECT immutable_value, mutable_value FROM conformance_parent WHERE id = 'p';
SELECT COUNT(*) AS child_count FROM conformance_child WHERE parent_id = 'p';
-- Expected: immutable_value='keep', mutable_value='new', child_count=1.
DROP TABLE conformance_child;
DROP TABLE conformance_parent;
```
The `VALUES(mutable_value)` form is used here because the target remains MySQL 8.0 as a family and
no minimum 8.0 patch release has been approved. It is deprecated in later MySQL 8.0 releases, so an
adapter that establishes a newer minimum MAY use the supported row-alias form instead. The harness
asserts identity-preserving behavior, not either SQL spelling.
Tables with multiple unique indexes require special care because a duplicate can select an
unexpected conflicting row. Portable upsert schema SHOULD have one unambiguous conflict identity.
### 5.6 Unicode and index-size constraints
`utf8mb4` uses up to four bytes per character. InnoDB's maximum index key is 3072 bytes for common
`DYNAMIC` or `COMPRESSED` row formats with a 16 KiB page, and is lower for smaller page sizes or
legacy row formats. A prefix unique index is not equivalent to full-value uniqueness.
Schema acceptance MUST:
- set bounded lengths for all indexed identity strings;
- calculate the worst-case byte length of every composite index;
- verify the actual page size and row format;
- reject a prefix unique index for a full-identity contract;
- test maximum-length non-ASCII values before migration is accepted;
- classify an incompatible definition as `schema_incompatible`, not `unique_violation`.
Example boundary probe for a 16 KiB/DYNAMIC profile:
```sql
CREATE TEMPORARY TABLE conformance_index (
value_text VARCHAR(768) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
UNIQUE KEY uq_value_text (value_text)
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC;
```
The exact accepted length MUST be derived from all key parts and the verified deployment profile;
this example is deliberately near a physical boundary and is not a proposed production column.
### 5.7 IDs and connection-local state
The current combo and mapping modules generate UUIDs in the application. A MySQL implementation
SHOULD preserve this strategy for those domains.
If another domain uses a database-generated incrementing ID, the adapter MUST observe these rules:
- ID retrieval is part of the same driver operation and physical connection as the insert;
- callers never issue a later connection-level `LAST_INSERT_ID()` query;
- multi-row inserts define whether one ID or all IDs are returned;
- an error or rollback makes a previously observed `LAST_INSERT_ID()` unsuitable as proof of commit;
- retries use a stable domain idempotency key;
- upsert defines whether it returns an existing or newly generated identity.
MySQL documents `LAST_INSERT_ID()` as per-connection state and leaves it undefined after some errors
or error-driven rollbacks. Pool leases are therefore part of correctness, not merely performance.
### 5.8 JSON representation
Current combo data is JSON text, and malformed JSON is observable: combo reads can skip malformed
rows and mapping resolution skips malformed combo payloads. Switching the MySQL column directly to
native `JSON` would reject malformed rows at write/import time and normalize duplicate keys,
whitespace, and key order.
Before choosing `LONGTEXT` or `JSON`, the combo contract MUST decide:
- whether malformed stored payloads remain representable for compatibility tests;
- whether equality is structural or byte-for-byte;
- whether duplicate object keys are rejected before persistence;
- whether serialization order is stable and application-owned;
- which fields are duplicated into typed columns and which representation is authoritative.
For the first slice, an identity-preserving migration SHOULD keep application serialization as the
domain boundary. If native `JSON` is selected, imports MUST parse and validate before writing, and
tests MUST compare parsed domain values rather than raw JSON text.
Minimum normalization probe:
```sql
CREATE TEMPORARY TABLE conformance_json (id VARCHAR(64) PRIMARY KEY, payload JSON) ENGINE=InnoDB;
INSERT INTO conformance_json VALUES ('j', '{"b": 2, "a": 1, "a": 3}');
SELECT payload FROM conformance_json WHERE id = 'j';
-- The value is normalized; original whitespace/key duplication is not preserved.
```
### 5.9 Exact numerics and timestamps
| Type | Risk | Required contract |
| ----------- | ----------------------------------------------------- | ----------------------------------------------------------------------- |
| `BIGINT` | Values can exceed JavaScript's safe integer range | Return a string or validated bigint representation across every backend |
| `DECIMAL` | Driver options may return strings or lossy numbers | Fix precision/scale and use an exact domain representation |
| `TIMESTAMP` | Session time zone conversion and fractional precision | Force UTC session time zone and specify fractional precision |
| `DATETIME` | No intrinsic time zone | Use only for explicitly zone-free civil time |
| ISO text | Lexical ordering depends on one canonical format | Validate UTC suffix and exact precision before persistence |
Combo and mapping timestamps are currently application-generated ISO strings. The first slice SHOULD
preserve their exact domain format rather than introducing server-generated local time.
### 5.10 Transaction isolation and observable concurrency
MySQL InnoDB uses `REPEATABLE READ` as its default isolation level. Within an explicit transaction,
its consistent non-locking reads normally establish and reuse an MVCC snapshot, while locking reads
and writes inspect and lock current index records or ranges. SQLite instead combines snapshot/read
transaction behavior with a database-wide single-writer model; transaction mode and WAL state affect
when a writer is admitted and when a read transaction can be upgraded. These mechanisms are not
interchangeable even when a simple CRUD fixture produces the same final row.
The backend profile MUST select and verify an isolation level rather than silently accept either
backend's default. The repository contract MUST then define observable results for each atomic
operation. It MUST NOT promise the implementation mechanism itself, such as gap locks or a
SQLite-wide writer lock.
| Scenario | SQLite-shaped risk | InnoDB `REPEATABLE READ` risk | Required conformance decision |
| --------------------------------- | --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| Two reads in one transaction | Snapshot timing depends on when the read transaction begins and the active journal mode | Consistent reads normally reuse the transaction's first established read view | State whether the operation requires one stable snapshot or deliberately performs a current read |
| Range read plus concurrent insert | A concurrent writer may be serialized by SQLite's writer admission rules | A plain consistent read can retain its snapshot; a locking range read can lock index gaps | Define whether a later read sees the insert and whether the operation requires a locking predicate |
| Read-modify-write | Single-writer serialization can mask an unsafe application sequence | Concurrent transactions can read the same value and later contend or overwrite without a version predicate | Require compare/update, a locking read, or another explicit invariant; never rely on backend serialization |
| Writers touching different rows | SQLite still admits only one writer at a time | InnoDB can execute both until their record/range locks conflict | Do not infer portable throughput or lock order; assert only atomic effects and classified conflicts |
| Pagination across transactions | Separate page reads can observe different committed states | Separate autocommit reads get separate views; one transaction may retain one view | Declare snapshot pagination or documented live pagination and test that policy |
| Retry after conflict | Busy/locked outcomes and transaction upgrade failures are SQLite-shaped | Deadlocks and lock timeouts have different rollback scopes | Normalize the error, discard the failed context, and retry the complete idempotent operation only |
Minimum two-connection visibility probe for the selected MySQL profile:
```text
Connection A Connection B
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT value_no FROM conformance_isolation
WHERE id = 1; -- establishes read view: 0
START TRANSACTION;
UPDATE conformance_isolation
SET value_no = 1 WHERE id = 1;
COMMIT;
SELECT value_no FROM conformance_isolation
WHERE id = 1; -- same consistent-read view: 0
COMMIT;
SELECT value_no FROM conformance_isolation
WHERE id = 1; -- new transaction/view: 1
```
The shared harness MUST NOT assert that every backend reproduces this internal sequence. It must use
it to prove that the chosen repository operation either requests a stable snapshot explicitly or
avoids depending on repeat-read visibility. If an operation uses a current/locking read, that choice
and its conflict behavior need a separate test.
## 6. Transactions, failures, and retry policy
### 6.1 Transaction states
The backend contract should expose only opaque transaction contexts, but its implementation must
maintain the following lifecycle:
```text
idle
-> active
-> committed
-> rolled_back
-> failed_statement -> rolled_back
-> failed_transaction -> rolled_back
-> outcome_unknown -> reconciled | escalated
```
A context in `committed`, `rolled_back`, `failed_transaction`, or `outcome_unknown` MUST reject new
repository work. A context with a failed statement SHOULD be explicitly rolled back before its
connection returns to the pool, even when MySQL would technically permit more statements.
### 6.2 Error classification matrix
Numeric codes and SQLSTATE values below are MySQL 8.0 server signals. A Node.js driver can also
produce transport-specific codes; those MUST be normalized without leaking raw messages to callers.
| Condition | MySQL signal | Rollback scope | Portable class | Retry policy |
| ------------------------------ | -------------------------------------- | ------------------------------------------------- | ------------------------ | -------------------------------------------------------------- |
| Duplicate key | `1062`, SQLSTATE `23000` | Statement | `unique_violation` | No, unless contract defines idempotent create |
| Missing referenced parent | `1452`, SQLSTATE `23000` | Statement | `foreign_key_violation` | No |
| Parent still referenced | `1451`, SQLSTATE `23000` | Statement | `foreign_key_violation` | No |
| Deadlock victim | `1213`, SQLSTATE `40001` | Entire transaction | `transaction_conflict` | Retry whole atomic operation |
| Lock wait timeout | `1205`, SQLSTATE `HY000` | Statement by default; server option can change it | `lock_timeout` | Roll back explicitly, then retry whole operation if idempotent |
| Invalid JSON text | `3140`, SQLSTATE `22032` | Statement | `invalid_data` | No |
| Data too long | `1406`, SQLSTATE `22001` | Statement | `invalid_data` | No |
| Check constraint | `3819`, SQLSTATE `HY000` | Statement | `constraint_violation` | No |
| Server gone before request | Driver/server transport signal | No operation or unknown | `unavailable` | Retry only if operation definitely was not sent |
| Connection lost during request | Driver transport signal | Unknown | `outcome_unknown` | Reconcile by idempotency key; do not blind retry |
| Pool acquisition timeout | Driver/pool signal | None | `unavailable` | Bounded retry outside transaction |
| Unsupported profile | Initialization probe mismatch | None | `unsupported` | No; fail readiness |
| Migration lock timeout | Named-lock acquisition returns timeout | None | `migration_lock_timeout` | Wait/back off according to startup policy |
| Migration lock error | Named-lock acquisition returns error | None | `migration_lock_failed` | No blind retry; inspect connection state |
The adapter MUST classify by structured code and SQLSTATE where available, never by localized message
text. Public HTTP/SSE/MCP responses must still pass through the repository's existing sanitized error
helpers.
### 6.3 Retry rules
A retryable classification does not automatically make an operation safe to retry.
A retry loop MUST:
1. own the entire repository atomic operation;
2. discard the failed transaction context;
3. acquire a valid connection and begin a new transaction;
4. preserve a stable operation or entity identity;
5. use bounded attempts with jitter;
6. stop on non-retryable classifications;
7. reconcile `outcome_unknown` before issuing another write;
8. emit structured diagnostics without credentials or raw SQL values.
MySQL explicitly recommends retrying the entire transaction after a deadlock. A lock wait timeout
rolls back only the current statement by default, so explicit rollback is required to make the retry
boundary independent of server configuration.
### 6.4 Reproducible two-connection deadlock probe
Use two physical connections, not two logical operations that might share one pool connection:
```sql
CREATE TABLE conformance_deadlock (
id INT PRIMARY KEY,
value_no INT NOT NULL
) ENGINE=InnoDB;
INSERT INTO conformance_deadlock VALUES (1, 0), (2, 0);
```
```text
Connection A Connection B
START TRANSACTION; START TRANSACTION;
UPDATE ... WHERE id = 1; UPDATE ... WHERE id = 2;
UPDATE ... WHERE id = 2; UPDATE ... WHERE id = 1;
```
Exactly one transaction should become the deadlock victim. The harness asserts that the victim is
classified as retryable, its whole transaction is retried with a new context, both logical updates
occur once, and no partial result remains.
## 7. Migration ownership and DDL recovery
### 7.1 Why a normal transaction is insufficient
MySQL DDL statements commonly commit the current transaction implicitly before execution and often
afterward. Atomic DDL protects one supported DDL statement; it does not make a sequence of DDL,
data backfill, and schema-history updates one user transaction.
A MySQL migration runner therefore MUST model a migration as recoverable phases:
```text
lock acquired
-> current schema inspected
-> intent/checkpoint recorded
-> DDL phase applied and verified
-> data phase applied in bounded transactions
-> postconditions verified
-> logical milestone recorded
-> readiness allowed
-> lock released
```
A process crash at any arrow must have a deterministic resume or stop condition.
### 7.2 Ownership alternatives
| Option | Strengths | Failure modes | Decision |
| ------------------------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| Process-local mutex | Simple and useful for one process | Does not coordinate replicas | Rejected for external-backend migration ownership |
| Row lock held in a transaction | Uses normal InnoDB locking | DDL implicit commit releases transaction ownership | Rejected as the sole DDL migration lock |
| Lease row with owner and expiry | Survives pooled connections and can support takeover | Requires clock/expiry/fencing design; stale owner may continue | Candidate for scheduled jobs, not first migration mechanism |
| MySQL named lock | Server-wide, exclusive, tied to physical session, released on disconnect | Must pin one connection; not transaction-scoped; one-server scope; undefined waiter order | Recommended first MySQL migration mutex, combined with durable history |
| External coordinator | Can coordinate across database topologies | Adds an operational dependency outside the database contract | Deferred unless deployment topology requires it |
### 7.3 Recommended first mechanism
For a single writable MySQL primary, the migration runner SHOULD:
1. lease and pin one physical connection;
2. acquire one application-and-database-specific named lock of at most 64 characters;
3. distinguish acquired (`1`), timeout (`0`), and error (`NULL`);
4. inspect a durable migration-history table after acquiring the lock;
5. execute idempotent physical phases with explicit postcondition checks;
6. record completion only after all postconditions pass;
7. release the named lock explicitly in `finally`;
8. close/discard the pinned connection if release cannot be confirmed.
Named locks are released when the session ends, not on commit or rollback. They are server-wide on one
`mysqld`; topology and failover behavior must be validated before active-active support is advertised.
A durable history/checkpoint table remains necessary because lock ownership alone says nothing about
partially completed DDL.
### 7.4 Migration failure matrix
| Injection point | Required durable evidence | Restart behavior | Readiness |
| ------------------------------- | --------------------------------------------- | ----------------------------------- | --------------------------------------------- |
| Before lock | No intent | Retry lock acquisition | Not ready while required migration is pending |
| After lock, before intent | No schema change | Reinspect and restart | Not ready |
| After DDL, before checkpoint | Schema postcondition reveals DDL applied | Mark/continue only after validation | Not ready |
| During data backfill | Bounded checkpoint identifies completed range | Resume from verified checkpoint | Not ready |
| After data, before milestone | Postconditions prove completion | Record milestone idempotently | Not ready until recorded |
| After milestone, before release | History proves complete | New owner verifies and proceeds | Ready if all required milestones pass |
## 8. SQLite-to-MySQL migration validation
An offline migration tool is required before database switching can be advertised. For each migrated
domain it MUST provide a dry run and a post-import report.
### 8.1 Preflight
- verify supported SQLite and MySQL schema milestones;
- validate every source JSON payload according to the chosen target representation;
- detect names that collide under the target collation;
- validate UTF-8 and maximum indexed byte lengths;
- detect orphaned foreign keys even if the source connection had checks disabled;
- validate timestamps and numeric ranges;
- count source rows by table and logical domain;
- refuse to mutate either database during dry run.
### 8.2 Import
- preserve application-generated IDs;
- use deterministic batches and checkpoints;
- import parents before children;
- do not use replacement semantics to hide conflicts;
- classify every rejected row with a stable reason;
- keep encrypted credential ciphertext opaque and never log it;
- stop on an unclassified difference.
### 8.3 Postconditions
- row counts match for every migrated table;
- identity sets match exactly;
- foreign-key orphan counts are zero;
- canonical domain digests match for JSON-backed records;
- list ordering and mapping resolution produce the same results;
- a second dry run reports no pending changes;
- SQLite remains unchanged and available for operator rollback until cutover is accepted.
## 9. Backend-neutral conformance catalog
Each test below runs the same repository fixture against SQLite and MySQL. MySQL-specific probes may
assert error metadata internally, but the shared assertion compares only domain results and durable
state.
### 9.1 Core CRUD and representation
| Test name | Fixture/action | Required assertion |
| --------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------ |
| `create_round_trips_domain_values` | Create Unicode, nullable, JSON, and timestamp fields | Parsed domain object equals normalized input |
| `find_missing_distinguishes_absent_from_null` | Read an absent ID and a present nullable row | Results are distinct |
| `update_missing_returns_not_found` | Update an absent ID | Stable `not_found` result |
| `delete_is_idempotent_as_declared` | Delete the same ID twice | First and second results match the repository contract |
| `json_round_trips_structurally` | Write equivalent JSON with different whitespace/order | Parsed values are equal; raw text is not asserted |
| `timestamp_round_trips_in_utc` | Change MySQL session default before leasing a verified connection | Domain serialization remains canonical UTC |
| `decimal_round_trips_without_float_loss` | Write precision/scale boundaries | Exact representation is unchanged |
| `large_integer_does_not_cross_number_lossily` | Write beyond JavaScript safe integer range | String/bigint domain representation is exact |
### 9.2 Identity and collation
| Test name | Fixture/action | Required assertion |
| ---------------------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------- |
| `id_is_byte_exact` | Create IDs differing only by case | Both remain distinct if the ID contract is binary |
| `exact_name_lookup_is_case_sensitive` | Store `MASTER-LIGHT`, query exact lowercase | Exact lookup misses |
| `insensitive_name_lookup_uses_declared_policy` | Query the same row through the named insensitive operation | One deterministic row is returned |
| `unique_name_case_policy_is_explicit` | Insert case variants | Result matches the selected name policy on both backends |
| `unique_name_accent_policy_is_explicit` | Insert accent variants | Result matches the selected policy |
| `unique_violation_is_classified` | Concurrently create one identity | One wins; loser is `unique_violation` without backend text |
| `nullable_unique_policy_is_explicit` | Insert two `NULL` logical keys | Result matches domain rule, not accidental index behavior |
### 9.3 Ordering and pagination
| Test name | Fixture/action | Required assertion |
| --------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------ |
| `list_uses_unique_final_tiebreaker` | Insert rows with identical primary sort values | Repeated list order is identical and ID-ordered |
| `pagination_has_no_gaps_or_duplicates` | Traverse small pages across tied rows | Union equals full ID set; page intersections are empty |
| `nullable_sort_position_is_fixed` | Mix `NULL` and non-`NULL` values | `NULL` appears at the contract-defined end |
| `cursor_predicate_matches_sort_tuple` | Page forward through mixed sort keys | Every row appears exactly once in declared order |
| `concurrent_insert_pagination_behavior_is_declared` | Insert between page reads | Result matches snapshot or documented live-page policy |
### 9.4 Writes and affected rows
| Test name | Fixture/action | Required assertion |
| ------------------------------------------- | ------------------------------------------ | -------------------------------------------------- |
| `same_value_update_is_not_missing` | Update an existing row to identical values | `unchanged` or declared success, never `not_found` |
| `same_value_result_ignores_found_rows_mode` | Run fixture with both connection modes | Domain result is identical |
| `compare_update_detects_stale_version` | Two writers use one old version | One succeeds; one returns `conflict` |
| `batch_count_uses_contract_definition` | Mix changed and unchanged matches | Count means the same thing on both backends |
| `upsert_preserves_identity_and_children` | Upsert parent with a child row | ID, immutable fields, and child survive |
| `insert_only_never_silently_updates` | Repeat insert-only identity | Second call is `unique_violation` |
### 9.5 Transactions, isolation, and failure injection
| Test name | Fixture/action | Required assertion |
| ----------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------- |
| `related_changes_commit_atomically` | Update parent and children | All postconditions commit together |
| `related_changes_roll_back_atomically` | Inject a child constraint failure | All tables equal pre-operation state |
| `stable_snapshot_behavior_is_declared` | Read, commit a concurrent update, then read in the same operation | Result follows the operation's declared snapshot/current-read policy |
| `range_insert_visibility_is_declared` | Read a range while another transaction inserts a matching row | Later visibility matches the declared snapshot/live policy |
| `read_modify_write_prevents_lost_update` | Two transactions read one version and attempt distinct updates | One declared winner; loser conflicts/retries without overwriting |
| `independent_writers_preserve_atomic_effects` | Two transactions update different identities concurrently | Both logical effects commit; no contract depends on backend lock order |
| `deadlock_retries_whole_operation` | Two physical connections lock in opposite order | One victim; final logical effect occurs once |
| `lock_timeout_discards_context` | Hold a row lock past timeout | Explicit rollback; old context rejects work |
| `duplicate_and_foreign_key_errors_are_distinct` | Trigger each constraint | Stable distinct classes |
| `disconnect_before_send_is_unavailable` | Fail connection before dispatch | Safe bounded retry is permitted |
| `disconnect_during_commit_is_outcome_unknown` | Drop connection at commit boundary | No blind retry; reconciliation is required |
| `retry_uses_stable_operation_identity` | Fail first attempt after durable write | At most one logical effect exists |
### 9.6 Migration and readiness
| Test name | Fixture/action | Required assertion |
| --------------------------------------- | ------------------------------------------- | -------------------------------------------------- |
| `only_one_instance_owns_migration` | Two backend instances acquire one name | Exactly one executes migration phases |
| `lock_timeout_is_not_reported_as_ready` | Hold migration lock from another connection | Startup waits/fails with classified state |
| `disconnect_releases_named_lock` | Terminate owner connection | Another instance can acquire and reinspect |
| `ddl_checkpoint_recovers_after_crash` | Stop after DDL before history update | Restart detects postcondition and continues safely |
| `backfill_resumes_without_duplication` | Stop between deterministic batches | Completed rows are neither skipped nor duplicated |
| `partial_migration_blocks_readiness` | Leave required milestone incomplete | Health may be alive; readiness is false |
| `completed_history_is_idempotent` | Start against fully migrated schema | No DDL/data mutation occurs |
## 10. First-slice acceptance profile: combos and model mappings
This section specializes the general catalog for the candidate first slice discussed in #8075 and
implemented experimentally in Draft PR #8757. It does not approve that runtime PR.
### 10.1 Contract decisions required before adapter code
| Decision | Current evidence | Required resolution |
| --------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
| Combo ID | Application UUID | Preserve as byte-exact text/binary identity |
| Combo name uniqueness | SQLite unique name; exact and insensitive reads differ | Select explicit uniqueness collation independently from insensitive fallback |
| Combo list | `sort_order`, then `name NOCASE` | Add `id` as final tie-breaker and define Unicode name order |
| Next sort order | `MAX(sort_order) + 1` | Replace race-prone read-then-insert with an atomic allocation or retryable unique invariant |
| Reorder | One SQLite transaction updates all parseable rows | Define concurrent reorder serialization and all-or-nothing behavior |
| Corrupt combo JSON | Reads/resolution skip malformed payloads | Decide whether MySQL schema can represent malformed legacy rows during migration |
| Mapping order | `priority DESC, created_at ASC` | Add `id ASC` final tie-breaker |
| Mapping delete | Boolean from affected rows | Preserve `true` then `false` behavior independent of found-rows mode |
| Combo delete | Foreign key cascade removes mappings | Preserve one-operation atomic cascade |
| Timestamps | Application ISO strings | Preserve canonical UTC text or define an exact typed conversion |
### 10.2 Required combo fixtures
The shared fixture MUST include:
- combo names `Alpha`, `alpha`, `Résumé`, and `resume` to exercise selected collation policy;
- three combos with the same requested `sortOrder` to exercise the unique final order;
- one missing ID for update and delete results;
- one payload with explicit JSON `null` and one with a missing member;
- one intentionally malformed legacy payload if compatibility requires it;
- mappings with identical `priority` and `createdAt` but different IDs;
- enabled, disabled, inactive-target, and corrupt-target mappings;
- one combo with at least two dependent mappings for cascade verification.
### 10.3 Required combo assertions
A MySQL implementation cannot claim the first slice complete until the shared harness proves:
1. application UUIDs and ISO timestamps round-trip unchanged;
2. exact and insensitive combo-name lookups remain distinct operations;
3. uniqueness follows the approved name policy, not server defaults;
4. combo and mapping lists have a total deterministic order;
5. every offset page is a contiguous slice of that order;
6. update of a missing combo/mapping returns `null`;
7. first delete returns `true`, repeated delete returns `false`;
8. reorder filters unknown/duplicate requested IDs exactly as the accepted contract specifies;
9. reorder either commits every intended row or none;
10. mapping resolution uses the deterministic order and skips disabled, inactive, and malformed targets;
11. deleting a combo atomically removes all dependent mappings;
12. errors are classified without raw MySQL messages;
13. SQLite starts without loading a MySQL dependency;
14. no external-backend support is advertised by the presence of this slice alone.
### 10.4 Concurrency probes specific to the slice
#### Concurrent combo creation
Two connections create different UUIDs with the same contract-equivalent name. Exactly one succeeds;
the other receives `unique_violation`. If case/accent variants are allowed by the approved policy,
both succeed and exact lookup returns the correct identity.
#### Concurrent sort allocation
Two connections create combos without an explicit sort order. The final values MUST follow the
contract without duplicates caused by both transactions reading the same `MAX(sort_order)`. The
implementation may serialize allocation, use a separate sequence, or retry a protected invariant;
the contract must not require one specific SQL mechanism.
#### Concurrent reorder
Two connections reorder the same set in opposite orders. The accepted outcome MUST be one complete
order or the other, never a mixed sequence or mismatched JSON/column `sortOrder`. The loser may wait,
return conflict, or retry according to the approved contract.
#### Delete versus mapping creation
One connection deletes a combo while another creates a mapping to it. The final state MUST be either
an existing combo with a valid mapping or no combo and no mapping. An orphan mapping is forbidden.
## 11. Implementation gate checklist
A MySQL adapter PR for any domain MUST NOT start until reviewers can answer all applicable items:
- [ ] Identity, case, accent, and collation semantics are explicit.
- [ ] Every list has a complete order, `NULL` position, and unique tie-breaker.
- [ ] Missing, unchanged, conflict, and delete results are distinguishable.
- [ ] Every write is classified as insert-only, identity-preserving upsert, or replacement.
- [ ] ID generation and idempotency ownership are explicit.
- [ ] JSON and temporal representations are selected with migration compatibility in mind.
- [ ] Error codes map to the backend-neutral taxonomy.
- [ ] Retry ownership and maximum scope are explicit.
- [ ] Migration mutex, durable checkpoints, and readiness rules are approved.
- [ ] SQLite and MySQL fixtures run through one behavior harness.
- [ ] Offline migration preflight and postconditions exist before cutover is advertised.
- [ ] SQLite remains the zero-configuration default and clean startup path.
## 12. Reference sources
### 12.1 OmniRoute sources
- `docs/architecture/persistence-backend-boundary.md`
- `docs/architecture/sqlite-coupling-inventory.md`
- `src/lib/db/combos.ts`
- `src/lib/db/modelComboMappings.ts`
- `src/lib/db/migrations/001_initial_schema.sql`
- `src/lib/db/migrations/010_model_combo_mappings.sql`
- `src/lib/db/migrations/020_combo_sort_order.sql`
### 12.2 MySQL 8.0 reference manual
- [Character sets and collations](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/charset.html)
- [CREATE TABLE](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/create-table.html)
- [UPDATE](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/update.html)
- [INSERT ... ON DUPLICATE KEY UPDATE](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/insert-on-duplicate.html)
- [Information functions](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/information-functions.html)
- [The JSON data type](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/json.html)
- [InnoDB transaction isolation](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/innodb-transaction-isolation-levels.html)
- [InnoDB error handling](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/innodb-error-handling.html)
- [Handling deadlocks](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/innodb-deadlocks-handling.html)
- [Statements that cause an implicit commit](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/implicit-commit.html)
- [Locking functions](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/locking-functions.html)
- [InnoDB limits](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/innodb-limits.html)
### 12.3 SQLite references
- [ON CONFLICT](https://sqlite.org/lang_conflict.html)
- [`NULL` handling](https://sqlite.org/nulls.html)
- [Transactions](https://sqlite.org/lang_transaction.html)
- [SELECT and ordering](https://sqlite.org/lang_select.html#orderby)
## 13. Open decisions
This specification deliberately leaves the following decisions to the accepted first-slice design:
1. the exact collation and normalization policy for combo names;
2. the typed or text representation of combo JSON in MySQL;
3. the repository result type for an existing same-value update;
4. the isolation level selected by the backend profile;
5. the concurrency mechanism for sort-order allocation and reorder;
6. the physical MySQL migration schema and durable checkpoint format;
7. the exact retry budget and backoff policy;
8. the topology boundary within which a MySQL named migration lock is sufficient.
These are not adapter implementation details. Each changes observable behavior or operational
correctness and therefore requires explicit review before runtime support proceeds.

View File

@@ -0,0 +1,278 @@
---
title: "Antigravity (Google One AI) — Onboarding with OmniRoute"
version: 3.8.50
lastUpdated: 2026-07-31
---
# OmniRoute Antigravity (Google One AI) Onboarding Guide
> **What you get**: Access to Gemini 3.1 Pro, Gemini 3.5 Flash, Claude Sonnet 4.6, and other models through your Google One AI Pro subscription — routed through OmniRoute as a unified gateway.
**Official references**:
- [Google Antigravity](https://antigravity.google) — product homepage
- [Antigravity Plans & Pricing](https://antigravity.google/pricing) — subscription tiers
- [Antigravity Docs: Plans](https://antigravity.google/docs/plans) — baseline quota details
- [Google One AI Plans](https://one.google.com/about/google-ai-plans/) — Google One subscription comparison
- [Antigravity CLI Blog](https://antigravity.google/blog/introducing-google-antigravity-cli) — CLI announcement
---
## 1. Antigravity vs Antigravity CLI (agy)
Both providers share the **same Google backend** — identical OAuth client, token refresh, endpoints, and Google accounts. The difference is what models you see.
> See [Antigravity CLI announcement](https://antigravity.google/blog/introducing-google-antigravity-cli) for Google's official comparison.
| Aspect | `antigravity` (IDE) | `agy` (CLI) |
| -------------------- | ----------------------------------------- | --------------------------------------------------- |
| **Google product** | Antigravity 2.0 / Antigravity IDE | Antigravity CLI |
| **Backend** | Same Google Cloud Code API | Same Google Cloud Code API |
| **OAuth / Token** | Same client, same refresh | Same client, same refresh |
| **Model catalog** | Static curated list (OmniRoute hardcoded) | Live-probed from Google via `:fetchAvailableModels` |
| **Claude models** | Sonnet 4.6, Opus 4.6 (4 variants each) | Sonnet 4.6, Opus 4.6 (4 variants each) |
| **Gemini naming** | Clean labels (Low/Medium/High) | Upstream IDs (extra-low/low/agent) |
| **Extra models** | `gpt-oss-120b-medium` | May include additional models from Google |
| **Default use case** | IDE integration (VS Code, JetBrains) | CLI / API access |
| **Quota** | Shared with agy (same Google account) | Shared with antigravity (same Google account) |
**Available models (verified via experiment, 2026-07-29)**:
- Gemini: 3.6 Flash, 3.5 Flash, 3.1 Pro, 3 Flash, 2.5 Flash (various thinking levels)
- Claude: Sonnet 4.6, Opus 4.6 (each with default/low/medium/high variants)
- Other: GPT-OSS 120B Medium
- **Claude Sonnet 5 is NOT available** — only 4.6 variants are supported
**Why the model catalog differs**: Google's CLI is "optimized for speed and low overhead" and "co-optimized with Gemini models" (per Google's official blog). The Web/IDE product is "optimized for comprehensiveness." The CLI uses `:fetchAvailableModels` to dynamically discover models, while the IDE uses a static curated list.
**In practice**: Use `agy/` prefix for Gemini models (e.g. `agy/gemini-3.5-flash-high`). Use `antigravity/` for the static curated list. Both hit the same Google backend, but expose different model naming. The quota is shared — using either provider counts against the same Google account's limits.
---
## 2. Google One AI Pro: Quota System
> See [Antigravity Docs: Plans](https://antigravity.google/docs/plans) for official quota details and [Changes to Antigravity Plans](https://antigravity.google/blog/changes-to-antigravity-plans) for the latest pricing updates.
Google Antigravity uses a **dual-layer quota** based on "Work Done" (computational weight), not message count.
### The Two Layers
| Layer | What it is | Refresh cycle |
| ------------------ | ----------------------------- | ------------------------------------------------------------------ |
| **5-hour sprint** | Immediate pool of "work done" | Resets 5 hours after first request in a session |
| **7-day baseline** | Weekly hard cap | Overrides 5-hour refresh if hit; locks out until next 7-day period |
**How "Work Done" is calculated**: Agent-heavy tasks (e.g. "Refactor this entire repository") drain quota much faster than simple tasks (e.g. "Fix this function"). There is no real-time dashboard showing consumption.
### Plan Tiers
| Plan | Price | Quota | Weekly limit |
| ------------ | ---------- | ---------------------------------- | ----------------------------- |
| Free | $0 | Meaningful quota, refreshed weekly | Yes |
| AI Pro | $19.99/mo | High quota, 5-hour rolling refresh | Yes (overrides 5-hour if hit) |
| AI Ultra 5x | $99.99/mo | 5x Pro quota | No weekly limit |
| AI Ultra 20x | $199.99/mo | 20x Pro quota | No weekly limit |
### Gemini vs Non-Gemini Models
- **Gemini models** (Flash + Pro): Share a single rate limit, drawn down by API pricing. If Flash is 8x cheaper than Pro, you get 8x more Flash tokens.
- **Non-Gemini models** (Claude, GPT-OSS): Have **separate** rate limits. May remain available even when Gemini is locked out.
### AI Credits (Overage)
> See [Google One AI credits](https://support.google.com/googleone/answer/14534406) for how credits work.
When baseline quota is exhausted:
- **Never**: Wait for quota to refresh; shows "Baseline model quota reached"
- **Always**: Auto-use AI credits; switches back to baseline when it refreshes
Credits are purchased separately and deducted at standard API pricing.
### Key Details
- Quota is **account-level shared** — the same Google account in Antigravity IDE, CLI, and OmniRoute shares one quota pool
- Each Google account has its own independent quota — multiple accounts = multiple quota pools
- AI Pro users have reported **7-day lockouts** instead of 5-hour resets when weekly baseline is hit (Google confirmed this is by design for high demand)
**When your account is exhausted**: OmniRoute automatically retries with the next available account in the combo route. No manual intervention needed.
---
## 3. How to Get a projectId
Every antigravity/agy connection needs a Google Cloud Code `projectId`. Without it, the `/v1internal:models` endpoint returns 404.
### Method A: Automatic (Recommended)
OmniRoute handles this automatically. When you add a new Google account via Dashboard OAuth:
1. OmniRoute refreshes the token
2. Calls `loadCodeAssist` to discover the projectId
3. If no project exists, calls `onboardUser` to create one
4. Retries `loadCodeAssist` to get the newly created projectId
5. Saves it to the database
**This works for most accounts** — no manual steps needed.
### Method B: Manual via agy CLI
If automatic discovery fails (see Section 5 for when this happens):
```bash
# Install agy CLI (if not already)
npm install -g @anthropic-ai/agy
# Login with your Google account
agy login
# Select the account that needs onboarding
# This triggers Cloud Code registration and assigns a projectId
```
After `agy login` succeeds, refresh the token in OmniRoute Dashboard. The projectId will be discovered automatically.
### How to verify
Check the database:
```bash
# Inside OmniRoute container
node -e "const db=require('better-sqlite3')('/app/data/storage.sqlite'); \
console.log(JSON.stringify(db.prepare(\
'SELECT email,project_id FROM provider_connections WHERE provider=\"agy\"'\
).all(), null, 2))"
```
Or check the logs:
```
podman logs omniroute 2>&1 | grep "projectId discovered"
```
---
## 4. OAuth Redirect URI
### The Problem
Google OAuth requires a valid redirect URI. OmniRoute's default uses `http://127.0.0.1:20128/callback` (loopback). This works for local builds but **fails for remote deployments** (e.g., a server accessed via LAN IP).
Google rejects redirect URIs that:
- Use IP addresses (must be a domain ending in `.com`, `.org`, etc.)
- Don't match the registered redirect URIs in the OAuth client config
### The Solution
**Option A: Use the built-in OAuth flow (default)**
- Works when you access OmniRoute from `localhost` or `127.0.0.1`
- No configuration needed
**Option B: Custom OAuth credentials**
- Set `ANTIGRAVITY_OAUTH_CLIENT_TYPE=web` in your environment
- Provide your own Google OAuth credentials:
```
GOOGLE_OAUTH_CLIENT_ID=your-client-id
GOOGLE_OAUTH_CLIENT_SECRET=your-client-secret
```
- Register `https://your-domain.com/callback` as an authorized redirect URI in Google Cloud Console
**Option C: Use agy CLI for initial login**
- Run `agy login` on the machine that will access OmniRoute
- The OAuth flow completes locally, tokens are stored
- Import the connection into OmniRoute via Dashboard
### Limitations
- Custom OAuth credentials require a domain name (Google does not accept IP addresses as redirect URIs)
- If you don't have a domain, use Option A or C instead
---
## 5. Troubleshooting: When Automatic Setup Fails
OmniRoute handles projectId discovery and onboarding automatically for most accounts. When it fails, the root cause is usually one of these:
### Account region is blocked
**Symptom**: `agy login` returns "Eligibility check failed: Your current account is not eligible for Antigravity, because it is not currently available in your location."
**Root cause**: Google accounts have a backend "Country Association" field set at registration time. The agy CLI and Cloud Code API check this field strictly — unlike web Gemini which only checks your current IP.
> To check or change your account's associated region, visit [Google Country Association Form](https://policies.google.com/country-association-form).
**Why web Gemini works but agy doesn't**:
- Web Gemini / Google One: checks current IP only (proxy passes)
- agy CLI / Cloud Code API: reads backend Country Association field (proxy doesn't help)
**Fix**:
1. Visit [Google Country Association Form](https://policies.google.com/country-association-form) while on a US IP
2. Submit region change request (select "I live in a different country")
3. Wait 1-24 hours for Google to process + email notification
4. Then `agy login` should succeed
### Account has no Cloud Code project
**Symptom**: Logs show `loadCodeAssist returned no project id` and `onboardUser failed (400)`.
**Root cause**: The account has never been registered with Google Cloud Code, and the automatic onboarding failed.
**Fix**: Run `agy login` manually to trigger Cloud Code registration, then refresh the token in OmniRoute Dashboard.
### Token expired or revoked
**Symptom**: 401 errors in logs, or "Token has expired" messages.
**Fix**: Refresh the token in Dashboard → Providers → agy → Click refresh icon. If the refresh token itself is revoked, you'll need to re-authenticate via OAuth.
---
## Decision Flowchart
```
Account not working?
├─ Does it have a projectId in the database?
│ ├─ YES → Problem is elsewhere (token expired, rate limit, etc.)
│ └─ NO ↓
├─ Is the account's Country Association set to a restricted region?
│ ├─ YES → Change region at Google Country Association Form
│ │ (https://policies.google.com/country-association-form)
│ │ Wait 1-24 hours, then retry
│ └─ NO ↓
├─ Does the account have Google One AI Pro subscription?
│ ├─ NO → Subscribe first at one.google.com
│ └─ YES ↓
├─ Try automatic discovery (refresh token in Dashboard)
│ ├─ Works → Done
│ └─ Still fails ↓
└─ Manual: Run `agy login` on the machine
├─ Works → Refresh token in Dashboard, projectId discovered
└─ Fails → Check error message, likely region or subscription issue
```
---
## Quick Reference
| Task | Command / URL |
| --------------------- | --------------------------------------------------------------------------------------- |
| Change account region | [Google Country Association Form](https://policies.google.com/country-association-form) |
| agy CLI login | `agy login` |
| Check projectId in DB | `SELECT email,project_id FROM provider_connections WHERE provider='agy'` |
| Check logs | `podman logs omniroute 2>&1 \| grep projectId` |
| Refresh token | Dashboard → Providers → agy → Click refresh icon |
---
_Last updated: 2026-07-31. Based on OmniRoute v3.8.50._

View File

@@ -1,3 +1,9 @@
---
title: "AgentRouter WAF"
version: 3.8.50
lastUpdated: 2026-08-03
---
# agentrouter.org WAF (Web Application Firewall)
The `agentrouter` upstream gateway runs a keyword-based content filter on

View File

@@ -22,8 +22,7 @@ const LOCAL_DB_IMPORT_RESTRICTION = {
const EXECUTOR_IMPORT_RESTRICTION = {
regex: "^(?:@omniroute/)?open-sse/executors(?:/|$)",
message:
"Executor implementations must stay behind an open-sse handler or service boundary.",
message: "Executor implementations must stay behind an open-sse handler or service boundary.",
};
const PROP_TYPES_RESTRICTION = {
@@ -165,6 +164,14 @@ const eslintConfig = [
// their files move mid-scan, so never lint them from the main checkout.
".claude/**",
".omnivscodeagent/**",
// _tasks/ — planning/handoff/research artifacts (gitignored, external code)
"_tasks/**",
// .agents/ — skill definitions + their helper scripts (gitignored; the
// canonical copy lives here and is symlinked into .claude/).
".agents/**",
// .source/ — fumadocs codegen output (@ts-nocheck + bundler-only import
// query params like `?collection=docs`, which are not valid TS on their own).
".source/**",
// VS Code extension and its large test fixtures
"vscode-extension/**",
"_references/**",

View File

@@ -1,8 +0,0 @@
node_modules/
*.log
.DS_Store
test/
*.test.js
.env
.env.*

View File

@@ -24,6 +24,8 @@ const ANTHROPIC_BETA_BASE = Object.freeze([
"advisor-tool-2026-03-01",
"extended-cache-ttl-2025-04-11",
"cache-diagnosis-2026-04-07",
"code-execution-2025-08-25",
"skills-2025-10-02",
]);
const CLAUDE_OAUTH_EXTRA_BETAS = Object.freeze(["fine-grained-tool-streaming-2025-05-14"]);
@@ -53,6 +55,8 @@ export const ANTHROPIC_BETA_CLAUDE_OAUTH = [
export const FORWARDABLE_CLIENT_BETAS = Object.freeze([
"tool-search-tool-2025-10-19",
"context-1m-2025-08-07",
"code-execution-2025-08-25",
"skills-2025-10-02",
]);
/**

View File

@@ -12,16 +12,10 @@ export interface KimiWebModelConfig {
const STATIC_MODEL_CONFIGS: Record<string, KimiWebModelConfig> = {
k3: {
scenario: "SCENARIO_OK_COMPUTER",
kimiPlusId: "ok-computer",
supportedReasoningEfforts: [
"REASONING_EFFORT_LOW",
"REASONING_EFFORT_HIGH",
"REASONING_EFFORT_MAX",
],
defaultReasoningEffort: "REASONING_EFFORT_MAX",
supportedContextLengths: ["CONTEXT_LENGTH_L", "CONTEXT_LENGTH_XL"],
defaultContextLength: "CONTEXT_LENGTH_L",
scenario: "SCENARIO_K2D5",
supportedReasoningEfforts: ["REASONING_EFFORT_NONE", "REASONING_EFFORT_LOW"],
defaultReasoningEffort: "REASONING_EFFORT_NONE",
supportedContextLengths: [],
},
k2d6: {
scenario: "SCENARIO_K2D5",

View File

@@ -12,6 +12,18 @@ export const ollama_cloudProvider: RegistryEntry = {
// Note: rate limits vary by plan (free = "Light usage", Pro = more, Max = 5x Pro).
// Users can generate API keys at https://ollama.com/settings/keys
models: [
{
id: "gpt-oss:20b",
name: "GPT-OSS 20B",
supportsReasoning: true,
supportedThinkingEfforts: ["low", "medium", "high"],
},
{
id: "gpt-oss:120b",
name: "GPT-OSS 120B",
supportsReasoning: true,
supportedThinkingEfforts: ["low", "medium", "high"],
},
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true },
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true },
{ id: "kimi-k2.6", name: "Kimi K2.6" },

View File

@@ -15,7 +15,7 @@ export const perplexity_webProvider: RegistryEntry = {
{ id: "pplx-gpt-5.6-sol", name: "GPT-5.6 Sol (via Perplexity)", toolCalling: false },
{ id: "pplx-gemini", name: "Gemini 3.1 Pro (via Perplexity)", toolCalling: false },
{ id: "pplx-sonnet", name: "Claude Sonnet 5.0 (via Perplexity)", toolCalling: false },
{ id: "pplx-opus", name: "Claude Opus 4.8 (via Perplexity)", toolCalling: false },
{ id: "pplx-opus", name: "Claude Opus 5.0 (via Perplexity)", toolCalling: false },
{ id: "pplx-glm", name: "GLM-5.2 (via Perplexity)", toolCalling: false },
{ id: "pplx-kimi", name: "Kimi K2.6 (via Perplexity)", toolCalling: false },
{ id: "pplx-grok-4.5", name: "Grok 4.5 (via Perplexity)", toolCalling: false },

View File

@@ -48,6 +48,7 @@ export interface RegistryModel {
aliases?: readonly string[];
toolCalling?: boolean;
supportsReasoning?: boolean;
supportedThinkingEfforts?: readonly string[];
supportsVision?: boolean;
supportsXHighEffort?: boolean;
maxOutputTokens?: number;

View File

@@ -7,10 +7,11 @@ import { supportsClaudeMaxEffort, supportsXHighEffort } from "../../config/provi
/**
* Sanitize reasoning_effort for providers that don't accept all values.
*
* The claude→openai translator may emit reasoning_effort=max/xhigh when the
* client sends output_config.effort=max on a Claude-shape request. Combined with
* runtime alias remapping (e.g. claude-opus-4-6 → mimo/mimo-v2.5-pro), this
* routes xhigh to OpenAI-shape providers that don't accept the value:
* The claude→openai translator passes output_config.effort through verbatim
* (including max) and only performs form conversion; provider-aware effort
* policy is owned here. Combined with runtime alias remapping (e.g.
* claude-opus-4-6 → mimo/mimo-v2.5-pro), this routes a client's effort value
* to OpenAI-shape providers that don't accept it:
*
* xiaomi-mimo : low|medium|high only — 400 literal_error on xhigh
* mistral : devstral models reject reasoning_effort entirely
@@ -216,10 +217,7 @@ function writeEffortValue(
}
/** Strip the effort field from every carrier that was present. */
function stripEffortValue(
b: Record<string, unknown>,
c: EffortCarriers
): Record<string, unknown> {
function stripEffortValue(b: Record<string, unknown>, c: EffortCarriers): Record<string, unknown> {
const next: Record<string, unknown> = { ...b };
if (c.hasTopLevelReasoningEffort) delete next.reasoning_effort;
if (c.hasReasoningEffort && c.reasoning) {

View File

@@ -213,14 +213,21 @@ function makeErrorResponse(
details?: unknown;
type?: string;
code?: string;
extraHeaders?: Record<string, string>;
}
): Response {
const body = buildErrorBody(status, message, options?.details);
if (options?.type) body.error.type = options.type;
if (options?.code) body.error.code = options.code;
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (options?.extraHeaders) {
for (const [key, value] of Object.entries(options.extraHeaders)) {
headers[key] = value;
}
}
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
headers,
});
}
@@ -302,7 +309,12 @@ async function errorResponseForTransport(
return makeErrorResponse(401, "Session expired or invalid");
}
if (result.status === 429) {
return makeErrorResponse(429, "Rate limited by Claude Web API");
const extraHeaders: Record<string, string> = {};
const upstreamRetryAfter = result.headers.get("retry-after");
if (upstreamRetryAfter) {
extraHeaders["Retry-After"] = upstreamRetryAfter;
}
return makeErrorResponse(429, "Rate limited by Claude Web API", { extraHeaders });
}
if (isClaudeWebChallenge({ ...result, bodyText })) {
return makeErrorResponse(403, "Claude Web returned a Cloudflare browser challenge", {

View File

@@ -217,6 +217,20 @@ function messageText(content: unknown): string {
return content.map(contentPartText).filter(Boolean).join("\n");
}
function buildPromptFromMessages(messages: unknown[]): string {
const parts: string[] = [];
for (const candidate of messages) {
if (!isRecord(candidate)) continue;
const role = candidate.role;
const text = messageText(candidate.content);
if (!text) continue;
if (role === "user" || role === "tool") {
parts.push(text);
}
}
return parts.join("\n\n");
}
function latestUserPrompt(messages: unknown[]): string {
let prompt = "";
for (const candidate of messages) {
@@ -308,7 +322,9 @@ export function transformToClaude(
const messages = Array.isArray(body.messages) ? body.messages : [];
const reasoningEffort = resolveClaudeWebReasoningEffort(body);
const resolvedModel = model || DEFAULT_CLAUDE_MODEL;
const resolvedTurn = turn ?? defaultTurn(latestUserPrompt(messages));
const prompt =
turn?.prompt ?? (buildPromptFromMessages(messages) || latestUserPrompt(messages));
const resolvedTurn = turn ?? defaultTurn(prompt);
if (resolvedTurn.operation === "completion" && !resolvedTurn.prompt.trim()) {
throw new Error("No user message found in request");

View File

@@ -13,14 +13,22 @@ export interface ClaudeWebStreamOptions {
}
type StreamPhase = "awaiting_message" | "in_message" | "stopped" | "failed";
type BlockKind = "thinking" | "text" | "other";
type BlockKind = "thinking" | "text" | "tool_use" | "other";
const MAX_CLAUDE_WEB_SSE_PENDING_CHARS = 1024 * 1024;
type SemanticEvent =
| { kind: "content"; text: string }
| { kind: "reasoning"; text: string }
| { kind: "tool_call"; index: number; id: string; name: string; input: string }
| { kind: "metadata"; eventType: string; data: Record<string, unknown> }
| { kind: "finish"; stopReason: string };
interface ToolBlockInfo {
id: string;
name: string;
inputParts: string[];
initialInput: string;
}
const KNOWN_METADATA_EVENTS = new Set([
"ping",
"completion",
@@ -193,6 +201,7 @@ function thinkingSummaryText(delta: Record<string, unknown>): string {
interface ProtocolState {
phase: StreamPhase;
openBlocks: Map<number, BlockKind>;
toolBlocks: Map<number, ToolBlockInfo>;
stopReason: string;
}
@@ -241,6 +250,7 @@ function handleMessageStart(state: ProtocolState): null {
function blockKind(block: Record<string, unknown>): BlockKind {
if (block.type === "thinking") return "thinking";
if (block.type === "text") return "text";
if (block.type === "tool_use") return "tool_use";
return "other";
}
@@ -252,17 +262,35 @@ function handleContentBlockStart(
const index = requireBlockIndex(event);
if (state.openBlocks.has(index)) protocolFailure(state, "Content block was opened twice");
const kind = blockKind(requireRecord(event.content_block, "content_block"));
const contentBlock = requireRecord(event.content_block, "content_block");
const kind = blockKind(contentBlock);
state.openBlocks.set(index, kind);
if (kind === "tool_use") {
const id = typeof contentBlock.id === "string" ? contentBlock.id : "";
const name = typeof contentBlock.name === "string" ? contentBlock.name : "";
let initialInput = "";
if (contentBlock.input !== undefined) {
try {
initialInput = JSON.stringify(contentBlock.input);
} catch {
initialInput = "";
}
}
state.toolBlocks.set(index, { id, name, inputParts: [], initialInput });
return null;
}
return kind === "thinking" ? { kind: "reasoning", text: "" } : null;
}
function handleContentBlockDelta(
event: Record<string, unknown>,
state: ProtocolState
): SemanticEvent {
): SemanticEvent | null {
assertInMessage(state, "content_block_delta");
const block = state.openBlocks.get(requireBlockIndex(event));
const index = requireBlockIndex(event);
const block = state.openBlocks.get(index);
if (!block) protocolFailure(state, "Content delta has no open block");
const delta = requireRecord(event.delta, "delta");
@@ -275,14 +303,42 @@ function handleContentBlockDelta(
if (delta.type === "thinking_summary_delta" && block === "thinking") {
return { kind: "reasoning", text: thinkingSummaryText(delta) };
}
if (delta.type === "input_json_delta" && block === "tool_use") {
const toolBlock = state.toolBlocks.get(index);
if (!toolBlock) protocolFailure(state, "input_json_delta has no tool block state");
if (typeof delta.partial_json === "string") {
toolBlock.inputParts.push(delta.partial_json);
}
return null;
}
return protocolFailure(state, "Content delta type does not match its block");
}
function handleContentBlockStop(event: Record<string, unknown>, state: ProtocolState): null {
function handleContentBlockStop(
event: Record<string, unknown>,
state: ProtocolState
): SemanticEvent | null {
assertInMessage(state, "content_block_stop");
if (!state.openBlocks.delete(requireBlockIndex(event))) {
protocolFailure(state, "Content block stop has no open block");
const index = requireBlockIndex(event);
const kind = state.openBlocks.get(index);
if (!kind) protocolFailure(state, "Content block stop has no open block");
state.openBlocks.delete(index);
if (kind === "tool_use") {
const toolBlock = state.toolBlocks.get(index);
state.toolBlocks.delete(index);
if (!toolBlock) protocolFailure(state, "Tool block stop has no tool state");
let inputStr = "";
if (toolBlock.inputParts.length > 0) {
inputStr = toolBlock.inputParts.join("");
} else if (toolBlock.initialInput) {
inputStr = toolBlock.initialInput;
}
return { kind: "tool_call", index, id: toolBlock.id, name: toolBlock.name, input: inputStr };
}
return null;
}
@@ -336,6 +392,7 @@ async function* parseClaudeWebEvents(
const state: ProtocolState = {
phase: "awaiting_message",
openBlocks: new Map(),
toolBlocks: new Map(),
stopReason: "end_turn",
};
@@ -447,6 +504,7 @@ async function createBufferedResponse(
let assistantText = "";
let reasoningText = "";
let stopReason = "end_turn";
const toolCalls: Array<{ id: string; name: string; input: string }> = [];
const metadataEvents: Array<{ type: string; data: Record<string, unknown> }> = [];
const control: StreamControl = { reader: null, cancelled: false };
@@ -454,12 +512,30 @@ async function createBufferedResponse(
for await (const event of parseClaudeWebEvents(source, control)) {
if (event.kind === "content") assistantText += event.text;
if (event.kind === "reasoning") reasoningText += event.text;
if (event.kind === "tool_call") {
toolCalls.push({ id: event.id, name: event.name, input: event.input });
}
if (event.kind === "metadata") {
metadataEvents.push({ type: event.eventType, data: event.data });
}
if (event.kind === "finish") stopReason = event.stopReason;
}
notifyComplete(options, { assistantText, stopReason });
const message: Record<string, unknown> = {
role: "assistant",
content: assistantText || null,
...(reasoningText ? { reasoning_content: reasoningText } : {}),
};
if (toolCalls.length > 0) {
message.tool_calls = toolCalls.map((tc) => ({
id: tc.id,
type: "function",
function: { name: tc.name, arguments: tc.input },
}));
}
return new Response(
JSON.stringify({
id,
@@ -469,11 +545,7 @@ async function createBufferedResponse(
choices: [
{
index: 0,
message: {
role: "assistant",
content: assistantText,
...(reasoningText ? { reasoning_content: reasoningText } : {}),
},
message,
finish_reason: openAiFinishReason(stopReason),
logprobs: null,
},
@@ -569,6 +641,31 @@ async function queueSemanticEvent(
);
return;
}
if (event.kind === "tool_call") {
state.pendingChunks.push(
encodeStreamEvent(
state,
makeChunk(
state.id,
state.created,
options,
{
tool_calls: [
{
index: event.index,
id: event.id,
type: "function",
function: { name: event.name, arguments: event.input },
},
],
},
null
)
)
);
return;
}
if (event.kind === "metadata") {
state.pendingChunks.push(
encodeStreamEvent(

View File

@@ -166,6 +166,13 @@ export interface ChatInvocationOptions {
tone?: string;
/** Tier-specific allowed message types; defaults to {@link ALLOWED_MESSAGE_TYPES}. */
allowedMessageTypes?: readonly string[];
/**
* Tier-specific disconnect behavior sent in every type:4 chat invocation. The work
* Surface rejects any value other than exactly "continue" (#8971). Defaults to ""
* for individual/consumer/EDU tiers; {@link resolveChatInvocationOverrides} returns
* "continue" for the enterprise tier.
*/
disconnectBehavior?: string;
}
/**
@@ -178,18 +185,21 @@ export function resolveChatInvocationOverrides(tier: string | undefined): {
optionsSets: string[];
tone: string;
allowedMessageTypes: readonly string[];
disconnectBehavior: string;
} {
if (tier === "enterprise") {
return {
optionsSets: [...M365_ENTERPRISE_OPTION_SETS],
tone: "Magic",
allowedMessageTypes: [...ALLOWED_MESSAGE_TYPES, ...M365_ENTERPRISE_EXTRA_MESSAGE_TYPES],
disconnectBehavior: "continue",
};
}
return {
optionsSets: [...M365_DEFAULT_OPTION_SETS],
tone: "",
allowedMessageTypes: ALLOWED_MESSAGE_TYPES,
disconnectBehavior: "",
};
}
@@ -253,7 +263,7 @@ export function buildChatInvocation(opts: ChatInvocationOptions): Record<string,
isSbsSupported: false,
tone: opts.tone ?? "",
renderReferencesBehindEOS: true,
disconnectBehavior: "",
disconnectBehavior: opts.disconnectBehavior ?? "",
},
],
};

View File

@@ -395,9 +395,26 @@ export class DefaultExecutor extends BaseExecutor {
}
case "claude":
case "anthropic":
effectiveKey
? (headers["x-api-key"] = effectiveKey)
: (headers["Authorization"] = `Bearer ${credentials.accessToken}`);
if (effectiveKey) {
headers["x-api-key"] = effectiveKey;
// Port of decolua/9router commit b977bf74:
// Third-party Anthropic-compatible gateways frequently require
// Authorization: Bearer ALONGSIDE x-api-key — without it they
// return 401 missing_api_key on every forward. Only emit the
// Bearer fallback for non-official upstreams; api.anthropic.com
// (and the empty/default baseUrl that targets it) must keep the
// x-api-key-only behavior to avoid regressing the official path.
const baseUrl = credentials?.providerSpecificData?.baseUrl || "";
const isOfficial = isOfficialAnthropicBaseUrl(baseUrl);
if (!isOfficial) {
headers["Authorization"] = `Bearer ${effectiveKey}`;
}
} else if (credentials.accessToken) {
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
}
// If neither effectiveKey nor accessToken is available, emit no
// auth header — the handler will produce a clean "no credentials"
// 4xx instead of forwarding garbage auth headers to the upstream.
break;
case "glm":
case "glmt":

View File

@@ -348,6 +348,30 @@ export class GeminiWebExecutor extends BaseExecutor {
super("gemini-web", { id: "gemini-web", baseUrl: GEMINI_URL });
}
/**
* testConnection — validates the cookie format without making a network call
* or launching Playwright. Returns true when the cookie is non-empty and
* contains at least one name=value pair with a non-empty value. This is a
* lightweight pre-check before the browser automation path; full session
* validation is done by validateGeminiWebProvider in the connection test
* flow (#9407).
*/
async testConnection(
credentials: Record<string, unknown>,
_signal?: AbortSignal
): Promise<boolean> {
try {
const cookie = resolveGeminiWebCookie(
credentials as unknown as ExecuteInput["credentials"]
);
if (!cookie) return false;
const pairs = parseCookies(cookie);
return pairs.some((p) => p.value.length > 0);
} catch {
return false;
}
}
/**
* Read the live Playwright cookie jar back after a successful run and, if
* Google rotated any of the __Secure-1PSID* cookies, forward the merged
@@ -593,6 +617,30 @@ export class GeminiWebExecutor extends BaseExecutor {
transformedBody: body,
};
}
// #9407: Playwright selector/click timeout errors are terminal — they indicate
// the page DOM does not match expectations (e.g. Gemini changed their UI or
// the session is so expired it lands on a different page). Return 400 so the
// account-fallback system does NOT retry this request as a transient 5xx.
if (
error instanceof Error &&
(error.name === "TimeoutError" ||
rawMessage.includes("waitForSelector") ||
rawMessage.includes("Timeout") ||
rawMessage.includes("actionability") ||
rawMessage.includes("interception"))
) {
return {
response: new Response(
JSON.stringify({
error: sanitizeErrorMessage(rawMessage),
}),
{ status: 400, headers: { "Content-Type": "application/json" } }
),
url: GEMINI_URL,
headers: {},
transformedBody: body,
};
}
return {
response: new Response(
JSON.stringify({

View File

@@ -388,7 +388,10 @@ export class PerplexityWebExecutor extends BaseExecutor {
let pplxMode: string;
let modelPref: string;
if (thinking && THINKING_MAP[model]) {
pplxMode = "search";
// "copilot", not "search": the backend downgrades "search" to CONCISE and drops
// model_preference, so the thinking variant would fail the same way the catalog
// models do (see the note above MODEL_MAP).
pplxMode = "copilot";
modelPref = THINKING_MAP[model];
log?.info?.("PPLX-WEB", `Thinking mode → ${model} using ${modelPref}`);
} else if (MODEL_MAP[model]) {

View File

@@ -51,31 +51,40 @@ export const PPLX_STREAM_EOF_SYMBOL = "event: end_of_stream";
export const PPLX_USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0";
// mode / model_preference pairs. Live www.perplexity.ai still posts mode:"copilot"
// for the default turbo path; search mode is used for the curated catalog models.
// mode / model_preference pairs — every entry posts mode:"copilot", like the live
// www.perplexity.ai client does when a model is picked from the catalog.
//
// mode:"search" must NOT be used here. The backend now downgrades it to CONCISE and
// drops model_preference entirely, answering with status:"FAILED" and the text
// "Error in processing query." Verified against a paid `subscription_tier: "max"`
// account: mode:"search" + claude50sonnet → {"mode":"CONCISE","status":"FAILED"},
// while mode:"copilot" + the same preference → {"mode":"COPILOT",
// "display_model":"claude50sonnet"} and a normal stream. Same for every other
// catalog model, so "search" breaks the whole catalog, not just one entry.
export const MODEL_MAP: Record<string, [string, string]> = {
// pplx-auto/pplx-sonar use "copilot" mode (was "search", which for pplx-sonar
// maps to "experimental" — that model no longer streams answer-text blocks
// for many sessions → empty content, issue #6955). The live web client uses
// mode:"copilot" + model_preference:"turbo" for the default turbo path.
// pplx-auto/pplx-sonar were already on "copilot" (with "search", pplx-sonar maps to
// "experimental" — that model no longer streams answer-text blocks for many
// sessions → empty content, issue #6955).
"pplx-auto": ["copilot", "pplx_pro"],
"pplx-sonar": ["copilot", "turbo"],
"pplx-gpt-5.6-terra": ["search", "gpt56_terra"],
"pplx-gpt-5.6-sol": ["search", "gpt56_sol"],
"pplx-gemini": ["search", "gemini31pro_high"],
"pplx-sonnet": ["search", "claude50sonnet"],
"pplx-opus": ["search", "claude48opus"],
"pplx-glm": ["search", "glm_5_2"],
"pplx-kimi": ["search", "kimik26instant"],
"pplx-grok-4.5": ["search", "grok45low"],
"pplx-nemotron": ["search", "nv_nemotron_3_ultra"],
"pplx-gpt-5.6-terra": ["copilot", "gpt56_terra"],
"pplx-gpt-5.6-sol": ["copilot", "gpt56_sol"],
"pplx-gemini": ["copilot", "gemini31pro_high"],
"pplx-sonnet": ["copilot", "claude50sonnet"],
// Perplexity's catalog moved Opus to 5.0; claude48opus is still accepted but
// answers from the older model.
"pplx-opus": ["copilot", "claude50opus"],
"pplx-glm": ["copilot", "glm_5_2"],
"pplx-kimi": ["copilot", "kimik26instant"],
"pplx-grok-4.5": ["copilot", "grok45low"],
"pplx-nemotron": ["copilot", "nv_nemotron_3_ultra"],
};
export const THINKING_MAP: Record<string, string> = {
"pplx-gpt-5.6-terra": "gpt56_terra_thinking",
"pplx-gpt-5.6-sol": "gpt56_sol_thinking",
"pplx-sonnet": "claude50sonnetthinking",
"pplx-opus": "claude48opusthinking",
"pplx-opus": "claude50opusthinking",
"pplx-kimi": "kimik26thinking",
"pplx-grok-4.5": "grok45medium",
};

View File

@@ -47,8 +47,8 @@ const BX_UMIDTOKEN_FALLBACK = "T2gA0000000000000000000000000000000000000000";
// header the upstream returns HTTP 200 with `{"success":false,"data":{"code":"Bad_Request"}}`
// for every completion request, even with a valid session. The version string is
// the SPA build identifier shipped in the React client's `version` request header.
// Pinned from a live capture (2026-07); bump if Qwen ships a breaking change.
const QWEN_SPA_VERSION = "0.2.66";
// Pinned from a live capture (2026-08); bump if Qwen ships a breaking change.
const QWEN_SPA_VERSION = "0.2.81";
const MODEL_ALIASES: Record<string, string> = {
// Legacy OmniRoute ids → current upstream catalog (GET /api/models).

View File

@@ -67,7 +67,7 @@ import {
resolveMemoryOwnerId,
} from "./chatCore/memoryExtraction.ts";
import { CORS_HEADERS } from "../utils/cors.ts";
import { checkHeapPressureGuard } from "../utils/heapPressure.ts";
import { checkResourcePressureGuard } from "../utils/resourcePressure.ts";
import { normalizeHeaders } from "../utils/headers.ts";
import { resolveChatCoreRequestFormat } from "./chatCore/requestFormat.ts";
import { resolveChatCoreTargetFormat } from "./chatCore/targetFormat.ts";
@@ -359,13 +359,6 @@ import {
isRpmExhausted,
} from "../services/geminiRateLimitTracker.ts";
// ── Global memory pressure guard ────────────────────────────────────────
// Prevents OOM by rejecting new requests when V8 heap exceeds threshold.
// Self-healing: no counters to leak, no cleanup needed. The threshold
// auto-calibrates to 85% of the actual V8 heap ceiling (see heapPressure.ts) so
// it tracks --max-old-space-size across 1GB/2GB/large VPS instead of a fixed
// 200MB that sat below the app's own ~260MB baseline and rejected every request.
import { isSmallEnoughForSemanticCache } from "../utils/estimateSize.ts";
/**
@@ -415,17 +408,16 @@ export async function handleChatCore({
createPiiTransform = null,
correlationId = null,
modelPinned = false,
skipResourcePressureGuard = false,
}) {
let { provider, model, extendedContext } = modelInfo;
// ── Memory pressure guard ────────────────────────────────────────────
// Reject early if V8 heap is already near the 256MB limit. Prevents
// cascading OOM when many large-context requests arrive concurrently.
try {
const heapUsedMB = process.memoryUsage().heapUsed / (1024 * 1024);
const heapGuard = checkHeapPressureGuard(heapUsedMB);
if (heapGuard) return heapGuard;
} catch {
/* memoryUsage() never throws */
if (!skipResourcePressureGuard) {
try {
const pressureGuard = checkResourcePressureGuard();
if (pressureGuard) return pressureGuard;
} catch {
/* fail open */
}
}
// Per-request model-routing metadata (first extracted slice of the request-setup phase).
@@ -4651,12 +4643,7 @@ export async function handleChatCore({
});
if (streamReadiness.ok === false) {
const { response: failureResponse, reason } = streamReadiness;
const failure = {
status: failureResponse.status,
message: reason,
code: streamReadiness.code,
type: streamReadiness.type,
};
const { classificationReason, upstreamDiagnostic } = streamReadiness;
trackPendingRequest(model, provider, connectionId, false);
appendRequestLog({
model,
@@ -4668,7 +4655,11 @@ export async function handleChatCore({
status: failureResponse.status,
error: reason,
providerRequest: finalBody || translatedBody,
clientResponse: buildErrorBody(failureResponse.status, reason),
clientResponse: buildErrorBody(
failureResponse.status,
classificationReason,
upstreamDiagnostic ? { error: { message: upstreamDiagnostic } } : undefined
),
claudeCacheMeta: claudePromptCacheLogMeta,
cacheSource: "upstream",
});
@@ -4680,6 +4671,7 @@ export async function handleChatCore({
success: false,
status: failureResponse.status,
error: reason,
classificationError: classificationReason,
errorType: streamReadiness.type,
errorCode: streamReadiness.code,
response: failureResponse,

View File

@@ -41,8 +41,8 @@ function extractSystemTexts(body: Record<string, unknown> | null | undefined): s
* True when the inbound request should be default-allowed without calling upstream.
*
* - `mode === "off"` (default): never short-circuits.
* - `mode === "always"`: short-circuits every Claude-format request (operator has
* decided every `/v1/messages` call through this route is the classifier).
* - `mode === "always"`: short-circuits only when the request carries the classifier's
* system-prompt marker (same body-awareness as "auto").
* - `mode === "auto"`: only short-circuits when the request carries the classifier's
* system-prompt marker. `</block>` in `stop_sequences` is corroborating evidence but
* is never sufficient alone — the marker is the strong, classifier-unique signal;
@@ -56,7 +56,6 @@ export function shouldDefaultAllowClassifier(
): boolean {
if (mode !== "auto" && mode !== "always") return false;
if (sourceFormat !== FORMATS.CLAUDE) return false;
if (mode === "always") return true;
return extractSystemTexts(body).some((text) => text.includes(SECURITY_MONITOR_MARKER));
}

View File

@@ -0,0 +1,168 @@
import type { AdmissionPressure, AdmissionReleaseOutcome } from "./types.ts";
export interface AdaptationParams {
minLimit: number;
maxLimit: number;
windowMs: number;
shortLatencyAlpha: number;
longLatencyAlpha: number;
increaseStep: number;
decreaseFactor: number;
criticalDecreaseFactor: number;
highUtilizationThreshold: number;
lowUtilizationThreshold: number;
latencyGradientThreshold: number;
maxIncreasePerWindow: number;
}
export interface AdaptationState {
currentLimit: number;
shortLatencyEwma: number;
longLatencyEwma: number;
pressure: AdmissionPressure;
/** Sum of admitted cost * time contribution proxies in the open window. */
windowActiveCostIntegral: number;
windowCompleted: number;
windowLatencySamples: number;
windowStartMs: number;
freezeGrowth: boolean;
/**
* When true, critical multiplicative decrease already applied for this window
* (e.g. via immediate observePressure). Window close must not re-apply it.
*/
criticalDecreaseConsumed: boolean;
utilization: number;
}
export function clampLimit(value: number, minLimit: number, maxLimit: number): number {
if (!Number.isFinite(value)) return minLimit;
return Math.min(maxLimit, Math.max(minLimit, Math.floor(value)));
}
export function createAdaptationState(
initialLimit: number,
minLimit: number,
maxLimit: number,
nowMs: number
): AdaptationState {
return {
currentLimit: clampLimit(initialLimit, minLimit, maxLimit),
shortLatencyEwma: 0,
longLatencyEwma: 0,
pressure: "normal",
windowActiveCostIntegral: 0,
windowCompleted: 0,
windowLatencySamples: 0,
windowStartMs: nowMs,
freezeGrowth: false,
criticalDecreaseConsumed: false,
utilization: 0,
};
}
export function noteLatency(
state: AdaptationState,
latencyMs: number,
params: AdaptationParams
): void {
const sample = Number.isFinite(latencyMs) && latencyMs >= 0 ? latencyMs : 0;
state.windowLatencySamples += 1;
const sa = params.shortLatencyAlpha;
const la = params.longLatencyAlpha;
if (state.shortLatencyEwma <= 0 && state.longLatencyEwma <= 0) {
state.shortLatencyEwma = sample;
state.longLatencyEwma = sample;
return;
}
state.shortLatencyEwma = sa * sample + (1 - sa) * state.shortLatencyEwma;
state.longLatencyEwma = la * sample + (1 - la) * state.longLatencyEwma;
}
export function noteOutcome(state: AdaptationState, outcome: AdmissionReleaseOutcome): void {
// A single upstream business error freezes growth for the current window; it must not
// apply critical multiplicative collapse on its own.
if (outcome === "upstream_error") {
state.freezeGrowth = true;
return;
}
if (outcome === "timeout") {
state.freezeGrowth = true;
}
}
export function setPressure(state: AdaptationState, pressure: AdmissionPressure): void {
const severity: Record<AdmissionPressure, number> = { normal: 0, high: 1, critical: 2 };
if (severity[pressure] > severity[state.pressure]) state.pressure = pressure;
}
/**
* Close the current feedback window and adjust the limit.
* Recovery (increase) is slower than decrease; idle/low utilization does not inflate.
*/
export function closeAdaptationWindow(
state: AdaptationState,
params: AdaptationParams,
nowMs: number
): void {
const elapsed = Math.max(1, Math.min(params.windowMs, nowMs - state.windowStartMs));
// sampleActiveIntegral already accounts for every interval exactly once.
const avgActive = state.windowActiveCostIntegral / elapsed;
const util = state.currentLimit > 0 ? avgActive / state.currentLimit : 0;
state.utilization = Math.max(0, Math.min(1, util));
let next = state.currentLimit;
const gradient =
state.longLatencyEwma > 0
? (state.shortLatencyEwma - state.longLatencyEwma) / state.longLatencyEwma
: 0;
if (state.pressure === "critical") {
// Immediate observePressure may already have applied the critical factor once.
if (!state.criticalDecreaseConsumed) {
next = Math.floor(next * params.criticalDecreaseFactor);
}
} else if (
state.pressure === "high" ||
(state.windowLatencySamples > 0 && gradient >= params.latencyGradientThreshold)
) {
next = Math.floor(next * params.decreaseFactor);
} else if (
!state.freezeGrowth &&
state.pressure === "normal" &&
state.utilization >= params.highUtilizationThreshold &&
state.windowCompleted > 0
) {
const step = Math.min(params.increaseStep, params.maxIncreasePerWindow);
next = next + step;
}
// A genuinely low-utilization window recovers the latency baseline so stale gradients expire.
if (state.utilization <= params.lowUtilizationThreshold) {
state.shortLatencyEwma = state.longLatencyEwma;
}
state.currentLimit = clampLimit(next, params.minLimit, params.maxLimit);
state.windowActiveCostIntegral = 0;
state.windowCompleted = 0;
state.windowLatencySamples = 0;
state.windowStartMs = nowMs;
state.freezeGrowth = false;
state.criticalDecreaseConsumed = false;
state.pressure = "normal";
}
export function sampleActiveIntegral(
state: AdaptationState,
activeCost: number,
dtMs: number
): void {
if (dtMs <= 0 || activeCost <= 0) return;
const boundedActiveCost = Math.min(activeCost, state.currentLimit);
const contribution =
dtMs > Math.floor(Number.MAX_SAFE_INTEGER / boundedActiveCost)
? Number.MAX_SAFE_INTEGER
: boundedActiveCost * dtMs;
state.windowActiveCostIntegral =
contribution >= Number.MAX_SAFE_INTEGER - state.windowActiveCostIntegral
? Number.MAX_SAFE_INTEGER
: state.windowActiveCostIntegral + contribution;
}

View File

@@ -0,0 +1,167 @@
import { resolveCostConfig } from "./cost.ts";
import {
MAX_ADMISSION_COST_OR_LIMIT,
MAX_ADMISSION_WINDOW_MS,
type AdaptiveAdmissionConfig,
type AdmissionMode,
} from "./types.ts";
import type { AdaptationParams } from "./adaptation.ts";
export { MAX_ADMISSION_COST_OR_LIMIT, MAX_ADMISSION_WINDOW_MS };
export interface ValidatedConfig {
mode: AdmissionMode;
minLimit: number;
maxLimit: number;
initialLimit: number;
maxQueueCount: number;
maxQueueCost: number;
defaultMaxWaitMs: number;
windowMs: number;
adaptation: AdaptationParams;
maxRequestCost: number;
costConfig: ReturnType<typeof resolveCostConfig>;
}
function requirePositiveInt(
name: string,
value: unknown,
max: number = MAX_ADMISSION_COST_OR_LIMIT
): number {
if (
typeof value !== "number" ||
!Number.isFinite(value) ||
value <= 0 ||
!Number.isSafeInteger(value)
) {
throw new RangeError(`${name} must be a positive safe integer`);
}
if (value > max) {
throw new RangeError(`${name} must be <= ${max}`);
}
return value;
}
function requireUnitInterval(name: string, value: unknown, fallback: number): number {
if (value === undefined) return fallback;
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value > 1) {
throw new RangeError(`${name} must be in (0, 1]`);
}
return value;
}
function requireDecreaseFactor(name: string, value: unknown, fallback: number): number {
if (value === undefined) return fallback;
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value >= 1) {
throw new RangeError(`${name} must be in (0, 1)`);
}
return value;
}
function resolveMode(mode: AdaptiveAdmissionConfig["mode"]): AdmissionMode {
if (mode === undefined) return "shadow";
if (mode !== "off" && mode !== "shadow" && mode !== "enforce") {
throw new RangeError("mode must be off|shadow|enforce");
}
return mode;
}
function resolveAdaptationParams(
input: AdaptiveAdmissionConfig,
minLimit: number,
maxLimit: number,
windowMs: number
): AdaptationParams {
const decreaseFactor = requireDecreaseFactor("decreaseFactor", input.decreaseFactor, 0.8);
const criticalDecreaseFactor = requireDecreaseFactor(
"criticalDecreaseFactor",
input.criticalDecreaseFactor,
0.5
);
const increaseStep =
input.increaseStep === undefined ? 1 : requirePositiveInt("increaseStep", input.increaseStep);
const maxIncreasePerWindow =
input.maxIncreasePerWindow === undefined
? increaseStep
: requirePositiveInt("maxIncreasePerWindow", input.maxIncreasePerWindow);
const shortLatencyAlpha = requireUnitInterval("shortLatencyAlpha", input.shortLatencyAlpha, 0.5);
const longLatencyAlpha = requireUnitInterval("longLatencyAlpha", input.longLatencyAlpha, 0.1);
const highUtilizationThreshold = requireUnitInterval(
"highUtilizationThreshold",
input.highUtilizationThreshold,
0.7
);
const lowUtilizationThreshold = requireUnitInterval(
"lowUtilizationThreshold",
input.lowUtilizationThreshold,
0.3
);
if (criticalDecreaseFactor > decreaseFactor) {
throw new RangeError("criticalDecreaseFactor must be <= decreaseFactor");
}
if (lowUtilizationThreshold >= highUtilizationThreshold) {
throw new RangeError("lowUtilizationThreshold must be < highUtilizationThreshold");
}
if (shortLatencyAlpha <= longLatencyAlpha) {
throw new RangeError("shortLatencyAlpha must be > longLatencyAlpha");
}
return {
minLimit,
maxLimit,
windowMs,
shortLatencyAlpha,
longLatencyAlpha,
increaseStep,
decreaseFactor,
criticalDecreaseFactor,
highUtilizationThreshold,
lowUtilizationThreshold,
latencyGradientThreshold: requireUnitInterval(
"latencyGradientThreshold",
input.latencyGradientThreshold,
0.25
),
maxIncreasePerWindow,
};
}
export function validateConfig(input: AdaptiveAdmissionConfig): ValidatedConfig {
const minLimit = requirePositiveInt("minLimit", input.minLimit);
const maxLimit = requirePositiveInt("maxLimit", input.maxLimit);
if (minLimit > maxLimit) {
throw new RangeError("minLimit must be <= maxLimit");
}
const initialLimit = requirePositiveInt("initialLimit", input.initialLimit);
// Queue count is not multiplied into cost×time products; keep the full safe-integer range.
const maxQueueCount = requirePositiveInt(
"maxQueueCount",
input.maxQueueCount,
Number.MAX_SAFE_INTEGER
);
const maxQueueCost = requirePositiveInt("maxQueueCost", input.maxQueueCost);
const windowMs =
input.windowMs === undefined
? 1000
: requirePositiveInt("windowMs", input.windowMs, MAX_ADMISSION_WINDOW_MS);
const defaultMaxWaitMs =
input.defaultMaxWaitMs === undefined
? 5_000
: requirePositiveInt("defaultMaxWaitMs", input.defaultMaxWaitMs, MAX_ADMISSION_WINDOW_MS);
const costConfig = resolveCostConfig(input.cost);
return {
mode: resolveMode(input.mode),
minLimit,
maxLimit,
initialLimit,
maxQueueCount,
maxQueueCost,
defaultMaxWaitMs,
windowMs,
maxRequestCost: costConfig.maxRequestCost,
costConfig,
adaptation: resolveAdaptationParams(input, minLimit, maxLimit, windowMs),
};
}

View File

@@ -0,0 +1,624 @@
import {
closeAdaptationWindow,
createAdaptationState,
noteLatency,
noteOutcome,
sampleActiveIntegral,
setPressure,
type AdaptationState,
} from "./adaptation.ts";
import { validateConfig, type ValidatedConfig } from "./config.ts";
import { estimateAdmissionCost, normalizeRequestCost } from "./cost.ts";
import { FairCostQueue, type QueueEntry } from "./queue.ts";
import {
MAX_ADMISSION_WINDOW_MS,
createAdmissionRejectError,
type AdaptiveAdmissionConfig,
type AdmissionAcquireResult,
type AdmissionAdmitted,
type AdmissionClock,
type AdmissionLease,
type AdmissionPressure,
type AdmissionRejectCode,
type AdmissionReleaseMeta,
type AdmissionReleaseOutcome,
type AdmissionRequest,
type AdmissionSnapshot,
type ShadowDecision,
} from "./types.ts";
type VirtualDisposition = "active" | "queued" | "rejected" | "none";
const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
/** Snapshot numbers are always finite safe integers; never emit rounded unsafe Number values. */
function saturateSnapshotNumber(value: number): number {
if (!Number.isFinite(value) || value <= 0) return 0;
if (value >= Number.MAX_SAFE_INTEGER) return Number.MAX_SAFE_INTEGER;
return Math.floor(value);
}
function bigintToSnapshotNumber(value: bigint): number {
if (value <= 0n) return 0;
if (value >= MAX_SAFE_BIGINT) return Number.MAX_SAFE_INTEGER;
return Number(value);
}
function addSaturated(total: number, delta: number): number {
if (delta <= 0) return saturateSnapshotNumber(total);
if (total >= Number.MAX_SAFE_INTEGER - delta) return Number.MAX_SAFE_INTEGER;
return total + delta;
}
interface ActiveLeaseRecord {
id: string;
cost: number;
released: boolean;
admittedAtMs: number;
virtualDisposition: VirtualDisposition;
}
interface QueuedPayload {
resolve: (value: AdmissionAdmitted) => void;
reject: (err: Error) => void;
signal?: AbortSignal;
onAbort?: () => void;
}
let leaseSeq = 0;
function nextId(prefix: string): string {
leaseSeq += 1;
return `${prefix}-${leaseSeq}`;
}
function defaultClock(): AdmissionClock {
return {
now: () => Date.now(),
setTimer: (fn, delayMs) => {
const handle = setTimeout(fn, delayMs);
// Window/deadline timers must not pin the event loop open when idle.
if (typeof handle.unref === "function") handle.unref();
return handle;
},
clearTimer: (id) => clearTimeout(id as ReturnType<typeof setTimeout>),
};
}
/**
* Dependency-injected weighted adaptive admission controller.
* Pure in-process core: no env/settings/route wiring.
*/
export class AdaptiveAdmissionController {
private config: ValidatedConfig;
private readonly clock: AdmissionClock;
private adaptation: AdaptationState;
private queue: FairCostQueue<QueuedPayload>;
private virtualQueue: FairCostQueue<{ recordId: string }>;
private readonly active = new Map<string, ActiveLeaseRecord>();
private activeCost = 0n;
private virtualActiveCost = 0;
private virtualActiveCount = 0;
private lastSampleMs: number;
private windowTimer: unknown = undefined;
private shutDown = false;
private admittedCount = 0;
private rejectedCount = 0;
private wouldAdmitCount = 0;
private wouldQueueCount = 0;
private wouldRejectCount = 0;
constructor(config: AdaptiveAdmissionConfig, clock?: Partial<AdmissionClock>) {
this.config = validateConfig(config);
this.clock = {
now: clock?.now ?? defaultClock().now,
setTimer: clock?.setTimer ?? defaultClock().setTimer,
clearTimer: clock?.clearTimer ?? defaultClock().clearTimer,
};
const now = this.clock.now();
this.adaptation = createAdaptationState(
this.config.initialLimit,
this.config.minLimit,
this.config.maxLimit,
now
);
this.queue = new FairCostQueue(this.config.maxQueueCount, this.config.maxQueueCost);
this.virtualQueue = new FairCostQueue(this.config.maxQueueCount, this.config.maxQueueCost);
this.lastSampleMs = now;
this.armWindowTimer();
}
updateConfig(config: AdaptiveAdmissionConfig): void {
const next = validateConfig(config);
this.sampleIntegral();
this.config = next;
this.adaptation.currentLimit = Math.min(
next.maxLimit,
Math.max(next.minLimit, this.adaptation.currentLimit)
);
this.adaptation.windowStartMs = this.clock.now();
this.adaptation.windowActiveCostIntegral = 0;
this.adaptation.windowCompleted = 0;
this.adaptation.windowLatencySamples = 0;
this.adaptation.freezeGrowth = false;
this.adaptation.criticalDecreaseConsumed = false;
this.adaptation.pressure = "normal";
this.lastSampleMs = this.clock.now();
const drained = this.queue.drain();
this.queue = new FairCostQueue(next.maxQueueCount, next.maxQueueCost);
for (const entry of drained) {
if (next.mode !== "enforce") {
this.clearEntryTimer(entry);
this.detachAbort(entry);
entry.payload.resolve(this.admit(entry.cost));
continue;
}
// Cost above the new enforce limit must fail closed immediately, never strand until deadline.
if (entry.cost > this.adaptation.currentLimit) {
this.failQueued(
entry,
"ADMISSION_OVERSIZED",
"request cost exceeds max budget after config update"
);
continue;
}
if (!this.queue.enqueue(entry)) {
this.failQueued(entry, "ADMISSION_QUEUE_FULL", "queue capacity reduced");
}
}
this.rebuildVirtualState(next.mode === "shadow");
this.armWindowTimer();
if (next.mode === "enforce") {
this.dispatch();
}
}
snapshot(): AdmissionSnapshot {
this.sampleIntegral();
return {
mode: this.config.mode,
currentLimit: this.adaptation.currentLimit,
minLimit: this.config.minLimit,
maxLimit: this.config.maxLimit,
activeCost: bigintToSnapshotNumber(this.activeCost),
activeCount: saturateSnapshotNumber(this.active.size),
queuedCost: saturateSnapshotNumber(this.queue.totalCost),
queuedCount: saturateSnapshotNumber(this.queue.size),
virtualActiveCost: saturateSnapshotNumber(this.virtualActiveCost),
virtualActiveCount: saturateSnapshotNumber(this.virtualActiveCount),
virtualQueuedCost: saturateSnapshotNumber(this.virtualQueue.totalCost),
virtualQueuedCount: saturateSnapshotNumber(this.virtualQueue.size),
admittedCount: saturateSnapshotNumber(this.admittedCount),
rejectedCount: saturateSnapshotNumber(this.rejectedCount),
wouldAdmitCount: saturateSnapshotNumber(this.wouldAdmitCount),
wouldQueueCount: saturateSnapshotNumber(this.wouldQueueCount),
wouldRejectCount: saturateSnapshotNumber(this.wouldRejectCount),
shortLatencyEwma: this.adaptation.shortLatencyEwma,
longLatencyEwma: this.adaptation.longLatencyEwma,
utilization: this.adaptation.utilization,
pressure: this.adaptation.pressure,
shutdown: this.shutDown,
};
}
observePressure(pressure: AdmissionPressure): void {
setPressure(this.adaptation, pressure);
if (pressure === "critical") {
// Immediate fast decrease once per window; window close must not re-apply it.
if (!this.adaptation.criticalDecreaseConsumed) {
this.adaptation.currentLimit = Math.max(
this.config.minLimit,
Math.floor(this.adaptation.currentLimit * this.config.adaptation.criticalDecreaseFactor)
);
this.adaptation.criticalDecreaseConsumed = true;
this.dispatch();
this.dispatchVirtual();
}
}
}
/** Deterministic window tick for tests / injected clocks. */
tick(): void {
this.sampleIntegral();
closeAdaptationWindow(this.adaptation, this.config.adaptation, this.clock.now());
// Real queue first, then virtual: raised limits must promote shadow-queued work
// before newer arrivals are classified against the updated budget.
this.dispatch();
this.dispatchVirtual();
}
async acquire(request: AdmissionRequest): Promise<AdmissionAcquireResult> {
if (this.shutDown) {
return this.reject("ADMISSION_SHUTDOWN", "admission controller is shut down");
}
if (request.signal?.aborted) {
return this.reject("ADMISSION_ABORTED", "request aborted before acquire");
}
if (request.pressure) setPressure(this.adaptation, request.pressure);
const cost = this.resolveCost(request);
const mode = this.config.mode;
if (mode === "off") {
return this.admitVirtual(cost);
}
const limit = this.adaptation.currentLimit;
if (mode === "shadow") {
return this.acquireShadow(request, cost, limit);
}
// enforce
if (cost > limit) {
return this.reject("ADMISSION_OVERSIZED", "request cost exceeds max budget");
}
// Once work is queued, every newer request joins the same fair queue even if it
// currently fits. This makes bounded bypass accounting effective and prevents
// direct arrivals from indefinitely jumping an older reserved weighted request.
if (this.queue.size === 0 && this.activeCost + BigInt(cost) <= BigInt(limit)) {
return this.admit(cost);
}
if (!this.queue.canAccept(cost)) {
return this.reject("ADMISSION_QUEUE_FULL", "admission queue is full");
}
return this.enqueue(request, cost);
}
shutdown(): void {
if (this.shutDown) return;
this.shutDown = true;
if (this.windowTimer !== undefined) {
this.clock.clearTimer(this.windowTimer);
this.windowTimer = undefined;
}
const drained = this.queue.drain();
for (const entry of drained) {
this.clearEntryTimer(entry);
this.detachAbort(entry);
entry.payload.reject(
createAdmissionRejectError("ADMISSION_SHUTDOWN", "admission controller shut down")
);
this.rejectedCount += 1;
}
}
private resolveCost(request: AdmissionRequest): number {
if (request.cost !== undefined) {
return normalizeRequestCost(request.cost, this.config.maxRequestCost);
}
if (request.features) {
return estimateAdmissionCost(request.features, this.config.costConfig);
}
return 1;
}
private acquireShadow(request: AdmissionRequest, cost: number, limit: number): AdmissionAdmitted {
let decision: ShadowDecision;
let disposition: VirtualDisposition;
if (cost > limit || !Number.isSafeInteger(cost)) {
decision = "would-reject";
disposition = "rejected";
this.wouldRejectCount += 1;
} else if (this.virtualActiveCost + cost <= limit) {
decision = "would-admit";
disposition = "active";
this.virtualActiveCost = addSaturated(this.virtualActiveCost, cost);
this.virtualActiveCount = addSaturated(this.virtualActiveCount, 1);
this.wouldAdmitCount = addSaturated(this.wouldAdmitCount, 1);
} else if (this.virtualQueue.canAccept(cost)) {
decision = "would-queue";
disposition = "queued";
this.wouldQueueCount += 1;
} else {
decision = "would-reject";
disposition = "rejected";
this.wouldRejectCount += 1;
}
const admitted = this.admit(cost, disposition);
if (disposition === "queued") {
this.virtualQueue.enqueue({
id: admitted.lease.id,
tenantKey: request.tenantKey || "_default",
cost,
enqueuedAtMs: this.clock.now(),
deadlineMs: Number.MAX_SAFE_INTEGER,
payload: { recordId: admitted.lease.id },
});
}
return { ...admitted, shadowDecision: decision };
}
private admitVirtual(cost: number): AdmissionAdmitted {
// Mode off: no accounting.
const id = nextId("lease");
const lease: AdmissionLease = {
id,
cost,
get released() {
return true;
},
release: () => {
/* no-op */
},
};
this.admittedCount += 1;
return { status: "admitted", lease };
}
private admit(cost: number, virtualDisposition: VirtualDisposition = "none"): AdmissionAdmitted {
this.sampleIntegral();
const id = nextId("lease");
const record: ActiveLeaseRecord = {
id,
cost,
released: false,
admittedAtMs: this.clock.now(),
virtualDisposition,
};
this.active.set(id, record);
this.activeCost += BigInt(cost);
this.admittedCount += 1;
const controller = this;
const lease: AdmissionLease = {
id,
cost,
get released() {
return record.released;
},
release(outcome: AdmissionReleaseOutcome = "success", meta?: AdmissionReleaseMeta) {
controller.releaseLease(record, outcome, meta);
},
};
return { status: "admitted", lease };
}
private releaseLease(
record: ActiveLeaseRecord,
outcome: AdmissionReleaseOutcome,
meta?: AdmissionReleaseMeta
): void {
if (record.released) return;
record.released = true;
// Sample while the lease still contributes to activeCost so utilization EWMA sees load.
this.sampleIntegral();
if (this.active.has(record.id)) {
this.active.delete(record.id);
this.activeCost -= BigInt(record.cost);
}
const latency =
meta?.latencyMs !== undefined
? meta.latencyMs
: Math.max(0, this.clock.now() - record.admittedAtMs);
noteLatency(this.adaptation, latency, this.config.adaptation);
noteOutcome(this.adaptation, outcome);
this.adaptation.windowCompleted += 1;
if (meta?.pressure) setPressure(this.adaptation, meta.pressure);
this.releaseVirtual(record);
this.dispatch();
}
private enqueue(request: AdmissionRequest, cost: number): AdmissionAcquireResult {
const id = nextId("q");
const maxWait = normalizeRequestCost(
request.maxWaitMs ?? this.config.defaultMaxWaitMs,
MAX_ADMISSION_WINDOW_MS
);
const now = this.clock.now();
const deadlineMs = Math.min(Number.MAX_SAFE_INTEGER, now + maxWait);
let settle: {
resolve: (v: AdmissionAdmitted) => void;
reject: (e: Error) => void;
};
const promise = new Promise<AdmissionAdmitted>((resolve, reject) => {
settle = { resolve, reject };
});
const entry: QueueEntry<QueuedPayload> = {
id,
tenantKey: request.tenantKey && request.tenantKey.length > 0 ? request.tenantKey : "_default",
cost,
enqueuedAtMs: now,
deadlineMs,
payload: {
resolve: (v) => settle.resolve(v),
reject: (e) => settle.reject(e),
signal: request.signal,
},
};
if (!this.queue.enqueue(entry)) {
return this.reject("ADMISSION_QUEUE_FULL", "admission queue is full");
}
entry.timerId = this.clock.setTimer(
() => {
this.expireEntry(id, "ADMISSION_DEADLINE", "admission wait deadline exceeded");
},
Math.max(0, deadlineMs - now)
);
if (request.signal) {
const onAbort = () => {
this.expireEntry(id, "ADMISSION_ABORTED", "request aborted while queued");
};
entry.payload.onAbort = onAbort;
request.signal.addEventListener("abort", onAbort, { once: true });
}
// Capacity may have freed between check and enqueue in concurrent hosts; try dispatch.
this.dispatch();
return { status: "queued", promise };
}
private expireEntry(id: string, code: AdmissionRejectCode, message: string): void {
const entry = this.queue.removeById(id);
if (!entry) return;
this.clearEntryTimer(entry);
this.detachAbort(entry);
entry.payload.reject(createAdmissionRejectError(code, message));
this.rejectedCount += 1;
// Resume enforce dispatch so a now-fitting successor is not stranded until
// unrelated activity. dispatch() is a no-op after shutdown / non-enforce.
this.dispatch();
}
private failQueued(
entry: QueueEntry<QueuedPayload>,
code: AdmissionRejectCode,
message: string
): void {
this.clearEntryTimer(entry);
this.detachAbort(entry);
entry.payload.reject(createAdmissionRejectError(code, message));
this.rejectedCount += 1;
}
private dispatch(): void {
if (this.shutDown || this.config.mode !== "enforce") return;
while (this.queue.size > 0) {
const limit = this.adaptation.currentLimit;
const available = BigInt(limit) - this.activeCost;
if (available <= 0n) return;
const entry = this.queue.dequeue(Number(available));
if (!entry) return;
this.clearEntryTimer(entry);
this.detachAbort(entry);
if (entry.payload.signal?.aborted) {
entry.payload.reject(
createAdmissionRejectError("ADMISSION_ABORTED", "request aborted while queued")
);
this.rejectedCount += 1;
continue;
}
if (this.clock.now() >= entry.deadlineMs) {
entry.payload.reject(
createAdmissionRejectError("ADMISSION_DEADLINE", "admission wait deadline exceeded")
);
this.rejectedCount += 1;
continue;
}
entry.payload.resolve(this.admit(entry.cost));
}
}
private releaseVirtual(record: ActiveLeaseRecord): void {
if (record.virtualDisposition === "active") {
this.virtualActiveCost -= record.cost;
this.virtualActiveCount -= 1;
} else if (record.virtualDisposition === "queued") {
this.virtualQueue.removeById(record.id);
}
record.virtualDisposition = "none";
this.dispatchVirtual();
}
private dispatchVirtual(): void {
while (this.virtualQueue.size > 0) {
const available = this.adaptation.currentLimit - this.virtualActiveCost;
if (available <= 0) return;
const entry = this.virtualQueue.dequeue(available);
if (!entry) return;
const record = this.active.get(entry.payload.recordId);
if (!record || record.released) continue;
record.virtualDisposition = "active";
this.virtualActiveCost = addSaturated(this.virtualActiveCost, record.cost);
this.virtualActiveCount = addSaturated(this.virtualActiveCount, 1);
}
}
private rebuildVirtualState(enable: boolean): void {
this.virtualQueue = new FairCostQueue(this.config.maxQueueCount, this.config.maxQueueCost);
this.virtualActiveCost = 0;
this.virtualActiveCount = 0;
for (const record of this.active.values()) record.virtualDisposition = "none";
if (!enable) return;
for (const record of this.active.values()) {
// Individually oversized work is virtual-rejected, never virtually queued.
if (record.cost > this.adaptation.currentLimit) {
record.virtualDisposition = "rejected";
continue;
}
if (record.cost <= this.adaptation.currentLimit - this.virtualActiveCost) {
record.virtualDisposition = "active";
this.virtualActiveCost = addSaturated(this.virtualActiveCost, record.cost);
this.virtualActiveCount = addSaturated(this.virtualActiveCount, 1);
} else if (
this.virtualQueue.enqueue({
id: record.id,
tenantKey: "_existing",
cost: record.cost,
enqueuedAtMs: record.admittedAtMs,
deadlineMs: Number.MAX_SAFE_INTEGER,
payload: { recordId: record.id },
})
) {
record.virtualDisposition = "queued";
} else {
record.virtualDisposition = "rejected";
}
}
}
private reject(code: AdmissionRejectCode, message: string): AdmissionAcquireResult {
this.rejectedCount += 1;
return { status: "rejected", code, message };
}
private clearEntryTimer(entry: QueueEntry<QueuedPayload>): void {
if (entry.timerId !== undefined) {
this.clock.clearTimer(entry.timerId);
entry.timerId = undefined;
}
}
private detachAbort(entry: QueueEntry<QueuedPayload>): void {
if (entry.payload.signal && entry.payload.onAbort) {
entry.payload.signal.removeEventListener("abort", entry.payload.onAbort);
entry.payload.onAbort = undefined;
}
}
private sampleIntegral(): void {
const now = this.clock.now();
const dt = now - this.lastSampleMs;
if (dt > 0) {
// Cap at currentLimit before Number conversion so shadow oversubscription never
// feeds an unsafe rounded activeCost into the utilization integral.
const limit = this.adaptation.currentLimit;
const activeForIntegral = this.activeCost >= BigInt(limit) ? limit : Number(this.activeCost);
sampleActiveIntegral(this.adaptation, activeForIntegral, dt);
this.lastSampleMs = now;
}
}
private armWindowTimer(): void {
if (this.windowTimer !== undefined) {
this.clock.clearTimer(this.windowTimer);
this.windowTimer = undefined;
}
if (this.shutDown || this.config.mode === "off") return;
const tick = () => {
this.tick();
if (!this.shutDown && this.config.mode !== "off") {
this.windowTimer = this.clock.setTimer(tick, this.config.windowMs);
}
};
this.windowTimer = this.clock.setTimer(tick, this.config.windowMs);
}
}

View File

@@ -0,0 +1,107 @@
import {
MAX_ADMISSION_COST_OR_LIMIT,
type AdmissionCostConfig,
type AdmissionCostFeatures,
} from "./types.ts";
export { MAX_ADMISSION_COST_OR_LIMIT };
export const DEFAULT_ADMISSION_COST_CONFIG: AdmissionCostConfig = Object.freeze({
baseCost: 1,
bodyBytesPerUnit: 16_384,
tokensPerUnit: 1_024,
messagesPerUnit: 32,
toolsPerUnit: 8,
fanoutPerUnit: 1,
streamingClassCost: 1,
nonStreamingClassCost: 2,
maxRequestCost: 1_000,
});
function finiteNonNegative(value: unknown): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return 0;
return Math.min(value, Number.MAX_SAFE_INTEGER);
}
function requirePositiveSafeInteger(
name: string,
value: unknown,
max: number = MAX_ADMISSION_COST_OR_LIMIT
): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
throw new RangeError(`${name} must be a positive safe integer`);
}
if (value > max) {
throw new RangeError(`${name} must be <= ${max}`);
}
return value;
}
const COST_CONFIG_KEYS = [
"baseCost",
"bodyBytesPerUnit",
"tokensPerUnit",
"messagesPerUnit",
"toolsPerUnit",
"fanoutPerUnit",
"streamingClassCost",
"nonStreamingClassCost",
"maxRequestCost",
] as const satisfies ReadonlyArray<keyof AdmissionCostConfig>;
/** Merge cost quanta after strictly validating every supplied value. */
export function resolveCostConfig(partial?: Partial<AdmissionCostConfig>): AdmissionCostConfig {
const d = DEFAULT_ADMISSION_COST_CONFIG;
const resolved = {} as AdmissionCostConfig;
for (const key of COST_CONFIG_KEYS) {
resolved[key] = requirePositiveSafeInteger(key, partial?.[key] ?? d[key]);
}
return resolved;
}
function unitsFrom(amount: number, quantum: number): number {
return amount <= 0 ? 0 : Math.ceil(amount / quantum);
}
function addBounded(total: number, contribution: number, maximum: number): number {
if (contribution >= maximum - total) return maximum;
return total + contribution;
}
/** Pure bounded cost estimator from transparent positive safe-integer quanta. */
export function estimateAdmissionCost(
features: AdmissionCostFeatures,
config?: Partial<AdmissionCostConfig>
): number {
const cfg = resolveCostConfig(config);
const body = finiteNonNegative(features?.bodyBytes);
const tokens = finiteNonNegative(features?.estimatedInputTokens);
const messages = finiteNonNegative(features?.messageCount);
const tools = finiteNonNegative(features?.toolCount);
const fanout = Math.max(1, finiteNonNegative(features?.requestedFanout));
const contributions = [
unitsFrom(body, cfg.bodyBytesPerUnit),
unitsFrom(tokens, cfg.tokensPerUnit),
unitsFrom(messages, cfg.messagesPerUnit),
unitsFrom(tools, cfg.toolsPerUnit),
unitsFrom(fanout, cfg.fanoutPerUnit),
features?.streaming !== false ? cfg.streamingClassCost : cfg.nonStreamingClassCost,
];
let total = Math.min(cfg.baseCost, cfg.maxRequestCost);
for (const contribution of contributions) {
total = addBounded(total, contribution, cfg.maxRequestCost);
if (total === cfg.maxRequestCost) break;
}
return total;
}
/** Validate and bound a caller-supplied request cost. */
export function normalizeRequestCost(
cost: unknown,
maxRequestCost: number = DEFAULT_ADMISSION_COST_CONFIG.maxRequestCost
): number {
const max = requirePositiveSafeInteger("maxRequestCost", maxRequestCost);
const value = requirePositiveSafeInteger("request cost", cost);
return Math.min(value, max);
}

View File

@@ -0,0 +1,37 @@
/**
* Pure weighted adaptive admission-control core.
* No route, settings, or environment wiring in this module surface.
*/
export {
DEFAULT_ADMISSION_COST_CONFIG,
estimateAdmissionCost,
normalizeRequestCost,
resolveCostConfig,
} from "./cost.ts";
export { AdaptiveAdmissionController } from "./controller.ts";
export {
MAX_ADMISSION_COST_OR_LIMIT,
MAX_ADMISSION_WINDOW_MS,
createAdmissionRejectError,
type AdaptiveAdmissionConfig,
type AdmissionAcquireResult,
type AdmissionAdmitted,
type AdmissionClock,
type AdmissionCostConfig,
type AdmissionCostFeatures,
type AdmissionLease,
type AdmissionMode,
type AdmissionPressure,
type AdmissionQueued,
type AdmissionRejectCode,
type AdmissionRejectError,
type AdmissionRejected,
type AdmissionReleaseMeta,
type AdmissionReleaseOutcome,
type AdmissionRequest,
type AdmissionSnapshot,
type ShadowDecision,
} from "./types.ts";

View File

@@ -0,0 +1,194 @@
/**
* Bounded multi-tenant fair queue (round-robin across tenant buckets).
* Count + total cost caps; no unbounded arrays of timers beyond one per entry.
*/
/**
* After this many pass-overs while unfittable, reserve capacity for the aged head
* instead of indefinitely admitting smaller work from other tenants.
*/
const MAX_UNFITTABLE_SKIPS = 2;
export interface QueueEntry<T> {
id: string;
tenantKey: string;
cost: number;
enqueuedAtMs: number;
deadlineMs: number;
payload: T;
timerId?: unknown;
/** Times this head was skipped because it did not fit available cost. */
skipCount?: number;
}
export interface FairQueueSnapshot {
count: number;
cost: number;
}
export class FairCostQueue<T> {
private readonly buckets = new Map<string, QueueEntry<T>[]>();
private readonly order: string[] = [];
private cursor = 0;
private count = 0;
private cost = 0;
constructor(
readonly maxCount: number,
readonly maxCost: number
) {}
get size(): number {
return this.count;
}
get totalCost(): number {
return this.cost;
}
snapshot(): FairQueueSnapshot {
return { count: this.count, cost: this.cost };
}
canAccept(entryCost: number): boolean {
if (!Number.isSafeInteger(entryCost) || entryCost <= 0) return false;
if (this.count >= this.maxCount) return false;
if (entryCost > this.maxCost - this.cost) return false;
return true;
}
enqueue(entry: QueueEntry<T>): boolean {
if (!this.canAccept(entry.cost)) return false;
let bucket = this.buckets.get(entry.tenantKey);
if (!bucket) {
bucket = [];
this.buckets.set(entry.tenantKey, bucket);
this.order.push(entry.tenantKey);
}
bucket.push(entry);
this.count += 1;
this.cost += entry.cost;
return true;
}
/**
* Round-robin dequeue, optionally skipping tenant heads that do not fit available cost.
* After MAX_UNFITTABLE_SKIPS actual pass-overs, an unfittable head reserves capacity:
* smaller work is not admitted ahead of it until it fits, is removed, or capacity rises.
*/
dequeue(maxCost = Number.MAX_SAFE_INTEGER): QueueEntry<T> | undefined {
if (this.count === 0) return undefined;
const n = this.order.length;
// Bounded anti-starvation: prefer the oldest aged unfittable head once reserved.
let reserved: { idx: number; entry: QueueEntry<T> } | undefined;
for (let i = 0; i < n; i++) {
const idx = (this.cursor + i) % n;
const tenant = this.order[idx];
const entry = this.buckets.get(tenant)?.[0];
if (!entry) continue;
if ((entry.skipCount ?? 0) >= MAX_UNFITTABLE_SKIPS) {
if (!reserved || entry.enqueuedAtMs < reserved.entry.enqueuedAtMs) {
reserved = { idx, entry };
}
}
}
if (reserved) {
if (reserved.entry.cost > maxCost) return undefined;
return this.takeAt(reserved.idx);
}
const bypassed: QueueEntry<T>[] = [];
for (let i = 0; i < n; i++) {
const idx = (this.cursor + i) % n;
const tenant = this.order[idx];
const bucket = this.buckets.get(tenant);
const entry = bucket?.[0];
if (!entry) continue;
if (entry.cost > maxCost) {
bypassed.push(entry);
continue;
}
// Only an actual smaller admission counts as a pass-over. Merely polling
// with no available capacity must not age a head into reservation.
for (const skipped of bypassed) {
skipped.skipCount = (skipped.skipCount ?? 0) + 1;
}
return this.takeAt(idx);
}
return undefined;
}
private takeAt(idx: number): QueueEntry<T> | undefined {
const tenant = this.order[idx];
const bucket = this.buckets.get(tenant);
const entry = bucket?.[0];
if (!entry) return undefined;
bucket!.shift();
this.count -= 1;
this.cost -= entry.cost;
entry.skipCount = 0;
if (bucket!.length === 0) {
this.buckets.delete(tenant);
this.order.splice(idx, 1);
this.cursor = this.order.length === 0 ? 0 : idx % this.order.length;
} else {
this.cursor = (idx + 1) % this.order.length;
}
return entry;
}
/** Peek next without removing (for oversized-vs-limit checks). */
peek(): QueueEntry<T> | undefined {
if (this.count === 0) return undefined;
const n = this.order.length;
for (let i = 0; i < n; i++) {
const idx = (this.cursor + i) % n;
const tenant = this.order[idx];
const bucket = this.buckets.get(tenant);
if (bucket && bucket.length > 0) return bucket[0];
}
return undefined;
}
removeById(id: string): QueueEntry<T> | undefined {
for (let ti = 0; ti < this.order.length; ti++) {
const tenant = this.order[ti];
const bucket = this.buckets.get(tenant);
if (!bucket) continue;
const idx = bucket.findIndex((e) => e.id === id);
if (idx < 0) continue;
const [entry] = bucket.splice(idx, 1);
this.count -= 1;
this.cost -= entry.cost;
if (bucket.length === 0) {
this.buckets.delete(tenant);
this.order.splice(ti, 1);
if (this.order.length === 0) {
this.cursor = 0;
} else if (ti < this.cursor) {
// Removing a prior bucket shifts the successor into cursor - 1.
this.cursor -= 1;
} else if (this.cursor >= this.order.length) {
// Removed the final bucket at the cursor; wrap to the head.
this.cursor = 0;
}
// ti === cursor: leave cursor so it now points at the logical successor.
// ti > cursor: cursor is unaffected.
}
return entry;
}
return undefined;
}
drain(): QueueEntry<T>[] {
const out: QueueEntry<T>[] = [];
while (true) {
const e = this.dequeue();
if (!e) break;
out.push(e);
}
this.cursor = 0;
return out;
}
}

View File

@@ -0,0 +1,186 @@
/**
* Cheap bounded admission cost features from an already-parsed request body.
* Never re-parses, stringifies, clones, or invokes toJSON.
*/
import { estimateSizeFast } from "../../utils/estimateSize.ts";
import type { AdmissionCostFeatures } from "./types.ts";
export type AdmissionFeatureExtractionContext = {
/** When set, wins over any body/wrapped stream field. */
streaming?: boolean;
};
/**
* Max tools/functions array entries inspected.
* Uninspected tail is charged conservatively so truncation cannot undercharge cost.
*/
export const ADMISSION_TOOL_SCAN_BUDGET = 64;
type FeatureDraft = {
messageCount: number;
toolCount: number;
requestedFanout: number | null;
streaming: boolean | null;
};
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function asArray(value: unknown): unknown[] | null {
return Array.isArray(value) ? value : null;
}
function positiveInt(value: unknown): number | null {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null;
if (!Number.isSafeInteger(value)) {
return Math.min(Number.MAX_SAFE_INTEGER, Math.floor(value));
}
return value;
}
function saturateCount(n: number): number {
if (!Number.isFinite(n) || n <= 0) return 0;
if (!Number.isSafeInteger(n)) {
return Math.min(Number.MAX_SAFE_INTEGER, Math.floor(n));
}
return n;
}
/**
* Count all recognized tool aliases/layers under one shared entry budget.
* If their combined length cannot be inspected completely, saturate before indexed access
* so an unseen alias or wrapped tail cannot undercharge heavier declarations.
*/
function countTools(layers: Array<Record<string, unknown>>): number {
const sources: unknown[][] = [];
const seen = new Set<unknown[]>();
for (const layer of layers) {
for (const value of [layer.tools, layer.functions]) {
const source = asArray(value);
if (!source || seen.has(source)) continue;
seen.add(source);
sources.push(source);
}
}
let entryCount = 0;
for (const source of sources) {
if (source.length > ADMISSION_TOOL_SCAN_BUDGET - entryCount) {
return Number.MAX_SAFE_INTEGER;
}
entryCount += source.length;
}
let total = 0;
for (const source of sources) {
for (let i = 0; i < source.length; i++) {
const entry = source[i];
if (isPlainObject(entry)) {
const declarations = asArray(entry.functionDeclarations);
if (declarations) {
total = Math.min(Number.MAX_SAFE_INTEGER, total + saturateCount(declarations.length));
continue;
}
}
total = Math.min(Number.MAX_SAFE_INTEGER, total + 1);
}
}
return total;
}
function countMessages(layer: Record<string, unknown>): number {
const messages = asArray(layer.messages);
const contents = asArray(layer.contents);
const inputArr = asArray(layer.input);
let count = Math.max(
saturateCount(messages?.length ?? 0),
saturateCount(contents?.length ?? 0),
saturateCount(inputArr?.length ?? 0)
);
// Responses API: non-empty string `input` is one input item.
if (count === 0 && typeof layer.input === "string" && layer.input.length > 0) {
count = 1;
}
return count;
}
function readFanout(layer: Record<string, unknown>): number | null {
const direct =
positiveInt(layer.n) ?? positiveInt(layer.candidateCount) ?? positiveInt(layer.candidate_count);
if (direct != null) return direct;
// Known nested Gemini/Antigravity shape only — no recursive walk.
if (isPlainObject(layer.generationConfig)) {
return (
positiveInt(layer.generationConfig.candidateCount) ??
positiveInt(layer.generationConfig.candidate_count)
);
}
return null;
}
function featureLayers(body: unknown): Array<Record<string, unknown>> {
const top = isPlainObject(body) ? body : null;
const wrapped = top && isPlainObject(top.request) ? top.request : null;
const layers: Array<Record<string, unknown>> = [];
if (top) layers.push(top);
if (wrapped) layers.push(wrapped);
return layers;
}
function absorbLayer(draft: FeatureDraft, layer: Record<string, unknown>): void {
if (draft.messageCount === 0) {
draft.messageCount = countMessages(layer);
}
if (draft.requestedFanout == null) {
draft.requestedFanout = readFanout(layer);
}
if (draft.streaming == null && "stream" in layer) {
draft.streaming = layer.stream === true;
}
}
function resolveStreaming(
draftStreaming: boolean | null,
context?: AdmissionFeatureExtractionContext
): boolean {
if (context && "streaming" in context && context.streaming !== undefined) {
return context.streaming === true;
}
return draftStreaming ?? false;
}
/**
* Inspect top-level fields and one known wrapper (`request`) only.
* Prefer the first non-empty match for each feature family.
*/
export function extractAdmissionCostFeatures(
body: unknown,
context?: AdmissionFeatureExtractionContext
): AdmissionCostFeatures {
const bodyBytes = estimateSizeFast(body);
const layers = featureLayers(body);
const draft: FeatureDraft = {
messageCount: 0,
toolCount: countTools(layers),
requestedFanout: null,
streaming: null,
};
for (const layer of layers) {
absorbLayer(draft, layer);
}
// Conservative token estimate from already-measured body size (no re-walk/stringify).
const estimatedInputTokens =
bodyBytes > 0 ? Math.min(Number.MAX_SAFE_INTEGER, Math.ceil(bodyBytes / 4)) : 0;
return {
bodyBytes,
estimatedInputTokens,
messageCount: draft.messageCount,
toolCount: draft.toolCount,
requestedFanout: draft.requestedFanout ?? 1,
streaming: resolveStreaming(draft.streaming, context),
};
}

View File

@@ -0,0 +1,614 @@
/**
* Process-local adaptive admission runtime facade around the pure controller.
* No HTTP route wiring — suitable for later shared handleChat integration.
*/
import { AdaptiveAdmissionController } from "./controller.ts";
import { validateConfig } from "./config.ts";
import { extractAdmissionCostFeatures } from "./requestFeatures.ts";
import {
type AdaptiveAdmissionConfig,
type AdmissionAcquireResult,
type AdmissionClock,
type AdmissionLease,
type AdmissionMode,
type AdmissionPressure,
type AdmissionRejectCode,
type AdmissionReleaseOutcome,
type AdmissionSnapshot,
type ShadowDecision,
} from "./types.ts";
import { buildErrorBody } from "../../utils/error.ts";
import { CORS_HEADERS } from "../../utils/cors.ts";
import {
checkResourcePressureGuard,
getResourcePressureObservation,
type ResourcePressureGuardResult,
type ResourcePressureObservation,
} from "../../utils/resourcePressure.ts";
import type { PressureReason, PressureSeverity } from "../../utils/resourcePressurePolicy.ts";
export { extractAdmissionCostFeatures } from "./requestFeatures.ts";
export const DEFAULT_ADAPTIVE_ADMISSION_CONFIG: Readonly<AdaptiveAdmissionConfig> = Object.freeze({
mode: "shadow",
minLimit: 8,
initialLimit: 64,
maxLimit: 1000,
maxQueueCount: 128,
maxQueueCost: 2000,
defaultMaxWaitMs: 5_000,
windowMs: 1_000,
});
const RUNTIME_STORE_KEY = Symbol.for("omniroute.adaptiveAdmission.runtime");
type RuntimeStore = {
runtime: AdaptiveAdmissionRuntime | null;
};
type GlobalWithRuntimeStore = typeof globalThis & {
[RUNTIME_STORE_KEY]?: RuntimeStore;
};
function getRuntimeStore(): RuntimeStore {
const globalWithStore = globalThis as GlobalWithRuntimeStore;
let store = globalWithStore[RUNTIME_STORE_KEY];
if (!store) {
store = { runtime: null };
globalWithStore[RUNTIME_STORE_KEY] = store;
}
return store;
}
const ENV_KEYS = {
mode: "ADAPTIVE_ADMISSION_MODE",
minLimit: "ADAPTIVE_ADMISSION_MIN_LIMIT",
initialLimit: "ADAPTIVE_ADMISSION_INITIAL_LIMIT",
maxLimit: "ADAPTIVE_ADMISSION_MAX_LIMIT",
maxQueueCount: "ADAPTIVE_ADMISSION_MAX_QUEUE_COUNT",
maxQueueCost: "ADAPTIVE_ADMISSION_MAX_QUEUE_COST",
defaultMaxWaitMs: "ADAPTIVE_ADMISSION_MAX_WAIT_MS",
windowMs: "ADAPTIVE_ADMISSION_WINDOW_MS",
} as const;
function parsePositiveSafeInt(name: string, raw: string): number {
if (!/^[0-9]+$/.test(raw)) {
throw new RangeError(`${name} must be a positive safe integer`);
}
const value = Number(raw);
if (!Number.isSafeInteger(value) || value <= 0) {
throw new RangeError(`${name} must be a positive safe integer`);
}
return value;
}
/** Strict env → config resolver. Throws clear config errors for direct callers. */
export function resolveAdaptiveAdmissionConfigFromEnv(
env: NodeJS.ProcessEnv | Record<string, string | undefined> = process.env
): AdaptiveAdmissionConfig {
const cfg: AdaptiveAdmissionConfig = { ...DEFAULT_ADAPTIVE_ADMISSION_CONFIG };
const modeRaw = env[ENV_KEYS.mode];
if (modeRaw !== undefined && modeRaw !== "") {
if (modeRaw !== "off" && modeRaw !== "shadow" && modeRaw !== "enforce") {
throw new RangeError(`${ENV_KEYS.mode} must be off|shadow|enforce`);
}
cfg.mode = modeRaw;
}
// Numeric env keys only — typed assignment without index-signature cast (TS2352).
type EnvIntField = Exclude<keyof typeof ENV_KEYS, "mode">;
const intFields = [
"minLimit",
"initialLimit",
"maxLimit",
"maxQueueCount",
"maxQueueCost",
"defaultMaxWaitMs",
"windowMs",
] as const satisfies ReadonlyArray<EnvIntField>;
for (const field of intFields) {
const envName = ENV_KEYS[field];
const raw = env[envName];
if (raw === undefined || raw === "") continue;
cfg[field] = parsePositiveSafeInt(envName, raw);
}
// Shared pure validation — accept exact documented maxima, reject core-invalid configs.
validateConfig(cfg);
return cfg;
}
export type AdaptiveAdmissionAcquireInput = {
/** Opaque fairness key; never exposed in snapshots or client errors. */
tenantKey: string;
/** Already-parsed request body — must not be re-read or stringified for cost. */
body: unknown;
signal?: AbortSignal;
maxWaitMs?: number;
/** Authoritative streaming class; wins body stream inference when set. */
streaming?: boolean;
};
export type AdaptiveAdmissionAdmitted = {
status: "admitted";
mode: AdmissionMode;
lease: AdmissionLease;
admittedAtMs: number;
shadowDecision?: ShadowDecision;
};
export type AdaptiveAdmissionRejected = {
status: "rejected";
code: string;
response: Response;
};
export type AdaptiveAdmissionAcquireResult = AdaptiveAdmissionAdmitted | AdaptiveAdmissionRejected;
export type AdaptiveAdmissionPublicSnapshot = AdmissionSnapshot & {
resourceSeverity: PressureSeverity;
resourceReason: PressureReason;
resourceObservedAtMs: number;
pressureGuardRejectCount: number;
};
export type AdaptiveAdmissionLifecycleOptions = {
admittedAtMs: number;
signal?: AbortSignal;
nowMs?: () => number;
};
export type AdaptiveAdmissionRuntimeOptions = {
config?: AdaptiveAdmissionConfig;
env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
clock?: Partial<AdmissionClock>;
checkResourcePressure?: () => ResourcePressureGuardResult | null;
getResourcePressureObservation?: () => ResourcePressureObservation;
/** Test seam: observe pressure values fed into the controller after dedupe. */
onPressureObserved?: (pressure: AdmissionPressure) => void;
warn?: (message: string) => void;
nowMs?: () => number;
};
/** Non-success release outcomes callers must choose explicitly for handler failures. */
export type AdaptiveAdmissionFailureOutcome = Exclude<AdmissionReleaseOutcome, "success">;
export type AdaptiveAdmissionRuntime = {
acquire(input: AdaptiveAdmissionAcquireInput): Promise<AdaptiveAdmissionAcquireResult>;
snapshot(): AdaptiveAdmissionPublicSnapshot;
dispose(): void;
/**
* Release an admitted lease after a handler failure before any HTTP response exists.
* Callers must supply the concrete non-success outcome — never defaults to local_reject.
*/
releaseHandlerFailure(
lease: AdmissionLease,
outcome: AdaptiveAdmissionFailureOutcome,
options?: { admittedAtMs?: number; nowMs?: () => number }
): void;
attachResponseLifecycle(
response: Response,
lease: AdmissionLease,
options: AdaptiveAdmissionLifecycleOptions
): Response;
};
type RejectHttpMapping = {
status: number;
code: string;
message: string;
retryAfter?: string;
};
const REJECT_MAP: Record<AdmissionRejectCode, RejectHttpMapping> = {
ADMISSION_ABORTED: {
status: 499,
code: "admission_aborted",
message: "Request aborted",
},
ADMISSION_OVERSIZED: {
status: 503,
code: "admission_oversized",
message: "Request too large for current capacity",
},
ADMISSION_QUEUE_FULL: {
status: 503,
code: "admission_queue_full",
message: "Service temporarily unavailable",
retryAfter: "1",
},
ADMISSION_DEADLINE: {
status: 503,
code: "admission_deadline",
message: "Service temporarily unavailable",
retryAfter: "1",
},
ADMISSION_SHUTDOWN: {
status: 503,
code: "admission_shutdown",
message: "Service temporarily unavailable",
},
ADMISSION_UNAVAILABLE: {
status: 503,
code: "admission_unavailable",
message: "Service temporarily unavailable",
retryAfter: "1",
},
};
function isAdmissionRejectError(
err: unknown
): err is { code: AdmissionRejectCode; name: string; message: string } {
return (
!!err &&
typeof err === "object" &&
(err as { name?: string }).name === "AdmissionRejectError" &&
typeof (err as { code?: unknown }).code === "string"
);
}
function buildAdmissionRejectResponse(code: AdmissionRejectCode): AdaptiveAdmissionRejected {
const mapping = REJECT_MAP[code] ?? REJECT_MAP.ADMISSION_UNAVAILABLE;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...CORS_HEADERS,
};
if (mapping.retryAfter) headers["Retry-After"] = mapping.retryAfter;
const body = buildErrorBody(mapping.status, mapping.message, undefined, {
type: mapping.status === 499 ? "client_disconnected" : "server_error",
code: mapping.code,
});
return {
status: "rejected",
code: mapping.code,
response: new Response(JSON.stringify(body), {
status: mapping.status,
headers,
}),
};
}
function observationIdentity(state: ResourcePressureObservation["state"]): string {
return `${state.observedAtMs}|${state.severity}|${state.reason}`;
}
function toAdmissionPressure(severity: PressureSeverity): AdmissionPressure {
if (severity === "critical") return "critical";
if (severity === "high") return "high";
return "normal";
}
function isSseResponse(response: Response): boolean {
const contentType = response.headers.get("content-type") ?? "";
return contentType.toLowerCase().includes("text/event-stream");
}
function releaseOnce(
lease: AdmissionLease,
outcome: AdmissionReleaseOutcome,
admittedAtMs: number | undefined,
nowMs: () => number
): void {
if (lease.released) return;
const latencyMs = admittedAtMs === undefined ? undefined : Math.max(0, nowMs() - admittedAtMs);
lease.release(outcome, latencyMs === undefined ? undefined : { latencyMs });
}
/**
* Map HTTP status (+ optional request signal) to admission release outcome.
* Cancellation always wins over status classification.
*/
function classifyHttpOutcome(status: number, signal?: AbortSignal): AdmissionReleaseOutcome {
if (signal?.aborted || status === 499) return "cancelled";
if (status === 408 || status === 504) return "timeout";
if (status >= 500) return "upstream_error";
if (status >= 400) return "local_reject";
// 2xx / 3xx (and rare 1xx) complete successfully from admission's perspective.
return "success";
}
class AdaptiveAdmissionRuntimeImpl implements AdaptiveAdmissionRuntime {
private readonly controller: AdaptiveAdmissionController;
private readonly checkResourcePressure: () => ResourcePressureGuardResult | null;
private readonly getResourcePressureObservation: () => ResourcePressureObservation;
private readonly onPressureObserved?: (pressure: AdmissionPressure) => void;
private readonly nowMs: () => number;
private lastObservationKey: string | null = null;
private lastResource: {
severity: PressureSeverity;
reason: PressureReason;
observedAtMs: number;
} = { severity: "normal", reason: "none", observedAtMs: 0 };
private pressureGuardRejectCount = 0;
private disposed = false;
constructor(options: AdaptiveAdmissionRuntimeOptions, config: AdaptiveAdmissionConfig) {
this.controller = new AdaptiveAdmissionController(config, options.clock);
this.checkResourcePressure = options.checkResourcePressure ?? checkResourcePressureGuard;
this.getResourcePressureObservation =
options.getResourcePressureObservation ?? getResourcePressureObservation;
this.onPressureObserved = options.onPressureObserved;
this.nowMs = options.nowMs ?? options.clock?.now ?? (() => Date.now());
}
async acquire(input: AdaptiveAdmissionAcquireInput): Promise<AdaptiveAdmissionAcquireResult> {
if (this.disposed) {
return buildAdmissionRejectResponse("ADMISSION_SHUTDOWN");
}
// Independent safety fuse first — never acquire provider work on critical guard.
// Still feed pressure observations so the controller learns from critical samples.
let guard: ResourcePressureGuardResult | null = null;
try {
guard = this.checkResourcePressure();
} catch {
// Fail open on sampling/check failures.
}
this.feedFreshPressureObservation();
if (guard) {
this.pressureGuardRejectCount += 1;
return {
status: "rejected",
code: "resource_pressure",
response: guard.response,
};
}
const features = extractAdmissionCostFeatures(
input.body,
input.streaming === undefined ? undefined : { streaming: input.streaming }
);
let result: AdmissionAcquireResult;
try {
result = await this.controller.acquire({
tenantKey: input.tenantKey,
features,
signal: input.signal,
maxWaitMs: input.maxWaitMs,
});
} catch (err) {
if (isAdmissionRejectError(err)) {
return buildAdmissionRejectResponse(err.code);
}
return buildAdmissionRejectResponse("ADMISSION_UNAVAILABLE");
}
if (result.status === "rejected") {
return buildAdmissionRejectResponse(result.code);
}
if (result.status === "queued") {
try {
const admitted = await result.promise;
return {
status: "admitted",
mode: this.controller.snapshot().mode,
lease: admitted.lease,
admittedAtMs: this.nowMs(),
shadowDecision: admitted.shadowDecision,
};
} catch (err) {
if (isAdmissionRejectError(err)) {
return buildAdmissionRejectResponse(err.code);
}
return buildAdmissionRejectResponse("ADMISSION_UNAVAILABLE");
}
}
return {
status: "admitted",
mode: this.controller.snapshot().mode,
lease: result.lease,
admittedAtMs: this.nowMs(),
shadowDecision: result.shadowDecision,
};
}
snapshot(): AdaptiveAdmissionPublicSnapshot {
const core = this.controller.snapshot();
return {
...core,
resourceSeverity: this.lastResource.severity,
resourceReason: this.lastResource.reason,
resourceObservedAtMs: this.lastResource.observedAtMs,
pressureGuardRejectCount: this.pressureGuardRejectCount,
};
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.controller.shutdown();
}
releaseHandlerFailure(
lease: AdmissionLease,
outcome: AdaptiveAdmissionFailureOutcome,
options?: { admittedAtMs?: number; nowMs?: () => number }
): void {
releaseOnce(lease, outcome, options?.admittedAtMs, options?.nowMs ?? this.nowMs);
}
attachResponseLifecycle(
response: Response,
lease: AdmissionLease,
options: AdaptiveAdmissionLifecycleOptions
): Response {
const nowMs = options.nowMs ?? this.nowMs;
const admittedAtMs = options.admittedAtMs;
if (!response.body || !isSseResponse(response)) {
releaseOnce(lease, classifyHttpOutcome(response.status, options.signal), admittedAtMs, nowMs);
return response;
}
const upstream = response.body;
const reader = upstream.getReader();
let settled = false;
let readerCancelled = false;
const settle = (outcome: AdmissionReleaseOutcome): void => {
if (settled) return;
settled = true;
releaseOnce(lease, outcome, admittedAtMs, nowMs);
};
const cancelReader = (reason?: unknown): void => {
if (readerCancelled) return;
readerCancelled = true;
void reader.cancel(reason).catch(() => {
/* ignore cancel races */
});
};
const onAbort = (): void => {
cancelReader(options.signal?.reason);
settle("cancelled");
};
if (options.signal) {
if (options.signal.aborted) {
onAbort();
} else {
options.signal.addEventListener("abort", onAbort, { once: true });
}
}
const detachAbort = (): void => {
options.signal?.removeEventListener("abort", onAbort);
};
const stream = new ReadableStream<Uint8Array>({
async pull(controller) {
if (settled) {
controller.close();
return;
}
try {
const { done, value } = await reader.read();
if (done) {
detachAbort();
settle(classifyHttpOutcome(response.status, options.signal));
controller.close();
return;
}
controller.enqueue(value);
} catch (err) {
detachAbort();
settle(options.signal?.aborted ? "cancelled" : "upstream_error");
controller.error(err);
}
},
cancel(reason) {
detachAbort();
cancelReader(reason);
settle("cancelled");
},
});
return new Response(stream, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
private feedFreshPressureObservation(): void {
try {
const observation = this.getResourcePressureObservation();
const state = observation.state;
this.lastResource = {
severity: state.severity,
reason: state.reason,
observedAtMs: state.observedAtMs,
};
const key = observationIdentity(state);
if (state.observedAtMs <= 0) return;
if (key === this.lastObservationKey) return;
this.lastObservationKey = key;
const pressure = toAdmissionPressure(state.severity);
this.controller.observePressure(pressure);
this.onPressureObserved?.(pressure);
} catch {
// Fail open.
}
}
}
function createRuntimeFromResolvedConfig(
options: AdaptiveAdmissionRuntimeOptions,
config: AdaptiveAdmissionConfig
): AdaptiveAdmissionRuntime {
return new AdaptiveAdmissionRuntimeImpl(options, config);
}
/**
* Create an injected adaptive-admission runtime for tests or process use.
* Invalid explicit `config` still throws (direct callers want fail-fast).
*/
export function createAdaptiveAdmissionRuntime(
options: AdaptiveAdmissionRuntimeOptions = {}
): AdaptiveAdmissionRuntime {
const config =
options.config ??
(options.env
? resolveAdaptiveAdmissionConfigFromEnv(options.env)
: { ...DEFAULT_ADAPTIVE_ADMISSION_CONFIG });
return createRuntimeFromResolvedConfig(options, config);
}
function warnInvalidDefaultConfig(warn: ((message: string) => void) | undefined): void {
const message =
"[adaptiveAdmission] invalid environment configuration; using default shadow admission settings";
if (warn) {
warn(message);
return;
}
console.warn(message);
}
function createDefaultProcessRuntime(
options: AdaptiveAdmissionRuntimeOptions = {}
): AdaptiveAdmissionRuntime {
const warn = options.warn;
try {
const config =
options.config ?? resolveAdaptiveAdmissionConfigFromEnv(options.env ?? process.env);
return createRuntimeFromResolvedConfig(options, config);
} catch {
warnInvalidDefaultConfig(warn);
return createRuntimeFromResolvedConfig(options, {
...DEFAULT_ADAPTIVE_ADMISSION_CONFIG,
});
}
}
/** Call-time process-global runtime (HMR-safe via globalThis symbol store). */
export function getAdaptiveAdmissionRuntime(): AdaptiveAdmissionRuntime {
const store = getRuntimeStore();
if (!store.runtime) {
store.runtime = createDefaultProcessRuntime();
}
return store.runtime;
}
/** Dispose previous controller and replace the process-global runtime. */
export function reloadAdaptiveAdmissionRuntime(
options: AdaptiveAdmissionRuntimeOptions = {}
): AdaptiveAdmissionRuntime {
const store = getRuntimeStore();
store.runtime?.dispose();
store.runtime = createDefaultProcessRuntime(options);
return store.runtime;
}
/** Test isolation: dispose and clear the process-global runtime slot. */
export function resetAdaptiveAdmissionRuntimeForTests(): void {
const store = getRuntimeStore();
store.runtime?.dispose();
store.runtime = null;
}

View File

@@ -0,0 +1,171 @@
/**
* Pure weighted adaptive admission-control types.
* No route/settings wiring — dependency-injected controller seam only.
*/
/**
* Upper bound for adaptation windows and wait deadlines that participate in
* cost×time products (utilization integrals, deadline offsets).
* 24h is far beyond practical control windows while keeping the product domain exact.
*/
export const MAX_ADMISSION_WINDOW_MS = 86_400_000;
/**
* Upper bound for every validated cost, limit, and queue-cost quantum.
* Derived so `MAX_ADMISSION_COST_OR_LIMIT * MAX_ADMISSION_WINDOW_MS` remains a
* safe integer: a full window at the maximum limit integrates to utilization 1.0
* without saturating or rounding Number arithmetic.
*/
export const MAX_ADMISSION_COST_OR_LIMIT = Math.floor(
Number.MAX_SAFE_INTEGER / MAX_ADMISSION_WINDOW_MS
);
export type AdmissionMode = "off" | "shadow" | "enforce";
export type AdmissionPressure = "normal" | "high" | "critical";
/** Local outcome categories. Upstream business errors must not collapse capacity. */
export type AdmissionReleaseOutcome =
"success" | "upstream_error" | "timeout" | "local_reject" | "cancelled";
export type AdmissionRejectCode =
| "ADMISSION_OVERSIZED"
| "ADMISSION_QUEUE_FULL"
| "ADMISSION_DEADLINE"
| "ADMISSION_ABORTED"
| "ADMISSION_SHUTDOWN"
| "ADMISSION_UNAVAILABLE";
export type ShadowDecision = "would-admit" | "would-queue" | "would-reject";
export interface AdmissionCostFeatures {
bodyBytes?: number | null;
estimatedInputTokens?: number | null;
messageCount?: number | null;
toolCount?: number | null;
requestedFanout?: number | null;
streaming?: boolean | null;
}
export interface AdmissionCostConfig {
baseCost: number;
bodyBytesPerUnit: number;
tokensPerUnit: number;
messagesPerUnit: number;
toolsPerUnit: number;
fanoutPerUnit: number;
streamingClassCost: number;
nonStreamingClassCost: number;
maxRequestCost: number;
}
export interface AdaptiveAdmissionConfig {
mode?: AdmissionMode;
minLimit: number;
maxLimit: number;
initialLimit: number;
maxQueueCount: number;
maxQueueCost: number;
defaultMaxWaitMs?: number;
windowMs?: number;
shortLatencyAlpha?: number;
longLatencyAlpha?: number;
increaseStep?: number;
decreaseFactor?: number;
criticalDecreaseFactor?: number;
highUtilizationThreshold?: number;
lowUtilizationThreshold?: number;
latencyGradientThreshold?: number;
maxIncreasePerWindow?: number;
/** Optional cost quanta override used only when callers pass features instead of cost. */
cost?: Partial<AdmissionCostConfig>;
}
export interface AdmissionRequest {
/** Positive integer cost units. If omitted, `features` + cost config are used. */
cost?: number;
features?: AdmissionCostFeatures;
/** Opaque fairness key; never exposed in snapshots. */
tenantKey?: string;
maxWaitMs?: number;
signal?: AbortSignal;
pressure?: AdmissionPressure;
}
export interface AdmissionReleaseMeta {
latencyMs?: number;
pressure?: AdmissionPressure;
}
export interface AdmissionLease {
readonly id: string;
readonly cost: number;
readonly released: boolean;
release(outcome?: AdmissionReleaseOutcome, meta?: AdmissionReleaseMeta): void;
}
export interface AdmissionAdmitted {
status: "admitted";
lease: AdmissionLease;
shadowDecision?: ShadowDecision;
}
export interface AdmissionQueued {
status: "queued";
promise: Promise<AdmissionAdmitted>;
}
export interface AdmissionRejected {
status: "rejected";
code: AdmissionRejectCode;
message: string;
shadowDecision?: ShadowDecision;
}
export type AdmissionAcquireResult = AdmissionAdmitted | AdmissionQueued | AdmissionRejected;
export interface AdmissionSnapshot {
mode: AdmissionMode;
currentLimit: number;
minLimit: number;
maxLimit: number;
activeCost: number;
activeCount: number;
queuedCost: number;
queuedCount: number;
virtualActiveCost: number;
virtualActiveCount: number;
virtualQueuedCost: number;
virtualQueuedCount: number;
admittedCount: number;
rejectedCount: number;
wouldAdmitCount: number;
wouldQueueCount: number;
wouldRejectCount: number;
shortLatencyEwma: number;
longLatencyEwma: number;
utilization: number;
pressure: AdmissionPressure;
shutdown: boolean;
}
export interface AdmissionClock {
now: () => number;
setTimer: (fn: () => void, delayMs: number) => unknown;
clearTimer: (id: unknown) => void;
}
export interface AdmissionRejectError extends Error {
code: AdmissionRejectCode;
name: "AdmissionRejectError";
}
export function createAdmissionRejectError(
code: AdmissionRejectCode,
message: string
): AdmissionRejectError {
const err = new Error(message) as AdmissionRejectError;
err.name = "AdmissionRejectError";
err.code = code;
return err;
}

View File

@@ -153,6 +153,7 @@ import {
resolveDelayMs,
comboModelNotFoundResponse,
isStreamReadinessFailureErrorBody,
isStreamEarlyEofErrorBody,
isTokenLimitBreachErrorBody,
toRecordedTarget,
getExhaustedTargetSkipReason,
@@ -1511,6 +1512,11 @@ export async function handleComboChat({
const isStreamReadinessFailure =
(result.status === 502 || result.status === 504) &&
isStreamReadinessFailureErrorBody(errorBody);
// An early EOF is an upstream failure, not a readiness probe — the breaker must
// see it even though the transient-retry path below treats both codes alike.
const isStreamEarlyEof =
(result.status === 502 || result.status === 504) &&
isStreamEarlyEofErrorBody(errorBody);
// FIX 5: a local per-API-key token-limit 429 must not cool shared accounts.
const isTokenLimitBreach =
@@ -1713,6 +1719,7 @@ export async function handleComboChat({
if (
shouldRecordProviderBreakerFailure({
isStreamReadinessFailure,
isStreamEarlyEof,
status: result.status,
sameProviderNext,
skipProviderBreaker: fallbackResult.skipProviderBreaker,

View File

@@ -133,7 +133,11 @@ const PROVIDER_BREAKER_FAILURE_STATUSES = new Set([408, 500, 502, 503, 504]);
* failure (#1731 / #2743 gap-d). This is the consumer side of `skipProviderBreaker`:
*
* - Stream-readiness failures (pre-flight zombie/ping probes) never count as provider
* failures — they are a connection-readiness signal, not an upstream outage.
* failures — they are a connection-readiness signal, not an upstream outage. EXCEPT a
* STREAM_EARLY_EOF (`isStreamEarlyEof`): there the upstream returned HTTP 200, opened the
* SSE stream and then hung up without a single non-ping event, which is a genuine upstream
* failure. Excluding it made a provider-wide outage invisible to the breaker — see the
* STREAM_EARLY_EOF section of RESILIENCE_GUIDE.md.
* - Only whole-provider failure statuses (408/500/502/503/504) count. A plain rate-limit
* 429 is deliberately EXCLUDED — it belongs to connection cooldown / model lockout scope
* (a genuine quota/token-limit 429 is handled there), NOT the whole-provider breaker. This
@@ -163,6 +167,10 @@ const PROVIDER_BREAKER_FAILURE_STATUSES = new Set([408, 500, 502, 503, 504]);
*/
export function shouldRecordProviderBreakerFailure(args: {
isStreamReadinessFailure: boolean;
/** True when the failure is specifically a STREAM_EARLY_EOF (upstream hung up after
* HTTP 200). Overrides the `isStreamReadinessFailure` exemption only; every other
* AND-term below still gates the trip. */
isStreamEarlyEof?: boolean;
status: number;
sameProviderNext: boolean;
skipProviderBreaker?: boolean;
@@ -173,7 +181,7 @@ export function shouldRecordProviderBreakerFailure(args: {
isProxyUnreachable?: boolean;
}): boolean {
return (
!args.isStreamReadinessFailure &&
(!args.isStreamReadinessFailure || args.isStreamEarlyEof === true) &&
PROVIDER_BREAKER_FAILURE_STATUSES.has(args.status) &&
(!args.sameProviderNext || args.isProxyUnreachable === true) &&
!args.skipProviderBreaker &&
@@ -186,6 +194,8 @@ const REQUEST_SCOPED_UPSTREAM_ERROR_CODES = new Set([
"context_length_exceeded",
"upstream_empty_response",
"upstream_response_failed",
// Local combo per-target timer (targetTimeoutRunner) — not a connection health signal.
"combo_target_timeout",
]);
/** Request/model-specific failures must not poison provider-wide resilience state. */
@@ -308,6 +318,28 @@ export function isStreamReadinessFailureErrorBody(errorBody: unknown): boolean {
return code === "STREAM_READINESS_TIMEOUT" || code === "STREAM_EARLY_EOF";
}
/**
* A STREAM_EARLY_EOF specifically: the upstream accepted the request (HTTP 200), opened the
* SSE stream, then closed it before emitting a single non-ping event.
*
* This is deliberately NOT the same signal as STREAM_READINESS_TIMEOUT. The readiness probe
* is a pre-flight liveness check on a connection we have not committed to yet, so failing it
* says "this connection looks stale", not "this provider is failing". An early EOF is the
* opposite: the provider took the request and then failed to serve it, which is an upstream
* failure by any reasonable definition.
*
* `isStreamReadinessFailureErrorBody` still covers both codes because the transient-retry and
* semaphore-cooldown paths in combo.ts want identical treatment for both. Only the
* whole-provider circuit breaker needs to tell them apart — see
* `shouldRecordProviderBreakerFailure`.
*/
export function isStreamEarlyEofErrorBody(errorBody: unknown): boolean {
if (!errorBody || typeof errorBody !== "object") return false;
const error = (errorBody as Record<string, unknown>).error;
if (!error || typeof error !== "object") return false;
return (error as Record<string, unknown>).code === "STREAM_EARLY_EOF";
}
/**
* A local per-API-key token-limit breach surfaces as a 429 tagged with
* errorCode "TOKEN_LIMIT_EXCEEDED" (see chatCore.ts Tier 2 early return). This

View File

@@ -1,17 +1,20 @@
/**
* Wrap a single-model dispatch with a per-target timeout that aborts and falls back.
*
* Verbatim extraction of handleComboChat's `handleSingleModelWithTimeout` closure
* (combo.ts). Behavior is byte-identical; the only change is that the closed-over locals
* (`handleSingleModel`, `comboTargetTimeoutMs`, `log`) became explicit factory params.
* Extracted from handleComboChat's `handleSingleModelWithTimeout` closure (combo.ts).
* A locally expired timer aborts that target and returns a typed 504 response so the Combo
* can fall back without treating OmniRoute's own deadline as a provider-connection failure.
* The per-model abort signal still comes from the target (`target.modelAbortSignal`), so
* the outer request signal is intentionally NOT a dependency here.
*
* See _tasks/superpowers/plans/2026-07-03-blocoJ-combo-hotpath-decomposition.md (Task 1).
*/
import { errorResponse } from "../../utils/error.ts";
import { buildErrorBody, errorResponse, sanitizeErrorMessage } from "../../utils/error.ts";
import type { HandleSingleModel, SingleModelTarget, ComboLogger } from "./types.ts";
/** Stable internal classification for OmniRoute's own combo per-target timer. */
export const COMBO_TARGET_TIMEOUT_CODE = "combo_target_timeout";
export function buildTargetTimeoutRunner(deps: {
handleSingleModel: HandleSingleModel;
comboTargetTimeoutMs: number;
@@ -44,11 +47,23 @@ export function buildTargetTimeoutRunner(deps: {
`Model ${modelStr} exceeded ${comboTargetTimeoutMs}ms timeout — falling back`
);
timeoutController.abort(new Error("combo-per-model-timeout"));
// HTTP 504 (not proprietary 524): this is OmniRoute's own per-target timer.
// Typed as combo_target_timeout so request-scoped classification can keep the
// connection eligible for fallback instead of treating it like Cloudflare 524
// or a genuine upstream gateway timeout.
resolve(
new Response(JSON.stringify({ error: { message: `Model ${modelStr} timed out` } }), {
status: 524,
headers: { "Content-Type": "application/json" },
})
new Response(
JSON.stringify(
buildErrorBody(504, sanitizeErrorMessage(`Model ${modelStr} timed out`), undefined, {
type: COMBO_TARGET_TIMEOUT_CODE,
code: COMBO_TARGET_TIMEOUT_CODE,
})
),
{
status: 504,
headers: { "Content-Type": "application/json" },
}
)
);
}, comboTargetTimeoutMs);
});
@@ -72,7 +87,7 @@ export function buildTargetTimeoutRunner(deps: {
return await Promise.race([
handleSingleModel(b, modelStr, targetWithSignal).catch((err) => {
if (timedOut) {
// Inner call rejected because we aborted it. The synthetic 524 from
// Inner call rejected because we aborted it. The synthetic 504 from
// timeoutPromise already wins the race; return an empty response so
// the loser branch resolves cleanly without leaking err.message.
return new Response(null, { status: 599 });

View File

@@ -61,8 +61,8 @@ export function isComboCooldownWaitEligible(
* When the combo is wait-eligible (see isComboCooldownWaitEligible), a single target's
* dispatch can legitimately wait out cooldowns for up to `comboCooldownWait.budgetMs`
* before it resolves — so the per-target timeout must never be shorter than that budget,
* or the wait gets cut off mid-retry and the target times out with a synthetic 524
* (open-sse/services/combo/targetTimeoutRunner.ts) instead of completing the wait. This
* or the wait gets cut off mid-retry and the target times out with a synthetic 504
* (`combo_target_timeout`, open-sse/services/combo/targetTimeoutRunner.ts) instead of completing the wait. This
* only raises the *default* floor; an operator's explicit `targetTimeoutMs` on the combo
* still wins (see resolveComboTargetTimeoutMs).
*/
@@ -99,7 +99,7 @@ const DEFAULT_COMBO_CONFIG = {
retryDelayMs: 2000,
fallbackDelayMs: 0,
concurrencyPerModel: 3, // max simultaneous requests per model (round-robin)
queueTimeoutMs: 30000, // max wait time in semaphore queue (round-robin)
queueTimeoutMs: 120000, // max wait time in semaphore queue (round-robin); raised from 30s for browser-automation providers like gemini-web (#9407)
queueDepth: DEFAULT_COMBO_QUEUE_DEPTH, // pre-cascade semaphore queue depth (round-robin, #3872)
handoffThreshold: 0.85,
handoffModel: "",

View File

@@ -557,20 +557,36 @@ function parseAliasTarget(target: string): ResolvedModelTarget | null {
}
async function resolveModelByProviderInference(modelId: string, extendedContext: boolean) {
if (CODEX_NATIVE_UNPREFIXED_MODELS.has(modelId)) {
return {
provider: "codex",
model: modelId,
extendedContext,
};
}
const [activeProviders, activeSyncedProviders, preferClaudeCodeForUnprefixedClaudeModels] =
await Promise.all([
getActiveProviderSet(),
getActiveSyncedProvidersForModel(modelId),
getPreferClaudeCodeForUnprefixedClaudeModels(),
]);
// Codex-native bare ids prefer the ChatGPT subscription, but the preference is only
// allowed to PREEMPT another provider when a codex connection is actually active.
// Returning "codex" unconditionally (as this did once the set grew past
// `codex-auto-review` to cover gpt-5.5 / the gpt-5.6-sol tiers) hands ids that OpenAI
// also serves to a provider the operator may not have configured: an OpenAI-only
// install fails with "no active credentials for provider: codex" on a model that
// works, and an install whose codex connection is merely *inactive* fails the same way.
// Ids only codex catalogs (e.g. `codex-auto-review`) keep resolving to codex with no
// connection at all — there is no alternative to preempt, and "no codex credentials"
// is the honest error. With codex active the preference still beats OpenAI, and an
// explicit `openai/…` prefix remains the per-request override either way.
if (CODEX_NATIVE_UNPREFIXED_MODELS.has(modelId)) {
const codexNativeAlternatives = (MODEL_TO_PROVIDERS.get(modelId) || []).filter(
(p) => p !== "codex"
);
if (codexNativeAlternatives.length === 0 || activeProviders?.has("codex")) {
return {
provider: "codex",
model: modelId,
extendedContext,
};
}
}
// #FIX: synced catalogs (populated from `/v1/models` per connection) can
// claim ownership of models the provider does not actually serve (e.g. a
// `kiro` upstream briefly advertising `claude-opus-5` before it was

View File

@@ -135,7 +135,17 @@ export function detectFormatFromEndpoint(body, endpointPath = "") {
// Thin wrapper for call sites that only have the full request URL (not the bare endpoint
// path chatCore already threads) — single source of truth stays detectFormatFromEndpoint.
export function detectFormatFromUrl(body, requestUrl) {
return detectFormatFromEndpoint(body, new URL(requestUrl).pathname);
const rawUrl = typeof requestUrl === "string" ? requestUrl : "";
let pathname = rawUrl;
try {
// Supplying a base URL keeps relative client endpoints (for example,
// `/v1/messages`) valid while preserving pathname-only detection.
pathname = new URL(rawUrl || "/", "http://omniroute.local").pathname;
} catch {
// Fall back to the raw value; detectFormatFromEndpoint is intentionally
// safe for unknown or malformed paths.
}
return detectFormatFromEndpoint(body, pathname);
}
// Detect request format from body structure
@@ -193,7 +203,7 @@ export function detectFormat(body) {
if (firstContent?.type === "text" && !body.model?.includes("/")) {
// Could be Claude or OpenAI multimodal
// Check for Claude-specific fields
if (body.system || body.anthropic_version) {
if (body.system || body.anthropic_version || body["anthropic-version"]) {
return "claude";
}
// Check if image format is Claude (source.type) vs OpenAI (image_url.url)
@@ -216,7 +226,7 @@ export function detectFormat(body) {
// If content is string, it's likely OpenAI (Claude also supports this)
// Check for other Claude-specific indicators
if (body.system !== undefined || body.anthropic_version) {
if (body.system !== undefined || body.anthropic_version || body["anthropic-version"]) {
return "claude";
}

View File

@@ -66,6 +66,7 @@ import { getVertexUsage } from "./usage/vertex.ts";
import { getXiaomiMimoUsage } from "./usage/xiaomi-mimo.ts";
import { getXaiUsage } from "./usage/xai.ts";
import { getXaiOauthUsage } from "./usage/xaiOauth.ts";
import { getGrokCliUsage } from "./usage/grokCli.ts";
import { getFirecrawlUsage } from "./usage/firecrawl.ts";
type JsonRecord = Record<string, unknown>;
@@ -116,6 +117,7 @@ export const USAGE_FETCHER_PROVIDERS = [
"xai",
"xai-oauth",
"xao",
"grok-cli",
"vertex",
"vertex-partner",
"codebuddy-cn",
@@ -210,6 +212,8 @@ export async function getUsageForProvider(
case "xai-oauth":
case "xao":
return await getXaiOauthUsage(id || "", accessToken, connection);
case "grok-cli":
return await getGrokCliUsage(accessToken);
case "codebuddy-cn":
return await getCodeBuddyCnUsage(accessToken, apiKey, providerSpecificData);
case "promptql":

View File

@@ -0,0 +1,278 @@
import { z } from "zod";
import { GROK_BUILD_PROXY_BASE_URL, getGrokBuildModelsHeaders } from "../../config/grokBuild.ts";
import {
GROK_BUILD_ADDITIONAL_CREDITS_URL,
type GrokAutoTopUpStatus,
} from "../../../src/shared/utils/grokBilling.ts";
const GROK_BUILD_FETCH_TIMEOUT_MS = 10_000;
const GROK_BUILD_MAX_RESPONSE_BYTES = 256 * 1024;
const optionalNonEmptyString = z
.string()
.trim()
.min(1)
.max(256)
.optional()
.nullable()
.catch(undefined);
const optionalPercent = z.number().finite().min(0).max(100).optional().nullable().catch(undefined);
const centSchema = z
.object({ val: z.number().finite().int().safe().optional() })
.passthrough()
.transform(({ val }) => ({ val: Math.abs(val ?? 0) }));
const userSchema = z
.object({
userId: optionalNonEmptyString,
subscriptionTier: optionalNonEmptyString,
})
.passthrough();
const productUsageSchema = z
.object({
product: z.string().trim().min(1).max(128),
usagePercent: z.number().finite().min(0).max(100),
})
.passthrough();
const productUsageListSchema = z
.array(z.unknown())
.max(100)
.transform((items) =>
items.flatMap((item) => {
const parsed = productUsageSchema.safeParse(item);
return parsed.success ? [parsed.data] : [];
})
);
const currentPeriodSchema = z
.object({
type: optionalNonEmptyString,
start: optionalNonEmptyString,
end: optionalNonEmptyString,
})
.passthrough();
const billingConfigSchema = z
.object({
creditUsagePercent: optionalPercent,
currentPeriod: currentPeriodSchema.optional().nullable().catch(undefined),
productUsage: productUsageListSchema.optional().nullable().catch(undefined),
prepaidBalance: centSchema.optional().nullable().catch(undefined),
})
.passthrough();
const billingSchema = z
.object({
config: billingConfigSchema.optional().nullable().catch(undefined),
})
.passthrough();
const autoTopUpRuleSchema = z
.object({
enabled: z.boolean().optional(),
minBeforeHittingSl: centSchema.optional().nullable().catch(undefined),
topupAmount: centSchema.optional().nullable().catch(undefined),
maxAmountPerMonth: centSchema.optional().nullable().catch(undefined),
})
.passthrough();
const autoTopUpSchema = z
.object({
rule: autoTopUpRuleSchema.optional().nullable().catch(undefined),
})
.passthrough();
type JsonSchema<T> = z.ZodType<T>;
type GrokBuildHeaders = ReturnType<typeof getGrokBuildModelsHeaders>;
function finitePercent(value: number): number {
return Math.max(0, Math.min(100, value));
}
function normalizeProduct(value: string): { key: string; displayName: string } {
const compact = value
.normalize("NFKC")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "");
if (compact === "grokbuild" || compact === "productgrokbuild") {
return { key: "grok_build", displayName: "Grok Build" };
}
const slug = value
.normalize("NFKD")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
return { key: slug || "unknown", displayName: value };
}
function percentageQuota(used: number, resetAt: string | null, displayName?: string) {
const normalizedUsed = finitePercent(used);
const remaining = 100 - normalizedUsed;
return {
...(displayName ? { displayName } : {}),
used: normalizedUsed,
total: 100,
remaining,
remainingPercentage: remaining,
resetAt,
isPercentageOnly: true,
};
}
async function readBoundedJson<T>(response: Response, schema: JsonSchema<T>): Promise<T | null> {
if (!response.ok) return null;
const declaredLength = Number(response.headers.get("content-length"));
if (Number.isFinite(declaredLength) && declaredLength > GROK_BUILD_MAX_RESPONSE_BYTES)
return null;
const reader = response.body?.getReader();
if (!reader) return null;
const chunks: Uint8Array[] = [];
let size = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > GROK_BUILD_MAX_RESPONSE_BYTES) {
await reader.cancel();
return null;
}
chunks.push(value);
}
try {
const bytes = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return schema.parse(JSON.parse(new TextDecoder().decode(bytes)));
} catch {
return null;
}
}
async function fetchGrokBuildJson<T>(
path: string,
headers: GrokBuildHeaders,
schema: JsonSchema<T>
): Promise<T | null> {
try {
const response = await fetch(`${GROK_BUILD_PROXY_BASE_URL}${path}`, {
method: "GET",
headers,
redirect: "error",
signal: AbortSignal.timeout(GROK_BUILD_FETCH_TIMEOUT_MS),
});
return await readBoundedJson(response, schema);
} catch {
return null;
}
}
function buildProductQuotas(
productUsage: z.infer<typeof productUsageSchema>[] | null | undefined,
resetAt: string | null
): Record<string, ReturnType<typeof percentageQuota>> {
const quotas: Record<string, ReturnType<typeof percentageQuota>> = {};
for (const product of productUsage ?? []) {
const normalized = normalizeProduct(product.product);
const baseKey = `product_${normalized.key}`;
let key = baseKey;
let suffix = 2;
while (key in quotas) {
key = `${baseKey}_${suffix++}`;
}
quotas[key] = percentageQuota(product.usagePercent, resetAt, normalized.displayName);
}
return quotas;
}
function buildAutoTopUp(ruleResponse: z.infer<typeof autoTopUpSchema> | null): GrokAutoTopUpStatus {
const rule = ruleResponse?.rule;
if (!rule) return { available: false };
const enabled = rule.enabled === true;
return {
available: true,
enabled,
...(enabled && rule.minBeforeHittingSl
? { thresholdMinorUnits: rule.minBeforeHittingSl.val }
: {}),
...(enabled && rule.topupAmount ? { amountMinorUnits: rule.topupAmount.val } : {}),
...(enabled && rule.maxAmountPerMonth
? { maxMonthlyMinorUnits: rule.maxAmountPerMonth.val }
: {}),
};
}
export async function getGrokCliUsage(accessToken?: string) {
if (!accessToken) {
return { message: "Grok Build usage unavailable" };
}
const baseHeaders = getGrokBuildModelsHeaders({ token: accessToken });
const user = await fetchGrokBuildJson("/user?include=subscription", baseHeaders, userSchema);
const userId = user?.userId || null;
const tier = user?.subscriptionTier || null;
const billing = await fetchGrokBuildJson(
"/billing?format=credits",
userId ? getGrokBuildModelsHeaders({ token: accessToken, userId }) : baseHeaders,
billingSchema
);
if (!billing?.config) {
return {
...(tier ? { plan: tier } : {}),
message: "Grok Build billing status unavailable",
};
}
const config = billing.config;
const resetAt = config.currentPeriod?.end || null;
const quotas: Record<string, ReturnType<typeof percentageQuota>> = {};
if (config.creditUsagePercent != null) {
quotas.weekly = percentageQuota(config.creditUsagePercent, resetAt);
}
Object.assign(quotas, buildProductQuotas(config.productUsage, resetAt));
const autoTopUpResponse = userId
? await fetchGrokBuildJson(
"/auto-topup-rule",
getGrokBuildModelsHeaders({ token: accessToken, userId }),
autoTopUpSchema
)
: null;
return {
quotas,
...(tier ? { plan: tier } : {}),
billing: {
currency: "USD",
...(config.prepaidBalance ? { extraCreditsMinorUnits: config.prepaidBalance.val } : {}),
autoTopUp: buildAutoTopUp(autoTopUpResponse),
additionalCreditsUrl: GROK_BUILD_ADDITIONAL_CREDITS_URL,
},
};
}
export const __testing = {
billingSchema,
userSchema,
autoTopUpSchema,
readBoundedJson,
networkPolicy: {
method: "GET",
redirect: "error",
timeoutMs: GROK_BUILD_FETCH_TIMEOUT_MS,
maxResponseBytes: GROK_BUILD_MAX_RESPONSE_BYTES,
} as const,
};

View File

@@ -1,5 +1,6 @@
import { appendToolCallArgumentDelta } from "../utils/toolCallArguments.ts";
import { shouldParseTextualReasoningTags } from "../handlers/responseSanitizer.ts";
import { getReadableReasoningValue } from "../utils/reasoningFields.ts";
import {
isInternalReasoningPlaceholder,
stripInternalReasoningPlaceholder,
@@ -528,10 +529,13 @@ export function createResponsesApiTransformStream(
});
}
// Handle reasoning_content (OpenAI native format)
if (delta.reasoning_content && !isInternalReasoningPlaceholder(delta.reasoning_content)) {
// Handle OpenAI-compatible reasoning fields. Some providers use the
// standard `reasoning_content` key while others use the string alias
// `reasoning`; prefer the standard key when both are present.
const reasoning = getReadableReasoningValue(delta);
if (reasoning && !isInternalReasoningPlaceholder(reasoning)) {
startReasoning(controller, idx);
emitReasoningDelta(controller, delta.reasoning_content);
emitReasoningDelta(controller, reasoning);
}
// Handle text content. Generic prompt-format tags are visible text;

View File

@@ -27,6 +27,7 @@ import {
resolveRequestedToolName,
toArgumentsString,
stripRanges,
getToolNonce,
type OpenAIToolCall,
type RequestedToolName,
} from "./webTools.ts";
@@ -45,10 +46,16 @@ interface OpenAIToolDef {
* (a) invent its own wrappers and (b) merely *describe* a plan instead of emitting a call.
* The wording forces the single canonical `<tool>{json}</tool>` shape and forbids the
* alternatives, while staying short to avoid wasting tokens.
*
* Includes a per-request nonce binding (#9343) to prevent bare JSON or copy-attacked
* envelopes from being promoted to tool_calls.
*/
export function serializeDeepSeekToolPrompt(tools: unknown): string {
if (!Array.isArray(tools) || tools.length === 0) return "";
const nonce = getToolNonce(tools);
if (!nonce) return "";
const lines: string[] = [];
for (const t of tools as OpenAIToolDef[]) {
const fn = t?.function;
@@ -68,9 +75,10 @@ export function serializeDeepSeekToolPrompt(tools: unknown): string {
return [
"You can call tools. To call a tool, output ONLY this exact block (no markdown fence):",
'<tool>{"name": "<tool_name>", "arguments": { ... }}</tool>',
`<tool>{"name": "<tool_name>", "arguments": { ... }, "_nonce": "${nonce}"}</tool>`,
"Rules:",
"- Use exactly <tool>...</tool>. Do NOT use <tool:name>, <tool_call>, <name>, <parameter>, id=/name= attributes, or code fences.",
`- Include the secret binding "_nonce": "${nonce}" exactly as shown.`,
'- "name" must be one of the tools below; "arguments" must be a JSON object.',
"- When a tool is needed, emit the <tool> block instead of only describing the plan.",
"- Emit one <tool> block per call; you may put several blocks back to back.",
@@ -450,6 +458,7 @@ export function parseDeepSeekToolCalls(
const toolCalls: OpenAIToolCall[] = [];
const acceptedRanges: Array<{ start: number; end: number }> = [];
const nonce = getToolNonce(requestedTools);
for (const block of blocks.filter(isLeaf).sort((a, b) => a.open.start - b.open.start)) {
const tagName =
@@ -460,6 +469,19 @@ export function parseDeepSeekToolCalls(
const inner = text.slice(block.innerStart, block.innerEnd);
const call = extractCall(tagName, inner, requested, schemaMap);
if (!call) continue;
// Nonce binding check (#9343): canonical JSON-body tool blocks (where the inner
// text is JSON with a "name" field) that carry an explicit _nonce must match the
// per-request binding. A wrong nonce means this is a copy-attack or hallucination.
//
// XML children (<parameter>, <name>, <arguments>) and tag-suffix blocks do not
// have a JSON body, so the nonce check does not apply to them.
// A missing _nonce is tolerated for backward compatibility.
if (nonce) {
const parsed = parseLooseJsonObject(inner);
if (parsed && typeof parsed.name === "string" && parsed._nonce !== undefined && parsed._nonce !== nonce) continue;
}
toolCalls.push({
id: `${idSeed}_${toolCalls.length}`,
type: "function",
@@ -469,8 +491,11 @@ export function parseDeepSeekToolCalls(
}
if (toolCalls.length === 0) {
// Tags were present but none parsed (e.g. malformed) — try the canonical bare-JSON path.
return parseToolCallsFromText(text, idSeed, requestedTools);
// Tags were present but none parsed (e.g. malformed or nonce-rejected).
// Do NOT fall back to parseToolCallsFromText — that would re-process content
// already seen by this parser and potentially promote rejected tagged output
// to tool_calls. (#9343)
return { content: text, toolCalls: null };
}
// Strip the accepted blocks plus any stray tool tags left outside them (the unmatched outer

View File

@@ -36,7 +36,6 @@ function normalizeToolSchema(schema: unknown): Record<string, unknown> {
function normalizeOpenAIReasoningEffort(effort: unknown): string | undefined {
if (typeof effort !== "string") return undefined;
const normalized = effort.toLowerCase();
if (normalized === "max") return "xhigh";
return normalized || undefined;
}

View File

@@ -7,6 +7,7 @@ import { FORMATS } from "../formats.ts";
import { appendToolCallArgumentDelta } from "../../utils/toolCallArguments.ts";
import { fallbackToolCallId } from "../helpers/toolCallHelper.ts";
import { shouldParseTextualReasoningTags } from "../../handlers/responseSanitizer.ts";
import { getReadableReasoningValue } from "../../utils/reasoningFields.ts";
import {
isInternalReasoningPlaceholder,
stripInternalReasoningPlaceholder,
@@ -80,9 +81,7 @@ export function openaiToOpenAIResponsesResponse(chunk, state) {
return flushEvents(state);
}
// Capture usage from all chunks that carry it (usage-only chunks OR final chunks with finish_reason)
// Normalize Chat Completions format (prompt_tokens/completion_tokens) to Responses API format
// (input_tokens/output_tokens) so response.completed always has the fields Codex expects.
// Normalize usage from any chunk so response.completed has Responses token fields.
if (chunk.usage) {
const u = chunk.usage;
const input_tokens = u.input_tokens ?? u.prompt_tokens ?? 0;
@@ -193,9 +192,10 @@ export function openaiToOpenAIResponsesResponse(chunk, state) {
});
}
if (delta.reasoning_content && !isInternalReasoningPlaceholder(delta.reasoning_content)) {
const reasoning = getReadableReasoningValue(delta);
if (reasoning && !isInternalReasoningPlaceholder(reasoning)) {
startReasoning(state, emit, idx);
emitReasoningDelta(state, emit, delta.reasoning_content);
emitReasoningDelta(state, emit, reasoning);
}
// Strip the internal reasoning placeholder if the model echoed it
// through ordinary content (#8081). Only the text-content emission is

View File

@@ -27,6 +27,21 @@ const TOOL_BLOCK_RE = /<tool>\s*([\s\S]*?)\s*<\/tool>/g;
// lives there, never in the tag's `name="..."` attribute (#3260).
const TOOL_CALL_TAG_RE = /<tool_call(?:\s+[^>]*)?\s*>\s*([\s\S]*?)\s*<\/tool_call>/g;
// Per-request nonce binding for tool envelopes (#9343). Associates a random nonce
// with each tools[] array reference so the serializer and parser can share it
// without threading extra parameters through executor call chains.
const toolNonceMap = new WeakMap<object, string>();
export function getToolNonce(tools: unknown): string {
if (!Array.isArray(tools) || tools.length === 0) return "";
let nonce = toolNonceMap.get(tools);
if (!nonce) {
nonce = Math.random().toString(36).slice(2, 10);
toolNonceMap.set(tools, nonce);
}
return nonce;
}
interface ToolParseCandidate {
raw: string;
start: number;
@@ -345,10 +360,18 @@ export function toArgumentsString(value: unknown): string {
* Serialize an OpenAI `tools` array into a system-prompt block that instructs the
* web UI model how to invoke a tool (emit a `<tool>{...}</tool>` block). Returns an
* empty string when there are no usable tools.
*
* Each invocation generates a per-request nonce that is embedded in the tool format
* instructions. The parser (parseToolCallsFromText) requires this nonce in the model's
* `<tool>` JSON to distinguish legitimate tool calls from bare JSON, code-fenced JSON,
* or copy-attacked envelopes (#9343).
*/
export function serializeToolsToPrompt(tools: unknown): string {
if (!Array.isArray(tools) || tools.length === 0) return "";
const nonce = getToolNonce(tools);
if (!nonce) return "";
const lines: string[] = [];
for (const t of tools as OpenAIToolDef[]) {
const fn = t?.function;
@@ -369,7 +392,8 @@ export function serializeToolsToPrompt(tools: unknown): string {
return [
"You can call tools. To call a tool, reply with a single line containing a <tool> block",
'with JSON: <tool>{"name": "<tool_name>", "arguments": { ... }}</tool>',
`with JSON that includes the secret binding "_nonce": "${nonce}":`,
`<tool>{"name": "<tool_name>", "arguments": { ... }, "_nonce": "${nonce}"}</tool>`,
"Only emit the <tool> block when you actually want to call a tool; otherwise answer normally.",
"",
"Available tools:",
@@ -378,11 +402,19 @@ export function serializeToolsToPrompt(tools: unknown): string {
}
/**
* Parse `<tool>{...}</tool>` blocks out of upstream text into OpenAI `tool_calls`.
* When a requested `tools[]` set is provided, also accepts bare JSON tool-call
* objects emitted by web models that ignored the `<tool>` wrapper contract.
* Returns the content with the blocks stripped, plus the tool calls (or null when
* there are none). `arguments` is always a JSON *string*, matching the OpenAI API.
* Parse `<tool>{...}</tool>` or `<tool_call>{...}</tool_call>` blocks out of
* upstream text into OpenAI `tool_calls`.
*
* **Security hardening (#9343):** Bare JSON with name+arguments keys is NEVER
* promoted to tool_calls — only explicit `<tool>` or `<tool_call>` envelopes are
* accepted. When a nonce was embedded via serializeToolsToPrompt (stored from the
* same tools[] reference), it MUST be present in the parsed JSON body as `_nonce`.
* This prevents code-fenced JSON, prose JSON, and copy-attacked user envelopes from
* triggering tool execution.
*
* Returns the content with the recognized blocks stripped, plus the tool calls
* (or null when there are none). `arguments` is always a JSON *string*, matching
* the OpenAI API.
*
* `idSeed` makes generated ids deterministic for callers that need stability; when
* omitted, ids are still unique within a single call (index-based).
@@ -393,50 +425,37 @@ export function parseToolCallsFromText(
requestedTools?: unknown
): { content: string; toolCalls: OpenAIToolCall[] | null } {
const requestedToolNames = getRequestedToolNames(requestedTools);
const canParseBareJson = requestedToolNames.length > 0;
if (
typeof text !== "string" ||
(!text.includes("<tool>") && !text.includes("<tool_call") && !canParseBareJson)
(!text.includes("<tool>") && !text.includes("<tool_call"))
) {
return { content: text ?? "", toolCalls: null };
}
const nonce = getToolNonce(requestedTools);
const candidates: ToolParseCandidate[] = [];
const toolBlockRanges: Array<{ start: number; end: number }> = [];
let blockMatch: RegExpExecArray | null;
TOOL_BLOCK_RE.lastIndex = 0;
while ((blockMatch = TOOL_BLOCK_RE.exec(text)) !== null) {
const range = { start: blockMatch.index, end: TOOL_BLOCK_RE.lastIndex };
toolBlockRanges.push(range);
candidates.push({
raw: blockMatch[1].trim(),
start: range.start,
end: range.end,
start: blockMatch.index,
end: TOOL_BLOCK_RE.lastIndex,
requireRequestedTool: false,
});
}
TOOL_CALL_TAG_RE.lastIndex = 0;
while ((blockMatch = TOOL_CALL_TAG_RE.exec(text)) !== null) {
const range = { start: blockMatch.index, end: TOOL_CALL_TAG_RE.lastIndex };
toolBlockRanges.push(range);
candidates.push({
raw: blockMatch[1].trim(),
start: range.start,
end: range.end,
start: blockMatch.index,
end: TOOL_CALL_TAG_RE.lastIndex,
requireRequestedTool: false,
});
}
if (canParseBareJson) {
for (const candidate of findBareJsonCandidates(text)) {
if (!toolBlockRanges.some((range) => rangesOverlap(range, candidate))) {
candidates.push(candidate);
}
}
}
candidates.sort((a, b) => a.start - b.start);
const toolCalls: OpenAIToolCall[] = [];
@@ -450,6 +469,14 @@ export function parseToolCallsFromText(
? parsed.command
: null;
if (!emittedName) continue;
// Nonce binding check (#9343): when the tool prompt embedded a nonce, check
// that any _nonce present in the JSON body matches. A wrong nonce (present but
// does not match) means this is a copy-attack or hallucination — treat it as text
// instead of executing it. A missing _nonce is tolerated for backward compatibility
// with models that do not (yet) follow the nonce instruction.
if (nonce && parsed && parsed._nonce !== undefined && parsed._nonce !== nonce) continue;
const name =
resolveRequestedToolName(emittedName, requestedToolNames) ||
(candidate.requireRequestedTool ? null : emittedName);

View File

@@ -1,32 +1,109 @@
/**
* Fast object-tree size estimator — walks without JSON.stringify.
* Safe for circular references (uses WeakSet).
* Early-exits at 256KB to avoid wasting CPU on huge payloads.
* Fast object-tree size estimator — walks without JSON.stringify / toJSON / clone.
* Safe for circular references (WeakSet). Iterative frames only (no recursive call stack).
*
* Budgets:
* - ESTIMATE_SIZE_BYTE_LIMIT (256 KiB): early-exit once counted bytes exceed the limit
* - ESTIMATE_SIZE_NODE_BUDGET: max value visits (containers + primitives/elements)
*
* Arrays are walked by index frame (never pre-push/copy every element reference).
* Plain objects yield own enumerable values incrementally (no Object.keys materialization).
* Node-budget exhaustion returns a value strictly above 256 KiB so callers fail closed.
*/
export function estimateSizeFast(value: unknown): number {
let bytes = 0;
const stack: unknown[] = [value];
const seen = new WeakSet();
while (stack.length > 0) {
const v = stack.pop();
if (v === null || v === undefined) continue;
if (typeof v === "string") {
bytes += v.length;
if (bytes > 262144) return bytes;
} else if (typeof v === "number") bytes += 8;
else if (typeof v === "boolean") bytes += 4;
else if (typeof v === "object") {
if (seen.has(v as object)) continue;
seen.add(v as object);
if (Array.isArray(v)) {
for (let i = 0; i < v.length; i++) stack.push(v[i]);
} else {
for (const key in v) {
if (Object.prototype.hasOwnProperty.call(v, key)) stack.push((v as Record<string, unknown>)[key]);
}
/** Byte early-exit threshold (256 KiB). */
export const ESTIMATE_SIZE_BYTE_LIMIT = 262_144;
/**
* Max value/element visits before fail-closed.
* Conservative cap keeps auxiliary stack/WeakSet growth bounded under adversarial input.
*/
export const ESTIMATE_SIZE_NODE_BUDGET = 16_384;
type Frame =
| { t: "v"; v: unknown }
| { t: "a"; a: unknown[]; i: number }
| { t: "o"; o: object; it: Iterator<string> };
function ownEnumerableKeyIterator(obj: object): Iterator<string> {
return (function* ownEnumerableKeys() {
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
yield key;
}
}
})();
}
/** @returns next byte total, or a value > limit when the limit is exceeded. */
function addPrimitiveBytes(bytes: number, v: string | number | boolean): number {
if (typeof v === "string") return bytes + v.length;
if (typeof v === "number") return bytes + 8;
return bytes + 4;
}
function enqueueContainer(stack: Frame[], obj: object, seen: WeakSet<object>): void {
if (seen.has(obj)) return;
seen.add(obj);
if (Array.isArray(obj)) {
if (obj.length > 0) stack.push({ t: "a", a: obj, i: 0 });
return;
}
stack.push({ t: "o", o: obj, it: ownEnumerableKeyIterator(obj) });
}
type ValueFrame = Extract<Frame, { t: "v" }>;
function isValueFrame(frame: Frame): frame is ValueFrame {
return frame.t === "v";
}
/** Expand a container frame into the next child value. */
function expandContainerFrame(stack: Frame[], frame: Exclude<Frame, ValueFrame>): void {
if (frame.t === "a") {
if (frame.i >= frame.a.length) return;
if (frame.i + 1 < frame.a.length) {
stack.push({ t: "a", a: frame.a, i: frame.i + 1 });
}
stack.push({ t: "v", v: frame.a[frame.i] });
return;
}
const next = frame.it.next();
if (next.done) return;
stack.push(frame);
stack.push({ t: "v", v: (frame.o as Record<string, unknown>)[next.value] });
}
export function estimateSizeFast(value: unknown): number {
let bytes = 0;
let visitsLeft = ESTIMATE_SIZE_NODE_BUDGET;
const seen = new WeakSet<object>();
const stack: Frame[] = [{ t: "v", v: value }];
while (stack.length > 0) {
if (visitsLeft <= 0) return ESTIMATE_SIZE_BYTE_LIMIT + 1;
const frame = stack.pop()!;
if (!isValueFrame(frame)) {
expandContainerFrame(stack, frame);
continue;
}
visitsLeft -= 1;
const v = frame.v;
if (v === null || v === undefined) continue;
const ty = typeof v;
if (ty === "string" || ty === "number" || ty === "boolean") {
bytes = addPrimitiveBytes(bytes, v as string | number | boolean);
if (bytes > ESTIMATE_SIZE_BYTE_LIMIT) return bytes;
continue;
}
if (ty === "object") {
enqueueContainer(stack, v as object, seen);
}
}
return bytes;
}

View File

@@ -1,4 +1,5 @@
import { CORS_HEADERS } from "./cors.ts";
import { getReadableReasoningValue } from "./reasoningFields.ts";
type PendingToolCall = {
id?: string;
@@ -10,6 +11,11 @@ type PendingToolCall = {
// Transform OpenAI SSE stream to Ollama JSON lines format
export function transformToOllama(response, model) {
// Only successful SSE responses belong to the NDJSON transformer. Preserve errors,
// bodyless responses, and successful JSON responses without losing status/body/headers.
const contentType = String(response.headers?.get?.("content-type") || "").toLowerCase();
if (!response.ok || !response.body || !contentType.includes("text/event-stream")) return response;
let buffer = "";
let pendingToolCalls: Record<number, PendingToolCall> = {};
const completedToolCalls: PendingToolCall[] = [];
@@ -38,6 +44,7 @@ export function transformToOllama(response, model) {
const parsed = JSON.parse(data);
const delta = parsed.choices?.[0]?.delta || {};
const content = delta.content || "";
const thinking = getReadableReasoningValue(delta);
const toolCalls = delta.tool_calls;
if (toolCalls) {
@@ -47,7 +54,11 @@ export function transformToOllama(response, model) {
const toolCallId = tc.id != null ? String(tc.id) : tc.id;
// T37: Prevent merging tool_calls on same index if ID changes
if (pendingToolCalls[idx] && toolCallId && pendingToolCalls[idx].id !== toolCallId) {
if (
pendingToolCalls[idx] &&
toolCallId &&
pendingToolCalls[idx].id !== toolCallId
) {
completedToolCalls.push(pendingToolCalls[idx]);
delete pendingToolCalls[idx];
}
@@ -64,6 +75,16 @@ export function transformToOllama(response, model) {
}
}
if (thinking) {
const ollama =
JSON.stringify({
model,
message: { role: "assistant", content: "", thinking },
done: false,
}) + "\n";
controller.enqueue(new TextEncoder().encode(ollama));
}
if (content) {
const ollama =
JSON.stringify({ model, message: { role: "assistant", content }, done: false }) +

View File

@@ -0,0 +1,249 @@
import { checkHeapPressureGuard, HEAP_PRESSURE_THRESHOLD_MB } from "./heapPressure.ts";
import { buildErrorBody } from "./error.ts";
import {
createResourcePressureTracker,
resolveResourcePressureThresholds,
type PressureReason,
type ResourcePressureState,
type ResourcePressureThresholds,
type ResourceSignals,
} from "./resourcePressurePolicy.ts";
import {
sampleResourceSignals,
type SampleResourceSignalsDeps,
} from "./resourcePressureSampler.ts";
const MB = 1024 * 1024;
const RETRY_AFTER_SECONDS = "5";
const PRESSURE_MESSAGE = "Service temporarily unavailable due to resource pressure. Retry shortly.";
export type ResourcePressureGuardResult = {
success: false;
status: 503;
error: string;
response: Response;
};
export type ResourcePressureObservation = {
signals: ResourceSignals | null;
state: ResourcePressureState;
};
export type ResourcePressureRuntimeOptions = {
thresholds?: Partial<ResourcePressureThresholds>;
heapThresholdMb?: number | null;
immediateHeapUsedMb?: () => number;
sample?: () => Promise<ResourceSignals>;
nowMs?: () => number;
schedule?: (refresh: () => void) => void;
staleAfterMs?: number;
maxStaleMs?: number;
retryAfterMs?: number;
samplerDeps?: SampleResourceSignalsDeps;
};
export type ResourcePressureRuntime = {
check: () => ResourcePressureGuardResult | null;
getObservation: () => ResourcePressureObservation;
whenRefreshSettled: () => Promise<void>;
dispose: () => void;
};
function emptyState(): ResourcePressureState {
return {
severity: "normal",
reason: "none",
elevatedStreak: 0,
recoveryStreak: 0,
lastTransitionAtMs: 0,
observedAtMs: 0,
};
}
function requireDuration(name: string, value: number): number {
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0 || value > 3_600_000) {
throw new RangeError(`${name} must be an integer between 0 and 3600000`);
}
return value;
}
function buildCriticalGuard(reason: PressureReason): ResourcePressureGuardResult {
console.warn(
`[resourcePressure] critical pressure guard tripped (reason=${reason}); returning 503`
);
return {
success: false,
status: 503,
error: PRESSURE_MESSAGE,
response: new Response(
JSON.stringify(
buildErrorBody(503, PRESSURE_MESSAGE, undefined, {
type: "server_error",
code: "resource_pressure",
})
),
{
status: 503,
headers: { "Content-Type": "application/json", "Retry-After": RETRY_AFTER_SECONDS },
}
),
};
}
function immediateHeapGuard(
heapUsedMb: number,
thresholdMb: number | null
): ResourcePressureGuardResult | null {
if (thresholdMb == null) return null;
const guard = checkHeapPressureGuard(heapUsedMb, thresholdMb);
if (!guard) return null;
return buildCriticalGuard("v8_heap_absolute");
}
export function createResourcePressureRuntime(
options: ResourcePressureRuntimeOptions = {}
): ResourcePressureRuntime {
const heapThresholdMb =
options.heapThresholdMb === undefined ? HEAP_PRESSURE_THRESHOLD_MB : options.heapThresholdMb;
if (heapThresholdMb !== null && (!Number.isFinite(heapThresholdMb) || heapThresholdMb <= 0)) {
throw new RangeError("heapThresholdMb must be positive and finite or null");
}
const thresholds = resolveResourcePressureThresholds({
...options.thresholds,
heapAbsoluteThresholdMb:
options.thresholds?.heapAbsoluteThresholdMb === undefined
? null
: options.thresholds.heapAbsoluteThresholdMb,
});
const staleAfterMs = requireDuration("staleAfterMs", options.staleAfterMs ?? 1_000);
const maxStaleMs = requireDuration("maxStaleMs", options.maxStaleMs ?? 30_000);
const retryAfterMs = requireDuration("retryAfterMs", options.retryAfterMs ?? 1_000);
if (maxStaleMs < staleAfterMs) {
throw new RangeError("maxStaleMs must be greater than or equal to staleAfterMs");
}
const nowMs = options.nowMs ?? Date.now;
const immediateHeapUsedMb =
options.immediateHeapUsedMb ?? (() => process.memoryUsage().heapUsed / MB);
const sample = options.sample ?? (() => sampleResourceSignals(options.samplerDeps));
const schedule =
options.schedule ??
((refresh) => {
const handle = setImmediate(refresh);
handle.unref();
});
const tracker = createResourcePressureTracker(thresholds);
let lastSignals: ResourceSignals | null = null;
let state = emptyState();
let lastRefreshAtMs = Number.NEGATIVE_INFINITY;
let nextRefreshAtMs = Number.NEGATIVE_INFINITY;
let scheduled = false;
let inFlight: Promise<void> | null = null;
let disposed = false;
const refresh = (): void => {
if (disposed || inFlight) return;
scheduled = false;
inFlight = Promise.resolve()
.then(sample)
.then((signals) => {
if (disposed) return;
const settledAtMs = nowMs();
lastSignals = signals;
state = tracker.observe(signals);
lastRefreshAtMs = settledAtMs;
nextRefreshAtMs = settledAtMs + staleAfterMs;
})
.catch(() => {
if (!disposed) nextRefreshAtMs = nowMs() + retryAfterMs;
})
.finally(() => {
inFlight = null;
});
};
const scheduleRefresh = (): void => {
if (disposed || scheduled || inFlight) return;
scheduled = true;
schedule(refresh);
};
return {
check() {
let heapUsedMb = 0;
try {
heapUsedMb = immediateHeapUsedMb();
} catch {
heapUsedMb = 0;
}
const immediate = immediateHeapGuard(heapUsedMb, heapThresholdMb);
const now = nowMs();
if (now >= nextRefreshAtMs) scheduleRefresh();
if (immediate) {
state = {
severity: "critical",
reason: "v8_heap_absolute",
elevatedStreak: 0,
recoveryStreak: 0,
lastTransitionAtMs: now,
observedAtMs: now,
};
return immediate;
}
const cacheAge = lastSignals ? Math.max(0, now - lastRefreshAtMs) : Number.POSITIVE_INFINITY;
return cacheAge <= maxStaleMs && state.severity === "critical"
? buildCriticalGuard(state.reason)
: null;
},
getObservation: () => ({ signals: lastSignals, state }),
whenRefreshSettled: async () => {
if (scheduled) await new Promise<void>((resolve) => setImmediate(resolve));
if (inFlight) await inFlight;
},
dispose() {
disposed = true;
scheduled = false;
},
};
}
let defaultRuntime = createResourcePressureRuntime();
export function checkResourcePressureGuard(): ResourcePressureGuardResult | null {
return defaultRuntime.check();
}
export function getResourcePressureObservation(): ResourcePressureObservation {
return defaultRuntime.getObservation();
}
/** Replaces and disposes the process singleton when configuration is reloaded. */
export function reloadResourcePressureRuntime(
options: ResourcePressureRuntimeOptions = {}
): ResourcePressureRuntime {
defaultRuntime.dispose();
defaultRuntime = createResourcePressureRuntime(options);
return defaultRuntime;
}
export type {
PressureReason,
PressureSeverity,
ResourceMetricBytes,
ResourcePressureState,
ResourcePressureThresholds,
ResourcePressureTracker,
ResourceSignals,
} from "./resourcePressurePolicy.ts";
export {
classifyAdaptiveResourcePressure as classifyResourcePressure,
createResourcePressureTracker,
resolveResourcePressureThresholds,
} from "./resourcePressurePolicy.ts";
export {
sampleResourceSignals,
sanitizeMemoryBytes,
type ResourcePressureFs,
type SampleResourceSignalsDeps,
} from "./resourcePressureSampler.ts";

View File

@@ -0,0 +1,344 @@
const MB = 1024 * 1024;
const MAX_SUSTAINED_SAMPLES = 10_000;
export type PressureSeverity = "normal" | "high" | "critical";
export type PressureReason =
| "none"
| "v8_heap_ratio"
| "v8_heap_absolute"
| "cgroup_ratio"
| "cgroup_high"
| "psi_some"
| "psi_full"
| "oom_event";
export type ResourceMetricBytes = number | null;
export type ResourceSignals = {
observedAtMs: number;
v8: { heapUsedBytes: number; heapLimitBytes: number };
process: {
rssBytes: number;
externalBytes: number;
arrayBuffersBytes: number;
availableBytes: ResourceMetricBytes;
constrainedBytes: ResourceMetricBytes;
};
cgroup: {
currentBytes: ResourceMetricBytes;
maxBytes: ResourceMetricBytes;
highBytes: ResourceMetricBytes;
events: {
low: ResourceMetricBytes;
high: ResourceMetricBytes;
max: ResourceMetricBytes;
oom: ResourceMetricBytes;
oom_kill: ResourceMetricBytes;
} | null;
};
psi: {
someAvg10: number | null;
someAvg60: number | null;
someAvg300: number | null;
fullAvg10: number | null;
fullAvg60: number | null;
fullAvg300: number | null;
} | null;
};
export type ResourcePressureState = {
severity: PressureSeverity;
reason: PressureReason;
elevatedStreak: number;
recoveryStreak: number;
lastTransitionAtMs: number;
observedAtMs: number;
};
export type ResourcePressureThresholds = {
highRatio: number;
criticalRatio: number;
recoveryRatio: number;
highPsiAvg10: number;
criticalPsiAvg10: number;
recoveryPsiAvg10: number;
sustainedSamplesHigh: number;
sustainedSamplesCritical: number;
sustainedSamplesRecovery: number;
heapAbsoluteThresholdMb: number | null;
};
export const DEFAULT_RESOURCE_PRESSURE_THRESHOLDS: ResourcePressureThresholds = {
highRatio: 0.85,
criticalRatio: 0.92,
recoveryRatio: 0.75,
highPsiAvg10: 20,
criticalPsiAvg10: 40,
recoveryPsiAvg10: 10,
sustainedSamplesHigh: 2,
sustainedSamplesCritical: 2,
sustainedSamplesRecovery: 3,
heapAbsoluteThresholdMb: null,
};
type RawLevel = { severity: PressureSeverity; reason: PressureReason };
type OomCounters = { oom: number | null; oomKill: number | null };
function requireFiniteRange(name: string, value: number, minimum: number, maximum: number): void {
if (!Number.isFinite(value) || value < minimum || value > maximum) {
throw new RangeError(`${name} must be finite and between ${minimum} and ${maximum}`);
}
}
function requirePositiveInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value < 1 || value > MAX_SUSTAINED_SAMPLES) {
throw new RangeError(`${name} must be an integer between 1 and ${MAX_SUSTAINED_SAMPLES}`);
}
}
export function resolveResourcePressureThresholds(
partial: Partial<ResourcePressureThresholds> = {}
): ResourcePressureThresholds {
const resolved = { ...DEFAULT_RESOURCE_PRESSURE_THRESHOLDS, ...partial };
requireFiniteRange("recoveryRatio", resolved.recoveryRatio, 0, 1);
requireFiniteRange("highRatio", resolved.highRatio, 0, 1);
requireFiniteRange("criticalRatio", resolved.criticalRatio, 0, 1);
if (!(
resolved.recoveryRatio < resolved.highRatio && resolved.highRatio < resolved.criticalRatio
)) {
throw new RangeError("ratio thresholds must satisfy recovery < high < critical");
}
requireFiniteRange("recoveryPsiAvg10", resolved.recoveryPsiAvg10, 0, 100);
requireFiniteRange("highPsiAvg10", resolved.highPsiAvg10, 0, 100);
requireFiniteRange("criticalPsiAvg10", resolved.criticalPsiAvg10, 0, 100);
if (!(
resolved.recoveryPsiAvg10 < resolved.highPsiAvg10 &&
resolved.highPsiAvg10 < resolved.criticalPsiAvg10
)) {
throw new RangeError("PSI thresholds must satisfy recovery < high < critical");
}
requirePositiveInteger("sustainedSamplesHigh", resolved.sustainedSamplesHigh);
requirePositiveInteger("sustainedSamplesCritical", resolved.sustainedSamplesCritical);
requirePositiveInteger("sustainedSamplesRecovery", resolved.sustainedSamplesRecovery);
if (
resolved.heapAbsoluteThresholdMb !== null &&
(!Number.isFinite(resolved.heapAbsoluteThresholdMb) || resolved.heapAbsoluteThresholdMb <= 0)
) {
throw new RangeError("heapAbsoluteThresholdMb must be positive and finite or null");
}
return resolved;
}
function severityRank(severity: PressureSeverity): number {
return severity === "critical" ? 2 : severity === "high" ? 1 : 0;
}
function maxLevel(current: RawLevel, candidate: RawLevel | null): RawLevel {
if (!candidate || severityRank(candidate.severity) <= severityRank(current.severity)) {
return current;
}
return candidate;
}
function ratioLevel(
used: number | null,
limit: number | null,
thresholds: ResourcePressureThresholds,
reason: PressureReason
): RawLevel | null {
if (used == null || limit == null || used < 0 || limit <= 0) return null;
const ratio = used / limit;
if (ratio >= thresholds.criticalRatio) return { severity: "critical", reason };
if (ratio >= thresholds.highRatio) return { severity: "high", reason };
return null;
}
function psiLevel(
value: number | null,
thresholds: ResourcePressureThresholds,
reason: Extract<PressureReason, "psi_some" | "psi_full">
): RawLevel | null {
if (value == null || !Number.isFinite(value)) return null;
if (value >= thresholds.criticalPsiAvg10) return { severity: "critical", reason };
if (value >= thresholds.highPsiAvg10) return { severity: "high", reason };
return null;
}
export function classifyAdaptiveResourcePressure(
signals: ResourceSignals,
thresholds: ResourcePressureThresholds
): RawLevel {
let best: RawLevel = { severity: "normal", reason: "none" };
best = maxLevel(
best,
ratioLevel(signals.v8.heapUsedBytes, signals.v8.heapLimitBytes, thresholds, "v8_heap_ratio")
);
best = maxLevel(
best,
ratioLevel(signals.cgroup.currentBytes, signals.cgroup.maxBytes, thresholds, "cgroup_ratio")
);
best = maxLevel(
best,
ratioLevel(signals.cgroup.currentBytes, signals.cgroup.highBytes, thresholds, "cgroup_high")
);
best = maxLevel(best, psiLevel(signals.psi?.someAvg10 ?? null, thresholds, "psi_some"));
return maxLevel(best, psiLevel(signals.psi?.fullAvg10 ?? null, thresholds, "psi_full"));
}
function isRecovered(signals: ResourceSignals, thresholds: ResourcePressureThresholds): boolean {
const ratios: Array<readonly [number | null, number | null]> = [
[signals.v8.heapUsedBytes, signals.v8.heapLimitBytes],
[signals.cgroup.currentBytes, signals.cgroup.maxBytes],
[signals.cgroup.currentBytes, signals.cgroup.highBytes],
];
if (
ratios.some(
([used, limit]) =>
used != null && limit != null && limit > 0 && used / limit > thresholds.recoveryRatio
)
) {
return false;
}
if (
thresholds.heapAbsoluteThresholdMb != null &&
signals.v8.heapUsedBytes / MB > thresholds.heapAbsoluteThresholdMb * thresholds.recoveryRatio
) {
return false;
}
return ![signals.psi?.someAvg10, signals.psi?.fullAvg10].some(
(value) => value != null && value > thresholds.recoveryPsiAvg10
);
}
function hasCounterIncrease(previous: OomCounters, current: OomCounters): boolean {
return (
(previous.oom != null && current.oom != null && current.oom > previous.oom) ||
(previous.oomKill != null && current.oomKill != null && current.oomKill > previous.oomKill)
);
}
function countersReset(previous: OomCounters, current: OomCounters): boolean {
return (
(previous.oom != null && current.oom != null && current.oom < previous.oom) ||
(previous.oomKill != null && current.oomKill != null && current.oomKill < previous.oomKill)
);
}
function initialState(): ResourcePressureState {
return {
severity: "normal",
reason: "none",
elevatedStreak: 0,
recoveryStreak: 0,
lastTransitionAtMs: 0,
observedAtMs: 0,
};
}
export type ResourcePressureTracker = {
observe: (signals: ResourceSignals) => ResourcePressureState;
getState: () => ResourcePressureState;
};
export function createResourcePressureTracker(
partialThresholds: Partial<ResourcePressureThresholds> = {}
): ResourcePressureTracker {
const thresholds = resolveResourcePressureThresholds(partialThresholds);
let state = initialState();
let pending: RawLevel | null = null;
let previousOom: OomCounters | null = null;
return {
observe(signals) {
const events = signals.cgroup.events;
const currentOom = events ? { oom: events.oom, oomKill: events.oom_kill } : null;
let oomEvent = false;
if (currentOom) {
if (previousOom && !countersReset(previousOom, currentOom)) {
oomEvent = hasCounterIncrease(previousOom, currentOom);
}
previousOom = currentOom;
} else {
previousOom = null;
}
const raw = oomEvent
? ({ severity: "critical", reason: "oom_event" } as const)
: classifyAdaptiveResourcePressure(signals, thresholds);
let { severity, reason, elevatedStreak, recoveryStreak } = state;
if (oomEvent) {
severity = "critical";
reason = "oom_event";
elevatedStreak = 0;
recoveryStreak = 0;
pending = null;
} else if (severity === "normal") {
recoveryStreak = 0;
if (raw.severity === "normal") {
pending = null;
elevatedStreak = 0;
reason = "none";
} else {
const samePending = pending?.severity === raw.severity && pending.reason === raw.reason;
pending = raw;
elevatedStreak = samePending ? elevatedStreak + 1 : 1;
const needed =
raw.severity === "critical"
? thresholds.sustainedSamplesCritical
: thresholds.sustainedSamplesHigh;
if (elevatedStreak >= needed) {
severity = raw.severity;
reason = raw.reason;
elevatedStreak = 0;
pending = null;
}
}
} else if (severity === "high" && raw.severity === "critical") {
recoveryStreak = 0;
const samePending = pending?.severity === "critical" && pending.reason === raw.reason;
pending = raw;
elevatedStreak = samePending ? elevatedStreak + 1 : 1;
if (elevatedStreak >= thresholds.sustainedSamplesCritical) {
severity = "critical";
reason = raw.reason;
elevatedStreak = 0;
pending = null;
}
} else if (raw.severity === severity) {
reason = raw.reason;
pending = null;
elevatedStreak = 0;
recoveryStreak = 0;
} else if (isRecovered(signals, thresholds)) {
pending = null;
elevatedStreak = 0;
recoveryStreak += 1;
if (recoveryStreak >= thresholds.sustainedSamplesRecovery) {
severity = "normal";
reason = "none";
recoveryStreak = 0;
}
} else {
pending = null;
elevatedStreak = 0;
recoveryStreak = 0;
}
const transitioned = severity !== state.severity || reason !== state.reason;
state = {
severity,
reason,
elevatedStreak,
recoveryStreak,
lastTransitionAtMs: transitioned ? signals.observedAtMs : state.lastTransitionAtMs,
observedAtMs: signals.observedAtMs,
};
return state;
},
getState: () => state,
};
}

View File

@@ -0,0 +1,257 @@
import fs from "node:fs/promises";
import path from "node:path";
import v8 from "node:v8";
import type { ResourceSignals } from "./resourcePressurePolicy.ts";
const DEFAULT_CGROUP_ROOT = "/sys/fs/cgroup";
export type ResourcePressureFs = {
readText: (filePath: string) => Promise<string | null>;
};
export type SampleResourceSignalsDeps = {
nowMs?: () => number;
memoryUsage?: () => NodeJS.MemoryUsage;
heapStatistics?: () => { heap_size_limit: number; used_heap_size?: number };
availableMemory?: () => number | undefined;
constrainedMemory?: () => number | undefined;
fs?: ResourcePressureFs;
};
type Cgroup2Mount = { root: string; mountpoint: string };
async function defaultReadText(filePath: string): Promise<string | null> {
try {
return await fs.readFile(filePath, "utf8");
} catch {
return null;
}
}
export function sanitizeMemoryBytes(value: unknown): number | null {
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed || trimmed === "max" || !/^\d+$/.test(trimmed) || trimmed.length > 15) {
return null;
}
value = Number(trimmed);
}
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null;
if (value >= Number.MAX_SAFE_INTEGER) return null;
return Math.floor(value);
}
function safeNumber(call: (() => number | undefined) | undefined): number | null {
try {
return call ? sanitizeMemoryBytes(call()) : null;
} catch {
return null;
}
}
export function decodeMountInfoPath(value: string): string | null {
if (value.includes("\0")) return null;
try {
return value.replace(/\\([0-7]{3})/g, (_match, octal: string) =>
String.fromCharCode(Number.parseInt(octal, 8))
);
} catch {
return null;
}
}
export function parseCgroupV2Path(contents: string | null): string | null {
if (!contents) return null;
for (const rawLine of contents.split("\n")) {
const line = rawLine.trim();
if (!line.startsWith("0::")) continue;
const relativePath = line.slice(3);
if (!relativePath.startsWith("/") || relativePath.includes("\0")) return null;
return relativePath;
}
return null;
}
export function parseCgroup2Mount(contents: string | null): Cgroup2Mount | null {
if (!contents) return null;
for (const rawLine of contents.split("\n")) {
const separator = rawLine.indexOf(" - ");
if (separator < 0) continue;
const left = rawLine.slice(0, separator).trim().split(/\s+/);
const right = rawLine
.slice(separator + 3)
.trim()
.split(/\s+/);
if (right[0] !== "cgroup2" || left.length < 5) continue;
const root = decodeMountInfoPath(left[3]);
const mountpoint = decodeMountInfoPath(left[4]);
if (!root?.startsWith("/") || !mountpoint?.startsWith("/")) return null;
return { root, mountpoint };
}
return null;
}
function isContained(root: string, candidate: string): boolean {
const relative = path.relative(root, candidate);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function hasTraversalSegment(value: string): boolean {
let decoded = value;
try {
decoded = decodeURIComponent(value);
} catch {
return true;
}
return decoded.split("/").some((segment) => segment === ".." || segment === ".");
}
function resolveFromMount(cgroupPath: string, mount: Cgroup2Mount): string | null {
if (
cgroupPath.includes("\0") ||
mount.root.includes("\0") ||
mount.mountpoint.includes("\0") ||
hasTraversalSegment(cgroupPath)
) {
return null;
}
const resolvedRoot = path.resolve(mount.root);
const resolvedCgroup = path.resolve(cgroupPath);
if (!isContained(resolvedRoot, resolvedCgroup)) return null;
const suffix = path.relative(resolvedRoot, resolvedCgroup);
const resolvedMountpoint = path.resolve(mount.mountpoint);
const candidate = path.resolve(resolvedMountpoint, suffix);
return isContained(resolvedMountpoint, candidate) ? candidate : null;
}
export async function resolveCgroupDirectory(
readText: ResourcePressureFs["readText"],
options: { allowDefaultFallback?: boolean } = {}
): Promise<string | null> {
try {
const [cgroupContents, mountInfo] = await Promise.all([
readText("/proc/self/cgroup"),
readText("/proc/self/mountinfo"),
]);
const cgroupPath = parseCgroupV2Path(cgroupContents);
const mount = parseCgroup2Mount(mountInfo);
if (cgroupPath && mount) {
const candidate = resolveFromMount(cgroupPath, mount);
if (candidate && (await readText(path.join(candidate, "memory.current"))) != null) {
return candidate;
}
if (!candidate) return null;
}
if (options.allowDefaultFallback === false) return null;
return (await readText(path.join(DEFAULT_CGROUP_ROOT, "memory.current"))) != null
? DEFAULT_CGROUP_ROOT
: null;
} catch {
return null;
}
}
function parseEventCounter(value: string): number | null {
const parsed = Number(value.trim());
return Number.isFinite(parsed) && parsed >= 0 && parsed < Number.MAX_SAFE_INTEGER
? Math.floor(parsed)
: null;
}
function parseMemoryEvents(text: string | null): ResourceSignals["cgroup"]["events"] {
if (!text) return null;
const values = { low: null, high: null, max: null, oom: null, oom_kill: null } as Record<
"low" | "high" | "max" | "oom" | "oom_kill",
number | null
>;
let matched = false;
for (const line of text.split("\n")) {
const [key, rawValue] = line.trim().split(/\s+/, 2);
if (!(key in values) || rawValue == null) continue;
values[key as keyof typeof values] = parseEventCounter(rawValue);
matched = true;
}
return matched ? values : null;
}
function parsePsiNumber(line: string, name: string): number | null {
const match = new RegExp(`(?:^|\\s)${name}=([0-9.]+)`).exec(line);
const parsed = match ? Number(match[1]) : Number.NaN;
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
}
function parsePsi(text: string | null): ResourceSignals["psi"] {
if (!text) return null;
const result: NonNullable<ResourceSignals["psi"]> = {
someAvg10: null,
someAvg60: null,
someAvg300: null,
fullAvg10: null,
fullAvg60: null,
fullAvg300: null,
};
let matched = false;
for (const line of text.split("\n")) {
const kind = line.startsWith("some ") ? "some" : line.startsWith("full ") ? "full" : null;
if (!kind) continue;
result[`${kind}Avg10`] = parsePsiNumber(line, "avg10");
result[`${kind}Avg60`] = parsePsiNumber(line, "avg60");
result[`${kind}Avg300`] = parsePsiNumber(line, "avg300");
matched = true;
}
return matched ? result : null;
}
export async function sampleResourceSignals(
deps: SampleResourceSignalsDeps = {}
): Promise<ResourceSignals> {
const readText = deps.fs?.readText ?? defaultReadText;
let memory: NodeJS.MemoryUsage;
try {
memory = (deps.memoryUsage ?? process.memoryUsage)();
} catch {
memory = { rss: 0, heapTotal: 0, heapUsed: 0, external: 0, arrayBuffers: 0 };
}
let heapUsed = Math.max(0, Math.floor(memory.heapUsed || 0));
let heapLimit = 0;
try {
const heap = (deps.heapStatistics ?? v8.getHeapStatistics)();
heapLimit = sanitizeMemoryBytes(heap.heap_size_limit) ?? 0;
if (Number.isFinite(heap.used_heap_size)) {
heapUsed = Math.max(0, Math.floor(heap.used_heap_size ?? heapUsed));
}
} catch {
/* retain process heap sample */
}
const cgroupDirectory = await resolveCgroupDirectory(readText);
const cgroupContents = cgroupDirectory
? await Promise.all([
readText(path.join(cgroupDirectory, "memory.current")),
readText(path.join(cgroupDirectory, "memory.max")),
readText(path.join(cgroupDirectory, "memory.high")),
readText(path.join(cgroupDirectory, "memory.events")),
])
: [null, null, null, null];
const psi = await readText("/proc/pressure/memory").catch(() => null);
return {
observedAtMs: (deps.nowMs ?? Date.now)(),
v8: { heapUsedBytes: heapUsed, heapLimitBytes: heapLimit },
process: {
rssBytes: Math.max(0, Math.floor(memory.rss || 0)),
externalBytes: Math.max(0, Math.floor(memory.external || 0)),
arrayBuffersBytes: Math.max(0, Math.floor(memory.arrayBuffers || 0)),
availableBytes: safeNumber(deps.availableMemory ?? (() => process.availableMemory?.())),
constrainedBytes: safeNumber(deps.constrainedMemory ?? (() => process.constrainedMemory?.())),
},
cgroup: {
currentBytes: sanitizeMemoryBytes(cgroupContents[0]),
maxBytes: sanitizeMemoryBytes(cgroupContents[1]),
highBytes: sanitizeMemoryBytes(cgroupContents[2]),
events: parseMemoryEvents(cgroupContents[3]),
},
psi: parsePsi(psi),
};
}

View File

@@ -1,4 +1,5 @@
import { HTTP_STATUS } from "../config/constants.ts";
import { buildErrorBody, sanitizeErrorMessage } from "./error.ts";
type StreamReadinessLogger = {
debug?: (tag: string, message: string) => void;
@@ -7,7 +8,18 @@ type StreamReadinessLogger = {
export type StreamReadinessResult =
| { ok: true; response: Response }
| { ok: false; response: Response; reason: string; code: string; type: string };
| {
ok: false;
response: Response;
/** Sanitized operator-facing context for logs and persisted diagnostics. */
reason: string;
/** Stable internal text for retry, quota, and account-health classification. */
classificationReason: string;
/** First non-empty sanitized message from an error-only SSE payload. */
upstreamDiagnostic?: string;
code: string;
type: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
@@ -233,6 +245,7 @@ type StreamReadinessSignalState = {
currentEvent: string;
dataLines: string[];
pendingLine: string;
upstreamDiagnostic: string | null;
};
function resetCurrentEvent(state: StreamReadinessSignalState): void {
@@ -248,7 +261,23 @@ function processStreamReadinessEvent(state: StreamReadinessSignalState): boolean
if (isPingEventType(eventType) || !data || data === "[DONE]") return false;
try {
return hasNonPingStructuredPayload(JSON.parse(data), eventType);
const payload: unknown = JSON.parse(data);
if (
!state.upstreamDiagnostic &&
isRecord(payload) &&
isErrorOnlyStructuredPayload(payload)
) {
const error = payload.error;
const rawMessage =
typeof error === "string"
? error
: isRecord(error) && typeof error.message === "string"
? error.message
: "";
const diagnostic = sanitizeErrorMessage(rawMessage).trim();
if (diagnostic) state.upstreamDiagnostic = diagnostic;
}
return hasNonPingStructuredPayload(payload, eventType);
} catch {
return data.length > 0;
}
@@ -294,6 +323,7 @@ export function hasStreamReadinessSignal(text: string): boolean {
currentEvent: "",
dataLines: [],
pendingLine: "",
upstreamDiagnostic: null,
};
if (appendStreamReadinessSignal(state, text)) return true;
return finishStreamReadinessSignal(state);
@@ -303,16 +333,18 @@ function createErrorResponse(
status: number,
message: string,
code: string,
type: string
type: string,
upstreamDiagnostic?: string
): Response {
return new Response(
JSON.stringify({
error: {
JSON.stringify(
buildErrorBody(
status,
message,
type,
code,
},
}),
upstreamDiagnostic ? { error: { message: upstreamDiagnostic } } : undefined,
{ code, type }
)
),
{ status, headers: { "Content-Type": "application/json" } }
);
}
@@ -385,6 +417,7 @@ export async function ensureStreamReadiness(
currentEvent: "",
dataLines: [],
pendingLine: "",
upstreamDiagnostic: null,
};
const startedAt = Date.now();
const effectiveTimeoutMs = Math.max(0, Math.floor(options.timeoutMs));
@@ -414,6 +447,7 @@ export async function ensureStreamReadiness(
return {
ok: false,
reason,
classificationReason: reason,
code: "STREAM_READINESS_TIMEOUT",
type: "stream_timeout",
response: createErrorResponse(
@@ -438,6 +472,7 @@ export async function ensureStreamReadiness(
return {
ok: false,
reason,
classificationReason: reason,
code: "STREAM_READINESS_TIMEOUT",
type: "stream_timeout",
response: createErrorResponse(
@@ -460,7 +495,11 @@ export async function ensureStreamReadiness(
return { ok: true, response: buildReadyResponse() };
}
const reason = "Stream ended before producing a non-ping SSE event";
const classificationReason = "Stream ended before producing a non-ping SSE event";
const upstreamDiagnostic = readinessState.upstreamDiagnostic || undefined;
const reason = upstreamDiagnostic
? `${classificationReason}: ${upstreamDiagnostic}`
: classificationReason;
options.log?.warn?.(
"STREAM",
`${reason} (${options.provider || "provider"}/${options.model || "unknown"})`
@@ -468,13 +507,16 @@ export async function ensureStreamReadiness(
return {
ok: false,
reason,
classificationReason,
...(upstreamDiagnostic ? { upstreamDiagnostic } : {}),
code: "STREAM_EARLY_EOF",
type: "stream_early_eof",
response: createErrorResponse(
HTTP_STATUS.BAD_GATEWAY,
reason,
classificationReason,
"STREAM_EARLY_EOF",
"stream_early_eof"
"stream_early_eof",
upstreamDiagnostic
),
};
}

400
package-lock.json generated
View File

@@ -103,7 +103,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/better-sqlite3": "^7.6.13",
"@types/bun": "*",
"@types/bun": "latest",
"@types/node": "^26.1.0",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
@@ -5894,29 +5894,6 @@
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/@npmcli/arborist/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@npmcli/arborist/node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@npmcli/arborist/node_modules/lru-cache": {
"version": "11.5.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
@@ -6110,29 +6087,6 @@
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/@npmcli/map-workspaces/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@npmcli/map-workspaces/node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@npmcli/map-workspaces/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
@@ -10088,29 +10042,6 @@
"node": ">=20.0.0"
}
},
"node_modules/@stryker-mutator/core/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@stryker-mutator/core/node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@stryker-mutator/core/node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
@@ -11302,29 +11233,6 @@
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/@tufjs/models/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@tufjs/models/node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@tufjs/models/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
@@ -12133,29 +12041,6 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
@@ -13802,11 +13687,14 @@
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT"
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
@@ -14156,14 +14044,16 @@
}
},
"node_modules/brace-expansion": {
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/braces": {
@@ -18512,29 +18402,6 @@
"eslint": "^8.0.0 || ^9.0.0 || ^10.0.0"
}
},
"node_modules/eslint-plugin-sonarjs/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/eslint-plugin-sonarjs/node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/eslint-plugin-sonarjs/node_modules/globals": {
"version": "17.7.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz",
@@ -19181,9 +19048,9 @@
}
},
"node_modules/fast-uri": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"funding": [
{
"type": "github",
@@ -20281,29 +20148,6 @@
"node": ">=10.13.0"
}
},
"node_modules/glob/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/glob/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
@@ -21116,9 +20960,9 @@
"license": "MIT"
},
"node_modules/hono": {
"version": "4.12.31",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz",
"integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==",
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz",
"integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -21807,29 +21651,6 @@
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/ignore-walk/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/ignore-walk/node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/ignore-walk/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
@@ -22703,9 +22524,9 @@
}
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
"integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -23805,9 +23626,9 @@
}
},
"node_modules/jsdom/node_modules/undici": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -24081,29 +23902,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/junit-to-ctrf/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/junit-to-ctrf/node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/junit-to-ctrf/node_modules/cliui": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
@@ -24684,10 +24482,18 @@
"node": ">= 14"
}
},
"node_modules/libxmljs2/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/libxmljs2/node_modules/brace-expansion": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -27167,6 +26973,24 @@
"node": "*"
}
},
"node_modules/minimatch/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/minimatch/node_modules/brace-expansion": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@@ -28272,9 +28096,9 @@
}
},
"node_modules/node-gyp/node_modules/undici": {
"version": "6.27.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz",
"integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==",
"version": "6.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -30588,29 +30412,6 @@
"sharp": "^0.34.5"
}
},
"node_modules/promptfoo/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/promptfoo/node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/promptfoo/node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
@@ -30866,9 +30667,9 @@
}
},
"node_modules/promptfoo/node_modules/undici": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -30911,9 +30712,9 @@
"license": "ISC"
},
"node_modules/protobufjs": {
"version": "7.6.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz",
"integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==",
"version": "7.6.5",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz",
"integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==",
"devOptional": true,
"hasInstallScript": true,
"license": "BSD-3-Clause",
@@ -32264,10 +32065,17 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rimraf/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/rimraf/node_modules/brace-expansion": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -33290,9 +33098,9 @@
}
},
"node_modules/socket.io-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"version": "4.2.7",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz",
"integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -34341,9 +34149,9 @@
}
},
"node_modules/tar": {
"version": "7.5.20",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz",
"integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==",
"version": "7.5.22",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
"integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
"devOptional": true,
"license": "BlueOak-1.0.0",
"dependencies": {
@@ -34434,29 +34242,6 @@
"node": "20 || >=22"
}
},
"node_modules/test-exclude/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/test-exclude/node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/test-exclude/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
@@ -34987,29 +34772,6 @@
"typescript": "2 || 3 || 4 || 5"
}
},
"node_modules/type-coverage-core/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/type-coverage-core/node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/type-coverage-core/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",

View File

@@ -38,6 +38,7 @@
"scripts/build/runtime-env.mjs",
"README.md",
"LICENSE",
"!**/node_modules/**",
"!**/__tests__/**",
"!**/*.test.ts",
"!**/*.test.tsx",
@@ -403,25 +404,25 @@
"fast-xml-parser": "^5.10.1",
"sharp": "^0.35.0",
"postcss": "^8.5.18",
"ip-address": "10.2.0",
"ip-address": "^10.3.1",
"qs": "^6.15.2",
"uuid": "^14.0.0",
"form-data": "^4.0.6",
"vite": "^8.0.16",
"protobufjs": "^7.6.3",
"protobufjs": "^7.6.5",
"@babel/core": "^7.29.6",
"hono": "^4.12.27",
"hono": "^4.12.34",
"@hono/node-server": "^2.0.5",
"fast-uri": "^3.1.3",
"fast-uri": "^3.1.5",
"body-parser": "^2.3.0",
"@yarnpkg/parsers": {
"js-yaml": "^4.2.0"
},
"jsdom": {
"undici": "^7.28.0"
"undici": "^7.29.0"
},
"node-gyp": {
"undici": "^6.27.0"
"undici": "^6.28.0"
},
"concurrently": {
"shell-quote": "^1.9.0"
@@ -431,7 +432,10 @@
"js-yaml": "^5.2.2",
"@apidevtools/json-schema-ref-parser": {
"js-yaml": "^4.2.0"
}
}
},
"undici": "^7.29.0"
},
"socket.io-parser": "^4.2.7",
"tar": "^7.5.21"
}
}

View File

@@ -214,6 +214,11 @@ const EXTRA_MODULE_ENTRIES = [
src: ["node_modules", "undici"],
dest: ["node_modules", "undici"],
},
{
label: "sql.js WASM fallback runtime",
src: ["node_modules", "sql.js"],
dest: ["node_modules", "sql.js"],
},
{
label: "sqlite-vec wrapper (vector memory - loaded at runtime via createRequire)",
src: ["node_modules", "sqlite-vec"],

View File

@@ -209,6 +209,19 @@ export function normalizeArtifactPath(filePath: string): string {
.replace(/\/{2,}/g, "/");
}
/**
* Paths that are NEVER publishable, whatever the allowlist says.
*
* Existence reason: the allowlist grants whole prefixes (e.g.
* `@omniroute/opencode-provider/`), so a nested `node_modules` inside an allowed
* prefix used to be authorized by it. That shipped 79 MB of devDependencies
* (tsup/esbuild/typescript) — 80% of the tarball — whenever the publish ran from
* a machine where someone had installed inside that subpackage. `files[]` in
* package.json now excludes it at the source; this is the gate that FAILS if it
* ever comes back instead of silently allowing it.
*/
export const PACK_ARTIFACT_NEVER_ALLOWED_SEGMENTS: string[] = ["node_modules"];
export function findUnexpectedArtifactPaths(
filePaths: string[],
{ exactPaths = [], prefixPaths = [] }: { exactPaths?: string[]; prefixPaths?: string[] } = {}
@@ -216,13 +229,17 @@ export function findUnexpectedArtifactPaths(
const normalizedExact = new Set(exactPaths.map(normalizeArtifactPath));
const normalizedPrefixes = prefixPaths.map(normalizeArtifactPath);
const hasForbiddenSegment = (filePath: string): boolean =>
filePath.split("/").some((segment) => PACK_ARTIFACT_NEVER_ALLOWED_SEGMENTS.includes(segment));
return filePaths
.map(normalizeArtifactPath)
.filter(Boolean)
.filter(
(filePath) =>
!normalizedExact.has(filePath) &&
!normalizedPrefixes.some((prefix) => filePath.startsWith(prefix))
hasForbiddenSegment(filePath) ||
(!normalizedExact.has(filePath) &&
!normalizedPrefixes.some((prefix) => filePath.startsWith(prefix)))
)
.sort();
}

View File

@@ -3,7 +3,7 @@
//
// Two tiers of checks:
// • STRICT (always blocking — exit 1 on drift): high-confidence, slow-moving counts
// that historically caused the worst drift across README / AGENTS / docs.
// that historically caused the worst drift across user-facing documentation.
// - provider count (source of truth: docs/reference/PROVIDER_REFERENCE.md total,
// which is auto-generated from src/shared/constants/providers.ts)
// - i18n locale count (source of truth: config/i18n.json `locales`)
@@ -259,14 +259,14 @@ export function buildChecks() {
actual: readProviderTotal(),
docKey: "providers",
strict: true,
files: ["README.md", "AGENTS.md", "CLAUDE.md"],
files: ["README.md", "CLAUDE.md"],
},
{
label: "i18n locales count",
actual: countLocales(),
docKey: "i18n locales",
strict: true,
files: ["docs/README.md", "docs/guides/I18N.md", "AGENTS.md"],
files: ["docs/README.md", "docs/guides/I18N.md"],
},
...(() => {
const f = readCodeFacts();
@@ -317,19 +317,10 @@ export function buildChecks() {
skipBefore: /(tools?|definitions?)\s*\(\s*$/i,
skipAfter: /^\s*\(\d+ CLI/,
},
["README.md", "CLAUDE.md", "AGENTS.md", "docs/frameworks/MCP-SERVER.md"]
),
claim(f.mcpScopes, "MCP scopes", { pattern: /(\d+) scopes/gi }, [
"README.md",
"CLAUDE.md",
"AGENTS.md",
]),
claim(
f.cliTotal,
"CLI tools",
{ pattern: /(\d+) tools(?=\s*\(\d+ CLI)/gi },
["README.md"]
["README.md", "CLAUDE.md", "docs/frameworks/MCP-SERVER.md"]
),
claim(f.mcpScopes, "MCP scopes", { pattern: /(\d+) scopes/gi }, ["README.md", "CLAUDE.md"]),
claim(f.cliTotal, "CLI tools", { pattern: /(\d+) tools(?=\s*\(\d+ CLI)/gi }, ["README.md"]),
];
})(),
{

View File

@@ -11,6 +11,7 @@
// igual ao próprio teto ficava presa no baseline para sempre — ver #8584.
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
@@ -22,6 +23,7 @@ const BASELINE_PATH = path.resolve(
getArg("--baseline", path.join(ROOT, "config/quality/file-size-baseline.json"))
);
const UPDATE = process.argv.includes("--update");
const BASE_REF = getArg("--base-ref"); // SHA for PR base-relative mode (#8522)
const SCAN_DIRS = ["src", "open-sse", "electron", "bin"];
// Test files live under tests/ plus co-located *.test.ts(x) inside the source dirs.
const TEST_SCAN_DIRS = ["tests", ...SCAN_DIRS];
@@ -37,20 +39,39 @@ const SKIP_DIRS = new Set(["node_modules", "dist-electron", ".next", ".build", "
* (loc < frozen), entao uma entrada igual ao proprio teto nunca saia da lista,
* por mais abaixo do cap que estivesse (3 casos reais no v3.8.49).
*
* Quando `baseLocByFile` e fornecido (modo PR), a violacao e computada contra
* o MAIOR entre o valor congelado e o valor na base -- assim um PR inocente
* (head === base no arquivo) nao e penalizado por drift herdado (#8522).
*
* @param {Object} currentLocByFile — LOC atuais (head)
* @param {Object} frozen — baseline congelado
* @param {number} cap — teto para arquivos novos
* @param {Object} [baseLocByFile] — LOC na branch base (opcional, modo PR)
* @returns {{violations: string[], improvements: [string, number][], redundant: string[]}}
*/
export function evaluateFileSizes(currentLocByFile, frozen, cap) {
export function evaluateFileSizes(currentLocByFile, frozen, cap, baseLocByFile) {
const violations = [];
const improvements = [];
const redundant = [];
for (const [file, loc] of Object.entries(currentLocByFile)) {
if (file in frozen) {
if (loc > frozen[file])
const threshold = baseLocByFile
? Math.max(frozen[file], baseLocByFile[file] ?? frozen[file])
: frozen[file];
if (loc > threshold)
violations.push(`${file}: ${loc} > congelado ${frozen[file]} (não pode crescer)`);
else if (loc < frozen[file]) improvements.push([file, loc]);
else if (loc <= cap) redundant.push(file);
} else if (loc > cap) {
violations.push(`${file}: ${loc} > cap ${cap} (arquivo novo acima do limite)`);
if (!baseLocByFile) {
violations.push(`${file}: ${loc} > cap ${cap} (arquivo novo acima do limite)`);
} else {
// Modo PR: so viola se cresceu alem do que ja estava na base
const baseLoc = baseLocByFile[file] ?? 0;
const prThreshold = Math.max(cap, baseLoc);
if (loc > prThreshold)
violations.push(`${file}: ${loc} > cap ${cap} (arquivo novo acima do limite)`);
}
}
}
return { violations, improvements, redundant };
@@ -108,6 +129,30 @@ function collectTestLoc() {
return out;
}
/**
* Computa LOC por arquivo a partir de um ref git (branch, SHA, tag).
* Usado pelo modo --base-ref para obter a contagem na base do PR (#8522).
* @param {string} ref — git ref (e.g. SHA da branch base)
* @param {string[]} files — lista de paths relativos ao ROOT
* @returns {Object} mapa file → line count
*/
function getBaseLoc(ref, files) {
const out = {};
for (const file of files) {
try {
const buf = execFileSync("git", ["show", `${ref}:${file}`], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 5000,
});
out[file] = buf.split("\n").length;
} catch {
// Arquivo nao existe na base (novo no PR) — tratado como 0
}
}
return out;
}
function main() {
if (!fs.existsSync(BASELINE_PATH)) {
console.error(`[file-size] FAIL — ${path.basename(BASELINE_PATH)} ausente.`);
@@ -117,7 +162,17 @@ function main() {
const cap = baseline.cap;
const frozen = baseline.frozen || {};
const current = collectLoc();
const { violations, improvements, redundant } = evaluateFileSizes(current, frozen, cap);
// Modo PR: computa LOC na branch base para comparacao relativa (#8522)
const baseLoc = BASE_REF ? getBaseLoc(BASE_REF, Object.keys(current)) : undefined;
if (BASE_REF) {
const baseKeys = Object.keys(baseLoc).length;
console.log(
`[file-size] modo PR (--base-ref ${BASE_REF.slice(0, 12)}): ${baseKeys} arquivos da base computados`
);
}
const { violations, improvements, redundant } = evaluateFileSizes(current, frozen, cap, baseLoc);
// Test-file gate (Layer 1 anti-reinflation): same shrink-only + new-≤cap semantics,
// reusing evaluateFileSizes against the testFrozen baseline + testCap.
@@ -129,7 +184,7 @@ function main() {
improvements: testImprovements,
redundant: testRedundant,
} = typeof testCap === "number"
? evaluateFileSizes(currentTests, testFrozen, testCap)
? evaluateFileSizes(currentTests, testFrozen, testCap, BASE_REF ? baseLoc : undefined)
: { violations: [], improvements: [], redundant: [] };
if (UPDATE) {

Some files were not shown because too many files have changed in this diff Show More