mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 08:12:20 +03:00
Compare commits
59 Commits
fix/9237-e
...
feat/5501-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26a2067f21 | ||
|
|
5018712a4b | ||
|
|
9fcefcce9f | ||
|
|
ed122b2caf | ||
|
|
7d5e8235da | ||
|
|
3022df548e | ||
|
|
ef3f554665 | ||
|
|
6b0e11e378 | ||
|
|
a549db7dee | ||
|
|
f4e93f339d | ||
|
|
2c966c28af | ||
|
|
8ca40e7971 | ||
|
|
a61020153c | ||
|
|
ce764bc6f3 | ||
|
|
2cb7567d66 | ||
|
|
b840628de8 | ||
|
|
d969555417 | ||
|
|
f1ea77fd04 | ||
|
|
85f30d4da8 | ||
|
|
ab560cce7b | ||
|
|
6b531fbacd | ||
|
|
7d6a64b054 | ||
|
|
b07182c72a | ||
|
|
7e55abbc41 | ||
|
|
b0501642dd | ||
|
|
7d46d4039f | ||
|
|
d502f144b9 | ||
|
|
eaea0347ac | ||
|
|
37edd74f2d | ||
|
|
0b70a14a3b | ||
|
|
28a1f4d1b6 | ||
|
|
ed2c4dbab3 | ||
|
|
0965b041fa | ||
|
|
7b8055c7f8 | ||
|
|
3440c118e0 | ||
|
|
712910612b | ||
|
|
0538eec05e | ||
|
|
0ca25d61f4 | ||
|
|
8027c60726 | ||
|
|
4a3dcf6b0b | ||
|
|
16ed707148 | ||
|
|
8b97ef99aa | ||
|
|
e50f2329dc | ||
|
|
455906c181 | ||
|
|
b6bcc491bc | ||
|
|
45d375aa0b | ||
|
|
f11d883f22 | ||
|
|
2cb77bbca7 | ||
|
|
a8216c92fe | ||
|
|
c790b57af8 | ||
|
|
9acf79f04f | ||
|
|
09665ab455 | ||
|
|
9ee6435f0e | ||
|
|
edd9b0d664 | ||
|
|
ba353aa3d6 | ||
|
|
224bc0a5a5 | ||
|
|
2e5854906d | ||
|
|
2e4268003a | ||
|
|
0c2aa35aee |
18
.cbmignore
18
.cbmignore
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
11
.github/dependabot.yml
vendored
11
.github/dependabot.yml
vendored
@@ -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)
|
||||
|
||||
10
.github/workflows/docker-publish.yml
vendored
10
.github/workflows/docker-publish.yml
vendored
@@ -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
|
||||
|
||||
13
.github/workflows/quality.yml
vendored
13
.github/workflows/quality.yml
vendored
@@ -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
9
.gitignore
vendored
@@ -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/
|
||||
|
||||
29
.mergify.yml
29
.mergify.yml
@@ -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
|
||||
|
||||
|
||||
15
.npmignore
15
.npmignore
@@ -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
|
||||
|
||||
@@ -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
695
AGENTS.md
@@ -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) |
|
||||
|
||||
1
changelog.d/features/9247-provider-detail-connections.md
Normal file
1
changelog.d/features/9247-provider-detail-connections.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(providers):** filter provider detail connections server-side while preserving full-page search and pagination. (thanks @RobertsXML)
|
||||
@@ -0,0 +1,2 @@
|
||||
- **feat(sse):** combo `system_message` supports server-side `{{MODEL_ID}}`, `{{PROVIDER_ID}}`, `{{ACCOUNT}}` and `{{FINGERPRINT}}` template expansion from the actually-routed target ([#5501](https://github.com/diegosouzapw/OmniRoute/issues/5501))
|
||||
- **feat(sse):** template expansion covers the standard dispatch loop, round-robin and pinned context-cache sessions; fusion, chaos, pipeline and nested-execute strategies do not expand yet ([#5501](https://github.com/diegosouzapw/OmniRoute/issues/5501))
|
||||
3
changelog.d/fixes/8430-fix.plan.md
Normal file
3
changelog.d/fixes/8430-fix.plan.md
Normal 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)
|
||||
1
changelog.d/fixes/8522-fix.plan.md
Normal file
1
changelog.d/fixes/8522-fix.plan.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(quality): add base-relative file-size check so inherited drift does not red innocent PRs (#8522)
|
||||
1
changelog.d/fixes/8653-fix.plan.md
Normal file
1
changelog.d/fixes/8653-fix.plan.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(executor): guard claude/anthropic buildHeaders against empty credentials and extend dual-Bearer parity for third-party baseUrls (#8653)
|
||||
1
changelog.d/fixes/8843-provider-media-body-limits.md
Normal file
1
changelog.d/fixes/8843-provider-media-body-limits.md
Normal 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
|
||||
1
changelog.d/fixes/8853-fix.plan.md
Normal file
1
changelog.d/fixes/8853-fix.plan.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(proxy-health): include credentials in proxy health check URLs (#8853)
|
||||
1
changelog.d/fixes/8950-fix.plan.md
Normal file
1
changelog.d/fixes/8950-fix.plan.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(auth): setting first dashboard login password no longer fails with HTTP 400 PASSWORD_REQUIRED (#8950)
|
||||
1
changelog.d/fixes/8971-fix.plan.md
Normal file
1
changelog.d/fixes/8971-fix.plan.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(providers): copilot-m365-web enterprise turns send disconnectBehavior=continue (#8971)
|
||||
1
changelog.d/fixes/9022-stream-error-diagnostic.md
Normal file
1
changelog.d/fixes/9022-stream-error-diagnostic.md
Normal 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
|
||||
@@ -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`.
|
||||
@@ -0,0 +1 @@
|
||||
- fix(providers): anthropic strips code-execution/skills beta flag, causing container rejection (#9064)
|
||||
1
changelog.d/fixes/9149-autosync-per-connection.md
Normal file
1
changelog.d/fixes/9149-autosync-per-connection.md
Normal 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))
|
||||
1
changelog.d/fixes/9159-fix.plan.md
Normal file
1
changelog.d/fixes/9159-fix.plan.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(management): authorize mcp:connect-only keys on loopback/LAN when requireLogin is enabled (#9159)
|
||||
1
changelog.d/fixes/9250-cli-compatible-provider-apply.md
Normal file
1
changelog.d/fixes/9250-cli-compatible-provider-apply.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(cli-tools):** keep Apply enabled for active OpenAI-compatible and Anthropic-compatible providers without static catalog entries. (thanks @lazysaltyfish)
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(translator):** harden Claude format detection for relative message endpoints and kebab-case version metadata. (thanks @ervareza)
|
||||
1
changelog.d/fixes/9276-fix.plan.md
Normal file
1
changelog.d/fixes/9276-fix.plan.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(claude): remove unconditional "always" return in claudeClassifierCompat so normal chat requests are not swallowed (#9276)
|
||||
1
changelog.d/fixes/9291-proxy-logs-egress-ip.md
Normal file
1
changelog.d/fixes/9291-proxy-logs-egress-ip.md
Normal 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
|
||||
1
changelog.d/fixes/9297-fix.plan.md
Normal file
1
changelog.d/fixes/9297-fix.plan.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(mcp): break circular import between googApiKeyAuth.ts and auth.ts to fix esbuild SyntaxError in MCP server bundle (#9297)
|
||||
1
changelog.d/fixes/9320-fix.plan.md
Normal file
1
changelog.d/fixes/9320-fix.plan.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(security): require auth for /v1/models when management auth is configured (#9320)
|
||||
1
changelog.d/fixes/9338-kimi-web-k3-exhausted.md
Normal file
1
changelog.d/fixes/9338-kimi-web-k3-exhausted.md
Normal 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)
|
||||
1
changelog.d/fixes/9343-bare-json-tool-calls.md
Normal file
1
changelog.d/fixes/9343-bare-json-tool-calls.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(security): require explicit tool envelope to prevent bare JSON from being promoted to real tool_calls (#9343)
|
||||
2
changelog.d/fixes/9406-claude-web-429-test.md
Normal file
2
changelog.d/fixes/9406-claude-web-429-test.md
Normal 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)
|
||||
1
changelog.d/fixes/9407-gemini-web-false-positive.md
Normal file
1
changelog.d/fixes/9407-gemini-web-false-positive.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(providers): detect expired gemini-web sessions via ServiceLogin redirect and add testConnection override (#9407)
|
||||
1
changelog.d/fixes/9408-claude-web-tool-use.md
Normal file
1
changelog.d/fixes/9408-claude-web-tool-use.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(providers): add tool_use block handling to claude-web stream parser for OpenAI tool_calls projection (#9408)
|
||||
1
changelog.d/fixes/9416-internal-provider-prefixes.md
Normal file
1
changelog.d/fixes/9416-internal-provider-prefixes.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(api): fall back to slugified provider name when prefix is empty to prevent UUID leak in /v1/models (#9416)
|
||||
1
changelog.d/fixes/9447-bare-model-codex-preemption.md
Normal file
1
changelog.d/fixes/9447-bare-model-codex-preemption.md
Normal 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))
|
||||
1
changelog.d/maintenance/9425-dependabot-ioredis-major.md
Normal file
1
changelog.d/maintenance/9425-dependabot-ioredis-major.md
Normal 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))
|
||||
@@ -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.
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
916
docs/architecture/mysql-conformance-semantics.md
Normal file
916
docs/architecture/mysql-conformance-semantics.md
Normal 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.
|
||||
278
docs/guides/ANTIGRAVITY-ONBOARDING.md
Normal file
278
docs/guides/ANTIGRAVITY-ONBOARDING.md
Normal 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._
|
||||
@@ -69,6 +69,17 @@ Recent combo improvements:
|
||||
- **Repeated provider support** — reuse the same provider many times in one combo as long as the `(provider, model, connection)` tuple is unique
|
||||
- **Combo target health** — analytics and health surfaces now distinguish individual combo targets/steps instead of collapsing everything into model strings
|
||||
- **Composite tier ordering** — `defaultTier -> fallbackTier` now influences runtime execution/fallback order for top-level combo steps
|
||||
- **System prompt templates** — combo `system_message` supports server-side
|
||||
`{{MODEL_ID}}`, `{{PROVIDER_ID}}`, `{{ACCOUNT}}` and `{{FINGERPRINT}}`
|
||||
placeholders, expanded from the actually-routed target right before dispatch.
|
||||
Allowlisted and non-recursive; unknown placeholders stay literal; empty values
|
||||
expand to empty; client system prompts are never rewritten. `{{FINGERPRINT}}`
|
||||
resolves only for fingerprint-based free providers with a pinned or
|
||||
auto-rotated fingerprint — it expands to empty elsewhere (e.g.
|
||||
single-fingerprint connections, non-fp providers). Expansion covers the
|
||||
standard dispatch loop, round-robin, and pinned context-cache sessions;
|
||||
fusion, chaos, pipeline and nested-execute strategies do not expand
|
||||
placeholders yet.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/**",
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
test/
|
||||
*.test.js
|
||||
.env
|
||||
.env.*
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface RegistryModel {
|
||||
aliases?: readonly string[];
|
||||
toolCalling?: boolean;
|
||||
supportsReasoning?: boolean;
|
||||
supportedThinkingEfforts?: readonly string[];
|
||||
supportsVision?: boolean;
|
||||
supportsXHighEffort?: boolean;
|
||||
maxOutputTokens?: number;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 ?? "",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
168
open-sse/services/admission/adaptation.ts
Normal file
168
open-sse/services/admission/adaptation.ts
Normal 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;
|
||||
}
|
||||
167
open-sse/services/admission/config.ts
Normal file
167
open-sse/services/admission/config.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
624
open-sse/services/admission/controller.ts
Normal file
624
open-sse/services/admission/controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
107
open-sse/services/admission/cost.ts
Normal file
107
open-sse/services/admission/cost.ts
Normal 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);
|
||||
}
|
||||
37
open-sse/services/admission/index.ts
Normal file
37
open-sse/services/admission/index.ts
Normal 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";
|
||||
194
open-sse/services/admission/queue.ts
Normal file
194
open-sse/services/admission/queue.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
186
open-sse/services/admission/requestFeatures.ts
Normal file
186
open-sse/services/admission/requestFeatures.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
614
open-sse/services/admission/runtime.ts
Normal file
614
open-sse/services/admission/runtime.ts
Normal 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;
|
||||
}
|
||||
171
open-sse/services/admission/types.ts
Normal file
171
open-sse/services/admission/types.ts
Normal 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;
|
||||
}
|
||||
@@ -34,6 +34,10 @@ import {
|
||||
import { buildNoUpstreamResponseDiagnostics, buildRecoveryHint } from "./combo/pinRecovery.ts";
|
||||
import { buildTargetTimeoutRunner } from "./combo/targetTimeoutRunner.ts";
|
||||
import { recordComboRequest, recordComboShadowRequest, getComboMetrics } from "./comboMetrics.ts";
|
||||
import {
|
||||
expandComboSystemPromptIfPresent,
|
||||
resolveTargetFingerprint,
|
||||
} from "./comboAgentMiddleware.ts";
|
||||
import {
|
||||
resolveComboConfig,
|
||||
getDefaultComboConfig,
|
||||
@@ -153,6 +157,7 @@ import {
|
||||
resolveDelayMs,
|
||||
comboModelNotFoundResponse,
|
||||
isStreamReadinessFailureErrorBody,
|
||||
isStreamEarlyEofErrorBody,
|
||||
isTokenLimitBreachErrorBody,
|
||||
toRecordedTarget,
|
||||
getExhaustedTargetSkipReason,
|
||||
@@ -1168,6 +1173,18 @@ export async function handleComboChat({
|
||||
}
|
||||
}
|
||||
}
|
||||
// #5501: server-side template expansion for the combo system_message —
|
||||
// resolved per-target, scoped to combo-injected content only (never
|
||||
// client-owned system messages). Gate: a non-empty combo system_message.
|
||||
attemptBody = expandComboSystemPromptIfPresent(attemptBody, combo, {
|
||||
modelId: modelStr,
|
||||
providerId: provider !== "unknown" ? provider : "",
|
||||
account:
|
||||
typeof target.label === "string" && target.label.trim().length > 0
|
||||
? target.label.trim()
|
||||
: "",
|
||||
fingerprint: resolveTargetFingerprint(target) ?? "",
|
||||
});
|
||||
const result = await handleSingleModelWithTimeout(attemptBody, modelStr, {
|
||||
...targetForAttempt,
|
||||
effectiveComboStrategy: strategy,
|
||||
@@ -1511,6 +1528,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 +1735,7 @@ export async function handleComboChat({
|
||||
if (
|
||||
shouldRecordProviderBreakerFailure({
|
||||
isStreamReadinessFailure,
|
||||
isStreamEarlyEof,
|
||||
status: result.status,
|
||||
sameProviderNext,
|
||||
skipProviderBreaker: fallbackResult.skipProviderBreaker,
|
||||
@@ -2561,6 +2584,18 @@ async function handleRoundRobinCombo({
|
||||
}
|
||||
}
|
||||
|
||||
// #5501: combo system_message template expansion per target (same gate
|
||||
// as the main iteration loop — round-robin branches here, not executeTarget).
|
||||
attemptBody = expandComboSystemPromptIfPresent(attemptBody, combo, {
|
||||
modelId: modelStr,
|
||||
providerId: provider !== "unknown" ? provider : "",
|
||||
account:
|
||||
typeof target.label === "string" && target.label.trim().length > 0
|
||||
? target.label.trim()
|
||||
: "",
|
||||
fingerprint: resolveTargetFingerprint(target) ?? "",
|
||||
});
|
||||
|
||||
const result = await handleSingleModel(attemptBody, modelStr, {
|
||||
...targetForAttempt,
|
||||
effectiveComboStrategy: "round-robin",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,6 +22,10 @@ import type { resolveComboSetupConfig } from "../comboConfig.ts";
|
||||
import { clampComboDepth, MAX_GLOBAL_ATTEMPTS, resolveDelayMs } from "./comboPredicates.ts";
|
||||
import { resolveComboRuntimeUnits, resolveComboTargets } from "./comboStructure.ts";
|
||||
import { buildFusionHandleSingleModel, extractFusionPanelSpec } from "./fusionPanel.ts";
|
||||
import {
|
||||
expandComboSystemPromptIfPresent,
|
||||
resolveTargetFingerprint,
|
||||
} from "../comboAgentMiddleware.ts";
|
||||
import {
|
||||
clampStickyWeightedTargetLimit,
|
||||
getStickyRoundRobinStartIndex,
|
||||
@@ -254,11 +258,16 @@ export async function tryPinnedModelDispatch(args: {
|
||||
// when allCombos is authoritative (non-empty) so we can resolve combo-refs;
|
||||
// the auto-combo redirect path passes an empty list and keeps prior behavior.
|
||||
const haveFullCombos = Array.isArray(allCombos) ? allCombos.length > 0 : !!allCombos;
|
||||
const pinInCombo =
|
||||
!haveFullCombos ||
|
||||
resolveComboTargets(combo, allCombos, clampComboDepth(config.maxComboDepth)).some(
|
||||
(t) => t.modelStr === pinnedModel
|
||||
);
|
||||
// Eagerly resolve the combo's targets once (used for the pin-validity check AND
|
||||
// #5501 template expansion). A non-authoritative allCombos (empty/missing)
|
||||
// resolves to the combo's direct targets only — same semantics as the original
|
||||
// `!haveFullCombos ||` short-circuit, without feeding `[]` to the nested resolver.
|
||||
const comboTargets = resolveComboTargets(
|
||||
combo,
|
||||
haveFullCombos ? allCombos : undefined,
|
||||
clampComboDepth(config.maxComboDepth)
|
||||
);
|
||||
const pinInCombo = !haveFullCombos || comboTargets.some((t) => t.modelStr === pinnedModel);
|
||||
// Honor the pin only if it is still a combo target AND its provider is not
|
||||
// DURABLY down. Without the health gate a pin keeps routing a session to a
|
||||
// dead/credits-exhausted/throttled account forever (strategy bypassed, no
|
||||
@@ -273,7 +282,21 @@ export async function tryPinnedModelDispatch(args: {
|
||||
);
|
||||
let pinnedResult: Response | null = null;
|
||||
try {
|
||||
pinnedResult = await handleSingleModelWithTimeout(body, pinnedModel, {
|
||||
// #5501: the combo system_message also expands on the pinned context path —
|
||||
// a session pin bypasses the main loop, so without this the template would
|
||||
// go literal from the second in-session request on. Target context comes
|
||||
// from the pinned model's resolved combo target when available.
|
||||
const pinnedTarget = comboTargets.find((t) => t.modelStr === pinnedModel);
|
||||
const pinnedBody = expandComboSystemPromptIfPresent(body, combo, {
|
||||
modelId: pinnedModel,
|
||||
providerId: pinnedTarget && pinnedTarget.provider !== "unknown" ? pinnedTarget.provider : "",
|
||||
account:
|
||||
typeof pinnedTarget?.label === "string" && pinnedTarget.label.trim().length > 0
|
||||
? pinnedTarget.label.trim()
|
||||
: "",
|
||||
fingerprint: pinnedTarget ? resolveTargetFingerprint(pinnedTarget) ?? "" : "",
|
||||
});
|
||||
pinnedResult = await handleSingleModelWithTimeout(pinnedBody, pinnedModel, {
|
||||
modelPinned: true,
|
||||
} as SingleModelTarget);
|
||||
} catch (pinErr) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
* All features are opt-in per combo and backward compatible with existing setups.
|
||||
*/
|
||||
|
||||
import { isFingerprintProvider } from "./combo/fingerprintExpansion.ts";
|
||||
|
||||
interface ComboConfig {
|
||||
system_message?: string | null;
|
||||
tool_filter_regex?: string | null;
|
||||
@@ -221,3 +223,122 @@ export function applyComboAgentMiddleware(
|
||||
pinnedModel,
|
||||
};
|
||||
}
|
||||
|
||||
// ── System Prompt Template Expansion (#5501) ─────────────────────────────────
|
||||
|
||||
export interface ComboSystemPromptTemplateContext {
|
||||
modelId: string;
|
||||
providerId: string;
|
||||
account: string;
|
||||
fingerprint: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace allowlisted `{{TOKEN}}` placeholders in a single left-to-right scan.
|
||||
* No regex (ReDoS-averse, cf. #3870) and no recursion: an expanded value is
|
||||
* appended to the output and never re-scanned. Unknown tokens ({{FOO}}) and
|
||||
* dangling "{{" stay literal.
|
||||
*/
|
||||
function expandStringTemplates(value: string, values: Record<string, string>): string {
|
||||
let out = "";
|
||||
let rest = value;
|
||||
while (rest.length > 0) {
|
||||
const start = rest.indexOf("{{");
|
||||
if (start === -1) {
|
||||
out += rest;
|
||||
break;
|
||||
}
|
||||
const end = rest.indexOf("}}", start + 2);
|
||||
if (end === -1) {
|
||||
out += rest;
|
||||
break;
|
||||
}
|
||||
const token = rest.slice(start, end + 2);
|
||||
out += rest.slice(0, start);
|
||||
out += token in values ? values[token] : token;
|
||||
rest = rest.slice(end + 2);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand allowlisted placeholders in the combo-injected system prompt (#5501).
|
||||
*
|
||||
* Strictly scoped to the content the combo override produced — never
|
||||
* client-owned system content:
|
||||
* - Responses API body (has `instructions`) → expand `body.instructions`.
|
||||
* - messages body → expand `body.messages[0]` when it is the injected combo
|
||||
* system message (the override filters all system messages and injects its
|
||||
* own at index 0 with string content).
|
||||
* - otherwise → body unchanged.
|
||||
*/
|
||||
export function expandComboSystemPromptTemplates(
|
||||
body: Record<string, unknown>,
|
||||
ctx: ComboSystemPromptTemplateContext
|
||||
): Record<string, unknown> {
|
||||
const values: Record<string, string> = {
|
||||
"{{MODEL_ID}}": ctx.modelId,
|
||||
"{{PROVIDER_ID}}": ctx.providerId,
|
||||
"{{ACCOUNT}}": ctx.account,
|
||||
"{{FINGERPRINT}}": ctx.fingerprint,
|
||||
};
|
||||
const result = { ...body };
|
||||
if (typeof result.instructions === "string") {
|
||||
result.instructions = expandStringTemplates(result.instructions, values);
|
||||
return result;
|
||||
}
|
||||
const messages = result.messages;
|
||||
if (Array.isArray(messages)) {
|
||||
const first = messages[0] as Record<string, unknown> | undefined;
|
||||
if (
|
||||
first &&
|
||||
(first.role === "system" || first.role === "developer") &&
|
||||
typeof first.content === "string"
|
||||
) {
|
||||
const next = [...messages];
|
||||
next[0] = { ...first, content: expandStringTemplates(first.content, values) };
|
||||
result.messages = next;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate + expand: expand the combo `system_message` template placeholders only
|
||||
* when the combo actually defines a non-empty `system_message`. Client-owned
|
||||
* content passes through untouched (single gate shared by every dispatch path).
|
||||
*/
|
||||
export function expandComboSystemPromptIfPresent(
|
||||
body: Record<string, unknown>,
|
||||
combo: { system_message?: string | null },
|
||||
ctx: ComboSystemPromptTemplateContext
|
||||
): Record<string, unknown> {
|
||||
if (typeof combo.system_message === "string" && combo.system_message.trim()) {
|
||||
return expandComboSystemPromptTemplates(body, ctx);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the device fingerprint for a combo target (#5501, #6087).
|
||||
* Only fingerprint-based providers carry fingerprints (see isFingerprintProvider).
|
||||
* Priority: explicit pin (`pinnedFingerprint`, combo builder) → the `@fp:`
|
||||
* suffix in `executionKey` (auto-rotation).
|
||||
* Returns null when none is knowable (the first fingerprint of an auto-rotated
|
||||
* set keeps the bare execution key — documented limitation).
|
||||
*/
|
||||
export function resolveTargetFingerprint(target: {
|
||||
provider: string;
|
||||
pinnedFingerprint?: string;
|
||||
executionKey?: string;
|
||||
}): string | null {
|
||||
if (!isFingerprintProvider(target.provider)) return null;
|
||||
if (target.pinnedFingerprint) return target.pinnedFingerprint;
|
||||
const key = target.executionKey;
|
||||
if (key) {
|
||||
const marker = "@fp:";
|
||||
const idx = key.lastIndexOf(marker);
|
||||
if (idx !== -1) return key.slice(idx + marker.length);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
278
open-sse/services/usage/grokCli.ts
Normal file
278
open-sse/services/usage/grokCli.ts
Normal 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,
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }) +
|
||||
|
||||
249
open-sse/utils/resourcePressure.ts
Normal file
249
open-sse/utils/resourcePressure.ts
Normal 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";
|
||||
344
open-sse/utils/resourcePressurePolicy.ts
Normal file
344
open-sse/utils/resourcePressurePolicy.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
257
open-sse/utils/resourcePressureSampler.ts
Normal file
257
open-sse/utils/resourcePressureSampler.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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
400
package-lock.json
generated
@@ -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",
|
||||
|
||||
20
package.json
20
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user