Compare commits

..

15 Commits

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

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

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

* chore(changelog): fragment for #9250

---------

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

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

* chore(changelog): fragment for #9253

---------

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

* test(ci): register stream readiness mutation coverage

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

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

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

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

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

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

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

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

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

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

* docs(db): deepen MySQL conformance specification

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

695
AGENTS.md
View File

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

View File

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

View File

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

View File

@@ -1 +0,0 @@
- **fix(i18n):** completed the French UI catalog by adding all missing keys and replacing every placeholder translation ([#9235](https://github.com/diegosouzapw/OmniRoute/pull/9235)) — thanks @alex-jordan547

View File

@@ -1 +0,0 @@
- **fix(i18n):** localized hardcoded web UI copy across public pages, dashboard views, and shared components, with complete French and Vietnamese coverage ([#9245](https://github.com/diegosouzapw/OmniRoute/pull/9245)) — thanks @alex-jordan547

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4651,12 +4651,7 @@ export async function handleChatCore({
});
if (streamReadiness.ok === false) {
const { response: failureResponse, reason } = streamReadiness;
const failure = {
status: failureResponse.status,
message: reason,
code: streamReadiness.code,
type: streamReadiness.type,
};
const { classificationReason, upstreamDiagnostic } = streamReadiness;
trackPendingRequest(model, provider, connectionId, false);
appendRequestLog({
model,
@@ -4668,7 +4663,11 @@ export async function handleChatCore({
status: failureResponse.status,
error: reason,
providerRequest: finalBody || translatedBody,
clientResponse: buildErrorBody(failureResponse.status, reason),
clientResponse: buildErrorBody(
failureResponse.status,
classificationReason,
upstreamDiagnostic ? { error: { message: upstreamDiagnostic } } : undefined
),
claudeCacheMeta: claudePromptCacheLogMeta,
cacheSource: "upstream",
});
@@ -4680,6 +4679,7 @@ export async function handleChatCore({
success: false,
status: failureResponse.status,
error: reason,
classificationError: classificationReason,
errorType: streamReadiness.type,
errorCode: streamReadiness.code,
response: failureResponse,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,17 +27,22 @@ export default function BootstrapBanner() {
<span className="text-amber-500 dark:text-amber-400 text-base shrink-0 mt-0.5"></span>
<div className="flex-1 min-w-0">
<p className="font-semibold text-amber-900 dark:text-amber-300">
{t("zeroConfigBannerTitle")}
Running in zero-config mode
</p>
<p className="mt-0.5 text-amber-800/80 dark:text-amber-200/80">
{t.rich("zeroConfigBannerBody", {
dataDir,
code: (chunks) => (
<code className="font-mono bg-amber-200/50 dark:bg-amber-500/20 px-1 rounded text-xs">
{chunks}
</code>
),
})}
OmniRoute auto-generated secure encryption keys on first launch. They are persisted to{" "}
<code className="font-mono bg-amber-200/50 dark:bg-amber-500/20 px-1 rounded text-xs">
{dataDir}
</code>
. No action is required your data is encrypted and safe. To use custom keys, add{" "}
<code className="font-mono bg-amber-200/50 dark:bg-amber-500/20 px-1 rounded text-xs">
JWT_SECRET
</code>{" "}
and{" "}
<code className="font-mono bg-amber-200/50 dark:bg-amber-500/20 px-1 rounded text-xs">
STORAGE_ENCRYPTION_KEY
</code>{" "}
to that file.
</p>
</div>
<button

View File

@@ -144,31 +144,31 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
const cleanLatest = latest.replace(/^v/, "");
if (platform === "darwin") {
return {
label: t("downloadDmg"),
label: "Download DMG (macOS)",
url: `https://github.com/diegosouzapw/OmniRoute/releases/download/v${cleanLatest}/OmniRoute-${cleanLatest}.dmg`,
desc: t("downloadDmgDescription", { version: versionInfo?.current || "" }),
desc: `A new version of the OmniRoute desktop app is available. Please download and install the macOS DMG installer to update (current: v${versionInfo?.current || ""}).`,
};
}
if (platform === "win32") {
return {
label: t("downloadExe"),
label: "Download EXE (Windows)",
url: `https://github.com/diegosouzapw/OmniRoute/releases/download/v${cleanLatest}/OmniRoute.Setup.${cleanLatest}.exe`,
desc: t("downloadExeDescription", { version: versionInfo?.current || "" }),
desc: `A new version of the OmniRoute desktop app is available. Please download and install the Windows EXE installer to update (current: v${versionInfo?.current || ""}).`,
};
}
if (platform === "linux") {
return {
label: t("downloadAppImage"),
label: "Download AppImage (Linux)",
url: `https://github.com/diegosouzapw/OmniRoute/releases/download/v${cleanLatest}/OmniRoute-${cleanLatest}.AppImage`,
desc: t("downloadAppImageDescription", { version: versionInfo?.current || "" }),
desc: `A new version of the OmniRoute desktop app is available. Please download the Linux AppImage package to update (current: v${versionInfo?.current || ""}).`,
};
}
return {
label: t("downloadUpdate"),
label: "Download Update",
url: `https://github.com/diegosouzapw/OmniRoute/releases/tag/v${cleanLatest}`,
desc: t("downloadUpdateDescription", { version: versionInfo?.current || "" }),
desc: `A new version of the OmniRoute desktop app is available. Please download the respective app format for your system to update (current: v${versionInfo?.current || ""}).`,
};
}, [platform, t, versionInfo?.latest, versionInfo?.current]);
}, [platform, versionInfo?.latest, versionInfo?.current]);
// Electron internal auto-updater state and listeners
const [electronUpdateStatus, setElectronUpdateStatus] = useState<{
@@ -539,29 +539,29 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
{
step: "install",
status: "done",
message: message || t("updateQueued", { version: targetVersion }),
message: message || `Queued update to v${targetVersion}.`,
},
{
step: "rebuild",
status: "running",
message: t("updateDockerRebuilding"),
message: "Docker image is rebuilding in the background.",
},
{
step: "restart",
status: "pending",
message: t("updateWaitingRestart"),
message: "Waiting for OmniRoute to restart with the new version.",
},
]
: [
{
step: "install",
status: "running",
message: message || t("updateInstalling", { version: targetVersion }),
message: message || `Installing v${targetVersion}.`,
},
{
step: "restart",
status: "pending",
message: t("updateWaitingRestart"),
message: "Waiting for OmniRoute to restart with the new version.",
},
];
@@ -593,14 +593,14 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
next = mergeUpdateStep(next, {
step: "complete",
status: "done",
message: t("updateRunning", { version: targetVersion }),
message: `OmniRoute is now running v${targetVersion}.`,
});
return next;
});
setUpdating(false);
setUpdatePhase("done");
notify.success(t("updateCompleted", { version: targetVersion }));
notify.success(`OmniRoute updated to v${targetVersion}.`);
await fetchData();
return;
}
@@ -611,20 +611,20 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
next = mergeUpdateStep(next, {
step: "rebuild",
status: "running",
message: t("updateDockerStillRebuilding", { version: targetVersion }),
message: `Docker image is still rebuilding for v${targetVersion}.`,
});
} else {
next = mergeUpdateStep(next, {
step: "install",
status: "running",
message: t("updateInstallingBackground", { version: targetVersion }),
message: `Installing v${targetVersion} in the background.`,
});
}
next = mergeUpdateStep(next, {
step: "restart",
status: "pending",
message: t("updateWaitingVersion", { version: targetVersion }),
message: `Waiting for OmniRoute to come back on v${targetVersion}.`,
});
return next;
@@ -636,20 +636,20 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
next = mergeUpdateStep(next, {
step: "rebuild",
status: "running",
message: t("updateDockerStillInProgress"),
message: "Docker rebuild is still in progress.",
});
} else {
next = mergeUpdateStep(next, {
step: "install",
status: "running",
message: t("updateInstallingBackground", { version: targetVersion }),
message: `Installing v${targetVersion} in the background.`,
});
}
next = mergeUpdateStep(next, {
step: "restart",
status: "running",
message: t("updateRestarting"),
message: "Service restart in progress. Waiting for OmniRoute to come back online...",
});
return next;
@@ -661,14 +661,14 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
mergeUpdateStep(prev, {
step: "error",
status: "failed",
message: t("updateTimeout", { version: targetVersion }),
message: `Update started, but v${targetVersion} did not become available before timeout. Refresh the page or check server logs.`,
})
);
setUpdating(false);
setUpdatePhase("failed");
notify.error(t("updateTimedOut", { version: targetVersion }));
notify.error(`Update to v${targetVersion} timed out.`);
},
[fetchData, t]
[fetchData]
);
const handleUpdate = async () => {
@@ -689,12 +689,12 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
// Passing the raw object to notify.error() rendered it as a React child →
// "Minified React error #31" crash ("Internal Server Error" screen), e.g. on
// the 403 from the loopback-only /api/system/version. Extract the string.
notify.error(extractApiErrorMessage(data, t("updateStartFailed")));
notify.error(extractApiErrorMessage(data, "Failed to start update."));
setUpdating(false);
setUpdatePhase("idle");
return;
}
notify.success(data.message || t("updateStarted"));
notify.success(data.message || "Update started.");
await pollBackgroundUpdate({
channel: data.channel || "docker-compose",
message: data.message || "",
@@ -705,7 +705,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
// SSE stream — read progress events
if (!res.body) {
notify.error(t("noResponseStream"));
notify.error("No response stream received.");
setUpdating(false);
setUpdatePhase("idle");
return;
@@ -735,10 +735,10 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
if (event.step === "complete") {
setUpdatePhase("done");
setUpdating(false);
notify.success(event.message || t("updateComplete"));
notify.success(event.message || "Update complete!");
} else if (event.step === "error") {
setUpdatePhase("failed");
notify.error(event.message || t("updateFailed"));
notify.error(event.message || "Update failed.");
setUpdating(false);
}
} catch {
@@ -753,7 +753,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
{
step: "error",
status: "failed",
message: t("updateNetworkError"),
message: "Network error — connection lost during update.",
},
]);
setUpdating(false);
@@ -769,11 +769,11 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
return () => clearTimeout(timer);
}, [updatePhase]);
const stepLabels: Record<string, string> = {
install: t("stepInstallPackage"),
rebuild: t("stepRebuildNativeModules"),
restart: t("stepRestartService"),
complete: t("stepComplete"),
error: t("stepError"),
install: "Install Package",
rebuild: "Rebuild Native Modules",
restart: "Restart Service",
complete: "Complete",
error: "Error",
};
const showUpdateOverlay = updatePhase !== "idle";
@@ -801,17 +801,17 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
<div>
<h3 className="text-lg font-bold">
{updatePhase === "done"
? t("updateCompleteTitle")
? "Update Complete!"
: updatePhase === "failed"
? t("updateFailedTitle")
: t("updatingTitle")}
? "Update Failed"
: "Updating OmniRoute..."}
</h3>
<p className="text-xs text-text-muted mt-0.5">
{updatePhase === "done"
? t("reloadNotice")
? "The page will reload automatically in a few seconds."
: updatePhase === "failed"
? t("retryNotice")
: t("restartNotice")}
? "Please try again or update manually via the CLI."
: "Do not close this page. The system will restart automatically."}
</p>
</div>
</div>
@@ -871,7 +871,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
<div className="mt-1 px-3 py-2.5 rounded-lg border border-green-500/30 bg-green-500/5">
<p className="text-sm font-semibold text-green-500 flex items-center gap-2">
<span className="material-symbols-outlined text-[18px]">check_circle</span>
{updateSteps.find((s) => s.step === "complete")?.message || t("updateComplete")}
{updateSteps.find((s) => s.step === "complete")?.message || "Update complete!"}
</p>
<p className="text-xs text-text-muted mt-1">{t("reloadingPageAutomatically")}</p>
</div>
@@ -891,11 +891,11 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
if (updatePhase === "done") globalThis.window.location.reload();
}}
>
{updatePhase === "done" ? t("reloadNow") : t("closeUpdate")}
{updatePhase === "done" ? "Reload Now" : "Close"}
</Button>
{updatePhase === "failed" && (
<Button size="sm" variant="secondary" fullWidth onClick={handleUpdate}>
{t("retryUpdate")}
Retry
</Button>
)}
</div>
@@ -917,32 +917,30 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
</span>
<div>
<p className="font-semibold text-sm">
{t("updateAvailableTitle", {
version: versionInfo.latest,
desktop: isElectron ? ` ${t("desktopAppLabel")}` : "",
})}
Update Available: v{versionInfo.latest} {isElectron && "(Desktop App)"}
</p>
<p className="text-xs opacity-80 mt-0.5">
{isElectron ? (
<>
{electronUpdateStatus.status === "checking" && t("checkingForUpdates")}
{electronUpdateStatus.status === "checking" && "Checking for updates..."}
{electronUpdateStatus.status === "available" &&
t("versionAvailableForDownload", { version: versionInfo.latest })}
`Version v${versionInfo.latest} is available for download.`}
{electronUpdateStatus.status === "downloading" &&
t("downloadingUpdate", { percent: electronUpdateStatus.percent || 0 })}
{electronUpdateStatus.status === "downloaded" && t("updateDownloaded")}
`Downloading update... ${electronUpdateStatus.percent || 0}% complete.`}
{electronUpdateStatus.status === "downloaded" &&
"Update downloaded successfully! Click Restart & Install to apply."}
{electronUpdateStatus.status === "error" &&
t("autoUpdateFailed", {
reason: electronUpdateStatus.message || t("unknownUpdateError"),
})}
`Auto-update failed: ${electronUpdateStatus.message || "Unknown error"}.`}
{(electronUpdateStatus.status === "idle" ||
electronUpdateStatus.status === "not-available") &&
t("versionAvailableDesktop", { version: versionInfo.latest })}
`Version v${versionInfo.latest} is available for the desktop app.`}
</>
) : versionInfo.autoUpdateSupported ? (
t("updateAvailableDesc")
t("updateAvailableDesc") ||
`You are currently using v${versionInfo.current}. Update to access the latest features and bug fixes.`
) : (
versionInfo.autoUpdateError || t("manualUpdateRequired")
versionInfo.autoUpdateError ||
"Manual update required for this installation type."
)}
</p>
</div>
@@ -956,7 +954,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
onClick={() => globalThis.window.electronAPI?.downloadUpdate()}
className="font-semibold"
>
{t("downloadUpdate")}
Download Update
</Button>
)}
{electronUpdateStatus.status === "downloading" && (
@@ -975,7 +973,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
onClick={() => globalThis.window.electronAPI?.installUpdate()}
className="font-semibold animate-pulse"
>
{t("restartAndInstall")}
Restart & Install
</Button>
)}
{(electronUpdateStatus.status === "error" ||
@@ -991,7 +989,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
}}
className="font-semibold"
>
{t("checkForUpdate")}
Check for Update
</Button>
)}
</div>
@@ -1003,7 +1001,9 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
className="ml-4 shrink-0 font-semibold"
title={versionInfo.autoUpdateError || ""}
>
{versionInfo.autoUpdateSupported ? t("updateNow") : t("manualUpdate")}
{versionInfo.autoUpdateSupported
? t("updateNow") || "Update Now"
: "Manual Update"}
</Button>
)}
</div>
@@ -1015,7 +1015,9 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
electronUpdateStatus.status === "available" ||
electronUpdateStatus.status === "not-available") && (
<div className="flex flex-col sm:flex-row sm:items-center justify-between border-t border-primary/20 mt-2 pt-3 gap-2">
<p className="text-xs opacity-75">{t("directDownloadHint")}</p>
<p className="text-xs opacity-75">
Or download the respective installer format directly:
</p>
<div className="flex gap-2">
<Button
size="sm"
@@ -1027,7 +1029,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
}
className="font-semibold text-xs py-1"
>
{t("releaseNotes")}
Release Notes
</Button>
<Button
size="sm"
@@ -1065,7 +1067,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
rel="noopener noreferrer"
className="ml-4 inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-border bg-bg px-4 py-2 text-xs font-semibold text-text-main transition-colors hover:border-primary/30 hover:text-primary"
>
{versionInfo.news.linkLabel || t("readMore")}
{versionInfo.news.linkLabel || "Ler Mais"}
<span className="material-symbols-outlined text-[14px]">arrow_forward</span>
</a>
)}
@@ -1074,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
@@ -1212,7 +1214,7 @@ function ProviderOverviewCard({
item.errors > 0 ? "text-red-500" : item.connected > 0 ? "text-green-500" : "text-text-muted";
const authTypeConfig = {
"no-auth": { color: "bg-stone-500", label: t("noAuthLabel") },
"no-auth": { color: "bg-stone-500", label: "No Auth" },
free: { color: "bg-green-500", label: tc("free") },
oauth: { color: "bg-blue-500", label: t("oauthLabel") },
apikey: { color: "bg-amber-500", label: t("apiKeyLabel") },

View File

@@ -42,7 +42,9 @@ export default function AutoRoutingAnalyticsTab() {
if (!stats) {
return (
<Card>
<div className="text-center py-8 text-text-muted">{t("autoRoutingNoDataAvailable")}</div>
<div className="text-center py-8 text-text-muted">
No auto-routing analytics available. Make requests using the auto/ prefix to see metrics.
</div>
</Card>
);
}
@@ -108,9 +110,7 @@ export default function AutoRoutingAnalyticsTab() {
const percentage = stats.totalRequests > 0 ? (count / stats.totalRequests) * 100 : 0;
return (
<div key={variant} className="flex items-center gap-3">
<div className="w-32 text-sm font-medium capitalize">
{variant || t("defaultVariantLabel")}
</div>
<div className="w-32 text-sm font-medium capitalize">{variant || "default"}</div>
<div className="flex-1 h-3 bg-border rounded-full overflow-hidden">
<div
className="h-full bg-indigo-500 rounded-full transition-all"
@@ -133,9 +133,9 @@ export default function AutoRoutingAnalyticsTab() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 px-3 font-medium">{t("chartProvider")}</th>
<th className="text-right py-2 px-3 font-medium">{t("chartRequests")}</th>
<th className="text-right py-2 px-3 font-medium">{t("chartShare")}</th>
<th className="text-left py-2 px-3 font-medium">Provider</th>
<th className="text-right py-2 px-3 font-medium">Requests</th>
<th className="text-right py-2 px-3 font-medium">Share</th>
</tr>
</thead>
<tbody>

View File

@@ -50,13 +50,11 @@ function ProviderBar({
count,
total,
costUsd,
queriesLabel,
}: {
provider: string;
count: number;
total: number;
costUsd: number;
queriesLabel: string;
}) {
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
return (
@@ -64,7 +62,7 @@ function ProviderBar({
<div className="flex justify-between text-sm">
<span className="font-medium text-text">{provider}</span>
<span className="text-text-muted">
{count} {queriesLabel} · ${costUsd.toFixed(4)}
{count} queries · ${costUsd.toFixed(4)}
</span>
</div>
<div className="h-2 rounded-full bg-bg-muted overflow-hidden">
@@ -101,7 +99,7 @@ export default function SearchAnalyticsTab() {
return (
<div className="flex items-center justify-center py-16 text-text-muted">
<span className="material-symbols-outlined animate-spin mr-2">progress_activity</span>
{t("searchAnalyticsLoading")}
Loading search analytics
</div>
);
}
@@ -110,11 +108,9 @@ export default function SearchAnalyticsTab() {
return (
<div className="card p-6 text-center text-text-muted">
<span className="material-symbols-outlined text-[32px] mb-2 block">search_off</span>
{error || t("searchAnalyticsNoData")}
{error || "No search data available yet."}
<p className="text-xs mt-2">
{t.rich("searchAnalyticsNoDataDescription", {
code: (chunks) => <code className="bg-bg-muted px-1 rounded">{chunks}</code>,
})}
Search requests will appear here after the first search via /v1/search.
</p>
</div>
);
@@ -130,29 +126,25 @@ export default function SearchAnalyticsTab() {
icon="manage_search"
label={t("searchAnalyticsTotalSearches")}
value={stats.total.toLocaleString()}
sub={t("searchAnalyticsToday", { count: stats.today })}
sub={`${stats.today} today`}
/>
<StatCard
icon="cached"
label={t("searchAnalyticsCacheHitRate")}
value={`${stats.cacheHitRate}%`}
sub={t("searchAnalyticsCachedRequests", { count: stats.cached })}
sub={`${stats.cached} cached requests`}
/>
<StatCard
icon="attach_money"
label={t("searchAnalyticsTotalCost")}
value={`$${stats.totalCostUsd.toFixed(4)}`}
sub={t("searchAnalyticsApiCosts")}
sub="search API costs"
/>
<StatCard
icon="timer"
label={t("searchAnalyticsAvgResponse")}
value={`${stats.avgDurationMs}ms`}
sub={
stats.errors > 0
? t("searchAnalyticsErrors", { count: stats.errors })
: t("searchAnalyticsNoErrors")
}
sub={stats.errors > 0 ? `${stats.errors} errors` : "No errors"}
/>
</div>
@@ -161,7 +153,7 @@ export default function SearchAnalyticsTab() {
<div className="card p-5">
<h3 className="font-semibold text-text mb-4 flex items-center gap-2">
<span className="material-symbols-outlined text-primary text-[20px]">hub</span>
{t("searchAnalyticsProviderBreakdown")}
Provider Breakdown
</h3>
<div className="flex flex-col gap-4">
{providers.map(([prov, data]) => (
@@ -171,7 +163,6 @@ export default function SearchAnalyticsTab() {
count={data.count}
total={stats.total}
costUsd={data.costUsd}
queriesLabel={t("searchAnalyticsQueries")}
/>
))}
</div>
@@ -186,9 +177,8 @@ export default function SearchAnalyticsTab() {
</span>
<p className="font-medium text-text">{t("searchAnalyticsNoSearchesYet")}</p>
<p className="text-sm mt-1">
{t.rich("searchAnalyticsEmptyDescription", {
code: (chunks) => <code className="bg-bg-muted px-1 rounded">{chunks}</code>,
})}
Use <code className="bg-bg-muted px-1 rounded">POST /v1/search</code> to start routing
web searches.
</p>
</div>
)}
@@ -199,9 +189,8 @@ export default function SearchAnalyticsTab() {
check_circle
</span>
<span>
{t.rich("searchAnalyticsFreeTier", {
strong: (chunks) => <strong>{chunks}</strong>,
})}
<strong>Free tier available:</strong> Serper (2,500/mo), Brave (2,000/mo), Exa (1,000/mo),
Tavily (1,000/mo) total 6,500+ free searches/month with automatic failover.
</span>
</div>
</div>

View File

@@ -122,23 +122,23 @@ export default function McpAuditTab() {
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
{[
{
label: t("mcpMetricCalls24h"),
label: "Calls (24h)",
value: stats.totalCalls.toLocaleString(),
icon: "terminal",
},
{
label: t("mcpMetricSuccessRate"),
label: "Success rate",
value: `${Math.round(stats.successRate * 100)}%`,
icon: "check_circle",
highlight: stats.successRate >= 0.9,
},
{
label: t("mcpMetricAvgDuration"),
label: "Avg duration",
value: `${Math.round(stats.avgDurationMs)}ms`,
icon: "timer",
},
{
label: t("mcpMetricTopTool"),
label: "Top tool",
value: stats.topTools[0]?.tool ?? "—",
icon: "star",
},

View File

@@ -4,9 +4,7 @@ import { useEffect } from "react";
import { useTranslations } from "next-intl";
import { useBatchActions } from "./components/useBatchActions";
type BatchTranslator = ReturnType<typeof useTranslations>;
function relativeTime(ts: number, t: BatchTranslator): string {
function relativeTime(ts: number): string {
const diffMs = Date.now() - ts * 1000;
const isFuture = diffMs < 0;
const absDiffMs = Math.abs(diffMs);
@@ -24,9 +22,8 @@ function relativeTime(ts: number, t: BatchTranslator): string {
}
}
return isFuture
? t("batchRelativeTimeIn", { value: res })
: t("batchRelativeTimeAgo", { value: res });
if (isFuture) return `in ${res}`;
return `${res} ago`;
}
interface BatchRecord {
@@ -96,22 +93,6 @@ const STATUS_LABELS: Record<string, string> = {
expired_with_failures: "expired (partial)",
};
const STATUS_TRANSLATION_KEYS: Record<string, string> = {
completed: "batchStatusCompleted",
completed_with_failures: "batchStatusCompletedWithFailures",
failed: "batchStatusFailed",
in_progress: "batchStatusInProgress",
in_progress_with_failures: "batchStatusInProgressWithFailures",
finalizing: "batchStatusFinalizing",
finalizing_with_failures: "batchStatusFinalizingWithFailures",
validating: "batchStatusValidating",
cancelling: "batchStatusCancelling",
cancelled: "batchStatusCancelled",
cancelled_with_failures: "batchStatusCancelledWithFailures",
expired: "batchStatusExpired",
expired_with_failures: "batchStatusExpiredWithFailures",
};
function effectiveStatus(batch: BatchRecord): string {
const hasFailed = (batch.requestCountsFailed ?? 0) > 0;
if (!hasFailed) return batch.status;
@@ -125,12 +106,10 @@ function effectiveStatus(batch: BatchRecord): string {
return map[batch.status] ?? batch.status;
}
function StatusBadge({ batch, t }: { batch: BatchRecord; t: BatchTranslator }) {
function StatusBadge({ batch }: { batch: BatchRecord }) {
const key = effectiveStatus(batch);
const cls = STATUS_STYLES[key] ?? "bg-gray-500/15 text-gray-400 border-gray-500/25";
const label = STATUS_TRANSLATION_KEYS[key]
? t(STATUS_TRANSLATION_KEYS[key])
: (STATUS_LABELS[key] ?? key.replace(/_/g, " "));
const label = STATUS_LABELS[key] ?? key.replace(/_/g, " ");
return (
<span className={`inline-block px-2 py-0.5 rounded-md text-xs font-medium border ${cls}`}>
{label}
@@ -162,24 +141,12 @@ function formatTs(ts: number | null | undefined): string {
});
}
export default function BatchDetailModal({
batch,
files,
onClose,
onActionDone,
}: BatchDetailModalProps) {
export default function BatchDetailModal({ batch, files, onClose, onActionDone }: BatchDetailModalProps) {
const t = useTranslations("common");
// ── Action hook (F7) ─────────────────────────────────────────────────────────
const {
cancelling,
retrying,
error: actionError,
cancel,
retry,
downloadHrefOutput,
downloadHrefErrors,
} = useBatchActions({ onRefresh: onActionDone, t });
const { cancelling, retrying, error: actionError, cancel, retry, downloadHrefOutput, downloadHrefErrors } =
useBatchActions({ onRefresh: onActionDone, t });
// ── Status flags ──────────────────────────────────────────────────────────────
const isTerminal = ["completed", "failed", "cancelled", "expired"].includes(batch.status);
@@ -228,11 +195,8 @@ export default function BatchDetailModal({
pending_actions
</span>
<div>
<h2
id="batch-detail-modal-title"
className="text-base font-semibold text-[var(--color-text-main)]"
>
{t("batchDetailsTitle")}
<h2 id="batch-detail-modal-title" className="text-base font-semibold text-[var(--color-text-main)]">
Batch Details
</h2>
<div className="flex items-center gap-2 mt-0.5">
<p className="text-xs text-[var(--color-text-muted)] font-mono">{batch.id}</p>
@@ -263,18 +227,16 @@ export default function BatchDetailModal({
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
<div className="flex flex-col gap-0.5">
<span className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
{t("status")}
Status
</span>
<StatusBadge batch={batch} t={t} />
<StatusBadge batch={batch} />
</div>
<Field label={t("batchDetailEndpoint")} value={batch.endpoint} />
{batch.model && <Field label={t("batchDetailModel")} value={batch.model} />}
<Field label={t("batchDetailWindow")} value={batch.completionWindow} />
<Field
label={t("batchDetailCreated")}
value={
<span title={formatTs(batch.createdAt)}>{relativeTime(batch.createdAt, t)}</span>
}
value={<span title={formatTs(batch.createdAt)}>{relativeTime(batch.createdAt)}</span>}
/>
</div>
@@ -283,7 +245,7 @@ export default function BatchDetailModal({
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--color-text-muted)] uppercase tracking-wider font-medium">
{t("batchProgress")}
Progress
</span>
<span className="text-[var(--color-text-muted)]">
{completed} / {total} ({pct}%)
@@ -300,9 +262,17 @@ export default function BatchDetailModal({
/>
</div>
<div className="flex gap-4 text-xs text-[var(--color-text-muted)]">
<span>{t("batchCompletedCount", { count: completed })}</span>
{failed > 0 && <span>{t("batchFailedCount", { count: failed })}</span>}
<span>{t("batchPendingCount", { count: total - completed - failed })}</span>
<span>
<span className="text-emerald-400 font-medium">{completed}</span> completed
</span>
{failed > 0 && (
<span>
<span className="text-red-400 font-medium">{failed}</span> failed
</span>
)}
<span>
<span className="font-medium">{total - completed - failed}</span> pending
</span>
</div>
</div>
)}
@@ -310,19 +280,19 @@ export default function BatchDetailModal({
{/* Timestamps */}
<div>
<h3 className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)] mb-3">
{t("batchTimeline")}
Timeline
</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 text-sm">
{[
{ label: t("batchDetailCreated"), ts: batch.createdAt },
{ label: t("batchTimelineInProgress"), ts: batch.inProgressAt },
{ label: t("batchTimelineFinalizing"), ts: batch.finalizingAt },
{ label: t("batchTimelineCompleted"), ts: batch.completedAt },
{ label: t("batchTimelineFailed"), ts: batch.failedAt },
{ label: t("batchTimelineExpires"), ts: batch.expiresAt },
{ label: t("batchTimelineExpired"), ts: batch.expiredAt },
{ label: t("batchTimelineCancelling"), ts: batch.cancellingAt },
{ label: t("batchTimelineCancelled"), ts: batch.cancelledAt },
{ label: "Created", ts: batch.createdAt },
{ label: "In Progress", ts: batch.inProgressAt },
{ label: "Finalizing", ts: batch.finalizingAt },
{ label: "Completed", ts: batch.completedAt },
{ label: "Failed", ts: batch.failedAt },
{ label: "Expires", ts: batch.expiresAt },
{ label: "Expired", ts: batch.expiredAt },
{ label: "Cancelling", ts: batch.cancellingAt },
{ label: "Cancelled", ts: batch.cancelledAt },
]
.filter((t) => t.ts)
.map(({ label, ts }) => (
@@ -345,21 +315,9 @@ export default function BatchDetailModal({
</h3>
<div className="space-y-2">
{[
{
role: t("filesListUsedByRoleInput"),
fileId: batch.inputFileId,
record: inputFile,
},
{
role: t("filesListUsedByRoleOutput"),
fileId: batch.outputFileId,
record: outputFile,
},
{
role: t("filesListUsedByRoleError"),
fileId: batch.errorFileId,
record: errorFile,
},
{ role: "Input", fileId: batch.inputFileId, record: inputFile },
{ role: "Output", fileId: batch.outputFileId, record: outputFile },
{ role: "Errors", fileId: batch.errorFileId, record: errorFile },
]
.filter((f) => f.fileId)
.map(({ role, fileId, record }) => (
@@ -392,7 +350,7 @@ export default function BatchDetailModal({
className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-muted)] hover:text-[var(--color-text-main)] transition-colors"
>
<span className="material-symbols-outlined text-[13px]">download</span>
{t("filesListDownload")}
Download
</a>
</div>
</div>
@@ -404,7 +362,7 @@ export default function BatchDetailModal({
{batch.usage && (
<div>
<h3 className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)] mb-3">
{t("batchTokenUsage")}
Token Usage
</h3>
<pre className="p-3 rounded-lg bg-[var(--color-bg-alt)] border border-[var(--color-border)] text-xs font-mono text-[var(--color-text-main)] overflow-x-auto">
{JSON.stringify(batch.usage, null, 2)}
@@ -416,7 +374,7 @@ export default function BatchDetailModal({
{batch.errors && (
<div>
<h3 className="text-[11px] uppercase tracking-wider font-medium text-red-400 mb-3">
{t("errors")}
Errors
</h3>
<pre className="p-3 rounded-lg bg-red-500/5 border border-red-500/20 text-xs font-mono text-red-300 overflow-x-auto">
{JSON.stringify(batch.errors, null, 2)}
@@ -428,7 +386,7 @@ export default function BatchDetailModal({
{batch.metadata && Object.keys(batch.metadata).length > 0 && (
<div>
<h3 className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)] mb-3">
{t("batchMetadata")}
Metadata
</h3>
<div className="space-y-1">
{Object.entries(batch.metadata).map(([k, v]) => (
@@ -480,7 +438,7 @@ export default function BatchDetailModal({
if (
window.confirm(
t("batchDetailActionRetry") +
` (${batch.requestCountsFailed} ${t("batchActionRetry")})?`
` (${batch.requestCountsFailed} ${t("batchActionRetry")})?`,
)
) {
const result = await retry({

View File

@@ -4,24 +4,22 @@ import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/shared/components";
type FileTranslator = ReturnType<typeof useTranslations>;
function relativeTime(ts: number, t: FileTranslator): string {
function relativeTime(ts: number): string {
const diffMs = Date.now() - ts * 1000;
const diffSec = Math.round(diffMs / 1000);
if (diffSec < 60) return t("batchRelativeTimeAgo", { value: `${diffSec}s` });
if (diffSec < 60) return `${diffSec}s ago`;
const diffMin = Math.round(diffSec / 60);
if (diffMin < 60) return t("batchRelativeTimeAgo", { value: `${diffMin}m` });
if (diffMin < 60) return `${diffMin}m ago`;
const diffHr = Math.round(diffMin / 60);
if (diffHr < 24) return t("batchRelativeTimeAgo", { value: `${diffHr}h` });
if (diffHr < 24) return `${diffHr}h ago`;
const diffDays = Math.round(diffHr / 24);
return t("batchRelativeTimeAgo", { value: `${diffDays}d` });
return `${diffDays}d ago`;
}
function relativeExpiration(ts: number | null, t: FileTranslator): string {
if (!ts) return t("batchFilesNeverExpires");
function relativeExpiration(ts: number | null): string {
if (!ts) return "Never";
const diffMs = ts * 1000 - Date.now();
if (diffMs <= 0) return t("expirationBadgeExpired");
if (diffMs <= 0) return "Expired";
const diffSec = Math.round(diffMs / 1000);
if (diffSec < 60) return `${diffSec}s`;
const diffMin = Math.round(diffSec / 60);
@@ -57,22 +55,6 @@ interface BatchRecord {
model?: string | null;
}
const BATCH_STATUS_TRANSLATION_KEYS: Record<string, string> = {
completed: "batchStatusCompleted",
completed_with_failures: "batchStatusCompletedWithFailures",
failed: "batchStatusFailed",
in_progress: "batchStatusInProgress",
in_progress_with_failures: "batchStatusInProgressWithFailures",
finalizing: "batchStatusFinalizing",
finalizing_with_failures: "batchStatusFinalizingWithFailures",
validating: "batchStatusValidating",
cancelling: "batchStatusCancelling",
cancelled: "batchStatusCancelled",
cancelled_with_failures: "batchStatusCancelledWithFailures",
expired: "batchStatusExpired",
expired_with_failures: "batchStatusExpiredWithFailures",
};
interface FileDetailModalProps {
file: FileRecord;
contents: string | null;
@@ -151,7 +133,7 @@ export default function FileDetailModal({
</span>
<div>
<h2 className="text-base font-semibold text-[var(--color-text-main)]">
{t("batchFileContents")}
File Contents
</h2>
<div className="flex items-center gap-2 mt-0.5">
<p className="text-xs text-[var(--color-text-muted)] font-mono">{file.id}</p>
@@ -182,7 +164,7 @@ export default function FileDetailModal({
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 p-4 rounded-xl bg-[var(--color-bg-alt)] border border-[var(--color-border)]">
<div className="flex flex-col gap-0.5">
<span className="text-[10px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
{t("batchFilesSizeColumn")}
Size
</span>
<span className="text-sm text-[var(--color-text-main)]">
{formatBytes(file.bytes)}
@@ -190,24 +172,24 @@ export default function FileDetailModal({
</div>
<div className="flex flex-col gap-0.5">
<span className="text-[10px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
{t("batchFilesPurpose")}
Purpose
</span>
<span className="text-sm text-[var(--color-text-main)]">{file.purpose}</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-[10px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
{t("batchDetailCreated")}
Created
</span>
<span className="text-sm text-[var(--color-text-main)]">
{createdAtTs ? relativeTime(createdAtTs, t) : "—"}
{createdAtTs ? relativeTime(createdAtTs) : "—"}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-[10px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
{t("batchFilesExpires")}
Expires
</span>
<span className="text-sm text-[var(--color-text-main)]">
{expiresAtTs ? relativeExpiration(expiresAtTs, t) : t("batchFilesNeverExpires")}
{expiresAtTs ? relativeExpiration(expiresAtTs) : "Never"}
</span>
</div>
</div>
@@ -216,7 +198,7 @@ export default function FileDetailModal({
{relatedBatches.length > 0 && (
<div>
<h3 className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)] mb-2">
{t("batchFileUsedByCount", { count: relatedBatches.length })}
Used by {relatedBatches.length} batch{relatedBatches.length > 1 ? "es" : ""}
</h3>
<div className="space-y-1.5">
{relatedBatches.map((b) => (
@@ -237,9 +219,7 @@ export default function FileDetailModal({
: "bg-gray-500/15 text-gray-400 border-gray-500/25"
}`}
>
{BATCH_STATUS_TRANSLATION_KEYS[b.status]
? t(BATCH_STATUS_TRANSLATION_KEYS[b.status])
: b.status.replaceAll("_", " ")}
{b.status.replaceAll("_", " ")}
</span>
</div>
))}
@@ -251,7 +231,7 @@ export default function FileDetailModal({
<div className="flex-1 flex flex-col min-h-[300px]">
<div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
{t("batchFilePreview")}
Preview
</h3>
{contents && (
<button
@@ -261,7 +241,7 @@ export default function FileDetailModal({
<span className="material-symbols-outlined text-[14px]">
{copied ? "check" : "content_copy"}
</span>
{copied ? t("copied") : t("copy")}
{copied ? "Copied!" : "Copy"}
</button>
)}
</div>
@@ -279,7 +259,7 @@ export default function FileDetailModal({
{isTruncated && (
<div className="mt-3 p-3 bg-yellow-500/10 border border-yellow-500/25 rounded-lg text-xs text-yellow-400 flex items-center gap-2">
<span className="material-symbols-outlined text-[16px]">warning</span>
{t("batchFilePreviewTruncated", { shown: 1000, total: lineCount })}
Showing first 1000 lines ({lineCount} total lines)
</div>
)}
</div>
@@ -301,7 +281,7 @@ export default function FileDetailModal({
className="flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-lg bg-[var(--color-accent)] text-white hover:opacity-90 transition-opacity"
>
<span className="material-symbols-outlined text-[18px]">download</span>
{t("batchFileDownloadFull")}
Download Full File
</Button>
</div>
</div>

View File

@@ -1,16 +0,0 @@
export type ChaosTranslator = ((
key: string,
values?: Record<string, string | number>
) => string) & {
has?: (key: string) => boolean;
};
export function chaosText(
t: ChaosTranslator,
key: string,
fallback: string,
values?: Record<string, string | number>
): string {
if (typeof t.has !== "function" || !t.has(key)) return fallback;
return values ? t(key, values) : t(key);
}

View File

@@ -1,7 +1,6 @@
"use client";
import { useTranslations } from "next-intl";
import { chaosText, type ChaosTranslator } from "../chaosI18n";
/**
* Save/Reset + Test-run action buttons for the Chaos Mode config page.
@@ -23,7 +22,7 @@ export function ChaosConfigActionsBar({
onReset: () => void;
onTest: () => void;
}) {
const t = useTranslations("chaosConfig") as ChaosTranslator;
const t = useTranslations("chaosConfig");
return (
<>
@@ -67,7 +66,7 @@ export function ChaosConfigActionsBar({
) : (
<span className="material-symbols-outlined text-[16px]">play_arrow</span>
)}
{testing ? chaosText(t, "running", "Running...") : t("testButton")}
{testing ? "Running..." : t("testButton")}
</button>
</div>
</>

View File

@@ -1,8 +1,5 @@
"use client";
import { useTranslations } from "next-intl";
import { chaosText, type ChaosTranslator } from "../chaosI18n";
export interface ChaosModelResult {
providerId: string;
providerName: string;
@@ -29,24 +26,14 @@ export interface ChaosTestResult {
* complexity/size ratchet (config/quality/complexity-baseline.json).
*/
export function ChaosTestResultsPanel({ result }: { result: ChaosTestResult }) {
const t = useTranslations("chaosConfig") as ChaosTranslator;
const resultsTitle = chaosText(
t,
"testResults",
`Test Results — ${result.mode} mode (${result.totalProviders} providers)`,
{ mode: result.mode, count: result.totalProviders }
);
const startedLabel = chaosText(
t,
"started",
`Started: ${new Date(result.startedAt).toLocaleTimeString()}`,
{ time: new Date(result.startedAt).toLocaleTimeString() }
);
return (
<div className="p-3 rounded-lg border border-border bg-surface/40 space-y-3">
<h3 className="text-sm font-bold text-text-main">{resultsTitle}</h3>
<div className="text-xs text-text-muted">{startedLabel}</div>
<h3 className="text-sm font-bold text-text-main">
Test Results {result.mode} mode ({result.totalProviders} providers)
</h3>
<div className="text-xs text-text-muted">
Started: {new Date(result.startedAt).toLocaleTimeString()}
</div>
{result.models.map((model, idx) => (
<div
key={idx}

View File

@@ -1,13 +1,11 @@
/**
* /dashboard/chaos/page.tsx — Chaos Mode Configuration
*/
import { getTranslations } from "next-intl/server";
import ChaosConfigPageClient from "./ChaosConfigPageClient";
export async function generateMetadata() {
const t = await getTranslations("chaosConfig");
return { title: `${t("pageTitle")} — OmniRoute` };
}
export const metadata = {
title: "Chaos Mode — OmniRoute",
};
export default function Page() {
return <ChaosConfigPageClient />;

View File

@@ -2,7 +2,6 @@
import { useCallback, useState, type Dispatch, type SetStateAction } from "react";
import { useTranslations } from "next-intl";
import { chaosText, type ChaosTranslator } from "./chaosI18n";
import type { ChaosPageConfig, ChaosPageMessage } from "./chaosPageTypes";
/**
@@ -14,7 +13,7 @@ export function useChaosConfigPersistence(
config: ChaosPageConfig,
setConfig: Dispatch<SetStateAction<ChaosPageConfig>>
) {
const t = useTranslations("chaosConfig") as ChaosTranslator;
const t = useTranslations("chaosConfig");
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<ChaosPageMessage>(null);
@@ -50,26 +49,17 @@ export function useChaosConfigPersistence(
if (res.ok) {
const data = await res.json();
setConfig(data.config);
setMessage({
type: "success",
text: chaosText(t, "resetSuccess", "Config reset to defaults"),
});
setMessage({ type: "success", text: "Config reset to defaults" });
} else {
const err = await res.json().catch(() => ({ error: null }));
setMessage({
type: "error",
text: err.error || chaosText(t, "resetFailed", "Reset failed"),
});
const err = await res.json().catch(() => ({ error: "Reset failed" }));
setMessage({ type: "error", text: err.error || "Reset failed" });
}
} catch {
setMessage({
type: "error",
text: chaosText(t, "resetFailed", "Failed to reset config"),
});
setMessage({ type: "error", text: "Failed to reset config" });
} finally {
setSaving(false);
}
}, [setConfig, t]);
}, [setConfig]);
return { t, saving, message, setMessage, saveConfig, resetConfig };
}

View File

@@ -2,7 +2,6 @@
import { useCallback, useState } from "react";
import { useTranslations } from "next-intl";
import { chaosText, type ChaosTranslator } from "./chaosI18n";
import type { ChaosTestResult } from "./components/ChaosTestResultsPanel";
import type { ChaosPageConfig, ChaosPageMessage } from "./chaosPageTypes";
@@ -11,11 +10,8 @@ import type { ChaosPageConfig, ChaosPageMessage } from "./chaosPageTypes";
* of the page component to keep it under the complexity/size ratchet
* (config/quality/complexity-baseline.json).
*/
export function useChaosTestRun(
config: ChaosPageConfig,
setMessage: (message: ChaosPageMessage) => void
) {
const t = useTranslations("chaosConfig") as ChaosTranslator;
export function useChaosTestRun(config: ChaosPageConfig, setMessage: (message: ChaosPageMessage) => void) {
const t = useTranslations("chaosConfig");
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<ChaosTestResult | null>(null);
@@ -38,17 +34,11 @@ export function useChaosTestRun(
const data: ChaosTestResult = await res.json();
setTestResult(data);
} else {
const err = await res.json().catch(() => ({ error: null }));
setMessage({
type: "error",
text: err.error || chaosText(t, "testFailed", "Test failed"),
});
const err = await res.json().catch(() => ({ error: "Unknown error" }));
setMessage({ type: "error", text: err.error || "Test failed" });
}
} catch (err: any) {
setMessage({
type: "error",
text: err.message || chaosText(t, "testFailed", "Test failed"),
});
setMessage({ type: "error", text: err.message || "Test failed" });
} finally {
setTesting(false);
}

View File

@@ -1,7 +1,6 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Card, Button } from "@/shared/components";
interface ToolState {
@@ -25,7 +24,6 @@ interface UpdateInfo {
}
export default function CliproxyapiToolCard({ isExpanded = false, onToggle = () => {} }) {
const t = useTranslations("cliTools");
const [toolState, setToolState] = useState<ToolState | null>(null);
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
const [loading, setLoading] = useState<string | null>(null);
@@ -73,7 +71,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: data.message || t("cliproxyapiActionSucceeded") });
setMessage({ type: "success", text: data.message || `${action} succeeded` });
await fetchStatus();
if (action === "install" || action === "restart") await fetchUpdateInfo();
} else {
@@ -81,14 +79,11 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
type: "error",
text:
(typeof data.error === "string" ? data.error : data.error?.message) ||
t("cliproxyapiActionFailed"),
`${action} failed`,
});
}
} catch (err) {
setMessage({
type: "error",
text: err instanceof Error ? err.message : t("cliproxyapiRequestFailed"),
});
setMessage({ type: "error", text: err instanceof Error ? err.message : "Request failed" });
} finally {
setLoading(null);
}
@@ -98,26 +93,14 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
if (!toolState) return null;
const s = toolState.status;
const map: Record<string, { label: string; color: string }> = {
running: {
label: t("cliproxyapiStatusRunning"),
color: "bg-green-500/10 text-green-600 dark:text-green-400",
},
stopped: {
label: t("cliproxyapiStatusStopped"),
color: "bg-zinc-500/10 text-zinc-500 dark:text-zinc-400",
},
running: { label: "Running", color: "bg-green-500/10 text-green-600 dark:text-green-400" },
stopped: { label: "Stopped", color: "bg-zinc-500/10 text-zinc-500 dark:text-zinc-400" },
not_installed: {
label: t("cliproxyapiStatusNotInstalled"),
label: "Not Installed",
color: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400",
},
installed: {
label: t("cliproxyapiStatusInstalled"),
color: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
},
error: {
label: t("cliproxyapiStatusError"),
color: "bg-red-500/10 text-red-600 dark:text-red-400",
},
installed: { label: "Installed", color: "bg-blue-500/10 text-blue-600 dark:text-blue-400" },
error: { label: "Error", color: "bg-red-500/10 text-red-600 dark:text-red-400" },
};
const badge = map[s] || map.not_installed;
return (
@@ -142,7 +125,9 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
<h3 className="font-medium text-sm">CLIProxyAPI</h3>
{statusBadge()}
</div>
<p className="text-xs text-text-muted truncate">{t("cliproxyapiDescription")}</p>
<p className="text-xs text-text-muted truncate">
Upstream proxy fallback (Go-based OAuth)
</p>
</div>
</div>
<span
@@ -176,10 +161,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
system_update
</span>
<span className="text-sm text-yellow-700 dark:text-yellow-300">
{t("cliproxyapiUpdateAvailable", {
current: updateInfo.current,
latest: updateInfo.latest,
})}
Update available: v{updateInfo.current} v{updateInfo.latest}
</span>
</div>
<Button
@@ -188,34 +170,32 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
onClick={() => apiCall("install", { version: updateInfo.latest })}
loading={loading === "install"}
>
{t("cliproxyapiUpdate")}
Update
</Button>
</div>
)}
<div className="grid grid-cols-3 gap-3">
<div className="p-3 rounded-lg bg-bg-secondary">
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiVersion")}</p>
<p className="text-xs text-text-muted mb-1">Version</p>
<p className="text-sm font-medium">
{toolState?.installedVersion
? `v${toolState.installedVersion}`
: t("cliproxyapiNotInstalledValue")}
{toolState?.installedVersion ? `v${toolState.installedVersion}` : "Not installed"}
</p>
</div>
<div className="p-3 rounded-lg bg-bg-secondary">
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiHealth")}</p>
<p className="text-xs text-text-muted mb-1">Health</p>
<p
className={`text-sm font-medium ${toolState?.healthStatus === "healthy" ? "text-green-600 dark:text-green-400" : toolState?.healthStatus === "unhealthy" ? "text-red-600 dark:text-red-400" : "text-text-muted"}`}
>
{toolState?.healthStatus === "healthy"
? t("cliproxyapiHealthy")
? `Healthy`
: toolState?.healthStatus === "unhealthy"
? t("cliproxyapiUnhealthy")
: t("cliproxyapiUnknown")}
? "Unhealthy"
: "Unknown"}
</p>
</div>
<div className="p-3 rounded-lg bg-bg-secondary">
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiPort")}</p>
<p className="text-xs text-text-muted mb-1">Port</p>
<p className="text-sm font-mono">{toolState?.port || 8317}</p>
</div>
</div>
@@ -229,7 +209,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
loading={loading === "install"}
>
<span className="material-symbols-outlined text-[14px] mr-1">download</span>
{t("cliproxyapiInstall")}
Install
</Button>
)}
{toolState?.status === "running" ? (
@@ -240,7 +220,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
loading={loading === "stop"}
>
<span className="material-symbols-outlined text-[14px] mr-1">stop</span>
{t("cliproxyapiStop")}
Stop
</Button>
) : toolState?.installedVersion ? (
<Button
@@ -250,7 +230,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
loading={loading === "start"}
>
<span className="material-symbols-outlined text-[14px] mr-1">play_arrow</span>
{t("cliproxyapiStart")}
Start
</Button>
) : null}
{toolState?.status === "running" && (
@@ -261,7 +241,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
loading={loading === "restart"}
>
<span className="material-symbols-outlined text-[14px] mr-1">restart_alt</span>
{t("cliproxyapiRestart")}
Restart
</Button>
)}
<Button
@@ -271,7 +251,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
loading={loading === "check"}
>
<span className="material-symbols-outlined text-[14px] mr-1">sync</span>
{t("cliproxyapiCheckUpdates")}
Check Updates
</Button>
</div>
</div>

View File

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

View File

@@ -133,8 +133,8 @@ export default function IntelligentComboPanel({
<code className="rounded bg-black/5 dark:bg-white/5 px-2 py-1 text-text-main">
{combo?.name}
</code>
<span>{t("intelligentComboCount", { count: allCombos.length })}</span>
<span>{t("providersInScope", { count: providerScopeCount })}</span>
<span>{allCombos.length} intelligent combo(s)</span>
<span>{providerScopeCount} providers in scope</span>
</div>
</div>
@@ -161,7 +161,7 @@ export default function IntelligentComboPanel({
</div>
<div className="rounded-lg bg-black/5 dark:bg-white/5 px-3 py-2 text-right">
<p className="text-[10px] uppercase tracking-wide text-text-muted">
{getI18nOrFallback(t, "candidatePoolLabel", "Candidate Pool")}
Candidate Pool
</p>
<p className="text-lg font-semibold text-text-main">{providerScopeCount}</p>
</div>
@@ -183,12 +183,7 @@ export default function IntelligentComboPanel({
</p>
</div>
{savingModePack && (
<span className="text-[11px] text-text-muted">
{getI18nOrFallback(t, "savingModePack", "Saving {pack}…").replace(
"{pack}",
savingModePack
)}
</span>
<span className="text-[11px] text-text-muted">Saving {savingModePack}</span>
)}
</div>
@@ -313,7 +308,7 @@ export default function IntelligentComboPanel({
</div>
<div className="rounded-lg border border-black/8 bg-white/60 p-3 dark:border-white/8 dark:bg-white/[0.03]">
<p className="text-[11px] uppercase tracking-wide text-text-muted">
{getI18nOrFallback(t, "explorationRateLabel", "Exploration Rate")}
Exploration Rate
</p>
<p className="mt-1 text-sm font-semibold text-text-main">
{Math.round(normalizedConfig.explorationRate * 100)}%

View File

@@ -1,7 +1,5 @@
"use client";
import { useTranslations } from "next-intl";
export default function CombosError({
error: _error,
reset,
@@ -9,8 +7,6 @@ export default function CombosError({
error: Error & { digest?: string };
reset: () => void;
}) {
const t = useTranslations("combos");
return (
<div
className="flex flex-col items-center justify-center min-h-[400px]"
@@ -18,10 +14,14 @@ export default function CombosError({
aria-live="assertive"
>
<div className="text-center space-y-4">
<h2 className="text-xl font-semibold text-red-600 dark:text-red-400">{t("errorTitle")}</h2>
<p className="text-text-muted max-w-md">{t("errorDescription")}</p>
<h2 className="text-xl font-semibold text-red-600 dark:text-red-400">
Failed to load combos
</h2>
<p className="text-text-muted max-w-md">
We could not load combo data right now. Check your connection and try again.
</p>
{_error?.digest && (
<p className="text-xs text-text-muted font-mono">{t("errorId", { id: _error.digest })}</p>
<p className="text-xs text-text-muted font-mono">Error ID: {_error.digest}</p>
)}
{process.env.NODE_ENV === "development" && _error?.message && (
<p className="text-xs text-red-600 dark:text-red-400 font-mono">{_error.message}</p>
@@ -30,7 +30,7 @@ export default function CombosError({
onClick={reset}
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors focus:outline-2 focus:outline-offset-2 focus:outline-primary"
>
{t("errorRetry")}
Try Again
</button>
</div>
</div>

View File

@@ -1,13 +1,9 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
export async function generateMetadata() {
const t = await getTranslations("metadata");
return {
title: t("compressionTitle"),
description: t("compressionDescription"),
};
}
export const metadata = {
title: "Compression",
description: "Configure context compression settings to reduce token usage and costs.",
};
export default function CompressionPage() {
redirect("/dashboard/context/caveman");

View File

@@ -534,10 +534,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
if (!cloudConfigured) {
setCloudStatus({
type: "warning",
message: translateOrFallback(
"cloudSyncNotConfigured",
"Cloud sync is not configured on this instance."
),
message: "Cloud sync is not configured on this instance.",
});
return;
}
@@ -1825,7 +1822,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
<div className="flex items-center gap-2 mb-3">
<span className="material-symbols-outlined text-sm text-primary">hub</span>
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
{t("categoryCore")}
{t("categoryCore") || "Core APIs"}
</h3>
<div className="flex-1 h-px bg-border/50" />
</div>
@@ -1846,7 +1843,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
icon="code"
iconColor="text-indigo-500"
iconBg="bg-indigo-500/10"
title={t("responses")}
title={t("responses") || "Responses API"}
path="/v1/responses"
models={endpointData.chat}
copy={copy}
@@ -1858,7 +1855,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
icon="text_fields"
iconColor="text-orange-500"
iconBg="bg-orange-500/10"
title={t("completionsLegacy")}
title={t("completionsLegacy") || "Completions (Legacy)"}
path="/v1/completions"
models={endpointData.chat}
copy={copy}
@@ -1870,7 +1867,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
icon="psychology"
iconColor="text-violet-500"
iconBg="bg-violet-500/10"
title={t("messagesApi")}
title={t("messagesApi") || "Messages"}
path="/v1/messages"
models={null}
badge="Anthropic"
@@ -1886,7 +1883,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
<div className="flex items-center gap-2 mb-3">
<span className="material-symbols-outlined text-sm text-purple-400">perm_media</span>
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
{t("categoryMedia")}
{t("categoryMedia") || "Media & Multi-Modal"}
</h3>
<div className="flex-1 h-px bg-border/50" />
</div>
@@ -1919,7 +1916,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
icon="edit_square"
iconColor="text-violet-500"
iconBg="bg-violet-500/10"
title={t("imageEdits")}
title={t("imageEdits") || "Image Edits"}
path="/v1/images/edits"
models={endpointData.images}
copy={copy}
@@ -1955,7 +1952,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
icon="music_note"
iconColor="text-fuchsia-500"
iconBg="bg-fuchsia-500/10"
title={t("musicGeneration")}
title={t("musicGeneration") || "Music Generation"}
path="/v1/music/generations"
models={endpointData.music}
copy={copy}
@@ -1967,7 +1964,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
icon="videocam"
iconColor="text-red-500"
iconBg="bg-red-500/10"
title={t("videoGeneration")}
title={t("videoGeneration") || "Video Generation"}
path="/v1/videos/generations"
models={endpointData.video}
copy={copy}
@@ -1986,7 +1983,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
travel_explore
</span>
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
{t("categorySearch")}
{t("categorySearch") || "Search & Discovery"}
</h3>
<div className="flex-1 h-px bg-border/50" />
</div>
@@ -1995,7 +1992,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
icon="search"
iconColor="text-cyan-500"
iconBg="bg-cyan-500/10"
title={t("webSearch")}
title={t("webSearch") || "Web Search"}
path="/v1/search"
models={searchProviders.map((p) => ({ id: p.id, owned_by: p.id, type: "search" }))}
copy={copy}
@@ -2011,7 +2008,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
<div className="flex items-center gap-2 mb-3">
<span className="material-symbols-outlined text-sm text-amber-400">build</span>
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
{t("categoryUtility")}
{t("categoryUtility") || "Utility & Management"}
</h3>
<div className="flex-1 h-px bg-border/50" />
</div>
@@ -2044,7 +2041,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
icon="view_list"
iconColor="text-teal-500"
iconBg="bg-teal-500/10"
title={t("batchApi")}
title={t("batchApi") || "Batch API"}
path="/v1/batches"
models={null}
badge="OpenAI"
@@ -2056,7 +2053,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
icon="folder"
iconColor="text-yellow-500"
iconBg="bg-yellow-500/10"
title={t("filesApi")}
title={t("filesApi") || "Files API"}
path="/v1/files"
models={null}
copy={copy}
@@ -2067,7 +2064,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
icon="list"
iconColor="text-teal-500"
iconBg="bg-teal-500/10"
title={t("listModels")}
title={t("listModels") || "List Models"}
path="/v1/models"
models={null}
copy={copy}

View File

@@ -429,7 +429,7 @@ export default function A2ADashboardPage() {
<th className="text-left py-2 pr-2">{t("tableTask")}</th>
<th className="text-left py-2 pr-2">{t("tableSkill")}</th>
<th className="text-left py-2 pr-2">{t("tableState")}</th>
<th className="text-left py-2 pr-2">{t("tablePhase")}</th>
<th className="text-left py-2 pr-2">{t("tablePhase") || "FSM Status"}</th>
<th className="text-left py-2 pr-2">{t("tableUpdated")}</th>
<th className="text-left py-2">{t("tableActions")}</th>
</tr>

View File

@@ -12,18 +12,6 @@ export default function NotionSourceCard() {
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
const [expanded, setExpanded] = useState(false);
const translateOrFallback = (key: string, fallback: string) => {
try {
const translated = t(key as never);
if (!translated || translated === key || translated === `endpoint.${key}`) {
return fallback;
}
return translated;
} catch {
return fallback;
}
};
const fetchConfig = useCallback(async () => {
try {
const res = await fetch("/api/settings/notion");
@@ -49,10 +37,7 @@ export default function NotionSourceCard() {
const handleSaveToken = async () => {
if (!token.trim()) {
setMessage({
type: "error",
text: translateOrFallback("notionEnterToken", "Please enter a Notion integration token"),
});
setMessage({ type: "error", text: "Please enter a Notion integration token" });
return;
}
setBusy(true);
@@ -68,20 +53,11 @@ export default function NotionSourceCard() {
setConnected(true);
setMessage({ type: "success", text: data.message });
} else {
setMessage({
type: "error",
text: data.error ?? translateOrFallback("notionConnectFailed", "Failed to connect"),
});
setMessage({ type: "error", text: data.error ?? "Failed to connect" });
setConnected(false);
}
} catch (err) {
setMessage({
type: "error",
text:
err instanceof Error
? err.message
: translateOrFallback("notionConnectionFailed", "Connection failed"),
});
setMessage({ type: "error", text: err instanceof Error ? err.message : "Connection failed" });
} finally {
setBusy(false);
}
@@ -98,19 +74,10 @@ export default function NotionSourceCard() {
setToken("");
setMessage({ type: "success", text: data.message });
} else {
setMessage({
type: "error",
text: data.error ?? translateOrFallback("notionDisconnectFailed", "Failed to disconnect"),
});
setMessage({ type: "error", text: data.error ?? "Failed to disconnect" });
}
} catch (err) {
setMessage({
type: "error",
text:
err instanceof Error
? err.message
: translateOrFallback("notionDisconnectFailed", "Disconnect failed"),
});
setMessage({ type: "error", text: err instanceof Error ? err.message : "Disconnect failed" });
} finally {
setBusy(false);
}
@@ -130,16 +97,11 @@ export default function NotionSourceCard() {
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-sm">Notion</span>
<Badge variant={connected ? "success" : "default"}>
{connected
? translateOrFallback("notionConnected", "Connected")
: translateOrFallback("notionNotConnected", "Not connected")}
{connected ? "Connected" : "Not connected"}
</Badge>
</div>
<p className="text-xs text-text-muted mt-0.5">
{translateOrFallback(
"notionDescription",
"Search, read, query, and write to Notion through routed AI models"
)}
Search, read, query, and write to Notion through routed AI models
</p>
</div>
<span
@@ -169,10 +131,7 @@ export default function NotionSourceCard() {
{!connected ? (
<div className="flex flex-col gap-2">
<label className="text-xs text-text-muted font-medium">
{translateOrFallback(
"notionIntegrationToken",
"Notion Internal Integration Token"
)}
Notion Internal Integration Token
</label>
<div className="flex gap-2">
<Input
@@ -184,14 +143,11 @@ export default function NotionSourceCard() {
className="font-mono text-sm flex-1"
/>
<Button onClick={handleSaveToken} loading={busy} variant="primary" size="sm">
{translateOrFallback("notionConnect", "Connect")}
Connect
</Button>
</div>
<p className="text-[10px] text-text-muted">
{translateOrFallback(
"notionIntegrationHelp",
"Create an Internal Integration at"
)}{" "}
Create an Internal Integration at{" "}
<code className="text-primary font-mono bg-surface/80 px-1 rounded">
https://www.notion.so/profile/integrations
</code>
@@ -200,10 +156,7 @@ export default function NotionSourceCard() {
) : (
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted flex-1">
{translateOrFallback(
"notionTokenConfigured",
"Token configured. Notion tools are available via MCP."
)}
Token configured. Notion tools are available via MCP.
</span>
<Button
onClick={handleDisconnect}
@@ -212,7 +165,7 @@ export default function NotionSourceCard() {
size="sm"
className="border-red-500/30! text-red-400! hover:bg-red-500/10!"
>
{translateOrFallback("notionDisconnect", "Disconnect")}
Disconnect
</Button>
</div>
)}

View File

@@ -15,7 +15,11 @@ import {
getCodexEffectiveServiceTier,
type CodexGlobalServiceMode,
} from "@/lib/providers/codexFastTier";
import { normalizeCodexLimitPolicy, providerText, ERROR_TYPE_LABELS } from "../providerPageHelpers";
import {
normalizeCodexLimitPolicy,
providerText,
ERROR_TYPE_LABELS,
} from "../providerPageHelpers";
import { getCodexPlanLabel } from "../codexPlanLabel";
import ProviderQuotaVisibilityToggle from "./ProviderQuotaVisibilityToggle";
@@ -241,7 +245,7 @@ function getStatusPresentation(
if (errorType === "account_deactivated") {
return {
statusVariant: "error",
statusLabel: providerText(t, "statusDeactivated", "Deactivated"),
statusLabel: t("statusDeactivated", "Deactivated"),
errorType,
errorBadge,
errorTextClass: "text-red-600 font-bold",
@@ -296,7 +300,7 @@ function getStatusPresentation(
if (errorType === "banned") {
return {
statusVariant: "error",
statusLabel: providerText(t, "statusBanned", "Banned (403)"),
statusLabel: t("statusBanned", "Banned (403)"),
errorType,
errorBadge,
errorTextClass: "text-red-600 font-bold",
@@ -306,7 +310,7 @@ function getStatusPresentation(
if (errorType === "credits_exhausted") {
return {
statusVariant: "warning",
statusLabel: providerText(t, "statusCreditsExhausted", "Out of Credits"),
statusLabel: t("statusCreditsExhausted", "Out of Credits"),
errorType,
errorBadge,
errorTextClass: "text-amber-500",
@@ -387,10 +391,22 @@ export default function ConnectionRow({
t("oauthAccount")
)
: connection.name;
const applyCodexAuthLabel = providerText(t, "applyCodexAuthLocal", "Apply auth");
const exportCodexAuthLabel = providerText(t, "exportCodexAuthFile", "Export auth");
const applyClaudeAuthLabel = providerText(t, "applyClaudeAuthLocal", "Apply auth");
const exportClaudeAuthLabel = providerText(t, "exportClaudeAuthFile", "Export auth");
const applyCodexAuthLabel =
typeof t.has === "function" && t.has("applyCodexAuthLocal")
? t("applyCodexAuthLocal")
: "Apply auth";
const exportCodexAuthLabel =
typeof t.has === "function" && t.has("exportCodexAuthFile")
? t("exportCodexAuthFile")
: "Export auth";
const applyClaudeAuthLabel =
typeof t.has === "function" && t.has("applyClaudeAuthLocal")
? t("applyClaudeAuthLocal")
: "Apply auth";
const exportClaudeAuthLabel =
typeof t.has === "function" && t.has("exportClaudeAuthFile")
? t("exportClaudeAuthFile")
: "Export auth";
// Use useState + useEffect for impure Date.now() to avoid calling during render
const [isCooldown, setIsCooldown] = useState(false);
// T12: token expiry status — lazy init avoids calling Date.now() during render;

View File

@@ -22,10 +22,8 @@
*/
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { formatResetCountdown } from "@/shared/utils/formatting";
import type { ConnectionRowConnection } from "./ConnectionRow";
import { providerText } from "../providerPageHelpers";
export interface CoolingConnectionsPanelProps {
readonly connections: readonly ConnectionRowConnection[];
@@ -39,7 +37,6 @@ function isCoolingNow(connection: ConnectionRowConnection, now: number): boolean
export default function CoolingConnectionsPanel(props: CoolingConnectionsPanelProps) {
const { connections } = props;
const t = useTranslations("providers");
// Tick once per second so the human-readable countdown updates.
const [now, setNow] = useState<number>(() => Date.now());
useEffect(() => {
@@ -61,17 +58,12 @@ export default function CoolingConnectionsPanel(props: CoolingConnectionsPanelPr
className="inline-block h-2 w-2 animate-pulse rounded-full bg-amber-500"
/>
<h3 className="text-sm font-medium text-amber-700 dark:text-amber-300">
{providerText(t, "coolingConnectionsTitle", "Currently cooling ({count})", {
count: cooling.length,
})}
Currently cooling ({cooling.length})
</h3>
</div>
<p className="mb-3 text-xs text-muted-foreground">
{providerText(
t,
"coolingConnectionsDescription",
"These connections returned a 429 (rate-limit) on their last request. OmniRoute will skip them until the timer expires — no manual disable required."
)}
These connections returned a 429 (rate-limit) on their last request. OmniRoute will skip
them until the timer expires no manual disable required.
</p>
<ul className="space-y-1">
{cooling.map((c) => {
@@ -80,9 +72,7 @@ export default function CoolingConnectionsPanel(props: CoolingConnectionsPanelPr
c.displayName ||
c.name ||
c.email ||
(c.id
? `${providerText(t, "connectionFallback", "connection")} ${c.id.slice(0, 8)}`
: providerText(t, "connectionFallback", "connection"));
(c.id ? `connection ${c.id.slice(0, 8)}` : "connection");
return (
<li
key={c.id ?? label}

View File

@@ -21,7 +21,6 @@ import {
effectivePreserveForProtocol,
effectiveUpstreamHeadersForProtocol,
formatProviderModelsErrorResponse,
providerText,
targetFormatBadgeI18nKey,
type CompatModelRow,
type CompatByProtocolMap,
@@ -286,32 +285,17 @@ export default function CustomModelsSection({
if (!res.ok) {
const detail = await formatProviderModelsErrorResponse(res);
throw new Error(
detail ||
providerText(
t,
"failedSaveModelEndpointSettings",
"Failed to save model endpoint settings"
)
);
throw new Error(detail || "Failed to save model endpoint settings");
}
await fetchCustomModels();
onModelsChanged?.();
notify.success(
providerText(t, "savedModelEndpointSettings", "Saved model endpoint settings")
);
notify.success("Saved model endpoint settings");
cancelEdit();
} catch (e) {
console.error("Failed to save custom model:", e);
notify.error(
e instanceof Error && e.message
? e.message
: providerText(
t,
"failedSaveModelEndpointSettings",
"Failed to save model endpoint settings"
)
e instanceof Error && e.message ? e.message : "Failed to save model endpoint settings"
);
} finally {
setSavingModelId(null);
@@ -321,9 +305,7 @@ export default function CustomModelsSection({
const saveEdit = async (modelId: string) => {
if (!editingModelId || editingModelId !== modelId) return;
if (!editingEndpoints.length) {
notify.error(
providerText(t, "selectSupportedEndpoint", "Select at least one supported endpoint")
);
notify.error("Select at least one supported endpoint");
return;
}
@@ -447,7 +429,7 @@ export default function CustomModelsSection({
: ep === "embeddings"
? `📐 ${t("supportedEndpointEmbeddings")}`
: ep === "rerank"
? providerText(t, "rerankEndpoint", "Rerank")
? "Rerank"
: ep === "images"
? `🖼️ ${t("supportedEndpointImages")}`
: `🔊 ${t("supportedEndpointAudio")}`}
@@ -679,7 +661,7 @@ export default function CustomModelsSection({
: ep === "embeddings"
? `📐 ${t("supportedEndpointEmbeddings")}`
: ep === "rerank"
? providerText(t, "rerankEndpoint", "Rerank")
? "Rerank"
: ep === "images"
? `🖼️ ${t("supportedEndpointImages")}`
: `🔊 ${t("supportedEndpointAudio")}`}

View File

@@ -1,7 +1,7 @@
"use client";
import { Modal } from "@/shared/components";
import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers";
import type { ProviderMessageTranslator } from "../providerPageHelpers";
type KimiCodeAuthMethodModalProps = {
isOpen: boolean;
@@ -50,9 +50,7 @@ export default function KimiCodeAuthMethodModal({
<div className="flex items-start gap-3">
<span className="material-symbols-outlined mt-0.5 text-primary">key</span>
<div className="min-w-0 flex-1">
<h3 className="mb-1 font-semibold">
{providerText(t, "kimiCodeApiKeyLabel", "Kimi Code API Key")}
</h3>
<h3 className="mb-1 font-semibold">Kimi Code API Key</h3>
<p className="text-sm text-text-muted">{t("apiKeySecure")}</p>
</div>
</div>

View File

@@ -13,7 +13,6 @@ import {
UPSTREAM_HEADERS_UI_MAX,
headerRowsToRecord,
compatProtocolLabelKey,
providerText,
type HeaderDraftRow,
} from "../providerPageHelpers";
@@ -354,7 +353,7 @@ export default function ModelCompatPopover({
{/* Param filters — model-level block/allow (#6625) */}
<div className="mt-4 space-y-2.5">
<label className="block text-[11px] font-semibold text-text-main">
{providerText(t, "compatParamFiltersLabel", "Param Filters")}
{t("compatParamFiltersLabel") ?? "Param Filters"}
</label>
<div>
<input
@@ -370,11 +369,7 @@ export default function ModelCompatPopover({
className="mb-1 w-full rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-[11px] font-mono text-text-main placeholder:text-text-muted focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 dark:border-zinc-600 dark:bg-zinc-900"
/>
<p className="text-[10px] text-text-muted">
{providerText(
t,
"compatBlockedParamsHint",
"Blocked params (stripped from requests)"
)}
{t("compatBlockedParamsHint") ?? "Blocked params (stripped from requests)"}
{paramSaving && `${t("compatSaving")}`}
</p>
</div>
@@ -392,11 +387,7 @@ export default function ModelCompatPopover({
className="mb-1 w-full rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-[11px] font-mono text-text-main placeholder:text-text-muted focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 dark:border-zinc-600 dark:bg-zinc-900"
/>
<p className="text-[10px] text-text-muted">
{providerText(
t,
"compatAllowedParamsHint",
"Allowed params (re-added after deny)"
)}
{t("compatAllowedParamsHint") ?? "Allowed params (re-added after deny)"}
</p>
</div>
</div>

View File

@@ -411,7 +411,7 @@ export default function ModelRow({
: testStatus === "ok"
? "OK"
: testStatus === "error"
? providerText(t, "errorShort", "Error")
? "Error"
: t("testModel")
}
>

View File

@@ -5,7 +5,6 @@ import { useTranslations } from "next-intl";
import { NoAuthAccountCard, NoAuthProviderCard } from "@/shared/components";
import { getProviderAlias, supportsNoAuthProviderProxy } from "@/shared/constants/providers";
import { useNotificationStore } from "@/store/notificationStore";
import { providerText } from "../providerPageHelpers";
const ACCOUNT_PROVIDER_NAMES: Record<string, string> = {
mimocode: "MiMoCode",
@@ -124,10 +123,7 @@ export default function NoAuthProviderControls({
const res = await fetch("/api/dahl/tokens", { method: "POST" });
const data = await res.json();
if (!res.ok || !data.token) {
throw new Error(
data?.error ||
providerText(t, "createDahlTokenFailed", "Failed to create Dahl token")
);
throw new Error(data?.error || "Failed to create Dahl token");
}
return data.token as string;
}

View File

@@ -190,7 +190,7 @@ export default function PassthroughModelRow({
: testStatus === "ok"
? "OK"
: testStatus === "error"
? providerText(t, "errorShort", "Error")
? "Error"
: t("testModel")
}
>

View File

@@ -6,7 +6,6 @@
// a single-kind panel or the LlmChatCard for standard LLM providers.
import { useState } from "react";
import { useTranslations } from "next-intl";
import { LlmChatCard } from "@/app/(dashboard)/dashboard/media-providers/components/LlmChatCard";
import { ServiceKindTabs } from "@/app/(dashboard)/dashboard/media-providers/components/ServiceKindTabs";
import { EmbeddingExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/EmbeddingExampleCard";
@@ -19,7 +18,6 @@ import { VideoExampleCard } from "@/app/(dashboard)/dashboard/media-providers/co
import { MusicExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/MusicExampleCard";
import type { ServiceKind } from "@/shared/constants/providers";
import { AI_PROVIDERS } from "@/shared/constants/providers";
import { providerText } from "../providerPageHelpers";
export const MEDIA_SERVICE_KINDS: ServiceKind[] = [
"embedding",
@@ -58,12 +56,12 @@ export function renderKindPanel(kind: ServiceKind, providerId: string): JSX.Elem
}
export default function ProviderPlaygroundPanel({ providerId }: { providerId: string }) {
const t = useTranslations("providers");
// Resolve serviceKinds from AI_PROVIDERS.
// For providers without explicit serviceKinds (most LLM providers), we infer
// "llm" as the default.
const providerEntry = AI_PROVIDERS[providerId as keyof typeof AI_PROVIDERS] as
(Record<string, unknown> & { serviceKinds?: string[] }) | undefined;
| (Record<string, unknown> & { serviceKinds?: string[] })
| undefined;
const rawKinds: string[] = providerEntry?.serviceKinds ?? [];
@@ -95,7 +93,7 @@ export default function ProviderPlaygroundPanel({ providerId }: { providerId: st
return (
<div className="flex flex-col gap-3">
<h2 className="text-lg font-semibold">{providerText(t, "playgroundTitle", "Playground")}</h2>
<h2 className="text-lg font-semibold">Playground</h2>
<ServiceKindTabs
kinds={playgroundableKinds}
activeKind={activeKind}

View File

@@ -3,7 +3,6 @@ import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Button, Badge, Input, Modal, Select } from "@/shared/components";
import { CC_COMPATIBLE_DEFAULT_CHAT_PATH } from "../../providerDetailConstants";
import { providerText } from "../../providerPageHelpers";
interface EditCompatibleNodeModalNode {
id?: string;
name?: string;
@@ -46,11 +45,9 @@ export default function EditCompatibleNodeModal({
const [checkKey, setCheckKey] = useState("");
const [checkModelId, setCheckModelId] = useState("");
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState<null | {
valid: boolean;
error?: string | null;
method?: string | null;
}>(null);
const [validationResult, setValidationResult] = useState<
null | { valid: boolean; error?: string | null; method?: string | null }
>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
useEffect(() => {
@@ -133,10 +130,7 @@ export default function EditCompatibleNodeModal({
method: data.method ?? null,
});
} catch {
setValidationResult({
valid: false,
error: providerText(t, "networkError", "Network error"),
});
setValidationResult({ valid: false, error: "Network error" });
} finally {
setValidating(false);
}

View File

@@ -1,6 +1,5 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import { providerText, type CommandCodeAuthFlowState } from "../providerPageHelpers";
import { type CommandCodeAuthFlowState } from "../providerPageHelpers";
export type UseCommandCodeAuthParams = {
providerId: string;
@@ -16,7 +15,6 @@ export function useCommandCodeAuth({
setShowAddApiKeyModal,
notify,
}: UseCommandCodeAuthParams) {
const t = useTranslations("providers");
const [commandCodeAuthState, setCommandCodeAuthState] = useState<CommandCodeAuthFlowState>({
phase: "idle",
state: "",
@@ -64,7 +62,7 @@ export function useCommandCodeAuth({
setCommandCodeAuthState((current) => ({
...current,
phase: "applying",
message: providerText(t, "commandCodeApplyingKey", "Applying browser-approved key…"),
message: "Applying browser-approved key…",
}));
try {
@@ -76,9 +74,7 @@ export function useCommandCodeAuth({
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const errorMessage =
data.error ||
providerText(t, "commandCodeApplyFailed", "Failed to apply Command Code auth");
const errorMessage = data.error || "Failed to apply Command Code auth";
setCommandCodeAuthState((current) => ({
...current,
phase: "error",
@@ -91,30 +87,26 @@ export function useCommandCodeAuth({
setCommandCodeAuthState((current) => ({
...current,
phase: "applied",
message: providerText(t, "commandCodeConnected", "Command Code connected"),
message: "Command Code connected",
}));
commandCodeAuthWindowRef.current?.close?.();
commandCodeAuthWindowRef.current = null;
await fetchConnections();
handleCloseAddApiKeyModal();
notify.success(
providerText(t, "commandCodeConnectionAdded", "Command Code connection added")
);
notify.success("Command Code connection added");
return true;
} catch (error) {
console.error("Error applying Command Code auth:", error);
setCommandCodeAuthState((current) => ({
...current,
phase: "error",
message: providerText(t, "commandCodeApplyFailed", "Failed to apply Command Code auth"),
message: "Failed to apply Command Code auth",
}));
notify.error(
providerText(t, "commandCodeApplyFailed", "Failed to apply Command Code auth")
);
notify.error("Failed to apply Command Code auth");
return false;
}
},
[fetchConnections, handleCloseAddApiKeyModal, notify, t]
[fetchConnections, handleCloseAddApiKeyModal, notify]
);
const handleStartCommandCodeAuth = useCallback(async () => {
@@ -132,7 +124,7 @@ export function useCommandCodeAuth({
authUrl: "",
callbackUrl: "",
expiresAt: null,
message: providerText(t, "commandCodeOpeningStudio", "Opening Command Code Studio…"),
message: "Opening Command Code Studio…",
});
try {
@@ -143,9 +135,7 @@ export function useCommandCodeAuth({
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.state || !data.authUrl) {
const errorMessage =
data.error ||
providerText(t, "commandCodeStartFailed", "Failed to start Command Code auth");
const errorMessage = data.error || "Failed to start Command Code auth";
setCommandCodeAuthState((current) => ({
...current,
phase: "error",
@@ -162,11 +152,7 @@ export function useCommandCodeAuth({
authUrl: data.authUrl,
callbackUrl: data.callbackUrl || "",
expiresAt: data.expiresAt || null,
message: providerText(
t,
"commandCodeApprovalInstructions",
"Open the auth URL, approve access, then paste the returned key/JSON/URL below…"
),
message: "Open the auth URL, approve access, then paste the returned key/JSON/URL below…",
});
if (popup) {
@@ -183,19 +169,9 @@ export function useCommandCodeAuth({
setCommandCodeAuthState((current) => ({
...current,
phase: "error",
message: providerText(
t,
"commandCodePopupBlocked",
"Popup blocked. Please allow popups and try Command Code Connect again."
),
message: "Popup blocked. Please allow popups and try Command Code Connect again.",
}));
notify.error(
providerText(
t,
"commandCodePopupBlocked",
"Popup blocked. Please allow popups and try Command Code Connect again."
)
);
notify.error("Popup blocked. Please allow popups and try Command Code Connect again.");
return;
}
commandCodeAuthWindowRef.current = fallbackPopup;
@@ -207,11 +183,11 @@ export function useCommandCodeAuth({
setCommandCodeAuthState((current) => ({
...current,
phase: "expired",
message: providerText(t, "commandCodeLinkExpired", "Command Code link expired"),
message: "Command Code link expired",
}));
commandCodeAuthWindowRef.current?.close?.();
commandCodeAuthWindowRef.current = null;
notify.error(providerText(t, "commandCodeAuthExpired", "Command Code auth expired"));
notify.error("Command Code auth expired");
clearCommandCodeAuthTimer();
return;
}
@@ -230,11 +206,11 @@ export function useCommandCodeAuth({
setCommandCodeAuthState((current) => ({
...current,
phase: "expired",
message: providerText(t, "commandCodeLinkExpired", "Command Code link expired"),
message: "Command Code link expired",
}));
commandCodeAuthWindowRef.current?.close?.();
commandCodeAuthWindowRef.current = null;
notify.error(providerText(t, "commandCodeAuthExpired", "Command Code auth expired"));
notify.error("Command Code auth expired");
clearCommandCodeAuthTimer();
return;
}
@@ -243,15 +219,13 @@ export function useCommandCodeAuth({
setCommandCodeAuthState((current) => ({
...current,
phase: "applied",
message: providerText(t, "commandCodeConnected", "Command Code connected"),
message: "Command Code connected",
}));
commandCodeAuthWindowRef.current?.close?.();
commandCodeAuthWindowRef.current = null;
await fetchConnections();
handleCloseAddApiKeyModal();
notify.success(
providerText(t, "commandCodeConnectionAdded", "Command Code connection added")
);
notify.success("Command Code connection added");
clearCommandCodeAuthTimer();
return;
}
@@ -260,11 +234,7 @@ export function useCommandCodeAuth({
setCommandCodeAuthState((current) => ({
...current,
phase: "received",
message: providerText(
t,
"commandCodeApplyingApproval",
"Browser approved, applying…"
),
message: "Browser approved, applying…",
}));
clearCommandCodeAuthTimer();
await handleCommandCodeAuthApply(
@@ -288,9 +258,9 @@ export function useCommandCodeAuth({
setCommandCodeAuthState((current) => ({
...current,
phase: "error",
message: providerText(t, "commandCodeStartFailed", "Failed to start Command Code auth"),
message: "Failed to start Command Code auth",
}));
notify.error(providerText(t, "commandCodeStartFailed", "Failed to start Command Code auth"));
notify.error("Failed to start Command Code auth");
popup?.close?.();
commandCodeAuthWindowRef.current = null;
clearCommandCodeAuthTimer();
@@ -302,7 +272,6 @@ export function useCommandCodeAuth({
fetchConnections,
handleCommandCodeAuthApply,
notify,
t,
]);
const handleOpenCommandCodeConnect = useCallback(() => {

View File

@@ -11,8 +11,6 @@
*/
import { useCallback, useState } from "react";
import { useTranslations } from "next-intl";
import { providerText } from "../providerPageHelpers";
export interface ConnectionDeleteConfirmTarget {
id: string;
@@ -36,7 +34,6 @@ export function useConnectionDeleteConfirm(
fetchConnections: () => Promise<void>,
notify: NotifyLike
): ConnectionDeleteConfirmState {
const t = useTranslations("providers");
const [connection, setConnection] = useState<ConnectionDeleteConfirmTarget | null>(null);
const [deleting, setDeleting] = useState(false);
@@ -59,24 +56,24 @@ export function useConnectionDeleteConfirm(
try {
const res = await fetch(`/api/providers/${connectionId}`, { method: "DELETE" });
if (res.ok) {
notify.success(providerText(t, "connectionDeleted", "Connection deleted"));
notify.success("Connection deleted");
await fetchConnections();
} else {
const data = await res.json().catch(() => ({}));
const message =
(typeof data?.error === "string" && data.error) ||
data?.error?.message ||
providerText(t, "failedDeleteConnection", "Failed to delete connection");
"Failed to delete connection";
notify.error(message);
}
} catch (error) {
console.error("Error deleting connection:", error);
notify.error(providerText(t, "failedDeleteConnection", "Failed to delete connection"));
notify.error("Failed to delete connection");
} finally {
setDeleting(false);
setConnection(null);
}
}, [connection, fetchConnections, notify, t]);
}, [connection, fetchConnections, notify]);
return { connection, deleting, request, confirm, cancel };
}

View File

@@ -316,13 +316,11 @@ export function useModelVisibilityHandlers({
// extractApiErrorMessage coerces any object-shaped `error` (e.g. a Zod
// format object) to a string so notify.error never hands the toast a
// non-string child (React #31 → frozen page).
notify.error(
extractApiErrorMessage(data, providerText(t, "modelTestFailed", "Model test failed"))
);
notify.error(extractApiErrorMessage(data, "Model test failed"));
setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" }));
}
} catch (err) {
notify.error(providerText(t, "modelTestNetworkError", "Network error testing model"));
notify.error("Network error testing model");
setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" }));
} finally {
setTestingModelId(null);

View File

@@ -27,7 +27,7 @@ import { useNotificationStore } from "@/store/notificationStore";
import { isClaudeCodeCompatibleProvider } from "@/shared/constants/providers";
import type { ConnectionRowConnection } from "../components/ConnectionRow";
import { connectionBelongsToProviderPage } from "../../providerPageUtils";
import { normalizeCodexLimitPolicy, providerText } from "../providerPageHelpers";
import { normalizeCodexLimitPolicy } from "../providerPageHelpers";
import { useProviderQuotaVisibility } from "./useProviderQuotaVisibility";
import { useReorderByAvailability } from "./useReorderByAvailability";
import {
@@ -378,14 +378,7 @@ export function useProviderConnections(
if (!res.ok) {
const data = await res.json().catch(() => ({}));
notify.error(
data.error ||
providerText(
t,
"failedUpdateClaudeExtraUsagePolicy",
"Failed to update Claude extra-usage policy"
)
);
notify.error(data.error || "Failed to update Claude extra-usage policy");
return;
}
@@ -415,26 +408,12 @@ export function useProviderConnections(
);
notify.success(
enabled
? providerText(
t,
"claudeExtraUsageBlockingEnabled",
"Claude extra-usage blocking enabled (extra usage will be blocked)"
)
: providerText(
t,
"claudeExtraUsageBlockingDisabled",
"Claude extra-usage blocking disabled (extra usage is allowed)"
)
? "Claude extra-usage blocking enabled (extra usage will be blocked)"
: "Claude extra-usage blocking disabled (extra usage is allowed)"
);
} catch (error) {
console.error("Error toggling Claude extra-usage policy:", error);
notify.error(
providerText(
t,
"failedUpdateClaudeExtraUsagePolicy",
"Failed to update Claude extra-usage policy"
)
);
notify.error("Failed to update Claude extra-usage policy");
}
};
@@ -468,10 +447,7 @@ export function useProviderConnections(
if (!res.ok) {
const data = await res.json().catch(() => ({}));
notify.error(
data.error ||
providerText(t, "failedUpdateCodexLimitPolicy", "Failed to update Codex limit policy")
);
notify.error(data.error || "Failed to update Codex limit policy");
return;
}
@@ -488,12 +464,10 @@ export function useProviderConnections(
: connection
)
);
notify.success(providerText(t, "codexLimitPolicyUpdated", "Codex limit policy updated"));
notify.success("Codex limit policy updated");
} catch (error) {
console.error("Error toggling Codex quota policy:", error);
notify.error(
providerText(t, "failedUpdateCodexLimitPolicy", "Failed to update Codex limit policy")
);
notify.error("Failed to update Codex limit policy");
}
};
@@ -507,27 +481,18 @@ export function useProviderConnections(
if (!res.ok) {
const data = await res.json().catch(() => ({}));
notify.error(
data.error ||
providerText(t, "failedUpdateCliproxyRouting", "Failed to update CLIProxyAPI routing")
);
notify.error(data.error || "Failed to update CLIProxyAPI routing");
return;
}
setCpaProviderEnabled(enabled);
notify.success(
enabled
? providerText(
t,
"cliproxyRoutingEnabled",
"Requests now route through CLIProxyAPI (deeper emulation)"
)
: providerText(t, "cliproxyRoutingDisabled", "Requests now use native OmniRoute (direct)")
? "Requests now route through CLIProxyAPI (deeper emulation)"
: "Requests now use native OmniRoute (direct)"
);
} catch {
notify.error(
providerText(t, "failedUpdateCliproxyRouting", "Failed to update CLIProxyAPI routing")
);
notify.error("Failed to update CLIProxyAPI routing");
}
};
@@ -699,10 +664,10 @@ export function useProviderConnections(
if (onAfter) await onAfter();
} else {
const data = await res.json();
notify.error(data.error || providerText(t, "batchDeleteFailed", "Batch delete failed"));
notify.error(data.error || "Batch delete failed");
}
} catch {
notify.error(providerText(t, "batchDeleteNetworkError", "Network error during batch delete"));
notify.error("Network error during batch delete");
} finally {
setBatchDeleting(false);
}
@@ -724,11 +689,7 @@ export function useProviderConnections(
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(
data.error?.message ||
data.error ||
providerText(t, "batchUpdateFailed", "Batch update failed")
);
throw new Error(data.error?.message || data.error || "Batch update failed");
}
const data = await res.json();
updated += data.updated ?? 0;
@@ -749,10 +710,7 @@ export function useProviderConnections(
);
}
} catch (error: any) {
notify.error(
error?.message ||
providerText(t, "batchUpdateNetworkError", "Network error during batch update")
);
notify.error(error?.message || "Network error during batch update");
} finally {
setBatchUpdating(null);
}
@@ -847,13 +805,7 @@ export function useProviderConnections(
const proxiesData = await proxiesRes.json();
const savedProxies = (proxiesData?.items || []).filter((p: any) => p.status === "active");
if (savedProxies.length === 0) {
notify.error(
providerText(
t,
"noSavedProxies",
"No saved proxies found. Add proxies in Settings → Proxy first."
)
);
notify.error("No saved proxies found. Add proxies in Settings → Proxy first.");
return;
}
@@ -904,16 +856,11 @@ export function useProviderConnections(
await fetchConnections();
const tagLabel = tagFilter ? `"${tagFilter}" ` : "";
notify.success(
providerText(
t,
"proxiesDistributed",
"Distributed {assigned} proxy assignment(s) across {tagLabel}{total} connection(s).",
{ assigned, tagLabel, total: sorted.length }
)
`Distributed ${assigned} proxy assignment(s) across ${tagLabel}${sorted.length} connection(s).`
);
} catch (err) {
console.error("Error distributing proxies:", err);
notify.error(providerText(t, "failedDistributeProxies", "Failed to distribute proxies."));
notify.error("Failed to distribute proxies.");
} finally {
setDistributingProxies(false);
}

View File

@@ -16,7 +16,7 @@
import { useState, useCallback } from "react";
import { useTranslations } from "next-intl";
import { useNotificationStore } from "@/store/notificationStore";
import { providerText, type CompatModelRow } from "../providerPageHelpers";
import type { CompatModelRow } from "../providerPageHelpers";
// ──── types ─────────────────────────────────────────────────────────────────
@@ -79,13 +79,11 @@ export function useProviderModels(
notify.success(t("setAliasSuccess", { alias }));
} else {
const data = await res.json().catch(() => ({}));
notify.error(
data?.error?.message || providerText(t, "failedSetAlias", "Failed to set alias")
);
notify.error(data?.error?.message || "Failed to set alias");
}
} catch (error) {
console.log("Error setting alias:", error);
notify.error(providerText(t, "networkErrorSettingAlias", "Network error setting alias"));
notify.error("Network error setting alias");
}
},
[fetchAliases, t, notify]
@@ -102,13 +100,11 @@ export function useProviderModels(
notify.success(t("deleteAliasSuccess", { alias }));
} else {
const data = await res.json().catch(() => ({}));
notify.error(
data?.error?.message || providerText(t, "failedDeleteAlias", "Failed to delete alias")
);
notify.error(data?.error?.message || "Failed to delete alias");
}
} catch (error) {
console.log("Error deleting alias:", error);
notify.error(providerText(t, "networkErrorDeletingAlias", "Network error deleting alias"));
notify.error("Network error deleting alias");
}
},
[fetchAliases, t, notify]
@@ -117,9 +113,10 @@ export function useProviderModels(
const fetchProviderModelMeta = useCallback(async () => {
if (isSearchProvider) return;
try {
const res = await fetch(`/api/provider-models?provider=${encodeURIComponent(providerId)}`, {
cache: "no-store",
});
const res = await fetch(
`/api/provider-models?provider=${encodeURIComponent(providerId)}`,
{ cache: "no-store" }
);
if (!res.ok) return;
const data = await res.json();
setModelMeta({

View File

@@ -115,7 +115,9 @@ export function useProviderSettings(providerId: string): UseProviderSettingsRetu
} catch (error) {
if (!isCurrentRequest()) return;
setCodexSettingsLoaded(false);
setCodexSettingsLoadError(error instanceof Error ? error.message : "Failed to load settings");
setCodexSettingsLoadError(
error instanceof Error ? error.message : "Failed to load settings"
);
}
}, [providerId]);
@@ -182,20 +184,15 @@ export function useProviderSettings(providerId: string): UseProviderSettingsRetu
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setCodexGlobalServiceMode(previousMode);
notify.error(
data.error ||
providerText(t, "failedUpdateCodexServiceMode", "Failed to update Codex service mode")
);
notify.error(data.error || "Failed to update Codex service mode");
return;
}
notify.success(providerText(t, "codexServiceModeUpdated", "Codex service mode updated"));
notify.success("Codex service mode updated");
} catch (error) {
setCodexGlobalServiceMode(previousMode);
console.error("Error updating Codex service mode:", error);
notify.error(
providerText(t, "failedUpdateCodexServiceMode", "Failed to update Codex service mode")
);
notify.error("Failed to update Codex service mode");
} finally {
setSavingCodexGlobalServiceMode(false);
}
@@ -218,14 +215,7 @@ export function useProviderSettings(providerId: string): UseProviderSettingsRetu
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setPreferClaudeCodeForUnprefixedClaudeModels(previous);
notify.error(
data.error ||
providerText(
t,
"failedUpdateClaudeRoutingPreference",
"Failed to update Claude Code routing preference"
)
);
notify.error(data.error || "Failed to update Claude Code routing preference");
return;
}
@@ -237,26 +227,14 @@ export function useProviderSettings(providerId: string): UseProviderSettingsRetu
}
notify.success(
enabled
? providerText(
t,
"claudeRoutingPreferenceEnabled",
"Unprefixed Claude models now prefer Claude Code"
)
: providerText(
t,
"claudeRoutingPreferenceDisabled",
"Unprefixed Claude models no longer prefer Claude Code"
)
? "Unprefixed Claude models now prefer Claude Code"
: "Unprefixed Claude models no longer prefer Claude Code"
);
} catch (error) {
setPreferClaudeCodeForUnprefixedClaudeModels(previous);
console.error("Error updating Claude Code routing preference:", error);
notify.error(
providerText(
t,
"failedUpdateClaudeRoutingPreference",
"Failed to update Claude Code routing preference"
)
providerText(t, "failedUpdateClaudeRoutingPreference", "Failed to update Claude Code routing preference")
);
} finally {
setSavingClaudeRoutingPreference(false);

View File

@@ -8,7 +8,6 @@ import {
CLIENT_IDENTITY_PROFILE_OPTIONS,
getClientIdentityProfileHeaders,
} from "@/shared/constants/clientIdentityProfiles";
import { providerText } from "../[id]/providerPageHelpers";
type CompatibleMode = "openai" | "anthropic" | "cc";
type CompatibleProviderNode = { id: string } & Record<string, unknown>;
@@ -101,11 +100,9 @@ export default function AddCompatibleProviderModal({
const [checkKey, setCheckKey] = useState("");
const [checkModelId, setCheckModelId] = useState("");
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState<null | {
valid: boolean;
error?: string | null;
method?: string | null;
}>(null);
const [validationResult, setValidationResult] = useState<
null | { valid: boolean; error?: string | null; method?: string | null }
>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const apiTypeOptions = useMemo(
@@ -245,10 +242,7 @@ export default function AddCompatibleProviderModal({
method: data.method ?? null,
});
} catch {
setValidationResult({
valid: false,
error: providerText(t, "networkError", "Network error"),
});
setValidationResult({ valid: false, error: "Network error" });
} finally {
setValidating(false);
}

View File

@@ -30,17 +30,17 @@ interface ProviderStats {
codexServiceTier?: "default" | "priority" | "flex" | null;
}
const KIND_LABEL_KEYS: Record<string, { key: string; fallback: string }> = {
llm: { key: "serviceKindChat", fallback: "Chat" },
embedding: { key: "serviceKindEmbedding", fallback: "Embed" },
image: { key: "serviceKindImage", fallback: "Image" },
imageToText: { key: "serviceKindImageToText", fallback: "I→T" },
tts: { key: "serviceKindTts", fallback: "TTS" },
stt: { key: "serviceKindStt", fallback: "STT" },
webSearch: { key: "serviceKindWebSearch", fallback: "Search" },
webFetch: { key: "serviceKindWebFetch", fallback: "Fetch" },
video: { key: "serviceKindVideo", fallback: "Video" },
music: { key: "serviceKindMusic", fallback: "Music" },
const KIND_LABEL: Record<string, string> = {
llm: "Chat",
embedding: "Embed",
image: "Image",
imageToText: "I→T",
tts: "TTS",
stt: "STT",
webSearch: "Search",
webFetch: "Fetch",
video: "Video",
music: "Music",
};
/** Maps a compatible-provider `apiType` to its `KIND_LABEL` key (#6936: non-chat
@@ -166,10 +166,6 @@ const ProviderCard = forwardRef<ProviderCardHandle, ProviderCardProps>(function
const t = useTranslations("providers");
const tc = useTranslations("common");
const tp = useTranslations("miniPlayground");
const kindLabel = (kind: string) => {
const entry = KIND_LABEL_KEYS[kind];
return entry ? providerText(t, entry.key, entry.fallback) : kind;
};
const [testExpanded, setTestExpanded] = useState<boolean>(false);
const innerRef = useRef<HTMLDivElement>(null);
const linkElementRef = useRef<HTMLAnchorElement>(null);
@@ -430,15 +426,15 @@ const ProviderCard = forwardRef<ProviderCardHandle, ProviderCardProps>(function
key={k}
className="text-[10px] px-1.5 py-0.5 rounded bg-bg-subtle border border-border text-text-muted leading-none"
>
{kindLabel(k)}
{KIND_LABEL[k] ?? k}
</span>
))}
{isCompatible && (
<Badge variant="default" size="sm">
{provider.apiType === "responses"
? t("responses")
: kindLabel(COMPATIBLE_API_TYPE_KIND[provider.apiType ?? ""] ?? "") ||
t("chat")}
: (KIND_LABEL[COMPATIBLE_API_TYPE_KIND[provider.apiType ?? ""] ?? ""] ??
t("chat"))}
</Badge>
)}
{isCcCompatible && (

View File

@@ -110,12 +110,7 @@ export default function ProviderSummaryCard({
const categories = [
{ key: null, color: null, label: t("providerSummaryAll"), stat: summaryStats.all },
{ key: "oauth", color: "bg-blue-500", label: t("oauthLabel"), stat: summaryStats.oauth },
{
key: "ide",
color: "bg-cyan-500",
label: providerText(t, "categoryIde", "IDE"),
stat: summaryStats.ide,
},
{ key: "ide", color: "bg-cyan-500", label: "IDE", stat: summaryStats.ide },
{
key: "free",
color: "bg-green-500",
@@ -137,18 +132,8 @@ export default function ProviderSummaryCard({
label: t("compatibleLabel"),
stat: summaryStats.compatible,
},
{
key: "webcookie",
color: "bg-purple-500",
label: providerText(t, "categoryWebCookie", "Web Cookie"),
stat: summaryStats.webcookie,
},
{
key: "search",
color: "bg-teal-500",
label: providerText(t, "categorySearch", "Search"),
stat: summaryStats.search,
},
{ key: "webcookie", color: "bg-purple-500", label: "Web Cookie", stat: summaryStats.webcookie },
{ key: "search", color: "bg-teal-500", label: "Search", stat: summaryStats.search },
{
key: "webfetch",
color: "bg-orange-500",
@@ -156,22 +141,12 @@ export default function ProviderSummaryCard({
stat: summaryStats.webfetch,
title: t("webFetchTooltip"),
},
{
key: "audio",
color: "bg-rose-500",
label: providerText(t, "categoryAudio", "Audio"),
stat: summaryStats.audio,
},
{
key: "local",
color: "bg-emerald-500",
label: providerText(t, "categoryLocal", "Local"),
stat: summaryStats.local,
},
{ key: "audio", color: "bg-rose-500", label: "Audio", stat: summaryStats.audio },
{ key: "local", color: "bg-emerald-500", label: "Local", stat: summaryStats.local },
{
key: "cloudagent",
color: "bg-violet-500",
label: providerText(t, "categoryCloudAgent", "Cloud Agent"),
label: "Cloud Agent",
stat: summaryStats.cloudagent,
},
].filter((category) => category.key !== "no-auth" || category.stat.total > 0);
@@ -203,8 +178,8 @@ export default function ProviderSummaryCard({
<Input
value={modelSearchQuery}
onChange={(e) => setModelSearchQuery(e.target.value)}
placeholder={providerText(t, "searchByModel", "Search by model…")}
aria-label={providerText(t, "searchByModelAria", "Search by model")}
placeholder={t("searchByModel") || "Search by model…"}
aria-label={t("searchByModel") || "Search by model"}
icon="psychology"
inputClassName={modelSearchQuery ? "pr-9" : ""}
/>

View File

@@ -1,27 +1,5 @@
"use client";
import { useTranslations } from "next-intl";
type ProviderMessageTranslator = ((key: string, values?: Record<string, unknown>) => string) & {
has?: (key: string) => boolean;
};
function providerText(
t: ProviderMessageTranslator,
key: string,
fallback: string,
values?: Record<string, unknown>
): string {
if (typeof t.has === "function" && t.has(key)) return t(key, values);
if (values) {
return Object.entries(values).reduce(
(acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)),
fallback
);
}
return fallback;
}
export default function ProvidersError({
error: _error,
reset,
@@ -29,8 +7,6 @@ export default function ProvidersError({
error: Error & { digest?: string };
reset: () => void;
}) {
const t = useTranslations("providers");
return (
<div
className="flex flex-col items-center justify-center min-h-[400px]"
@@ -39,19 +15,13 @@ export default function ProvidersError({
>
<div className="text-center space-y-4">
<h2 className="text-xl font-semibold text-red-600 dark:text-red-400">
{providerText(t, "pageLoadErrorTitle", "Failed to load providers")}
Failed to load providers
</h2>
<p className="text-text-muted max-w-md">
{providerText(
t,
"pageLoadErrorDescription",
"We could not load provider data right now. Check your connection and try again."
)}
We could not load provider data right now. Check your connection and try again.
</p>
{_error?.digest && (
<p className="text-xs text-text-muted font-mono">
{providerText(t, "pageLoadErrorId", "Error ID: {id}", { id: _error.digest })}
</p>
<p className="text-xs text-text-muted font-mono">Error ID: {_error.digest}</p>
)}
{process.env.NODE_ENV === "development" && _error?.message && (
<p className="text-xs text-red-600 dark:text-red-400 font-mono">{_error.message}</p>
@@ -60,7 +30,7 @@ export default function ProvidersError({
onClick={reset}
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors focus:outline-2 focus:outline-offset-2 focus:outline-primary"
>
{providerText(t, "pageLoadErrorRetry", "Try Again")}
Try Again
</button>
</div>
</div>

View File

@@ -1,7 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
export interface ProviderModel {
id: string;
@@ -30,7 +29,6 @@ interface UseProviderModelsResult {
* `providerId` changes).
*/
export function useProviderModels(providerId: string): UseProviderModelsResult {
const t = useTranslations("providers");
const [models, setModels] = useState<ProviderModel[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
@@ -51,7 +49,7 @@ export function useProviderModels(providerId: string): UseProviderModelsResult {
const body = (await res.json().catch(() => null)) as {
error?: { message?: string };
} | null;
const msg = body?.error?.message ?? `${t("providerTestFailed")} (HTTP ${res.status})`;
const msg = body?.error?.message ?? `HTTP ${res.status}`;
if (!cancelled) setError(msg);
return;
}
@@ -115,7 +113,7 @@ export function useProviderModels(providerId: string): UseProviderModelsResult {
return () => {
cancelled = true;
};
}, [providerId, t]);
}, [providerId]);
return { models, loading, error };
}

View File

@@ -134,28 +134,22 @@ type ProviderBatchTestResults = {
error?: string | { message?: string };
};
function getConnectionErrorTag(connection, t: ProviderMessageTranslator) {
function getConnectionErrorTag(connection) {
if (!connection) return null;
const explicitType = connection.lastErrorType;
if (explicitType === "runtime_error") return providerText(t, "errorTypeRuntime", "Runtime");
if (explicitType === "runtime_error") return "Runtime";
if (
explicitType === "upstream_auth_error" ||
explicitType === "auth_missing" ||
explicitType === "token_refresh_failed" ||
explicitType === "token_expired"
) {
return providerText(t, "errorTypeUpstreamAuth", "Auth");
}
if (explicitType === "upstream_rate_limited") {
return providerText(t, "errorTypeRateLimited", "Rate limited");
}
if (explicitType === "upstream_unavailable") {
return providerText(t, "errorTypeUpstreamUnavailable", "Server error");
}
if (explicitType === "network_error") {
return providerText(t, "errorTypeNetworkError", "Network");
return "Auth";
}
if (explicitType === "upstream_rate_limited") return "Rate limited";
if (explicitType === "upstream_unavailable") return "Server error";
if (explicitType === "network_error") return "Network";
const numericCode = Number(connection.errorCode);
if (Number.isFinite(numericCode) && numericCode >= 400) {
@@ -163,21 +157,19 @@ function getConnectionErrorTag(connection, t: ProviderMessageTranslator) {
}
const fromMessage = getErrorCode(connection.lastError);
if (fromMessage === "401" || fromMessage === "403") {
return providerText(t, "errorTypeUpstreamAuth", "Auth");
}
if (fromMessage === "401" || fromMessage === "403") return "Auth";
if (fromMessage && fromMessage !== "ERR") return fromMessage;
const msg = (connection.lastError || "").toLowerCase();
if (msg.includes("runtime") || msg.includes("not runnable") || msg.includes("not installed"))
return providerText(t, "errorTypeRuntime", "Runtime");
return "Runtime";
if (
msg.includes("invalid api key") ||
msg.includes("token invalid") ||
msg.includes("revoked") ||
msg.includes("unauthorized")
)
return providerText(t, "errorTypeUpstreamAuth", "Auth");
return "Auth";
return "ERR";
}
@@ -360,7 +352,7 @@ export default function ProvidersPage() {
(a: any, b: any) =>
(new Date(b.lastErrorAt || 0) as any) - (new Date(a.lastErrorAt || 0) as any)
)[0];
const errorCode = latestError ? getConnectionErrorTag(latestError, t) : null;
const errorCode = latestError ? getConnectionErrorTag(latestError) : null;
const errorTime = latestError?.lastErrorAt ? getRelativeTime(latestError.lastErrorAt) : null;
// Check expirations
@@ -830,14 +822,11 @@ export default function ProvidersPage() {
<span className="material-symbols-outlined text-[32px] text-primary">dns</span>
</div>
<h2 className="text-xl font-semibold text-text-main">
{providerText(t, "addFirstProvider", "Add your first provider")}
{t("addFirstProvider") || "Add your first provider"}
</h2>
<p className="text-sm text-text-muted mt-2 max-w-md">
{providerText(
t,
"addFirstProviderDesc",
"Connect an AI provider to start routing requests through OmniRoute. You can use free providers, API keys, or OAuth accounts."
)}
{t("addFirstProviderDesc") ||
"Connect an AI provider to start routing requests through OmniRoute. You can use free providers, API keys, or OAuth accounts."}
</p>
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
<Button icon="add" onClick={() => router.push("/dashboard/providers/new")}>
@@ -850,7 +839,7 @@ export default function ProvidersPage() {
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-lg border border-border text-text-muted hover:text-text-main hover:bg-bg-subtle transition-colors"
>
<span className="material-symbols-outlined text-[16px]">help</span>
{providerText(t, "learnMore", "Learn more")}
{t("learnMore") || "Learn more"}
</a>
</div>
</div>
@@ -1105,10 +1094,10 @@ export default function ProvidersPage() {
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-xl font-semibold flex items-center gap-2 flex-1 min-w-0">
{providerText(t, "ideProviders", "IDE Providers")}{" "}
{t("ideProviders") || "IDE Providers"}{" "}
<span
className="size-2.5 rounded-full bg-cyan-500"
title={providerText(t, "ideProviders", "IDE Providers")}
title={t("ideProviders") || "IDE Providers"}
/>
<ProviderCountBadge {...countConfigured(ideProviderEntriesAll)} />
</h2>
@@ -1132,15 +1121,12 @@ export default function ProvidersPage() {
</button>
</div>
<p className="text-sm text-text-muted -mt-2">
{providerText(
t,
"ideProvidersDesc",
"Editors with built-in AI subscription. Use the provider page to import credentials directly from the IDE's keychain."
)}
{t("ideProvidersDesc") ||
"Editors with built-in AI subscription. Use the provider page to import credentials directly from the IDE's keychain."}
</p>
{ideProviderEntries.length === 0 ? (
<div className="rounded-lg border border-dashed border-border bg-bg-subtle p-6 text-center text-sm text-text-muted">
{providerText(t, "noIdeProviders", "No IDE providers match the current filters.")}
{t("noIdeProviders") || "No IDE providers match the current filters."}
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">

View File

@@ -6,34 +6,12 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Card, Toggle } from "@/shared/components";
import { useServiceStatus } from "../hooks/useServiceStatus";
type ServiceMessageTranslator = ((key: string, values?: Record<string, unknown>) => string) & {
has?: (key: string) => boolean;
};
function serviceText(
t: ServiceMessageTranslator,
key: string,
fallback: string,
values?: Record<string, unknown>
): string {
if (typeof t.has === "function" && t.has(key)) return t(key, values);
if (values) {
return Object.entries(values).reduce(
(acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)),
fallback
);
}
return fallback;
}
const NAME = "cliproxy";
export function CliproxyProviderExposureCard() {
const t = useTranslations("embeddedServices");
const { data, mutate } = useServiceStatus(NAME);
const [pending, setPending] = useState(false);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
@@ -50,30 +28,14 @@ export function CliproxyProviderExposureCard() {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const errorMsg =
body?.error?.message ??
body?.message ??
serviceText(
t,
"cliproxyProviderExposureUpdateFailed",
"Failed to update (HTTP {status})",
{
status: res.status,
}
);
body?.error?.message ?? body?.message ?? `Failed to update (HTTP ${res.status})`;
setMsg({ ok: false, text: errorMsg });
return;
}
setMsg(null);
mutate();
} catch {
setMsg({
ok: false,
text: serviceText(
t,
"cliproxyProviderExposureNetworkFailed",
"Network error — could not update provider exposure setting"
),
});
setMsg({ ok: false, text: "Network error — could not update provider exposure setting" });
} finally {
setPending(false);
}
@@ -86,15 +48,10 @@ export function CliproxyProviderExposureCard() {
<span className="material-symbols-outlined text-sky-500 text-xl">hub</span>
</div>
<div>
<h3 className="font-medium text-sm">
{serviceText(t, "cliproxyProviderExposureTitle", "Provider Exposure")}
</h3>
<h3 className="font-medium text-sm">Provider Exposure</h3>
<p className="text-xs text-text-muted">
{serviceText(
t,
"cliproxyProviderExposureDescription",
"Expose CLIProxyAPI models as a routing target under the cliproxyapi/ prefix."
)}
Expose CLIProxyAPI models as a routing target under the{" "}
<code className="font-mono bg-bg-subtle px-1 rounded">cliproxyapi/</code> prefix.
</p>
</div>
</div>
@@ -117,23 +74,16 @@ export function CliproxyProviderExposureCard() {
<div className="flex items-center justify-between">
<div>
<p className="text-sm">
{serviceText(t, "cliproxyProviderExposureLabel", "Expose as")}{" "}
Expose as{" "}
<code className="font-mono bg-bg-subtle px-1 rounded text-xs">cliproxyapi/...</code>
</p>
<p className="text-xs text-text-muted mt-0.5">
{serviceText(
t,
"cliproxyProviderExposureHint",
"When enabled, discovered models appear in provider selects across OmniRoute."
)}
When enabled, discovered models appear in provider selects across OmniRoute.
</p>
</div>
<Toggle
checked={data?.providerExpose ?? false}
onChange={handleToggle}
disabled={pending || !data}
/>
<Toggle checked={data?.providerExpose ?? false} onChange={handleToggle} disabled={pending || !data} />
</div>
</Card>
);
}

View File

@@ -1,14 +1,10 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import RelayProxyClient from "./RelayProxyClient";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("metadata");
return {
title: t("relayTitle"),
description: t("relayDescription"),
};
}
export const metadata: Metadata = {
title: "OmniRoute — Relay Proxies",
description: "Serverless relay proxy endpoints for your AI infrastructure",
};
export default function RelayProxyPage() {
return <RelayProxyClient />;

View File

@@ -320,7 +320,7 @@ export default function AuthzSection() {
onClick={() => handleRemovePrefix(prefix)}
disabled={locked || submitting}
>
{t("authz.remove")}
Remove
</Button>
</li>
);

View File

@@ -38,7 +38,7 @@ export default function AutoDisableCard() {
const savedData = await res.json();
setData(savedData);
setEditMode(false);
notify.success(t("savedSuccessfully"));
notify.success(t("savedSuccessfully") || "Saved successfully");
} catch (err) {
notify.error(err instanceof Error ? err.message : "Error saving");
} finally {

View File

@@ -93,12 +93,18 @@ export default function BackgroundDegradationTab() {
</span>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold">{t("backgroundDegradationTitle")}</h3>
<p className="text-sm text-text-muted">{t("backgroundDegradationDesc")}</p>
<h3 className="text-lg font-semibold">
{t("backgroundDegradationTitle") || "Background Task Degradation"}
</h3>
<p className="text-sm text-text-muted">
{t("backgroundDegradationDesc") ||
"Auto-redirect background requests (titles, summaries) to cheaper models"}
</p>
</div>
{status === "saved" && (
<span className="text-xs font-medium text-emerald-500 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">check_circle</span> {t("saved")}
<span className="material-symbols-outlined text-[14px]">check_circle</span>{" "}
{t("saved") || "Saved"}
</span>
)}
</div>
@@ -106,7 +112,9 @@ export default function BackgroundDegradationTab() {
{/* Toggle */}
<div className="flex items-center justify-between p-4 rounded-lg bg-surface/30 border border-border/30 mb-4">
<div>
<p className="text-sm font-medium">{t("enableDegradation")}</p>
<p className="text-sm font-medium">
{t("enableDegradation") || "Enable Background Degradation"}
</p>
<p className="text-xs text-text-muted mt-0.5">
{t("enableDegradationHint") ||
"Automatically use cheaper models for background utility tasks"}
@@ -116,7 +124,7 @@ export default function BackgroundDegradationTab() {
checked={config.enabled}
onChange={(enabled) => save({ enabled })}
disabled={loading || saving}
ariaLabel={t("enableDegradation")}
ariaLabel={t("enableDegradation") || "Enable Background Degradation"}
/>
</div>
@@ -125,7 +133,9 @@ export default function BackgroundDegradationTab() {
<div className="flex items-center gap-4 p-3 rounded-lg bg-sky-500/5 border border-sky-500/20 mb-4">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[16px] text-sky-400">analytics</span>
<span className="text-xs text-text-muted">{t("tasksDetected")}:</span>
<span className="text-xs text-text-muted">
{t("tasksDetected") || "Tasks detected"}:
</span>
<span className="text-sm font-mono font-semibold text-sky-400">
{config.stats.detected}
</span>
@@ -138,7 +148,7 @@ export default function BackgroundDegradationTab() {
{/* Degradation Map */}
<div className="mb-4">
<p className="text-xs font-medium text-text-muted uppercase tracking-wider mb-2">
{t("degradationMap")}
{t("degradationMap") || "Model Degradation Map"}
</p>
{/* Add new mapping */}
@@ -147,19 +157,23 @@ export default function BackgroundDegradationTab() {
<ModelSelectField
value={newFrom}
onChange={setNewFrom}
placeholder={t("premiumModel")}
placeholder={t("premiumModel") || "Premium model"}
/>
</div>
<span className="text-text-muted text-lg"></span>
<div className="flex-1">
<ModelSelectField value={newTo} onChange={setNewTo} placeholder={t("cheapModel")} />
<ModelSelectField
value={newTo}
onChange={setNewTo}
placeholder={t("cheapModel") || "Cheap model"}
/>
</div>
<button
onClick={addMapping}
disabled={saving || !newFrom.trim() || !newTo.trim()}
className="px-3 py-2 rounded-lg text-sm font-medium bg-sky-500/10 text-sky-500 hover:bg-sky-500/20 disabled:opacity-50 transition-all"
>
{t("add")}
{t("add") || "Add"}
</button>
</div>
@@ -192,14 +206,15 @@ export default function BackgroundDegradationTab() {
<span className="material-symbols-outlined text-[14px] group-open:rotate-90 transition-transform">
chevron_right
</span>
{t("detectionPatterns")} ({config.detectionPatterns?.length || 0})
{t("detectionPatterns") || "Detection Patterns"} (
{config.detectionPatterns?.length || 0})
</summary>
{/* Add new pattern */}
<div className="flex items-center gap-2 mb-3">
<input
type="text"
placeholder={t("newPattern")}
placeholder={t("newPattern") || 'e.g. "generate a title"'}
value={newPattern}
onChange={(e) => setNewPattern(e.target.value)}
className="flex-1 px-3 py-2 rounded-lg text-sm bg-surface border border-border/50 focus:border-sky-500/50 focus:outline-none"
@@ -209,7 +224,7 @@ export default function BackgroundDegradationTab() {
disabled={saving || !newPattern.trim()}
className="px-3 py-2 rounded-lg text-sm font-medium bg-sky-500/10 text-sky-500 hover:bg-sky-500/20 disabled:opacity-50 transition-all"
>
{t("add")}
{t("add") || "Add"}
</button>
</div>

View File

@@ -49,21 +49,17 @@ export default function CliproxyapiSettingsTab() {
const data = await res.json();
if (res.ok) {
setImportResult(
t("cliproxyapiImportResult", {
imported: data.imported ?? 0,
scanned: data.scanned ?? 0,
skipped: data.skipped ?? 0,
})
`Imported ${data.imported ?? 0} account(s) (scanned ${data.scanned ?? 0}, skipped ${data.skipped ?? 0}).`
);
} else {
setImportResult(data.error || t("cliproxyapiImportFailed"));
setImportResult(data.error || "Import failed.");
}
} catch {
setImportResult(t("cliproxyapiImportFailed"));
setImportResult("Import failed.");
} finally {
setImporting(false);
}
}, [t]);
}, []);
useEffect(() => {
fetch("/api/settings")
@@ -99,37 +95,34 @@ export default function CliproxyapiSettingsTab() {
});
}, []);
const updateSetting = useCallback(
async (key: string, value: boolean | string) => {
if (key === "cliproxyapi_url" && typeof value === "string" && value.trim() !== "") {
if (!isValidUrl(value)) {
setMessage({ type: "error", text: t("cliproxyapiInvalidUrl") });
return;
}
const updateSetting = useCallback(async (key: string, value: boolean | string) => {
if (key === "cliproxyapi_url" && typeof value === "string" && value.trim() !== "") {
if (!isValidUrl(value)) {
setMessage({ type: "error", text: "Invalid URL format. Use http:// or https://" });
return;
}
}
setSaving(true);
setMessage(null);
try {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [key]: value }),
});
if (!res.ok) {
throw new Error(`Server returned ${res.status}`);
}
await res.json();
setSettings((prev) => ({ ...prev, [key]: value }));
setMessage({ type: "success", text: t("settingSaved") });
} catch {
setMessage({ type: "error", text: t("settingSaveFailed") });
} finally {
setSaving(false);
setSaving(true);
setMessage(null);
try {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [key]: value }),
});
if (!res.ok) {
throw new Error(`Server returned ${res.status}`);
}
},
[t]
);
await res.json();
setSettings((prev) => ({ ...prev, [key]: value }));
setMessage({ type: "success", text: "Setting saved" });
} catch {
setMessage({ type: "error", text: "Failed to save setting" });
} finally {
setSaving(false);
}
}, []);
const cpaEnabled = settings.cliproxyapi_fallback_enabled === true;
const cpaUrl = settings.cliproxyapi_url || "http://127.0.0.1:8317";
@@ -155,14 +148,14 @@ export default function CliproxyapiSettingsTab() {
<div className="flex items-start gap-2 px-3 py-2.5 rounded-lg bg-blue-500/10 text-blue-700 dark:text-blue-300 text-xs">
<span className="material-symbols-outlined text-[14px] mt-0.5 shrink-0">info</span>
<span>
{t("cliproxyapiLifecycleNoticeBefore")}{" "}
CLIProxyAPI lifecycle management (install, start, stop) has moved to{" "}
<Link
href="/dashboard/providers/services"
className="underline underline-offset-2 hover:opacity-80"
>
{t("cliproxyapiLifecycleNoticeLink")}
Providers Services
</Link>
{t("cliproxyapiLifecycleNoticeAfter")}
. Fallback routing settings below remain here.
</span>
</div>
@@ -188,7 +181,9 @@ export default function CliproxyapiSettingsTab() {
</div>
<div>
<h3 className="font-medium text-sm">{t("cliproxyapiFallback")}</h3>
<p className="text-xs text-text-muted">{t("cliproxyapiFallbackDescription")}</p>
<p className="text-xs text-text-muted">
When enabled, failed requests are retried through CLIProxyAPI (localhost:8317)
</p>
</div>
</div>
@@ -217,7 +212,7 @@ export default function CliproxyapiSettingsTab() {
<div>
<label className="text-xs text-text-muted mb-1.5 block">
{t("cliproxyapiFallbackCodes")}
Fallback Status Codes (comma-separated)
</label>
<Input
value={cpaCodes}
@@ -238,31 +233,31 @@ export default function CliproxyapiSettingsTab() {
<span className="material-symbols-outlined animate-spin text-base">
progress_activity
</span>
{t("loading")}
Loading...
</div>
) : toolStateError ? (
<p className="text-sm text-text-muted">{toolStateError}</p>
) : toolState ? (
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg bg-bg-secondary">
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiStatusLabel")}</p>
<p className="text-xs text-text-muted mb-1">Status</p>
<div className="flex items-center gap-1.5">
<span className={`material-symbols-outlined text-sm ${statusColor}`}>
{statusIcon}
</span>
<p className={`text-sm font-medium capitalize ${statusColor}`}>
{toolState.status?.replace("_", " ") || t("unknown")}
{toolState.status?.replace("_", " ") || "Unknown"}
</p>
</div>
</div>
<div className="p-3 rounded-lg bg-bg-secondary">
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiVersionLabel")}</p>
<p className="text-xs text-text-muted mb-1">Version</p>
<p className="text-sm font-medium">
{toolState.installedVersion ? `v${toolState.installedVersion}` : t("notInstalled")}
{toolState.installedVersion ? `v${toolState.installedVersion}` : "Not installed"}
</p>
</div>
<div className="p-3 rounded-lg bg-bg-secondary">
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiHealthLabel")}</p>
<p className="text-xs text-text-muted mb-1">Health</p>
<p
className={`text-sm font-medium ${
toolState.healthStatus === "healthy"
@@ -273,14 +268,14 @@ export default function CliproxyapiSettingsTab() {
}`}
>
{toolState.healthStatus === "healthy"
? t("healthy")
? "Healthy"
: toolState.healthStatus === "unhealthy"
? t("unhealthy")
: t("unknown")}
? "Unhealthy"
: "Unknown"}
</p>
</div>
<div className="p-3 rounded-lg bg-bg-secondary">
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiPortLabel")}</p>
<p className="text-xs text-text-muted mb-1">Port</p>
<p className="text-sm font-mono">{toolState.port || 8317}</p>
</div>
</div>

View File

@@ -369,10 +369,7 @@ export default function MemorySkillsTab() {
role="note"
data-testid="memory-token-cost-warning"
>
<span
className="material-symbols-outlined text-[18px] leading-none mt-0.5"
aria-hidden="true"
>
<span className="material-symbols-outlined text-[18px] leading-none mt-0.5" aria-hidden="true">
info
</span>
<p className="text-xs leading-relaxed">
@@ -543,7 +540,7 @@ export default function MemorySkillsTab() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="p-4 rounded-lg bg-surface/30 border border-border/30">
<label className="text-sm font-medium block mb-2">{t("host")}</label>
<label className="text-sm font-medium block mb-2">Host</label>
<input
value={qdrant.host}
onChange={(e) => setQdrant((s) => ({ ...s, host: e.target.value }))}
@@ -570,7 +567,7 @@ export default function MemorySkillsTab() {
</div>
<div className="p-4 rounded-lg bg-surface/30 border border-border/30">
<label className="text-sm font-medium block mb-2">{t("collection")}</label>
<label className="text-sm font-medium block mb-2">Collection</label>
<input
value={qdrant.collection}
onChange={(e) => setQdrant((s) => ({ ...s, collection: e.target.value }))}
@@ -784,7 +781,9 @@ export default function MemorySkillsTab() {
</div>
<div>
<h3 className="text-lg font-semibold">{t("memorySkillsSkillsmpMarketplace")}</h3>
<p className="text-sm text-text-muted">{t("memorySkillsSkillsmpDescription")}</p>
<p className="text-sm text-text-muted">
Connect to SkillsMP to discover and install skills from the marketplace.
</p>
</div>
{skillsmpStatus === "saved" && (
<span className="ml-auto text-xs font-medium text-emerald-500 flex items-center gap-1">
@@ -814,12 +813,12 @@ export default function MemorySkillsTab() {
disabled={skillsmpSaving}
className="px-4 py-2 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 disabled:opacity-50 transition-colors"
>
{skillsmpSaving ? t("saving") : t("save")}
{skillsmpSaving ? "Saving..." : "Save"}
</button>
</div>
<p className="text-xs text-text-muted mt-2">
{t("skillsmpApiKeyHintBefore")} <span className="text-violet-400">skillsmp.com</span>
{t("skillsmpApiKeyHintAfter", { limit: 500 })}
Get your API key from <span className="text-violet-400">skillsmp.com</span>. Rate limit:
500 requests/day.
</p>
</div>
</Card>
@@ -834,7 +833,9 @@ export default function MemorySkillsTab() {
</div>
<div>
<h3 className="text-lg font-semibold">{t("memorySkillsActiveSkillsProvider")}</h3>
<p className="text-sm text-text-muted">{t("memorySkillsActiveProviderDescription")}</p>
<p className="text-sm text-text-muted">
Choose which provider the Skills page uses for search and install.
</p>
</div>
{skillsProviderStatus === "saved" && (
<span className="ml-auto text-xs font-medium text-emerald-500 flex items-center gap-1">
@@ -863,10 +864,10 @@ export default function MemorySkillsTab() {
<p
className={`text-sm font-medium ${skillsProvider === "skillsmp" ? "text-indigo-400" : ""}`}
>
{t("memorySkillsSkillsmpProviderTitle")}
SkillsMP Marketplace
</p>
<p className="text-xs text-text-muted mt-0.5 leading-relaxed">
{t("memorySkillsSkillsmpProviderDescription")}
Authenticated marketplace (uses your SkillsMP API key).
</p>
</button>
@@ -883,10 +884,10 @@ export default function MemorySkillsTab() {
<p
className={`text-sm font-medium ${skillsProvider === "skillssh" ? "text-indigo-400" : ""}`}
>
{t("memorySkillsSkillsshProviderTitle")}
skills.sh Directory
</p>
<p className="text-xs text-text-muted mt-0.5 leading-relaxed">
{t("memorySkillsSkillsshProviderDescription")}
Public directory provider (no API key required).
</p>
</button>
</div>

View File

@@ -82,23 +82,26 @@ export default function ModelAliasesTab() {
</span>
</div>
<div>
<h3 className="text-lg font-semibold">{t("modelAliasesTitle")}</h3>
<p className="text-sm text-text-muted">{t("modelAliasesDesc")}</p>
<h3 className="text-lg font-semibold">{t("modelAliasesTitle") || "Model Aliases"}</h3>
<p className="text-sm text-text-muted">
{t("modelAliasesDesc") || "Auto-forward deprecated model IDs to their replacements"}
</p>
</div>
{status === "saved" && (
<span className="ml-auto text-xs font-medium text-emerald-500 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">check_circle</span> {t("saved")}
<span className="material-symbols-outlined text-[14px]">check_circle</span>{" "}
{t("saved") || "Saved"}
</span>
)}
</div>
{/* Add custom alias */}
<div className="p-4 rounded-lg bg-surface/30 border border-border/30 mb-4">
<p className="text-sm font-medium mb-3">{t("addCustomAlias")}</p>
<p className="text-sm font-medium mb-3">{t("addCustomAlias") || "Add Custom Alias"}</p>
<div className="flex items-center gap-2">
<input
type="text"
placeholder={t("deprecatedModelId")}
placeholder={t("deprecatedModelId") || "Deprecated model ID"}
value={newFrom}
onChange={(e) => setNewFrom(e.target.value)}
className="flex-1 px-3 py-2 rounded-lg text-sm bg-surface border border-border/50 focus:border-amber-500/50 focus:outline-none"
@@ -106,7 +109,7 @@ export default function ModelAliasesTab() {
<span className="text-text-muted text-lg"></span>
<input
type="text"
placeholder={t("newModelId")}
placeholder={t("newModelId") || "New model ID"}
value={newTo}
onChange={(e) => setNewTo(e.target.value)}
className="flex-1 px-3 py-2 rounded-lg text-sm bg-surface border border-border/50 focus:border-amber-500/50 focus:outline-none"
@@ -116,7 +119,7 @@ export default function ModelAliasesTab() {
disabled={saving || !newFrom.trim() || !newTo.trim()}
className="px-4 py-2 rounded-lg text-sm font-medium bg-amber-500/10 text-amber-500 hover:bg-amber-500/20 disabled:opacity-50 transition-all"
>
{t("add")}
{t("add") || "Add"}
</button>
</div>
</div>
@@ -125,7 +128,7 @@ export default function ModelAliasesTab() {
{customEntries.length > 0 && (
<div className="mb-4">
<p className="text-xs font-medium text-text-muted uppercase tracking-wider mb-2">
{t("customAliases")}
{t("customAliases") || "Custom Aliases"}
</p>
<div className="rounded-lg border border-border/30 divide-y divide-border/20">
{customEntries.map(([from, to]) => (
@@ -154,7 +157,7 @@ export default function ModelAliasesTab() {
<span className="material-symbols-outlined text-[14px] group-open:rotate-90 transition-transform">
chevron_right
</span>
{t("builtInAliases")} ({builtInEntries.length})
{t("builtInAliases") || "Built-in Aliases"} ({builtInEntries.length})
</summary>
<div className="rounded-lg border border-border/30 divide-y divide-border/20 max-h-60 overflow-y-auto">
{builtInEntries.map(([from, to]) => (

View File

@@ -85,7 +85,7 @@ export default function ModelsDevSyncTab() {
});
fetchStatus();
} else {
setFeedback({ type: "error", message: result.error || t("syncFailed") });
setFeedback({ type: "error", message: result.error || "Sync failed" });
}
} else {
setFeedback({ type: "error", message: "Sync request failed" });
@@ -110,7 +110,7 @@ export default function ModelsDevSyncTab() {
});
if (!res.ok) {
setEnabled(!newVal);
setFeedback({ type: "error", message: t("enableSyncError") });
setFeedback({ type: "error", message: t("enableSyncError") || "Failed to update" });
} else {
setFeedback({ type: "success", message: "Settings saved" });
}
@@ -136,7 +136,7 @@ export default function ModelsDevSyncTab() {
if (!res.ok) {
setIntervalHours(oldInterval);
setDraftIntervalHours(oldInterval);
setFeedback({ type: "error", message: t("enableSyncError") });
setFeedback({ type: "error", message: t("enableSyncError") || "Failed to update" });
} else {
setFeedback({ type: "success", message: "Interval updated" });
}

View File

@@ -44,7 +44,6 @@ export default function OneproxyTab() {
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<string | null>(null);
const [syncSucceeded, setSyncSucceeded] = useState(false);
const [filterProtocol, setFilterProtocol] = useState("");
const [filterCountry, setFilterCountry] = useState("");
const [minQuality, setMinQuality] = useState("");
@@ -84,32 +83,24 @@ export default function OneproxyTab() {
const handleSync = async () => {
setSyncing(true);
setSyncResult(null);
setSyncSucceeded(false);
try {
const res = await fetch("/api/settings/oneproxy", { method: "POST" });
const data = await res.json();
if (data.success) {
setSyncSucceeded(true);
setSyncResult(
t("oneproxySyncSuccess", {
total: data.total,
added: data.added,
updated: data.updated,
})
);
setSyncResult(`Synced ${data.total} proxies (${data.added} new, ${data.updated} updated)`);
} else {
setSyncResult(t("oneproxySyncFailed", { error: data.error }));
setSyncResult(`Sync failed: ${data.error}`);
}
await loadData();
} catch (err) {
setSyncResult(t("oneproxySyncFailed", { error: String(err) }));
setSyncResult(`Sync failed: ${err}`);
} finally {
setSyncing(false);
}
};
const handleClearAll = async () => {
if (!confirm(t("oneproxyClearAllConfirm"))) return;
if (!confirm("Clear all 1proxy proxies?")) return;
try {
await fetch("/api/settings/oneproxy?clearAll=1", { method: "DELETE" });
await loadData();
@@ -150,15 +141,17 @@ export default function OneproxyTab() {
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-text-main">{t("oneproxyTitle")}</h2>
<p className="text-sm text-text-muted mt-1">{t("oneproxyDescription")}</p>
<p className="text-sm text-text-muted mt-1">
Fetch and rotate free validated proxies from the 1proxy community platform
</p>
</div>
<div className="flex gap-2">
<Button onClick={handleSync} disabled={syncing} variant="primary">
{syncing ? t("oneproxySyncing") : t("oneproxySyncNow")}
{syncing ? "Syncing..." : "Sync Now"}
</Button>
{proxies.length > 0 && (
<Button onClick={handleClearAll} variant="danger">
{t("oneproxyClearAll")}
Clear All
</Button>
)}
</div>
@@ -167,7 +160,7 @@ export default function OneproxyTab() {
{syncResult && (
<div
className={`p-3 rounded-lg text-sm ${
syncSucceeded
syncResult.startsWith("Synced")
? "bg-green-50 text-green-800 dark:bg-green-900/30 dark:text-green-300"
: "bg-red-50 text-red-800 dark:bg-red-900/30 dark:text-red-300"
}`}
@@ -184,7 +177,7 @@ export default function OneproxyTab() {
</Card>
<Card className="p-4">
<div className="text-2xl font-bold text-green-600">{stats.active}</div>
<div className="text-sm text-text-muted">{t("oneproxyActive")}</div>
<div className="text-sm text-text-muted">Active</div>
</Card>
<Card className="p-4">
<div className="text-2xl font-bold text-text-main">
@@ -237,36 +230,22 @@ export default function OneproxyTab() {
{loading ? (
<div className="text-center py-8 text-text-muted">{t("oneproxyLoadingProxies")}</div>
) : proxies.length === 0 ? (
<div className="text-center py-8 text-text-muted">{t("oneproxyEmpty")}</div>
<div className="text-center py-8 text-text-muted">
No 1proxy proxies found. Click &quot;Sync Now&quot; to fetch free proxies.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 px-3 text-text-muted font-medium">
{t("oneproxyHost")}
</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">
{t("oneproxyProtocol")}
</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">
{t("oneproxyCountry")}
</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">
{t("oneproxyQuality")}
</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">
{t("oneproxyLatency")}
</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">
{t("oneproxyAnonymity")}
</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">
{t("oneproxyGoogle")}
</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">
{t("oneproxyActions")}
</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">Host</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">Protocol</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">Country</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">Quality</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">Latency</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">Anonymity</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">Google</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">Actions</th>
</tr>
</thead>
<tbody>
@@ -310,7 +289,7 @@ export default function OneproxyTab() {
onClick={() => handleDelete(proxy.id)}
className="text-red-500 hover:text-red-700 text-xs"
>
{t("oneproxyDelete")}
Delete
</button>
</td>
</tr>

View File

@@ -1,13 +1,10 @@
"use client";
import { useTranslations } from "next-intl";
interface ProxyStatusBadgeProps {
status?: string;
}
export function ProxyStatusBadge({ status }: ProxyStatusBadgeProps) {
const t = useTranslations("settings");
const isInactive = status === "inactive";
return (
<span
@@ -20,7 +17,7 @@ export function ProxyStatusBadge({ status }: ProxyStatusBadgeProps) {
<span
className={`w-1.5 h-1.5 rounded-full ${isInactive ? "bg-red-400" : "bg-emerald-400"}`}
/>
{isInactive ? t("proxyStatusInactive") : t("proxyStatusActive")}
{isInactive ? "Inactive" : "Active"}
</span>
);
}

View File

@@ -702,7 +702,7 @@ function ComboCooldownWaitCard({
setDraft(value);
}, [value]);
const title = t("resilienceComboCooldownWaitTitle");
const title = t("resilienceComboCooldownWaitTitle") || "Combo cooldown wait";
const desc =
t("resilienceComboCooldownWaitDesc") ||
"For all combo strategies: wait out a short transient cooldown and re-dispatch instead of returning a 429 immediately. Never waits on quota_exhausted.";
@@ -735,7 +735,7 @@ function ComboCooldownWaitCard({
{editing ? (
<>
<BooleanField
label={t("resilienceEnableServerWait")}
label={t("resilienceEnableServerWait") || "Enabled"}
description={
t("resilienceComboCooldownWaitToggleDesc") ||
"All combo strategies; never waits on quota_exhausted."
@@ -744,20 +744,20 @@ function ComboCooldownWaitCard({
onChange={(enabled) => setDraft((prev) => ({ ...prev, enabled }))}
/>
<NumberField
label={t("resilienceComboCooldownMaxWaitMs")}
label={t("resilienceComboCooldownMaxWaitMs") || "Max wait per attempt"}
value={draft.maxWaitMs}
min={0}
suffix="ms"
onChange={(maxWaitMs) => setDraft((prev) => ({ ...prev, maxWaitMs }))}
/>
<NumberField
label={t("resilienceMaxAttempts")}
label={t("resilienceMaxAttempts") || "Max attempts"}
value={draft.maxAttempts}
min={0}
onChange={(maxAttempts) => setDraft((prev) => ({ ...prev, maxAttempts }))}
/>
<NumberField
label={t("resilienceComboCooldownBudgetMs")}
label={t("resilienceComboCooldownBudgetMs") || "Total wait budget"}
value={draft.budgetMs}
min={0}
suffix="ms"
@@ -767,23 +767,31 @@ function ComboCooldownWaitCard({
) : (
<>
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">{t("resilienceEnableServerWait")}</div>
<div className="text-xs text-text-muted">
{t("resilienceEnableServerWait") || "Enabled"}
</div>
<div className="mt-1 text-sm font-semibold text-text-main">
{value.enabled ? t("statusEnabled") : t("statusDisabled")}
</div>
</div>
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">{t("resilienceComboCooldownMaxWaitMs")}</div>
<div className="text-xs text-text-muted">
{t("resilienceComboCooldownMaxWaitMs") || "Max wait per attempt"}
</div>
<div className="mt-1 text-sm font-semibold text-text-main">
{formatMs(value.maxWaitMs)}
</div>
</div>
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">{t("resilienceMaxAttempts")}</div>
<div className="text-xs text-text-muted">
{t("resilienceMaxAttempts") || "Max attempts"}
</div>
<div className="mt-1 text-sm font-semibold text-text-main">{value.maxAttempts}</div>
</div>
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">{t("resilienceComboCooldownBudgetMs")}</div>
<div className="text-xs text-text-muted">
{t("resilienceComboCooldownBudgetMs") || "Total wait budget"}
</div>
<div className="mt-1 text-sm font-semibold text-text-main">
{formatMs(value.budgetMs)}
</div>
@@ -812,7 +820,8 @@ function QuotaShareConcurrencyLimitCard({
setDraft(value);
}, [value]);
const title = t("resilienceQuotaShareConcurrencyTitle");
const title =
t("resilienceQuotaShareConcurrencyTitle") || "Quota-share per-connection concurrency";
const desc =
t("resilienceQuotaShareConcurrencyDesc") ||
"For quota-share combos only: when a connection sets a Max Concurrent cap, serialize concurrent requests to that subscription account so it is never flooded past its ceiling — excess requests wait in the queue instead of getting a 429. The cap comes from each connection's Max Concurrent field; this switch only enables/disables honoring it.";
@@ -844,7 +853,7 @@ function QuotaShareConcurrencyLimitCard({
<div className="grid grid-cols-1 gap-3">
{editing ? (
<BooleanField
label={t("resilienceEnableServerWait")}
label={t("resilienceEnableServerWait") || "Enabled"}
description={
t("resilienceQuotaShareConcurrencyToggleDesc") ||
"Quota-share combos only; honors each connection's Max Concurrent cap."
@@ -854,7 +863,9 @@ function QuotaShareConcurrencyLimitCard({
/>
) : (
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">{t("resilienceEnableServerWait")}</div>
<div className="text-xs text-text-muted">
{t("resilienceEnableServerWait") || "Enabled"}
</div>
<div className="mt-1 text-sm font-semibold text-text-main">
{value.enabled ? t("statusEnabled") : t("statusDisabled")}
</div>

View File

@@ -180,17 +180,17 @@ const DEFAULT_SYSTEM_TRANSFORMS_CLIENT = {
const PROVIDER_TILE_DISPLAY: Record<
string,
{ nameKey: string; descriptionKey: string; icon: string; tone: string }
{ name: string; description: string; icon: string; tone: string }
> = {
[PROVIDER_CLAUDE]: {
nameKey: "routingClaudeProviderName",
descriptionKey: "routingClaudeProviderDescription",
name: "Claude (OAuth)",
description: "Native Claude provider with OAuth-issued tokens.",
icon: "anthropic",
tone: "indigo",
},
[PROVIDER_CC_BRIDGE]: {
nameKey: "routingCcBridgeName",
descriptionKey: "routingCcBridgeDescription",
name: "Claude-Code Bridge",
description: "Relay endpoints using API keys (anthropic-compatible-cc-*).",
icon: "hub",
tone: "purple",
},
@@ -335,7 +335,7 @@ function StringListEditor({
onClick={() => onChange([...items, ""])}
className="self-start"
>
{t("routingAddEntry")}
{tCommon("add") || "Add entry"}
</Button>
</div>
);
@@ -567,11 +567,7 @@ function OpEditor({
</div>
);
default:
return (
<p className="text-xs text-text-muted">
{t("routingUnknownOpKind", { kind: String(op?.kind ?? "") })}
</p>
);
return <p className="text-xs text-text-muted">Unknown op kind: {op?.kind}</p>;
}
}
@@ -629,16 +625,16 @@ function summarizeTransformOp(op: any, t: any): string {
// Client-side validator — light shape check before we PATCH; the server
// re-validates with the full zod schema in settingsSchemas.ts.
function validateProviderTransformsConfig(value: unknown, t: any): string | null {
if (!value || typeof value !== "object") return t("routingConfigMustBeObject");
function validateProviderTransformsConfig(value: unknown): string | null {
if (!value || typeof value !== "object") return "Config must be a JSON object";
const cfg = value as { enabled?: unknown; pipeline?: unknown };
if (typeof cfg.enabled !== "boolean") return t("routingEnabledMustBeBoolean");
if (!Array.isArray(cfg.pipeline)) return t("routingPipelineMustBeArray");
if (cfg.pipeline.length > 50) return t("routingPipelineTooLong");
if (typeof cfg.enabled !== "boolean") return "`enabled` must be true or false";
if (!Array.isArray(cfg.pipeline)) return "`pipeline` must be an array of ops";
if (cfg.pipeline.length > 50) return "Pipeline cannot exceed 50 ops";
for (let i = 0; i < cfg.pipeline.length; i++) {
const op = cfg.pipeline[i] as { kind?: unknown };
if (!op || typeof op !== "object" || typeof op.kind !== "string") {
return t("routingOpMissingKind", { index: i + 1 });
return `Op #${i + 1}: missing or invalid \`kind\``;
}
const validKinds = [
"drop_paragraph_if_contains",
@@ -652,7 +648,7 @@ function validateProviderTransformsConfig(value: unknown, t: any): string | null
"obfuscate_words",
];
if (!validKinds.includes(op.kind)) {
return t("routingOpUnknownKind", { index: i + 1, kind: op.kind });
return `Op #${i + 1}: unknown kind "${op.kind}"`;
}
}
return null;
@@ -817,11 +813,11 @@ export default function RoutingTab() {
} catch (err) {
setJsonErrors((prev) => ({
...prev,
[providerId]: t("routingInvalidJson", { error: (err as Error).message }),
[providerId]: `Invalid JSON: ${(err as Error).message}`,
}));
return;
}
const validationError = validateProviderTransformsConfig(parsed, t);
const validationError = validateProviderTransformsConfig(parsed);
if (validationError) {
setJsonErrors((prev) => ({ ...prev, [providerId]: validationError }));
return;
@@ -1021,7 +1017,7 @@ export default function RoutingTab() {
</option>
{availableProvidersToAdd.map((p) => (
<option key={p.id} value={p.id}>
{p.id === PROVIDER_CC_BRIDGE ? t("routingCcBridgeCatalogName") : p.name} ({p.id})
{p.name} ({p.id})
</option>
))}
</Select>
@@ -1043,11 +1039,12 @@ export default function RoutingTab() {
<div className="flex flex-col gap-3">
{Object.entries(systemTransforms.providers).map(([providerId, providerCfg]) => {
const isBuiltin = BUILTIN_PROVIDERS.has(providerId);
const display = PROVIDER_TILE_DISPLAY[providerId];
const displayName = display ? t(display.nameKey) : providerId;
const displayDescription = display
? t(display.descriptionKey)
: t("routingCustomProviderDescription");
const display = PROVIDER_TILE_DISPLAY[providerId] ?? {
name: providerId,
description: "Custom provider.",
icon: "extension",
tone: "purple",
};
const draft = jsonDrafts[providerId] ?? JSON.stringify(providerCfg, null, 2);
const errorMsg = jsonErrors[providerId] ?? null;
const opCount = Array.isArray(providerCfg.pipeline) ? providerCfg.pipeline.length : 0;
@@ -1069,7 +1066,7 @@ export default function RoutingTab() {
<code className="text-xs font-mono rounded bg-surface px-1.5 py-0.5">
{providerId}
</code>
<span className="text-sm font-medium">{displayName}</span>
<span className="text-sm font-medium">{display.name}</span>
</div>
}
subtitle={
@@ -1084,7 +1081,7 @@ export default function RoutingTab() {
onChange={(checked) => toggleProviderEnabled(providerId, checked)}
disabled={loading}
ariaLabel={
tCommon("enable") + " " + displayName + " " + t("systemTransforms")
tCommon("enable") + " " + display.name + " " + t("systemTransforms")
}
/>
{!isBuiltin && (
@@ -1101,7 +1098,7 @@ export default function RoutingTab() {
</>
}
>
<p className="text-xs text-text-muted mb-3">{displayDescription}</p>
<p className="text-xs text-text-muted mb-3">{display.description}</p>
{providerSaveErrors[providerId] && (
<div
role="alert"
@@ -1214,13 +1211,13 @@ export default function RoutingTab() {
className="text-[11px] text-primary hover:underline"
>
{isJsonOpen
? `${t("routingJsonEditorHide")}`
: `${t("routingJsonEditorImportExport")}`}
? "▾ " + tCommon("hide") + " JSON editor"
: "▸ Import / export JSON"}
</button>
{isJsonOpen && (
<div className="mt-2">
<label className="text-[11px] font-medium text-text-muted block mb-1">
{t("routingJsonEditorLabel")}
JSON ({tCommon("edit")} &amp; Apply, or paste to import)
</label>
<textarea
value={draft}
@@ -1243,7 +1240,7 @@ export default function RoutingTab() {
size="sm"
icon="check"
>
{t("routingApplyJson")}
Apply JSON
</Button>
{hasDefault && (
<Button
@@ -1265,7 +1262,10 @@ export default function RoutingTab() {
})}
</div>
<p className="mt-3 text-[11px] text-text-muted">{t("routingTransformsFootnote")}</p>
<p className="mt-3 text-[11px] text-text-muted">
All transform ops are idempotent on re-run. Changes take effect immediately on the next
request.
</p>
</Card>
<Card>
@@ -1471,8 +1471,13 @@ export default function RoutingTab() {
</span>
</div>
<div>
<h3 className="text-lg font-semibold">{t("echoRequestedModelTitle")}</h3>
<p className="text-sm text-text-muted mt-1">{t("echoRequestedModelDesc")}</p>
<h3 className="text-lg font-semibold">
{t("echoRequestedModelTitle") || "Echo requested model name in responses"}
</h3>
<p className="text-sm text-text-muted mt-1">
{t("echoRequestedModelDesc") ||
"When enabled, the response model field echoes the alias or combo name the client requested instead of the upstream model name."}
</p>
</div>
</div>
<div className="pt-1">
@@ -1495,15 +1500,20 @@ export default function RoutingTab() {
</span>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold">{t("webSearchRouteTitle")}</h3>
<p className="text-sm text-text-muted mt-1">{t("webSearchRouteDesc")}</p>
<h3 className="text-lg font-semibold">
{t("webSearchRouteTitle") || "Web search routing"}
</h3>
<p className="text-sm text-text-muted mt-1">
{t("webSearchRouteDesc") ||
"When a request includes a native web_search tool, route the whole request to this model instead of the default — useful for providers that don't implement Anthropic's web_search server tool. Leave blank to disable."}
</p>
<div className="mt-3">
<ModelSelectField
value={String(settings.webSearchRouteModel ?? "")}
onChange={(v) => updateSetting({ webSearchRouteModel: v })}
placeholder={t("webSearchRoutePlaceholder")}
placeholder={t("webSearchRoutePlaceholder") || "Search or select a model…"}
disabled={loading}
ariaLabel={t("webSearchRouteTitle")}
ariaLabel={t("webSearchRouteTitle") || "Web search routing model"}
/>
</div>
</div>
@@ -1519,8 +1529,13 @@ export default function RoutingTab() {
</span>
</div>
<div>
<h3 className="text-lg font-semibold">{t("lkgpToggleTitle")}</h3>
<p className="text-sm text-text-muted mt-1">{t("lkgpToggleDesc")}</p>
<h3 className="text-lg font-semibold">
{t("lkgpToggleTitle") || "Last Known Good Provider (LKGP)"}
</h3>
<p className="text-sm text-text-muted mt-1">
{t("lkgpToggleDesc") ||
"When enabled, the router remembers which provider last served a successful response and tries it first on subsequent requests."}
</p>
</div>
</div>
<div className="pt-1">
@@ -1546,18 +1561,19 @@ export default function RoutingTab() {
if (res.ok) {
setLkgpCacheStatus({
type: "success",
message: t("lkgpCacheCleared"),
message: t("lkgpCacheCleared") || "LKGP cache cleared successfully",
});
} else {
setLkgpCacheStatus({
type: "error",
message: data.error || t("lkgpCacheClearFailed"),
message:
data.error || t("lkgpCacheClearFailed") || "Failed to clear LKGP cache",
});
}
} catch {
setLkgpCacheStatus({
type: "error",
message: t("errorOccurred"),
message: t("errorOccurred") || "An error occurred",
});
} finally {
setLkgpCacheLoading(false);
@@ -1567,7 +1583,7 @@ export default function RoutingTab() {
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
delete_sweep
</span>
{t("clearLkgpCache")}
{t("clearLkgpCache") || "Clear LKGP Cache"}
</Button>
{lkgpCacheStatus.message && (
<span
@@ -1588,8 +1604,13 @@ export default function RoutingTab() {
</span>
</div>
<div>
<h3 className="text-lg font-semibold">{t("adaptiveVolumeRouting")}</h3>
<p className="text-sm text-text-muted mt-1">{t("adaptiveVolumeRoutingDesc")}</p>
<h3 className="text-lg font-semibold">
{t("adaptiveVolumeRouting") || "Adaptive Volume Routing"}
</h3>
<p className="text-sm text-text-muted mt-1">
{t("adaptiveVolumeRoutingDesc") ||
"Automatically adjusts traffic volume between providers based on real-time latency and error rates."}
</p>
</div>
</div>
<div className="pt-1">

View File

@@ -81,11 +81,13 @@ export default function SecurityTab() {
setRequireLoginModalOpen(false);
} else {
const data = await res.json();
setRequireLoginError(data?.error?.message || t("errorOccurred"));
setRequireLoginError(
data?.error?.message || t("errorOccurred", { fallback: "An error occurred" })
);
}
} catch (err) {
console.error("Failed to update require login:", err);
setRequireLoginError(t("errorOccurred"));
setRequireLoginError(t("errorOccurred", { fallback: "An error occurred" }));
} finally {
setRequireLoginLoading(false);
}
@@ -194,7 +196,9 @@ export default function SecurityTab() {
title={t("currentPassword")}
>
<div className="flex flex-col gap-4">
<p className="text-sm text-text-muted">{t("enterCurrentPassword")}</p>
<p className="text-sm text-text-muted">
{t("enterCurrentPassword", { fallback: "Enter your current password to continue" })}
</p>
<Input
label={t("currentPassword")}
type="password"
@@ -222,7 +226,7 @@ export default function SecurityTab() {
loading={requireLoginLoading}
disabled={!requireLoginPassword}
>
{t("confirm")}
{t("confirm", { fallback: "Confirm" })}
</Button>
</div>
</div>

View File

@@ -236,12 +236,12 @@ export default function SystemStorageTab() {
if (res.ok) {
setClearCacheStatus({
type: "success",
message: t("cacheCleared"),
message: t("cacheCleared") || "Cache cleared successfully",
});
} else {
setClearCacheStatus({
type: "error",
message: data?.error || t("clearCacheFailed"),
message: data?.error || t("clearCacheFailed") || "Failed to clear cache",
});
}
} catch {
@@ -266,7 +266,7 @@ export default function SystemStorageTab() {
} else {
setPurgeLogsStatus({
type: "error",
message: data?.error || t("purgeLogsFailed"),
message: data?.error || t("purgeLogsFailed") || "Failed to purge logs",
});
}
} catch {
@@ -1391,7 +1391,7 @@ export default function SystemStorageTab() {
<span className="material-symbols-outlined text-[18px] text-blue-500" aria-hidden="true">
build
</span>
<p className="font-medium">{t("maintenance")}</p>
<p className="font-medium">{t("maintenance") || "Maintenance"}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
@@ -1403,7 +1403,7 @@ export default function SystemStorageTab() {
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
delete_sweep
</span>
{t("clearCache")}
{t("clearCache") || "Clear Cache"}
</Button>
<Button
variant="outline"
@@ -1414,7 +1414,7 @@ export default function SystemStorageTab() {
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
auto_delete
</span>
{t("purgeExpiredLogs")}
{t("purgeExpiredLogs") || "Purge Expired Logs"}
</Button>
<Button
variant="outline"
@@ -1469,7 +1469,7 @@ export default function SystemStorageTab() {
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
restart_alt
</span>
{t("resetUsageData")}
{t("resetUsageData") || "Reset Usage Data"}
</Button>
</div>
<div className="mt-4 border-t border-border/50 pt-3">
@@ -1543,7 +1543,7 @@ export default function SystemStorageTab() {
isOpen={resetUsageModalOpen}
onClose={() => !resetUsageLoading && setResetUsageModalOpen(false)}
onConfirm={handleResetUsageHistory}
title={t("resetUsageData")}
title={t("resetUsageData") || "Reset Usage Data"}
message={
<div className="space-y-3">
<p className="text-text-muted">
@@ -1563,7 +1563,7 @@ export default function SystemStorageTab() {
</select>
</div>
}
confirmText={resetUsageLoading ? t("resetting") : t("reset")}
confirmText={resetUsageLoading ? t("resetting") || "Resetting..." : t("reset") || "Reset"}
variant="danger"
loading={resetUsageLoading}
/>

View File

@@ -41,22 +41,23 @@ export default function DocumentationTab() {
<h3 className="font-semibold mb-2">SOCKS5</h3>
<p className="text-sm text-text-muted">
{t("proxyDocumentationSocks5DescBefore")}{" "}
<code className="bg-surface-alt px-1 rounded">ENABLE_SOCKS5_PROXY=false</code>{" "}
{t("proxyDocumentationSocks5DescAfter")}
<code className="bg-surface-alt px-1 rounded">ENABLE_SOCKS5_PROXY=false</code> to disable
(ON by default).
</p>
</section>
<section>
<h3 className="font-semibold mb-2">{t("freePoolTab")}</h3>
<p className="text-sm text-text-muted">{t("proxyDocumentationFreePoolDesc")}</p>
<p className="text-sm text-text-muted">
{t("proxyDocumentationFreePoolDesc")}
</p>
</section>
<section>
<h3 className="font-semibold mb-2">Vercel Relay</h3>
<p className="text-sm text-text-muted">
{t("proxyDocumentationVercelRelayDescBefore")} (
<code className="bg-surface-alt px-1 rounded">x-relay-auth</code>).{" "}
{t("proxyDocumentationVercelRelayDescAfter")}
<code className="bg-surface-alt px-1 rounded">x-relay-auth</code>). {t("proxyDocumentationVercelRelayDescAfter")}
</p>
</section>
</Card>

View File

@@ -1,7 +1,5 @@
"use client";
import { useTranslations } from "next-intl";
export interface FreeProxyRowData {
id: string;
source: string;
@@ -30,7 +28,6 @@ export default function FreeProxyRow({
onAddToPool,
adding,
}: FreeProxyRowProps) {
const t = useTranslations("settings");
const qualityColor =
proxy.qualityScore == null
? "text-text-muted"
@@ -49,7 +46,7 @@ export default function FreeProxyRow({
onChange={() => onToggleSelect(proxy.id)}
className="rounded"
disabled={proxy.inPool}
aria-label={t("proxyFreePoolSelectProxy", { endpoint: `${proxy.host}:${proxy.port}` })}
aria-label={`Select ${proxy.host}:${proxy.port}`}
/>
</td>
<td className="px-3 py-2 text-text-muted text-xs">{proxy.source}</td>
@@ -67,16 +64,16 @@ export default function FreeProxyRow({
<td className="px-3 py-2">
{proxy.inPool ? (
<span className="px-2 py-0.5 rounded text-xs bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
{t("proxyFreePoolInPool")}
in pool
</span>
) : (
<button
onClick={() => onAddToPool(proxy.id)}
disabled={adding}
aria-label={t("proxyFreePoolAddProxy", { endpoint: `${proxy.host}:${proxy.port}` })}
aria-label={`Add ${proxy.host}:${proxy.port} to pool`}
className="px-2 py-0.5 rounded text-xs bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25 disabled:opacity-50"
>
{adding ? t("proxyFreePoolAdding") : "⊕"}
{adding ? "..." : "⊕"}
</button>
)}
</td>

View File

@@ -59,22 +59,19 @@ export default function SubscriptionTab() {
// Resolve a subscription `error` value into a localized message. Values are
// either a `{ code, detail? }` JSON (user-facing, i18n'd) or a plain
// diagnostic string (technical fetch/sync errors) shown verbatim.
const resolveSubError = useCallback(
(raw: string | null): string | null => {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as { code?: string; detail?: string };
if (parsed?.code) {
const base = t(`proxySubscription.error.${parsed.code}`);
return parsed.detail ? `${base}${parsed.detail}` : base;
}
} catch {
// plain diagnostic string — show as-is
const resolveSubError = useCallback((raw: string | null): string | null => {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as { code?: string; detail?: string };
if (parsed?.code) {
const base = t(`proxySubscription.error.${parsed.code}`);
return parsed.detail ? `${base}${parsed.detail}` : base;
}
return raw;
},
[t]
);
} catch {
// plain diagnostic string — show as-is
}
return raw;
}, [t]);
const [busyId, setBusyId] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
@@ -88,7 +85,7 @@ export default function SubscriptionTab() {
setError(null);
try {
const res = await fetch("/api/v1/management/proxy-subscriptions");
if (!res.ok) throw new Error(t("proxySubscription.loadFailed"));
if (!res.ok) throw new Error("加载订阅列表失败");
const data = await res.json();
setSubs(Array.isArray(data.items) ? data.items : []);
} catch (e) {
@@ -96,7 +93,7 @@ export default function SubscriptionTab() {
} finally {
setLoading(false);
}
}, [t]);
}, []);
const loadProviders = useCallback(async () => {
try {
@@ -146,10 +143,10 @@ export default function SubscriptionTab() {
setSaving(true);
setFormError(null);
try {
if (!form.name.trim()) throw new Error(t("proxySubscription.nameRequired"));
if (!form.url.trim()) throw new Error(t("proxySubscription.urlRequired"));
if (!form.name.trim()) throw new Error("请填写名称");
if (!form.url.trim()) throw new Error("请填写订阅链接");
if (form.mode === "rule" && form.ruleProviders.length === 0) {
throw new Error(t("proxySubscription.providerRequired"));
throw new Error("规则模式下请至少选择一个 Provider");
}
const payload = {
name: form.name.trim(),
@@ -173,7 +170,7 @@ export default function SubscriptionTab() {
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || t("proxySubscription.saveFailed"));
throw new Error(data.error || "保存失败");
}
resetForm();
await load();
@@ -192,7 +189,7 @@ export default function SubscriptionTab() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: !sub.enabled }),
});
if (!res.ok) throw new Error(t("proxySubscription.toggleFailed"));
if (!res.ok) throw new Error("切换开关失败");
await load();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
@@ -207,7 +204,7 @@ export default function SubscriptionTab() {
const res = await fetch(`/api/v1/management/proxy-subscriptions/${sub.id}/refresh`, {
method: "POST",
});
if (!res.ok) throw new Error(t("proxySubscription.refreshFailed"));
if (!res.ok) throw new Error("刷新失败");
await load();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
@@ -217,13 +214,13 @@ export default function SubscriptionTab() {
};
const remove = async (sub: SubscriptionRecord) => {
if (!window.confirm(t("proxySubscription.deleteConfirm", { name: sub.name }))) return;
if (!window.confirm(`确定删除订阅「${sub.name}」?相关代理节点也会一并移除。`)) return;
setBusyId(sub.id);
try {
const res = await fetch(`/api/v1/management/proxy-subscriptions/${sub.id}`, {
method: "DELETE",
});
if (!res.ok) throw new Error(t("proxySubscription.deleteFailed"));
if (!res.ok) throw new Error("删除失败");
await load();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
@@ -241,10 +238,13 @@ export default function SubscriptionTab() {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-text-muted">{t("proxySubscription.description")}</p>
<p className="text-sm text-text-muted">
Provider
</p>
{!showForm && (
<Button size="sm" variant="primary" icon="add" onClick={() => setShowForm(true)}>
{t("proxySubscription.add")}
</Button>
)}
</div>
@@ -259,38 +259,38 @@ export default function SubscriptionTab() {
<div className="rounded-lg border border-border bg-surface p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">
{editingId ? t("proxySubscription.edit") : t("proxySubscription.add")}
{editingId ? "编辑订阅" : "新增订阅"}
</h3>
<Button size="sm" variant="secondary" icon="close" onClick={resetForm}>
{t("proxySubscription.cancel")}
</Button>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<label className="flex flex-col gap-1 text-sm">
<span className="text-text-muted">{t("proxySubscription.name")}</span>
<span className="text-text-muted"></span>
<input
className="rounded border border-border bg-surface px-2 py-1.5 text-text outline-none focus:border-primary"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder={t("proxySubscription.namePlaceholder")}
placeholder="例如我的订阅A"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-text-muted">{t("proxySubscription.url")}</span>
<span className="text-text-muted"></span>
<input
className="rounded border border-border bg-surface px-2 py-1.5 text-text outline-none focus:border-primary"
value={form.url}
onChange={(e) => setForm({ ...form, url: e.target.value })}
placeholder={t("proxySubscription.urlPlaceholder")}
placeholder="https://.../subscribe?token=..."
/>
</label>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="flex flex-col gap-1 text-sm">
<span className="text-text-muted">{t("proxySubscription.mode")}</span>
<span className="text-text-muted"></span>
<div className="flex gap-2">
{(["global", "rule"] as const).map((m) => (
<button
@@ -303,21 +303,19 @@ export default function SubscriptionTab() {
: "border-border text-text-muted hover:text-text"
}`}
>
{m === "global"
? t("proxySubscription.globalMode")
: t("proxySubscription.ruleMode")}
{m === "global" ? "全局模式" : "规则模式"}
</button>
))}
</div>
<span className="text-xs text-text-muted">
{form.mode === "global"
? t("proxySubscription.globalModeDescription")
: t("proxySubscription.ruleModeDescription")}
? "所有 Provider 流量都走该订阅的代理池。"
: "仅所选 Provider 的流量走代理,其余直连。"}
</span>
</div>
<label className="flex flex-col gap-1 text-sm">
<span className="text-text-muted">{t("proxySubscription.localCoreEndpoint")}</span>
<span className="text-text-muted"> SOCKS5/HTTP </span>
<input
className="rounded border border-border bg-surface px-2 py-1.5 text-text outline-none focus:border-primary"
value={form.localCoreEndpoint}
@@ -325,18 +323,16 @@ export default function SubscriptionTab() {
placeholder="socks5://127.0.0.1:1080"
/>
<span className="text-xs text-text-muted">
{t("proxySubscription.localCoreHint")}
127.0.0.1 / localhostSS/VMess/Trojan/VLESS sing-box/clash
</span>
</label>
</div>
{form.mode === "rule" && (
<div className="flex flex-col gap-1 text-sm">
<span className="text-text-muted">{t("proxySubscription.providerRouting")}</span>
<span className="text-text-muted"> Provider </span>
{providers.length === 0 ? (
<span className="text-xs text-text-muted">
{t("proxySubscription.loadingProviders")}
</span>
<span className="text-xs text-text-muted"> Provider </span>
) : (
<div className="flex flex-wrap gap-2">
{providers.map((p) => {
@@ -370,7 +366,7 @@ export default function SubscriptionTab() {
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<label className="flex flex-col gap-1 text-sm">
<span className="text-text-muted">{t("proxySubscription.refreshInterval")}</span>
<span className="text-text-muted"></span>
<input
type="number"
min={5}
@@ -387,7 +383,7 @@ export default function SubscriptionTab() {
checked={form.enabled}
onChange={(e) => setForm({ ...form, enabled: e.target.checked })}
/>
<span>{t("proxySubscription.enableAfterCreate")}</span>
<span></span>
</label>
</div>
@@ -395,147 +391,129 @@ export default function SubscriptionTab() {
<div className="flex justify-end gap-2">
<Button size="sm" variant="secondary" onClick={resetForm}>
{t("proxySubscription.cancel")}
</Button>
<Button size="sm" variant="primary" icon="save" onClick={save} disabled={saving}>
{saving
? t("proxySubscription.saving")
: editingId
? t("proxySubscription.saveChanges")
: t("proxySubscription.create")}
{saving ? "保存中…" : editingId ? "保存修改" : "创建订阅"}
</Button>
</div>
</div>
)}
<div className="space-y-2">
{loading && <p className="text-sm text-text-muted">{t("proxySubscription.loading")}</p>}
{loading && <p className="text-sm text-text-muted"></p>}
{!loading && subs.length === 0 && (
<p className="text-sm text-text-muted">{t("proxySubscription.empty")}</p>
<p className="text-sm text-text-muted"></p>
)}
{subs.map((sub) => {
const needsCoreNodes = (sub.lastNodes ?? []).filter(isNeedsCoreNode);
const showCoreHint = needsCoreNodes.length > 0 && !sub.localCoreEndpoint;
return (
<div
key={sub.id}
className="rounded-lg border border-border bg-surface p-3 flex flex-col gap-2"
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{sub.name}</span>
<span
className={`text-xs px-2 py-0.5 rounded border ${
statusBadge[sub.status] || statusBadge.empty
}`}
>
{sub.status === "ok"
? t("proxySubscription.statusOk")
: sub.status === "error"
? t("proxySubscription.statusError")
: t("proxySubscription.statusEmpty")}
</span>
<span className="text-xs px-2 py-0.5 rounded border border-border text-text-muted">
{sub.mode === "global"
? t("proxySubscription.global")
: t("proxySubscription.rule")}
</span>
<span className="text-xs px-2 py-0.5 rounded border border-border text-text-muted">
{sub.enabled
? t("proxySubscription.enabled")
: t("proxySubscription.disabled")}
</span>
</div>
<p className="text-xs text-text-muted truncate mt-1" title={sub.url}>
{sub.url}
</p>
{resolveSubError(sub.error) && (
<p className="text-xs text-amber-600 mt-1 break-words">
{resolveSubError(sub.error)}
<div
key={sub.id}
className="rounded-lg border border-border bg-surface p-3 flex flex-col gap-2"
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{sub.name}</span>
<span
className={`text-xs px-2 py-0.5 rounded border ${
statusBadge[sub.status] || statusBadge.empty
}`}
>
{sub.status === "ok" ? "正常" : sub.status === "error" ? "错误" : "空"}
</span>
<span className="text-xs px-2 py-0.5 rounded border border-border text-text-muted">
{sub.mode === "global" ? "全局" : "规则"}
</span>
<span className="text-xs px-2 py-0.5 rounded border border-border text-text-muted">
{sub.enabled ? "已启用" : "已停用"}
</span>
</div>
<p className="text-xs text-text-muted truncate mt-1" title={sub.url}>
{sub.url}
</p>
{resolveSubError(sub.error) && (
<p className="text-xs text-amber-600 mt-1 break-words">{resolveSubError(sub.error)}</p>
)}
{showCoreHint && (
<div className="mt-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 space-y-1.5">
<p className="font-medium">
{needsCoreNodes.length} SS / VMess / Trojan / VLESS
</p>
)}
{showCoreHint && (
<div className="mt-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 space-y-1.5">
<p className="font-medium">
{t("proxySubscription.coreNodesHint", { count: needsCoreNodes.length })}
</p>
<p>{t("proxySubscription.coreEndpointHint")}</p>
<div className="flex flex-wrap items-center gap-2">
<code className="rounded bg-surface px-2 py-1 border border-border">
socks5://127.0.0.1:2080
</code>
<button
type="button"
onClick={() => navigator.clipboard?.writeText("socks5://127.0.0.1:2080")}
className="px-2 py-1 rounded border border-border hover:border-primary/50"
>
{t("proxySubscription.copyEndpoint")}
</button>
<button
type="button"
onClick={() => startEdit(sub)}
className="px-2 py-1 rounded border border-border hover:border-primary/50"
>
{t("proxySubscription.configure")}
</button>
</div>
<p>
OmniRoute <code>sing-box</code> {" "}
<code>clashClash.Meta</code> SOCKS5/HTTP 127.0.0.1 / localhost
</p>
<div className="flex flex-wrap items-center gap-2">
<code className="rounded bg-surface px-2 py-1 border border-border">socks5://127.0.0.1:2080</code>
<button
type="button"
onClick={() => navigator.clipboard?.writeText("socks5://127.0.0.1:2080")}
className="px-2 py-1 rounded border border-border hover:border-primary/50"
>
</button>
<button
type="button"
onClick={() => startEdit(sub)}
className="px-2 py-1 rounded border border-border hover:border-primary/50"
>
</button>
</div>
)}
<p className="text-xs text-text-muted mt-1">
{t("proxySubscription.nodeSummary", {
count: sub.lastNodes?.length ?? 0,
coreCount: needsCoreNodes.length,
lastFetchedAt: sub.lastFetchedAt ?? "",
failures: sub.consecutiveFailures,
lastErrorAt: sub.lastErrorAt ?? "",
})}
</p>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button
type="button"
disabled={busyId === sub.id}
onClick={() => toggleEnabled(sub)}
className="px-2 py-1 text-xs rounded border border-border hover:border-primary/50"
title={
sub.enabled ? t("proxySubscription.disable") : t("proxySubscription.enable")
}
>
{sub.enabled ? t("proxySubscription.disable") : t("proxySubscription.enable")}
</button>
<button
type="button"
disabled={busyId === sub.id}
onClick={() => refresh(sub)}
className="px-2 py-1 text-xs rounded border border-border hover:border-primary/50"
title={t("proxySubscription.refreshNodes")}
>
{t("proxySubscription.refresh")}
</button>
<button
type="button"
disabled={busyId === sub.id}
onClick={() => startEdit(sub)}
className="px-2 py-1 text-xs rounded border border-border hover:border-primary/50"
title={t("proxySubscription.edit")}
>
{t("proxySubscription.edit")}
</button>
<button
type="button"
disabled={busyId === sub.id}
onClick={() => remove(sub)}
className="px-2 py-1 text-xs rounded border border-red-500/30 text-red-600 hover:bg-red-500/10"
title={t("proxySubscription.delete")}
>
{t("proxySubscription.delete")}
</button>
</div>
</div>
)}
<p className="text-xs text-text-muted mt-1">
{sub.lastNodes?.length ?? 0}
{needsCoreNodes.length > 0 ? `${needsCoreNodes.length} 个需本地内核)` : ""}
{sub.lastFetchedAt ? ` · 上次同步:${sub.lastFetchedAt}` : ""}
{sub.consecutiveFailures > 0 ? ` · 连续失败 ${sub.consecutiveFailures}` : ""}
{sub.lastErrorAt ? ` · 上次错误:${sub.lastErrorAt}` : ""}
</p>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button
type="button"
disabled={busyId === sub.id}
onClick={() => toggleEnabled(sub)}
className="px-2 py-1 text-xs rounded border border-border hover:border-primary/50"
title={sub.enabled ? "停用" : "启用"}
>
{sub.enabled ? "停用" : "启用"}
</button>
<button
type="button"
disabled={busyId === sub.id}
onClick={() => refresh(sub)}
className="px-2 py-1 text-xs rounded border border-border hover:border-primary/50"
title="刷新节点"
>
</button>
<button
type="button"
disabled={busyId === sub.id}
onClick={() => startEdit(sub)}
className="px-2 py-1 text-xs rounded border border-border hover:border-primary/50"
title="编辑"
>
</button>
<button
type="button"
disabled={busyId === sub.id}
onClick={() => remove(sub)}
className="px-2 py-1 text-xs rounded border border-red-500/30 text-red-600 hover:bg-red-500/10"
title="删除"
>
</button>
</div>
</div>
);
})}
</div>
); })}
</div>
</div>
);

View File

@@ -1,7 +1,5 @@
"use client";
import { useTranslations } from "next-intl";
export default function SettingsError({
error: _error,
reset,
@@ -9,8 +7,6 @@ export default function SettingsError({
error: Error & { digest?: string };
reset: () => void;
}) {
const t = useTranslations("settings");
return (
<div
className="flex flex-col items-center justify-center min-h-[400px]"
@@ -19,13 +15,13 @@ export default function SettingsError({
>
<div className="text-center space-y-4">
<h2 className="text-xl font-semibold text-red-600 dark:text-red-400">
{t("errorPage.title")}
Failed to load settings
</h2>
<p className="text-text-muted max-w-md">{t("errorPage.description")}</p>
<p className="text-text-muted max-w-md">
We could not load settings right now. Please retry in a few seconds.
</p>
{_error?.digest && (
<p className="text-xs text-text-muted font-mono">
{t("errorPage.errorId", { id: _error.digest })}
</p>
<p className="text-xs text-text-muted font-mono">Error ID: {_error.digest}</p>
)}
{process.env.NODE_ENV === "development" && _error?.message && (
<p className="text-xs text-red-600 dark:text-red-400 font-mono">{_error.message}</p>
@@ -34,7 +30,7 @@ export default function SettingsError({
onClick={reset}
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors focus:outline-2 focus:outline-offset-2 focus:outline-primary"
>
{t("errorPage.retry")}
Try Again
</button>
</div>
</div>

View File

@@ -1,13 +1,9 @@
import { getTranslations } from "next-intl/server";
import { TrafficInspectorPageClient } from "./TrafficInspectorPageClient";
export async function generateMetadata() {
const t = await getTranslations("metadata");
return {
title: t("trafficInspectorTitle"),
description: t("trafficInspectorDescription"),
};
}
export const metadata = {
title: "Traffic Inspector — OmniRoute",
description: "Monitor LLM calls + debug any application's HTTPS traffic",
};
export default function TrafficInspectorPage() {
return <TrafficInspectorPageClient />;

View File

@@ -24,12 +24,11 @@ export interface PipelineViewProps extends Omit<AdvancedAccordionProps, "slug">
}
/** Default demo steps shown when no real pipeline is running. */
const DEMO_STEP_CONTENT: Array<
Pick<PipelineStep, "id" | "format" | "content" | "status"> & { translationKey: string }
> = [
const DEMO_STEPS: PipelineStep[] = [
{
id: "1",
translationKey: "pipelineStepClientRequest",
name: "Client Request",
description: "Request received in client format",
format: "claude",
content:
'{\n "model": "claude-sonnet-4-20250514",\n "messages": [\n { "role": "user", "content": "Hello!" }\n ]\n}',
@@ -37,14 +36,16 @@ const DEMO_STEP_CONTENT: Array<
},
{
id: "2",
translationKey: "pipelineStepFormatDetected",
name: "Format Detected",
description: "Auto-detected source format",
format: "claude",
content: '{\n "detectedFormat": "claude",\n "confidence": "high"\n}',
status: "done",
},
{
id: "3",
translationKey: "pipelineStepOpenAIIntermediate",
name: "OpenAI Intermediate",
description: "Translated to OpenAI hub format",
format: "openai",
content:
'{\n "model": "claude-sonnet-4-20250514",\n "messages": [\n { "role": "user", "content": "Hello!" }\n ],\n "stream": true\n}',
@@ -52,7 +53,8 @@ const DEMO_STEP_CONTENT: Array<
},
{
id: "4",
translationKey: "pipelineStepProviderFormat",
name: "Provider Format",
description: "Translated to provider target format",
format: "gemini",
content:
'{\n "model": "gemini-2.5-flash",\n "contents": [\n { "role": "user", "parts": [{ "text": "Hello!" }] }\n ]\n}',
@@ -60,7 +62,8 @@ const DEMO_STEP_CONTENT: Array<
},
{
id: "5",
translationKey: "pipelineStepProviderResponse",
name: "Provider Response",
description: "Streaming response from provider",
format: "openai",
content:
'data: {"choices":[{"delta":{"content":"Hello! How can I help you today?"}}]}\ndata: [DONE]',
@@ -148,13 +151,7 @@ export default function PipelineView({
[onOpenChange]
);
const steps =
pipelineSteps ??
DEMO_STEP_CONTENT.map((step) => ({
...step,
name: t(step.translationKey as Parameters<typeof t>[0]),
description: t(`${step.translationKey}Desc` as Parameters<typeof t>[0]),
}));
const steps = pipelineSteps ?? DEMO_STEPS;
const tr = (key: string, fallback: string): string => {
try {

View File

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

View File

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

View File

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

View File

@@ -1,25 +1,19 @@
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
import { useTranslations } from "next-intl";
export default function BadRequestPage() {
const t = useTranslations("publicSystem");
return (
<ErrorPageScaffold
code="400"
icon="rule"
title={t("statusPages.400.title")}
description={t("statusPages.400.description")}
title="Bad Request"
description="The request payload is invalid or incomplete."
suggestions={[
t("statusPages.400.suggestion1"),
t("statusPages.400.suggestion2"),
t("statusPages.400.suggestion3"),
"Review required fields and payload format before retrying.",
"If you are using the API, validate the JSON schema locally.",
"If this keeps happening, open the request in Translator Playground to inspect the payload.",
]}
primaryAction={{ href: "/docs", label: t("statusPages.400.primaryAction") }}
secondaryAction={{
href: "/dashboard/translator",
label: t("statusPages.400.secondaryAction"),
}}
primaryAction={{ href: "/docs", label: "Open Documentation" }}
secondaryAction={{ href: "/dashboard/translator", label: "Open Translator" }}
/>
);
}

View File

@@ -1,25 +1,19 @@
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
import { useTranslations } from "next-intl";
export default function UnauthorizedPage() {
const t = useTranslations("publicSystem");
return (
<ErrorPageScaffold
code="401"
icon="lock"
title={t("statusPages.401.title")}
description={t("statusPages.401.description")}
title="Unauthorized"
description="Authentication is required to access this resource."
suggestions={[
t("statusPages.401.suggestion1"),
t("statusPages.401.suggestion2"),
t("statusPages.401.suggestion3"),
"Sign in again and retry the operation.",
"For API calls, confirm the Bearer token is present and valid.",
"If the token was recently rotated, update your client credentials.",
]}
primaryAction={{ href: "/login", label: t("statusPages.401.primaryAction") }}
secondaryAction={{
href: "/dashboard/api-manager",
label: t("statusPages.401.secondaryAction"),
}}
primaryAction={{ href: "/login", label: "Go to Login" }}
secondaryAction={{ href: "/dashboard/api-manager", label: "Manage API Keys" }}
/>
);
}

View File

@@ -1,24 +1,21 @@
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
import { useTranslations } from "next-intl";
export default function ForbiddenStatusPage() {
const t = useTranslations("publicSystem");
return (
<ErrorPageScaffold
code="403"
icon="gpp_bad"
title={t("statusPages.403.title")}
description={t("statusPages.403.description")}
title="Forbidden"
description="Your request was understood, but access is denied by policy."
suggestions={[
t("statusPages.403.suggestion1"),
t("statusPages.403.suggestion2"),
t("statusPages.403.suggestion3"),
"Check IP allowlist/blocklist rules in settings.",
"Verify model and budget policies assigned to your API key.",
"Ask an administrator to grant the required permission scope.",
]}
primaryAction={{ href: "/forbidden", label: t("statusPages.403.primaryAction") }}
primaryAction={{ href: "/forbidden", label: "Open Access Help" }}
secondaryAction={{
href: "/dashboard/settings?tab=security",
label: t("statusPages.403.secondaryAction"),
label: "Open Security Settings",
}}
/>
);

View File

@@ -1,22 +1,19 @@
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
import { useTranslations } from "next-intl";
export default function RequestTimeoutPage() {
const t = useTranslations("publicSystem");
return (
<ErrorPageScaffold
code="408"
icon="timer_off"
title={t("statusPages.408.title")}
description={t("statusPages.408.description")}
title="Request Timeout"
description="The server did not receive a complete request in time."
suggestions={[
t("statusPages.408.suggestion1"),
t("statusPages.408.suggestion2"),
t("statusPages.408.suggestion3"),
"Retry the request with a smaller payload.",
"Check your network stability and VPN/proxy latency.",
"For long operations, enable streaming or split the request.",
]}
primaryAction={{ href: "/dashboard/endpoint", label: t("statusPages.408.primaryAction") }}
secondaryAction={{ href: "/status", label: t("statusPages.408.secondaryAction") }}
primaryAction={{ href: "/dashboard/endpoint", label: "Open Endpoint Guide" }}
secondaryAction={{ href: "/status", label: "Check Network Status" }}
/>
);
}

View File

@@ -1,25 +1,22 @@
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
import { useTranslations } from "next-intl";
export default function TooManyRequestsPage() {
const t = useTranslations("publicSystem");
return (
<ErrorPageScaffold
code="429"
icon="hourglass_top"
title={t("statusPages.429.title")}
description={t("statusPages.429.description")}
title="Too Many Requests"
description="Rate limits were exceeded for this client, key, or provider."
suggestions={[
t("statusPages.429.suggestion1"),
t("statusPages.429.suggestion2"),
t("statusPages.429.suggestion3"),
"Wait for cooldown and retry after the suggested interval.",
"Switch to a combo with fallback providers.",
"Tune provider resilience/rate-limit profiles in settings.",
]}
primaryAction={{
href: "/dashboard/settings?tab=resilience",
label: t("statusPages.429.primaryAction"),
label: "Open Resilience Settings",
}}
secondaryAction={{ href: "/dashboard/combos", label: t("statusPages.429.secondaryAction") }}
secondaryAction={{ href: "/dashboard/combos", label: "Open Combos" }}
/>
);
}

View File

@@ -1,22 +1,19 @@
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
import { useTranslations } from "next-intl";
export default function InternalServerErrorPage() {
const t = useTranslations("publicSystem");
return (
<ErrorPageScaffold
code="500"
icon="warning"
title={t("statusPages.500.title")}
description={t("statusPages.500.description")}
title="Internal Server Error"
description="An unexpected server-side error occurred while processing your request."
suggestions={[
t("statusPages.500.suggestion1"),
t("statusPages.500.suggestion2"),
t("statusPages.500.suggestion3"),
"Retry once in a few seconds.",
"Check health telemetry and server logs for correlated request IDs.",
"If persistent, report the issue with timestamp and request context.",
]}
primaryAction={{ href: "/dashboard/health", label: t("statusPages.500.primaryAction") }}
secondaryAction={{ href: "/dashboard/logs", label: t("statusPages.500.secondaryAction") }}
primaryAction={{ href: "/dashboard/health", label: "Open Health Dashboard" }}
secondaryAction={{ href: "/dashboard/logs", label: "Open Logs" }}
/>
);
}

View File

@@ -1,25 +1,19 @@
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
import { useTranslations } from "next-intl";
export default function BadGatewayPage() {
const t = useTranslations("publicSystem");
return (
<ErrorPageScaffold
code="502"
icon="hub"
title={t("statusPages.502.title")}
description={t("statusPages.502.description")}
title="Bad Gateway"
description="Upstream provider or gateway integration returned an invalid response."
suggestions={[
t("statusPages.502.suggestion1"),
t("statusPages.502.suggestion2"),
t("statusPages.502.suggestion3"),
"Retry with another provider or active combo route.",
"Check provider credentials and model availability.",
"Inspect translator output if format conversion is involved.",
]}
primaryAction={{ href: "/dashboard/providers", label: t("statusPages.502.primaryAction") }}
secondaryAction={{
href: "/dashboard/translator",
label: t("statusPages.502.secondaryAction"),
}}
primaryAction={{ href: "/dashboard/providers", label: "Open Providers" }}
secondaryAction={{ href: "/dashboard/translator", label: "Open Translator" }}
/>
);
}

View File

@@ -1,22 +1,19 @@
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
import { useTranslations } from "next-intl";
export default function ServiceUnavailablePage() {
const t = useTranslations("publicSystem");
return (
<ErrorPageScaffold
code="503"
icon="build_circle"
title={t("statusPages.503.title")}
description={t("statusPages.503.description")}
title="Service Unavailable"
description="The service is temporarily unavailable due to maintenance or degraded dependencies."
suggestions={[
t("statusPages.503.suggestion1"),
t("statusPages.503.suggestion2"),
t("statusPages.503.suggestion3"),
"Wait a moment and retry.",
"Check maintenance notices and system status.",
"Use fallback providers if your workflow is latency-sensitive.",
]}
primaryAction={{ href: "/maintenance", label: t("statusPages.503.primaryAction") }}
secondaryAction={{ href: "/status", label: t("statusPages.503.secondaryAction") }}
primaryAction={{ href: "/maintenance", label: "Maintenance Details" }}
secondaryAction={{ href: "/status", label: "System Status" }}
/>
);
}

View File

@@ -1,5 +1,4 @@
import { NextResponse } from "next/server";
import { getTranslations } from "next-intl/server";
import { createProviderConnection } from "@/models";
import { parseTraeCallbackQuery } from "./parseCallback";
@@ -30,7 +29,7 @@ import { parseTraeCallbackQuery } from "./parseCallback";
* authorize URL; Trae echoes it back as `loginTraceID`. The modal verifies
* the echoed state before trusting the postMessage.
*/
function htmlClose(message: Record<string, unknown>, t: (key: string) => string): NextResponse {
function htmlClose(message: Record<string, unknown>): NextResponse {
// Embedding values: only emit the small/sanitized status payload — never the
// raw token. We post to the loopback origin pair (localhost + 127.0.0.1) on
// this same port rather than "*": Trae forces the callback onto 127.0.0.1,
@@ -41,12 +40,10 @@ function htmlClose(message: Record<string, unknown>, t: (key: string) => string)
type: "trae-oauth-callback",
...message,
}).replace(/</g, "\\u003c");
const title = message.success ? t("traeAuthorizationSuccess") : t("traeAuthorizationFailed");
const body = message.success ? t("closeAuthorizationWindow") : t("returnToDashboard");
return new NextResponse(
`<!doctype html><html><body style="font:16px sans-serif;padding:40px">
<h2 style="margin:0 0 8px">${title}</h2>
<p>${body}</p>
<h2 style="margin:0 0 8px">Trae authorization ${message.success ? "✓" : "failed"}</h2>
<p>${message.success ? "You can close this window." : "Return to the dashboard."}</p>
<script>
(function () {
try {
@@ -67,25 +64,21 @@ function htmlClose(message: Record<string, unknown>, t: (key: string) => string)
}
export async function GET(request: Request) {
const t = await getTranslations("auth");
const url = new URL(request.url);
const q = url.searchParams;
const parsed = parseTraeCallbackQuery(q);
if (!parsed.ok) {
return htmlClose({ success: false, error: parsed.error }, t);
return htmlClose({ success: false, error: parsed.error });
}
try {
const connection: any = await createProviderConnection(parsed.record);
return htmlClose(
{
success: true,
connectionId: connection.id,
loginTraceId: q.get("loginTraceID") || null,
},
t
);
return htmlClose({
success: true,
connectionId: connection.id,
loginTraceId: q.get("loginTraceID") || null,
});
} catch (err: any) {
console.error("[trae callback] error:", err);
return htmlClose({ success: false, error: "Internal error during callback" }, t);
return htmlClose({ success: false, error: "Internal error during callback" });
}
}

View File

@@ -1,7 +1,6 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import Button from "@/shared/components/Button";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import {
@@ -18,8 +17,6 @@ type Status = "validating" | "ready" | "starting" | "awaiting" | "saving" | "suc
* tokens back to the ticket-gated completion endpoint for persistence.
*/
export default function CodexConnectClient({ token }: { token: string }) {
const t = useTranslations("auth");
const tc = useTranslations("common");
const [status, setStatus] = useState<Status>("validating");
const [error, setError] = useState<string | null>(null);
const [userCode, setUserCode] = useState<CodexUserCode | null>(null);
@@ -37,12 +34,12 @@ export default function CodexConnectClient({ token }: { token: string }) {
setStatus("ready");
} else {
const data = await res.json().catch(() => ({}));
setError(data?.error || t("codexLinkInvalidOrExpired"));
setError(data?.error || "This link is invalid or expired.");
setStatus("error");
}
} catch {
if (!cancelled) {
setError(t("codexValidationServerError"));
setError("Could not reach the server to validate this link.");
setStatus("error");
}
}
@@ -50,7 +47,7 @@ export default function CodexConnectClient({ token }: { token: string }) {
return () => {
cancelled = true;
};
}, [token, t]);
}, [token]);
// Abort any in-flight device flow if the visitor leaves.
useEffect(() => () => abortRef.current?.abort(), []);
@@ -82,26 +79,26 @@ export default function CodexConnectClient({ token }: { token: string }) {
if (res.ok && data?.success) {
setStatus("success");
} else {
setError(data?.error || t("codexSaveConnectionError"));
setError(data?.error || "Could not save the connection. The link may have expired.");
setStatus("error");
}
} catch (err) {
if (err instanceof CodexDeviceFlowError) {
setError(
err.code === "device_disabled"
? t("codexDeviceLoginDisabled")
? "Device code login is disabled for this OpenAI account. Enable it in ChatGPT security settings (or ask your workspace admin)."
: err.code === "timeout"
? t("codexAuthorizationTimedOut")
? "Authorization timed out. Click Start again to retry."
: err.code === "aborted"
? t("authenticationCancelled")
? "Authentication was cancelled."
: err.message
);
} else {
setError(t("unexpectedAuthenticationError"));
setError("Unexpected error during authentication. Please try again.");
}
setStatus("error");
}
}, [token, t]);
}, [token]);
return (
<div className="min-h-screen flex items-center justify-center bg-bg-base px-4 py-10">
@@ -110,33 +107,40 @@ export default function CodexConnectClient({ token }: { token: string }) {
<div className="mb-3 inline-flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 text-primary">
<span className="material-symbols-outlined text-[28px]">key</span>
</div>
<h1 className="text-lg font-semibold text-text-main">{t("connectOpenAiCodexTitle")}</h1>
<p className="mt-1 text-sm text-text-muted">{t("codexConnectDescription")}</p>
<h1 className="text-lg font-semibold text-text-main">Connect OpenAI Codex</h1>
<p className="mt-1 text-sm text-text-muted">
Authorize a ChatGPT account to finish setting up this connection.
</p>
</div>
{status === "validating" && (
<p className="text-center text-sm text-text-muted">{t("validatingCodexLink")}</p>
<p className="text-center text-sm text-text-muted">Validating link</p>
)}
{status === "ready" && (
<div className="text-center">
<p className="mb-4 text-sm text-text-muted">{t("codexGenerateCodeDescription")}</p>
<p className="mb-4 text-sm text-text-muted">
Click below to generate a one-time code, then sign in to OpenAI.
</p>
<Button onClick={start} icon="login" className="w-full">
{t("startCodexFlow")}
Start
</Button>
</div>
)}
{status === "starting" && (
<p className="text-center text-sm text-text-muted">{t("requestingOpenAiCode")}</p>
<p className="text-center text-sm text-text-muted">Requesting code from OpenAI</p>
)}
{status === "awaiting" && userCode && (
<div className="space-y-4">
<p className="text-sm text-text-muted">{t("codexVerificationInstructions")}</p>
<p className="text-sm text-text-muted">
1. Open the OpenAI verification page and 2. enter this code. This page updates
automatically once you authorize.
</p>
<div className="rounded-lg border border-border bg-bg-base p-3">
<p className="mb-1 text-xs text-text-muted">{t("yourCode")}</p>
<p className="mb-1 text-xs text-text-muted">Your code</p>
<div className="flex items-center justify-between gap-2">
<code className="text-lg font-semibold tracking-widest text-text-main">
{userCode.userCode}
@@ -147,7 +151,7 @@ export default function CodexConnectClient({ token }: { token: string }) {
icon="content_copy"
onClick={() => copy(userCode.userCode, "code")}
>
{copied === "code" ? tc("copied") : tc("copy")}
{copied === "code" ? "Copied" : "Copy"}
</Button>
</div>
</div>
@@ -158,23 +162,23 @@ export default function CodexConnectClient({ token }: { token: string }) {
icon="open_in_new"
onClick={() => window.open(userCode.verificationUri, "_blank", "noopener")}
>
{t("openVerificationPage")}
Open verification page
</Button>
<Button
variant="secondary"
icon="link"
onClick={() => copy(userCode.verificationUri, "url")}
>
{copied === "url" ? tc("copied") : t("copyUrlShort")}
{copied === "url" ? "Copied" : "Copy URL"}
</Button>
</div>
<p className="text-center text-xs text-text-muted">{t("waitingForAuthorization")}</p>
<p className="text-center text-xs text-text-muted">Waiting for authorization</p>
</div>
)}
{status === "saving" && (
<p className="text-center text-sm text-text-muted">{t("savingConnection")}</p>
<p className="text-center text-sm text-text-muted">Saving connection</p>
)}
{status === "success" && (
@@ -182,8 +186,10 @@ export default function CodexConnectClient({ token }: { token: string }) {
<div className="mb-3 inline-flex h-12 w-12 items-center justify-center rounded-full bg-green-500/10 text-green-500">
<span className="material-symbols-outlined text-[26px]">check_circle</span>
</div>
<p className="font-medium text-text-main">{t("codexConnected")}</p>
<p className="mt-1 text-sm text-text-muted">{t("codexConnectionRegistered")}</p>
<p className="font-medium text-text-main">Connected!</p>
<p className="mt-1 text-sm text-text-muted">
The Codex account was registered. You can close this tab.
</p>
</div>
)}
@@ -194,7 +200,7 @@ export default function CodexConnectClient({ token }: { token: string }) {
</div>
<p className="mb-4 text-sm text-text-muted">{error}</p>
<Button variant="secondary" icon="refresh" onClick={start} className="w-full">
{t("tryAgain")}
Try again
</Button>
</div>
)}

View File

@@ -10,7 +10,6 @@ import path from "node:path";
import { marked } from "marked";
import { sanitizeDocsHtml } from "@/lib/docsSanitizer";
import { resolveSafeI18nSectionDir } from "@/lib/docsI18nPath";
import { getTranslations } from "next-intl/server";
// ── Locale detection ────────────────────────────────────────────────────────
@@ -112,10 +111,9 @@ export async function generateMetadata(props: {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) return {};
const t = await getTranslations("docs");
return {
title: t("pageMetadataTitle", { title: page.data.title }),
description: page.data.description ?? t("pageMetadataDescription", { title: page.data.title }),
title: `${page.data.title} — OmniRoute Docs`,
description: page.data.description ?? `OmniRoute documentation: ${page.data.title}`,
};
}

View File

@@ -1,22 +1,19 @@
import { Metadata } from "next";
import { ApiExplorerClient } from "../components/ApiExplorerClient";
import { getTranslations } from "next-intl/server";
import { useTranslations } from "next-intl";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("docs");
return {
title: t("apiExplorerMetadataTitle"),
description: t("apiExplorerMetadataDescription"),
};
}
export const metadata: Metadata = {
title: "API Explorer — OmniRoute Docs",
description: "Interactive API explorer — try OmniRoute endpoints live with real-time responses",
};
export default function ApiExplorerPage() {
const t = useTranslations("docs");
return (
<div>
<h1 className="text-3xl font-bold text-text-main mb-2">{t("apiExplorerTitle")}</h1>
<p className="text-text-muted mb-8">{t("apiExplorerDescription")}</p>
<h1 className="text-3xl font-bold text-text-main mb-2">API Explorer</h1>
<p className="text-text-muted mb-8">
Try OmniRoute endpoints live. Select an endpoint, configure your request, and see the
response in real time.
</p>
<ApiExplorerClient />
</div>
);

View File

@@ -1,7 +1,6 @@
"use client";
import React, { useState, useCallback, useMemo } from "react";
import { useTranslations } from "next-intl";
import {
OPENAPI_ENDPOINTS,
OPENAPI_TAGS,
@@ -89,8 +88,6 @@ const EXAMPLE_BODIES: Record<string, string> = {
};
export function ApiExplorerClient() {
const t = useTranslations("docs");
const te = useTranslations("endpoint");
const [selected, setSelected] = useState<OpenApiEndpoint | null>(null);
const [baseUrl, setBaseUrl] = useState("http://localhost:20128");
const [apiKey, setApiKey] = useState("");
@@ -133,17 +130,13 @@ export function ApiExplorerClient() {
const contentType = res.headers.get("content-type") || "";
if (contentType.includes("text/event-stream")) {
setResponse(t("apiExplorerSseStarted"));
setResponse("SSE stream started — check the terminal/devtools for real-time output.");
} else {
const data = await res.json();
setResponse(JSON.stringify(data, null, 2));
}
} catch (err) {
setResponse(
t("apiExplorerError", {
message: err instanceof Error ? err.message : te("requestFailed"),
})
);
setResponse(`Error: ${err instanceof Error ? err.message : "Request failed"}`);
} finally {
setLoading(false);
}
@@ -164,7 +157,7 @@ export function ApiExplorerClient() {
className={`px-2.5 py-1 text-xs rounded-full border transition-colors
${!filterTag ? "bg-primary/10 text-primary border-primary/20" : "border-border text-text-muted hover:text-text-main"}`}
>
{te("all")}
All
</button>
{OPENAPI_TAGS.map((tag) => (
<button
@@ -218,7 +211,7 @@ export function ApiExplorerClient() {
<span className="font-mono text-sm text-text-main">{selected.path}</span>
{selected.requiresAuth && (
<span className="px-1.5 py-0.5 text-[10px] font-mono rounded border border-amber-500/30 bg-amber-500/10 text-amber-600">
{t("apiExplorerAuth")}
auth
</span>
)}
</div>
@@ -229,7 +222,7 @@ export function ApiExplorerClient() {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="text-xs text-text-muted block mb-1">{te("baseUrl")}</label>
<label className="text-xs text-text-muted block mb-1">Base URL</label>
<input
type="text"
value={baseUrl}
@@ -238,7 +231,7 @@ export function ApiExplorerClient() {
/>
</div>
<div>
<label className="text-xs text-text-muted block mb-1">{te("apiKey")}</label>
<label className="text-xs text-text-muted block mb-1">API Key</label>
<input
type="password"
value={apiKey}
@@ -251,7 +244,7 @@ export function ApiExplorerClient() {
{selected.method !== "GET" && selected.hasRequestBody && (
<div>
<label className="text-xs text-text-muted block mb-1">{te("requestBody")}</label>
<label className="text-xs text-text-muted block mb-1">Request Body</label>
<textarea
value={requestBody}
onChange={(e) => setRequestBody(e.target.value)}
@@ -266,14 +259,12 @@ export function ApiExplorerClient() {
disabled={loading}
className="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{loading ? te("sending") : te("sendRequest")}
{loading ? "Sending..." : "Send Request"}
</button>
{response !== null && (
<div>
<label className="text-xs text-text-muted block mb-1">
{t("apiExplorerResponseLabel")}
</label>
<label className="text-xs text-text-muted block mb-1">Response</label>
<pre className="bg-bg-subtle p-4 rounded-lg overflow-x-auto text-xs font-mono text-text-main max-h-80">
{response}
</pre>
@@ -283,8 +274,10 @@ export function ApiExplorerClient() {
) : (
<div className="text-center py-16 text-text-muted">
<span className="material-symbols-outlined text-4xl mb-2 block">api</span>
<p className="text-lg font-medium">{t("apiExplorerSelectEndpoint")}</p>
<p className="text-sm mt-1">{t("apiExplorerChooseApi")}</p>
<p className="text-lg font-medium">Select an endpoint to explore</p>
<p className="text-sm mt-1">
Choose an API from the sidebar to see details and try it live
</p>
</div>
)}
</div>

View File

@@ -1,11 +1,8 @@
"use client";
import React, { useState } from "react";
import { useTranslations } from "next-intl";
export function FeedbackWidget({ slug }: { slug: string }) {
const t = useTranslations("docs");
const tc = useTranslations("common");
const [feedback, setFeedback] = useState<"yes" | "no" | null>(null);
const [submitted, setSubmitted] = useState(false);
@@ -25,28 +22,28 @@ export function FeedbackWidget({ slug }: { slug: string }) {
<span className="material-symbols-outlined text-primary text-2xl block mb-1">
check_circle
</span>
<p className="text-sm text-text-main">{t("feedbackThanks")}</p>
<p className="text-sm text-text-main">Thanks for your feedback!</p>
</div>
);
}
return (
<div className="mt-8 p-4 bg-bg-subtle border border-border rounded-lg">
<p className="text-sm text-text-main mb-3">{t("feedbackQuestion")}</p>
<p className="text-sm text-text-main mb-3">Was this page helpful?</p>
<div className="flex gap-3">
<button
onClick={() => handleFeedback("yes")}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-border rounded-lg hover:border-primary hover:text-primary transition-colors"
>
<span className="material-symbols-outlined text-sm">thumb_up</span>
{tc("yes")}
Yes
</button>
<button
onClick={() => handleFeedback("no")}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-border rounded-lg hover:border-red-400 hover:text-red-400 transition-colors"
>
<span className="material-symbols-outlined text-sm">thumb_down</span>
{tc("no")}
No
</button>
</div>
</div>

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