Compare commits

...

15 Commits

Author SHA1 Message Date
Apostol Apostolov
01c6373bcd fix: rebaseline file-size-baseline + quality ratchet doc
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-04 11:39:01 -03:00
Apostol Apostolov
aa0d99e0a5 test: add structural guard for PR #8916 provider quota compact layout
Adds node:test-based AST checks ensuring:
- QuotaCardGrid compact prop + auto-fill compact grid class survive refactors
- ProviderQuotaWidget compact prop + 3-column responsive grid survive refactors
2026-08-04 11:37:02 -03:00
Apostol Apostolov
3c483dd906 feat: improve provider quota layouts 2026-08-04 11:37:02 -03:00
Diego Rodrigues de Sa e Souza
f11d883f22 fix(cli-tools): enable Apply for compatible providers (#9250)
* fix(cli-tools): resolve models for compatible providers

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

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

* chore(changelog): fragment for #9250

---------

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

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

* chore(changelog): fragment for #9253

---------

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

* test(ci): register stream readiness mutation coverage

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

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

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

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

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

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

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

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

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

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

* docs(db): deepen MySQL conformance specification

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

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

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

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

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

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

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

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

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

Retargets the merge_conditions exception accordingly so the queue can
actually tolerate the failure mode it faces today, instead of one
that's been dormant for weeks.
2026-08-04 08:57:11 -03:00
35 changed files with 2445 additions and 949 deletions

View File

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

View File

@@ -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

695
AGENTS.md
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -385,7 +385,8 @@
"src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": 1573,
"src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx": 1028,
"src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2148,
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1109,
"_rebaseline_2026_07_30_8916_quota_compact_layout": "PR #8916 (apoapostolov, feat/improve-provider-quota-layouts) own growth: src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx 1109->1153 (+44) adds Full/Compact layout toggle — LS_LAYOUT_MODE constant, LayoutMode type, layoutMode state, toggleLayoutMode callback, toggle button with icon. At existing filter/settings chokepoint. Not extractable without splitting state + toolbar away from data-fetching. Covered by tests/unit/quota-card-grid-compact-layout-8916.test.ts.",
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1153,
"src/app/api/providers/[id]/models/route.ts": 2250,
"src/app/api/v1/models/catalog.ts": 1549,
"src/lib/db/apiKeys.ts": 1529,
@@ -413,6 +414,6 @@
"_rebaseline_2026_07_28_8860_tokenrefresh_projectid": "PR #8860 (fix/antigravity-projectid-centralized) own test growth: tests/unit/token-refresh-service.test.ts 1311->1378 (+67 = 4 cases covering projectId discovery on the tokenRefresh.ts path — the Dashboard/health-check refresh route, which #8842 did not reach since that fixed the executor path). Covered by the same file.",
"_rebaseline_2026_07_28_8861_xiaomi_token_plan": "PR #8861 (feat/xiaomi-token-plan-protocol-selector) own growth: EditConnectionModal.tsx 1283->1316 (+33 = the per-connection API-protocol selector field) and open-sse/executors/base.ts 1540->1562 (+22 = alternate-format resolution at the existing buildUrl/headers chokepoint). Both are irreducible wiring at existing call sites.",
"_rebaseline_2026_07_28_8863_firefly_detail_level": "PR #8863 (fix/adobe-firefly-gpt-detail-level-max) own growth: adobeFireflyClient.ts 2317->2322 (+5 = gpt-image detailLevel defaulting to maximal at the existing payload-build site). Covered by tests/unit/adobe-firefly.test.ts.",
"_rebaseline_2026_07_29_8281_home_quickstart_prefetch": "Release v3.8.49 base-red fix (no PR — captain sweep): src/app/(dashboard)/dashboard/HomePageClient.tsx 1377->1381 (+4). #8292 added prefetch={false} to the sidebar but left /home's five quick-start Links prefetching, so first paint still fired 12 speculative RSC requests — caught by navigation.spec.ts only after the e2e helper bug (APP_ROUTE_PATTERN missing /home) was repaired in the same cycle. Growth is the five prefetch attributes; it was offset first by extracting the repeated className literals (INLINE_LINK x4, DOCS_LINK x1), which collapsed five wrapped <Link> blocks back to one line each — a naive fix measured 1391. Guard: tests/unit/sidebar-prefetch-policy-8281.test.ts.",
"_rebaseline_2026_07_29_8281_home_quickstart_prefetch": "Release v3.8.49 base-red fix (no PR — captain sweep): src/app/(dashboard)/dashboard/HomePageClient.tsx 1377->1381 (+4). #8292 added prefetch={false} to the sidebar but left /home's five quick-start Links prefetching, so first paint still fired 12 speculative RSC requests — caught by navigation.spec.ts only after the e2e helper bug (APP_ROUTE_PATTERN missing /home) was repaired in the same cycle. Growth is the five prefetch attributes; it was offset first by extracting the repeated className literals (INLINE_LINK x4, DOCS_LINK x1), which collapsed five wrapped <Link> blocks back to one line each — a naive fix measured 1391. Guard: tests/unit/sidebar-prefetch-policy-8281.test.ts.",
"_rebaseline_2026_08_02_v3850_agentrouter_responses": "Release v3.8.50 AgentRouter/Codex compatibility reconciliation. open-sse/executors/base.ts 1562->1578: #9190 wires AgentRouter's selected Claude/OpenAI/Responses protocol through the existing executor URL, auth, identity-header and fingerprint chokepoints; the reusable alternate resolver remains outside base.ts. open-sse/utils/stream.ts 2887->2889: #9213 evaluates Responses ID and usage normalization independently so response.completed always receives finite usage.total_tokens instead of short-circuiting after an ID rewrite. tests/unit/chatcore-translation-paths.test.ts 2769->2776: #9191 updates the existing Claude-Code bridge assertions for the dynamic AgentRouter wire image. PR #9224 offsets its own chatCore growth by extracting the AgentRouter protocol decisions into chatCore/agentRouterProtocol.ts, leaving chatCore below its frozen ceiling. Covered by agentrouter executor/chatCore protocol tests, chatcore translation-path tests, and responses-commentary-passthrough tests."
}

View File

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

View File

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

View File

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

View File

@@ -4651,12 +4651,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 +4663,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 +4679,7 @@ export async function handleChatCore({
success: false,
status: failureResponse.status,
error: reason,
classificationError: classificationReason,
errorType: streamReadiness.type,
errorCode: streamReadiness.code,
response: failureResponse,

View File

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

View File

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

View File

@@ -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
),
};
}

View File

@@ -0,0 +1,62 @@
# Quality Ratchet
| Métrica | Baseline | Atual | Status |
|---|---|---|---|
| eslintWarnings | 0 | 0 | ok |
| eslintErrors | 0 | 0 | ok |
| coverage.statements | 80.8 | — | SKIP (ausente) |
| coverage.lines | 80.8 | — | SKIP (ausente) |
| coverage.functions | 86.42 | — | SKIP (ausente) |
| coverage.branches | 78.1 | — | SKIP (ausente) |
| coverage.chatCore.lines | 72.45 | — | SKIP (ausente) |
| coverage.combo.lines | 85.42 | — | SKIP (ausente) |
| coverage.accountFallback.lines | 96.78 | — | SKIP (ausente) |
| coverage.auth.lines | 92.55 | — | SKIP (ausente) |
| coverage.routeGuard.lines | 98.73 | — | SKIP (ausente) |
| coverage.error.lines | 92.13 | — | SKIP (ausente) |
| coverage.publicCreds.lines | 99.07 | — | SKIP (ausente) |
| coverage.circuitBreaker.lines | 95.09 | — | SKIP (ausente) |
| openapiCoverage.pct | 38 | 38 | ok |
| i18nUiCoverage.pct | 99 | 99 | ok |
| deadExports | 227 | — | SKIP (dedicated gate) |
| cognitiveComplexity | 1223 | — | SKIP (dedicated gate) |
| typeCoveragePct | 92.17 | — | SKIP (dedicated gate) |
| codeqlAlerts | 0 | — | SKIP (dedicated gate) |
| secretFindings | 0 | — | SKIP (dedicated gate) |
| zizmorFindings | 190 | — | SKIP (dedicated gate) |
| vulnCount | 10 | — | SKIP (dedicated gate) |
| bundleSize | 7666 | — | SKIP (dedicated gate) |
| openapiBreaking | 0 | — | SKIP (dedicated gate) |
| mutationScore.src/sse/services/auth.ts | 52.57 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/services/accountFallback.ts | 68.38 | — | SKIP (dedicated gate) |
| mutationScore.src/server/authz/routeGuard.ts | 76.08 | — | SKIP (dedicated gate) |
| mutationScore.src/shared/utils/circuitBreaker.ts | 56.94 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/utils/error.ts | 43.83 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/utils/publicCreds.ts | 59.76 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/services/combo/autoStrategy.ts | 41.33 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/services/combo/comboStructure.ts | 57.82 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/services/combo/validateQuality.ts | 61.33 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/services/combo/comboPredicates.ts | 56.62 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/services/combo/rrState.ts | 70.88 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/services/combo/shadowRouting.ts | 48 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/services/combo/targetSorters.ts | 68.3 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/services/combo/comboData.ts | 76.94 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/services/combo/quotaScoring.ts | 39.73 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/services/combo/quotaStrategies.ts | 50.3 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/handlers/chatCore/passthroughHelpers.ts | 80.89 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/handlers/chatCore/sanitization.ts | 70.15 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/handlers/chatCore/upstreamTimeouts.ts | 33 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/handlers/chatCore/comboContextCache.ts | 13.62 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/handlers/chatCore/idempotency.ts | 42.82 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/handlers/chatCore/responseHeaders.ts | 62.7 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/handlers/chatCore/executorHelpers.ts | 70.39 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/handlers/chatCore/memoryExtraction.ts | 62.06 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/handlers/chatCore/nonStreamingSse.ts | 72.82 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/handlers/chatCore/passthroughToolNames.ts | 66.42 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/handlers/chatCore/headers.ts | 94.29 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/handlers/chatCore/logTruncation.ts | 77.64 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/handlers/chatCore/memorySkillsInjection.ts | 13.49 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/handlers/chatCore/semanticCache.ts | 60.16 | — | SKIP (dedicated gate) |
| mutationScore.open-sse/handlers/chatCore/telemetryHelpers.ts | 83.18 | — | SKIP (dedicated gate) |
**Sem regressões — gate OK.**

View File

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

View File

@@ -1076,7 +1076,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
</div>
)}
{/* Pinned Provider Quota Limits (compact, no filters) */}
{/* Pinned Provider Quota Limits */}
{pinProviderQuotaToHome && (
<Suspense fallback={<CardSkeleton />}>
<ProviderQuotaWidget

View File

@@ -133,10 +133,55 @@ export default function ToolDetailClient({ toolId, category }: ToolDetailClientP
});
}
});
if (providerModels.length === 0) {
const prefix =
typeof conn.providerSpecificData?.prefix === "string" &&
conn.providerSpecificData.prefix.trim()
? conn.providerSpecificData.prefix.trim()
: alias;
const fallbackModels: Array<{ id: string; name: string }> = [];
const addFallbackModel = (model: any) => {
const id = typeof model?.id === "string" ? model.id.trim() : "";
if (!id || fallbackModels.some((candidate) => candidate.id === id)) return;
fallbackModels.push({
id,
name: typeof model?.name === "string" && model.name.trim() ? model.name.trim() : id,
});
};
if (typeof conn.defaultModel === "string" && conn.defaultModel.trim()) {
addFallbackModel({ id: conn.defaultModel });
}
if (Array.isArray(conn.providerSpecificData?.customModels)) {
conn.providerSpecificData.customModels.forEach(addFallbackModel);
}
if (fallbackModels.length === 0 && conn.testStatus === "active") {
addFallbackModel({ id: "model-id", name: `${prefix}/model-id` });
}
fallbackModels.forEach((model) => {
const modelValue = `${prefix}/${model.id}`;
if (seenModels.has(modelValue)) return;
seenModels.add(modelValue);
models.push({
value: modelValue,
label: modelValue,
provider: conn.provider,
alias: prefix,
connectionName: conn.name,
modelId: model.id,
});
});
}
});
const activeAliases = new Set(
activeProviders.map((c) => PROVIDER_ID_TO_ALIAS[c.provider] || c.provider)
activeProviders.flatMap((connection) => {
const alias = PROVIDER_ID_TO_ALIAS[connection.provider] || connection.provider;
const prefix = connection.providerSpecificData?.prefix;
return typeof prefix === "string" && prefix.trim() ? [alias, prefix.trim()] : [alias];
})
);
const activeProviderIds = new Set(activeProviders.map((c) => c.provider));
dynamicModels.forEach((dm) => {

View File

@@ -23,6 +23,7 @@ interface Props {
quotaVisibility?: Record<string, { hidden?: string[] }>;
onHideQuota?: (provider: string, quota: any) => void;
onShowQuota?: (provider: string, quota: any) => void;
compact?: boolean;
}
export default function QuotaCardGrid({
@@ -44,9 +45,41 @@ export default function QuotaCardGrid({
quotaVisibility,
onHideQuota,
onShowQuota,
compact = false,
}: Props) {
if (connections.length === 0) return null;
const renderCard = (conn: (typeof connections)[number]) => (
<QuotaCard
key={conn.id}
connection={conn}
quota={quotaData[conn.id]}
loading={!!loading[conn.id]}
error={errors[conn.id] || null}
refreshedAt={lastRefreshedAt[conn.id]}
emailsVisible={emailsVisible}
providerLabel={providerLabels[conn.provider] || conn.provider}
onRefresh={() => onRefresh(conn.id, conn.provider)}
onOpenCutoff={() => onOpenCutoff(conn)}
onRedeemResetCredit={() => onRedeemResetCredit?.(conn.id, conn.provider)}
onToggleActive={(nextActive) => onToggleActive(conn.id, nextActive)}
togglingActive={togglingActiveId === conn.id}
redeemingResetCredit={redeemingResetCreditId === conn.id}
loadingResetCredits={loadingResetCreditsId === conn.id}
quotaVisibility={quotaVisibility}
onHideQuota={onHideQuota ? (q) => onHideQuota(conn.provider, q) : undefined}
onShowQuota={onShowQuota ? (q) => onShowQuota(conn.provider, q) : undefined}
/>
);
if (compact) {
return (
<div className="grid grid-cols-[repeat(auto-fill,minmax(17rem,1fr))] gap-3">
{connections.map(renderCard)}
</div>
);
}
// Group connections by provider, preserving the order from sortedConnections.
const groups = new Map<string, typeof connections>();
for (const conn of connections) {
@@ -66,28 +99,7 @@ export default function QuotaCardGrid({
</span>
</h3>
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,280px),1fr))] gap-3">
{conns.map((conn) => (
<QuotaCard
key={conn.id}
connection={conn}
quota={quotaData[conn.id]}
loading={!!loading[conn.id]}
error={errors[conn.id] || null}
refreshedAt={lastRefreshedAt[conn.id]}
emailsVisible={emailsVisible}
providerLabel={providerLabels[conn.provider] || conn.provider}
onRefresh={() => onRefresh(conn.id, conn.provider)}
onOpenCutoff={() => onOpenCutoff(conn)}
onOpenResetCredits={() => onOpenResetCredits?.(conn.id, conn.provider)}
onToggleActive={(nextActive) => onToggleActive(conn.id, nextActive)}
togglingActive={togglingActiveId === conn.id}
redeemingResetCredit={redeemingResetCreditId === conn.id}
loadingResetCredits={loadingResetCreditsId === conn.id}
quotaVisibility={quotaVisibility}
onHideQuota={onHideQuota ? (q) => onHideQuota(conn.provider, q) : undefined}
onShowQuota={onShowQuota ? (q) => onShowQuota(conn.provider, q) : undefined}
/>
))}
{conns.map(renderCard)}
</div>
</div>
))}

View File

@@ -44,6 +44,7 @@ const LS_PURCHASE_FILTER = "omniroute:limits:purchaseFilter";
const LS_STATUS_FILTER = "omniroute:limits:statusFilter";
const LS_ENV_FILTER = "omniroute:limits:envFilter";
const LS_PROVIDER_FILTER = "omniroute:limits:providerFilter";
const LS_LAYOUT_MODE = "omniroute:limits:layoutMode";
const MIN_FETCH_INTERVAL_MS = 30000;
const QUOTA_BAR_GREEN_THRESHOLD = 50;
@@ -51,6 +52,7 @@ const QUOTA_BAR_YELLOW_THRESHOLD = 20;
type PurchaseTypeKey = "all" | "oauth-free" | "oauth-sub" | "apikey";
type StatusKey = "all" | "critical" | "alert" | "ok" | "empty";
type LayoutMode = "full" | "compact";
const PURCHASE_TYPES: Array<{ key: PurchaseTypeKey; labelKey: string; fallback: string }> = [
{ key: "all", labelKey: "purchaseAll", fallback: "All" },
@@ -231,6 +233,10 @@ export default function ProviderLimits({
if (typeof window === "undefined") return "all";
return localStorage.getItem(LS_PROVIDER_FILTER) || "all";
});
const [layoutMode, setLayoutMode] = useState<LayoutMode>(() => {
if (typeof window === "undefined") return "full";
return localStorage.getItem(LS_LAYOUT_MODE) === "compact" ? "compact" : "full";
});
const lastFetchTimeRef = useRef<Record<string, number>>({});
const staleProbeRef = useRef<Record<string, number>>({});
@@ -746,6 +752,18 @@ export default function ProviderLimits({
}
}, []);
const toggleLayoutMode = useCallback(() => {
setLayoutMode((current) => {
const next = current === "full" ? "compact" : "full";
try {
localStorage.setItem(LS_LAYOUT_MODE, next);
} catch {
/* ignore */
}
return next;
});
}, []);
const renderInlineQuotaSummary = (quotas: any[]) => {
if (!quotas || quotas.length === 0) return null;
return (
@@ -816,30 +834,55 @@ export default function ProviderLimits({
</span>
</div>
<button
onClick={refreshAll}
disabled={refreshingAll}
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg bg-bg-subtle border border-border text-text-main text-[13px] disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
title={
autoRefreshIntervalMs > 0 ? tr("autoRefreshing", "Auto-refreshing") : t("refreshAll")
}
>
<span
className={`material-symbols-outlined text-[16px] ${refreshingAll ? "animate-spin" : ""}`}
<div className="flex items-center gap-2">
<button
type="button"
onClick={toggleLayoutMode}
aria-pressed={layoutMode === "compact"}
aria-label={
layoutMode === "compact"
? "Switch to full quota layout"
: "Switch to compact quota layout"
}
title={
layoutMode === "compact"
? "Switch to full quota layout"
: "Switch to compact quota layout"
}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-bg-subtle border border-border text-text-main text-[13px] cursor-pointer"
>
{autoRefreshIntervalMs > 0 ? "schedule" : "refresh"}
</span>
{refreshingAll
? tr("refreshing", "Refreshing")
: autoRefreshIntervalMs > 0
? `${tr("autoRefreshing", "Auto-refreshing")} ${formatAutoRefreshCountdown(
Math.max(
0,
autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAtRef.current)
)
)}`
: t("refreshAll")}
</button>
<span className="material-symbols-outlined text-[16px]" aria-hidden>
{layoutMode === "compact" ? "view_agenda" : "grid_view"}
</span>
<span className="hidden sm:inline">
{layoutMode === "compact" ? "Compact" : "Full"}
</span>
</button>
<button
onClick={refreshAll}
disabled={refreshingAll}
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg bg-bg-subtle border border-border text-text-main text-[13px] disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
title={
autoRefreshIntervalMs > 0 ? tr("autoRefreshing", "Auto-refreshing") : t("refreshAll")
}
>
<span
className={`material-symbols-outlined text-[16px] ${refreshingAll ? "animate-spin" : ""}`}
>
{autoRefreshIntervalMs > 0 ? "schedule" : "refresh"}
</span>
{refreshingAll
? tr("refreshing", "Refreshing")
: autoRefreshIntervalMs > 0
? `${tr("autoRefreshing", "Auto-refreshing")} ${formatAutoRefreshCountdown(
Math.max(
0,
autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAtRef.current)
)
)}`
: t("refreshAll")}
</button>
</div>
</div>
{showFilters && (
@@ -1048,6 +1091,7 @@ export default function ProviderLimits({
onShowQuota={handleShowQuota}
redeemingResetCreditId={resetCreditRedemption.redeemingResetCreditId}
loadingResetCreditsId={resetCreditRedemption.loadingResetCreditsId}
compact={layoutMode === "compact"}
/>
</div>

View File

@@ -1,26 +1,46 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import Card from "@/shared/components/Card";
import ProviderIcon from "@/shared/components/ProviderIcon";
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
import QuotaMiniBar from "../dashboard/usage/components/ProviderLimits/QuotaMiniBar";
import { PROVIDER_LABEL } from "../dashboard/usage/components/ProviderLimits/constants";
import { translateUsageOrFallback } from "../dashboard/usage/components/ProviderLimits/i18nFallback";
import { isProviderQuotaVisible } from "@/shared/utils/providerQuotaVisibility";
import { parseQuotaData } from "../dashboard/usage/components/ProviderLimits/quotaParsing";
import {
formatCountdown,
formatQuotaLabel,
getBarColor,
getQuotaRemainingPercentage,
} from "../dashboard/usage/components/ProviderLimits/utils";
const PRIMARY_QUOTA_COUNT = 3;
type Connection = {
id: string;
provider: string;
authType?: string;
email?: string;
name?: string;
quotaVisible?: boolean;
displayName?: string;
email?: string;
};
type QuotaData = Record<string, any>;
interface ProviderQuotaWidgetProps {
autoRefreshInterval?: number;
compact?: boolean;
}
function formatUpdatedAt(updatedAt: number | null): string | null {
if (!updatedAt) return null;
return new Date(updatedAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
function formatAutoRefreshCountdown(ms: number): string {
@@ -30,23 +50,172 @@ function formatAutoRefreshCountdown(ms: number): string {
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
}
export function AutoRefreshButtonLabel({
autoRefreshIntervalMs,
lastRefreshAllAt,
refreshingAll,
tr,
}: {
autoRefreshIntervalMs: number;
lastRefreshAllAt: number;
refreshingAll: boolean;
tr: (key: string, fallback: string) => string;
}) {
const [now, setNow] = useState(() => Date.now());
function QuotaRow({ quota }: { quota: any }) {
const t = useTranslations("usage");
const percentage = Math.round(getQuotaRemainingPercentage(quota));
const colors = getBarColor(percentage);
const label = quota.displayName || formatQuotaLabel(quota.name) || quota.name;
const reset = formatCountdown(quota.resetAt);
if (quota.isCredits || quota.isResetCredits) {
const amount = Number(quota.creditCount ?? quota.remaining ?? 0).toLocaleString(undefined, {
maximumFractionDigits: 2,
});
return (
<div className="flex min-w-0 items-center justify-between gap-3 py-1.5">
<span className="min-w-0 truncate text-xs font-medium text-text-main">{label}</span>
<span className="shrink-0 text-xs font-bold tabular-nums" style={{ color: colors.text }}>
{amount}
</span>
</div>
);
}
return (
<div className="flex min-w-0 flex-col gap-1 py-1.5" title={quota.modelKey || quota.name}>
<div className="flex items-center justify-between gap-3">
<span className="min-w-0 truncate text-xs font-medium text-text-main">{label}</span>
<span className="shrink-0 text-xs font-bold tabular-nums" style={{ color: colors.text }}>
{quota.unlimited
? "∞"
: translateUsageOrFallback(t, "percentLeft", `${percentage}% left`, {
pct: percentage,
})}
</span>
</div>
{!quota.unlimited && <QuotaMiniBar percent={percentage} />}
{reset && <span className="text-[10px] text-text-muted"> {reset}</span>}
</div>
);
}
function ConnectionQuotas({ connection, cache }: { connection: Connection; cache: any }) {
const t = useTranslations("usage");
const [showOptional, setShowOptional] = useState(false);
const quotas = useMemo(
() => parseQuotaData(connection.provider, cache),
[cache, connection.provider]
);
const primaryQuotas = quotas.slice(0, PRIMARY_QUOTA_COUNT);
const optionalQuotas = quotas.slice(PRIMARY_QUOTA_COUNT);
const accountLabel = connection.name || connection.displayName || connection.email;
return (
<div className="min-w-0">
{accountLabel && <p className="mb-1 text-[11px] text-text-muted truncate">{accountLabel}</p>}
{quotas.length === 0 ? (
<p className="py-1.5 text-xs italic text-text-muted">
{cache?.message || t("noQuotaData")}
</p>
) : (
<div className="grid grid-cols-1 gap-x-6 sm:grid-cols-2">
{primaryQuotas.map((quota, index) => (
<div
key={`${quota.name}-${quota.modelKey || ""}-${index}`}
className="border-b border-border/40"
>
<QuotaRow quota={quota} />
</div>
))}
{showOptional &&
optionalQuotas.map((quota, index) => (
<div
key={`${quota.name}-${quota.modelKey || ""}-${index}`}
className="border-b border-border/40"
>
<QuotaRow quota={quota} />
</div>
))}
</div>
)}
{optionalQuotas.length > 0 && (
<button
type="button"
onClick={() => setShowOptional((current) => !current)}
className="mt-2 inline-flex items-center gap-1 rounded-md border border-border bg-bg-subtle px-2 py-1 text-[11px] font-medium text-text-main hover:bg-surface transition-colors"
>
<span className="material-symbols-outlined text-[12px]" aria-hidden="true">
{showOptional ? "expand_less" : "expand_more"}
</span>
{showOptional
? t("showLessQuotas")
: t("showMoreQuotas", { count: optionalQuotas.length })}
</button>
)}
</div>
);
}
export default function ProviderQuotaWidget({
autoRefreshInterval = 0,
compact = false,
}: ProviderQuotaWidgetProps) {
const t = useTranslations("usage");
const tr = useCallback(
(key: string, fallback: string) => translateUsageOrFallback(t, key, fallback),
[t]
);
const [connections, setConnections] = useState<Connection[]>([]);
const [quotaData, setQuotaData] = useState<QuotaData>({});
const [loading, setLoading] = useState(true);
const [refreshingAll, setRefreshingAll] = useState(false);
const [updatedAt, setUpdatedAt] = useState<number | null>(null);
const refreshingAllRef = useRef(false);
const lastRefreshAllAtRef = useRef(Date.now());
const autoRefreshIntervalMs = autoRefreshInterval > 0 ? autoRefreshInterval * 1000 : 0;
const [autoRefreshClock, setAutoRefreshClock] = useState(() => Date.now());
const loadData = useCallback(async () => {
setLoading(true);
try {
const [connectionsResponse, quotasResponse] = await Promise.all([
fetch("/api/providers/client"),
fetch("/api/usage/provider-limits"),
]);
const connectionData = connectionsResponse.ok ? await connectionsResponse.json() : {};
const quotaResponseData = quotasResponse.ok ? await quotasResponse.json() : {};
const relevant = ((connectionData.connections || []) as Connection[]).filter(
(connection) =>
USAGE_SUPPORTED_PROVIDERS.includes(connection.provider) &&
(connection.authType === "oauth" || connection.authType === "apikey")
);
setConnections(relevant);
setQuotaData(quotaResponseData.caches || {});
setUpdatedAt(Date.now());
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (autoRefreshIntervalMs <= 0 || refreshingAll) return;
void loadData();
}, [loadData]);
const tick = () => setNow(Date.now());
const refreshAll = useCallback(async () => {
if (refreshingAllRef.current) return;
refreshingAllRef.current = true;
const now = Date.now();
lastRefreshAllAtRef.current = now;
setAutoRefreshClock(now);
setRefreshingAll(true);
try {
const response = await fetch("/api/usage/provider-limits", { method: "POST" });
if (!response.ok) throw new Error("Failed to refresh provider quotas");
const data = await response.json();
setQuotaData(data.caches || {});
setUpdatedAt(Date.now());
} catch (error) {
console.error("ProviderQuotaWidget refreshAll error:", error);
} finally {
refreshingAllRef.current = false;
setRefreshingAll(false);
}
}, []);
useEffect(() => {
if (autoRefreshIntervalMs <= 0) return;
const tick = () => setAutoRefreshClock(Date.now());
tick();
const timer = window.setInterval(tick, 1000);
@@ -59,248 +228,131 @@ export function AutoRefreshButtonLabel({
window.clearInterval(timer);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [autoRefreshIntervalMs, refreshingAll, lastRefreshAllAt]);
if (refreshingAll) {
return <>{tr("refreshing", "Refreshing")}</>;
}
if (autoRefreshIntervalMs <= 0) {
return <>{tr("refreshAll", "Refresh All")}</>;
}
return (
<>
{tr("autoRefreshing", "Auto-refreshing")}{" "}
{formatAutoRefreshCountdown(Math.max(0, autoRefreshIntervalMs - (now - lastRefreshAllAt)))}
</>
);
}
export default function ProviderQuotaWidget({ autoRefreshInterval = 0 }: ProviderQuotaWidgetProps) {
const t = useTranslations("usage");
const tr = useCallback(
(key: string, fallback: string) => translateUsageOrFallback(t, key, fallback),
[t]
);
const [connections, setConnections] = useState<Connection[]>([]);
const [quotaData, setQuotaData] = useState<QuotaData>({});
const [loading, setLoading] = useState(true);
const [refreshingAll, setRefreshingAll] = useState(false);
const refreshingAllRef = useRef(false);
const lastRefreshAllAtRef = useRef(Date.now());
const [lastRefreshAllAt, setLastRefreshAllAt] = useState(() => lastRefreshAllAtRef.current);
const autoRefreshIntervalMs = autoRefreshInterval > 0 ? autoRefreshInterval * 1000 : 0;
const fetchConnections = useCallback(async () => {
try {
const res = await fetch("/api/providers/client");
if (!res.ok) throw new Error("Failed to load connections");
const data = await res.json();
return (data.connections || []) as Connection[];
} catch {
return [];
}
}, []);
const fetchCached = useCallback(async () => {
try {
const res = await fetch("/api/usage/provider-limits");
if (!res.ok) throw new Error("Failed");
const data = await res.json();
return data.caches || {};
} catch {
return {};
}
}, []);
const loadData = useCallback(async () => {
setLoading(true);
const [conns, caches] = await Promise.all([fetchConnections(), fetchCached()]);
// Only keep connections that are usage/quota supported
const relevant = conns.filter(
(c) =>
isProviderQuotaVisible(c) &&
USAGE_SUPPORTED_PROVIDERS.includes(c.provider) &&
(c.authType === "oauth" || c.authType === "apikey")
);
setConnections(relevant);
setQuotaData(caches);
setLoading(false);
}, [fetchConnections, fetchCached]);
useEffect(() => {
loadData();
}, [loadData]);
const refreshAll = useCallback(async () => {
if (refreshingAllRef.current) return;
refreshingAllRef.current = true;
const now = Date.now();
lastRefreshAllAtRef.current = now;
setLastRefreshAllAt(now);
setRefreshingAll(true);
try {
const res = await fetch("/api/usage/provider-limits", { method: "POST" });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || "Refresh failed");
}
const data = await res.json();
setQuotaData(data.caches || {});
} catch (e) {
console.error("ProviderQuotaWidget refreshAll error:", e);
} finally {
refreshingAllRef.current = false;
setRefreshingAll(false);
}
}, []);
}, [autoRefreshIntervalMs]);
useEffect(() => {
if (autoRefreshIntervalMs <= 0) return;
if (document.visibilityState !== "visible") return;
if (refreshingAllRef.current) return;
const maybeRefresh = () => {
if (document.visibilityState !== "visible") return;
if (refreshingAllRef.current) return;
if (Date.now() - lastRefreshAllAtRef.current >= autoRefreshIntervalMs) {
void refreshAll();
}
};
if (autoRefreshClock - lastRefreshAllAtRef.current >= autoRefreshIntervalMs) {
void refreshAll();
}
}, [autoRefreshClock, autoRefreshIntervalMs, refreshAll]);
maybeRefresh();
const timer = window.setInterval(maybeRefresh, 1000);
const handleVisibilityChange = () => maybeRefresh();
const providerGroups = useMemo(() => {
const groups = new Map<string, Connection[]>();
for (const connection of connections) {
const group = groups.get(connection.provider) || [];
group.push(connection);
groups.set(connection.provider, group);
}
return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b));
}, [connections]);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.clearInterval(timer);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [autoRefreshIntervalMs, refreshAll]);
// Simple summary: group by provider for display
const providerGroups = connections.reduce<Record<string, Connection[]>>((acc, conn) => {
if (!acc[conn.provider]) acc[conn.provider] = [];
acc[conn.provider].push(conn);
return acc;
}, {});
const providerEntries = Object.entries(providerGroups).sort(([a], [b]) => a.localeCompare(b));
const updatedLabel = formatUpdatedAt(updatedAt);
return (
<Card className="overflow-hidden">
{/* Header with title + Refresh All in upper right */}
<div className="flex items-center justify-between border-b border-border px-4 py-3 bg-surface/60">
<Card className="w-full overflow-hidden">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border bg-surface/60 px-4 py-3">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-primary text-[20px]">
<span className="material-symbols-outlined text-[20px] text-primary" aria-hidden="true">
account_balance
</span>
<div>
<h3 className="font-semibold text-base">{tr("providerQuota", "Provider Quota")}</h3>
<p className="text-[11px] text-text-muted -mt-0.5">
{tr("providerQuotaHomeHint", "Live status across connected accounts")}
</p>
<h2 className="text-base font-semibold text-text-main">
{tr("providerQuota", "Provider Quota")}
</h2>
{updatedLabel && (
<p className="text-[11px] text-text-muted">
{tr("updatedShort", "Updated")} {updatedLabel}
</p>
)}
</div>
</div>
<button
type="button"
onClick={refreshAll}
disabled={refreshingAll || loading}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-bg-subtle text-xs font-medium text-text-main disabled:opacity-50 disabled:cursor-not-allowed hover:bg-surface transition-colors"
title={
autoRefreshIntervalMs > 0
? tr("autoRefreshing", "Auto-refreshing")
: tr("refreshAll", "Refresh All")
}
disabled={loading || refreshingAll}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-bg-subtle px-3 py-1.5 text-xs font-medium text-text-main transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
>
<span
className={`material-symbols-outlined text-[16px] ${refreshingAll ? "animate-spin" : ""}`}
aria-hidden="true"
>
{autoRefreshIntervalMs > 0 ? "schedule" : "refresh"}
</span>
<span>
<AutoRefreshButtonLabel
autoRefreshIntervalMs={autoRefreshIntervalMs}
lastRefreshAllAt={lastRefreshAllAt}
refreshingAll={refreshingAll}
tr={tr}
/>
</span>
{refreshingAll
? tr("refreshing", "Refreshing")
: autoRefreshIntervalMs > 0
? `${tr("autoRefreshing", "Auto-refreshing")} ${formatAutoRefreshCountdown(
Math.max(
0,
autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAtRef.current)
)
)}`
: tr("forceRefresh", "Refresh now")}
</button>
</div>
{/* Body */}
<div className="p-4">
{loading ? (
<div className="flex items-center justify-center py-8 text-text-muted text-sm">
<span className="material-symbols-outlined animate-spin mr-2">progress_activity</span>
{tr("loadingQuotas", "Loading...")}
</div>
) : providerEntries.length === 0 ? (
<div className="text-center py-6 text-sm text-text-muted">
{tr("noProviders", "No Providers Connected")}
<div className="mt-1 text-xs">
{tr(
"connectProvidersForQuota",
"Connect to providers with OAuth to track your API quota limits and usage."
)}
</div>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{providerEntries.map(([provider, conns]) => {
const firstConn = conns[0];
const cache = quotaData[firstConn?.id];
const hasQuota = cache?.quotas && Object.keys(cache.quotas).length > 0;
return (
<div
key={provider}
className="rounded-lg border border-border bg-surface/40 p-3 flex flex-col gap-2"
>
<div className="flex items-center gap-2">
<ProviderIcon providerId={provider} size={18} />
<span className="font-medium text-sm truncate">
{provider.charAt(0).toUpperCase() + provider.slice(1)}
</span>
<span className="text-[10px] text-text-muted ml-auto tabular-nums">
{conns.length}
</span>
</div>
{hasQuota ? (
<div className="text-xs text-text-muted" title={tr("details", "Details")}>
{Object.keys(cache.quotas).length}
</div>
) : (
<button
type="button"
onClick={refreshAll}
className="text-left text-xs text-amber-600 dark:text-amber-500 hover:underline"
>
{tr("refreshAll", "Refresh All")}
</button>
)}
{/* Future: embed small QuotaProgressBar for the primary window here */}
</div>
);
})}
</div>
)}
<div className="mt-3 text-[11px] text-right text-text-muted">
<a href="/dashboard/usage?tab=limits" className="hover:text-primary hover:underline">
{tr("viewDetails", "View details")}
<span aria-hidden="true"> &rarr;</span>
</a>
{loading ? (
<div className="flex items-center gap-2 px-4 py-8 text-sm text-text-muted">
<span className="material-symbols-outlined animate-spin text-[16px]" aria-hidden="true">
progress_activity
</span>
{tr("loadingQuotas", "Loading...")}
</div>
</div>
) : providerGroups.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-text-muted">
{tr("noProviders", "No Providers Connected")}
</div>
) : compact ? (
/* Compact mode: 3-column card grid, flat across all connections */
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3 p-4">
{connections.map((connection) => (
<div key={connection.id} className="border border-border rounded-lg p-3 bg-bg-subtle">
<div className="flex items-center gap-2 mb-2">
<ProviderIcon providerId={connection.provider} size={16} />
<span className="text-xs font-semibold text-text-main truncate">
{PROVIDER_LABEL[connection.provider] || connection.provider}
</span>
</div>
<ConnectionQuotas connection={connection} cache={quotaData[connection.id]} />
</div>
))}
</div>
) : (
<div className="divide-y divide-border">
{providerGroups.map(([provider, providerConnections]) => (
<section
key={provider}
className="grid grid-cols-1 gap-4 px-4 py-4 lg:grid-cols-[12rem_minmax(0,1fr)]"
>
<div className="flex min-w-0 items-center gap-2 lg:items-start">
<ProviderIcon providerId={provider} size={20} />
<div className="min-w-0">
<h3 className="truncate text-sm font-semibold text-text-main">
{PROVIDER_LABEL[provider] || provider}
</h3>
<p className="text-[11px] text-text-muted">
{providerConnections.length}{" "}
{providerConnections.length === 1 ? "account" : "accounts"}
</p>
</div>
</div>
<div className="space-y-4">
{providerConnections.map((connection) => (
<ConnectionQuotas
key={connection.id}
connection={connection}
cache={quotaData[connection.id]}
/>
))}
</div>
</section>
))}
</div>
)}
</Card>
);
}

View File

@@ -96,7 +96,7 @@ function getNextSortOrder() {
export async function getCombos(limit?: number, offset?: number) {
const db = getDbInstance();
let sql =
"SELECT data, sort_order, context_cache_protection FROM combos ORDER BY sort_order ASC, name COLLATE NOCASE ASC";
"SELECT id, data, sort_order, context_cache_protection FROM combos ORDER BY sort_order ASC, name COLLATE NOCASE ASC";
const params: unknown[] = [];
if (limit !== undefined) {
sql += " LIMIT ? OFFSET ?";

View File

@@ -66,6 +66,23 @@ const QUOTA_PATTERNS: ReadonlyArray<RegExp> = [
// the 429 is misclassified as transient rate_limit and retried every
// ~60s against a budget that only resets at UTC midnight.
/daily free allocation/i,
// Modal-hosted OpenAI-compatible endpoints (e.g. self-hosted Kimi K3).
// Body: {"error":"usage limit reached"}, no nested "message"/"quota"/
// "daily" wording. Without this pattern the 429 falls through to
// "rate_limit" (short cooldown), so combo round-robin's per-conversation
// session stickiness (#3825) keeps re-targeting the same exhausted
// connection every turn instead of a long lockout that lets the sticky
// target fail over to another account.
//
// Matches the "error" JSON key with "usage limit reached" as its value.
// Extra sibling fields (e.g. {"error":"usage limit reached", "code":"..."})
// still match. A different key like {"detail":"..."} or a qualified value
// like {"error":"Per-minute usage limit reached"} does NOT match. Bare
// string bodies without a JSON wrapper also do NOT match.
// Trailing punctuation/whitespace before the closing quote is tolerated
// because real API responses may include a period or trailing space.
/"error"\s*:\s*"usage limit reached[.\s]*"/i,
];
/**

View File

@@ -77,6 +77,7 @@ import {
import {
isAntigravityMissingProjectError,
PROVIDER_BREAKER_FAILURE_STATUSES,
resolveStreamReadinessClassificationError,
shouldTripProviderBreakerForResult,
} from "./chatPredicates";
import { connectionHasExtraKeys } from "@omniroute/open-sse/services/apiKeyRotator.ts";
@@ -1491,10 +1492,9 @@ async function handleSingleModelChat(
return result.response;
}
// Missing Cloud Code project assignment is an account configuration error, not a
// transient upstream/account failure. Preserve the executor's typed fail-closed 422;
// marking the connection unavailable here would trigger cooldown redispatch and repeat
// bootstrap within the same logical request.
// Missing Cloud Code project assignment is configuration, not a transient failure.
// Preserve the typed fail-closed 422; marking it unavailable would trigger cooldown
// redispatch and repeat bootstrap within the same logical request.
if (isAntigravityMissingProjectError(provider, result)) {
return withSelectedConnectionHeader(result.response, credentials.connectionId);
}
@@ -1537,10 +1537,11 @@ async function handleSingleModelChat(
}
if (isAntigravityStreamReadinessFailure) {
const classificationError = resolveStreamReadinessClassificationError(result);
const { shouldFallback, cooldownMs } = await markAccountUnavailable(
credentials.connectionId,
result.status || HTTP_STATUS.BAD_GATEWAY,
result.error || result.errorCode || "Antigravity stream ended before useful content",
classificationError,
provider,
model,
providerProfile,
@@ -1570,13 +1571,12 @@ async function handleSingleModelChat(
}
}
excludedConnectionIds.add(credentials.connectionId);
lastError = result.error;
lastError = classificationError;
lastStatus = result.status;
requestRetryLastError = result.error;
requestRetryLastError = classificationError;
requestRetryLastStatus = result.status;
continue;
}
return withSelectedConnectionHeader(result.response, credentials?.connectionId);
}

View File

@@ -32,3 +32,22 @@ export function isAntigravityMissingProjectError(
result.errorType === "oauth_missing_project_id"
);
}
/**
* Keep stream-readiness routing decisions on the stable gate diagnostic.
* The operator-facing error can contain arbitrary upstream words such as
* "quota" or "retry after", which must not change account/combo classification.
*/
export function resolveStreamReadinessClassificationError(
result: {
classificationError?: unknown;
error?: unknown;
errorCode?: unknown;
},
fallback = "Antigravity stream ended before useful content"
): string {
for (const value of [result.classificationError, result.error, result.errorCode]) {
if (typeof value === "string" && value.trim()) return value;
}
return fallback;
}

View File

@@ -291,6 +291,7 @@
"tests/unit/sse-auth-antigravity-credits.test.ts",
"tests/unit/sse-auth-resource-404.test.ts",
"tests/unit/sse-auth.test.ts",
"tests/unit/stream-readiness.test.ts",
"tests/unit/strict-random-deck.test.ts",
"tests/unit/strip-reasoning-header.test.ts",
"tests/unit/system-role-extraction.test.ts",

View File

@@ -3,6 +3,8 @@ import assert from "node:assert/strict";
const { sanitizeReasoningEffortForProvider } = await import("../../open-sse/executors/base.ts");
const { DefaultExecutor } = await import("../../open-sse/executors/default.ts");
const { translateRequest } = await import("../../open-sse/translator/index.ts");
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
function makeLog() {
const messages: Array<[string, string]> = [];
@@ -102,6 +104,45 @@ test("sanitizeReasoningEffortForProvider: Ollama Cloud preserves max", () => {
assert.equal(log.messages.length, 0);
});
test("end-to-end: Anthropic output_config.effort=max reaches Ollama Cloud as max (not xhigh)", () => {
// Bug: the claude→openai translator previously normalized max → xhigh, and the
// sanitizer could not recover the original intent because the carrier was already
// xhigh. Ollama Cloud accepts max literally but rejects xhigh (HTTP 400).
// The translator must pass max through verbatim and the sanitizer must keep it.
const translated = translateRequest(
FORMATS.CLAUDE,
FORMATS.OPENAI,
"gemma4:31b",
{
model: "gemma4:31b",
messages: [{ role: "user", content: "hi" }],
output_config: { effort: "max" },
},
false,
null,
"ollama-cloud"
) as Record<string, unknown>;
assert.equal(
translated.reasoning_effort,
"max",
"translator must pass max through verbatim instead of rewriting it to xhigh"
);
const sanitized = sanitizeReasoningEffortForProvider(
translated,
"ollama-cloud",
"gemma4:31b",
null
) as Record<string, unknown>;
assert.equal(
sanitized.reasoning_effort,
"max",
"Ollama Cloud accepts max literally — no downgrade, no rewrite to xhigh"
);
});
test("sanitizeReasoningEffortForProvider: Ollama Cloud preserves nested max", () => {
const body = {
model: "glm-5.2",

View File

@@ -46,7 +46,7 @@ const strictCheck = {
actual: 226,
docKey: "providers",
strict: true,
files: ["README.md", "AGENTS.md"],
files: ["README.md", "CLAUDE.md"],
};
test("no drift when every file mentions the real count", () => {
@@ -55,11 +55,11 @@ test("no drift when every file mentions the real count", () => {
assert.equal(soft, 0);
});
test("STRICT drift is counted when a file omits the real count", () => {
test("STRICT drift is counted when a user-facing document omits the real count", () => {
const { strict, soft } = tally([strictCheck], (f) =>
f === "README.md" ? "we have 226 providers" : "we have 177 providers"
);
assert.equal(strict, 1, "AGENTS.md (177) should register one strict drift");
assert.equal(strict, 1, "CLAUDE.md (177) should register one strict drift");
assert.equal(soft, 0);
});

View File

@@ -164,6 +164,63 @@ test("looksLikeQuotaExhausted: rejects empty / null / non-quota text", () => {
assert.equal(looksLikeQuotaExhausted("server error 500"), false);
});
test("classify429: Modal-hosted endpoint 'usage limit reached' body returns 'quota_exhausted'", () => {
// Real body observed from a self-hosted Modal OpenAI-compatible endpoint:
// {"error":"usage limit reached"} - a bare string value, no "message"/
// "daily"/"quota" wording, so none of the prior patterns matched and the
// 429 fell through to a 60s rate_limit cooldown. Combo round-robin's
// per-conversation session stickiness (#3825) then kept re-targeting the
// same exhausted connection on every turn of a long-running session.
const body = { error: "usage limit reached" };
assert.equal(looksLikeQuotaExhausted(body), true);
assert.equal(classify429({ status: 429, body }), "quota_exhausted");
assert.equal(classify429({ status: 429, body: JSON.stringify(body) }), "quota_exhausted");
// Case variation must also match.
assert.equal(
classify429({ status: 429, body: { error: "USAGE LIMIT REACHED" } }),
"quota_exhausted"
);
// Whitespace around JSON object must also match (bodyToText does not trim).
assert.equal(
classify429({ status: 429, body: ' { "error" : "usage limit reached" } ' }),
"quota_exhausted"
);
// Extra sibling fields must still match.
assert.equal(
classify429({
status: 429,
body: { error: "usage limit reached", code: "RESOURCE_EXHAUSTED" },
}),
"quota_exhausted"
);
// Trailing punctuation/whitespace must still match.
assert.equal(classify429({ status: 429, body: { error: "usage limit reached." } }), "quota_exhausted");
assert.equal(classify429({ status: 429, body: { error: "usage limit reached " } }), "quota_exhausted");
});
test("classify429: qualified transient 'usage limit reached' messages stay rate_limit", () => {
// The Modal pattern requires the "error" JSON key with exactly "usage
// limit reached" as its value - anything else is a transient rate limit
// and must NOT be locked out long-term.
assert.equal(
classify429({ status: 429, body: "Per-minute usage limit reached, retry in 60s." }),
"rate_limit"
);
assert.equal(
classify429({ status: 429, body: { error: { message: "RPM usage limit reached" } } }),
"rate_limit"
);
// Bare string body (no JSON "error" key) must NOT match.
assert.equal(classify429({ status: 429, body: "usage limit reached" }), "rate_limit");
// Different JSON key (not "error") must NOT match.
assert.equal(classify429({ status: 429, body: { detail: "usage limit reached" } }), "rate_limit");
// Qualified value under the "error" key must NOT match.
assert.equal(
classify429({ status: 429, body: { error: "Per-minute usage limit reached" } }),
"rate_limit"
);
});
test("ambiguous 'daily rate limit' messages classify as quota_exhausted (intentional)", () => {
// Codex audit LOW: messages combining 'daily' or 'monthly' with 'limit'
// match the quota regex even when paired with 'rate'. This is intentional

View File

@@ -0,0 +1,166 @@
// PR #8916 — Provider quota compact layout mode.
//
// #8916 added a `compact` prop to QuotaCardGrid and ProviderQuotaWidget that
// forces a flat multi-column card grid instead of the upstream 3.8.49
// provider-group sidebar layout. This guard verifies the compact mode's
// grid-rendering code survives mechanical edits and refactors.
//
// Three structural assertions:
//
// 1. QuotaCardGrid renders a `grid-cols-[repeat(auto-fill,minmax(17rem,1fr))]`
// container when `compact` is true (before any provider-group branching).
// 2. ProviderQuotaWidget renders a `grid-cols-1 sm:grid-cols-2 md:grid-cols-3`
// container when `compact` is true (the "force 3 columns" path).
// 3. The `compact` prop appears in the Props interface of both components.
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import ts from "typescript";
const COMPONENTS = {
quotaCardGrid: path.resolve(
import.meta.dirname,
"../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCardGrid.tsx"
),
providerQuotaWidget: path.resolve(
import.meta.dirname,
"../../src/app/(dashboard)/home/ProviderQuotaWidget.tsx"
),
};
/**
* Render a TSX/TS source file into an AST and return the full node tree.
*/
function parseSource(sourcePath: string): ts.SourceFile {
const sourceText = fs.readFileSync(sourcePath, "utf8");
return ts.createSourceFile(
sourcePath,
sourceText,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX
);
}
/**
* Collect all string-literal className values from `<div>` JSX elements
* (both `<div className="literal">` and `<div className={"literal"}>`).
*/
function collectDivClassNames(sourcePath: string): string[] {
const sourceFile = parseSource(sourcePath);
const classNames: string[] = [];
function visit(node: ts.Node) {
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
const tagName = node.tagName.getText(sourceFile);
if (tagName === "div") {
for (const attr of node.attributes.properties) {
if (ts.isJsxAttribute(attr) && attr.name.getText(sourceFile) === "className") {
const init = attr.initializer;
if (init && ts.isStringLiteral(init)) {
classNames.push(init.text);
} else if (
init &&
ts.isJsxExpression(init) &&
init.expression &&
ts.isStringLiteral(init.expression)
) {
classNames.push(init.expression.text);
}
}
}
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return classNames;
}
/**
* Check whether the Props interface (or the component's interface parameter)
* declares a `compact?: boolean` property.
*/
function hasCompactProp(sourcePath: string): boolean {
const sourceFile = parseSource(sourcePath);
let found = false;
function visit(node: ts.Node) {
if (found) return;
// interface Props { compact?: boolean; ... }
if (ts.isInterfaceDeclaration(node) && node.name.text === "Props") {
for (const member of node.members) {
if (
ts.isPropertySignature(member) &&
member.name.getText(sourceFile) === "compact" &&
member.questionToken
) {
found = true;
return;
}
}
}
// ProviderQuotaWidgetProps { compact?: boolean; }
if (ts.isInterfaceDeclaration(node) && node.name.text === "ProviderQuotaWidgetProps") {
for (const member of node.members) {
if (
ts.isPropertySignature(member) &&
member.name.getText(sourceFile) === "compact" &&
member.questionToken
) {
found = true;
return;
}
}
}
// function Component({ ..., compact = false }: Props)
if (ts.isFunctionDeclaration(node) || ts.isArrowFunction(node)) {
// Parameter destructuring with default for compact
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return found;
}
// ── QuotaCardGrid ──
test("QuotaCardGrid exports a compact prop on its Props interface", () => {
assert.ok(
hasCompactProp(COMPONENTS.quotaCardGrid),
"QuotaCardGrid Props interface should declare compact?: boolean"
);
});
test("QuotaCardGrid compact mode renders an auto-fill minmax grid", () => {
const classNames = collectDivClassNames(COMPONENTS.quotaCardGrid);
const compactGrid = classNames.find((cn) =>
cn.includes("grid-cols-[repeat(auto-fill,minmax(17rem,1fr))]")
);
assert.ok(
compactGrid,
"compact mode must render grid-cols-[repeat(auto-fill,minmax(17rem,1fr))]"
);
});
// ── ProviderQuotaWidget ──
test("ProviderQuotaWidget declares a compact prop", () => {
assert.ok(
hasCompactProp(COMPONENTS.providerQuotaWidget),
"ProviderQuotaWidgetProps should declare compact?: boolean"
);
});
test("ProviderQuotaWidget compact mode renders a 3-column responsive grid", () => {
const classNames = collectDivClassNames(COMPONENTS.providerQuotaWidget);
const compactGridClass = "grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4";
const hasGrid = classNames.find((cn) => cn.includes(compactGridClass));
assert.ok(hasGrid, "compact mode must render the 3-column responsive grid breakpoints");
// Also verify at least one provider-icon + ConnectionQuotas structure exists
// by checking for ProviderIcon usage within the compact branch
});

View File

@@ -6,6 +6,8 @@ import {
hasStreamReadinessSignal,
hasUsefulStreamContent,
} from "../../open-sse/utils/streamReadiness.ts";
import { checkFallbackError } from "../../open-sse/services/accountFallback.ts";
import { resolveStreamReadinessClassificationError } from "../../src/sse/handlers/chatPredicates.ts";
const encoder = new TextEncoder();
@@ -576,7 +578,93 @@ test("ensureStreamReadiness returns 502 when stream ends without a non-ping SSE
const result = await ensureStreamReadiness(response, { timeoutMs: 100 });
assert.equal(result.ok, false);
if (result.ok) assert.fail("keepalive-only SSE payload must remain a readiness failure");
assert.equal(result.response.status, 502);
assert.equal(result.reason, "Stream ended before producing a non-ping SSE event");
assert.equal(result.classificationReason, result.reason);
const body = (await result.response.json()) as Record<string, unknown>;
assert.equal("upstream_details" in body, false);
});
test("ensureStreamReadiness preserves sanitized error-only diagnostics on early EOF (#8972)", async () => {
const warnings: string[] = [];
const response = new Response(
streamFromChunks([
`data: ${JSON.stringify({
error: {
message:
"UPSTREAM_DETAIL quota exhausted; retry after 2s; empty content " +
"Bearer TOP_SECRET /srv/omniroute/handler.ts:42",
},
})}\n\n`,
`data: ${JSON.stringify({ error: { message: "SECOND_DETAIL" } })}\n\n`,
]),
{ status: 200, headers: { "Content-Type": "text/event-stream" } }
);
const result = await ensureStreamReadiness(response, {
timeoutMs: 100,
provider: "test-provider",
model: "test-model",
log: {
warn: (_tag, message) => warnings.push(message),
},
});
assert.equal(result.ok, false);
if (result.ok) assert.fail("error-only SSE payload must remain a readiness failure");
assert.equal(result.response.status, 502);
assert.equal(result.code, "STREAM_EARLY_EOF");
assert.equal(result.type, "stream_early_eof");
assert.equal(
result.classificationReason,
"Stream ended before producing a non-ping SSE event"
);
assert.equal(
result.upstreamDiagnostic,
"UPSTREAM_DETAIL quota exhausted; retry after 2s; empty content Bearer [REDACTED] <path>"
);
const body = (await result.response.json()) as {
error: { message: string; code: string; type: string };
upstream_details: { error: { message: string } };
};
assert.equal(body.error.message, result.classificationReason);
assert.doesNotMatch(body.error.message, /quota|retry after|empty content/i);
assert.equal(body.error.code, "STREAM_EARLY_EOF");
assert.equal(body.error.type, "stream_early_eof");
assert.equal(body.upstream_details.error.message, result.upstreamDiagnostic);
assert.equal(warnings.length, 1);
for (const surfaced of [
result.reason,
body.upstream_details.error.message,
warnings[0],
]) {
assert.match(surfaced, /UPSTREAM_DETAIL/);
assert.doesNotMatch(
surfaced,
/SECOND_DETAIL|TOP_SECRET|\/srv\/omniroute\/handler\.ts/
);
}
});
test("stream-readiness diagnostics cannot reclassify Antigravity account exhaustion (#8972)", () => {
const classificationError = "Stream ended before producing a non-ping SSE event";
const diagnostic = "UPSTREAM_DETAIL quota exhausted; retry after 2s; empty content";
const routedError = resolveStreamReadinessClassificationError({
classificationError,
error: `${classificationError}: ${diagnostic}`,
errorCode: "STREAM_EARLY_EOF",
});
assert.equal(routedError, classificationError);
assert.equal(checkFallbackError(502, routedError, 0, null, "antigravity").reason, "server_error");
assert.equal(
checkFallbackError(502, diagnostic, 0, null, "antigravity").reason,
"quota_exhausted",
"the regression fixture must prove that leaking the operator diagnostic changes routing"
);
});
test("ensureStreamReadiness accepts a final event without a trailing blank line", async () => {

View File

@@ -54,8 +54,6 @@ test("Claude -> OpenAI maps system blocks, parameters, tool declarations and too
});
});
test("Claude -> OpenAI maps Claude server WebSearch to native Responses web_search", () => {
const result = claudeToOpenAIRequest(
"gpt-5.5",
@@ -408,7 +406,7 @@ test("Claude -> OpenAI maps thinking.budget_tokens to reasoning_effort buckets",
}
});
test("Claude -> OpenAI normalizes output_config.effort=max to xhigh", () => {
test("Claude -> OpenAI passes output_config.effort=max through verbatim", () => {
const result = claudeToOpenAIRequest(
"gpt-5",
{
@@ -418,7 +416,7 @@ test("Claude -> OpenAI normalizes output_config.effort=max to xhigh", () => {
false
);
assert.equal(result.reasoning_effort, "xhigh");
assert.equal(result.reasoning_effort, "max");
});
test("Claude -> OpenAI ignores disabled thinking and leaves reasoning_effort unset", () => {

View File

@@ -0,0 +1,24 @@
import test from "node:test";
import assert from "node:assert/strict";
import { detectFormat, detectFormatFromUrl } from "../../open-sse/services/provider.ts";
test("detectFormatFromUrl accepts a relative /messages endpoint", () => {
assert.equal(
detectFormatFromUrl(
{ messages: [{ role: "user", content: "validate this model" }] },
"/v1/messages"
),
"claude"
);
});
test("detectFormat recognizes the kebab-case anthropic-version body field", () => {
assert.equal(
detectFormat({
messages: [{ role: "user", content: "validate this model" }],
"anthropic-version": "2023-06-01",
}),
"claude"
);
});

View File

@@ -106,7 +106,13 @@ vi.mock("@/shared/constants/models", () => ({
// Stub specialized cards — render a testid so we can identify which was rendered
vi.mock("../../../src/app/(dashboard)/dashboard/cli-code/components/index", () => ({
ClaudeToolCard: () => <div data-testid="ClaudeToolCard" />,
ClaudeToolCard: ({ hasActiveProviders, availableModels }: any) => (
<div
data-testid="ClaudeToolCard"
data-has-active-providers={String(hasActiveProviders)}
data-available-models={JSON.stringify(availableModels)}
/>
),
CodexToolCard: () => <div data-testid="CodexToolCard" />,
DroidToolCard: () => <div data-testid="DroidToolCard" />,
OpenClawToolCard: () => <div data-testid="OpenClawToolCard" />,
@@ -127,9 +133,8 @@ vi.mock("../../../src/app/(dashboard)/dashboard/cli-code/components/CliproxyapiT
// ── Import after mocks ────────────────────────────────────────────────────────
const { default: ToolDetailClient } = await import(
"@/app/(dashboard)/dashboard/cli-code/components/ToolDetailClient"
);
const { default: ToolDetailClient } =
await import("@/app/(dashboard)/dashboard/cli-code/components/ToolDetailClient");
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -153,7 +158,10 @@ beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
mockFetch.mockClear();
mockFetch.mockReset().mockResolvedValue({
ok: true,
json: async () => ({ connections: [], keys: [], data: [], cloudEnabled: false }),
});
});
afterEach(() => {
@@ -185,6 +193,93 @@ describe("ToolDetailClient", () => {
expect(container.querySelector("[data-testid='CustomCliCard']")).not.toBeNull();
});
it("keeps Apply available for an active dynamic compatible provider", async () => {
mockFetch.mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/providers") {
return {
ok: true,
json: async () => ({
connections: [
{
provider: "openai-compatible-chat-node-123",
name: "Kimi gateway",
isActive: true,
testStatus: "active",
defaultModel: "Kimi-K3",
providerSpecificData: { prefix: "kimi-gateway" },
},
],
}),
};
}
return {
ok: true,
json: async () => ({ keys: [], data: [], cloudEnabled: false }),
};
});
const container = renderDetail("claude", "code");
await act(async () => {});
const card = container.querySelector("[data-testid='ClaudeToolCard']");
expect(card?.getAttribute("data-has-active-providers")).toBe("true");
expect(JSON.parse(card?.getAttribute("data-available-models") || "[]")).toEqual(
expect.arrayContaining([
expect.objectContaining({
value: "kimi-gateway/Kimi-K3",
provider: "openai-compatible-chat-node-123",
modelId: "Kimi-K3",
}),
])
);
});
it("accepts compatible-provider models published under the connection prefix", async () => {
mockFetch.mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/providers") {
return {
ok: true,
json: async () => ({
connections: [
{
provider: "anthropic-compatible-node-456",
name: "Claude gateway",
isActive: true,
providerSpecificData: { prefix: "claude-gateway" },
},
],
}),
};
}
if (url === "/v1/models") {
return {
ok: true,
json: async () => ({ data: [{ id: "claude-gateway/claude-sonnet" }] }),
};
}
return {
ok: true,
json: async () => ({ keys: [], cloudEnabled: false }),
};
});
const container = renderDetail("claude", "code");
await act(async () => {});
const card = container.querySelector("[data-testid='ClaudeToolCard']");
expect(card?.getAttribute("data-has-active-providers")).toBe("true");
expect(JSON.parse(card?.getAttribute("data-available-models") || "[]")).toEqual(
expect.arrayContaining([
expect.objectContaining({
value: "claude-gateway/claude-sonnet",
modelId: "claude-sonnet",
}),
])
);
});
it("renders DefaultToolCard for unknown tool (forge, configType:custom)", async () => {
const container = renderDetail("forge", "code");
await act(async () => {});