mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-09 00:32:13 +03:00
Compare commits
15 Commits
fix/i18n-h
...
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
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v4.5.2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v4.5.2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -255,13 +255,13 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v4.5.2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v4.5.2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -390,7 +390,7 @@ jobs:
|
||||
- name: Upload Trivy SARIF to Security tab
|
||||
if: needs.prepare.outputs.version != 'main'
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
uses: github/codeql-action/upload-sarif@v4.37.3
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: trivy-image
|
||||
|
||||
29
.mergify.yml
29
.mergify.yml
@@ -17,6 +17,13 @@
|
||||
# • Fallback path if Mergify misbehaves or the OSS plan changes: the manual
|
||||
# merge-train runbook (docs/ops/MERGE_TRAIN.md) — remove labels, proceed by hand.
|
||||
|
||||
# Auto-enqueue (current Mergify model, 2026): auto_merge_conditions in
|
||||
# merge_protections_settings — the rules-based queue action / autoqueue path is
|
||||
# deprecated (EOL 2026-07-16). The owner-applied `queue` label IS the approval.
|
||||
merge_protections_settings:
|
||||
auto_merge_conditions:
|
||||
- label = queue
|
||||
|
||||
queue_rules:
|
||||
- name: release
|
||||
# Any current or future release branch — the reason GitHub's native queue was
|
||||
@@ -34,14 +41,26 @@ queue_rules:
|
||||
# is intentionally NOT a condition here: the owner-applied `queue` label IS the
|
||||
# approval in this repo's single-maintainer model (see governance header).
|
||||
merge_conditions:
|
||||
- "#check-failure=0"
|
||||
# "Zero failures" — EXCEPT the advisory "Build (advisory)" job (quality.yml):
|
||||
# continue-on-error by design, and its GH-hosted Turbopack build hangs
|
||||
# recurrently mid-"Creating an optimized production build" (100% failure rate
|
||||
# across every sampled PR since the job was added 2026-07-27, always killed by
|
||||
# a runner timeout/shutdown signal, never a real compile error). Any OTHER
|
||||
# failure still blocks (anti-fail-open kept). The prior dast-smoke exception
|
||||
# (#7225) was dropped here: dast-smoke's hang (#7226) has been dormant for
|
||||
# weeks (0 failures in the last 30 runs; 2 all-time, none since 2026-07-13) —
|
||||
# carrying its tolerance forward would mask problems it no longer causes.
|
||||
- or:
|
||||
- "#check-failure=0"
|
||||
- and:
|
||||
- "#check-failure=1"
|
||||
- check-failure=Build (advisory)
|
||||
- "#check-pending=0"
|
||||
- "#check-success>=1"
|
||||
- check-success=Merge integrity (changelog + generated skills)
|
||||
# Batching: validate up to 10 queued PRs together (the manual train's sweet spot);
|
||||
# don't hold a lone PR hostage waiting for siblings.
|
||||
batch_size: 10
|
||||
batch_max_wait_time: 5 min
|
||||
# NO batching: 'Merge Queue Batch' requires a paid Mergify tier (live finding
|
||||
# 2026-07-15 — the queue command fails with "Cannot use Merge Queue batch" on
|
||||
# the free plan). Serial queue (1 PR at a time) still automates the train.
|
||||
# Squash keeps the one-commit-per-PR history the CHANGELOG reconciliation expects.
|
||||
merge_method: squash
|
||||
|
||||
|
||||
695
AGENTS.md
695
AGENTS.md
@@ -1,600 +1,117 @@
|
||||
# omniroute — Agent Guidelines
|
||||
# OmniRoute agent guide
|
||||
|
||||
## Project
|
||||
|
||||
Unified AI proxy/router — route any LLM through one endpoint. Multi-provider support
|
||||
with **290 provider entries** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks,
|
||||
Cohere, NVIDIA, Cerebras, Pollinations, Puter, Cloudflare AI, HuggingFace, DeepInfra,
|
||||
SambaNova, Meta Llama API, Moonshot AI, AI21 Labs, Databricks, Snowflake, and many more)
|
||||
with **MCP Server** (104 tools), **A2A v0.3 Protocol**, and **Electron desktop app**.
|
||||
|
||||
> **Live counts (v3.8.49)**: providers 290 · MCP tools 104 · MCP scopes 30 · A2A skills 6 ·
|
||||
> open-sse services 134 · routing strategies 17 · auto-combo scoring factors 12 ·
|
||||
> DB modules 95 · DB migrations 110 · base tables 17 · search providers 11 ·
|
||||
> i18n locales 42. **Refresh with `npm run check:docs-all`.**
|
||||
|
||||
## Doc Accuracy Discipline (read before writing any doc)
|
||||
|
||||
> **If `grep -rn "name" src/ open-sse/ bin/` returns nothing, the name does not exist. Do not document it.**
|
||||
|
||||
The recurring failure mode in AI-generated docs is _plausible-but-unverified specifics_.
|
||||
Every claim in a `.md` file under `docs/` should be verifiable against the source.
|
||||
|
||||
**Rules (enforced by `npm run check:fabricated-docs`):**
|
||||
|
||||
1. **Never state an API name, endpoint, path, CLI command, or env var without grepping for it first.**
|
||||
```bash
|
||||
grep -rn "theName" src/ open-sse/ bin/
|
||||
# 0 hits → do not document
|
||||
```
|
||||
2. **Never write a line count, file size, migration count, provider count, or strategy count from memory.**
|
||||
```bash
|
||||
wc -l <file> # exact line count
|
||||
ls <dir>/*.ts | wc -l # file count
|
||||
```
|
||||
3. **Every code example should be copy-pasted from real usage or actually run** — not synthesized.
|
||||
Link to a real call site (`path:line`) instead of inventing a signature.
|
||||
4. **Prefer citing real source (`file.ts:line`) over paraphrasing behavior** — verifiable and self-correcting.
|
||||
5. **A shorter doc that is 100% accurate beats a comprehensive one with fabrications.**
|
||||
Wrong docs cost more than missing docs, because people trust and act on them.
|
||||
|
||||
The script `scripts/check/check-fabricated-docs.mjs` extracts every route path, env var, hook
|
||||
name, function name, and file reference from `docs/**/*.md` and verifies each one against the
|
||||
codebase. Run it locally before pushing docs; it runs in CI via `npm run check:docs-all`.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Runtime**: Next.js 16 (App Router), Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
|
||||
- **Language**: TypeScript 6.0 (`src/`) + JavaScript (`open-sse/`, `electron/`)
|
||||
- **Database**: better-sqlite3 (SQLite) — `DATA_DIR` configurable, default `~/.omniroute/`
|
||||
- **Streaming**: SSE via `open-sse` internal workspace package
|
||||
- **Styling**: Tailwind CSS v4
|
||||
- **i18n**: next-intl with 42 locales (`src/i18n/messages/`) — refresh with `ls src/i18n/messages/*.json | wc -l`
|
||||
- **Desktop**: Electron (cross-platform: Windows, macOS, Linux)
|
||||
- **Schemas**: Zod v4 for all API / MCP input validation
|
||||
|
||||
---
|
||||
|
||||
## Build, Lint, and Test Commands
|
||||
|
||||
| Command | Description |
|
||||
| ----------------------------------- | ------------------------------------------------------------------ |
|
||||
| `npm run dev` | Start Next.js dev server |
|
||||
| `npm run build` | Production build: `next build` → `.build/next/` + assemble `dist/` |
|
||||
| `npm run build:release` | Clean rebuild + HEAD sentinel (`dist/BUILD_SHA`) — use for deploy |
|
||||
| `npm run start` | Run production build |
|
||||
| `npm run build:cli` | Build CLI package |
|
||||
| `npm run lint` | ESLint on all source files |
|
||||
| `npm run typecheck:core` | TypeScript core type checking |
|
||||
| `npm run typecheck:noimplicit:core` | Strict checking (no implicit any) |
|
||||
| `npm run check` | Run lint + test |
|
||||
| `npm run check:cycles` | Check for circular dependencies |
|
||||
| `npm run electron:dev` | Run Electron app in dev mode |
|
||||
| `npm run electron:build` | Build Electron app for current OS |
|
||||
|
||||
**Build output layout:**
|
||||
|
||||
| Directory | Purpose | Gitignored |
|
||||
| --------- | -------------------------------------------------- | ---------- |
|
||||
| `src/` | Application source (TypeScript / TSX) | No |
|
||||
| `.build/` | Build intermediates (`distDir = .build/next`) | Yes |
|
||||
| `dist/` | Shippable bundle assembled by `assembleStandalone` | Yes |
|
||||
|
||||
The pipeline is a single `next build` pass — intermediates land in `.build/next/`, the
|
||||
assembled bundle in `dist/`. VPS deploys rsync `dist/` into the remote
|
||||
`/usr/lib/node_modules/omniroute/app/` directory (VPS image path is unchanged).
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# All tests (unit + vitest + ecosystem + e2e)
|
||||
npm run test:all
|
||||
|
||||
# Single test file (Node.js native test runner — most tests use this)
|
||||
node --import tsx/esm --test tests/unit/your-file.test.ts
|
||||
node --import tsx/esm --test tests/unit/plan3-p0.test.ts
|
||||
node --import tsx/esm --test tests/unit/fixes-p1.test.ts
|
||||
node --import tsx/esm --test tests/unit/security-fase01.test.ts
|
||||
|
||||
# Integration tests
|
||||
node --import tsx/esm --test tests/integration/*.test.ts
|
||||
|
||||
# Vitest (MCP server, autoCombo)
|
||||
npm run test:vitest
|
||||
|
||||
# E2E with Playwright
|
||||
npm run test:e2e
|
||||
|
||||
# Protocol clients E2E (MCP transports, A2A)
|
||||
npm run test:protocols:e2e
|
||||
|
||||
# Ecosystem compatibility tests
|
||||
npm run test:ecosystem
|
||||
|
||||
# Coverage (see CONTRIBUTING.md)
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
**For authoritative coverage requirements, test execution, and PR gates, see [`CONTRIBUTING.md`](CONTRIBUTING.md#running-tests).**
|
||||
|
||||
---
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### Formatting (Prettier — enforced via lint-staged)
|
||||
|
||||
2 spaces · semicolons required · double quotes (`"`) · 100 char width · es5 trailing commas.
|
||||
Always run `prettier --write` on changed files.
|
||||
|
||||
### TypeScript
|
||||
|
||||
- **Target**: ES2022 · **Module**: `esnext` · **Resolution**: `bundler`
|
||||
- `strict: false` — prefer explicit types, don't rely on inference
|
||||
- Path aliases: `@/*` → `src/`, `@omniroute/open-sse` → `open-sse/`, `@omniroute/open-sse/*` → `open-sse/*`
|
||||
|
||||
### ESLint Rules
|
||||
|
||||
- **Security (error, everywhere)**: `no-eval`, `no-implied-eval`, `no-new-func`
|
||||
- **Relaxed in `open-sse/` and `tests/`**: `@typescript-eslint/no-explicit-any` = warn
|
||||
- React hooks rules and `@next/next/no-assign-module-variable` disabled in `open-sse/` and `tests/`
|
||||
|
||||
### Naming
|
||||
|
||||
| Element | Convention | Example |
|
||||
| ------------------- | -------------------------------- | ------------------------------------ |
|
||||
| Files | camelCase / kebab-case | `chatCore.ts`, `tokenHealthCheck.ts` |
|
||||
| React components | PascalCase | `Dashboard.tsx`, `ProviderCard.tsx` |
|
||||
| Functions/variables | camelCase | `getHealth()`, `switchCombo()` |
|
||||
| Constants | UPPER_SNAKE | `MAX_RETRIES`, `DEFAULT_TIMEOUT` |
|
||||
| Interfaces | PascalCase (`I` prefix optional) | `ProviderConfig` |
|
||||
| Enums | PascalCase (members too) | `LogLevel.Error` |
|
||||
|
||||
### Imports
|
||||
|
||||
- **Order**: external → internal (`@/`, `@omniroute/open-sse`) → relative (`./`, `../`)
|
||||
- **No barrel imports** from `localDb.ts` — import from the specific `db/` module instead
|
||||
|
||||
### Error Handling
|
||||
|
||||
- try/catch with specific error types; always log with context (pino logger)
|
||||
- Never silently swallow errors in SSE streams — use abort signals for cleanup
|
||||
- Return proper HTTP status codes (4xx client, 5xx server)
|
||||
|
||||
### Security
|
||||
|
||||
- **NEVER** commit API keys, secrets, or credentials
|
||||
- Validate all user inputs with Zod schemas
|
||||
- Auth middleware required on all API routes
|
||||
- Never log SQLite encryption keys
|
||||
- Sanitize user content (dompurify for HTML)
|
||||
- **Public upstream OAuth identifiers** (Gemini / Antigravity / Windsurf-style client_id/secret + Firebase Web keys extracted from public CLIs): use `resolvePublicCred()` from `open-sse/utils/publicCreds.ts`, **never** as string literals. Full pattern in `docs/security/PUBLIC_CREDS.md`.
|
||||
- **Error responses** (HTTP / SSE / executor / MCP): use `buildErrorBody()` or `sanitizeErrorMessage()` from `open-sse/utils/error.ts`, **never** put raw `err.stack` / `err.message` in a Response body. Full pattern in `docs/security/ERROR_SANITIZATION.md`.
|
||||
- **`exec()` / `spawn()` with runtime values**: pass via the `env` option, **never** string-interpolate paths/values into the script body. Reference: `src/mitm/cert/install.ts::updateNssDatabases`.
|
||||
- Prefer secure-by-default libraries when available — see [tldrsec/awesome-secure-defaults](https://github.com/tldrsec/awesome-secure-defaults) for the curated list (Helmet.js, DOMPurify, ssrf-req-filter, safe-regex, Google Tink, etc.).
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data Layer (`src/lib/db/`)
|
||||
|
||||
All persistence uses SQLite through **95 domain-specific modules** in `src/lib/db/`. Top modules:
|
||||
|
||||
- Core: `core.ts`, `migrationRunner.ts`, `encryption.ts`, `stateReset.ts`
|
||||
- Providers / catalog: `providers.ts`, `models.ts`, `providerLimits.ts`, `compressionAnalytics.ts`
|
||||
- Routing: `combos.ts`, `modelComboMappings.ts`, `domainState.ts`, `commandCodeAuth.ts`
|
||||
- Auth: `apiKeys.ts`, `secrets.ts`, `registeredKeys.ts`, `sessionAccountAffinity.ts`
|
||||
- Usage / billing: `quotaSnapshots.ts`, `creditBalance.ts`, `usage*.ts`, `compressionCacheStats.ts`
|
||||
- Storage: `backup.ts`, `cleanup.ts`, `jsonMigration.ts`, `healthCheck.ts`, `databaseSettings.ts`
|
||||
- Extension modules: `evals.ts`, `webhooks.ts`, `reasoningCache.ts`, `readCache.ts`, `tierConfig.ts`, `compressionCombos.ts`, `compressionScheduler.ts`, `batches.ts`, `files.ts`, `syncTokens.ts`, `proxies.ts`, `oneproxy.ts`, `upstreamProxy.ts`, `versionManager.ts`, `cliToolState.ts`, `prompts.ts`, `detailedLogs.ts`, `contextHandoffs.ts`, `compression.ts`, `stats.ts`
|
||||
|
||||
Live count: `ls src/lib/db/*.ts | wc -l` (currently 95). Drift detection: `npm run check:docs-counts`.
|
||||
Schema migrations live in `db/migrations/` (**110 files** as of v3.8.43) and run via `migrationRunner.ts`.
|
||||
`src/lib/localDb.ts` is a **re-export layer only** — never add logic there.
|
||||
|
||||
#### DB Internals
|
||||
|
||||
- **`core.ts`**: `getDbInstance()` returns a singleton `better-sqlite3` instance with WAL
|
||||
journaling. `SCHEMA_SQL` defines **17 base tables** (verify with `grep -c "CREATE TABLE" src/lib/db/core.ts` minus 1 for the bookkeeping `_omniroute_migrations` table). Helpers: `rowToCamel`, `encryptConnectionFields`.
|
||||
- **`migrationRunner.ts`**: Applies versioned SQL files from `db/migrations/` inside transactions.
|
||||
Tracks applied migrations in `_omniroute_migrations` table.
|
||||
- **Migrations**: 110 files (`001_initial_schema.sql` → `110_*.sql`).
|
||||
Each migration is idempotent and runs in a transaction. Live count: `ls src/lib/db/migrations/*.sql | wc -l`.
|
||||
- **Domain modules** import `getDbInstance()` from `core.ts` for all CRUD operations.
|
||||
Each module owns a specific table/set of tables (e.g., `providers.ts` → `provider_connections`,
|
||||
`combos.ts` → `combos`). Encryption helpers protect sensitive fields at rest.
|
||||
- **`localDb.ts`** re-exports all domain modules — consumers import from here for convenience.
|
||||
|
||||
### API Route Layer (`src/app/api/v1/`)
|
||||
|
||||
Next.js App Router routes — each follows a consistent pattern:
|
||||
|
||||
```
|
||||
Route → CORS preflight → Body validation (Zod) → Optional auth (extractApiKey/isValidApiKey)
|
||||
→ API key policy enforcement (enforceApiKeyPolicy) → Handler delegation (open-sse)
|
||||
```
|
||||
|
||||
| Route | Handler | Notes |
|
||||
| ------------------------------- | ------------------------- | ------------------------------------------------------------- |
|
||||
| `chat/completions/route.ts` | `handleChat()` | + prompt injection guard (clones request) |
|
||||
| `responses/route.ts` | `handleChat()` (unified) | Responses API format |
|
||||
| `embeddings/route.ts` | `handleEmbedding()` | Model listing + creation |
|
||||
| `images/generations/route.ts` | `handleImageGeneration()` | Model listing + creation |
|
||||
| `audio/transcriptions/route.ts` | audio handler | Multipart form data |
|
||||
| `audio/speech/route.ts` | TTS handler | Binary audio response |
|
||||
| `videos/generations/route.ts` | video handler | ComfyUI/SD WebUI |
|
||||
| `music/generations/route.ts` | music handler | ComfyUI workflows |
|
||||
| `moderations/route.ts` | moderation handler | Content safety |
|
||||
| `rerank/route.ts` | rerank handler | Document relevance |
|
||||
| `search/route.ts` | search handler | Web search (12 providers per `open-sse/handlers/search.ts:6`) |
|
||||
|
||||
**No global Next.js middleware file** — interception is route-specific. Auth is optional
|
||||
(controlled by `REQUIRE_API_KEY` env). Prompt injection guard is unique to chat completions.
|
||||
|
||||
### Request Pipeline (`open-sse/`)
|
||||
|
||||
The `open-sse/` workspace is the core streaming engine. Full request flow:
|
||||
|
||||
```
|
||||
Client Request
|
||||
→ src/app/api/v1/.../route.ts (Next.js route)
|
||||
→ open-sse/handlers/chatCore.ts::handleChatCore()
|
||||
→ Semantic/signature cache check
|
||||
→ Rate limit check (rateLimitManager)
|
||||
→ Combo routing? → open-sse/services/combo.ts::handleComboChat()
|
||||
→ resolveComboTargets() → ordered ResolvedComboTarget[]
|
||||
→ For each target: handleSingleModel() (wraps chatCore)
|
||||
→ translateRequest() (open-sse/translator/)
|
||||
→ Convert source format (e.g., OpenAI) → target format (e.g., Claude)
|
||||
→ getExecutor() → provider-specific executor instance
|
||||
→ executor.execute() (BaseExecutor → DefaultExecutor or provider-specific)
|
||||
→ buildUrl() + buildHeaders() + transformRequest()
|
||||
→ fetch() to upstream provider
|
||||
→ Retry logic with exponential backoff
|
||||
→ Response translation back to client format
|
||||
→ If Responses API: responsesTransformer.ts TransformStream
|
||||
→ SSE stream or JSON response to client
|
||||
```
|
||||
|
||||
**Handlers** (`open-sse/handlers/`): `chatCore.ts`, `responsesHandler.ts`, `embeddings.ts`,
|
||||
`imageGeneration.ts`, `videoGeneration.ts`, `musicGeneration.ts`, `audioSpeech.ts`,
|
||||
`audioTranscription.ts`, `moderations.ts`, `rerank.ts`, `search.ts`.
|
||||
|
||||
**Upstream headers**: merged after default auth; same header name replaces executor value.
|
||||
**T5 intra-family fallback** recomputes headers using only the fallback model id.
|
||||
Forbidden header names: `src/shared/constants/upstreamHeaders.ts` — keep sanitize,
|
||||
Zod schemas, and unit tests aligned when editing.
|
||||
|
||||
### Provider Categories
|
||||
|
||||
- **Free** (2): Qoder AI, Kiro AI
|
||||
- **OAuth** (13): Claude Code, Antigravity, Codex, GitHub Copilot, Cursor, Kimi Coding, Kilo Code, Cline, Kiro, Qoder, Gemini, Windsurf (v3.8), GitLab Duo (v3.8)
|
||||
- **API Key** (120+): OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Perplexity,
|
||||
Together, Fireworks, Cerebras, Cohere, NVIDIA, Nebius, SiliconFlow, Hyperbolic,
|
||||
HuggingFace, OpenRouter, Vertex AI, Cloudflare AI, Scaleway, AI/ML API, Pollinations,
|
||||
Puter, Longcat, Alibaba, Kimi, Minimax, Blackbox, Synthetic, Kilo Gateway,
|
||||
Z.AI, GLM, Deepgram, AssemblyAI, ElevenLabs, Cartesia, PlayHT, Inworld,
|
||||
NanoBanana, SD WebUI, ComfyUI, Ollama Cloud, Perplexity Search, Serper, Brave, Exa,
|
||||
Tavily, OpenCode Zen/Go, Bailian Coding Plan, DeepInfra, Vercel AI Gateway,
|
||||
Lambda AI, SambaNova, nScale, OVHcloud AI, Baseten, PublicAI, Moonshot AI,
|
||||
Meta Llama API, v0 (Vercel), Morph, Featherless AI, FriendliAI, LlamaGate,
|
||||
Galadriel, Weights & Biases Inference, Volcengine, AI21 Labs, Venice.ai,
|
||||
Codestral, Upstage, Maritalk, Xiaomi MiMo, Inference.net, NanoGPT, Predibase,
|
||||
Bytez, Heroku AI, Databricks, Snowflake Cortex, GigaChat (Sber), CrofAI,
|
||||
AgentRouter, ChatGPT Web, Baidu Qianfan, AWS Polly, RunwayML, GitLab Duo,
|
||||
Amazon Q, Empower, Poe, and many more.
|
||||
- **Self-Hosted** (8+): LM Studio, vLLM, Lemonade, Llamafile, Triton, Docker Model Runner, Xinference, Oobabooga
|
||||
- **Custom**: OpenAI-compatible (`openai-compatible-*`) and Anthropic-compatible (`anthropic-compatible-*`) prefixes
|
||||
|
||||
Providers are registered in `src/shared/constants/providers.ts` with Zod validation at module load.
|
||||
|
||||
### Executors (`open-sse/executors/`)
|
||||
|
||||
Provider-specific request executors: `base.ts`, `default.ts`, `cursor.ts`, `codex.ts`,
|
||||
`antigravity.ts`, `github.ts`, `kiro.ts`, `qoder.ts`, `vertex.ts`,
|
||||
`cloudflare-ai.ts`, `opencode.ts`, `pollinations.ts`, `puter.ts`.
|
||||
|
||||
#### Executor Internals
|
||||
|
||||
- **`base.ts`** (`BaseExecutor`): Abstract base with `buildUrl()`, `buildHeaders()`,
|
||||
`transformRequest()`, retry logic (exponential backoff), and `execute()`. Subclasses
|
||||
override URL/header/transform methods for provider-specific behavior.
|
||||
- **`default.ts`** (`DefaultExecutor extends BaseExecutor`): Handles most OpenAI-compatible
|
||||
providers. Reads provider config from `providerRegistry.ts` to resolve base URL, auth
|
||||
header format, and request transformations.
|
||||
- **`getExecutor()`** (`executors/index.ts`): Factory that returns the correct executor
|
||||
instance based on provider ID. Provider-specific executors (Cursor, Codex, Vertex, etc.)
|
||||
override only what differs from the default.
|
||||
|
||||
### Translator (`open-sse/translator/`)
|
||||
|
||||
Translates between API formats (OpenAI-format ↔ Anthropic, Gemini, etc.).
|
||||
Includes request/response translators with helpers for image handling.
|
||||
|
||||
#### Translator Internals
|
||||
|
||||
- **`translator/index.ts`**: Exports `translateRequest()` and format constants. Called by
|
||||
`chatCore.ts` before executor dispatch.
|
||||
- **Flow**: `translateRequest(body, sourceFormat, targetFormat)` → detects source format
|
||||
(OpenAI, Anthropic, Gemini) → applies the matching translator module → returns
|
||||
transformed body ready for the target provider.
|
||||
- **Response translation** runs in reverse after upstream response, converting back to
|
||||
the client's expected format.
|
||||
|
||||
### Transformer (`open-sse/transformer/`)
|
||||
|
||||
`responsesTransformer.ts` — transforms Responses API format to/from Chat Completions format.
|
||||
|
||||
#### Transformer Internals
|
||||
|
||||
- **`createResponsesApiTransformStream()`**: Returns a `TransformStream` that converts
|
||||
Chat Completions SSE chunks (`data: {"choices":[...]}`) into Responses API SSE events
|
||||
(`response.output_item.added`, `response.output_text.delta`, etc.).
|
||||
- Used when the client sends a Responses API request: the request is internally converted
|
||||
to Chat Completions format, dispatched normally, and the response is piped through this
|
||||
transform stream before reaching the client.
|
||||
|
||||
### Services (`open-sse/services/`)
|
||||
|
||||
134 service modules in `open-sse/services/` (top-level only; more including sub-dirs like `autoCombo/` and `compression/`). Refresh: `ls open-sse/services/*.ts | wc -l`. Key modules:
|
||||
`combo.ts` (routing engine), `usage.ts`, `tokenRefresh.ts`,
|
||||
`rateLimitManager.ts`, `accountFallback.ts`, `sessionManager.ts`, `wildcardRouter.ts`,
|
||||
`autoCombo/`, `intentClassifier.ts`, `taskAwareRouter.ts`, `thinkingBudget.ts`,
|
||||
`contextManager.ts`, `modelDeprecation.ts`, `modelFamilyFallback.ts`,
|
||||
`emergencyFallback.ts`, `workflowFSM.ts`, `backgroundTaskDetector.ts`, `ipFilter.ts`,
|
||||
`signatureCache.ts`, `volumeDetector.ts`, `contextHandoff.ts`, `compression/` (prompt
|
||||
compression pipeline), and more.
|
||||
|
||||
#### Prompt Compression Pipeline (`compression/`)
|
||||
|
||||
Modular prompt compression that runs proactively before the existing reactive context manager.
|
||||
|
||||
- **`strategySelector.ts`**: Selects compression mode based on config, compression combo assignments,
|
||||
combo overrides, auto-trigger thresholds, and defaults. Priority: assigned compression combo >
|
||||
combo override > auto-trigger > default mode > off.
|
||||
- **`lite.ts`**: 5 lite-mode techniques: `collapseWhitespace`, `dedupSystemPrompt`,
|
||||
`compressToolResults`, `removeRedundantContent`, `replaceImageUrls`. Target: 10-15% savings at
|
||||
<1ms latency.
|
||||
- **`caveman.ts` / `cavemanRules.ts`**: Caveman-style semantic condensation backed by built-in
|
||||
rules plus file-loaded language packs under `compression/rules/`.
|
||||
- **`engines/rtk/`**: Rule-based terminal/tool-output compression inspired by RTK patterns. Detects
|
||||
command output classes, applies JSON filter packs, deduplicates repeated lines, strips ANSI/code
|
||||
noise, and preserves errors/actionable context. The RTK JSON DSL supports replace,
|
||||
match-output short-circuit, strip/keep, per-line truncation, head/tail/max-line truncation,
|
||||
inline tests, trust-gated project/global custom filters, and optional redacted raw-output
|
||||
retention for authenticated recovery.
|
||||
- **`engines/registry.ts`**: Registers engines (`caveman`, `rtk`) and powers stacked pipelines.
|
||||
- **`stats.ts`**: Per-request compression stats tracking (original tokens, compressed tokens,
|
||||
savings %, techniques used, engine breakdown, compression combo id).
|
||||
- **`types.ts`**: `CompressionMode` (off/lite/standard/aggressive/ultra/rtk/stacked),
|
||||
`CompressionConfig`, `CompressionStats`, `CompressionResult`.
|
||||
- DB settings in `src/lib/db/compression.ts`, compression combos in
|
||||
`src/lib/db/compressionCombos.ts`, API routes under `src/app/api/settings/compression/`,
|
||||
`src/app/api/context/*`, and preview/language-pack routes under `src/app/api/compression/*`.
|
||||
|
||||
#### Combo Routing Engine (`combo.ts`)
|
||||
|
||||
- **`handleComboChat()`**: Entry point for combo-routed requests. Receives the combo config
|
||||
and iterates through targets in order until one succeeds or all fail.
|
||||
- **`resolveComboTargets()`**: Expands a combo configuration into an ordered array of
|
||||
`ResolvedComboTarget[]`, each specifying provider + model + account + credentials.
|
||||
- **Strategies** (17): priority, weighted, fill-first, round-robin, P2C, random, least-used, reset-aware (v3.8),
|
||||
reset-window, cost-optimized, strict-random, auto, lkgp, context-optimized, context-relay, headroom, fusion. Source: `ROUTING_STRATEGY_VALUES` in `src/shared/constants/routingStrategies.ts`.
|
||||
- Each target calls **`handleSingleModel()`** which wraps `handleChatCore()` with
|
||||
per-target error handling and circuit breaker checks.
|
||||
|
||||
### Domain Layer (`src/domain/`)
|
||||
|
||||
Policy engine modules: `policyEngine.ts`, `comboResolver.ts`, `costRules.ts`,
|
||||
`degradation.ts`, `fallbackPolicy.ts`, `lockoutPolicy.ts`, `modelAvailability.ts`,
|
||||
`providerExpiration.ts`, `quotaCache.ts`, `responses.ts`, `configAudit.ts`.
|
||||
|
||||
### MCP Server (`open-sse/mcp-server/`)
|
||||
|
||||
**104 tools** total (`TOTAL_MCP_TOOL_COUNT`, `open-sse/mcp-server/server.ts`): a 42-entry base registry (`MCP_TOOLS` in `schemas/tools.ts`, bundling the core / cache / compression / 1proxy / advanced tools) **plus** standalone module sets — memory (3), skill (4), agentSkill (3), pool (6), gamification (8), plugin (8), notion (6), obsidian (22). 3 transports (stdio / SSE / Streamable HTTP). Scoped auth (31 scopes — see `OMNIROUTE_MCP_SCOPES`), Zod schemas. See [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md).
|
||||
|
||||
**Core tools** (20): get_health, list_combos, get_combo_metrics, switch_combo, check_quota,
|
||||
route_request, cost_report, list_models_catalog, web_search, simulate_route, set_budget_guard,
|
||||
set_routing_strategy, set_resilience_profile, test_combo, get_provider_metrics,
|
||||
best_combo_for_task, explain_route, get_session_snapshot, db_health_check, sync_pricing.
|
||||
|
||||
**Cache tools** (2): cache_stats, cache_flush.
|
||||
|
||||
**Compression tools** (5): compression_status, compression_configure, set_compression_engine,
|
||||
list_compression_combos, compression_combo_stats.
|
||||
|
||||
**1proxy tools** (3): oneproxy_fetch, oneproxy_rotate, oneproxy_stats.
|
||||
|
||||
**Memory tools** (3): memory_search, memory_add, memory_clear.
|
||||
|
||||
**Skill tools** (4): skills_list, skills_enable, skills_execute, skills_executions.
|
||||
|
||||
**Agent-skill tools** (3): A2A skill discovery / invocation bridges.
|
||||
|
||||
**Gamification tools** (8): levels, badges, leaderboard, and community-federation queries.
|
||||
|
||||
**Plugin tools** (8): plugin marketplace listing, install/enable/disable, and runtime inspection.
|
||||
|
||||
**Notion tools** (6) + **Obsidian tools** (22): knowledge-base read/write integrations (the largest tool family — vault search, note CRUD, WebDAV-backed file ops).
|
||||
|
||||
#### MCP Internals
|
||||
|
||||
- **Tool registration**: Each tool is an object with `{ name, description, inputSchema: ZodSchema,
|
||||
handler: async (args) => {...} }`. Zod validates inputs before the handler fires.
|
||||
- **`createMcpServer()`** and **`startMcpStdio()`** exported from `mcp-server/index.ts`.
|
||||
`createMcpServer()` wires all tool sets; `startMcpStdio()` launches the stdio transport.
|
||||
- **Transports**: stdio (CLI `omniroute --mcp`), SSE (`/api/mcp/sse`), Streamable HTTP
|
||||
(`/api/mcp/stream`). All share the same tool/scope engine.
|
||||
- **Scopes** (30): Control which tool categories an API key can access. Enforcement happens
|
||||
before handler dispatch.
|
||||
- **Audit**: Every tool invocation is logged to SQLite (`mcp_audit` table) with tool name,
|
||||
args, success/failure, API key attribution, and timestamp.
|
||||
|
||||
### A2A Server (`src/lib/a2a/`)
|
||||
|
||||
JSON-RPC 2.0, SSE streaming, Task Manager with TTL cleanup.
|
||||
Agent Card at `/.well-known/agent.json`.
|
||||
Skills (6): `smartRouting.ts`, `quotaManagement.ts`, `providerDiscovery.ts`, `costAnalysis.ts`, `healthReport.ts`, `listCapabilities.ts`.
|
||||
|
||||
#### A2A Internals
|
||||
|
||||
- **`taskManager.ts`**: State machine lifecycle for tasks: `submitted → working →
|
||||
completed | failed | canceled`. Tasks have TTL and are cleaned up automatically.
|
||||
- **JSON-RPC methods**: `message/send` (sync), `message/stream` (SSE), `tasks/get`,
|
||||
`tasks/cancel`. Dispatched via `POST /a2a`.
|
||||
- **Skills**: Registered in a DB-backed registry. Each skill receives task context
|
||||
(messages, metadata) and returns structured results. `quotaManagement.ts` summarizes
|
||||
quota; `smartRouting.ts` recommends routing decisions.
|
||||
- **Agent Card**: `/.well-known/agent.json` exposes capabilities, skills, and metadata
|
||||
for client auto-discovery.
|
||||
|
||||
### ACP Module (`src/lib/acp/`)
|
||||
|
||||
Agent Communication Protocol registry and manager.
|
||||
|
||||
### Memory System (`src/lib/memory/`)
|
||||
|
||||
Extraction, injection, retrieval, summarization, and store modules for persistent
|
||||
conversational memory across sessions.
|
||||
|
||||
### Skills System (`src/lib/skills/`)
|
||||
|
||||
Extensible skill framework: registry, executor, sandbox, built-in skills,
|
||||
custom skill support, interception, and injection.
|
||||
|
||||
#### Skills Internals
|
||||
|
||||
- **`registry.ts`**: DB-backed skill registration and discovery. Skills have metadata
|
||||
(name, description, version, enabled status) stored in SQLite.
|
||||
- **`executor.ts`**: Execution engine with configurable timeout and retry logic.
|
||||
Receives skill name + input, looks up the skill, runs it in the sandbox.
|
||||
- **`sandbox.ts`**: Isolation layer for custom (user-provided) skills. Limits resource
|
||||
access and execution time.
|
||||
- **Built-in skills**: Ship with OmniRoute (e.g., quota management, routing). Located
|
||||
alongside the registry.
|
||||
- **Interception/Injection**: Skills can intercept requests in the pipeline (pre/post
|
||||
processing) or inject context into prompts.
|
||||
|
||||
### Compliance (`src/lib/compliance/`)
|
||||
|
||||
Policy index for compliance enforcement.
|
||||
|
||||
### MITM Proxy (`src/mitm/`)
|
||||
|
||||
MITM proxy capability with certificate management, DNS handling, and target routing.
|
||||
|
||||
### Middleware (`src/middleware/`)
|
||||
|
||||
Request middleware including `promptInjectionGuard.ts`.
|
||||
|
||||
### Guardrails (`src/lib/guardrails/`)
|
||||
|
||||
Hot-reloadable guardrails framework (3 built-in: pii-masker, prompt-injection, vision-bridge). Fail-open. The `pii-masker` guardrail is registered and runs on every request, but its data-mutating logic is **opt-in** and OFF by default — it only redacts when `PII_REDACTION_ENABLED` (request) / `PII_RESPONSE_SANITIZATION` (response + streaming) are enabled (both `defaultValue: "false"`); with them off, payloads pass through untouched. A request can additionally opt OUT of any guardrail via header (`x-omniroute-disabled-guardrails`). Never make PII default-on (Hard Rule #20). See [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md).
|
||||
|
||||
### Cloud Agents (`src/lib/cloudAgent/`)
|
||||
|
||||
`CloudAgentBase` abstract class + 3 agents (codex-cloud, devin, jules). Tasks persisted in `cloud_agent_tasks`; management auth required. See [`docs/frameworks/CLOUD_AGENT.md`](docs/frameworks/CLOUD_AGENT.md).
|
||||
|
||||
### Evals (`src/lib/evals/`)
|
||||
|
||||
Generic eval framework: `evalRunner.ts`, `runtime.ts`. Targets: combo / model / suite-default. See [`docs/frameworks/EVALS.md`](docs/frameworks/EVALS.md).
|
||||
|
||||
### Webhooks (`src/lib/webhookDispatcher.ts`)
|
||||
|
||||
HMAC-signed delivery, exponential backoff, auto-disable after 10 failures. 7 event types. See [`docs/frameworks/WEBHOOKS.md`](docs/frameworks/WEBHOOKS.md).
|
||||
|
||||
### Authorization Pipeline (`src/server/authz/`)
|
||||
|
||||
`classify → policies → enforce`. 3 route classes (PUBLIC / CLIENT_API / MANAGEMENT). See [`docs/architecture/AUTHZ_GUIDE.md`](docs/architecture/AUTHZ_GUIDE.md).
|
||||
|
||||
### Reasoning Replay (`src/lib/db/reasoningCache.ts` + `open-sse/services/reasoningCache.ts`)
|
||||
|
||||
Hybrid in-memory + SQLite cache for `reasoning_content`. Re-injects on multi-turn for strict providers (DeepSeek V4, Kimi K2, Qwen-Thinking, GLM, xiaomi-mimo). See [`docs/routing/REASONING_REPLAY.md`](docs/routing/REASONING_REPLAY.md).
|
||||
|
||||
### Tunnels (`src/lib/{cloudflaredTunnel,ngrokTunnel}.ts` + `src/app/api/tunnels/`)
|
||||
|
||||
Cloudflare Quick/Named, ngrok, Tailscale Funnel. See [`docs/ops/TUNNELS_GUIDE.md`](docs/ops/TUNNELS_GUIDE.md).
|
||||
|
||||
### Adding a New Provider
|
||||
|
||||
1. Register in `src/shared/constants/providers.ts`
|
||||
2. Add executor in `open-sse/executors/` (if custom logic needed)
|
||||
3. Add translator in `open-sse/translator/` (if non-OpenAI format)
|
||||
4. Add OAuth config in `src/lib/oauth/constants/oauth.ts` (if OAuth-based)
|
||||
5. Add models in `open-sse/config/providerRegistry.ts`
|
||||
|
||||
---
|
||||
|
||||
## Subdirectory AGENTS.md Files
|
||||
|
||||
- **[`src/lib/db/AGENTS.md`](src/lib/db/AGENTS.md)** — SQLite persistence, domain modules, migrations
|
||||
- **[`open-sse/services/AGENTS.md`](open-sse/services/AGENTS.md)** — Routing engine, combo resolution, strategy selection
|
||||
|
||||
## Reference Documentation (docs/)
|
||||
|
||||
For any non-trivial change, read the matching deep-dive first:
|
||||
|
||||
| Area | Doc |
|
||||
| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
|
||||
| Repo navigation | [`docs/architecture/REPOSITORY_MAP.md`](docs/architecture/REPOSITORY_MAP.md) |
|
||||
| Architecture | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
|
||||
| Engineering reference | [`docs/architecture/CODEBASE_DOCUMENTATION.md`](docs/architecture/CODEBASE_DOCUMENTATION.md) |
|
||||
| Auto-Combo (12-factor, 18 strategies) | [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md) |
|
||||
| Resilience (3 layers) | [`docs/architecture/RESILIENCE_GUIDE.md`](docs/architecture/RESILIENCE_GUIDE.md) |
|
||||
| Skills | [`docs/frameworks/SKILLS.md`](docs/frameworks/SKILLS.md) |
|
||||
| Memory | [`docs/frameworks/MEMORY.md`](docs/frameworks/MEMORY.md) |
|
||||
| Cloud agents | [`docs/frameworks/CLOUD_AGENT.md`](docs/frameworks/CLOUD_AGENT.md) |
|
||||
| Guardrails | [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md) |
|
||||
| Evals | [`docs/frameworks/EVALS.md`](docs/frameworks/EVALS.md) |
|
||||
| Compliance | [`docs/security/COMPLIANCE.md`](docs/security/COMPLIANCE.md) |
|
||||
| Webhooks | [`docs/frameworks/WEBHOOKS.md`](docs/frameworks/WEBHOOKS.md) |
|
||||
| Authz | [`docs/architecture/AUTHZ_GUIDE.md`](docs/architecture/AUTHZ_GUIDE.md) |
|
||||
| Stealth | [`docs/security/STEALTH_GUIDE.md`](docs/security/STEALTH_GUIDE.md) |
|
||||
| Reasoning replay | [`docs/routing/REASONING_REPLAY.md`](docs/routing/REASONING_REPLAY.md) |
|
||||
| Agent protocols (A2A / ACP / Cloud) | [`docs/frameworks/AGENT_PROTOCOLS_GUIDE.md`](docs/frameworks/AGENT_PROTOCOLS_GUIDE.md) |
|
||||
| MCP server | [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md) |
|
||||
| A2A server | [`docs/frameworks/A2A-SERVER.md`](docs/frameworks/A2A-SERVER.md) |
|
||||
| API reference | [`docs/reference/API_REFERENCE.md`](docs/reference/API_REFERENCE.md) + [`docs/openapi.yaml`](docs/openapi.yaml) |
|
||||
| Provider catalog (auto-generated) | [`docs/reference/PROVIDER_REFERENCE.md`](docs/reference/PROVIDER_REFERENCE.md) |
|
||||
| Tunnels | [`docs/ops/TUNNELS_GUIDE.md`](docs/ops/TUNNELS_GUIDE.md) |
|
||||
| Electron desktop | [`docs/guides/ELECTRON_GUIDE.md`](docs/guides/ELECTRON_GUIDE.md) |
|
||||
| Release flow | [`docs/ops/RELEASE_CHECKLIST.md`](docs/ops/RELEASE_CHECKLIST.md) |
|
||||
| Quality gates (35 gates, allowlist policy) | [`docs/architecture/QUALITY_GATES.md`](docs/architecture/QUALITY_GATES.md) |
|
||||
| Cluster opt-in profiles (memory, bifrost) | [`docs/architecture/cluster-decisions.md`](docs/architecture/cluster-decisions.md) |
|
||||
|
||||
---
|
||||
|
||||
## Fork / Upstream Workflow
|
||||
|
||||
This repository is a fork of `diegosouzapw/OmniRoute`. Keep fork-only operational
|
||||
changes (for example GHCR image publishing, personal deployment workflows, or local
|
||||
automation) out of upstream contribution PRs.
|
||||
|
||||
When preparing a PR for upstream, always start the work branch from the upstream
|
||||
**default branch** — the active `release/vX.Y.Z` line (today `release/v3.8.49`).
|
||||
Never branch from `main`: `main` only receives release squash-merges, so a branch
|
||||
cut there is weeks behind and produces conflict-heavy PRs
|
||||
(see `CONTRIBUTING.md` and `docs/ops/BRANCHING_MODEL.md`):
|
||||
OmniRoute is a unified AI proxy/router. The repository contains the Next.js application
|
||||
(`src/`), streaming engine workspace (`open-sse/`), Electron desktop app (`electron/`),
|
||||
CLI (`bin/`), and tests (`tests/`).
|
||||
|
||||
## Setup and focused checks
|
||||
|
||||
- Runtime: Node.js `>=22.22.3 <23` or `>=24.0.0 <27`; npm 10+.
|
||||
- Install dependencies: `npm install`.
|
||||
- Start development: `npm run dev`.
|
||||
- Build: `npm run build`; release build: `npm run build:release`.
|
||||
- Lint: `npm run lint`.
|
||||
- Core type check: `npm run typecheck:core`.
|
||||
- Run the most focused test for changed code first:
|
||||
`node --import tsx/esm --test tests/unit/<file>.test.ts`.
|
||||
- Other suites: `npm run test:vitest`, `npm run test:e2e`,
|
||||
`npm run test:protocols:e2e`, and `npm run test:ecosystem`.
|
||||
- Run `npm run check:docs-all` after changing documentation.
|
||||
|
||||
For the complete test matrix, coverage requirements, and pull-request gates, read
|
||||
[`CONTRIBUTING.md`](CONTRIBUTING.md#running-tests).
|
||||
|
||||
## Documentation accuracy
|
||||
|
||||
Documentation must describe verified behavior, not plausible behavior.
|
||||
|
||||
1. Before documenting an API name, endpoint, path, CLI command, or environment variable,
|
||||
search for it: `rg -n "name" src/ open-sse/ bin/`. If it has no source match, do not
|
||||
document it.
|
||||
2. Measure mutable counts instead of writing them from memory: use `wc -l <file>` or a
|
||||
directory-specific count command.
|
||||
3. Copy code examples from working usage or run them. Prefer a source link such as
|
||||
`path/to/file.ts:line` to an invented signature.
|
||||
4. Run `npm run check:docs-all` for edits under `docs/`; it includes the fabricated-docs
|
||||
validation.
|
||||
|
||||
## Code conventions
|
||||
|
||||
- Format with Prettier: two spaces, semicolons, double quotes, 100-character line width,
|
||||
and ES5 trailing commas. Run Prettier on changed files.
|
||||
- TypeScript target is ES2022 with bundler module resolution. Prefer explicit types.
|
||||
- Import order: external, internal (`@/` and `@omniroute/open-sse`), then relative.
|
||||
- Do not add logic to `src/lib/localDb.ts`; import from the owning `src/lib/db/` module.
|
||||
- Use specific errors and contextual logging. Do not silently swallow SSE-stream failures;
|
||||
use abort signals for cleanup and return appropriate HTTP status codes.
|
||||
|
||||
## Security requirements
|
||||
|
||||
- Never commit credentials or log SQLite encryption keys.
|
||||
- Validate API inputs with Zod and use the route's required authentication path.
|
||||
- Sanitize user HTML with DOMPurify.
|
||||
- Use `resolvePublicCred()` for public upstream OAuth identifiers; never add them as string
|
||||
literals. See [`docs/security/PUBLIC_CREDS.md`](docs/security/PUBLIC_CREDS.md).
|
||||
- Use `buildErrorBody()` or `sanitizeErrorMessage()` for HTTP, SSE, executor, and MCP errors;
|
||||
do not return raw `err.stack` or `err.message`. See
|
||||
[`docs/security/ERROR_SANITIZATION.md`](docs/security/ERROR_SANITIZATION.md).
|
||||
- Pass runtime values to `exec()` or `spawn()` through `env`, not interpolation into a script.
|
||||
|
||||
## Repository map
|
||||
|
||||
Read the nearest `AGENTS.md` and the linked deep-dive before making a non-trivial change.
|
||||
|
||||
| Area | Location | Start here |
|
||||
| ---------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| API routes | `src/app/api/v1/` | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
|
||||
| Streaming request handling | `open-sse/handlers/` | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
|
||||
| Provider execution and translation | `open-sse/executors/`, `open-sse/translator/` | [`docs/architecture/CODEBASE_DOCUMENTATION.md`](docs/architecture/CODEBASE_DOCUMENTATION.md) |
|
||||
| Routing and resilience | `open-sse/services/` | [`open-sse/services/AGENTS.md`](open-sse/services/AGENTS.md), [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md) |
|
||||
| Database and migrations | `src/lib/db/`, `db/migrations/` | [`src/lib/db/AGENTS.md`](src/lib/db/AGENTS.md) |
|
||||
| Domain policy | `src/domain/` | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
|
||||
| MCP and A2A | `open-sse/mcp-server/`, `src/lib/a2a/` | [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md), [`docs/frameworks/A2A-SERVER.md`](docs/frameworks/A2A-SERVER.md) |
|
||||
| Agent features | `src/lib/{acp,memory,skills,cloudAgent}/` | [`docs/frameworks/AGENT_PROTOCOLS_GUIDE.md`](docs/frameworks/AGENT_PROTOCOLS_GUIDE.md), [`docs/frameworks/SKILLS.md`](docs/frameworks/SKILLS.md) |
|
||||
| Safety and governance | `src/lib/{guardrails,compliance}/`, `src/server/authz/` | [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md), [`docs/architecture/AUTHZ_GUIDE.md`](docs/architecture/AUTHZ_GUIDE.md) |
|
||||
| Operations | `src/mitm/`, tunnel modules, `electron/` | [`docs/ops/TUNNELS_GUIDE.md`](docs/ops/TUNNELS_GUIDE.md), [`docs/guides/ELECTRON_GUIDE.md`](docs/guides/ELECTRON_GUIDE.md) |
|
||||
|
||||
## Review focus
|
||||
|
||||
- Keep database operations in `src/lib/db/`; do not issue raw SQL from routes.
|
||||
- Send provider requests through `open-sse/handlers/`.
|
||||
- Keep MCP and A2A pages as tabs inside `/dashboard/endpoint`.
|
||||
- Preserve SSE cleanup, rate-limit header parsing, Zod validation, and provider-schema
|
||||
validation.
|
||||
- Treat Memory and Skills as cross-cutting changes that can affect MCP tools, the request
|
||||
pipeline, and A2A skills.
|
||||
- Do not close a contributor pull request after using its code; merge it through GitHub so
|
||||
the contributor receives credit.
|
||||
|
||||
## Upstream contributions
|
||||
|
||||
This checkout is a fork of `diegosouzapw/OmniRoute`. Keep fork-only deployment and personal
|
||||
automation changes out of upstream PRs.
|
||||
|
||||
Start upstream work from the active upstream default branch, not `main`:
|
||||
|
||||
```bash
|
||||
git fetch upstream
|
||||
# the default branch is the active release line, e.g. release/v3.8.49
|
||||
git switch -c <branch-name> upstream/release/vX.Y.Z
|
||||
git switch -c <branch-name> upstream/<default-branch>
|
||||
```
|
||||
|
||||
Only cherry-pick or reapply the changes intended for the upstream PR.
|
||||
Target that same release branch in the pull request. Stage only the intended files, run the
|
||||
focused checks, and use a Conventional Commit message (for example, `docs: slim AGENTS.md`).
|
||||
|
||||
---
|
||||
## Reference documentation
|
||||
|
||||
## Review Focus
|
||||
Use the source of truth for the area you are changing:
|
||||
|
||||
- **DB ops** go through `src/lib/db/` modules, never raw SQL in routes
|
||||
- **Provider requests** flow through `open-sse/handlers/`
|
||||
- **MCP/A2A pages** are tabs inside `/dashboard/endpoint`, not standalone routes
|
||||
- **No memory leaks** in SSE streams (abort signals, cleanup)
|
||||
- **Rate limit headers** must be parsed correctly
|
||||
- All API inputs validated with **Zod schemas**
|
||||
- **Provider constants** validated at module load via Zod (`src/shared/validation/providerSchema.ts`)
|
||||
- **Pricing data** syncs from LiteLLM via `src/lib/pricingSync.ts`
|
||||
- **Memory/Skills** are cross-cutting: affect MCP tools, request pipeline, and A2A skills
|
||||
- **⛔ NEVER close a contributor's PR** after using their code — always merge via GitHub so they get credit. See `.agents/workflows/review-prs.md` for full policy.
|
||||
| Area | Reference |
|
||||
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Repository navigation and architecture | [`docs/architecture/REPOSITORY_MAP.md`](docs/architecture/REPOSITORY_MAP.md), [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
|
||||
| API and providers | [`docs/reference/API_REFERENCE.md`](docs/reference/API_REFERENCE.md), [`docs/reference/PROVIDER_REFERENCE.md`](docs/reference/PROVIDER_REFERENCE.md), [`docs/openapi.yaml`](docs/openapi.yaml) |
|
||||
| Routing, resilience, and reasoning | [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md), [`docs/architecture/RESILIENCE_GUIDE.md`](docs/architecture/RESILIENCE_GUIDE.md), [`docs/routing/REASONING_REPLAY.md`](docs/routing/REASONING_REPLAY.md) |
|
||||
| Security | [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md), [`docs/security/COMPLIANCE.md`](docs/security/COMPLIANCE.md), [`docs/security/STEALTH_GUIDE.md`](docs/security/STEALTH_GUIDE.md) |
|
||||
| Platform features | [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md), [`docs/frameworks/A2A-SERVER.md`](docs/frameworks/A2A-SERVER.md), [`docs/frameworks/SKILLS.md`](docs/frameworks/SKILLS.md), [`docs/frameworks/MEMORY.md`](docs/frameworks/MEMORY.md) |
|
||||
| Releases and quality | [`docs/ops/RELEASE_CHECKLIST.md`](docs/ops/RELEASE_CHECKLIST.md), [`docs/architecture/QUALITY_GATES.md`](docs/architecture/QUALITY_GATES.md) |
|
||||
|
||||
1
changelog.d/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/usage/components/BudgetTab.tsx": 1028,
|
||||
"src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2148,
|
||||
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1109,
|
||||
"_rebaseline_2026_07_30_8916_quota_compact_layout": "PR #8916 (apoapostolov, feat/improve-provider-quota-layouts) own growth: src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx 1109->1153 (+44) adds Full/Compact layout toggle — LS_LAYOUT_MODE constant, LayoutMode type, layoutMode state, toggleLayoutMode callback, toggle button with icon. At existing filter/settings chokepoint. Not extractable without splitting state + toolbar away from data-fetching. Covered by tests/unit/quota-card-grid-compact-layout-8916.test.ts.",
|
||||
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1153,
|
||||
"src/app/api/providers/[id]/models/route.ts": 2250,
|
||||
"src/app/api/v1/models/catalog.ts": 1549,
|
||||
"src/lib/db/apiKeys.ts": 1529,
|
||||
@@ -413,6 +414,6 @@
|
||||
"_rebaseline_2026_07_28_8860_tokenrefresh_projectid": "PR #8860 (fix/antigravity-projectid-centralized) own test growth: tests/unit/token-refresh-service.test.ts 1311->1378 (+67 = 4 cases covering projectId discovery on the tokenRefresh.ts path — the Dashboard/health-check refresh route, which #8842 did not reach since that fixed the executor path). Covered by the same file.",
|
||||
"_rebaseline_2026_07_28_8861_xiaomi_token_plan": "PR #8861 (feat/xiaomi-token-plan-protocol-selector) own growth: EditConnectionModal.tsx 1283->1316 (+33 = the per-connection API-protocol selector field) and open-sse/executors/base.ts 1540->1562 (+22 = alternate-format resolution at the existing buildUrl/headers chokepoint). Both are irreducible wiring at existing call sites.",
|
||||
"_rebaseline_2026_07_28_8863_firefly_detail_level": "PR #8863 (fix/adobe-firefly-gpt-detail-level-max) own growth: adobeFireflyClient.ts 2317->2322 (+5 = gpt-image detailLevel defaulting to maximal at the existing payload-build site). Covered by tests/unit/adobe-firefly.test.ts.",
|
||||
"_rebaseline_2026_07_29_8281_home_quickstart_prefetch": "Release v3.8.49 base-red fix (no PR — captain sweep): src/app/(dashboard)/dashboard/HomePageClient.tsx 1377->1381 (+4). #8292 added prefetch={false} to the sidebar but left /home's five quick-start Links prefetching, so first paint still fired 12 speculative RSC requests — caught by navigation.spec.ts only after the e2e helper bug (APP_ROUTE_PATTERN missing /home) was repaired in the same cycle. Growth is the five prefetch attributes; it was offset first by extracting the repeated className literals (INLINE_LINK x4, DOCS_LINK x1), which collapsed five wrapped <Link> blocks back to one line each — a naive fix measured 1391. Guard: tests/unit/sidebar-prefetch-policy-8281.test.ts.",
|
||||
"_rebaseline_2026_07_29_8281_home_quickstart_prefetch": "Release v3.8.49 base-red fix (no PR — captain sweep): src/app/(dashboard)/dashboard/HomePageClient.tsx 1377->1381 (+4). #8292 added prefetch={false} to the sidebar but left /home's five quick-start Links prefetching, so first paint still fired 12 speculative RSC requests — caught by navigation.spec.ts only after the e2e helper bug (APP_ROUTE_PATTERN missing /home) was repaired in the same cycle. Growth is the five prefetch attributes; it was offset first by extracting the repeated className literals (INLINE_LINK x4, DOCS_LINK x1), which collapsed five wrapped <Link> blocks back to one line each — a naive fix measured 1391. Guard: tests/unit/sidebar-prefetch-policy-8281.test.ts.",
|
||||
"_rebaseline_2026_08_02_v3850_agentrouter_responses": "Release v3.8.50 AgentRouter/Codex compatibility reconciliation. open-sse/executors/base.ts 1562->1578: #9190 wires AgentRouter's selected Claude/OpenAI/Responses protocol through the existing executor URL, auth, identity-header and fingerprint chokepoints; the reusable alternate resolver remains outside base.ts. open-sse/utils/stream.ts 2887->2889: #9213 evaluates Responses ID and usage normalization independently so response.completed always receives finite usage.total_tokens instead of short-circuiting after an ID rewrite. tests/unit/chatcore-translation-paths.test.ts 2769->2776: #9191 updates the existing Claude-Code bridge assertions for the dynamic AgentRouter wire image. PR #9224 offsets its own chatCore growth by extracting the AgentRouter protocol decisions into chatCore/agentRouterProtocol.ts, leaving chatCore below its frozen ceiling. Covered by agentrouter executor/chatCore protocol tests, chatcore translation-path tests, and responses-commentary-passthrough tests."
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
* The claude→openai translator may emit reasoning_effort=max/xhigh when the
|
||||
* client sends output_config.effort=max on a Claude-shape request. Combined with
|
||||
* runtime alias remapping (e.g. claude-opus-4-6 → mimo/mimo-v2.5-pro), this
|
||||
* routes xhigh to OpenAI-shape providers that don't accept the value:
|
||||
* The claude→openai translator passes output_config.effort through verbatim
|
||||
* (including max) and only performs form conversion; provider-aware effort
|
||||
* policy is owned here. Combined with runtime alias remapping (e.g.
|
||||
* claude-opus-4-6 → mimo/mimo-v2.5-pro), this routes a client's effort value
|
||||
* to OpenAI-shape providers that don't accept it:
|
||||
*
|
||||
* xiaomi-mimo : low|medium|high only — 400 literal_error on xhigh
|
||||
* mistral : devstral models reject reasoning_effort entirely
|
||||
@@ -216,10 +217,7 @@ function writeEffortValue(
|
||||
}
|
||||
|
||||
/** Strip the effort field from every carrier that was present. */
|
||||
function stripEffortValue(
|
||||
b: Record<string, unknown>,
|
||||
c: EffortCarriers
|
||||
): Record<string, unknown> {
|
||||
function stripEffortValue(b: Record<string, unknown>, c: EffortCarriers): Record<string, unknown> {
|
||||
const next: Record<string, unknown> = { ...b };
|
||||
if (c.hasTopLevelReasoningEffort) delete next.reasoning_effort;
|
||||
if (c.hasReasoningEffort && c.reasoning) {
|
||||
|
||||
@@ -4651,12 +4651,7 @@ export async function handleChatCore({
|
||||
});
|
||||
if (streamReadiness.ok === false) {
|
||||
const { response: failureResponse, reason } = streamReadiness;
|
||||
const failure = {
|
||||
status: failureResponse.status,
|
||||
message: reason,
|
||||
code: streamReadiness.code,
|
||||
type: streamReadiness.type,
|
||||
};
|
||||
const { classificationReason, upstreamDiagnostic } = streamReadiness;
|
||||
trackPendingRequest(model, provider, connectionId, false);
|
||||
appendRequestLog({
|
||||
model,
|
||||
@@ -4668,7 +4663,11 @@ export async function handleChatCore({
|
||||
status: failureResponse.status,
|
||||
error: reason,
|
||||
providerRequest: finalBody || translatedBody,
|
||||
clientResponse: buildErrorBody(failureResponse.status, reason),
|
||||
clientResponse: buildErrorBody(
|
||||
failureResponse.status,
|
||||
classificationReason,
|
||||
upstreamDiagnostic ? { error: { message: upstreamDiagnostic } } : undefined
|
||||
),
|
||||
claudeCacheMeta: claudePromptCacheLogMeta,
|
||||
cacheSource: "upstream",
|
||||
});
|
||||
@@ -4680,6 +4679,7 @@ export async function handleChatCore({
|
||||
success: false,
|
||||
status: failureResponse.status,
|
||||
error: reason,
|
||||
classificationError: classificationReason,
|
||||
errorType: streamReadiness.type,
|
||||
errorCode: streamReadiness.code,
|
||||
response: failureResponse,
|
||||
|
||||
@@ -135,7 +135,17 @@ export function detectFormatFromEndpoint(body, endpointPath = "") {
|
||||
// Thin wrapper for call sites that only have the full request URL (not the bare endpoint
|
||||
// path chatCore already threads) — single source of truth stays detectFormatFromEndpoint.
|
||||
export function detectFormatFromUrl(body, requestUrl) {
|
||||
return detectFormatFromEndpoint(body, new URL(requestUrl).pathname);
|
||||
const rawUrl = typeof requestUrl === "string" ? requestUrl : "";
|
||||
let pathname = rawUrl;
|
||||
try {
|
||||
// Supplying a base URL keeps relative client endpoints (for example,
|
||||
// `/v1/messages`) valid while preserving pathname-only detection.
|
||||
pathname = new URL(rawUrl || "/", "http://omniroute.local").pathname;
|
||||
} catch {
|
||||
// Fall back to the raw value; detectFormatFromEndpoint is intentionally
|
||||
// safe for unknown or malformed paths.
|
||||
}
|
||||
return detectFormatFromEndpoint(body, pathname);
|
||||
}
|
||||
|
||||
// Detect request format from body structure
|
||||
@@ -193,7 +203,7 @@ export function detectFormat(body) {
|
||||
if (firstContent?.type === "text" && !body.model?.includes("/")) {
|
||||
// Could be Claude or OpenAI multimodal
|
||||
// Check for Claude-specific fields
|
||||
if (body.system || body.anthropic_version) {
|
||||
if (body.system || body.anthropic_version || body["anthropic-version"]) {
|
||||
return "claude";
|
||||
}
|
||||
// Check if image format is Claude (source.type) vs OpenAI (image_url.url)
|
||||
@@ -216,7 +226,7 @@ export function detectFormat(body) {
|
||||
|
||||
// If content is string, it's likely OpenAI (Claude also supports this)
|
||||
// Check for other Claude-specific indicators
|
||||
if (body.system !== undefined || body.anthropic_version) {
|
||||
if (body.system !== undefined || body.anthropic_version || body["anthropic-version"]) {
|
||||
return "claude";
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ function normalizeToolSchema(schema: unknown): Record<string, unknown> {
|
||||
function normalizeOpenAIReasoningEffort(effort: unknown): string | undefined {
|
||||
if (typeof effort !== "string") return undefined;
|
||||
const normalized = effort.toLowerCase();
|
||||
if (normalized === "max") return "xhigh";
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { HTTP_STATUS } from "../config/constants.ts";
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "./error.ts";
|
||||
|
||||
type StreamReadinessLogger = {
|
||||
debug?: (tag: string, message: string) => void;
|
||||
@@ -7,7 +8,18 @@ type StreamReadinessLogger = {
|
||||
|
||||
export type StreamReadinessResult =
|
||||
| { ok: true; response: Response }
|
||||
| { ok: false; response: Response; reason: string; code: string; type: string };
|
||||
| {
|
||||
ok: false;
|
||||
response: Response;
|
||||
/** Sanitized operator-facing context for logs and persisted diagnostics. */
|
||||
reason: string;
|
||||
/** Stable internal text for retry, quota, and account-health classification. */
|
||||
classificationReason: string;
|
||||
/** First non-empty sanitized message from an error-only SSE payload. */
|
||||
upstreamDiagnostic?: string;
|
||||
code: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
@@ -233,6 +245,7 @@ type StreamReadinessSignalState = {
|
||||
currentEvent: string;
|
||||
dataLines: string[];
|
||||
pendingLine: string;
|
||||
upstreamDiagnostic: string | null;
|
||||
};
|
||||
|
||||
function resetCurrentEvent(state: StreamReadinessSignalState): void {
|
||||
@@ -248,7 +261,23 @@ function processStreamReadinessEvent(state: StreamReadinessSignalState): boolean
|
||||
if (isPingEventType(eventType) || !data || data === "[DONE]") return false;
|
||||
|
||||
try {
|
||||
return hasNonPingStructuredPayload(JSON.parse(data), eventType);
|
||||
const payload: unknown = JSON.parse(data);
|
||||
if (
|
||||
!state.upstreamDiagnostic &&
|
||||
isRecord(payload) &&
|
||||
isErrorOnlyStructuredPayload(payload)
|
||||
) {
|
||||
const error = payload.error;
|
||||
const rawMessage =
|
||||
typeof error === "string"
|
||||
? error
|
||||
: isRecord(error) && typeof error.message === "string"
|
||||
? error.message
|
||||
: "";
|
||||
const diagnostic = sanitizeErrorMessage(rawMessage).trim();
|
||||
if (diagnostic) state.upstreamDiagnostic = diagnostic;
|
||||
}
|
||||
return hasNonPingStructuredPayload(payload, eventType);
|
||||
} catch {
|
||||
return data.length > 0;
|
||||
}
|
||||
@@ -294,6 +323,7 @@ export function hasStreamReadinessSignal(text: string): boolean {
|
||||
currentEvent: "",
|
||||
dataLines: [],
|
||||
pendingLine: "",
|
||||
upstreamDiagnostic: null,
|
||||
};
|
||||
if (appendStreamReadinessSignal(state, text)) return true;
|
||||
return finishStreamReadinessSignal(state);
|
||||
@@ -303,16 +333,18 @@ function createErrorResponse(
|
||||
status: number,
|
||||
message: string,
|
||||
code: string,
|
||||
type: string
|
||||
type: string,
|
||||
upstreamDiagnostic?: string
|
||||
): Response {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
JSON.stringify(
|
||||
buildErrorBody(
|
||||
status,
|
||||
message,
|
||||
type,
|
||||
code,
|
||||
},
|
||||
}),
|
||||
upstreamDiagnostic ? { error: { message: upstreamDiagnostic } } : undefined,
|
||||
{ code, type }
|
||||
)
|
||||
),
|
||||
{ status, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
@@ -385,6 +417,7 @@ export async function ensureStreamReadiness(
|
||||
currentEvent: "",
|
||||
dataLines: [],
|
||||
pendingLine: "",
|
||||
upstreamDiagnostic: null,
|
||||
};
|
||||
const startedAt = Date.now();
|
||||
const effectiveTimeoutMs = Math.max(0, Math.floor(options.timeoutMs));
|
||||
@@ -414,6 +447,7 @@ export async function ensureStreamReadiness(
|
||||
return {
|
||||
ok: false,
|
||||
reason,
|
||||
classificationReason: reason,
|
||||
code: "STREAM_READINESS_TIMEOUT",
|
||||
type: "stream_timeout",
|
||||
response: createErrorResponse(
|
||||
@@ -438,6 +472,7 @@ export async function ensureStreamReadiness(
|
||||
return {
|
||||
ok: false,
|
||||
reason,
|
||||
classificationReason: reason,
|
||||
code: "STREAM_READINESS_TIMEOUT",
|
||||
type: "stream_timeout",
|
||||
response: createErrorResponse(
|
||||
@@ -460,7 +495,11 @@ export async function ensureStreamReadiness(
|
||||
return { ok: true, response: buildReadyResponse() };
|
||||
}
|
||||
|
||||
const reason = "Stream ended before producing a non-ping SSE event";
|
||||
const classificationReason = "Stream ended before producing a non-ping SSE event";
|
||||
const upstreamDiagnostic = readinessState.upstreamDiagnostic || undefined;
|
||||
const reason = upstreamDiagnostic
|
||||
? `${classificationReason}: ${upstreamDiagnostic}`
|
||||
: classificationReason;
|
||||
options.log?.warn?.(
|
||||
"STREAM",
|
||||
`${reason} (${options.provider || "provider"}/${options.model || "unknown"})`
|
||||
@@ -468,13 +507,16 @@ export async function ensureStreamReadiness(
|
||||
return {
|
||||
ok: false,
|
||||
reason,
|
||||
classificationReason,
|
||||
...(upstreamDiagnostic ? { upstreamDiagnostic } : {}),
|
||||
code: "STREAM_EARLY_EOF",
|
||||
type: "stream_early_eof",
|
||||
response: createErrorResponse(
|
||||
HTTP_STATUS.BAD_GATEWAY,
|
||||
reason,
|
||||
classificationReason,
|
||||
"STREAM_EARLY_EOF",
|
||||
"stream_early_eof"
|
||||
"stream_early_eof",
|
||||
upstreamDiagnostic
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
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:
|
||||
// • STRICT (always blocking — exit 1 on drift): high-confidence, slow-moving counts
|
||||
// that historically caused the worst drift across README / AGENTS / docs.
|
||||
// that historically caused the worst drift across user-facing documentation.
|
||||
// - provider count (source of truth: docs/reference/PROVIDER_REFERENCE.md total,
|
||||
// which is auto-generated from src/shared/constants/providers.ts)
|
||||
// - i18n locale count (source of truth: config/i18n.json `locales`)
|
||||
@@ -259,14 +259,14 @@ export function buildChecks() {
|
||||
actual: readProviderTotal(),
|
||||
docKey: "providers",
|
||||
strict: true,
|
||||
files: ["README.md", "AGENTS.md", "CLAUDE.md"],
|
||||
files: ["README.md", "CLAUDE.md"],
|
||||
},
|
||||
{
|
||||
label: "i18n locales count",
|
||||
actual: countLocales(),
|
||||
docKey: "i18n locales",
|
||||
strict: true,
|
||||
files: ["docs/README.md", "docs/guides/I18N.md", "AGENTS.md"],
|
||||
files: ["docs/README.md", "docs/guides/I18N.md"],
|
||||
},
|
||||
...(() => {
|
||||
const f = readCodeFacts();
|
||||
@@ -317,19 +317,10 @@ export function buildChecks() {
|
||||
skipBefore: /(tools?|definitions?)\s*\(\s*$/i,
|
||||
skipAfter: /^\s*\(\d+ CLI/,
|
||||
},
|
||||
["README.md", "CLAUDE.md", "AGENTS.md", "docs/frameworks/MCP-SERVER.md"]
|
||||
),
|
||||
claim(f.mcpScopes, "MCP scopes", { pattern: /(\d+) scopes/gi }, [
|
||||
"README.md",
|
||||
"CLAUDE.md",
|
||||
"AGENTS.md",
|
||||
]),
|
||||
claim(
|
||||
f.cliTotal,
|
||||
"CLI tools",
|
||||
{ pattern: /(\d+) tools(?=\s*\(\d+ CLI)/gi },
|
||||
["README.md"]
|
||||
["README.md", "CLAUDE.md", "docs/frameworks/MCP-SERVER.md"]
|
||||
),
|
||||
claim(f.mcpScopes, "MCP scopes", { pattern: /(\d+) scopes/gi }, ["README.md", "CLAUDE.md"]),
|
||||
claim(f.cliTotal, "CLI tools", { pattern: /(\d+) tools(?=\s*\(\d+ CLI)/gi }, ["README.md"]),
|
||||
];
|
||||
})(),
|
||||
{
|
||||
|
||||
@@ -1076,7 +1076,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pinned Provider Quota Limits (compact, no filters) */}
|
||||
{/* Pinned Provider Quota Limits */}
|
||||
{pinProviderQuotaToHome && (
|
||||
<Suspense fallback={<CardSkeleton />}>
|
||||
<ProviderQuotaWidget
|
||||
|
||||
@@ -133,10 +133,55 @@ export default function ToolDetailClient({ toolId, category }: ToolDetailClientP
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (providerModels.length === 0) {
|
||||
const prefix =
|
||||
typeof conn.providerSpecificData?.prefix === "string" &&
|
||||
conn.providerSpecificData.prefix.trim()
|
||||
? conn.providerSpecificData.prefix.trim()
|
||||
: alias;
|
||||
const fallbackModels: Array<{ id: string; name: string }> = [];
|
||||
const addFallbackModel = (model: any) => {
|
||||
const id = typeof model?.id === "string" ? model.id.trim() : "";
|
||||
if (!id || fallbackModels.some((candidate) => candidate.id === id)) return;
|
||||
fallbackModels.push({
|
||||
id,
|
||||
name: typeof model?.name === "string" && model.name.trim() ? model.name.trim() : id,
|
||||
});
|
||||
};
|
||||
|
||||
if (typeof conn.defaultModel === "string" && conn.defaultModel.trim()) {
|
||||
addFallbackModel({ id: conn.defaultModel });
|
||||
}
|
||||
if (Array.isArray(conn.providerSpecificData?.customModels)) {
|
||||
conn.providerSpecificData.customModels.forEach(addFallbackModel);
|
||||
}
|
||||
if (fallbackModels.length === 0 && conn.testStatus === "active") {
|
||||
addFallbackModel({ id: "model-id", name: `${prefix}/model-id` });
|
||||
}
|
||||
|
||||
fallbackModels.forEach((model) => {
|
||||
const modelValue = `${prefix}/${model.id}`;
|
||||
if (seenModels.has(modelValue)) return;
|
||||
seenModels.add(modelValue);
|
||||
models.push({
|
||||
value: modelValue,
|
||||
label: modelValue,
|
||||
provider: conn.provider,
|
||||
alias: prefix,
|
||||
connectionName: conn.name,
|
||||
modelId: model.id,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const activeAliases = new Set(
|
||||
activeProviders.map((c) => PROVIDER_ID_TO_ALIAS[c.provider] || c.provider)
|
||||
activeProviders.flatMap((connection) => {
|
||||
const alias = PROVIDER_ID_TO_ALIAS[connection.provider] || connection.provider;
|
||||
const prefix = connection.providerSpecificData?.prefix;
|
||||
return typeof prefix === "string" && prefix.trim() ? [alias, prefix.trim()] : [alias];
|
||||
})
|
||||
);
|
||||
const activeProviderIds = new Set(activeProviders.map((c) => c.provider));
|
||||
dynamicModels.forEach((dm) => {
|
||||
|
||||
@@ -23,6 +23,7 @@ interface Props {
|
||||
quotaVisibility?: Record<string, { hidden?: string[] }>;
|
||||
onHideQuota?: (provider: string, quota: any) => void;
|
||||
onShowQuota?: (provider: string, quota: any) => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export default function QuotaCardGrid({
|
||||
@@ -44,9 +45,41 @@ export default function QuotaCardGrid({
|
||||
quotaVisibility,
|
||||
onHideQuota,
|
||||
onShowQuota,
|
||||
compact = false,
|
||||
}: Props) {
|
||||
if (connections.length === 0) return null;
|
||||
|
||||
const renderCard = (conn: (typeof connections)[number]) => (
|
||||
<QuotaCard
|
||||
key={conn.id}
|
||||
connection={conn}
|
||||
quota={quotaData[conn.id]}
|
||||
loading={!!loading[conn.id]}
|
||||
error={errors[conn.id] || null}
|
||||
refreshedAt={lastRefreshedAt[conn.id]}
|
||||
emailsVisible={emailsVisible}
|
||||
providerLabel={providerLabels[conn.provider] || conn.provider}
|
||||
onRefresh={() => onRefresh(conn.id, conn.provider)}
|
||||
onOpenCutoff={() => onOpenCutoff(conn)}
|
||||
onRedeemResetCredit={() => onRedeemResetCredit?.(conn.id, conn.provider)}
|
||||
onToggleActive={(nextActive) => onToggleActive(conn.id, nextActive)}
|
||||
togglingActive={togglingActiveId === conn.id}
|
||||
redeemingResetCredit={redeemingResetCreditId === conn.id}
|
||||
loadingResetCredits={loadingResetCreditsId === conn.id}
|
||||
quotaVisibility={quotaVisibility}
|
||||
onHideQuota={onHideQuota ? (q) => onHideQuota(conn.provider, q) : undefined}
|
||||
onShowQuota={onShowQuota ? (q) => onShowQuota(conn.provider, q) : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(17rem,1fr))] gap-3">
|
||||
{connections.map(renderCard)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Group connections by provider, preserving the order from sortedConnections.
|
||||
const groups = new Map<string, typeof connections>();
|
||||
for (const conn of connections) {
|
||||
@@ -66,28 +99,7 @@ export default function QuotaCardGrid({
|
||||
</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,280px),1fr))] gap-3">
|
||||
{conns.map((conn) => (
|
||||
<QuotaCard
|
||||
key={conn.id}
|
||||
connection={conn}
|
||||
quota={quotaData[conn.id]}
|
||||
loading={!!loading[conn.id]}
|
||||
error={errors[conn.id] || null}
|
||||
refreshedAt={lastRefreshedAt[conn.id]}
|
||||
emailsVisible={emailsVisible}
|
||||
providerLabel={providerLabels[conn.provider] || conn.provider}
|
||||
onRefresh={() => onRefresh(conn.id, conn.provider)}
|
||||
onOpenCutoff={() => onOpenCutoff(conn)}
|
||||
onOpenResetCredits={() => onOpenResetCredits?.(conn.id, conn.provider)}
|
||||
onToggleActive={(nextActive) => onToggleActive(conn.id, nextActive)}
|
||||
togglingActive={togglingActiveId === conn.id}
|
||||
redeemingResetCredit={redeemingResetCreditId === conn.id}
|
||||
loadingResetCredits={loadingResetCreditsId === conn.id}
|
||||
quotaVisibility={quotaVisibility}
|
||||
onHideQuota={onHideQuota ? (q) => onHideQuota(conn.provider, q) : undefined}
|
||||
onShowQuota={onShowQuota ? (q) => onShowQuota(conn.provider, q) : undefined}
|
||||
/>
|
||||
))}
|
||||
{conns.map(renderCard)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -44,6 +44,7 @@ const LS_PURCHASE_FILTER = "omniroute:limits:purchaseFilter";
|
||||
const LS_STATUS_FILTER = "omniroute:limits:statusFilter";
|
||||
const LS_ENV_FILTER = "omniroute:limits:envFilter";
|
||||
const LS_PROVIDER_FILTER = "omniroute:limits:providerFilter";
|
||||
const LS_LAYOUT_MODE = "omniroute:limits:layoutMode";
|
||||
|
||||
const MIN_FETCH_INTERVAL_MS = 30000;
|
||||
const QUOTA_BAR_GREEN_THRESHOLD = 50;
|
||||
@@ -51,6 +52,7 @@ const QUOTA_BAR_YELLOW_THRESHOLD = 20;
|
||||
|
||||
type PurchaseTypeKey = "all" | "oauth-free" | "oauth-sub" | "apikey";
|
||||
type StatusKey = "all" | "critical" | "alert" | "ok" | "empty";
|
||||
type LayoutMode = "full" | "compact";
|
||||
|
||||
const PURCHASE_TYPES: Array<{ key: PurchaseTypeKey; labelKey: string; fallback: string }> = [
|
||||
{ key: "all", labelKey: "purchaseAll", fallback: "All" },
|
||||
@@ -231,6 +233,10 @@ export default function ProviderLimits({
|
||||
if (typeof window === "undefined") return "all";
|
||||
return localStorage.getItem(LS_PROVIDER_FILTER) || "all";
|
||||
});
|
||||
const [layoutMode, setLayoutMode] = useState<LayoutMode>(() => {
|
||||
if (typeof window === "undefined") return "full";
|
||||
return localStorage.getItem(LS_LAYOUT_MODE) === "compact" ? "compact" : "full";
|
||||
});
|
||||
|
||||
const lastFetchTimeRef = useRef<Record<string, number>>({});
|
||||
const staleProbeRef = useRef<Record<string, number>>({});
|
||||
@@ -746,6 +752,18 @@ export default function ProviderLimits({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleLayoutMode = useCallback(() => {
|
||||
setLayoutMode((current) => {
|
||||
const next = current === "full" ? "compact" : "full";
|
||||
try {
|
||||
localStorage.setItem(LS_LAYOUT_MODE, next);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const renderInlineQuotaSummary = (quotas: any[]) => {
|
||||
if (!quotas || quotas.length === 0) return null;
|
||||
return (
|
||||
@@ -816,30 +834,55 @@ export default function ProviderLimits({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={refreshAll}
|
||||
disabled={refreshingAll}
|
||||
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg bg-bg-subtle border border-border text-text-main text-[13px] disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
title={
|
||||
autoRefreshIntervalMs > 0 ? tr("autoRefreshing", "Auto-refreshing") : t("refreshAll")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[16px] ${refreshingAll ? "animate-spin" : ""}`}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleLayoutMode}
|
||||
aria-pressed={layoutMode === "compact"}
|
||||
aria-label={
|
||||
layoutMode === "compact"
|
||||
? "Switch to full quota layout"
|
||||
: "Switch to compact quota layout"
|
||||
}
|
||||
title={
|
||||
layoutMode === "compact"
|
||||
? "Switch to full quota layout"
|
||||
: "Switch to compact quota layout"
|
||||
}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-bg-subtle border border-border text-text-main text-[13px] cursor-pointer"
|
||||
>
|
||||
{autoRefreshIntervalMs > 0 ? "schedule" : "refresh"}
|
||||
</span>
|
||||
{refreshingAll
|
||||
? tr("refreshing", "Refreshing")
|
||||
: autoRefreshIntervalMs > 0
|
||||
? `${tr("autoRefreshing", "Auto-refreshing")} ${formatAutoRefreshCountdown(
|
||||
Math.max(
|
||||
0,
|
||||
autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAtRef.current)
|
||||
)
|
||||
)}`
|
||||
: t("refreshAll")}
|
||||
</button>
|
||||
<span className="material-symbols-outlined text-[16px]" aria-hidden>
|
||||
{layoutMode === "compact" ? "view_agenda" : "grid_view"}
|
||||
</span>
|
||||
<span className="hidden sm:inline">
|
||||
{layoutMode === "compact" ? "Compact" : "Full"}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={refreshAll}
|
||||
disabled={refreshingAll}
|
||||
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg bg-bg-subtle border border-border text-text-main text-[13px] disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
title={
|
||||
autoRefreshIntervalMs > 0 ? tr("autoRefreshing", "Auto-refreshing") : t("refreshAll")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[16px] ${refreshingAll ? "animate-spin" : ""}`}
|
||||
>
|
||||
{autoRefreshIntervalMs > 0 ? "schedule" : "refresh"}
|
||||
</span>
|
||||
{refreshingAll
|
||||
? tr("refreshing", "Refreshing")
|
||||
: autoRefreshIntervalMs > 0
|
||||
? `${tr("autoRefreshing", "Auto-refreshing")} ${formatAutoRefreshCountdown(
|
||||
Math.max(
|
||||
0,
|
||||
autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAtRef.current)
|
||||
)
|
||||
)}`
|
||||
: t("refreshAll")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFilters && (
|
||||
@@ -1048,6 +1091,7 @@ export default function ProviderLimits({
|
||||
onShowQuota={handleShowQuota}
|
||||
redeemingResetCreditId={resetCreditRedemption.redeemingResetCreditId}
|
||||
loadingResetCreditsId={resetCreditRedemption.loadingResetCreditsId}
|
||||
compact={layoutMode === "compact"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,26 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Card from "@/shared/components/Card";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
|
||||
import QuotaMiniBar from "../dashboard/usage/components/ProviderLimits/QuotaMiniBar";
|
||||
import { PROVIDER_LABEL } from "../dashboard/usage/components/ProviderLimits/constants";
|
||||
import { translateUsageOrFallback } from "../dashboard/usage/components/ProviderLimits/i18nFallback";
|
||||
import { isProviderQuotaVisible } from "@/shared/utils/providerQuotaVisibility";
|
||||
import { parseQuotaData } from "../dashboard/usage/components/ProviderLimits/quotaParsing";
|
||||
import {
|
||||
formatCountdown,
|
||||
formatQuotaLabel,
|
||||
getBarColor,
|
||||
getQuotaRemainingPercentage,
|
||||
} from "../dashboard/usage/components/ProviderLimits/utils";
|
||||
|
||||
const PRIMARY_QUOTA_COUNT = 3;
|
||||
|
||||
type Connection = {
|
||||
id: string;
|
||||
provider: string;
|
||||
authType?: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
quotaVisible?: boolean;
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
type QuotaData = Record<string, any>;
|
||||
|
||||
interface ProviderQuotaWidgetProps {
|
||||
autoRefreshInterval?: number;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
function formatUpdatedAt(updatedAt: number | null): string | null {
|
||||
if (!updatedAt) return null;
|
||||
return new Date(updatedAt).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function formatAutoRefreshCountdown(ms: number): string {
|
||||
@@ -30,23 +50,172 @@ function formatAutoRefreshCountdown(ms: number): string {
|
||||
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function AutoRefreshButtonLabel({
|
||||
autoRefreshIntervalMs,
|
||||
lastRefreshAllAt,
|
||||
refreshingAll,
|
||||
tr,
|
||||
}: {
|
||||
autoRefreshIntervalMs: number;
|
||||
lastRefreshAllAt: number;
|
||||
refreshingAll: boolean;
|
||||
tr: (key: string, fallback: string) => string;
|
||||
}) {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
function QuotaRow({ quota }: { quota: any }) {
|
||||
const t = useTranslations("usage");
|
||||
const percentage = Math.round(getQuotaRemainingPercentage(quota));
|
||||
const colors = getBarColor(percentage);
|
||||
const label = quota.displayName || formatQuotaLabel(quota.name) || quota.name;
|
||||
const reset = formatCountdown(quota.resetAt);
|
||||
|
||||
if (quota.isCredits || quota.isResetCredits) {
|
||||
const amount = Number(quota.creditCount ?? quota.remaining ?? 0).toLocaleString(undefined, {
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
return (
|
||||
<div className="flex min-w-0 items-center justify-between gap-3 py-1.5">
|
||||
<span className="min-w-0 truncate text-xs font-medium text-text-main">{label}</span>
|
||||
<span className="shrink-0 text-xs font-bold tabular-nums" style={{ color: colors.text }}>
|
||||
{amount}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1 py-1.5" title={quota.modelKey || quota.name}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="min-w-0 truncate text-xs font-medium text-text-main">{label}</span>
|
||||
<span className="shrink-0 text-xs font-bold tabular-nums" style={{ color: colors.text }}>
|
||||
{quota.unlimited
|
||||
? "∞"
|
||||
: translateUsageOrFallback(t, "percentLeft", `${percentage}% left`, {
|
||||
pct: percentage,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{!quota.unlimited && <QuotaMiniBar percent={percentage} />}
|
||||
{reset && <span className="text-[10px] text-text-muted">⏱ {reset}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionQuotas({ connection, cache }: { connection: Connection; cache: any }) {
|
||||
const t = useTranslations("usage");
|
||||
const [showOptional, setShowOptional] = useState(false);
|
||||
const quotas = useMemo(
|
||||
() => parseQuotaData(connection.provider, cache),
|
||||
[cache, connection.provider]
|
||||
);
|
||||
const primaryQuotas = quotas.slice(0, PRIMARY_QUOTA_COUNT);
|
||||
const optionalQuotas = quotas.slice(PRIMARY_QUOTA_COUNT);
|
||||
const accountLabel = connection.name || connection.displayName || connection.email;
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
{accountLabel && <p className="mb-1 text-[11px] text-text-muted truncate">{accountLabel}</p>}
|
||||
{quotas.length === 0 ? (
|
||||
<p className="py-1.5 text-xs italic text-text-muted">
|
||||
{cache?.message || t("noQuotaData")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-x-6 sm:grid-cols-2">
|
||||
{primaryQuotas.map((quota, index) => (
|
||||
<div
|
||||
key={`${quota.name}-${quota.modelKey || ""}-${index}`}
|
||||
className="border-b border-border/40"
|
||||
>
|
||||
<QuotaRow quota={quota} />
|
||||
</div>
|
||||
))}
|
||||
{showOptional &&
|
||||
optionalQuotas.map((quota, index) => (
|
||||
<div
|
||||
key={`${quota.name}-${quota.modelKey || ""}-${index}`}
|
||||
className="border-b border-border/40"
|
||||
>
|
||||
<QuotaRow quota={quota} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{optionalQuotas.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOptional((current) => !current)}
|
||||
className="mt-2 inline-flex items-center gap-1 rounded-md border border-border bg-bg-subtle px-2 py-1 text-[11px] font-medium text-text-main hover:bg-surface transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]" aria-hidden="true">
|
||||
{showOptional ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
{showOptional
|
||||
? t("showLessQuotas")
|
||||
: t("showMoreQuotas", { count: optionalQuotas.length })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProviderQuotaWidget({
|
||||
autoRefreshInterval = 0,
|
||||
compact = false,
|
||||
}: ProviderQuotaWidgetProps) {
|
||||
const t = useTranslations("usage");
|
||||
const tr = useCallback(
|
||||
(key: string, fallback: string) => translateUsageOrFallback(t, key, fallback),
|
||||
[t]
|
||||
);
|
||||
const [connections, setConnections] = useState<Connection[]>([]);
|
||||
const [quotaData, setQuotaData] = useState<QuotaData>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshingAll, setRefreshingAll] = useState(false);
|
||||
const [updatedAt, setUpdatedAt] = useState<number | null>(null);
|
||||
const refreshingAllRef = useRef(false);
|
||||
const lastRefreshAllAtRef = useRef(Date.now());
|
||||
const autoRefreshIntervalMs = autoRefreshInterval > 0 ? autoRefreshInterval * 1000 : 0;
|
||||
const [autoRefreshClock, setAutoRefreshClock] = useState(() => Date.now());
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [connectionsResponse, quotasResponse] = await Promise.all([
|
||||
fetch("/api/providers/client"),
|
||||
fetch("/api/usage/provider-limits"),
|
||||
]);
|
||||
const connectionData = connectionsResponse.ok ? await connectionsResponse.json() : {};
|
||||
const quotaResponseData = quotasResponse.ok ? await quotasResponse.json() : {};
|
||||
const relevant = ((connectionData.connections || []) as Connection[]).filter(
|
||||
(connection) =>
|
||||
USAGE_SUPPORTED_PROVIDERS.includes(connection.provider) &&
|
||||
(connection.authType === "oauth" || connection.authType === "apikey")
|
||||
);
|
||||
setConnections(relevant);
|
||||
setQuotaData(quotaResponseData.caches || {});
|
||||
setUpdatedAt(Date.now());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoRefreshIntervalMs <= 0 || refreshingAll) return;
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const tick = () => setNow(Date.now());
|
||||
const refreshAll = useCallback(async () => {
|
||||
if (refreshingAllRef.current) return;
|
||||
refreshingAllRef.current = true;
|
||||
const now = Date.now();
|
||||
lastRefreshAllAtRef.current = now;
|
||||
setAutoRefreshClock(now);
|
||||
setRefreshingAll(true);
|
||||
try {
|
||||
const response = await fetch("/api/usage/provider-limits", { method: "POST" });
|
||||
if (!response.ok) throw new Error("Failed to refresh provider quotas");
|
||||
const data = await response.json();
|
||||
setQuotaData(data.caches || {});
|
||||
setUpdatedAt(Date.now());
|
||||
} catch (error) {
|
||||
console.error("ProviderQuotaWidget refreshAll error:", error);
|
||||
} finally {
|
||||
refreshingAllRef.current = false;
|
||||
setRefreshingAll(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoRefreshIntervalMs <= 0) return;
|
||||
|
||||
const tick = () => setAutoRefreshClock(Date.now());
|
||||
tick();
|
||||
|
||||
const timer = window.setInterval(tick, 1000);
|
||||
@@ -59,248 +228,131 @@ export function AutoRefreshButtonLabel({
|
||||
window.clearInterval(timer);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [autoRefreshIntervalMs, refreshingAll, lastRefreshAllAt]);
|
||||
|
||||
if (refreshingAll) {
|
||||
return <>{tr("refreshing", "Refreshing")}</>;
|
||||
}
|
||||
|
||||
if (autoRefreshIntervalMs <= 0) {
|
||||
return <>{tr("refreshAll", "Refresh All")}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{tr("autoRefreshing", "Auto-refreshing")}{" "}
|
||||
{formatAutoRefreshCountdown(Math.max(0, autoRefreshIntervalMs - (now - lastRefreshAllAt)))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProviderQuotaWidget({ autoRefreshInterval = 0 }: ProviderQuotaWidgetProps) {
|
||||
const t = useTranslations("usage");
|
||||
const tr = useCallback(
|
||||
(key: string, fallback: string) => translateUsageOrFallback(t, key, fallback),
|
||||
[t]
|
||||
);
|
||||
|
||||
const [connections, setConnections] = useState<Connection[]>([]);
|
||||
const [quotaData, setQuotaData] = useState<QuotaData>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshingAll, setRefreshingAll] = useState(false);
|
||||
|
||||
const refreshingAllRef = useRef(false);
|
||||
const lastRefreshAllAtRef = useRef(Date.now());
|
||||
const [lastRefreshAllAt, setLastRefreshAllAt] = useState(() => lastRefreshAllAtRef.current);
|
||||
const autoRefreshIntervalMs = autoRefreshInterval > 0 ? autoRefreshInterval * 1000 : 0;
|
||||
|
||||
const fetchConnections = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/providers/client");
|
||||
if (!res.ok) throw new Error("Failed to load connections");
|
||||
const data = await res.json();
|
||||
return (data.connections || []) as Connection[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchCached = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/usage/provider-limits");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const data = await res.json();
|
||||
return data.caches || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const [conns, caches] = await Promise.all([fetchConnections(), fetchCached()]);
|
||||
|
||||
// Only keep connections that are usage/quota supported
|
||||
const relevant = conns.filter(
|
||||
(c) =>
|
||||
isProviderQuotaVisible(c) &&
|
||||
USAGE_SUPPORTED_PROVIDERS.includes(c.provider) &&
|
||||
(c.authType === "oauth" || c.authType === "apikey")
|
||||
);
|
||||
|
||||
setConnections(relevant);
|
||||
setQuotaData(caches);
|
||||
setLoading(false);
|
||||
}, [fetchConnections, fetchCached]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
if (refreshingAllRef.current) return;
|
||||
refreshingAllRef.current = true;
|
||||
const now = Date.now();
|
||||
lastRefreshAllAtRef.current = now;
|
||||
setLastRefreshAllAt(now);
|
||||
setRefreshingAll(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/usage/provider-limits", { method: "POST" });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || "Refresh failed");
|
||||
}
|
||||
const data = await res.json();
|
||||
setQuotaData(data.caches || {});
|
||||
} catch (e) {
|
||||
console.error("ProviderQuotaWidget refreshAll error:", e);
|
||||
} finally {
|
||||
refreshingAllRef.current = false;
|
||||
setRefreshingAll(false);
|
||||
}
|
||||
}, []);
|
||||
}, [autoRefreshIntervalMs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoRefreshIntervalMs <= 0) return;
|
||||
if (document.visibilityState !== "visible") return;
|
||||
if (refreshingAllRef.current) return;
|
||||
|
||||
const maybeRefresh = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
if (refreshingAllRef.current) return;
|
||||
if (Date.now() - lastRefreshAllAtRef.current >= autoRefreshIntervalMs) {
|
||||
void refreshAll();
|
||||
}
|
||||
};
|
||||
if (autoRefreshClock - lastRefreshAllAtRef.current >= autoRefreshIntervalMs) {
|
||||
void refreshAll();
|
||||
}
|
||||
}, [autoRefreshClock, autoRefreshIntervalMs, refreshAll]);
|
||||
|
||||
maybeRefresh();
|
||||
const timer = window.setInterval(maybeRefresh, 1000);
|
||||
const handleVisibilityChange = () => maybeRefresh();
|
||||
const providerGroups = useMemo(() => {
|
||||
const groups = new Map<string, Connection[]>();
|
||||
for (const connection of connections) {
|
||||
const group = groups.get(connection.provider) || [];
|
||||
group.push(connection);
|
||||
groups.set(connection.provider, group);
|
||||
}
|
||||
return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||
}, [connections]);
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [autoRefreshIntervalMs, refreshAll]);
|
||||
|
||||
// Simple summary: group by provider for display
|
||||
const providerGroups = connections.reduce<Record<string, Connection[]>>((acc, conn) => {
|
||||
if (!acc[conn.provider]) acc[conn.provider] = [];
|
||||
acc[conn.provider].push(conn);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const providerEntries = Object.entries(providerGroups).sort(([a], [b]) => a.localeCompare(b));
|
||||
const updatedLabel = formatUpdatedAt(updatedAt);
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
{/* Header with title + Refresh All in upper right */}
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3 bg-surface/60">
|
||||
<Card className="w-full overflow-hidden">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border bg-surface/60 px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-primary text-[20px]">
|
||||
<span className="material-symbols-outlined text-[20px] text-primary" aria-hidden="true">
|
||||
account_balance
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="font-semibold text-base">{tr("providerQuota", "Provider Quota")}</h3>
|
||||
<p className="text-[11px] text-text-muted -mt-0.5">
|
||||
{tr("providerQuotaHomeHint", "Live status across connected accounts")}
|
||||
</p>
|
||||
<h2 className="text-base font-semibold text-text-main">
|
||||
{tr("providerQuota", "Provider Quota")}
|
||||
</h2>
|
||||
{updatedLabel && (
|
||||
<p className="text-[11px] text-text-muted">
|
||||
{tr("updatedShort", "Updated")} {updatedLabel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={refreshAll}
|
||||
disabled={refreshingAll || loading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-bg-subtle text-xs font-medium text-text-main disabled:opacity-50 disabled:cursor-not-allowed hover:bg-surface transition-colors"
|
||||
title={
|
||||
autoRefreshIntervalMs > 0
|
||||
? tr("autoRefreshing", "Auto-refreshing")
|
||||
: tr("refreshAll", "Refresh All")
|
||||
}
|
||||
disabled={loading || refreshingAll}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-bg-subtle px-3 py-1.5 text-xs font-medium text-text-main transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[16px] ${refreshingAll ? "animate-spin" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{autoRefreshIntervalMs > 0 ? "schedule" : "refresh"}
|
||||
</span>
|
||||
<span>
|
||||
<AutoRefreshButtonLabel
|
||||
autoRefreshIntervalMs={autoRefreshIntervalMs}
|
||||
lastRefreshAllAt={lastRefreshAllAt}
|
||||
refreshingAll={refreshingAll}
|
||||
tr={tr}
|
||||
/>
|
||||
</span>
|
||||
{refreshingAll
|
||||
? tr("refreshing", "Refreshing")
|
||||
: autoRefreshIntervalMs > 0
|
||||
? `${tr("autoRefreshing", "Auto-refreshing")} ${formatAutoRefreshCountdown(
|
||||
Math.max(
|
||||
0,
|
||||
autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAtRef.current)
|
||||
)
|
||||
)}`
|
||||
: tr("forceRefresh", "Refresh now")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8 text-text-muted text-sm">
|
||||
<span className="material-symbols-outlined animate-spin mr-2">progress_activity</span>
|
||||
{tr("loadingQuotas", "Loading...")}
|
||||
</div>
|
||||
) : providerEntries.length === 0 ? (
|
||||
<div className="text-center py-6 text-sm text-text-muted">
|
||||
{tr("noProviders", "No Providers Connected")}
|
||||
<div className="mt-1 text-xs">
|
||||
{tr(
|
||||
"connectProvidersForQuota",
|
||||
"Connect to providers with OAuth to track your API quota limits and usage."
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{providerEntries.map(([provider, conns]) => {
|
||||
const firstConn = conns[0];
|
||||
const cache = quotaData[firstConn?.id];
|
||||
const hasQuota = cache?.quotas && Object.keys(cache.quotas).length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={provider}
|
||||
className="rounded-lg border border-border bg-surface/40 p-3 flex flex-col gap-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon providerId={provider} size={18} />
|
||||
<span className="font-medium text-sm truncate">
|
||||
{provider.charAt(0).toUpperCase() + provider.slice(1)}
|
||||
</span>
|
||||
<span className="text-[10px] text-text-muted ml-auto tabular-nums">
|
||||
{conns.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hasQuota ? (
|
||||
<div className="text-xs text-text-muted" title={tr("details", "Details")}>
|
||||
{Object.keys(cache.quotas).length}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={refreshAll}
|
||||
className="text-left text-xs text-amber-600 dark:text-amber-500 hover:underline"
|
||||
>
|
||||
{tr("refreshAll", "Refresh All")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Future: embed small QuotaProgressBar for the primary window here */}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 text-[11px] text-right text-text-muted">
|
||||
<a href="/dashboard/usage?tab=limits" className="hover:text-primary hover:underline">
|
||||
{tr("viewDetails", "View details")}
|
||||
<span aria-hidden="true"> →</span>
|
||||
</a>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 px-4 py-8 text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin text-[16px]" aria-hidden="true">
|
||||
progress_activity
|
||||
</span>
|
||||
{tr("loadingQuotas", "Loading...")}
|
||||
</div>
|
||||
</div>
|
||||
) : providerGroups.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-text-muted">
|
||||
{tr("noProviders", "No Providers Connected")}
|
||||
</div>
|
||||
) : compact ? (
|
||||
/* Compact mode: 3-column card grid, flat across all connections */
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3 p-4">
|
||||
{connections.map((connection) => (
|
||||
<div key={connection.id} className="border border-border rounded-lg p-3 bg-bg-subtle">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<ProviderIcon providerId={connection.provider} size={16} />
|
||||
<span className="text-xs font-semibold text-text-main truncate">
|
||||
{PROVIDER_LABEL[connection.provider] || connection.provider}
|
||||
</span>
|
||||
</div>
|
||||
<ConnectionQuotas connection={connection} cache={quotaData[connection.id]} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{providerGroups.map(([provider, providerConnections]) => (
|
||||
<section
|
||||
key={provider}
|
||||
className="grid grid-cols-1 gap-4 px-4 py-4 lg:grid-cols-[12rem_minmax(0,1fr)]"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2 lg:items-start">
|
||||
<ProviderIcon providerId={provider} size={20} />
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-sm font-semibold text-text-main">
|
||||
{PROVIDER_LABEL[provider] || provider}
|
||||
</h3>
|
||||
<p className="text-[11px] text-text-muted">
|
||||
{providerConnections.length}{" "}
|
||||
{providerConnections.length === 1 ? "account" : "accounts"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{providerConnections.map((connection) => (
|
||||
<ConnectionQuotas
|
||||
key={connection.id}
|
||||
connection={connection}
|
||||
cache={quotaData[connection.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ function getNextSortOrder() {
|
||||
export async function getCombos(limit?: number, offset?: number) {
|
||||
const db = getDbInstance();
|
||||
let sql =
|
||||
"SELECT data, sort_order, context_cache_protection FROM combos ORDER BY sort_order ASC, name COLLATE NOCASE ASC";
|
||||
"SELECT id, data, sort_order, context_cache_protection FROM combos ORDER BY sort_order ASC, name COLLATE NOCASE ASC";
|
||||
const params: unknown[] = [];
|
||||
if (limit !== undefined) {
|
||||
sql += " LIMIT ? OFFSET ?";
|
||||
|
||||
@@ -66,6 +66,23 @@ const QUOTA_PATTERNS: ReadonlyArray<RegExp> = [
|
||||
// the 429 is misclassified as transient rate_limit and retried every
|
||||
// ~60s against a budget that only resets at UTC midnight.
|
||||
/daily free allocation/i,
|
||||
|
||||
// Modal-hosted OpenAI-compatible endpoints (e.g. self-hosted Kimi K3).
|
||||
// Body: {"error":"usage limit reached"}, no nested "message"/"quota"/
|
||||
// "daily" wording. Without this pattern the 429 falls through to
|
||||
// "rate_limit" (short cooldown), so combo round-robin's per-conversation
|
||||
// session stickiness (#3825) keeps re-targeting the same exhausted
|
||||
// connection every turn instead of a long lockout that lets the sticky
|
||||
// target fail over to another account.
|
||||
//
|
||||
// Matches the "error" JSON key with "usage limit reached" as its value.
|
||||
// Extra sibling fields (e.g. {"error":"usage limit reached", "code":"..."})
|
||||
// still match. A different key like {"detail":"..."} or a qualified value
|
||||
// like {"error":"Per-minute usage limit reached"} does NOT match. Bare
|
||||
// string bodies without a JSON wrapper also do NOT match.
|
||||
// Trailing punctuation/whitespace before the closing quote is tolerated
|
||||
// because real API responses may include a period or trailing space.
|
||||
/"error"\s*:\s*"usage limit reached[.\s]*"/i,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -77,6 +77,7 @@ import {
|
||||
import {
|
||||
isAntigravityMissingProjectError,
|
||||
PROVIDER_BREAKER_FAILURE_STATUSES,
|
||||
resolveStreamReadinessClassificationError,
|
||||
shouldTripProviderBreakerForResult,
|
||||
} from "./chatPredicates";
|
||||
import { connectionHasExtraKeys } from "@omniroute/open-sse/services/apiKeyRotator.ts";
|
||||
@@ -1491,10 +1492,9 @@ async function handleSingleModelChat(
|
||||
return result.response;
|
||||
}
|
||||
|
||||
// Missing Cloud Code project assignment is an account configuration error, not a
|
||||
// transient upstream/account failure. Preserve the executor's typed fail-closed 422;
|
||||
// marking the connection unavailable here would trigger cooldown redispatch and repeat
|
||||
// bootstrap within the same logical request.
|
||||
// Missing Cloud Code project assignment is configuration, not a transient failure.
|
||||
// Preserve the typed fail-closed 422; marking it unavailable would trigger cooldown
|
||||
// redispatch and repeat bootstrap within the same logical request.
|
||||
if (isAntigravityMissingProjectError(provider, result)) {
|
||||
return withSelectedConnectionHeader(result.response, credentials.connectionId);
|
||||
}
|
||||
@@ -1537,10 +1537,11 @@ async function handleSingleModelChat(
|
||||
}
|
||||
|
||||
if (isAntigravityStreamReadinessFailure) {
|
||||
const classificationError = resolveStreamReadinessClassificationError(result);
|
||||
const { shouldFallback, cooldownMs } = await markAccountUnavailable(
|
||||
credentials.connectionId,
|
||||
result.status || HTTP_STATUS.BAD_GATEWAY,
|
||||
result.error || result.errorCode || "Antigravity stream ended before useful content",
|
||||
classificationError,
|
||||
provider,
|
||||
model,
|
||||
providerProfile,
|
||||
@@ -1570,13 +1571,12 @@ async function handleSingleModelChat(
|
||||
}
|
||||
}
|
||||
excludedConnectionIds.add(credentials.connectionId);
|
||||
lastError = result.error;
|
||||
lastError = classificationError;
|
||||
lastStatus = result.status;
|
||||
requestRetryLastError = result.error;
|
||||
requestRetryLastError = classificationError;
|
||||
requestRetryLastStatus = result.status;
|
||||
continue;
|
||||
}
|
||||
|
||||
return withSelectedConnectionHeader(result.response, credentials?.connectionId);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,3 +32,22 @@ export function isAntigravityMissingProjectError(
|
||||
result.errorType === "oauth_missing_project_id"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep stream-readiness routing decisions on the stable gate diagnostic.
|
||||
* The operator-facing error can contain arbitrary upstream words such as
|
||||
* "quota" or "retry after", which must not change account/combo classification.
|
||||
*/
|
||||
export function resolveStreamReadinessClassificationError(
|
||||
result: {
|
||||
classificationError?: unknown;
|
||||
error?: unknown;
|
||||
errorCode?: unknown;
|
||||
},
|
||||
fallback = "Antigravity stream ended before useful content"
|
||||
): string {
|
||||
for (const value of [result.classificationError, result.error, result.errorCode]) {
|
||||
if (typeof value === "string" && value.trim()) return value;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@@ -291,6 +291,7 @@
|
||||
"tests/unit/sse-auth-antigravity-credits.test.ts",
|
||||
"tests/unit/sse-auth-resource-404.test.ts",
|
||||
"tests/unit/sse-auth.test.ts",
|
||||
"tests/unit/stream-readiness.test.ts",
|
||||
"tests/unit/strict-random-deck.test.ts",
|
||||
"tests/unit/strip-reasoning-header.test.ts",
|
||||
"tests/unit/system-role-extraction.test.ts",
|
||||
|
||||
@@ -3,6 +3,8 @@ import assert from "node:assert/strict";
|
||||
|
||||
const { sanitizeReasoningEffortForProvider } = await import("../../open-sse/executors/base.ts");
|
||||
const { DefaultExecutor } = await import("../../open-sse/executors/default.ts");
|
||||
const { translateRequest } = await import("../../open-sse/translator/index.ts");
|
||||
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
|
||||
|
||||
function makeLog() {
|
||||
const messages: Array<[string, string]> = [];
|
||||
@@ -102,6 +104,45 @@ test("sanitizeReasoningEffortForProvider: Ollama Cloud preserves max", () => {
|
||||
assert.equal(log.messages.length, 0);
|
||||
});
|
||||
|
||||
test("end-to-end: Anthropic output_config.effort=max reaches Ollama Cloud as max (not xhigh)", () => {
|
||||
// Bug: the claude→openai translator previously normalized max → xhigh, and the
|
||||
// sanitizer could not recover the original intent because the carrier was already
|
||||
// xhigh. Ollama Cloud accepts max literally but rejects xhigh (HTTP 400).
|
||||
// The translator must pass max through verbatim and the sanitizer must keep it.
|
||||
const translated = translateRequest(
|
||||
FORMATS.CLAUDE,
|
||||
FORMATS.OPENAI,
|
||||
"gemma4:31b",
|
||||
{
|
||||
model: "gemma4:31b",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
output_config: { effort: "max" },
|
||||
},
|
||||
false,
|
||||
null,
|
||||
"ollama-cloud"
|
||||
) as Record<string, unknown>;
|
||||
|
||||
assert.equal(
|
||||
translated.reasoning_effort,
|
||||
"max",
|
||||
"translator must pass max through verbatim instead of rewriting it to xhigh"
|
||||
);
|
||||
|
||||
const sanitized = sanitizeReasoningEffortForProvider(
|
||||
translated,
|
||||
"ollama-cloud",
|
||||
"gemma4:31b",
|
||||
null
|
||||
) as Record<string, unknown>;
|
||||
|
||||
assert.equal(
|
||||
sanitized.reasoning_effort,
|
||||
"max",
|
||||
"Ollama Cloud accepts max literally — no downgrade, no rewrite to xhigh"
|
||||
);
|
||||
});
|
||||
|
||||
test("sanitizeReasoningEffortForProvider: Ollama Cloud preserves nested max", () => {
|
||||
const body = {
|
||||
model: "glm-5.2",
|
||||
|
||||
@@ -46,7 +46,7 @@ const strictCheck = {
|
||||
actual: 226,
|
||||
docKey: "providers",
|
||||
strict: true,
|
||||
files: ["README.md", "AGENTS.md"],
|
||||
files: ["README.md", "CLAUDE.md"],
|
||||
};
|
||||
|
||||
test("no drift when every file mentions the real count", () => {
|
||||
@@ -55,11 +55,11 @@ test("no drift when every file mentions the real count", () => {
|
||||
assert.equal(soft, 0);
|
||||
});
|
||||
|
||||
test("STRICT drift is counted when a file omits the real count", () => {
|
||||
test("STRICT drift is counted when a user-facing document omits the real count", () => {
|
||||
const { strict, soft } = tally([strictCheck], (f) =>
|
||||
f === "README.md" ? "we have 226 providers" : "we have 177 providers"
|
||||
);
|
||||
assert.equal(strict, 1, "AGENTS.md (177) should register one strict drift");
|
||||
assert.equal(strict, 1, "CLAUDE.md (177) should register one strict drift");
|
||||
assert.equal(soft, 0);
|
||||
});
|
||||
|
||||
|
||||
@@ -164,6 +164,63 @@ test("looksLikeQuotaExhausted: rejects empty / null / non-quota text", () => {
|
||||
assert.equal(looksLikeQuotaExhausted("server error 500"), false);
|
||||
});
|
||||
|
||||
test("classify429: Modal-hosted endpoint 'usage limit reached' body returns 'quota_exhausted'", () => {
|
||||
// Real body observed from a self-hosted Modal OpenAI-compatible endpoint:
|
||||
// {"error":"usage limit reached"} - a bare string value, no "message"/
|
||||
// "daily"/"quota" wording, so none of the prior patterns matched and the
|
||||
// 429 fell through to a 60s rate_limit cooldown. Combo round-robin's
|
||||
// per-conversation session stickiness (#3825) then kept re-targeting the
|
||||
// same exhausted connection on every turn of a long-running session.
|
||||
const body = { error: "usage limit reached" };
|
||||
assert.equal(looksLikeQuotaExhausted(body), true);
|
||||
assert.equal(classify429({ status: 429, body }), "quota_exhausted");
|
||||
assert.equal(classify429({ status: 429, body: JSON.stringify(body) }), "quota_exhausted");
|
||||
// Case variation must also match.
|
||||
assert.equal(
|
||||
classify429({ status: 429, body: { error: "USAGE LIMIT REACHED" } }),
|
||||
"quota_exhausted"
|
||||
);
|
||||
// Whitespace around JSON object must also match (bodyToText does not trim).
|
||||
assert.equal(
|
||||
classify429({ status: 429, body: ' { "error" : "usage limit reached" } ' }),
|
||||
"quota_exhausted"
|
||||
);
|
||||
// Extra sibling fields must still match.
|
||||
assert.equal(
|
||||
classify429({
|
||||
status: 429,
|
||||
body: { error: "usage limit reached", code: "RESOURCE_EXHAUSTED" },
|
||||
}),
|
||||
"quota_exhausted"
|
||||
);
|
||||
// Trailing punctuation/whitespace must still match.
|
||||
assert.equal(classify429({ status: 429, body: { error: "usage limit reached." } }), "quota_exhausted");
|
||||
assert.equal(classify429({ status: 429, body: { error: "usage limit reached " } }), "quota_exhausted");
|
||||
});
|
||||
|
||||
test("classify429: qualified transient 'usage limit reached' messages stay rate_limit", () => {
|
||||
// The Modal pattern requires the "error" JSON key with exactly "usage
|
||||
// limit reached" as its value - anything else is a transient rate limit
|
||||
// and must NOT be locked out long-term.
|
||||
assert.equal(
|
||||
classify429({ status: 429, body: "Per-minute usage limit reached, retry in 60s." }),
|
||||
"rate_limit"
|
||||
);
|
||||
assert.equal(
|
||||
classify429({ status: 429, body: { error: { message: "RPM usage limit reached" } } }),
|
||||
"rate_limit"
|
||||
);
|
||||
// Bare string body (no JSON "error" key) must NOT match.
|
||||
assert.equal(classify429({ status: 429, body: "usage limit reached" }), "rate_limit");
|
||||
// Different JSON key (not "error") must NOT match.
|
||||
assert.equal(classify429({ status: 429, body: { detail: "usage limit reached" } }), "rate_limit");
|
||||
// Qualified value under the "error" key must NOT match.
|
||||
assert.equal(
|
||||
classify429({ status: 429, body: { error: "Per-minute usage limit reached" } }),
|
||||
"rate_limit"
|
||||
);
|
||||
});
|
||||
|
||||
test("ambiguous 'daily rate limit' messages classify as quota_exhausted (intentional)", () => {
|
||||
// Codex audit LOW: messages combining 'daily' or 'monthly' with 'limit'
|
||||
// match the quota regex even when paired with 'rate'. This is intentional
|
||||
|
||||
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,
|
||||
hasUsefulStreamContent,
|
||||
} from "../../open-sse/utils/streamReadiness.ts";
|
||||
import { checkFallbackError } from "../../open-sse/services/accountFallback.ts";
|
||||
import { resolveStreamReadinessClassificationError } from "../../src/sse/handlers/chatPredicates.ts";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
@@ -576,7 +578,93 @@ test("ensureStreamReadiness returns 502 when stream ends without a non-ping SSE
|
||||
|
||||
const result = await ensureStreamReadiness(response, { timeoutMs: 100 });
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) assert.fail("keepalive-only SSE payload must remain a readiness failure");
|
||||
assert.equal(result.response.status, 502);
|
||||
assert.equal(result.reason, "Stream ended before producing a non-ping SSE event");
|
||||
assert.equal(result.classificationReason, result.reason);
|
||||
const body = (await result.response.json()) as Record<string, unknown>;
|
||||
assert.equal("upstream_details" in body, false);
|
||||
});
|
||||
|
||||
test("ensureStreamReadiness preserves sanitized error-only diagnostics on early EOF (#8972)", async () => {
|
||||
const warnings: string[] = [];
|
||||
const response = new Response(
|
||||
streamFromChunks([
|
||||
`data: ${JSON.stringify({
|
||||
error: {
|
||||
message:
|
||||
"UPSTREAM_DETAIL quota exhausted; retry after 2s; empty content " +
|
||||
"Bearer TOP_SECRET /srv/omniroute/handler.ts:42",
|
||||
},
|
||||
})}\n\n`,
|
||||
`data: ${JSON.stringify({ error: { message: "SECOND_DETAIL" } })}\n\n`,
|
||||
]),
|
||||
{ status: 200, headers: { "Content-Type": "text/event-stream" } }
|
||||
);
|
||||
|
||||
const result = await ensureStreamReadiness(response, {
|
||||
timeoutMs: 100,
|
||||
provider: "test-provider",
|
||||
model: "test-model",
|
||||
log: {
|
||||
warn: (_tag, message) => warnings.push(message),
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) assert.fail("error-only SSE payload must remain a readiness failure");
|
||||
assert.equal(result.response.status, 502);
|
||||
assert.equal(result.code, "STREAM_EARLY_EOF");
|
||||
assert.equal(result.type, "stream_early_eof");
|
||||
assert.equal(
|
||||
result.classificationReason,
|
||||
"Stream ended before producing a non-ping SSE event"
|
||||
);
|
||||
assert.equal(
|
||||
result.upstreamDiagnostic,
|
||||
"UPSTREAM_DETAIL quota exhausted; retry after 2s; empty content Bearer [REDACTED] <path>"
|
||||
);
|
||||
|
||||
const body = (await result.response.json()) as {
|
||||
error: { message: string; code: string; type: string };
|
||||
upstream_details: { error: { message: string } };
|
||||
};
|
||||
assert.equal(body.error.message, result.classificationReason);
|
||||
assert.doesNotMatch(body.error.message, /quota|retry after|empty content/i);
|
||||
assert.equal(body.error.code, "STREAM_EARLY_EOF");
|
||||
assert.equal(body.error.type, "stream_early_eof");
|
||||
assert.equal(body.upstream_details.error.message, result.upstreamDiagnostic);
|
||||
assert.equal(warnings.length, 1);
|
||||
|
||||
for (const surfaced of [
|
||||
result.reason,
|
||||
body.upstream_details.error.message,
|
||||
warnings[0],
|
||||
]) {
|
||||
assert.match(surfaced, /UPSTREAM_DETAIL/);
|
||||
assert.doesNotMatch(
|
||||
surfaced,
|
||||
/SECOND_DETAIL|TOP_SECRET|\/srv\/omniroute\/handler\.ts/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("stream-readiness diagnostics cannot reclassify Antigravity account exhaustion (#8972)", () => {
|
||||
const classificationError = "Stream ended before producing a non-ping SSE event";
|
||||
const diagnostic = "UPSTREAM_DETAIL quota exhausted; retry after 2s; empty content";
|
||||
const routedError = resolveStreamReadinessClassificationError({
|
||||
classificationError,
|
||||
error: `${classificationError}: ${diagnostic}`,
|
||||
errorCode: "STREAM_EARLY_EOF",
|
||||
});
|
||||
|
||||
assert.equal(routedError, classificationError);
|
||||
assert.equal(checkFallbackError(502, routedError, 0, null, "antigravity").reason, "server_error");
|
||||
assert.equal(
|
||||
checkFallbackError(502, diagnostic, 0, null, "antigravity").reason,
|
||||
"quota_exhausted",
|
||||
"the regression fixture must prove that leaking the operator diagnostic changes routing"
|
||||
);
|
||||
});
|
||||
|
||||
test("ensureStreamReadiness accepts a final event without a trailing blank line", async () => {
|
||||
|
||||
@@ -54,8 +54,6 @@ test("Claude -> OpenAI maps system blocks, parameters, tool declarations and too
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
test("Claude -> OpenAI maps Claude server WebSearch to native Responses web_search", () => {
|
||||
const result = claudeToOpenAIRequest(
|
||||
"gpt-5.5",
|
||||
@@ -408,7 +406,7 @@ test("Claude -> OpenAI maps thinking.budget_tokens to reasoning_effort buckets",
|
||||
}
|
||||
});
|
||||
|
||||
test("Claude -> OpenAI normalizes output_config.effort=max to xhigh", () => {
|
||||
test("Claude -> OpenAI passes output_config.effort=max through verbatim", () => {
|
||||
const result = claudeToOpenAIRequest(
|
||||
"gpt-5",
|
||||
{
|
||||
@@ -418,7 +416,7 @@ test("Claude -> OpenAI normalizes output_config.effort=max to xhigh", () => {
|
||||
false
|
||||
);
|
||||
|
||||
assert.equal(result.reasoning_effort, "xhigh");
|
||||
assert.equal(result.reasoning_effort, "max");
|
||||
});
|
||||
|
||||
test("Claude -> OpenAI ignores disabled thinking and leaves reasoning_effort unset", () => {
|
||||
|
||||
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
|
||||
vi.mock("../../../src/app/(dashboard)/dashboard/cli-code/components/index", () => ({
|
||||
ClaudeToolCard: () => <div data-testid="ClaudeToolCard" />,
|
||||
ClaudeToolCard: ({ hasActiveProviders, availableModels }: any) => (
|
||||
<div
|
||||
data-testid="ClaudeToolCard"
|
||||
data-has-active-providers={String(hasActiveProviders)}
|
||||
data-available-models={JSON.stringify(availableModels)}
|
||||
/>
|
||||
),
|
||||
CodexToolCard: () => <div data-testid="CodexToolCard" />,
|
||||
DroidToolCard: () => <div data-testid="DroidToolCard" />,
|
||||
OpenClawToolCard: () => <div data-testid="OpenClawToolCard" />,
|
||||
@@ -127,9 +133,8 @@ vi.mock("../../../src/app/(dashboard)/dashboard/cli-code/components/CliproxyapiT
|
||||
|
||||
// ── Import after mocks ────────────────────────────────────────────────────────
|
||||
|
||||
const { default: ToolDetailClient } = await import(
|
||||
"@/app/(dashboard)/dashboard/cli-code/components/ToolDetailClient"
|
||||
);
|
||||
const { default: ToolDetailClient } =
|
||||
await import("@/app/(dashboard)/dashboard/cli-code/components/ToolDetailClient");
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -153,7 +158,10 @@ beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
mockFetch.mockClear();
|
||||
mockFetch.mockReset().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ connections: [], keys: [], data: [], cloudEnabled: false }),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -185,6 +193,93 @@ describe("ToolDetailClient", () => {
|
||||
expect(container.querySelector("[data-testid='CustomCliCard']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps Apply available for an active dynamic compatible provider", async () => {
|
||||
mockFetch.mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/providers") {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
connections: [
|
||||
{
|
||||
provider: "openai-compatible-chat-node-123",
|
||||
name: "Kimi gateway",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
defaultModel: "Kimi-K3",
|
||||
providerSpecificData: { prefix: "kimi-gateway" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ keys: [], data: [], cloudEnabled: false }),
|
||||
};
|
||||
});
|
||||
|
||||
const container = renderDetail("claude", "code");
|
||||
await act(async () => {});
|
||||
|
||||
const card = container.querySelector("[data-testid='ClaudeToolCard']");
|
||||
expect(card?.getAttribute("data-has-active-providers")).toBe("true");
|
||||
expect(JSON.parse(card?.getAttribute("data-available-models") || "[]")).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
value: "kimi-gateway/Kimi-K3",
|
||||
provider: "openai-compatible-chat-node-123",
|
||||
modelId: "Kimi-K3",
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts compatible-provider models published under the connection prefix", async () => {
|
||||
mockFetch.mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/providers") {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
connections: [
|
||||
{
|
||||
provider: "anthropic-compatible-node-456",
|
||||
name: "Claude gateway",
|
||||
isActive: true,
|
||||
providerSpecificData: { prefix: "claude-gateway" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url === "/v1/models") {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ data: [{ id: "claude-gateway/claude-sonnet" }] }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ keys: [], cloudEnabled: false }),
|
||||
};
|
||||
});
|
||||
|
||||
const container = renderDetail("claude", "code");
|
||||
await act(async () => {});
|
||||
|
||||
const card = container.querySelector("[data-testid='ClaudeToolCard']");
|
||||
expect(card?.getAttribute("data-has-active-providers")).toBe("true");
|
||||
expect(JSON.parse(card?.getAttribute("data-available-models") || "[]")).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
value: "claude-gateway/claude-sonnet",
|
||||
modelId: "claude-sonnet",
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("renders DefaultToolCard for unknown tool (forge, configType:custom)", async () => {
|
||||
const container = renderDetail("forge", "code");
|
||||
await act(async () => {});
|
||||
|
||||
Reference in New Issue
Block a user