mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 01:32:22 +03:00
Compare commits
15 Commits
compressio
...
codex/quot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01c6373bcd | ||
|
|
aa0d99e0a5 | ||
|
|
3c483dd906 | ||
|
|
f11d883f22 | ||
|
|
2cb77bbca7 | ||
|
|
a8216c92fe | ||
|
|
c790b57af8 | ||
|
|
9acf79f04f | ||
|
|
09665ab455 | ||
|
|
9ee6435f0e | ||
|
|
edd9b0d664 | ||
|
|
ba353aa3d6 | ||
|
|
224bc0a5a5 | ||
|
|
2e5854906d | ||
|
|
2e4268003a |
10
.github/workflows/docker-publish.yml
vendored
10
.github/workflows/docker-publish.yml
vendored
@@ -155,13 +155,13 @@ jobs:
|
|||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v4.5.2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v4.5.2
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -255,13 +255,13 @@ jobs:
|
|||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v4.5.2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v4.5.2
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -390,7 +390,7 @@ jobs:
|
|||||||
- name: Upload Trivy SARIF to Security tab
|
- name: Upload Trivy SARIF to Security tab
|
||||||
if: needs.prepare.outputs.version != 'main'
|
if: needs.prepare.outputs.version != 'main'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
uses: github/codeql-action/upload-sarif@v4
|
uses: github/codeql-action/upload-sarif@v4.37.3
|
||||||
with:
|
with:
|
||||||
sarif_file: trivy-results.sarif
|
sarif_file: trivy-results.sarif
|
||||||
category: trivy-image
|
category: trivy-image
|
||||||
|
|||||||
29
.mergify.yml
29
.mergify.yml
@@ -17,6 +17,13 @@
|
|||||||
# • Fallback path if Mergify misbehaves or the OSS plan changes: the manual
|
# • 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.
|
# 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:
|
queue_rules:
|
||||||
- name: release
|
- name: release
|
||||||
# Any current or future release branch — the reason GitHub's native queue was
|
# 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
|
# is intentionally NOT a condition here: the owner-applied `queue` label IS the
|
||||||
# approval in this repo's single-maintainer model (see governance header).
|
# approval in this repo's single-maintainer model (see governance header).
|
||||||
merge_conditions:
|
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-pending=0"
|
||||||
- "#check-success>=1"
|
- "#check-success>=1"
|
||||||
- check-success=Merge integrity (changelog + generated skills)
|
- check-success=Merge integrity (changelog + generated skills)
|
||||||
# Batching: validate up to 10 queued PRs together (the manual train's sweet spot);
|
# NO batching: 'Merge Queue Batch' requires a paid Mergify tier (live finding
|
||||||
# don't hold a lone PR hostage waiting for siblings.
|
# 2026-07-15 — the queue command fails with "Cannot use Merge Queue batch" on
|
||||||
batch_size: 10
|
# the free plan). Serial queue (1 PR at a time) still automates the train.
|
||||||
batch_max_wait_time: 5 min
|
|
||||||
# Squash keeps the one-commit-per-PR history the CHANGELOG reconciliation expects.
|
# Squash keeps the one-commit-per-PR history the CHANGELOG reconciliation expects.
|
||||||
merge_method: squash
|
merge_method: squash
|
||||||
|
|
||||||
|
|||||||
695
AGENTS.md
695
AGENTS.md
@@ -1,600 +1,117 @@
|
|||||||
# omniroute — Agent Guidelines
|
# OmniRoute agent guide
|
||||||
|
|
||||||
## Project
|
## Project
|
||||||
|
|
||||||
Unified AI proxy/router — route any LLM through one endpoint. Multi-provider support
|
OmniRoute is a unified AI proxy/router. The repository contains the Next.js application
|
||||||
with **290 provider entries** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks,
|
(`src/`), streaming engine workspace (`open-sse/`), Electron desktop app (`electron/`),
|
||||||
Cohere, NVIDIA, Cerebras, Pollinations, Puter, Cloudflare AI, HuggingFace, DeepInfra,
|
CLI (`bin/`), and tests (`tests/`).
|
||||||
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**.
|
## Setup and focused checks
|
||||||
|
|
||||||
> **Live counts (v3.8.49)**: providers 290 · MCP tools 104 · MCP scopes 30 · A2A skills 6 ·
|
- Runtime: Node.js `>=22.22.3 <23` or `>=24.0.0 <27`; npm 10+.
|
||||||
> open-sse services 134 · routing strategies 17 · auto-combo scoring factors 12 ·
|
- Install dependencies: `npm install`.
|
||||||
> DB modules 95 · DB migrations 110 · base tables 17 · search providers 11 ·
|
- Start development: `npm run dev`.
|
||||||
> i18n locales 42. **Refresh with `npm run check:docs-all`.**
|
- Build: `npm run build`; release build: `npm run build:release`.
|
||||||
|
- Lint: `npm run lint`.
|
||||||
## Doc Accuracy Discipline (read before writing any doc)
|
- Core type check: `npm run typecheck:core`.
|
||||||
|
- Run the most focused test for changed code first:
|
||||||
> **If `grep -rn "name" src/ open-sse/ bin/` returns nothing, the name does not exist. Do not document it.**
|
`node --import tsx/esm --test tests/unit/<file>.test.ts`.
|
||||||
|
- Other suites: `npm run test:vitest`, `npm run test:e2e`,
|
||||||
The recurring failure mode in AI-generated docs is _plausible-but-unverified specifics_.
|
`npm run test:protocols:e2e`, and `npm run test:ecosystem`.
|
||||||
Every claim in a `.md` file under `docs/` should be verifiable against the source.
|
- Run `npm run check:docs-all` after changing documentation.
|
||||||
|
|
||||||
**Rules (enforced by `npm run check:fabricated-docs`):**
|
For the complete test matrix, coverage requirements, and pull-request gates, read
|
||||||
|
[`CONTRIBUTING.md`](CONTRIBUTING.md#running-tests).
|
||||||
1. **Never state an API name, endpoint, path, CLI command, or env var without grepping for it first.**
|
|
||||||
```bash
|
## Documentation accuracy
|
||||||
grep -rn "theName" src/ open-sse/ bin/
|
|
||||||
# 0 hits → do not document
|
Documentation must describe verified behavior, not plausible behavior.
|
||||||
```
|
|
||||||
2. **Never write a line count, file size, migration count, provider count, or strategy count from memory.**
|
1. Before documenting an API name, endpoint, path, CLI command, or environment variable,
|
||||||
```bash
|
search for it: `rg -n "name" src/ open-sse/ bin/`. If it has no source match, do not
|
||||||
wc -l <file> # exact line count
|
document it.
|
||||||
ls <dir>/*.ts | wc -l # file count
|
2. Measure mutable counts instead of writing them from memory: use `wc -l <file>` or a
|
||||||
```
|
directory-specific count command.
|
||||||
3. **Every code example should be copy-pasted from real usage or actually run** — not synthesized.
|
3. Copy code examples from working usage or run them. Prefer a source link such as
|
||||||
Link to a real call site (`path:line`) instead of inventing a signature.
|
`path/to/file.ts:line` to an invented signature.
|
||||||
4. **Prefer citing real source (`file.ts:line`) over paraphrasing behavior** — verifiable and self-correcting.
|
4. Run `npm run check:docs-all` for edits under `docs/`; it includes the fabricated-docs
|
||||||
5. **A shorter doc that is 100% accurate beats a comprehensive one with fabrications.**
|
validation.
|
||||||
Wrong docs cost more than missing docs, because people trust and act on them.
|
|
||||||
|
## Code conventions
|
||||||
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
|
- Format with Prettier: two spaces, semicolons, double quotes, 100-character line width,
|
||||||
codebase. Run it locally before pushing docs; it runs in CI via `npm run check:docs-all`.
|
and ES5 trailing commas. Run Prettier on changed files.
|
||||||
|
- TypeScript target is ES2022 with bundler module resolution. Prefer explicit types.
|
||||||
## Stack
|
- 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.
|
||||||
- **Runtime**: Next.js 16 (App Router), Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
|
- Use specific errors and contextual logging. Do not silently swallow SSE-stream failures;
|
||||||
- **Language**: TypeScript 6.0 (`src/`) + JavaScript (`open-sse/`, `electron/`)
|
use abort signals for cleanup and return appropriate HTTP status codes.
|
||||||
- **Database**: better-sqlite3 (SQLite) — `DATA_DIR` configurable, default `~/.omniroute/`
|
|
||||||
- **Streaming**: SSE via `open-sse` internal workspace package
|
## Security requirements
|
||||||
- **Styling**: Tailwind CSS v4
|
|
||||||
- **i18n**: next-intl with 42 locales (`src/i18n/messages/`) — refresh with `ls src/i18n/messages/*.json | wc -l`
|
- Never commit credentials or log SQLite encryption keys.
|
||||||
- **Desktop**: Electron (cross-platform: Windows, macOS, Linux)
|
- Validate API inputs with Zod and use the route's required authentication path.
|
||||||
- **Schemas**: Zod v4 for all API / MCP input validation
|
- 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;
|
||||||
## Build, Lint, and Test Commands
|
do not return raw `err.stack` or `err.message`. See
|
||||||
|
[`docs/security/ERROR_SANITIZATION.md`](docs/security/ERROR_SANITIZATION.md).
|
||||||
| Command | Description |
|
- Pass runtime values to `exec()` or `spawn()` through `env`, not interpolation into a script.
|
||||||
| ----------------------------------- | ------------------------------------------------------------------ |
|
|
||||||
| `npm run dev` | Start Next.js dev server |
|
## Repository map
|
||||||
| `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 |
|
Read the nearest `AGENTS.md` and the linked deep-dive before making a non-trivial change.
|
||||||
| `npm run start` | Run production build |
|
|
||||||
| `npm run build:cli` | Build CLI package |
|
| Area | Location | Start here |
|
||||||
| `npm run lint` | ESLint on all source files |
|
| ---------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `npm run typecheck:core` | TypeScript core type checking |
|
| API routes | `src/app/api/v1/` | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
|
||||||
| `npm run typecheck:noimplicit:core` | Strict checking (no implicit any) |
|
| Streaming request handling | `open-sse/handlers/` | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
|
||||||
| `npm run check` | Run lint + test |
|
| Provider execution and translation | `open-sse/executors/`, `open-sse/translator/` | [`docs/architecture/CODEBASE_DOCUMENTATION.md`](docs/architecture/CODEBASE_DOCUMENTATION.md) |
|
||||||
| `npm run check:cycles` | Check for circular dependencies |
|
| 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) |
|
||||||
| `npm run electron:dev` | Run Electron app in dev mode |
|
| Database and migrations | `src/lib/db/`, `db/migrations/` | [`src/lib/db/AGENTS.md`](src/lib/db/AGENTS.md) |
|
||||||
| `npm run electron:build` | Build Electron app for current OS |
|
| 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) |
|
||||||
**Build output layout:**
|
| 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) |
|
||||||
| Directory | Purpose | Gitignored |
|
| 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) |
|
||||||
| --------- | -------------------------------------------------- | ---------- |
|
|
||||||
| `src/` | Application source (TypeScript / TSX) | No |
|
## Review focus
|
||||||
| `.build/` | Build intermediates (`distDir = .build/next`) | Yes |
|
|
||||||
| `dist/` | Shippable bundle assembled by `assembleStandalone` | Yes |
|
- Keep database operations in `src/lib/db/`; do not issue raw SQL from routes.
|
||||||
|
- Send provider requests through `open-sse/handlers/`.
|
||||||
The pipeline is a single `next build` pass — intermediates land in `.build/next/`, the
|
- Keep MCP and A2A pages as tabs inside `/dashboard/endpoint`.
|
||||||
assembled bundle in `dist/`. VPS deploys rsync `dist/` into the remote
|
- Preserve SSE cleanup, rate-limit header parsing, Zod validation, and provider-schema
|
||||||
`/usr/lib/node_modules/omniroute/app/` directory (VPS image path is unchanged).
|
validation.
|
||||||
|
- Treat Memory and Skills as cross-cutting changes that can affect MCP tools, the request
|
||||||
### Running Tests
|
pipeline, and A2A skills.
|
||||||
|
- Do not close a contributor pull request after using its code; merge it through GitHub so
|
||||||
```bash
|
the contributor receives credit.
|
||||||
# All tests (unit + vitest + ecosystem + e2e)
|
|
||||||
npm run test:all
|
## Upstream contributions
|
||||||
|
|
||||||
# Single test file (Node.js native test runner — most tests use this)
|
This checkout is a fork of `diegosouzapw/OmniRoute`. Keep fork-only deployment and personal
|
||||||
node --import tsx/esm --test tests/unit/your-file.test.ts
|
automation changes out of upstream PRs.
|
||||||
node --import tsx/esm --test tests/unit/plan3-p0.test.ts
|
|
||||||
node --import tsx/esm --test tests/unit/fixes-p1.test.ts
|
Start upstream work from the active upstream default branch, not `main`:
|
||||||
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`):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git fetch upstream
|
git fetch upstream
|
||||||
# the default branch is the active release line, e.g. release/v3.8.49
|
git switch -c <branch-name> upstream/<default-branch>
|
||||||
git switch -c <branch-name> upstream/release/vX.Y.Z
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
| Area | Reference |
|
||||||
- **Provider requests** flow through `open-sse/handlers/`
|
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
- **MCP/A2A pages** are tabs inside `/dashboard/endpoint`, not standalone routes
|
| Repository navigation and architecture | [`docs/architecture/REPOSITORY_MAP.md`](docs/architecture/REPOSITORY_MAP.md), [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
|
||||||
- **No memory leaks** in SSE streams (abort signals, cleanup)
|
| 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) |
|
||||||
- **Rate limit headers** must be parsed correctly
|
| 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) |
|
||||||
- All API inputs validated with **Zod schemas**
|
| 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) |
|
||||||
- **Provider constants** validated at module load via Zod (`src/shared/validation/providerSchema.ts`)
|
| 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) |
|
||||||
- **Pricing data** syncs from LiteLLM via `src/lib/pricingSync.ts`
|
| Releases and quality | [`docs/ops/RELEASE_CHECKLIST.md`](docs/ops/RELEASE_CHECKLIST.md), [`docs/architecture/QUALITY_GATES.md`](docs/architecture/QUALITY_GATES.md) |
|
||||||
- **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.
|
|
||||||
|
|||||||
1
changelog.d/fixes/9022-stream-error-diagnostic.md
Normal file
1
changelog.d/fixes/9022-stream-error-diagnostic.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **fix(sse):** error-only streams now preserve sanitized executor diagnostics for operators without changing stream-readiness fallback classification ([#9022](https://github.com/diegosouzapw/OmniRoute/pull/9022)) — thanks @shixi-li
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- **fix(translator):** pass `output_config.effort="max"` through verbatim instead of unconditionally rewriting it to `xhigh`, so Anthropic → OpenAI-shape upstream calls reach `sanitizeReasoningEffortForProvider` with the carrier intact and providers that accept `max` literally (Ollama Cloud, opencode-go DeepSeek, Moonshot K3, native Claude) no longer 400 on `invalid reasoning value: 'xhigh'`. Regression guard: end-to-end test in `tests/unit/base-executor-sanitize-effort.test.ts`.
|
||||||
1
changelog.d/fixes/9250-cli-compatible-provider-apply.md
Normal file
1
changelog.d/fixes/9250-cli-compatible-provider-apply.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **fix(cli-tools):** keep Apply enabled for active OpenAI-compatible and Anthropic-compatible providers without static catalog entries. (thanks @lazysaltyfish)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- **fix(translator):** harden Claude format detection for relative message endpoints and kebab-case version metadata. (thanks @ervareza)
|
||||||
@@ -385,7 +385,8 @@
|
|||||||
"src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": 1573,
|
"src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": 1573,
|
||||||
"src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx": 1028,
|
"src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx": 1028,
|
||||||
"src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2148,
|
"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/providers/[id]/models/route.ts": 2250,
|
||||||
"src/app/api/v1/models/catalog.ts": 1549,
|
"src/app/api/v1/models/catalog.ts": 1549,
|
||||||
"src/lib/db/apiKeys.ts": 1529,
|
"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_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_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_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."
|
"_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."
|
||||||
}
|
}
|
||||||
|
|||||||
916
docs/architecture/mysql-conformance-semantics.md
Normal file
916
docs/architecture/mysql-conformance-semantics.md
Normal file
@@ -0,0 +1,916 @@
|
|||||||
|
---
|
||||||
|
title: "MySQL conformance semantics and failure-mode matrix"
|
||||||
|
status: proposed-test-specification
|
||||||
|
lastUpdated: 2026-07-30
|
||||||
|
---
|
||||||
|
|
||||||
|
# MySQL conformance semantics and failure-mode matrix
|
||||||
|
|
||||||
|
- **Tracking issue:** [#8075](https://github.com/diegosouzapw/OmniRoute/issues/8075)
|
||||||
|
- **Governing proposal:** [Pluggable persistence boundary](persistence-backend-boundary.md)
|
||||||
|
- **Measured baseline:** [SQLite coupling inventory](sqlite-coupling-inventory.md)
|
||||||
|
- **Target:** MySQL 8.0 with InnoDB
|
||||||
|
- **Runtime impact:** None. This document adds no driver, dependency, configuration, schema,
|
||||||
|
migration, or support claim.
|
||||||
|
|
||||||
|
## 1. Purpose and normative language
|
||||||
|
|
||||||
|
The persistence-boundary ADR requires conformance tests to compare observable behavior, not only
|
||||||
|
repository method signatures. This document turns the MySQL/InnoDB differences that can change
|
||||||
|
OmniRoute behavior into an implementation-ready specification. It provides:
|
||||||
|
|
||||||
|
- a required server and session profile;
|
||||||
|
- evidence from the current SQLite implementation;
|
||||||
|
- minimal SQL probes that reviewers can reproduce independently;
|
||||||
|
- a backend-neutral error and retry taxonomy;
|
||||||
|
- normative decisions that a repository contract must make;
|
||||||
|
- executable acceptance specifications for a future shared conformance harness;
|
||||||
|
- a focused acceptance profile for combo definitions and model-to-combo mappings.
|
||||||
|
|
||||||
|
The terms **MUST**, **MUST NOT**, **SHOULD**, and **MAY** are normative. A proposed MySQL adapter is
|
||||||
|
not conformant merely because its SQL succeeds. It is conformant only when the same repository
|
||||||
|
fixture produces the same domain result, durable state, atomicity, ordering, and classified failure
|
||||||
|
as the SQLite implementation.
|
||||||
|
|
||||||
|
## 2. Scope and non-goals
|
||||||
|
|
||||||
|
### 2.1 In scope
|
||||||
|
|
||||||
|
This specification covers portable durable-state behavior for:
|
||||||
|
|
||||||
|
- create, read, update, delete, and missing-row results;
|
||||||
|
- uniqueness, collation, case and accent sensitivity, and `NULL`;
|
||||||
|
- stable ordering and pagination;
|
||||||
|
- no-op writes and affected-row reporting;
|
||||||
|
- insert, identity-preserving upsert, and replacement;
|
||||||
|
- IDs, JSON, exact numerics, and timestamps;
|
||||||
|
- transactions, deadlocks, lock waits, disconnects, and retry boundaries;
|
||||||
|
- foreign keys and atomic related-record changes;
|
||||||
|
- migration ownership, implicit DDL commits, recovery, and readiness.
|
||||||
|
|
||||||
|
### 2.2 Out of scope
|
||||||
|
|
||||||
|
This specification does not:
|
||||||
|
|
||||||
|
- approve PostgreSQL or MySQL runtime support;
|
||||||
|
- select a Node.js MySQL driver or pool;
|
||||||
|
- define a public environment variable or configuration UI;
|
||||||
|
- define final TypeScript repository interfaces;
|
||||||
|
- add physical MySQL schema or migration files;
|
||||||
|
- make SQLite maintenance, FTS5, `sqlite-vec`, backup files, or WAL portable;
|
||||||
|
- replace domain-specific acceptance criteria;
|
||||||
|
- permit runtime work while the governing ADR remains unapproved.
|
||||||
|
|
||||||
|
## 3. Evidence from the current repository
|
||||||
|
|
||||||
|
The current implementation establishes behavior that a portable contract must either preserve or
|
||||||
|
explicitly revise. These are source-backed observations, not proposed MySQL schema.
|
||||||
|
|
||||||
|
### 3.1 Combo identity and lookup
|
||||||
|
|
||||||
|
`src/lib/db/migrations/001_initial_schema.sql` defines `combos.id` as the primary key and
|
||||||
|
`combos.name` as unique. `src/lib/db/combos.ts` currently:
|
||||||
|
|
||||||
|
- generates UUIDs in the application;
|
||||||
|
- generates timestamps with `new Date().toISOString()`;
|
||||||
|
- performs exact name lookup first;
|
||||||
|
- provides a separate `COLLATE NOCASE` fallback lookup;
|
||||||
|
- lists by `sort_order ASC, name COLLATE NOCASE ASC`;
|
||||||
|
- treats an update of a missing ID as `null`;
|
||||||
|
- treats deletion of a missing ID as `false`;
|
||||||
|
- updates the JSON payload and deduplicated columns together;
|
||||||
|
- reorders all selected rows in one SQLite transaction.
|
||||||
|
|
||||||
|
Those choices imply that a future MySQL slice does not need database-generated numeric IDs for
|
||||||
|
combos, but it must still define Unicode collation, complete tie-breakers, update/delete results, and
|
||||||
|
reorder concurrency.
|
||||||
|
|
||||||
|
### 3.2 Model-to-combo mapping behavior
|
||||||
|
|
||||||
|
`src/lib/db/migrations/010_model_combo_mappings.sql` defines a foreign key from
|
||||||
|
`model_combo_mappings.combo_id` to `combos.id` with `ON DELETE CASCADE`.
|
||||||
|
`src/lib/db/modelComboMappings.ts` currently:
|
||||||
|
|
||||||
|
- generates mapping UUIDs and ISO timestamps in the application;
|
||||||
|
- lists by `priority DESC, created_at ASC`;
|
||||||
|
- returns a separate total count for paginated results;
|
||||||
|
- maps integer `0`/`1` values to booleans;
|
||||||
|
- treats a missing update as `null` and a missing delete as `false`;
|
||||||
|
- resolves the first enabled matching pattern;
|
||||||
|
- skips malformed combo JSON rather than failing resolution.
|
||||||
|
|
||||||
|
The current list and resolution order lacks a unique final tie-breaker. The MySQL implementation
|
||||||
|
MUST NOT preserve that accidental nondeterminism. Before portability is claimed, the contract must
|
||||||
|
add `id ASC` (or another unique stable key) after `created_at ASC` and the SQLite implementation
|
||||||
|
must adopt the same order.
|
||||||
|
|
||||||
|
### 3.3 Existing SQLite-specific signals
|
||||||
|
|
||||||
|
The measured SQLite coupling inventory records widespread use of synchronous prepared statements,
|
||||||
|
`INSERT OR REPLACE`, `lastInsertRowid`, SQLite transactions, and SQLite lifecycle operations. A
|
||||||
|
future adapter must not translate those tokens mechanically. In particular:
|
||||||
|
|
||||||
|
- `INSERT OR REPLACE` is delete-then-insert conflict handling, not an update;
|
||||||
|
- `changes` is a driver result, not a portable domain result;
|
||||||
|
- `COLLATE NOCASE` is not equivalent to a modern MySQL Unicode collation;
|
||||||
|
- SQLite numbered migration SQL is not reusable as MySQL migration SQL.
|
||||||
|
|
||||||
|
## 4. Required MySQL deployment and session profile
|
||||||
|
|
||||||
|
A conformance run MUST fail during backend initialization if the effective profile is outside the
|
||||||
|
supported envelope. Silently inheriting server defaults would make behavior depend on an operator's
|
||||||
|
installation history.
|
||||||
|
|
||||||
|
| Property | Required profile | Verification | Failure class |
|
||||||
|
| ------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | --------------------- |
|
||||||
|
| Server family | Oracle MySQL 8.0.x until another family passes the same suite | `SELECT VERSION()` and server metadata | `unsupported` |
|
||||||
|
| Storage engine | `InnoDB` for every portable table | `information_schema.tables` | `schema_incompatible` |
|
||||||
|
| Character set | `utf8mb4` for schema, tables, and portable text columns | `information_schema.schemata`, `tables`, and `columns` | `schema_incompatible` |
|
||||||
|
| Identity collation | Explicit per identity column; never inherited | `information_schema.columns.collation_name` | `schema_incompatible` |
|
||||||
|
| SQL mode | Strict mode and the engine-substitution guard; adapter records the effective value | `SELECT @@SESSION.sql_mode` | `unsupported` |
|
||||||
|
| Transaction isolation | Explicitly selected and verified by the backend | `SELECT @@SESSION.transaction_isolation` | `unsupported` |
|
||||||
|
| Session time zone | UTC | `SELECT @@SESSION.time_zone` | `unsupported` |
|
||||||
|
| Autocommit | Known pool default; repository transactions set boundaries explicitly | `SELECT @@SESSION.autocommit` | `unsupported` |
|
||||||
|
| Connection character set | `utf8mb4` | `SELECT @@character_set_client, @@character_set_connection, @@character_set_results` | `unsupported` |
|
||||||
|
| Found-rows behavior | One fixed pool setting, but repository results remain independent of it | Driver/pool configuration plus conformance probe | `unsupported` |
|
||||||
|
| Foreign-key checks | Enabled for normal runtime and conformance tests | `SELECT @@SESSION.foreign_key_checks` | `unsupported` |
|
||||||
|
| InnoDB page size | Recorded before validating indexed key lengths | `SELECT @@innodb_page_size` | `schema_incompatible` |
|
||||||
|
|
||||||
|
The backend readiness report SHOULD expose the verified profile without credentials. It MUST NOT
|
||||||
|
log connection strings or secrets.
|
||||||
|
|
||||||
|
### 4.1 Initialization probe
|
||||||
|
|
||||||
|
The adapter acceptance suite should run an equivalent of the following read-only probe on a newly
|
||||||
|
leased connection:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
VERSION() AS server_version,
|
||||||
|
@@SESSION.sql_mode AS sql_mode,
|
||||||
|
@@SESSION.transaction_isolation AS transaction_isolation,
|
||||||
|
@@SESSION.time_zone AS time_zone,
|
||||||
|
@@SESSION.autocommit AS autocommit,
|
||||||
|
@@SESSION.foreign_key_checks AS foreign_key_checks,
|
||||||
|
@@character_set_client AS character_set_client,
|
||||||
|
@@character_set_connection AS character_set_connection,
|
||||||
|
@@character_set_results AS character_set_results,
|
||||||
|
@@innodb_page_size AS innodb_page_size;
|
||||||
|
```
|
||||||
|
|
||||||
|
A pool MUST apply and verify session settings on every newly created physical connection. Applying
|
||||||
|
settings only to the first connection is insufficient.
|
||||||
|
|
||||||
|
## 5. Normative semantic matrix
|
||||||
|
|
||||||
|
### 5.0 Observable SQLite/MySQL difference summary
|
||||||
|
|
||||||
|
This table is the review index for the detailed rules below. It distinguishes current or common
|
||||||
|
backend behavior from the portable result the repository must expose. The MySQL column describes
|
||||||
|
InnoDB under the verified session profile; it must not be read as permission to inherit an
|
||||||
|
unverified server default.
|
||||||
|
|
||||||
|
| Concern | SQLite-shaped behavior | MySQL/InnoDB behavior | Required repository contract |
|
||||||
|
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Text identity | Binary comparison by default; current code opts into ASCII-oriented `NOCASE` for selected reads and sorts | Equality, uniqueness, and sort order follow the selected column/expression collation | Declare byte-exact identity separately from named insensitive lookup and display order |
|
||||||
|
| Nullable unique key | Multiple SQL `NULL` values can pass a plain unique constraint | Multiple SQL `NULL` values can pass a plain unique index | Enforce any "one logical null" invariant atomically outside a plain unique key |
|
||||||
|
| Unordered/tied results | No total order without a complete `ORDER BY` | No total order without a complete `ORDER BY` | Define `NULL` position and a unique final tie-breaker for every portable list |
|
||||||
|
| No-op update | Driver change count reflects SQLite's statement behavior | Changed-row count differs from matched-row mode for identical assignments | Return domain outcomes independently of raw affected-row counts |
|
||||||
|
| Conflict write | `INSERT OR REPLACE` can delete then insert | Duplicate-key upsert updates one selected conflict | Classify every operation as insert-only, identity-preserving upsert, or replacement |
|
||||||
|
| Generated identity | SQLite row IDs and driver-local last-insert state are connection-bound | Generated IDs and last-insert state are connection-bound | Retrieve identity in the insert operation/lease and use stable idempotency identity on retry |
|
||||||
|
| JSON | Existing combo payloads are text and malformed legacy text can be observed | Native `JSON` validates and normalizes its representation | Choose text or typed JSON deliberately and compare the declared domain representation |
|
||||||
|
| Exact values/time | Current modules commonly serialize JavaScript values and ISO UTC text | Driver conversion can lose large integers/decimals; temporal types depend on type and session zone | Fix exact representations, UTC policy, and precision across backends |
|
||||||
|
| Concurrency/isolation | Deferred transactions and a database-wide single-writer model shape conflicts; read visibility depends on transaction mode and WAL state | InnoDB defaults to `REPEATABLE READ`, uses MVCC snapshots for consistent reads, and permits concurrent writers on different locked records | Select and verify isolation, then test domain-visible reads, conflicts, and retry boundaries rather than relying on either default |
|
||||||
|
| DDL/migrations | SQLite migration sequences can be wrapped according to SQLite transaction rules | DDL commonly commits implicitly; one atomic DDL statement does not make a multi-step migration atomic | Use distributed ownership, durable phase checkpoints, postcondition inspection, and readiness gating |
|
||||||
|
|
||||||
|
### 5.1 Text identity, collation, and uniqueness
|
||||||
|
|
||||||
|
MySQL equality and unique indexes use the effective collation of the indexed expression. A `_ci`
|
||||||
|
collation is case-insensitive; an `_ai` collation is also accent-insensitive. SQLite's default text
|
||||||
|
comparison and `COLLATE NOCASE` do not provide an equivalent Unicode contract.
|
||||||
|
|
||||||
|
| Concern | SQLite-shaped risk | Required portable decision | MySQL implementation rule |
|
||||||
|
| ---------------- | -------------------------------------------------------- | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
||||||
|
| IDs | Text IDs can inherit an unintended collation | IDs are byte-exact and case-sensitive | Use an explicit binary collation or binary representation |
|
||||||
|
| Combo names | Exact lookup and insensitive fallback are separate today | Exact lookup remains exact; insensitive lookup is a named operation | Exact and insensitive queries use explicit, different collations or normalized keys |
|
||||||
|
| Unique names | A server default can collapse case or accents | The domain declares whether case/accent variants conflict | Unique index uses the declared collation, never the database default |
|
||||||
|
| Pattern text | Pattern matching occurs in application code | Stored pattern bytes round-trip unchanged | Store with an explicit case-sensitive collation |
|
||||||
|
| User-facing sort | SQLite `NOCASE` order is not portable Unicode order | List order is defined by a normalized sort key or explicit collation policy | Schema and query use the selected policy and a unique tie-breaker |
|
||||||
|
|
||||||
|
Minimum probe:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TEMPORARY TABLE conformance_text (
|
||||||
|
id VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin PRIMARY KEY,
|
||||||
|
name VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci UNIQUE
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
INSERT INTO conformance_text (id, name) VALUES ('A', 'Résumé');
|
||||||
|
-- The next statement conflicts under utf8mb4_0900_ai_ci.
|
||||||
|
INSERT INTO conformance_text (id, name) VALUES ('a', 'resume');
|
||||||
|
```
|
||||||
|
|
||||||
|
The harness MUST repeat the probe for the exact collation selected by the eventual schema; the
|
||||||
|
example collation above is evidence, not an approval for combo names.
|
||||||
|
|
||||||
|
### 5.2 `NULL`, missing rows, and nullable unique keys
|
||||||
|
|
||||||
|
MySQL unique indexes permit multiple `NULL` values. SQLite does likewise for unique columns.
|
||||||
|
However, neither behavior implements a domain invariant such as "only one active row may have no
|
||||||
|
owner."
|
||||||
|
|
||||||
|
Repository contracts MUST distinguish:
|
||||||
|
|
||||||
|
- no row found;
|
||||||
|
- a row found with a nullable field set to SQL `NULL`;
|
||||||
|
- a JSON document containing JSON `null`;
|
||||||
|
- a missing JSON member.
|
||||||
|
|
||||||
|
Minimum probe:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TEMPORARY TABLE conformance_null (
|
||||||
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
|
optional_key VARCHAR(64) NULL,
|
||||||
|
UNIQUE KEY uq_optional_key (optional_key)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
INSERT INTO conformance_null VALUES ('one', NULL), ('two', NULL);
|
||||||
|
SELECT COUNT(*) AS row_count FROM conformance_null;
|
||||||
|
-- Expected: 2.
|
||||||
|
```
|
||||||
|
|
||||||
|
If a domain allows at most one logical `NULL`, it MUST use an explicit atomic invariant rather than
|
||||||
|
rely on a plain unique index.
|
||||||
|
|
||||||
|
### 5.3 Ordering, ties, and pagination
|
||||||
|
|
||||||
|
Without `ORDER BY`, result order is undefined. With a non-unique `ORDER BY`, tied rows still have an
|
||||||
|
undefined relative order. Offset pagination can therefore duplicate or omit records if the complete
|
||||||
|
order is not stable.
|
||||||
|
|
||||||
|
Every portable list MUST specify:
|
||||||
|
|
||||||
|
1. every user-visible sort expression;
|
||||||
|
2. the position of `NULL` values;
|
||||||
|
3. a unique final tie-breaker;
|
||||||
|
4. the cursor comparison tuple, if cursor pagination is used;
|
||||||
|
5. the snapshot/concurrency expectation across pages.
|
||||||
|
|
||||||
|
For the proposed combo/mapping slice:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Combo list contract candidate.
|
||||||
|
ORDER BY sort_order ASC, normalized_name ASC, id ASC
|
||||||
|
|
||||||
|
-- Mapping list and resolution contract candidate.
|
||||||
|
ORDER BY priority DESC, created_at ASC, id ASC
|
||||||
|
```
|
||||||
|
|
||||||
|
The exact `normalized_name` representation remains a contract decision. It MUST NOT be implemented
|
||||||
|
by relying on an unspecified database default.
|
||||||
|
|
||||||
|
For nullable values, use an explicit sort key rather than a backend default:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ORDER BY nullable_column IS NULL ASC, nullable_column ASC, id ASC
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 Update, no-op, delete, and affected rows
|
||||||
|
|
||||||
|
MySQL `UPDATE` reports rows actually changed by default. With the C API found-rows connection flag,
|
||||||
|
it reports rows matched. `INSERT ... ON DUPLICATE KEY UPDATE` reports 1 for insert, 2 for an actual
|
||||||
|
update, and 0 for an update to identical values; the found-rows flag changes the last value to 1.
|
||||||
|
These numbers MUST NOT become repository semantics.
|
||||||
|
|
||||||
|
| Repository outcome | Required meaning | Forbidden implementation shortcut |
|
||||||
|
| ------------------ | ------------------------------------------------------ | --------------------------------------------- |
|
||||||
|
| `updated` | Target existed and the operation's postcondition holds | `affectedRows > 0` alone |
|
||||||
|
| `unchanged` | Target existed and already satisfied the postcondition | Treating 0 changed rows as missing |
|
||||||
|
| `not_found` | Target identity did not exist | Treating every 0 count as unchanged |
|
||||||
|
| `conflict` | Compare/update version or invariant failed | Returning generic `false` |
|
||||||
|
| delete `true` | A row existed and was deleted | Assuming a successful statement deleted a row |
|
||||||
|
| delete `false` | No row existed | Throwing a backend-specific error |
|
||||||
|
|
||||||
|
Minimum probe, run once with each supported connection mode:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TEMPORARY TABLE conformance_update (
|
||||||
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
|
value_text VARCHAR(64) NOT NULL,
|
||||||
|
version_no BIGINT NOT NULL
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
INSERT INTO conformance_update VALUES ('row', 'same', 1);
|
||||||
|
UPDATE conformance_update SET value_text = 'same' WHERE id = 'row';
|
||||||
|
UPDATE conformance_update SET value_text = 'changed' WHERE id = 'row';
|
||||||
|
UPDATE conformance_update SET value_text = 'missing' WHERE id = 'missing';
|
||||||
|
```
|
||||||
|
|
||||||
|
The harness asserts repository results and final rows, not raw driver counts. A versioned
|
||||||
|
compare/update SHOULD use a predicate such as `WHERE id = ? AND version_no = ?`, then distinguish a
|
||||||
|
missing identity from a stale version according to the domain contract.
|
||||||
|
|
||||||
|
### 5.5 Insert, upsert, and replacement
|
||||||
|
|
||||||
|
SQLite `INSERT OR REPLACE` deletes rows that conflict with a unique or primary key before inserting
|
||||||
|
the new row. MySQL `INSERT ... ON DUPLICATE KEY UPDATE` updates one conflicting row. The two forms
|
||||||
|
differ in foreign-key cascades, triggers, omitted columns, IDs, timestamps, and affected-row counts.
|
||||||
|
|
||||||
|
Every write method MUST be classified as exactly one of:
|
||||||
|
|
||||||
|
1. **insert-only:** duplicate identity returns `unique_violation`;
|
||||||
|
2. **identity-preserving upsert:** duplicate identity updates an explicit allowlist of mutable fields;
|
||||||
|
3. **replacement:** old identity is deleted and a new row is inserted, with cascade effects included
|
||||||
|
in the contract.
|
||||||
|
|
||||||
|
A generic helper MUST NOT choose among these behaviors based on SQL convenience.
|
||||||
|
|
||||||
|
Minimum difference probe. This uses ordinary InnoDB tables because MySQL temporary tables cannot
|
||||||
|
serve as the parent/child foreign-key fixture. Run it in an isolated conformance schema; cleanup is
|
||||||
|
included so the probe is repeatable:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DROP TABLE IF EXISTS conformance_child;
|
||||||
|
DROP TABLE IF EXISTS conformance_parent;
|
||||||
|
|
||||||
|
CREATE TABLE conformance_parent (
|
||||||
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
|
immutable_value VARCHAR(64) NOT NULL,
|
||||||
|
mutable_value VARCHAR(64) NOT NULL
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
CREATE TABLE conformance_child (
|
||||||
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
|
parent_id VARCHAR(64) NOT NULL,
|
||||||
|
CONSTRAINT fk_conformance_child_parent
|
||||||
|
FOREIGN KEY (parent_id) REFERENCES conformance_parent(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
INSERT INTO conformance_parent VALUES ('p', 'keep', 'old');
|
||||||
|
INSERT INTO conformance_child VALUES ('c', 'p');
|
||||||
|
INSERT INTO conformance_parent (id, immutable_value, mutable_value)
|
||||||
|
VALUES ('p', 'replacement', 'new')
|
||||||
|
ON DUPLICATE KEY UPDATE mutable_value = VALUES(mutable_value);
|
||||||
|
|
||||||
|
SELECT immutable_value, mutable_value FROM conformance_parent WHERE id = 'p';
|
||||||
|
SELECT COUNT(*) AS child_count FROM conformance_child WHERE parent_id = 'p';
|
||||||
|
-- Expected: immutable_value='keep', mutable_value='new', child_count=1.
|
||||||
|
|
||||||
|
DROP TABLE conformance_child;
|
||||||
|
DROP TABLE conformance_parent;
|
||||||
|
```
|
||||||
|
|
||||||
|
The `VALUES(mutable_value)` form is used here because the target remains MySQL 8.0 as a family and
|
||||||
|
no minimum 8.0 patch release has been approved. It is deprecated in later MySQL 8.0 releases, so an
|
||||||
|
adapter that establishes a newer minimum MAY use the supported row-alias form instead. The harness
|
||||||
|
asserts identity-preserving behavior, not either SQL spelling.
|
||||||
|
|
||||||
|
Tables with multiple unique indexes require special care because a duplicate can select an
|
||||||
|
unexpected conflicting row. Portable upsert schema SHOULD have one unambiguous conflict identity.
|
||||||
|
|
||||||
|
### 5.6 Unicode and index-size constraints
|
||||||
|
|
||||||
|
`utf8mb4` uses up to four bytes per character. InnoDB's maximum index key is 3072 bytes for common
|
||||||
|
`DYNAMIC` or `COMPRESSED` row formats with a 16 KiB page, and is lower for smaller page sizes or
|
||||||
|
legacy row formats. A prefix unique index is not equivalent to full-value uniqueness.
|
||||||
|
|
||||||
|
Schema acceptance MUST:
|
||||||
|
|
||||||
|
- set bounded lengths for all indexed identity strings;
|
||||||
|
- calculate the worst-case byte length of every composite index;
|
||||||
|
- verify the actual page size and row format;
|
||||||
|
- reject a prefix unique index for a full-identity contract;
|
||||||
|
- test maximum-length non-ASCII values before migration is accepted;
|
||||||
|
- classify an incompatible definition as `schema_incompatible`, not `unique_violation`.
|
||||||
|
|
||||||
|
Example boundary probe for a 16 KiB/DYNAMIC profile:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TEMPORARY TABLE conformance_index (
|
||||||
|
value_text VARCHAR(768) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
UNIQUE KEY uq_value_text (value_text)
|
||||||
|
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC;
|
||||||
|
```
|
||||||
|
|
||||||
|
The exact accepted length MUST be derived from all key parts and the verified deployment profile;
|
||||||
|
this example is deliberately near a physical boundary and is not a proposed production column.
|
||||||
|
|
||||||
|
### 5.7 IDs and connection-local state
|
||||||
|
|
||||||
|
The current combo and mapping modules generate UUIDs in the application. A MySQL implementation
|
||||||
|
SHOULD preserve this strategy for those domains.
|
||||||
|
|
||||||
|
If another domain uses a database-generated incrementing ID, the adapter MUST observe these rules:
|
||||||
|
|
||||||
|
- ID retrieval is part of the same driver operation and physical connection as the insert;
|
||||||
|
- callers never issue a later connection-level `LAST_INSERT_ID()` query;
|
||||||
|
- multi-row inserts define whether one ID or all IDs are returned;
|
||||||
|
- an error or rollback makes a previously observed `LAST_INSERT_ID()` unsuitable as proof of commit;
|
||||||
|
- retries use a stable domain idempotency key;
|
||||||
|
- upsert defines whether it returns an existing or newly generated identity.
|
||||||
|
|
||||||
|
MySQL documents `LAST_INSERT_ID()` as per-connection state and leaves it undefined after some errors
|
||||||
|
or error-driven rollbacks. Pool leases are therefore part of correctness, not merely performance.
|
||||||
|
|
||||||
|
### 5.8 JSON representation
|
||||||
|
|
||||||
|
Current combo data is JSON text, and malformed JSON is observable: combo reads can skip malformed
|
||||||
|
rows and mapping resolution skips malformed combo payloads. Switching the MySQL column directly to
|
||||||
|
native `JSON` would reject malformed rows at write/import time and normalize duplicate keys,
|
||||||
|
whitespace, and key order.
|
||||||
|
|
||||||
|
Before choosing `LONGTEXT` or `JSON`, the combo contract MUST decide:
|
||||||
|
|
||||||
|
- whether malformed stored payloads remain representable for compatibility tests;
|
||||||
|
- whether equality is structural or byte-for-byte;
|
||||||
|
- whether duplicate object keys are rejected before persistence;
|
||||||
|
- whether serialization order is stable and application-owned;
|
||||||
|
- which fields are duplicated into typed columns and which representation is authoritative.
|
||||||
|
|
||||||
|
For the first slice, an identity-preserving migration SHOULD keep application serialization as the
|
||||||
|
domain boundary. If native `JSON` is selected, imports MUST parse and validate before writing, and
|
||||||
|
tests MUST compare parsed domain values rather than raw JSON text.
|
||||||
|
|
||||||
|
Minimum normalization probe:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TEMPORARY TABLE conformance_json (id VARCHAR(64) PRIMARY KEY, payload JSON) ENGINE=InnoDB;
|
||||||
|
INSERT INTO conformance_json VALUES ('j', '{"b": 2, "a": 1, "a": 3}');
|
||||||
|
SELECT payload FROM conformance_json WHERE id = 'j';
|
||||||
|
-- The value is normalized; original whitespace/key duplication is not preserved.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.9 Exact numerics and timestamps
|
||||||
|
|
||||||
|
| Type | Risk | Required contract |
|
||||||
|
| ----------- | ----------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||||
|
| `BIGINT` | Values can exceed JavaScript's safe integer range | Return a string or validated bigint representation across every backend |
|
||||||
|
| `DECIMAL` | Driver options may return strings or lossy numbers | Fix precision/scale and use an exact domain representation |
|
||||||
|
| `TIMESTAMP` | Session time zone conversion and fractional precision | Force UTC session time zone and specify fractional precision |
|
||||||
|
| `DATETIME` | No intrinsic time zone | Use only for explicitly zone-free civil time |
|
||||||
|
| ISO text | Lexical ordering depends on one canonical format | Validate UTC suffix and exact precision before persistence |
|
||||||
|
|
||||||
|
Combo and mapping timestamps are currently application-generated ISO strings. The first slice SHOULD
|
||||||
|
preserve their exact domain format rather than introducing server-generated local time.
|
||||||
|
|
||||||
|
### 5.10 Transaction isolation and observable concurrency
|
||||||
|
|
||||||
|
MySQL InnoDB uses `REPEATABLE READ` as its default isolation level. Within an explicit transaction,
|
||||||
|
its consistent non-locking reads normally establish and reuse an MVCC snapshot, while locking reads
|
||||||
|
and writes inspect and lock current index records or ranges. SQLite instead combines snapshot/read
|
||||||
|
transaction behavior with a database-wide single-writer model; transaction mode and WAL state affect
|
||||||
|
when a writer is admitted and when a read transaction can be upgraded. These mechanisms are not
|
||||||
|
interchangeable even when a simple CRUD fixture produces the same final row.
|
||||||
|
|
||||||
|
The backend profile MUST select and verify an isolation level rather than silently accept either
|
||||||
|
backend's default. The repository contract MUST then define observable results for each atomic
|
||||||
|
operation. It MUST NOT promise the implementation mechanism itself, such as gap locks or a
|
||||||
|
SQLite-wide writer lock.
|
||||||
|
|
||||||
|
| Scenario | SQLite-shaped risk | InnoDB `REPEATABLE READ` risk | Required conformance decision |
|
||||||
|
| --------------------------------- | --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Two reads in one transaction | Snapshot timing depends on when the read transaction begins and the active journal mode | Consistent reads normally reuse the transaction's first established read view | State whether the operation requires one stable snapshot or deliberately performs a current read |
|
||||||
|
| Range read plus concurrent insert | A concurrent writer may be serialized by SQLite's writer admission rules | A plain consistent read can retain its snapshot; a locking range read can lock index gaps | Define whether a later read sees the insert and whether the operation requires a locking predicate |
|
||||||
|
| Read-modify-write | Single-writer serialization can mask an unsafe application sequence | Concurrent transactions can read the same value and later contend or overwrite without a version predicate | Require compare/update, a locking read, or another explicit invariant; never rely on backend serialization |
|
||||||
|
| Writers touching different rows | SQLite still admits only one writer at a time | InnoDB can execute both until their record/range locks conflict | Do not infer portable throughput or lock order; assert only atomic effects and classified conflicts |
|
||||||
|
| Pagination across transactions | Separate page reads can observe different committed states | Separate autocommit reads get separate views; one transaction may retain one view | Declare snapshot pagination or documented live pagination and test that policy |
|
||||||
|
| Retry after conflict | Busy/locked outcomes and transaction upgrade failures are SQLite-shaped | Deadlocks and lock timeouts have different rollback scopes | Normalize the error, discard the failed context, and retry the complete idempotent operation only |
|
||||||
|
|
||||||
|
Minimum two-connection visibility probe for the selected MySQL profile:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Connection A Connection B
|
||||||
|
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
|
||||||
|
START TRANSACTION;
|
||||||
|
SELECT value_no FROM conformance_isolation
|
||||||
|
WHERE id = 1; -- establishes read view: 0
|
||||||
|
START TRANSACTION;
|
||||||
|
UPDATE conformance_isolation
|
||||||
|
SET value_no = 1 WHERE id = 1;
|
||||||
|
COMMIT;
|
||||||
|
SELECT value_no FROM conformance_isolation
|
||||||
|
WHERE id = 1; -- same consistent-read view: 0
|
||||||
|
COMMIT;
|
||||||
|
SELECT value_no FROM conformance_isolation
|
||||||
|
WHERE id = 1; -- new transaction/view: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
The shared harness MUST NOT assert that every backend reproduces this internal sequence. It must use
|
||||||
|
it to prove that the chosen repository operation either requests a stable snapshot explicitly or
|
||||||
|
avoids depending on repeat-read visibility. If an operation uses a current/locking read, that choice
|
||||||
|
and its conflict behavior need a separate test.
|
||||||
|
|
||||||
|
## 6. Transactions, failures, and retry policy
|
||||||
|
|
||||||
|
### 6.1 Transaction states
|
||||||
|
|
||||||
|
The backend contract should expose only opaque transaction contexts, but its implementation must
|
||||||
|
maintain the following lifecycle:
|
||||||
|
|
||||||
|
```text
|
||||||
|
idle
|
||||||
|
-> active
|
||||||
|
-> committed
|
||||||
|
-> rolled_back
|
||||||
|
-> failed_statement -> rolled_back
|
||||||
|
-> failed_transaction -> rolled_back
|
||||||
|
-> outcome_unknown -> reconciled | escalated
|
||||||
|
```
|
||||||
|
|
||||||
|
A context in `committed`, `rolled_back`, `failed_transaction`, or `outcome_unknown` MUST reject new
|
||||||
|
repository work. A context with a failed statement SHOULD be explicitly rolled back before its
|
||||||
|
connection returns to the pool, even when MySQL would technically permit more statements.
|
||||||
|
|
||||||
|
### 6.2 Error classification matrix
|
||||||
|
|
||||||
|
Numeric codes and SQLSTATE values below are MySQL 8.0 server signals. A Node.js driver can also
|
||||||
|
produce transport-specific codes; those MUST be normalized without leaking raw messages to callers.
|
||||||
|
|
||||||
|
| Condition | MySQL signal | Rollback scope | Portable class | Retry policy |
|
||||||
|
| ------------------------------ | -------------------------------------- | ------------------------------------------------- | ------------------------ | -------------------------------------------------------------- |
|
||||||
|
| Duplicate key | `1062`, SQLSTATE `23000` | Statement | `unique_violation` | No, unless contract defines idempotent create |
|
||||||
|
| Missing referenced parent | `1452`, SQLSTATE `23000` | Statement | `foreign_key_violation` | No |
|
||||||
|
| Parent still referenced | `1451`, SQLSTATE `23000` | Statement | `foreign_key_violation` | No |
|
||||||
|
| Deadlock victim | `1213`, SQLSTATE `40001` | Entire transaction | `transaction_conflict` | Retry whole atomic operation |
|
||||||
|
| Lock wait timeout | `1205`, SQLSTATE `HY000` | Statement by default; server option can change it | `lock_timeout` | Roll back explicitly, then retry whole operation if idempotent |
|
||||||
|
| Invalid JSON text | `3140`, SQLSTATE `22032` | Statement | `invalid_data` | No |
|
||||||
|
| Data too long | `1406`, SQLSTATE `22001` | Statement | `invalid_data` | No |
|
||||||
|
| Check constraint | `3819`, SQLSTATE `HY000` | Statement | `constraint_violation` | No |
|
||||||
|
| Server gone before request | Driver/server transport signal | No operation or unknown | `unavailable` | Retry only if operation definitely was not sent |
|
||||||
|
| Connection lost during request | Driver transport signal | Unknown | `outcome_unknown` | Reconcile by idempotency key; do not blind retry |
|
||||||
|
| Pool acquisition timeout | Driver/pool signal | None | `unavailable` | Bounded retry outside transaction |
|
||||||
|
| Unsupported profile | Initialization probe mismatch | None | `unsupported` | No; fail readiness |
|
||||||
|
| Migration lock timeout | Named-lock acquisition returns timeout | None | `migration_lock_timeout` | Wait/back off according to startup policy |
|
||||||
|
| Migration lock error | Named-lock acquisition returns error | None | `migration_lock_failed` | No blind retry; inspect connection state |
|
||||||
|
|
||||||
|
The adapter MUST classify by structured code and SQLSTATE where available, never by localized message
|
||||||
|
text. Public HTTP/SSE/MCP responses must still pass through the repository's existing sanitized error
|
||||||
|
helpers.
|
||||||
|
|
||||||
|
### 6.3 Retry rules
|
||||||
|
|
||||||
|
A retryable classification does not automatically make an operation safe to retry.
|
||||||
|
|
||||||
|
A retry loop MUST:
|
||||||
|
|
||||||
|
1. own the entire repository atomic operation;
|
||||||
|
2. discard the failed transaction context;
|
||||||
|
3. acquire a valid connection and begin a new transaction;
|
||||||
|
4. preserve a stable operation or entity identity;
|
||||||
|
5. use bounded attempts with jitter;
|
||||||
|
6. stop on non-retryable classifications;
|
||||||
|
7. reconcile `outcome_unknown` before issuing another write;
|
||||||
|
8. emit structured diagnostics without credentials or raw SQL values.
|
||||||
|
|
||||||
|
MySQL explicitly recommends retrying the entire transaction after a deadlock. A lock wait timeout
|
||||||
|
rolls back only the current statement by default, so explicit rollback is required to make the retry
|
||||||
|
boundary independent of server configuration.
|
||||||
|
|
||||||
|
### 6.4 Reproducible two-connection deadlock probe
|
||||||
|
|
||||||
|
Use two physical connections, not two logical operations that might share one pool connection:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE conformance_deadlock (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
value_no INT NOT NULL
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
INSERT INTO conformance_deadlock VALUES (1, 0), (2, 0);
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Connection A Connection B
|
||||||
|
START TRANSACTION; START TRANSACTION;
|
||||||
|
UPDATE ... WHERE id = 1; UPDATE ... WHERE id = 2;
|
||||||
|
UPDATE ... WHERE id = 2; UPDATE ... WHERE id = 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
Exactly one transaction should become the deadlock victim. The harness asserts that the victim is
|
||||||
|
classified as retryable, its whole transaction is retried with a new context, both logical updates
|
||||||
|
occur once, and no partial result remains.
|
||||||
|
|
||||||
|
## 7. Migration ownership and DDL recovery
|
||||||
|
|
||||||
|
### 7.1 Why a normal transaction is insufficient
|
||||||
|
|
||||||
|
MySQL DDL statements commonly commit the current transaction implicitly before execution and often
|
||||||
|
afterward. Atomic DDL protects one supported DDL statement; it does not make a sequence of DDL,
|
||||||
|
data backfill, and schema-history updates one user transaction.
|
||||||
|
|
||||||
|
A MySQL migration runner therefore MUST model a migration as recoverable phases:
|
||||||
|
|
||||||
|
```text
|
||||||
|
lock acquired
|
||||||
|
-> current schema inspected
|
||||||
|
-> intent/checkpoint recorded
|
||||||
|
-> DDL phase applied and verified
|
||||||
|
-> data phase applied in bounded transactions
|
||||||
|
-> postconditions verified
|
||||||
|
-> logical milestone recorded
|
||||||
|
-> readiness allowed
|
||||||
|
-> lock released
|
||||||
|
```
|
||||||
|
|
||||||
|
A process crash at any arrow must have a deterministic resume or stop condition.
|
||||||
|
|
||||||
|
### 7.2 Ownership alternatives
|
||||||
|
|
||||||
|
| Option | Strengths | Failure modes | Decision |
|
||||||
|
| ------------------------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||||
|
| Process-local mutex | Simple and useful for one process | Does not coordinate replicas | Rejected for external-backend migration ownership |
|
||||||
|
| Row lock held in a transaction | Uses normal InnoDB locking | DDL implicit commit releases transaction ownership | Rejected as the sole DDL migration lock |
|
||||||
|
| Lease row with owner and expiry | Survives pooled connections and can support takeover | Requires clock/expiry/fencing design; stale owner may continue | Candidate for scheduled jobs, not first migration mechanism |
|
||||||
|
| MySQL named lock | Server-wide, exclusive, tied to physical session, released on disconnect | Must pin one connection; not transaction-scoped; one-server scope; undefined waiter order | Recommended first MySQL migration mutex, combined with durable history |
|
||||||
|
| External coordinator | Can coordinate across database topologies | Adds an operational dependency outside the database contract | Deferred unless deployment topology requires it |
|
||||||
|
|
||||||
|
### 7.3 Recommended first mechanism
|
||||||
|
|
||||||
|
For a single writable MySQL primary, the migration runner SHOULD:
|
||||||
|
|
||||||
|
1. lease and pin one physical connection;
|
||||||
|
2. acquire one application-and-database-specific named lock of at most 64 characters;
|
||||||
|
3. distinguish acquired (`1`), timeout (`0`), and error (`NULL`);
|
||||||
|
4. inspect a durable migration-history table after acquiring the lock;
|
||||||
|
5. execute idempotent physical phases with explicit postcondition checks;
|
||||||
|
6. record completion only after all postconditions pass;
|
||||||
|
7. release the named lock explicitly in `finally`;
|
||||||
|
8. close/discard the pinned connection if release cannot be confirmed.
|
||||||
|
|
||||||
|
Named locks are released when the session ends, not on commit or rollback. They are server-wide on one
|
||||||
|
`mysqld`; topology and failover behavior must be validated before active-active support is advertised.
|
||||||
|
A durable history/checkpoint table remains necessary because lock ownership alone says nothing about
|
||||||
|
partially completed DDL.
|
||||||
|
|
||||||
|
### 7.4 Migration failure matrix
|
||||||
|
|
||||||
|
| Injection point | Required durable evidence | Restart behavior | Readiness |
|
||||||
|
| ------------------------------- | --------------------------------------------- | ----------------------------------- | --------------------------------------------- |
|
||||||
|
| Before lock | No intent | Retry lock acquisition | Not ready while required migration is pending |
|
||||||
|
| After lock, before intent | No schema change | Reinspect and restart | Not ready |
|
||||||
|
| After DDL, before checkpoint | Schema postcondition reveals DDL applied | Mark/continue only after validation | Not ready |
|
||||||
|
| During data backfill | Bounded checkpoint identifies completed range | Resume from verified checkpoint | Not ready |
|
||||||
|
| After data, before milestone | Postconditions prove completion | Record milestone idempotently | Not ready until recorded |
|
||||||
|
| After milestone, before release | History proves complete | New owner verifies and proceeds | Ready if all required milestones pass |
|
||||||
|
|
||||||
|
## 8. SQLite-to-MySQL migration validation
|
||||||
|
|
||||||
|
An offline migration tool is required before database switching can be advertised. For each migrated
|
||||||
|
domain it MUST provide a dry run and a post-import report.
|
||||||
|
|
||||||
|
### 8.1 Preflight
|
||||||
|
|
||||||
|
- verify supported SQLite and MySQL schema milestones;
|
||||||
|
- validate every source JSON payload according to the chosen target representation;
|
||||||
|
- detect names that collide under the target collation;
|
||||||
|
- validate UTF-8 and maximum indexed byte lengths;
|
||||||
|
- detect orphaned foreign keys even if the source connection had checks disabled;
|
||||||
|
- validate timestamps and numeric ranges;
|
||||||
|
- count source rows by table and logical domain;
|
||||||
|
- refuse to mutate either database during dry run.
|
||||||
|
|
||||||
|
### 8.2 Import
|
||||||
|
|
||||||
|
- preserve application-generated IDs;
|
||||||
|
- use deterministic batches and checkpoints;
|
||||||
|
- import parents before children;
|
||||||
|
- do not use replacement semantics to hide conflicts;
|
||||||
|
- classify every rejected row with a stable reason;
|
||||||
|
- keep encrypted credential ciphertext opaque and never log it;
|
||||||
|
- stop on an unclassified difference.
|
||||||
|
|
||||||
|
### 8.3 Postconditions
|
||||||
|
|
||||||
|
- row counts match for every migrated table;
|
||||||
|
- identity sets match exactly;
|
||||||
|
- foreign-key orphan counts are zero;
|
||||||
|
- canonical domain digests match for JSON-backed records;
|
||||||
|
- list ordering and mapping resolution produce the same results;
|
||||||
|
- a second dry run reports no pending changes;
|
||||||
|
- SQLite remains unchanged and available for operator rollback until cutover is accepted.
|
||||||
|
|
||||||
|
## 9. Backend-neutral conformance catalog
|
||||||
|
|
||||||
|
Each test below runs the same repository fixture against SQLite and MySQL. MySQL-specific probes may
|
||||||
|
assert error metadata internally, but the shared assertion compares only domain results and durable
|
||||||
|
state.
|
||||||
|
|
||||||
|
### 9.1 Core CRUD and representation
|
||||||
|
|
||||||
|
| Test name | Fixture/action | Required assertion |
|
||||||
|
| --------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------ |
|
||||||
|
| `create_round_trips_domain_values` | Create Unicode, nullable, JSON, and timestamp fields | Parsed domain object equals normalized input |
|
||||||
|
| `find_missing_distinguishes_absent_from_null` | Read an absent ID and a present nullable row | Results are distinct |
|
||||||
|
| `update_missing_returns_not_found` | Update an absent ID | Stable `not_found` result |
|
||||||
|
| `delete_is_idempotent_as_declared` | Delete the same ID twice | First and second results match the repository contract |
|
||||||
|
| `json_round_trips_structurally` | Write equivalent JSON with different whitespace/order | Parsed values are equal; raw text is not asserted |
|
||||||
|
| `timestamp_round_trips_in_utc` | Change MySQL session default before leasing a verified connection | Domain serialization remains canonical UTC |
|
||||||
|
| `decimal_round_trips_without_float_loss` | Write precision/scale boundaries | Exact representation is unchanged |
|
||||||
|
| `large_integer_does_not_cross_number_lossily` | Write beyond JavaScript safe integer range | String/bigint domain representation is exact |
|
||||||
|
|
||||||
|
### 9.2 Identity and collation
|
||||||
|
|
||||||
|
| Test name | Fixture/action | Required assertion |
|
||||||
|
| ---------------------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------- |
|
||||||
|
| `id_is_byte_exact` | Create IDs differing only by case | Both remain distinct if the ID contract is binary |
|
||||||
|
| `exact_name_lookup_is_case_sensitive` | Store `MASTER-LIGHT`, query exact lowercase | Exact lookup misses |
|
||||||
|
| `insensitive_name_lookup_uses_declared_policy` | Query the same row through the named insensitive operation | One deterministic row is returned |
|
||||||
|
| `unique_name_case_policy_is_explicit` | Insert case variants | Result matches the selected name policy on both backends |
|
||||||
|
| `unique_name_accent_policy_is_explicit` | Insert accent variants | Result matches the selected policy |
|
||||||
|
| `unique_violation_is_classified` | Concurrently create one identity | One wins; loser is `unique_violation` without backend text |
|
||||||
|
| `nullable_unique_policy_is_explicit` | Insert two `NULL` logical keys | Result matches domain rule, not accidental index behavior |
|
||||||
|
|
||||||
|
### 9.3 Ordering and pagination
|
||||||
|
|
||||||
|
| Test name | Fixture/action | Required assertion |
|
||||||
|
| --------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------ |
|
||||||
|
| `list_uses_unique_final_tiebreaker` | Insert rows with identical primary sort values | Repeated list order is identical and ID-ordered |
|
||||||
|
| `pagination_has_no_gaps_or_duplicates` | Traverse small pages across tied rows | Union equals full ID set; page intersections are empty |
|
||||||
|
| `nullable_sort_position_is_fixed` | Mix `NULL` and non-`NULL` values | `NULL` appears at the contract-defined end |
|
||||||
|
| `cursor_predicate_matches_sort_tuple` | Page forward through mixed sort keys | Every row appears exactly once in declared order |
|
||||||
|
| `concurrent_insert_pagination_behavior_is_declared` | Insert between page reads | Result matches snapshot or documented live-page policy |
|
||||||
|
|
||||||
|
### 9.4 Writes and affected rows
|
||||||
|
|
||||||
|
| Test name | Fixture/action | Required assertion |
|
||||||
|
| ------------------------------------------- | ------------------------------------------ | -------------------------------------------------- |
|
||||||
|
| `same_value_update_is_not_missing` | Update an existing row to identical values | `unchanged` or declared success, never `not_found` |
|
||||||
|
| `same_value_result_ignores_found_rows_mode` | Run fixture with both connection modes | Domain result is identical |
|
||||||
|
| `compare_update_detects_stale_version` | Two writers use one old version | One succeeds; one returns `conflict` |
|
||||||
|
| `batch_count_uses_contract_definition` | Mix changed and unchanged matches | Count means the same thing on both backends |
|
||||||
|
| `upsert_preserves_identity_and_children` | Upsert parent with a child row | ID, immutable fields, and child survive |
|
||||||
|
| `insert_only_never_silently_updates` | Repeat insert-only identity | Second call is `unique_violation` |
|
||||||
|
|
||||||
|
### 9.5 Transactions, isolation, and failure injection
|
||||||
|
|
||||||
|
| Test name | Fixture/action | Required assertion |
|
||||||
|
| ----------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||||
|
| `related_changes_commit_atomically` | Update parent and children | All postconditions commit together |
|
||||||
|
| `related_changes_roll_back_atomically` | Inject a child constraint failure | All tables equal pre-operation state |
|
||||||
|
| `stable_snapshot_behavior_is_declared` | Read, commit a concurrent update, then read in the same operation | Result follows the operation's declared snapshot/current-read policy |
|
||||||
|
| `range_insert_visibility_is_declared` | Read a range while another transaction inserts a matching row | Later visibility matches the declared snapshot/live policy |
|
||||||
|
| `read_modify_write_prevents_lost_update` | Two transactions read one version and attempt distinct updates | One declared winner; loser conflicts/retries without overwriting |
|
||||||
|
| `independent_writers_preserve_atomic_effects` | Two transactions update different identities concurrently | Both logical effects commit; no contract depends on backend lock order |
|
||||||
|
| `deadlock_retries_whole_operation` | Two physical connections lock in opposite order | One victim; final logical effect occurs once |
|
||||||
|
| `lock_timeout_discards_context` | Hold a row lock past timeout | Explicit rollback; old context rejects work |
|
||||||
|
| `duplicate_and_foreign_key_errors_are_distinct` | Trigger each constraint | Stable distinct classes |
|
||||||
|
| `disconnect_before_send_is_unavailable` | Fail connection before dispatch | Safe bounded retry is permitted |
|
||||||
|
| `disconnect_during_commit_is_outcome_unknown` | Drop connection at commit boundary | No blind retry; reconciliation is required |
|
||||||
|
| `retry_uses_stable_operation_identity` | Fail first attempt after durable write | At most one logical effect exists |
|
||||||
|
|
||||||
|
### 9.6 Migration and readiness
|
||||||
|
|
||||||
|
| Test name | Fixture/action | Required assertion |
|
||||||
|
| --------------------------------------- | ------------------------------------------- | -------------------------------------------------- |
|
||||||
|
| `only_one_instance_owns_migration` | Two backend instances acquire one name | Exactly one executes migration phases |
|
||||||
|
| `lock_timeout_is_not_reported_as_ready` | Hold migration lock from another connection | Startup waits/fails with classified state |
|
||||||
|
| `disconnect_releases_named_lock` | Terminate owner connection | Another instance can acquire and reinspect |
|
||||||
|
| `ddl_checkpoint_recovers_after_crash` | Stop after DDL before history update | Restart detects postcondition and continues safely |
|
||||||
|
| `backfill_resumes_without_duplication` | Stop between deterministic batches | Completed rows are neither skipped nor duplicated |
|
||||||
|
| `partial_migration_blocks_readiness` | Leave required milestone incomplete | Health may be alive; readiness is false |
|
||||||
|
| `completed_history_is_idempotent` | Start against fully migrated schema | No DDL/data mutation occurs |
|
||||||
|
|
||||||
|
## 10. First-slice acceptance profile: combos and model mappings
|
||||||
|
|
||||||
|
This section specializes the general catalog for the candidate first slice discussed in #8075 and
|
||||||
|
implemented experimentally in Draft PR #8757. It does not approve that runtime PR.
|
||||||
|
|
||||||
|
### 10.1 Contract decisions required before adapter code
|
||||||
|
|
||||||
|
| Decision | Current evidence | Required resolution |
|
||||||
|
| --------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
|
||||||
|
| Combo ID | Application UUID | Preserve as byte-exact text/binary identity |
|
||||||
|
| Combo name uniqueness | SQLite unique name; exact and insensitive reads differ | Select explicit uniqueness collation independently from insensitive fallback |
|
||||||
|
| Combo list | `sort_order`, then `name NOCASE` | Add `id` as final tie-breaker and define Unicode name order |
|
||||||
|
| Next sort order | `MAX(sort_order) + 1` | Replace race-prone read-then-insert with an atomic allocation or retryable unique invariant |
|
||||||
|
| Reorder | One SQLite transaction updates all parseable rows | Define concurrent reorder serialization and all-or-nothing behavior |
|
||||||
|
| Corrupt combo JSON | Reads/resolution skip malformed payloads | Decide whether MySQL schema can represent malformed legacy rows during migration |
|
||||||
|
| Mapping order | `priority DESC, created_at ASC` | Add `id ASC` final tie-breaker |
|
||||||
|
| Mapping delete | Boolean from affected rows | Preserve `true` then `false` behavior independent of found-rows mode |
|
||||||
|
| Combo delete | Foreign key cascade removes mappings | Preserve one-operation atomic cascade |
|
||||||
|
| Timestamps | Application ISO strings | Preserve canonical UTC text or define an exact typed conversion |
|
||||||
|
|
||||||
|
### 10.2 Required combo fixtures
|
||||||
|
|
||||||
|
The shared fixture MUST include:
|
||||||
|
|
||||||
|
- combo names `Alpha`, `alpha`, `Résumé`, and `resume` to exercise selected collation policy;
|
||||||
|
- three combos with the same requested `sortOrder` to exercise the unique final order;
|
||||||
|
- one missing ID for update and delete results;
|
||||||
|
- one payload with explicit JSON `null` and one with a missing member;
|
||||||
|
- one intentionally malformed legacy payload if compatibility requires it;
|
||||||
|
- mappings with identical `priority` and `createdAt` but different IDs;
|
||||||
|
- enabled, disabled, inactive-target, and corrupt-target mappings;
|
||||||
|
- one combo with at least two dependent mappings for cascade verification.
|
||||||
|
|
||||||
|
### 10.3 Required combo assertions
|
||||||
|
|
||||||
|
A MySQL implementation cannot claim the first slice complete until the shared harness proves:
|
||||||
|
|
||||||
|
1. application UUIDs and ISO timestamps round-trip unchanged;
|
||||||
|
2. exact and insensitive combo-name lookups remain distinct operations;
|
||||||
|
3. uniqueness follows the approved name policy, not server defaults;
|
||||||
|
4. combo and mapping lists have a total deterministic order;
|
||||||
|
5. every offset page is a contiguous slice of that order;
|
||||||
|
6. update of a missing combo/mapping returns `null`;
|
||||||
|
7. first delete returns `true`, repeated delete returns `false`;
|
||||||
|
8. reorder filters unknown/duplicate requested IDs exactly as the accepted contract specifies;
|
||||||
|
9. reorder either commits every intended row or none;
|
||||||
|
10. mapping resolution uses the deterministic order and skips disabled, inactive, and malformed targets;
|
||||||
|
11. deleting a combo atomically removes all dependent mappings;
|
||||||
|
12. errors are classified without raw MySQL messages;
|
||||||
|
13. SQLite starts without loading a MySQL dependency;
|
||||||
|
14. no external-backend support is advertised by the presence of this slice alone.
|
||||||
|
|
||||||
|
### 10.4 Concurrency probes specific to the slice
|
||||||
|
|
||||||
|
#### Concurrent combo creation
|
||||||
|
|
||||||
|
Two connections create different UUIDs with the same contract-equivalent name. Exactly one succeeds;
|
||||||
|
the other receives `unique_violation`. If case/accent variants are allowed by the approved policy,
|
||||||
|
both succeed and exact lookup returns the correct identity.
|
||||||
|
|
||||||
|
#### Concurrent sort allocation
|
||||||
|
|
||||||
|
Two connections create combos without an explicit sort order. The final values MUST follow the
|
||||||
|
contract without duplicates caused by both transactions reading the same `MAX(sort_order)`. The
|
||||||
|
implementation may serialize allocation, use a separate sequence, or retry a protected invariant;
|
||||||
|
the contract must not require one specific SQL mechanism.
|
||||||
|
|
||||||
|
#### Concurrent reorder
|
||||||
|
|
||||||
|
Two connections reorder the same set in opposite orders. The accepted outcome MUST be one complete
|
||||||
|
order or the other, never a mixed sequence or mismatched JSON/column `sortOrder`. The loser may wait,
|
||||||
|
return conflict, or retry according to the approved contract.
|
||||||
|
|
||||||
|
#### Delete versus mapping creation
|
||||||
|
|
||||||
|
One connection deletes a combo while another creates a mapping to it. The final state MUST be either
|
||||||
|
an existing combo with a valid mapping or no combo and no mapping. An orphan mapping is forbidden.
|
||||||
|
|
||||||
|
## 11. Implementation gate checklist
|
||||||
|
|
||||||
|
A MySQL adapter PR for any domain MUST NOT start until reviewers can answer all applicable items:
|
||||||
|
|
||||||
|
- [ ] Identity, case, accent, and collation semantics are explicit.
|
||||||
|
- [ ] Every list has a complete order, `NULL` position, and unique tie-breaker.
|
||||||
|
- [ ] Missing, unchanged, conflict, and delete results are distinguishable.
|
||||||
|
- [ ] Every write is classified as insert-only, identity-preserving upsert, or replacement.
|
||||||
|
- [ ] ID generation and idempotency ownership are explicit.
|
||||||
|
- [ ] JSON and temporal representations are selected with migration compatibility in mind.
|
||||||
|
- [ ] Error codes map to the backend-neutral taxonomy.
|
||||||
|
- [ ] Retry ownership and maximum scope are explicit.
|
||||||
|
- [ ] Migration mutex, durable checkpoints, and readiness rules are approved.
|
||||||
|
- [ ] SQLite and MySQL fixtures run through one behavior harness.
|
||||||
|
- [ ] Offline migration preflight and postconditions exist before cutover is advertised.
|
||||||
|
- [ ] SQLite remains the zero-configuration default and clean startup path.
|
||||||
|
|
||||||
|
## 12. Reference sources
|
||||||
|
|
||||||
|
### 12.1 OmniRoute sources
|
||||||
|
|
||||||
|
- `docs/architecture/persistence-backend-boundary.md`
|
||||||
|
- `docs/architecture/sqlite-coupling-inventory.md`
|
||||||
|
- `src/lib/db/combos.ts`
|
||||||
|
- `src/lib/db/modelComboMappings.ts`
|
||||||
|
- `src/lib/db/migrations/001_initial_schema.sql`
|
||||||
|
- `src/lib/db/migrations/010_model_combo_mappings.sql`
|
||||||
|
- `src/lib/db/migrations/020_combo_sort_order.sql`
|
||||||
|
|
||||||
|
### 12.2 MySQL 8.0 reference manual
|
||||||
|
|
||||||
|
- [Character sets and collations](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/charset.html)
|
||||||
|
- [CREATE TABLE](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/create-table.html)
|
||||||
|
- [UPDATE](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/update.html)
|
||||||
|
- [INSERT ... ON DUPLICATE KEY UPDATE](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/insert-on-duplicate.html)
|
||||||
|
- [Information functions](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/information-functions.html)
|
||||||
|
- [The JSON data type](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/json.html)
|
||||||
|
- [InnoDB transaction isolation](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/innodb-transaction-isolation-levels.html)
|
||||||
|
- [InnoDB error handling](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/innodb-error-handling.html)
|
||||||
|
- [Handling deadlocks](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/innodb-deadlocks-handling.html)
|
||||||
|
- [Statements that cause an implicit commit](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/implicit-commit.html)
|
||||||
|
- [Locking functions](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/locking-functions.html)
|
||||||
|
- [InnoDB limits](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/innodb-limits.html)
|
||||||
|
|
||||||
|
### 12.3 SQLite references
|
||||||
|
|
||||||
|
- [ON CONFLICT](https://sqlite.org/lang_conflict.html)
|
||||||
|
- [`NULL` handling](https://sqlite.org/nulls.html)
|
||||||
|
- [Transactions](https://sqlite.org/lang_transaction.html)
|
||||||
|
- [SELECT and ordering](https://sqlite.org/lang_select.html#orderby)
|
||||||
|
|
||||||
|
## 13. Open decisions
|
||||||
|
|
||||||
|
This specification deliberately leaves the following decisions to the accepted first-slice design:
|
||||||
|
|
||||||
|
1. the exact collation and normalization policy for combo names;
|
||||||
|
2. the typed or text representation of combo JSON in MySQL;
|
||||||
|
3. the repository result type for an existing same-value update;
|
||||||
|
4. the isolation level selected by the backend profile;
|
||||||
|
5. the concurrency mechanism for sort-order allocation and reorder;
|
||||||
|
6. the physical MySQL migration schema and durable checkpoint format;
|
||||||
|
7. the exact retry budget and backoff policy;
|
||||||
|
8. the topology boundary within which a MySQL named migration lock is sufficient.
|
||||||
|
|
||||||
|
These are not adapter implementation details. Each changes observable behavior or operational
|
||||||
|
correctness and therefore requires explicit review before runtime support proceeds.
|
||||||
278
docs/guides/ANTIGRAVITY-ONBOARDING.md
Normal file
278
docs/guides/ANTIGRAVITY-ONBOARDING.md
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
---
|
||||||
|
title: "Antigravity (Google One AI) — Onboarding with OmniRoute"
|
||||||
|
version: 3.8.50
|
||||||
|
lastUpdated: 2026-07-31
|
||||||
|
---
|
||||||
|
|
||||||
|
# OmniRoute Antigravity (Google One AI) Onboarding Guide
|
||||||
|
|
||||||
|
> **What you get**: Access to Gemini 3.1 Pro, Gemini 3.5 Flash, Claude Sonnet 4.6, and other models through your Google One AI Pro subscription — routed through OmniRoute as a unified gateway.
|
||||||
|
|
||||||
|
**Official references**:
|
||||||
|
|
||||||
|
- [Google Antigravity](https://antigravity.google) — product homepage
|
||||||
|
- [Antigravity Plans & Pricing](https://antigravity.google/pricing) — subscription tiers
|
||||||
|
- [Antigravity Docs: Plans](https://antigravity.google/docs/plans) — baseline quota details
|
||||||
|
- [Google One AI Plans](https://one.google.com/about/google-ai-plans/) — Google One subscription comparison
|
||||||
|
- [Antigravity CLI Blog](https://antigravity.google/blog/introducing-google-antigravity-cli) — CLI announcement
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Antigravity vs Antigravity CLI (agy)
|
||||||
|
|
||||||
|
Both providers share the **same Google backend** — identical OAuth client, token refresh, endpoints, and Google accounts. The difference is what models you see.
|
||||||
|
|
||||||
|
> See [Antigravity CLI announcement](https://antigravity.google/blog/introducing-google-antigravity-cli) for Google's official comparison.
|
||||||
|
|
||||||
|
| Aspect | `antigravity` (IDE) | `agy` (CLI) |
|
||||||
|
| -------------------- | ----------------------------------------- | --------------------------------------------------- |
|
||||||
|
| **Google product** | Antigravity 2.0 / Antigravity IDE | Antigravity CLI |
|
||||||
|
| **Backend** | Same Google Cloud Code API | Same Google Cloud Code API |
|
||||||
|
| **OAuth / Token** | Same client, same refresh | Same client, same refresh |
|
||||||
|
| **Model catalog** | Static curated list (OmniRoute hardcoded) | Live-probed from Google via `:fetchAvailableModels` |
|
||||||
|
| **Claude models** | Sonnet 4.6, Opus 4.6 (4 variants each) | Sonnet 4.6, Opus 4.6 (4 variants each) |
|
||||||
|
| **Gemini naming** | Clean labels (Low/Medium/High) | Upstream IDs (extra-low/low/agent) |
|
||||||
|
| **Extra models** | `gpt-oss-120b-medium` | May include additional models from Google |
|
||||||
|
| **Default use case** | IDE integration (VS Code, JetBrains) | CLI / API access |
|
||||||
|
| **Quota** | Shared with agy (same Google account) | Shared with antigravity (same Google account) |
|
||||||
|
|
||||||
|
**Available models (verified via experiment, 2026-07-29)**:
|
||||||
|
|
||||||
|
- Gemini: 3.6 Flash, 3.5 Flash, 3.1 Pro, 3 Flash, 2.5 Flash (various thinking levels)
|
||||||
|
- Claude: Sonnet 4.6, Opus 4.6 (each with default/low/medium/high variants)
|
||||||
|
- Other: GPT-OSS 120B Medium
|
||||||
|
- **Claude Sonnet 5 is NOT available** — only 4.6 variants are supported
|
||||||
|
|
||||||
|
**Why the model catalog differs**: Google's CLI is "optimized for speed and low overhead" and "co-optimized with Gemini models" (per Google's official blog). The Web/IDE product is "optimized for comprehensiveness." The CLI uses `:fetchAvailableModels` to dynamically discover models, while the IDE uses a static curated list.
|
||||||
|
|
||||||
|
**In practice**: Use `agy/` prefix for Gemini models (e.g. `agy/gemini-3.5-flash-high`). Use `antigravity/` for the static curated list. Both hit the same Google backend, but expose different model naming. The quota is shared — using either provider counts against the same Google account's limits.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Google One AI Pro: Quota System
|
||||||
|
|
||||||
|
> See [Antigravity Docs: Plans](https://antigravity.google/docs/plans) for official quota details and [Changes to Antigravity Plans](https://antigravity.google/blog/changes-to-antigravity-plans) for the latest pricing updates.
|
||||||
|
|
||||||
|
Google Antigravity uses a **dual-layer quota** based on "Work Done" (computational weight), not message count.
|
||||||
|
|
||||||
|
### The Two Layers
|
||||||
|
|
||||||
|
| Layer | What it is | Refresh cycle |
|
||||||
|
| ------------------ | ----------------------------- | ------------------------------------------------------------------ |
|
||||||
|
| **5-hour sprint** | Immediate pool of "work done" | Resets 5 hours after first request in a session |
|
||||||
|
| **7-day baseline** | Weekly hard cap | Overrides 5-hour refresh if hit; locks out until next 7-day period |
|
||||||
|
|
||||||
|
**How "Work Done" is calculated**: Agent-heavy tasks (e.g. "Refactor this entire repository") drain quota much faster than simple tasks (e.g. "Fix this function"). There is no real-time dashboard showing consumption.
|
||||||
|
|
||||||
|
### Plan Tiers
|
||||||
|
|
||||||
|
| Plan | Price | Quota | Weekly limit |
|
||||||
|
| ------------ | ---------- | ---------------------------------- | ----------------------------- |
|
||||||
|
| Free | $0 | Meaningful quota, refreshed weekly | Yes |
|
||||||
|
| AI Pro | $19.99/mo | High quota, 5-hour rolling refresh | Yes (overrides 5-hour if hit) |
|
||||||
|
| AI Ultra 5x | $99.99/mo | 5x Pro quota | No weekly limit |
|
||||||
|
| AI Ultra 20x | $199.99/mo | 20x Pro quota | No weekly limit |
|
||||||
|
|
||||||
|
### Gemini vs Non-Gemini Models
|
||||||
|
|
||||||
|
- **Gemini models** (Flash + Pro): Share a single rate limit, drawn down by API pricing. If Flash is 8x cheaper than Pro, you get 8x more Flash tokens.
|
||||||
|
- **Non-Gemini models** (Claude, GPT-OSS): Have **separate** rate limits. May remain available even when Gemini is locked out.
|
||||||
|
|
||||||
|
### AI Credits (Overage)
|
||||||
|
|
||||||
|
> See [Google One AI credits](https://support.google.com/googleone/answer/14534406) for how credits work.
|
||||||
|
|
||||||
|
When baseline quota is exhausted:
|
||||||
|
|
||||||
|
- **Never**: Wait for quota to refresh; shows "Baseline model quota reached"
|
||||||
|
- **Always**: Auto-use AI credits; switches back to baseline when it refreshes
|
||||||
|
|
||||||
|
Credits are purchased separately and deducted at standard API pricing.
|
||||||
|
|
||||||
|
### Key Details
|
||||||
|
|
||||||
|
- Quota is **account-level shared** — the same Google account in Antigravity IDE, CLI, and OmniRoute shares one quota pool
|
||||||
|
- Each Google account has its own independent quota — multiple accounts = multiple quota pools
|
||||||
|
- AI Pro users have reported **7-day lockouts** instead of 5-hour resets when weekly baseline is hit (Google confirmed this is by design for high demand)
|
||||||
|
|
||||||
|
**When your account is exhausted**: OmniRoute automatically retries with the next available account in the combo route. No manual intervention needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. How to Get a projectId
|
||||||
|
|
||||||
|
Every antigravity/agy connection needs a Google Cloud Code `projectId`. Without it, the `/v1internal:models` endpoint returns 404.
|
||||||
|
|
||||||
|
### Method A: Automatic (Recommended)
|
||||||
|
|
||||||
|
OmniRoute handles this automatically. When you add a new Google account via Dashboard OAuth:
|
||||||
|
|
||||||
|
1. OmniRoute refreshes the token
|
||||||
|
2. Calls `loadCodeAssist` to discover the projectId
|
||||||
|
3. If no project exists, calls `onboardUser` to create one
|
||||||
|
4. Retries `loadCodeAssist` to get the newly created projectId
|
||||||
|
5. Saves it to the database
|
||||||
|
|
||||||
|
**This works for most accounts** — no manual steps needed.
|
||||||
|
|
||||||
|
### Method B: Manual via agy CLI
|
||||||
|
|
||||||
|
If automatic discovery fails (see Section 5 for when this happens):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install agy CLI (if not already)
|
||||||
|
npm install -g @anthropic-ai/agy
|
||||||
|
|
||||||
|
# Login with your Google account
|
||||||
|
agy login
|
||||||
|
|
||||||
|
# Select the account that needs onboarding
|
||||||
|
# This triggers Cloud Code registration and assigns a projectId
|
||||||
|
```
|
||||||
|
|
||||||
|
After `agy login` succeeds, refresh the token in OmniRoute Dashboard. The projectId will be discovered automatically.
|
||||||
|
|
||||||
|
### How to verify
|
||||||
|
|
||||||
|
Check the database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Inside OmniRoute container
|
||||||
|
node -e "const db=require('better-sqlite3')('/app/data/storage.sqlite'); \
|
||||||
|
console.log(JSON.stringify(db.prepare(\
|
||||||
|
'SELECT email,project_id FROM provider_connections WHERE provider=\"agy\"'\
|
||||||
|
).all(), null, 2))"
|
||||||
|
```
|
||||||
|
|
||||||
|
Or check the logs:
|
||||||
|
|
||||||
|
```
|
||||||
|
podman logs omniroute 2>&1 | grep "projectId discovered"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. OAuth Redirect URI
|
||||||
|
|
||||||
|
### The Problem
|
||||||
|
|
||||||
|
Google OAuth requires a valid redirect URI. OmniRoute's default uses `http://127.0.0.1:20128/callback` (loopback). This works for local builds but **fails for remote deployments** (e.g., a server accessed via LAN IP).
|
||||||
|
|
||||||
|
Google rejects redirect URIs that:
|
||||||
|
|
||||||
|
- Use IP addresses (must be a domain ending in `.com`, `.org`, etc.)
|
||||||
|
- Don't match the registered redirect URIs in the OAuth client config
|
||||||
|
|
||||||
|
### The Solution
|
||||||
|
|
||||||
|
**Option A: Use the built-in OAuth flow (default)**
|
||||||
|
|
||||||
|
- Works when you access OmniRoute from `localhost` or `127.0.0.1`
|
||||||
|
- No configuration needed
|
||||||
|
|
||||||
|
**Option B: Custom OAuth credentials**
|
||||||
|
|
||||||
|
- Set `ANTIGRAVITY_OAUTH_CLIENT_TYPE=web` in your environment
|
||||||
|
- Provide your own Google OAuth credentials:
|
||||||
|
```
|
||||||
|
GOOGLE_OAUTH_CLIENT_ID=your-client-id
|
||||||
|
GOOGLE_OAUTH_CLIENT_SECRET=your-client-secret
|
||||||
|
```
|
||||||
|
- Register `https://your-domain.com/callback` as an authorized redirect URI in Google Cloud Console
|
||||||
|
|
||||||
|
**Option C: Use agy CLI for initial login**
|
||||||
|
|
||||||
|
- Run `agy login` on the machine that will access OmniRoute
|
||||||
|
- The OAuth flow completes locally, tokens are stored
|
||||||
|
- Import the connection into OmniRoute via Dashboard
|
||||||
|
|
||||||
|
### Limitations
|
||||||
|
|
||||||
|
- Custom OAuth credentials require a domain name (Google does not accept IP addresses as redirect URIs)
|
||||||
|
- If you don't have a domain, use Option A or C instead
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Troubleshooting: When Automatic Setup Fails
|
||||||
|
|
||||||
|
OmniRoute handles projectId discovery and onboarding automatically for most accounts. When it fails, the root cause is usually one of these:
|
||||||
|
|
||||||
|
### Account region is blocked
|
||||||
|
|
||||||
|
**Symptom**: `agy login` returns "Eligibility check failed: Your current account is not eligible for Antigravity, because it is not currently available in your location."
|
||||||
|
|
||||||
|
**Root cause**: Google accounts have a backend "Country Association" field set at registration time. The agy CLI and Cloud Code API check this field strictly — unlike web Gemini which only checks your current IP.
|
||||||
|
|
||||||
|
> To check or change your account's associated region, visit [Google Country Association Form](https://policies.google.com/country-association-form).
|
||||||
|
|
||||||
|
**Why web Gemini works but agy doesn't**:
|
||||||
|
|
||||||
|
- Web Gemini / Google One: checks current IP only (proxy passes)
|
||||||
|
- agy CLI / Cloud Code API: reads backend Country Association field (proxy doesn't help)
|
||||||
|
|
||||||
|
**Fix**:
|
||||||
|
|
||||||
|
1. Visit [Google Country Association Form](https://policies.google.com/country-association-form) while on a US IP
|
||||||
|
2. Submit region change request (select "I live in a different country")
|
||||||
|
3. Wait 1-24 hours for Google to process + email notification
|
||||||
|
4. Then `agy login` should succeed
|
||||||
|
|
||||||
|
### Account has no Cloud Code project
|
||||||
|
|
||||||
|
**Symptom**: Logs show `loadCodeAssist returned no project id` and `onboardUser failed (400)`.
|
||||||
|
|
||||||
|
**Root cause**: The account has never been registered with Google Cloud Code, and the automatic onboarding failed.
|
||||||
|
|
||||||
|
**Fix**: Run `agy login` manually to trigger Cloud Code registration, then refresh the token in OmniRoute Dashboard.
|
||||||
|
|
||||||
|
### Token expired or revoked
|
||||||
|
|
||||||
|
**Symptom**: 401 errors in logs, or "Token has expired" messages.
|
||||||
|
|
||||||
|
**Fix**: Refresh the token in Dashboard → Providers → agy → Click refresh icon. If the refresh token itself is revoked, you'll need to re-authenticate via OAuth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision Flowchart
|
||||||
|
|
||||||
|
```
|
||||||
|
Account not working?
|
||||||
|
│
|
||||||
|
├─ Does it have a projectId in the database?
|
||||||
|
│ ├─ YES → Problem is elsewhere (token expired, rate limit, etc.)
|
||||||
|
│ └─ NO ↓
|
||||||
|
│
|
||||||
|
├─ Is the account's Country Association set to a restricted region?
|
||||||
|
│ ├─ YES → Change region at Google Country Association Form
|
||||||
|
│ │ (https://policies.google.com/country-association-form)
|
||||||
|
│ │ Wait 1-24 hours, then retry
|
||||||
|
│ └─ NO ↓
|
||||||
|
│
|
||||||
|
├─ Does the account have Google One AI Pro subscription?
|
||||||
|
│ ├─ NO → Subscribe first at one.google.com
|
||||||
|
│ └─ YES ↓
|
||||||
|
│
|
||||||
|
├─ Try automatic discovery (refresh token in Dashboard)
|
||||||
|
│ ├─ Works → Done
|
||||||
|
│ └─ Still fails ↓
|
||||||
|
│
|
||||||
|
└─ Manual: Run `agy login` on the machine
|
||||||
|
├─ Works → Refresh token in Dashboard, projectId discovered
|
||||||
|
└─ Fails → Check error message, likely region or subscription issue
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
| Task | Command / URL |
|
||||||
|
| --------------------- | --------------------------------------------------------------------------------------- |
|
||||||
|
| Change account region | [Google Country Association Form](https://policies.google.com/country-association-form) |
|
||||||
|
| agy CLI login | `agy login` |
|
||||||
|
| Check projectId in DB | `SELECT email,project_id FROM provider_connections WHERE provider='agy'` |
|
||||||
|
| Check logs | `podman logs omniroute 2>&1 \| grep projectId` |
|
||||||
|
| Refresh token | Dashboard → Providers → agy → Click refresh icon |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Last updated: 2026-07-31. Based on OmniRoute v3.8.50._
|
||||||
@@ -7,10 +7,11 @@ import { supportsClaudeMaxEffort, supportsXHighEffort } from "../../config/provi
|
|||||||
/**
|
/**
|
||||||
* Sanitize reasoning_effort for providers that don't accept all values.
|
* Sanitize reasoning_effort for providers that don't accept all values.
|
||||||
*
|
*
|
||||||
* The claude→openai translator may emit reasoning_effort=max/xhigh when the
|
* The claude→openai translator passes output_config.effort through verbatim
|
||||||
* client sends output_config.effort=max on a Claude-shape request. Combined with
|
* (including max) and only performs form conversion; provider-aware effort
|
||||||
* runtime alias remapping (e.g. claude-opus-4-6 → mimo/mimo-v2.5-pro), this
|
* policy is owned here. Combined with runtime alias remapping (e.g.
|
||||||
* routes xhigh to OpenAI-shape providers that don't accept the value:
|
* 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
|
* xiaomi-mimo : low|medium|high only — 400 literal_error on xhigh
|
||||||
* mistral : devstral models reject reasoning_effort entirely
|
* mistral : devstral models reject reasoning_effort entirely
|
||||||
@@ -216,10 +217,7 @@ function writeEffortValue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Strip the effort field from every carrier that was present. */
|
/** Strip the effort field from every carrier that was present. */
|
||||||
function stripEffortValue(
|
function stripEffortValue(b: Record<string, unknown>, c: EffortCarriers): Record<string, unknown> {
|
||||||
b: Record<string, unknown>,
|
|
||||||
c: EffortCarriers
|
|
||||||
): Record<string, unknown> {
|
|
||||||
const next: Record<string, unknown> = { ...b };
|
const next: Record<string, unknown> = { ...b };
|
||||||
if (c.hasTopLevelReasoningEffort) delete next.reasoning_effort;
|
if (c.hasTopLevelReasoningEffort) delete next.reasoning_effort;
|
||||||
if (c.hasReasoningEffort && c.reasoning) {
|
if (c.hasReasoningEffort && c.reasoning) {
|
||||||
|
|||||||
@@ -4651,12 +4651,7 @@ export async function handleChatCore({
|
|||||||
});
|
});
|
||||||
if (streamReadiness.ok === false) {
|
if (streamReadiness.ok === false) {
|
||||||
const { response: failureResponse, reason } = streamReadiness;
|
const { response: failureResponse, reason } = streamReadiness;
|
||||||
const failure = {
|
const { classificationReason, upstreamDiagnostic } = streamReadiness;
|
||||||
status: failureResponse.status,
|
|
||||||
message: reason,
|
|
||||||
code: streamReadiness.code,
|
|
||||||
type: streamReadiness.type,
|
|
||||||
};
|
|
||||||
trackPendingRequest(model, provider, connectionId, false);
|
trackPendingRequest(model, provider, connectionId, false);
|
||||||
appendRequestLog({
|
appendRequestLog({
|
||||||
model,
|
model,
|
||||||
@@ -4668,7 +4663,11 @@ export async function handleChatCore({
|
|||||||
status: failureResponse.status,
|
status: failureResponse.status,
|
||||||
error: reason,
|
error: reason,
|
||||||
providerRequest: finalBody || translatedBody,
|
providerRequest: finalBody || translatedBody,
|
||||||
clientResponse: buildErrorBody(failureResponse.status, reason),
|
clientResponse: buildErrorBody(
|
||||||
|
failureResponse.status,
|
||||||
|
classificationReason,
|
||||||
|
upstreamDiagnostic ? { error: { message: upstreamDiagnostic } } : undefined
|
||||||
|
),
|
||||||
claudeCacheMeta: claudePromptCacheLogMeta,
|
claudeCacheMeta: claudePromptCacheLogMeta,
|
||||||
cacheSource: "upstream",
|
cacheSource: "upstream",
|
||||||
});
|
});
|
||||||
@@ -4680,6 +4679,7 @@ export async function handleChatCore({
|
|||||||
success: false,
|
success: false,
|
||||||
status: failureResponse.status,
|
status: failureResponse.status,
|
||||||
error: reason,
|
error: reason,
|
||||||
|
classificationError: classificationReason,
|
||||||
errorType: streamReadiness.type,
|
errorType: streamReadiness.type,
|
||||||
errorCode: streamReadiness.code,
|
errorCode: streamReadiness.code,
|
||||||
response: failureResponse,
|
response: failureResponse,
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// path chatCore already threads) — single source of truth stays detectFormatFromEndpoint.
|
||||||
export function detectFormatFromUrl(body, requestUrl) {
|
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
|
// Detect request format from body structure
|
||||||
@@ -193,7 +203,7 @@ export function detectFormat(body) {
|
|||||||
if (firstContent?.type === "text" && !body.model?.includes("/")) {
|
if (firstContent?.type === "text" && !body.model?.includes("/")) {
|
||||||
// Could be Claude or OpenAI multimodal
|
// Could be Claude or OpenAI multimodal
|
||||||
// Check for Claude-specific fields
|
// Check for Claude-specific fields
|
||||||
if (body.system || body.anthropic_version) {
|
if (body.system || body.anthropic_version || body["anthropic-version"]) {
|
||||||
return "claude";
|
return "claude";
|
||||||
}
|
}
|
||||||
// Check if image format is Claude (source.type) vs OpenAI (image_url.url)
|
// 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)
|
// If content is string, it's likely OpenAI (Claude also supports this)
|
||||||
// Check for other Claude-specific indicators
|
// 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";
|
return "claude";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ function normalizeToolSchema(schema: unknown): Record<string, unknown> {
|
|||||||
function normalizeOpenAIReasoningEffort(effort: unknown): string | undefined {
|
function normalizeOpenAIReasoningEffort(effort: unknown): string | undefined {
|
||||||
if (typeof effort !== "string") return undefined;
|
if (typeof effort !== "string") return undefined;
|
||||||
const normalized = effort.toLowerCase();
|
const normalized = effort.toLowerCase();
|
||||||
if (normalized === "max") return "xhigh";
|
|
||||||
return normalized || undefined;
|
return normalized || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { HTTP_STATUS } from "../config/constants.ts";
|
import { HTTP_STATUS } from "../config/constants.ts";
|
||||||
|
import { buildErrorBody, sanitizeErrorMessage } from "./error.ts";
|
||||||
|
|
||||||
type StreamReadinessLogger = {
|
type StreamReadinessLogger = {
|
||||||
debug?: (tag: string, message: string) => void;
|
debug?: (tag: string, message: string) => void;
|
||||||
@@ -7,7 +8,18 @@ type StreamReadinessLogger = {
|
|||||||
|
|
||||||
export type StreamReadinessResult =
|
export type StreamReadinessResult =
|
||||||
| { ok: true; response: Response }
|
| { 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> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||||
@@ -233,6 +245,7 @@ type StreamReadinessSignalState = {
|
|||||||
currentEvent: string;
|
currentEvent: string;
|
||||||
dataLines: string[];
|
dataLines: string[];
|
||||||
pendingLine: string;
|
pendingLine: string;
|
||||||
|
upstreamDiagnostic: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function resetCurrentEvent(state: StreamReadinessSignalState): void {
|
function resetCurrentEvent(state: StreamReadinessSignalState): void {
|
||||||
@@ -248,7 +261,23 @@ function processStreamReadinessEvent(state: StreamReadinessSignalState): boolean
|
|||||||
if (isPingEventType(eventType) || !data || data === "[DONE]") return false;
|
if (isPingEventType(eventType) || !data || data === "[DONE]") return false;
|
||||||
|
|
||||||
try {
|
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 {
|
} catch {
|
||||||
return data.length > 0;
|
return data.length > 0;
|
||||||
}
|
}
|
||||||
@@ -294,6 +323,7 @@ export function hasStreamReadinessSignal(text: string): boolean {
|
|||||||
currentEvent: "",
|
currentEvent: "",
|
||||||
dataLines: [],
|
dataLines: [],
|
||||||
pendingLine: "",
|
pendingLine: "",
|
||||||
|
upstreamDiagnostic: null,
|
||||||
};
|
};
|
||||||
if (appendStreamReadinessSignal(state, text)) return true;
|
if (appendStreamReadinessSignal(state, text)) return true;
|
||||||
return finishStreamReadinessSignal(state);
|
return finishStreamReadinessSignal(state);
|
||||||
@@ -303,16 +333,18 @@ function createErrorResponse(
|
|||||||
status: number,
|
status: number,
|
||||||
message: string,
|
message: string,
|
||||||
code: string,
|
code: string,
|
||||||
type: string
|
type: string,
|
||||||
|
upstreamDiagnostic?: string
|
||||||
): Response {
|
): Response {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify(
|
||||||
error: {
|
buildErrorBody(
|
||||||
|
status,
|
||||||
message,
|
message,
|
||||||
type,
|
upstreamDiagnostic ? { error: { message: upstreamDiagnostic } } : undefined,
|
||||||
code,
|
{ code, type }
|
||||||
},
|
)
|
||||||
}),
|
),
|
||||||
{ status, headers: { "Content-Type": "application/json" } }
|
{ status, headers: { "Content-Type": "application/json" } }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -385,6 +417,7 @@ export async function ensureStreamReadiness(
|
|||||||
currentEvent: "",
|
currentEvent: "",
|
||||||
dataLines: [],
|
dataLines: [],
|
||||||
pendingLine: "",
|
pendingLine: "",
|
||||||
|
upstreamDiagnostic: null,
|
||||||
};
|
};
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const effectiveTimeoutMs = Math.max(0, Math.floor(options.timeoutMs));
|
const effectiveTimeoutMs = Math.max(0, Math.floor(options.timeoutMs));
|
||||||
@@ -414,6 +447,7 @@ export async function ensureStreamReadiness(
|
|||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
reason,
|
reason,
|
||||||
|
classificationReason: reason,
|
||||||
code: "STREAM_READINESS_TIMEOUT",
|
code: "STREAM_READINESS_TIMEOUT",
|
||||||
type: "stream_timeout",
|
type: "stream_timeout",
|
||||||
response: createErrorResponse(
|
response: createErrorResponse(
|
||||||
@@ -438,6 +472,7 @@ export async function ensureStreamReadiness(
|
|||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
reason,
|
reason,
|
||||||
|
classificationReason: reason,
|
||||||
code: "STREAM_READINESS_TIMEOUT",
|
code: "STREAM_READINESS_TIMEOUT",
|
||||||
type: "stream_timeout",
|
type: "stream_timeout",
|
||||||
response: createErrorResponse(
|
response: createErrorResponse(
|
||||||
@@ -460,7 +495,11 @@ export async function ensureStreamReadiness(
|
|||||||
return { ok: true, response: buildReadyResponse() };
|
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?.(
|
options.log?.warn?.(
|
||||||
"STREAM",
|
"STREAM",
|
||||||
`${reason} (${options.provider || "provider"}/${options.model || "unknown"})`
|
`${reason} (${options.provider || "provider"}/${options.model || "unknown"})`
|
||||||
@@ -468,13 +507,16 @@ export async function ensureStreamReadiness(
|
|||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
reason,
|
reason,
|
||||||
|
classificationReason,
|
||||||
|
...(upstreamDiagnostic ? { upstreamDiagnostic } : {}),
|
||||||
code: "STREAM_EARLY_EOF",
|
code: "STREAM_EARLY_EOF",
|
||||||
type: "stream_early_eof",
|
type: "stream_early_eof",
|
||||||
response: createErrorResponse(
|
response: createErrorResponse(
|
||||||
HTTP_STATUS.BAD_GATEWAY,
|
HTTP_STATUS.BAD_GATEWAY,
|
||||||
reason,
|
classificationReason,
|
||||||
"STREAM_EARLY_EOF",
|
"STREAM_EARLY_EOF",
|
||||||
"stream_early_eof"
|
"stream_early_eof",
|
||||||
|
upstreamDiagnostic
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
62
quality-ratchet/quality-ratchet.md
Normal file
62
quality-ratchet/quality-ratchet.md
Normal 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.**
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
//
|
//
|
||||||
// Two tiers of checks:
|
// Two tiers of checks:
|
||||||
// • STRICT (always blocking — exit 1 on drift): high-confidence, slow-moving counts
|
// • 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,
|
// - provider count (source of truth: docs/reference/PROVIDER_REFERENCE.md total,
|
||||||
// which is auto-generated from src/shared/constants/providers.ts)
|
// which is auto-generated from src/shared/constants/providers.ts)
|
||||||
// - i18n locale count (source of truth: config/i18n.json `locales`)
|
// - i18n locale count (source of truth: config/i18n.json `locales`)
|
||||||
@@ -259,14 +259,14 @@ export function buildChecks() {
|
|||||||
actual: readProviderTotal(),
|
actual: readProviderTotal(),
|
||||||
docKey: "providers",
|
docKey: "providers",
|
||||||
strict: true,
|
strict: true,
|
||||||
files: ["README.md", "AGENTS.md", "CLAUDE.md"],
|
files: ["README.md", "CLAUDE.md"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "i18n locales count",
|
label: "i18n locales count",
|
||||||
actual: countLocales(),
|
actual: countLocales(),
|
||||||
docKey: "i18n locales",
|
docKey: "i18n locales",
|
||||||
strict: true,
|
strict: true,
|
||||||
files: ["docs/README.md", "docs/guides/I18N.md", "AGENTS.md"],
|
files: ["docs/README.md", "docs/guides/I18N.md"],
|
||||||
},
|
},
|
||||||
...(() => {
|
...(() => {
|
||||||
const f = readCodeFacts();
|
const f = readCodeFacts();
|
||||||
@@ -317,19 +317,10 @@ export function buildChecks() {
|
|||||||
skipBefore: /(tools?|definitions?)\s*\(\s*$/i,
|
skipBefore: /(tools?|definitions?)\s*\(\s*$/i,
|
||||||
skipAfter: /^\s*\(\d+ CLI/,
|
skipAfter: /^\s*\(\d+ CLI/,
|
||||||
},
|
},
|
||||||
["README.md", "CLAUDE.md", "AGENTS.md", "docs/frameworks/MCP-SERVER.md"]
|
["README.md", "CLAUDE.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"]
|
|
||||||
),
|
),
|
||||||
|
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"]),
|
||||||
];
|
];
|
||||||
})(),
|
})(),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1076,7 +1076,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pinned Provider Quota Limits (compact, no filters) */}
|
{/* Pinned Provider Quota Limits */}
|
||||||
{pinProviderQuotaToHome && (
|
{pinProviderQuotaToHome && (
|
||||||
<Suspense fallback={<CardSkeleton />}>
|
<Suspense fallback={<CardSkeleton />}>
|
||||||
<ProviderQuotaWidget
|
<ProviderQuotaWidget
|
||||||
|
|||||||
@@ -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(
|
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));
|
const activeProviderIds = new Set(activeProviders.map((c) => c.provider));
|
||||||
dynamicModels.forEach((dm) => {
|
dynamicModels.forEach((dm) => {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface Props {
|
|||||||
quotaVisibility?: Record<string, { hidden?: string[] }>;
|
quotaVisibility?: Record<string, { hidden?: string[] }>;
|
||||||
onHideQuota?: (provider: string, quota: any) => void;
|
onHideQuota?: (provider: string, quota: any) => void;
|
||||||
onShowQuota?: (provider: string, quota: any) => void;
|
onShowQuota?: (provider: string, quota: any) => void;
|
||||||
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function QuotaCardGrid({
|
export default function QuotaCardGrid({
|
||||||
@@ -44,9 +45,41 @@ export default function QuotaCardGrid({
|
|||||||
quotaVisibility,
|
quotaVisibility,
|
||||||
onHideQuota,
|
onHideQuota,
|
||||||
onShowQuota,
|
onShowQuota,
|
||||||
|
compact = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
if (connections.length === 0) return null;
|
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.
|
// Group connections by provider, preserving the order from sortedConnections.
|
||||||
const groups = new Map<string, typeof connections>();
|
const groups = new Map<string, typeof connections>();
|
||||||
for (const conn of connections) {
|
for (const conn of connections) {
|
||||||
@@ -66,28 +99,7 @@ export default function QuotaCardGrid({
|
|||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,280px),1fr))] gap-3">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,280px),1fr))] gap-3">
|
||||||
{conns.map((conn) => (
|
{conns.map(renderCard)}
|
||||||
<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}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const LS_PURCHASE_FILTER = "omniroute:limits:purchaseFilter";
|
|||||||
const LS_STATUS_FILTER = "omniroute:limits:statusFilter";
|
const LS_STATUS_FILTER = "omniroute:limits:statusFilter";
|
||||||
const LS_ENV_FILTER = "omniroute:limits:envFilter";
|
const LS_ENV_FILTER = "omniroute:limits:envFilter";
|
||||||
const LS_PROVIDER_FILTER = "omniroute:limits:providerFilter";
|
const LS_PROVIDER_FILTER = "omniroute:limits:providerFilter";
|
||||||
|
const LS_LAYOUT_MODE = "omniroute:limits:layoutMode";
|
||||||
|
|
||||||
const MIN_FETCH_INTERVAL_MS = 30000;
|
const MIN_FETCH_INTERVAL_MS = 30000;
|
||||||
const QUOTA_BAR_GREEN_THRESHOLD = 50;
|
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 PurchaseTypeKey = "all" | "oauth-free" | "oauth-sub" | "apikey";
|
||||||
type StatusKey = "all" | "critical" | "alert" | "ok" | "empty";
|
type StatusKey = "all" | "critical" | "alert" | "ok" | "empty";
|
||||||
|
type LayoutMode = "full" | "compact";
|
||||||
|
|
||||||
const PURCHASE_TYPES: Array<{ key: PurchaseTypeKey; labelKey: string; fallback: string }> = [
|
const PURCHASE_TYPES: Array<{ key: PurchaseTypeKey; labelKey: string; fallback: string }> = [
|
||||||
{ key: "all", labelKey: "purchaseAll", fallback: "All" },
|
{ key: "all", labelKey: "purchaseAll", fallback: "All" },
|
||||||
@@ -231,6 +233,10 @@ export default function ProviderLimits({
|
|||||||
if (typeof window === "undefined") return "all";
|
if (typeof window === "undefined") return "all";
|
||||||
return localStorage.getItem(LS_PROVIDER_FILTER) || "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 lastFetchTimeRef = useRef<Record<string, number>>({});
|
||||||
const staleProbeRef = 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[]) => {
|
const renderInlineQuotaSummary = (quotas: any[]) => {
|
||||||
if (!quotas || quotas.length === 0) return null;
|
if (!quotas || quotas.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
@@ -816,30 +834,55 @@ export default function ProviderLimits({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={refreshAll}
|
<button
|
||||||
disabled={refreshingAll}
|
type="button"
|
||||||
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"
|
onClick={toggleLayoutMode}
|
||||||
title={
|
aria-pressed={layoutMode === "compact"}
|
||||||
autoRefreshIntervalMs > 0 ? tr("autoRefreshing", "Auto-refreshing") : t("refreshAll")
|
aria-label={
|
||||||
}
|
layoutMode === "compact"
|
||||||
>
|
? "Switch to full quota layout"
|
||||||
<span
|
: "Switch to compact quota layout"
|
||||||
className={`material-symbols-outlined text-[16px] ${refreshingAll ? "animate-spin" : ""}`}
|
}
|
||||||
|
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 className="material-symbols-outlined text-[16px]" aria-hidden>
|
||||||
</span>
|
{layoutMode === "compact" ? "view_agenda" : "grid_view"}
|
||||||
{refreshingAll
|
</span>
|
||||||
? tr("refreshing", "Refreshing")
|
<span className="hidden sm:inline">
|
||||||
: autoRefreshIntervalMs > 0
|
{layoutMode === "compact" ? "Compact" : "Full"}
|
||||||
? `${tr("autoRefreshing", "Auto-refreshing")} ${formatAutoRefreshCountdown(
|
</span>
|
||||||
Math.max(
|
</button>
|
||||||
0,
|
<button
|
||||||
autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAtRef.current)
|
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"
|
||||||
: t("refreshAll")}
|
title={
|
||||||
</button>
|
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>
|
</div>
|
||||||
|
|
||||||
{showFilters && (
|
{showFilters && (
|
||||||
@@ -1048,6 +1091,7 @@ export default function ProviderLimits({
|
|||||||
onShowQuota={handleShowQuota}
|
onShowQuota={handleShowQuota}
|
||||||
redeemingResetCreditId={resetCreditRedemption.redeemingResetCreditId}
|
redeemingResetCreditId={resetCreditRedemption.redeemingResetCreditId}
|
||||||
loadingResetCreditsId={resetCreditRedemption.loadingResetCreditsId}
|
loadingResetCreditsId={resetCreditRedemption.loadingResetCreditsId}
|
||||||
|
compact={layoutMode === "compact"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,46 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Card from "@/shared/components/Card";
|
import Card from "@/shared/components/Card";
|
||||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||||
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
|
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 { 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 = {
|
type Connection = {
|
||||||
id: string;
|
id: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
authType?: string;
|
authType?: string;
|
||||||
email?: string;
|
|
||||||
name?: string;
|
name?: string;
|
||||||
quotaVisible?: boolean;
|
displayName?: string;
|
||||||
|
email?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type QuotaData = Record<string, any>;
|
type QuotaData = Record<string, any>;
|
||||||
|
|
||||||
interface ProviderQuotaWidgetProps {
|
interface ProviderQuotaWidgetProps {
|
||||||
autoRefreshInterval?: number;
|
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 {
|
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")}`;
|
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AutoRefreshButtonLabel({
|
function QuotaRow({ quota }: { quota: any }) {
|
||||||
autoRefreshIntervalMs,
|
const t = useTranslations("usage");
|
||||||
lastRefreshAllAt,
|
const percentage = Math.round(getQuotaRemainingPercentage(quota));
|
||||||
refreshingAll,
|
const colors = getBarColor(percentage);
|
||||||
tr,
|
const label = quota.displayName || formatQuotaLabel(quota.name) || quota.name;
|
||||||
}: {
|
const reset = formatCountdown(quota.resetAt);
|
||||||
autoRefreshIntervalMs: number;
|
|
||||||
lastRefreshAllAt: number;
|
if (quota.isCredits || quota.isResetCredits) {
|
||||||
refreshingAll: boolean;
|
const amount = Number(quota.creditCount ?? quota.remaining ?? 0).toLocaleString(undefined, {
|
||||||
tr: (key: string, fallback: string) => string;
|
maximumFractionDigits: 2,
|
||||||
}) {
|
});
|
||||||
const [now, setNow] = useState(() => Date.now());
|
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(() => {
|
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();
|
tick();
|
||||||
|
|
||||||
const timer = window.setInterval(tick, 1000);
|
const timer = window.setInterval(tick, 1000);
|
||||||
@@ -59,248 +228,131 @@ export function AutoRefreshButtonLabel({
|
|||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
};
|
};
|
||||||
}, [autoRefreshIntervalMs, refreshingAll, lastRefreshAllAt]);
|
}, [autoRefreshIntervalMs]);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoRefreshIntervalMs <= 0) return;
|
if (autoRefreshIntervalMs <= 0) return;
|
||||||
|
if (document.visibilityState !== "visible") return;
|
||||||
|
if (refreshingAllRef.current) return;
|
||||||
|
|
||||||
const maybeRefresh = () => {
|
if (autoRefreshClock - lastRefreshAllAtRef.current >= autoRefreshIntervalMs) {
|
||||||
if (document.visibilityState !== "visible") return;
|
void refreshAll();
|
||||||
if (refreshingAllRef.current) return;
|
}
|
||||||
if (Date.now() - lastRefreshAllAtRef.current >= autoRefreshIntervalMs) {
|
}, [autoRefreshClock, autoRefreshIntervalMs, refreshAll]);
|
||||||
void refreshAll();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
maybeRefresh();
|
const providerGroups = useMemo(() => {
|
||||||
const timer = window.setInterval(maybeRefresh, 1000);
|
const groups = new Map<string, Connection[]>();
|
||||||
const handleVisibilityChange = () => maybeRefresh();
|
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);
|
const updatedLabel = formatUpdatedAt(updatedAt);
|
||||||
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));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="overflow-hidden">
|
<Card className="w-full overflow-hidden">
|
||||||
{/* Header with title + Refresh All in upper right */}
|
<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 justify-between border-b border-border px-4 py-3 bg-surface/60">
|
|
||||||
<div className="flex items-center gap-2">
|
<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
|
account_balance
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-base">{tr("providerQuota", "Provider Quota")}</h3>
|
<h2 className="text-base font-semibold text-text-main">
|
||||||
<p className="text-[11px] text-text-muted -mt-0.5">
|
{tr("providerQuota", "Provider Quota")}
|
||||||
{tr("providerQuotaHomeHint", "Live status across connected accounts")}
|
</h2>
|
||||||
</p>
|
{updatedLabel && (
|
||||||
|
<p className="text-[11px] text-text-muted">
|
||||||
|
{tr("updatedShort", "Updated")} {updatedLabel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={refreshAll}
|
onClick={refreshAll}
|
||||||
disabled={refreshingAll || loading}
|
disabled={loading || refreshingAll}
|
||||||
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"
|
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"
|
||||||
title={
|
|
||||||
autoRefreshIntervalMs > 0
|
|
||||||
? tr("autoRefreshing", "Auto-refreshing")
|
|
||||||
: tr("refreshAll", "Refresh All")
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`material-symbols-outlined text-[16px] ${refreshingAll ? "animate-spin" : ""}`}
|
className={`material-symbols-outlined text-[16px] ${refreshingAll ? "animate-spin" : ""}`}
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{autoRefreshIntervalMs > 0 ? "schedule" : "refresh"}
|
{autoRefreshIntervalMs > 0 ? "schedule" : "refresh"}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
{refreshingAll
|
||||||
<AutoRefreshButtonLabel
|
? tr("refreshing", "Refreshing")
|
||||||
autoRefreshIntervalMs={autoRefreshIntervalMs}
|
: autoRefreshIntervalMs > 0
|
||||||
lastRefreshAllAt={lastRefreshAllAt}
|
? `${tr("autoRefreshing", "Auto-refreshing")} ${formatAutoRefreshCountdown(
|
||||||
refreshingAll={refreshingAll}
|
Math.max(
|
||||||
tr={tr}
|
0,
|
||||||
/>
|
autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAtRef.current)
|
||||||
</span>
|
)
|
||||||
|
)}`
|
||||||
|
: tr("forceRefresh", "Refresh now")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{loading ? (
|
||||||
<div className="p-4">
|
<div className="flex items-center gap-2 px-4 py-8 text-sm text-text-muted">
|
||||||
{loading ? (
|
<span className="material-symbols-outlined animate-spin text-[16px]" aria-hidden="true">
|
||||||
<div className="flex items-center justify-center py-8 text-text-muted text-sm">
|
progress_activity
|
||||||
<span className="material-symbols-outlined animate-spin mr-2">progress_activity</span>
|
</span>
|
||||||
{tr("loadingQuotas", "Loading...")}
|
{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"> →</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
</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>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ function getNextSortOrder() {
|
|||||||
export async function getCombos(limit?: number, offset?: number) {
|
export async function getCombos(limit?: number, offset?: number) {
|
||||||
const db = getDbInstance();
|
const db = getDbInstance();
|
||||||
let sql =
|
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[] = [];
|
const params: unknown[] = [];
|
||||||
if (limit !== undefined) {
|
if (limit !== undefined) {
|
||||||
sql += " LIMIT ? OFFSET ?";
|
sql += " LIMIT ? OFFSET ?";
|
||||||
|
|||||||
@@ -66,6 +66,23 @@ const QUOTA_PATTERNS: ReadonlyArray<RegExp> = [
|
|||||||
// the 429 is misclassified as transient rate_limit and retried every
|
// the 429 is misclassified as transient rate_limit and retried every
|
||||||
// ~60s against a budget that only resets at UTC midnight.
|
// ~60s against a budget that only resets at UTC midnight.
|
||||||
/daily free allocation/i,
|
/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,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
isAntigravityMissingProjectError,
|
isAntigravityMissingProjectError,
|
||||||
PROVIDER_BREAKER_FAILURE_STATUSES,
|
PROVIDER_BREAKER_FAILURE_STATUSES,
|
||||||
|
resolveStreamReadinessClassificationError,
|
||||||
shouldTripProviderBreakerForResult,
|
shouldTripProviderBreakerForResult,
|
||||||
} from "./chatPredicates";
|
} from "./chatPredicates";
|
||||||
import { connectionHasExtraKeys } from "@omniroute/open-sse/services/apiKeyRotator.ts";
|
import { connectionHasExtraKeys } from "@omniroute/open-sse/services/apiKeyRotator.ts";
|
||||||
@@ -1491,10 +1492,9 @@ async function handleSingleModelChat(
|
|||||||
return result.response;
|
return result.response;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Missing Cloud Code project assignment is an account configuration error, not a
|
// Missing Cloud Code project assignment is configuration, not a transient failure.
|
||||||
// transient upstream/account failure. Preserve the executor's typed fail-closed 422;
|
// Preserve the typed fail-closed 422; marking it unavailable would trigger cooldown
|
||||||
// marking the connection unavailable here would trigger cooldown redispatch and repeat
|
// redispatch and repeat bootstrap within the same logical request.
|
||||||
// bootstrap within the same logical request.
|
|
||||||
if (isAntigravityMissingProjectError(provider, result)) {
|
if (isAntigravityMissingProjectError(provider, result)) {
|
||||||
return withSelectedConnectionHeader(result.response, credentials.connectionId);
|
return withSelectedConnectionHeader(result.response, credentials.connectionId);
|
||||||
}
|
}
|
||||||
@@ -1537,10 +1537,11 @@ async function handleSingleModelChat(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isAntigravityStreamReadinessFailure) {
|
if (isAntigravityStreamReadinessFailure) {
|
||||||
|
const classificationError = resolveStreamReadinessClassificationError(result);
|
||||||
const { shouldFallback, cooldownMs } = await markAccountUnavailable(
|
const { shouldFallback, cooldownMs } = await markAccountUnavailable(
|
||||||
credentials.connectionId,
|
credentials.connectionId,
|
||||||
result.status || HTTP_STATUS.BAD_GATEWAY,
|
result.status || HTTP_STATUS.BAD_GATEWAY,
|
||||||
result.error || result.errorCode || "Antigravity stream ended before useful content",
|
classificationError,
|
||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
providerProfile,
|
providerProfile,
|
||||||
@@ -1570,13 +1571,12 @@ async function handleSingleModelChat(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
excludedConnectionIds.add(credentials.connectionId);
|
excludedConnectionIds.add(credentials.connectionId);
|
||||||
lastError = result.error;
|
lastError = classificationError;
|
||||||
lastStatus = result.status;
|
lastStatus = result.status;
|
||||||
requestRetryLastError = result.error;
|
requestRetryLastError = classificationError;
|
||||||
requestRetryLastStatus = result.status;
|
requestRetryLastStatus = result.status;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return withSelectedConnectionHeader(result.response, credentials?.connectionId);
|
return withSelectedConnectionHeader(result.response, credentials?.connectionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,3 +32,22 @@ export function isAntigravityMissingProjectError(
|
|||||||
result.errorType === "oauth_missing_project_id"
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -291,6 +291,7 @@
|
|||||||
"tests/unit/sse-auth-antigravity-credits.test.ts",
|
"tests/unit/sse-auth-antigravity-credits.test.ts",
|
||||||
"tests/unit/sse-auth-resource-404.test.ts",
|
"tests/unit/sse-auth-resource-404.test.ts",
|
||||||
"tests/unit/sse-auth.test.ts",
|
"tests/unit/sse-auth.test.ts",
|
||||||
|
"tests/unit/stream-readiness.test.ts",
|
||||||
"tests/unit/strict-random-deck.test.ts",
|
"tests/unit/strict-random-deck.test.ts",
|
||||||
"tests/unit/strip-reasoning-header.test.ts",
|
"tests/unit/strip-reasoning-header.test.ts",
|
||||||
"tests/unit/system-role-extraction.test.ts",
|
"tests/unit/system-role-extraction.test.ts",
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import assert from "node:assert/strict";
|
|||||||
|
|
||||||
const { sanitizeReasoningEffortForProvider } = await import("../../open-sse/executors/base.ts");
|
const { sanitizeReasoningEffortForProvider } = await import("../../open-sse/executors/base.ts");
|
||||||
const { DefaultExecutor } = await import("../../open-sse/executors/default.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() {
|
function makeLog() {
|
||||||
const messages: Array<[string, string]> = [];
|
const messages: Array<[string, string]> = [];
|
||||||
@@ -102,6 +104,45 @@ test("sanitizeReasoningEffortForProvider: Ollama Cloud preserves max", () => {
|
|||||||
assert.equal(log.messages.length, 0);
|
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", () => {
|
test("sanitizeReasoningEffortForProvider: Ollama Cloud preserves nested max", () => {
|
||||||
const body = {
|
const body = {
|
||||||
model: "glm-5.2",
|
model: "glm-5.2",
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const strictCheck = {
|
|||||||
actual: 226,
|
actual: 226,
|
||||||
docKey: "providers",
|
docKey: "providers",
|
||||||
strict: true,
|
strict: true,
|
||||||
files: ["README.md", "AGENTS.md"],
|
files: ["README.md", "CLAUDE.md"],
|
||||||
};
|
};
|
||||||
|
|
||||||
test("no drift when every file mentions the real count", () => {
|
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);
|
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) =>
|
const { strict, soft } = tally([strictCheck], (f) =>
|
||||||
f === "README.md" ? "we have 226 providers" : "we have 177 providers"
|
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);
|
assert.equal(soft, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -164,6 +164,63 @@ test("looksLikeQuotaExhausted: rejects empty / null / non-quota text", () => {
|
|||||||
assert.equal(looksLikeQuotaExhausted("server error 500"), false);
|
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)", () => {
|
test("ambiguous 'daily rate limit' messages classify as quota_exhausted (intentional)", () => {
|
||||||
// Codex audit LOW: messages combining 'daily' or 'monthly' with 'limit'
|
// Codex audit LOW: messages combining 'daily' or 'monthly' with 'limit'
|
||||||
// match the quota regex even when paired with 'rate'. This is intentional
|
// match the quota regex even when paired with 'rate'. This is intentional
|
||||||
|
|||||||
166
tests/unit/quota-card-grid-compact-layout-8916.test.ts
Normal file
166
tests/unit/quota-card-grid-compact-layout-8916.test.ts
Normal 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
|
||||||
|
});
|
||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
hasStreamReadinessSignal,
|
hasStreamReadinessSignal,
|
||||||
hasUsefulStreamContent,
|
hasUsefulStreamContent,
|
||||||
} from "../../open-sse/utils/streamReadiness.ts";
|
} 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();
|
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 });
|
const result = await ensureStreamReadiness(response, { timeoutMs: 100 });
|
||||||
assert.equal(result.ok, false);
|
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.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 () => {
|
test("ensureStreamReadiness accepts a final event without a trailing blank line", async () => {
|
||||||
|
|||||||
@@ -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", () => {
|
test("Claude -> OpenAI maps Claude server WebSearch to native Responses web_search", () => {
|
||||||
const result = claudeToOpenAIRequest(
|
const result = claudeToOpenAIRequest(
|
||||||
"gpt-5.5",
|
"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(
|
const result = claudeToOpenAIRequest(
|
||||||
"gpt-5",
|
"gpt-5",
|
||||||
{
|
{
|
||||||
@@ -418,7 +416,7 @@ test("Claude -> OpenAI normalizes output_config.effort=max to xhigh", () => {
|
|||||||
false
|
false
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(result.reasoning_effort, "xhigh");
|
assert.equal(result.reasoning_effort, "max");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Claude -> OpenAI ignores disabled thinking and leaves reasoning_effort unset", () => {
|
test("Claude -> OpenAI ignores disabled thinking and leaves reasoning_effort unset", () => {
|
||||||
|
|||||||
24
tests/unit/translator-format-detection-2949.test.ts
Normal file
24
tests/unit/translator-format-detection-2949.test.ts
Normal 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"
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -106,7 +106,13 @@ vi.mock("@/shared/constants/models", () => ({
|
|||||||
|
|
||||||
// Stub specialized cards — render a testid so we can identify which was rendered
|
// Stub specialized cards — render a testid so we can identify which was rendered
|
||||||
vi.mock("../../../src/app/(dashboard)/dashboard/cli-code/components/index", () => ({
|
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" />,
|
CodexToolCard: () => <div data-testid="CodexToolCard" />,
|
||||||
DroidToolCard: () => <div data-testid="DroidToolCard" />,
|
DroidToolCard: () => <div data-testid="DroidToolCard" />,
|
||||||
OpenClawToolCard: () => <div data-testid="OpenClawToolCard" />,
|
OpenClawToolCard: () => <div data-testid="OpenClawToolCard" />,
|
||||||
@@ -127,9 +133,8 @@ vi.mock("../../../src/app/(dashboard)/dashboard/cli-code/components/CliproxyapiT
|
|||||||
|
|
||||||
// ── Import after mocks ────────────────────────────────────────────────────────
|
// ── Import after mocks ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const { default: ToolDetailClient } = await import(
|
const { default: ToolDetailClient } =
|
||||||
"@/app/(dashboard)/dashboard/cli-code/components/ToolDetailClient"
|
await import("@/app/(dashboard)/dashboard/cli-code/components/ToolDetailClient");
|
||||||
);
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -153,7 +158,10 @@ beforeEach(() => {
|
|||||||
(
|
(
|
||||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
mockFetch.mockClear();
|
mockFetch.mockReset().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ connections: [], keys: [], data: [], cloudEnabled: false }),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -185,6 +193,93 @@ describe("ToolDetailClient", () => {
|
|||||||
expect(container.querySelector("[data-testid='CustomCliCard']")).not.toBeNull();
|
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 () => {
|
it("renders DefaultToolCard for unknown tool (forge, configType:custom)", async () => {
|
||||||
const container = renderDetail("forge", "code");
|
const container = renderDetail("forge", "code");
|
||||||
await act(async () => {});
|
await act(async () => {});
|
||||||
|
|||||||
Reference in New Issue
Block a user