mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-09 08:42:15 +03:00
Compare commits
15 Commits
fix/i18n-h
...
codex/quot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01c6373bcd | ||
|
|
aa0d99e0a5 | ||
|
|
3c483dd906 | ||
|
|
f11d883f22 | ||
|
|
2cb77bbca7 | ||
|
|
a8216c92fe | ||
|
|
c790b57af8 | ||
|
|
9acf79f04f | ||
|
|
09665ab455 | ||
|
|
9ee6435f0e | ||
|
|
edd9b0d664 | ||
|
|
ba353aa3d6 | ||
|
|
224bc0a5a5 | ||
|
|
2e5854906d | ||
|
|
2e4268003a |
10
.github/workflows/docker-publish.yml
vendored
10
.github/workflows/docker-publish.yml
vendored
@@ -155,13 +155,13 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v4.5.2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v4.5.2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -255,13 +255,13 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v4.5.2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v4.5.2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -390,7 +390,7 @@ jobs:
|
||||
- name: Upload Trivy SARIF to Security tab
|
||||
if: needs.prepare.outputs.version != 'main'
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
uses: github/codeql-action/upload-sarif@v4.37.3
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: trivy-image
|
||||
|
||||
29
.mergify.yml
29
.mergify.yml
@@ -17,6 +17,13 @@
|
||||
# • Fallback path if Mergify misbehaves or the OSS plan changes: the manual
|
||||
# merge-train runbook (docs/ops/MERGE_TRAIN.md) — remove labels, proceed by hand.
|
||||
|
||||
# Auto-enqueue (current Mergify model, 2026): auto_merge_conditions in
|
||||
# merge_protections_settings — the rules-based queue action / autoqueue path is
|
||||
# deprecated (EOL 2026-07-16). The owner-applied `queue` label IS the approval.
|
||||
merge_protections_settings:
|
||||
auto_merge_conditions:
|
||||
- label = queue
|
||||
|
||||
queue_rules:
|
||||
- name: release
|
||||
# Any current or future release branch — the reason GitHub's native queue was
|
||||
@@ -34,14 +41,26 @@ queue_rules:
|
||||
# is intentionally NOT a condition here: the owner-applied `queue` label IS the
|
||||
# approval in this repo's single-maintainer model (see governance header).
|
||||
merge_conditions:
|
||||
- "#check-failure=0"
|
||||
# "Zero failures" — EXCEPT the advisory "Build (advisory)" job (quality.yml):
|
||||
# continue-on-error by design, and its GH-hosted Turbopack build hangs
|
||||
# recurrently mid-"Creating an optimized production build" (100% failure rate
|
||||
# across every sampled PR since the job was added 2026-07-27, always killed by
|
||||
# a runner timeout/shutdown signal, never a real compile error). Any OTHER
|
||||
# failure still blocks (anti-fail-open kept). The prior dast-smoke exception
|
||||
# (#7225) was dropped here: dast-smoke's hang (#7226) has been dormant for
|
||||
# weeks (0 failures in the last 30 runs; 2 all-time, none since 2026-07-13) —
|
||||
# carrying its tolerance forward would mask problems it no longer causes.
|
||||
- or:
|
||||
- "#check-failure=0"
|
||||
- and:
|
||||
- "#check-failure=1"
|
||||
- check-failure=Build (advisory)
|
||||
- "#check-pending=0"
|
||||
- "#check-success>=1"
|
||||
- check-success=Merge integrity (changelog + generated skills)
|
||||
# Batching: validate up to 10 queued PRs together (the manual train's sweet spot);
|
||||
# don't hold a lone PR hostage waiting for siblings.
|
||||
batch_size: 10
|
||||
batch_max_wait_time: 5 min
|
||||
# NO batching: 'Merge Queue Batch' requires a paid Mergify tier (live finding
|
||||
# 2026-07-15 — the queue command fails with "Cannot use Merge Queue batch" on
|
||||
# the free plan). Serial queue (1 PR at a time) still automates the train.
|
||||
# Squash keeps the one-commit-per-PR history the CHANGELOG reconciliation expects.
|
||||
merge_method: squash
|
||||
|
||||
|
||||
695
AGENTS.md
695
AGENTS.md
@@ -1,600 +1,117 @@
|
||||
# omniroute — Agent Guidelines
|
||||
# OmniRoute agent guide
|
||||
|
||||
## Project
|
||||
|
||||
Unified AI proxy/router — route any LLM through one endpoint. Multi-provider support
|
||||
with **290 provider entries** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks,
|
||||
Cohere, NVIDIA, Cerebras, Pollinations, Puter, Cloudflare AI, HuggingFace, DeepInfra,
|
||||
SambaNova, Meta Llama API, Moonshot AI, AI21 Labs, Databricks, Snowflake, and many more)
|
||||
with **MCP Server** (104 tools), **A2A v0.3 Protocol**, and **Electron desktop app**.
|
||||
|
||||
> **Live counts (v3.8.49)**: providers 290 · MCP tools 104 · MCP scopes 30 · A2A skills 6 ·
|
||||
> open-sse services 134 · routing strategies 17 · auto-combo scoring factors 12 ·
|
||||
> DB modules 95 · DB migrations 110 · base tables 17 · search providers 11 ·
|
||||
> i18n locales 42. **Refresh with `npm run check:docs-all`.**
|
||||
|
||||
## Doc Accuracy Discipline (read before writing any doc)
|
||||
|
||||
> **If `grep -rn "name" src/ open-sse/ bin/` returns nothing, the name does not exist. Do not document it.**
|
||||
|
||||
The recurring failure mode in AI-generated docs is _plausible-but-unverified specifics_.
|
||||
Every claim in a `.md` file under `docs/` should be verifiable against the source.
|
||||
|
||||
**Rules (enforced by `npm run check:fabricated-docs`):**
|
||||
|
||||
1. **Never state an API name, endpoint, path, CLI command, or env var without grepping for it first.**
|
||||
```bash
|
||||
grep -rn "theName" src/ open-sse/ bin/
|
||||
# 0 hits → do not document
|
||||
```
|
||||
2. **Never write a line count, file size, migration count, provider count, or strategy count from memory.**
|
||||
```bash
|
||||
wc -l <file> # exact line count
|
||||
ls <dir>/*.ts | wc -l # file count
|
||||
```
|
||||
3. **Every code example should be copy-pasted from real usage or actually run** — not synthesized.
|
||||
Link to a real call site (`path:line`) instead of inventing a signature.
|
||||
4. **Prefer citing real source (`file.ts:line`) over paraphrasing behavior** — verifiable and self-correcting.
|
||||
5. **A shorter doc that is 100% accurate beats a comprehensive one with fabrications.**
|
||||
Wrong docs cost more than missing docs, because people trust and act on them.
|
||||
|
||||
The script `scripts/check/check-fabricated-docs.mjs` extracts every route path, env var, hook
|
||||
name, function name, and file reference from `docs/**/*.md` and verifies each one against the
|
||||
codebase. Run it locally before pushing docs; it runs in CI via `npm run check:docs-all`.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Runtime**: Next.js 16 (App Router), Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
|
||||
- **Language**: TypeScript 6.0 (`src/`) + JavaScript (`open-sse/`, `electron/`)
|
||||
- **Database**: better-sqlite3 (SQLite) — `DATA_DIR` configurable, default `~/.omniroute/`
|
||||
- **Streaming**: SSE via `open-sse` internal workspace package
|
||||
- **Styling**: Tailwind CSS v4
|
||||
- **i18n**: next-intl with 42 locales (`src/i18n/messages/`) — refresh with `ls src/i18n/messages/*.json | wc -l`
|
||||
- **Desktop**: Electron (cross-platform: Windows, macOS, Linux)
|
||||
- **Schemas**: Zod v4 for all API / MCP input validation
|
||||
|
||||
---
|
||||
|
||||
## Build, Lint, and Test Commands
|
||||
|
||||
| Command | Description |
|
||||
| ----------------------------------- | ------------------------------------------------------------------ |
|
||||
| `npm run dev` | Start Next.js dev server |
|
||||
| `npm run build` | Production build: `next build` → `.build/next/` + assemble `dist/` |
|
||||
| `npm run build:release` | Clean rebuild + HEAD sentinel (`dist/BUILD_SHA`) — use for deploy |
|
||||
| `npm run start` | Run production build |
|
||||
| `npm run build:cli` | Build CLI package |
|
||||
| `npm run lint` | ESLint on all source files |
|
||||
| `npm run typecheck:core` | TypeScript core type checking |
|
||||
| `npm run typecheck:noimplicit:core` | Strict checking (no implicit any) |
|
||||
| `npm run check` | Run lint + test |
|
||||
| `npm run check:cycles` | Check for circular dependencies |
|
||||
| `npm run electron:dev` | Run Electron app in dev mode |
|
||||
| `npm run electron:build` | Build Electron app for current OS |
|
||||
|
||||
**Build output layout:**
|
||||
|
||||
| Directory | Purpose | Gitignored |
|
||||
| --------- | -------------------------------------------------- | ---------- |
|
||||
| `src/` | Application source (TypeScript / TSX) | No |
|
||||
| `.build/` | Build intermediates (`distDir = .build/next`) | Yes |
|
||||
| `dist/` | Shippable bundle assembled by `assembleStandalone` | Yes |
|
||||
|
||||
The pipeline is a single `next build` pass — intermediates land in `.build/next/`, the
|
||||
assembled bundle in `dist/`. VPS deploys rsync `dist/` into the remote
|
||||
`/usr/lib/node_modules/omniroute/app/` directory (VPS image path is unchanged).
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# All tests (unit + vitest + ecosystem + e2e)
|
||||
npm run test:all
|
||||
|
||||
# Single test file (Node.js native test runner — most tests use this)
|
||||
node --import tsx/esm --test tests/unit/your-file.test.ts
|
||||
node --import tsx/esm --test tests/unit/plan3-p0.test.ts
|
||||
node --import tsx/esm --test tests/unit/fixes-p1.test.ts
|
||||
node --import tsx/esm --test tests/unit/security-fase01.test.ts
|
||||
|
||||
# Integration tests
|
||||
node --import tsx/esm --test tests/integration/*.test.ts
|
||||
|
||||
# Vitest (MCP server, autoCombo)
|
||||
npm run test:vitest
|
||||
|
||||
# E2E with Playwright
|
||||
npm run test:e2e
|
||||
|
||||
# Protocol clients E2E (MCP transports, A2A)
|
||||
npm run test:protocols:e2e
|
||||
|
||||
# Ecosystem compatibility tests
|
||||
npm run test:ecosystem
|
||||
|
||||
# Coverage (see CONTRIBUTING.md)
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
**For authoritative coverage requirements, test execution, and PR gates, see [`CONTRIBUTING.md`](CONTRIBUTING.md#running-tests).**
|
||||
|
||||
---
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### Formatting (Prettier — enforced via lint-staged)
|
||||
|
||||
2 spaces · semicolons required · double quotes (`"`) · 100 char width · es5 trailing commas.
|
||||
Always run `prettier --write` on changed files.
|
||||
|
||||
### TypeScript
|
||||
|
||||
- **Target**: ES2022 · **Module**: `esnext` · **Resolution**: `bundler`
|
||||
- `strict: false` — prefer explicit types, don't rely on inference
|
||||
- Path aliases: `@/*` → `src/`, `@omniroute/open-sse` → `open-sse/`, `@omniroute/open-sse/*` → `open-sse/*`
|
||||
|
||||
### ESLint Rules
|
||||
|
||||
- **Security (error, everywhere)**: `no-eval`, `no-implied-eval`, `no-new-func`
|
||||
- **Relaxed in `open-sse/` and `tests/`**: `@typescript-eslint/no-explicit-any` = warn
|
||||
- React hooks rules and `@next/next/no-assign-module-variable` disabled in `open-sse/` and `tests/`
|
||||
|
||||
### Naming
|
||||
|
||||
| Element | Convention | Example |
|
||||
| ------------------- | -------------------------------- | ------------------------------------ |
|
||||
| Files | camelCase / kebab-case | `chatCore.ts`, `tokenHealthCheck.ts` |
|
||||
| React components | PascalCase | `Dashboard.tsx`, `ProviderCard.tsx` |
|
||||
| Functions/variables | camelCase | `getHealth()`, `switchCombo()` |
|
||||
| Constants | UPPER_SNAKE | `MAX_RETRIES`, `DEFAULT_TIMEOUT` |
|
||||
| Interfaces | PascalCase (`I` prefix optional) | `ProviderConfig` |
|
||||
| Enums | PascalCase (members too) | `LogLevel.Error` |
|
||||
|
||||
### Imports
|
||||
|
||||
- **Order**: external → internal (`@/`, `@omniroute/open-sse`) → relative (`./`, `../`)
|
||||
- **No barrel imports** from `localDb.ts` — import from the specific `db/` module instead
|
||||
|
||||
### Error Handling
|
||||
|
||||
- try/catch with specific error types; always log with context (pino logger)
|
||||
- Never silently swallow errors in SSE streams — use abort signals for cleanup
|
||||
- Return proper HTTP status codes (4xx client, 5xx server)
|
||||
|
||||
### Security
|
||||
|
||||
- **NEVER** commit API keys, secrets, or credentials
|
||||
- Validate all user inputs with Zod schemas
|
||||
- Auth middleware required on all API routes
|
||||
- Never log SQLite encryption keys
|
||||
- Sanitize user content (dompurify for HTML)
|
||||
- **Public upstream OAuth identifiers** (Gemini / Antigravity / Windsurf-style client_id/secret + Firebase Web keys extracted from public CLIs): use `resolvePublicCred()` from `open-sse/utils/publicCreds.ts`, **never** as string literals. Full pattern in `docs/security/PUBLIC_CREDS.md`.
|
||||
- **Error responses** (HTTP / SSE / executor / MCP): use `buildErrorBody()` or `sanitizeErrorMessage()` from `open-sse/utils/error.ts`, **never** put raw `err.stack` / `err.message` in a Response body. Full pattern in `docs/security/ERROR_SANITIZATION.md`.
|
||||
- **`exec()` / `spawn()` with runtime values**: pass via the `env` option, **never** string-interpolate paths/values into the script body. Reference: `src/mitm/cert/install.ts::updateNssDatabases`.
|
||||
- Prefer secure-by-default libraries when available — see [tldrsec/awesome-secure-defaults](https://github.com/tldrsec/awesome-secure-defaults) for the curated list (Helmet.js, DOMPurify, ssrf-req-filter, safe-regex, Google Tink, etc.).
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data Layer (`src/lib/db/`)
|
||||
|
||||
All persistence uses SQLite through **95 domain-specific modules** in `src/lib/db/`. Top modules:
|
||||
|
||||
- Core: `core.ts`, `migrationRunner.ts`, `encryption.ts`, `stateReset.ts`
|
||||
- Providers / catalog: `providers.ts`, `models.ts`, `providerLimits.ts`, `compressionAnalytics.ts`
|
||||
- Routing: `combos.ts`, `modelComboMappings.ts`, `domainState.ts`, `commandCodeAuth.ts`
|
||||
- Auth: `apiKeys.ts`, `secrets.ts`, `registeredKeys.ts`, `sessionAccountAffinity.ts`
|
||||
- Usage / billing: `quotaSnapshots.ts`, `creditBalance.ts`, `usage*.ts`, `compressionCacheStats.ts`
|
||||
- Storage: `backup.ts`, `cleanup.ts`, `jsonMigration.ts`, `healthCheck.ts`, `databaseSettings.ts`
|
||||
- Extension modules: `evals.ts`, `webhooks.ts`, `reasoningCache.ts`, `readCache.ts`, `tierConfig.ts`, `compressionCombos.ts`, `compressionScheduler.ts`, `batches.ts`, `files.ts`, `syncTokens.ts`, `proxies.ts`, `oneproxy.ts`, `upstreamProxy.ts`, `versionManager.ts`, `cliToolState.ts`, `prompts.ts`, `detailedLogs.ts`, `contextHandoffs.ts`, `compression.ts`, `stats.ts`
|
||||
|
||||
Live count: `ls src/lib/db/*.ts | wc -l` (currently 95). Drift detection: `npm run check:docs-counts`.
|
||||
Schema migrations live in `db/migrations/` (**110 files** as of v3.8.43) and run via `migrationRunner.ts`.
|
||||
`src/lib/localDb.ts` is a **re-export layer only** — never add logic there.
|
||||
|
||||
#### DB Internals
|
||||
|
||||
- **`core.ts`**: `getDbInstance()` returns a singleton `better-sqlite3` instance with WAL
|
||||
journaling. `SCHEMA_SQL` defines **17 base tables** (verify with `grep -c "CREATE TABLE" src/lib/db/core.ts` minus 1 for the bookkeeping `_omniroute_migrations` table). Helpers: `rowToCamel`, `encryptConnectionFields`.
|
||||
- **`migrationRunner.ts`**: Applies versioned SQL files from `db/migrations/` inside transactions.
|
||||
Tracks applied migrations in `_omniroute_migrations` table.
|
||||
- **Migrations**: 110 files (`001_initial_schema.sql` → `110_*.sql`).
|
||||
Each migration is idempotent and runs in a transaction. Live count: `ls src/lib/db/migrations/*.sql | wc -l`.
|
||||
- **Domain modules** import `getDbInstance()` from `core.ts` for all CRUD operations.
|
||||
Each module owns a specific table/set of tables (e.g., `providers.ts` → `provider_connections`,
|
||||
`combos.ts` → `combos`). Encryption helpers protect sensitive fields at rest.
|
||||
- **`localDb.ts`** re-exports all domain modules — consumers import from here for convenience.
|
||||
|
||||
### API Route Layer (`src/app/api/v1/`)
|
||||
|
||||
Next.js App Router routes — each follows a consistent pattern:
|
||||
|
||||
```
|
||||
Route → CORS preflight → Body validation (Zod) → Optional auth (extractApiKey/isValidApiKey)
|
||||
→ API key policy enforcement (enforceApiKeyPolicy) → Handler delegation (open-sse)
|
||||
```
|
||||
|
||||
| Route | Handler | Notes |
|
||||
| ------------------------------- | ------------------------- | ------------------------------------------------------------- |
|
||||
| `chat/completions/route.ts` | `handleChat()` | + prompt injection guard (clones request) |
|
||||
| `responses/route.ts` | `handleChat()` (unified) | Responses API format |
|
||||
| `embeddings/route.ts` | `handleEmbedding()` | Model listing + creation |
|
||||
| `images/generations/route.ts` | `handleImageGeneration()` | Model listing + creation |
|
||||
| `audio/transcriptions/route.ts` | audio handler | Multipart form data |
|
||||
| `audio/speech/route.ts` | TTS handler | Binary audio response |
|
||||
| `videos/generations/route.ts` | video handler | ComfyUI/SD WebUI |
|
||||
| `music/generations/route.ts` | music handler | ComfyUI workflows |
|
||||
| `moderations/route.ts` | moderation handler | Content safety |
|
||||
| `rerank/route.ts` | rerank handler | Document relevance |
|
||||
| `search/route.ts` | search handler | Web search (12 providers per `open-sse/handlers/search.ts:6`) |
|
||||
|
||||
**No global Next.js middleware file** — interception is route-specific. Auth is optional
|
||||
(controlled by `REQUIRE_API_KEY` env). Prompt injection guard is unique to chat completions.
|
||||
|
||||
### Request Pipeline (`open-sse/`)
|
||||
|
||||
The `open-sse/` workspace is the core streaming engine. Full request flow:
|
||||
|
||||
```
|
||||
Client Request
|
||||
→ src/app/api/v1/.../route.ts (Next.js route)
|
||||
→ open-sse/handlers/chatCore.ts::handleChatCore()
|
||||
→ Semantic/signature cache check
|
||||
→ Rate limit check (rateLimitManager)
|
||||
→ Combo routing? → open-sse/services/combo.ts::handleComboChat()
|
||||
→ resolveComboTargets() → ordered ResolvedComboTarget[]
|
||||
→ For each target: handleSingleModel() (wraps chatCore)
|
||||
→ translateRequest() (open-sse/translator/)
|
||||
→ Convert source format (e.g., OpenAI) → target format (e.g., Claude)
|
||||
→ getExecutor() → provider-specific executor instance
|
||||
→ executor.execute() (BaseExecutor → DefaultExecutor or provider-specific)
|
||||
→ buildUrl() + buildHeaders() + transformRequest()
|
||||
→ fetch() to upstream provider
|
||||
→ Retry logic with exponential backoff
|
||||
→ Response translation back to client format
|
||||
→ If Responses API: responsesTransformer.ts TransformStream
|
||||
→ SSE stream or JSON response to client
|
||||
```
|
||||
|
||||
**Handlers** (`open-sse/handlers/`): `chatCore.ts`, `responsesHandler.ts`, `embeddings.ts`,
|
||||
`imageGeneration.ts`, `videoGeneration.ts`, `musicGeneration.ts`, `audioSpeech.ts`,
|
||||
`audioTranscription.ts`, `moderations.ts`, `rerank.ts`, `search.ts`.
|
||||
|
||||
**Upstream headers**: merged after default auth; same header name replaces executor value.
|
||||
**T5 intra-family fallback** recomputes headers using only the fallback model id.
|
||||
Forbidden header names: `src/shared/constants/upstreamHeaders.ts` — keep sanitize,
|
||||
Zod schemas, and unit tests aligned when editing.
|
||||
|
||||
### Provider Categories
|
||||
|
||||
- **Free** (2): Qoder AI, Kiro AI
|
||||
- **OAuth** (13): Claude Code, Antigravity, Codex, GitHub Copilot, Cursor, Kimi Coding, Kilo Code, Cline, Kiro, Qoder, Gemini, Windsurf (v3.8), GitLab Duo (v3.8)
|
||||
- **API Key** (120+): OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Perplexity,
|
||||
Together, Fireworks, Cerebras, Cohere, NVIDIA, Nebius, SiliconFlow, Hyperbolic,
|
||||
HuggingFace, OpenRouter, Vertex AI, Cloudflare AI, Scaleway, AI/ML API, Pollinations,
|
||||
Puter, Longcat, Alibaba, Kimi, Minimax, Blackbox, Synthetic, Kilo Gateway,
|
||||
Z.AI, GLM, Deepgram, AssemblyAI, ElevenLabs, Cartesia, PlayHT, Inworld,
|
||||
NanoBanana, SD WebUI, ComfyUI, Ollama Cloud, Perplexity Search, Serper, Brave, Exa,
|
||||
Tavily, OpenCode Zen/Go, Bailian Coding Plan, DeepInfra, Vercel AI Gateway,
|
||||
Lambda AI, SambaNova, nScale, OVHcloud AI, Baseten, PublicAI, Moonshot AI,
|
||||
Meta Llama API, v0 (Vercel), Morph, Featherless AI, FriendliAI, LlamaGate,
|
||||
Galadriel, Weights & Biases Inference, Volcengine, AI21 Labs, Venice.ai,
|
||||
Codestral, Upstage, Maritalk, Xiaomi MiMo, Inference.net, NanoGPT, Predibase,
|
||||
Bytez, Heroku AI, Databricks, Snowflake Cortex, GigaChat (Sber), CrofAI,
|
||||
AgentRouter, ChatGPT Web, Baidu Qianfan, AWS Polly, RunwayML, GitLab Duo,
|
||||
Amazon Q, Empower, Poe, and many more.
|
||||
- **Self-Hosted** (8+): LM Studio, vLLM, Lemonade, Llamafile, Triton, Docker Model Runner, Xinference, Oobabooga
|
||||
- **Custom**: OpenAI-compatible (`openai-compatible-*`) and Anthropic-compatible (`anthropic-compatible-*`) prefixes
|
||||
|
||||
Providers are registered in `src/shared/constants/providers.ts` with Zod validation at module load.
|
||||
|
||||
### Executors (`open-sse/executors/`)
|
||||
|
||||
Provider-specific request executors: `base.ts`, `default.ts`, `cursor.ts`, `codex.ts`,
|
||||
`antigravity.ts`, `github.ts`, `kiro.ts`, `qoder.ts`, `vertex.ts`,
|
||||
`cloudflare-ai.ts`, `opencode.ts`, `pollinations.ts`, `puter.ts`.
|
||||
|
||||
#### Executor Internals
|
||||
|
||||
- **`base.ts`** (`BaseExecutor`): Abstract base with `buildUrl()`, `buildHeaders()`,
|
||||
`transformRequest()`, retry logic (exponential backoff), and `execute()`. Subclasses
|
||||
override URL/header/transform methods for provider-specific behavior.
|
||||
- **`default.ts`** (`DefaultExecutor extends BaseExecutor`): Handles most OpenAI-compatible
|
||||
providers. Reads provider config from `providerRegistry.ts` to resolve base URL, auth
|
||||
header format, and request transformations.
|
||||
- **`getExecutor()`** (`executors/index.ts`): Factory that returns the correct executor
|
||||
instance based on provider ID. Provider-specific executors (Cursor, Codex, Vertex, etc.)
|
||||
override only what differs from the default.
|
||||
|
||||
### Translator (`open-sse/translator/`)
|
||||
|
||||
Translates between API formats (OpenAI-format ↔ Anthropic, Gemini, etc.).
|
||||
Includes request/response translators with helpers for image handling.
|
||||
|
||||
#### Translator Internals
|
||||
|
||||
- **`translator/index.ts`**: Exports `translateRequest()` and format constants. Called by
|
||||
`chatCore.ts` before executor dispatch.
|
||||
- **Flow**: `translateRequest(body, sourceFormat, targetFormat)` → detects source format
|
||||
(OpenAI, Anthropic, Gemini) → applies the matching translator module → returns
|
||||
transformed body ready for the target provider.
|
||||
- **Response translation** runs in reverse after upstream response, converting back to
|
||||
the client's expected format.
|
||||
|
||||
### Transformer (`open-sse/transformer/`)
|
||||
|
||||
`responsesTransformer.ts` — transforms Responses API format to/from Chat Completions format.
|
||||
|
||||
#### Transformer Internals
|
||||
|
||||
- **`createResponsesApiTransformStream()`**: Returns a `TransformStream` that converts
|
||||
Chat Completions SSE chunks (`data: {"choices":[...]}`) into Responses API SSE events
|
||||
(`response.output_item.added`, `response.output_text.delta`, etc.).
|
||||
- Used when the client sends a Responses API request: the request is internally converted
|
||||
to Chat Completions format, dispatched normally, and the response is piped through this
|
||||
transform stream before reaching the client.
|
||||
|
||||
### Services (`open-sse/services/`)
|
||||
|
||||
134 service modules in `open-sse/services/` (top-level only; more including sub-dirs like `autoCombo/` and `compression/`). Refresh: `ls open-sse/services/*.ts | wc -l`. Key modules:
|
||||
`combo.ts` (routing engine), `usage.ts`, `tokenRefresh.ts`,
|
||||
`rateLimitManager.ts`, `accountFallback.ts`, `sessionManager.ts`, `wildcardRouter.ts`,
|
||||
`autoCombo/`, `intentClassifier.ts`, `taskAwareRouter.ts`, `thinkingBudget.ts`,
|
||||
`contextManager.ts`, `modelDeprecation.ts`, `modelFamilyFallback.ts`,
|
||||
`emergencyFallback.ts`, `workflowFSM.ts`, `backgroundTaskDetector.ts`, `ipFilter.ts`,
|
||||
`signatureCache.ts`, `volumeDetector.ts`, `contextHandoff.ts`, `compression/` (prompt
|
||||
compression pipeline), and more.
|
||||
|
||||
#### Prompt Compression Pipeline (`compression/`)
|
||||
|
||||
Modular prompt compression that runs proactively before the existing reactive context manager.
|
||||
|
||||
- **`strategySelector.ts`**: Selects compression mode based on config, compression combo assignments,
|
||||
combo overrides, auto-trigger thresholds, and defaults. Priority: assigned compression combo >
|
||||
combo override > auto-trigger > default mode > off.
|
||||
- **`lite.ts`**: 5 lite-mode techniques: `collapseWhitespace`, `dedupSystemPrompt`,
|
||||
`compressToolResults`, `removeRedundantContent`, `replaceImageUrls`. Target: 10-15% savings at
|
||||
<1ms latency.
|
||||
- **`caveman.ts` / `cavemanRules.ts`**: Caveman-style semantic condensation backed by built-in
|
||||
rules plus file-loaded language packs under `compression/rules/`.
|
||||
- **`engines/rtk/`**: Rule-based terminal/tool-output compression inspired by RTK patterns. Detects
|
||||
command output classes, applies JSON filter packs, deduplicates repeated lines, strips ANSI/code
|
||||
noise, and preserves errors/actionable context. The RTK JSON DSL supports replace,
|
||||
match-output short-circuit, strip/keep, per-line truncation, head/tail/max-line truncation,
|
||||
inline tests, trust-gated project/global custom filters, and optional redacted raw-output
|
||||
retention for authenticated recovery.
|
||||
- **`engines/registry.ts`**: Registers engines (`caveman`, `rtk`) and powers stacked pipelines.
|
||||
- **`stats.ts`**: Per-request compression stats tracking (original tokens, compressed tokens,
|
||||
savings %, techniques used, engine breakdown, compression combo id).
|
||||
- **`types.ts`**: `CompressionMode` (off/lite/standard/aggressive/ultra/rtk/stacked),
|
||||
`CompressionConfig`, `CompressionStats`, `CompressionResult`.
|
||||
- DB settings in `src/lib/db/compression.ts`, compression combos in
|
||||
`src/lib/db/compressionCombos.ts`, API routes under `src/app/api/settings/compression/`,
|
||||
`src/app/api/context/*`, and preview/language-pack routes under `src/app/api/compression/*`.
|
||||
|
||||
#### Combo Routing Engine (`combo.ts`)
|
||||
|
||||
- **`handleComboChat()`**: Entry point for combo-routed requests. Receives the combo config
|
||||
and iterates through targets in order until one succeeds or all fail.
|
||||
- **`resolveComboTargets()`**: Expands a combo configuration into an ordered array of
|
||||
`ResolvedComboTarget[]`, each specifying provider + model + account + credentials.
|
||||
- **Strategies** (17): priority, weighted, fill-first, round-robin, P2C, random, least-used, reset-aware (v3.8),
|
||||
reset-window, cost-optimized, strict-random, auto, lkgp, context-optimized, context-relay, headroom, fusion. Source: `ROUTING_STRATEGY_VALUES` in `src/shared/constants/routingStrategies.ts`.
|
||||
- Each target calls **`handleSingleModel()`** which wraps `handleChatCore()` with
|
||||
per-target error handling and circuit breaker checks.
|
||||
|
||||
### Domain Layer (`src/domain/`)
|
||||
|
||||
Policy engine modules: `policyEngine.ts`, `comboResolver.ts`, `costRules.ts`,
|
||||
`degradation.ts`, `fallbackPolicy.ts`, `lockoutPolicy.ts`, `modelAvailability.ts`,
|
||||
`providerExpiration.ts`, `quotaCache.ts`, `responses.ts`, `configAudit.ts`.
|
||||
|
||||
### MCP Server (`open-sse/mcp-server/`)
|
||||
|
||||
**104 tools** total (`TOTAL_MCP_TOOL_COUNT`, `open-sse/mcp-server/server.ts`): a 42-entry base registry (`MCP_TOOLS` in `schemas/tools.ts`, bundling the core / cache / compression / 1proxy / advanced tools) **plus** standalone module sets — memory (3), skill (4), agentSkill (3), pool (6), gamification (8), plugin (8), notion (6), obsidian (22). 3 transports (stdio / SSE / Streamable HTTP). Scoped auth (31 scopes — see `OMNIROUTE_MCP_SCOPES`), Zod schemas. See [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md).
|
||||
|
||||
**Core tools** (20): get_health, list_combos, get_combo_metrics, switch_combo, check_quota,
|
||||
route_request, cost_report, list_models_catalog, web_search, simulate_route, set_budget_guard,
|
||||
set_routing_strategy, set_resilience_profile, test_combo, get_provider_metrics,
|
||||
best_combo_for_task, explain_route, get_session_snapshot, db_health_check, sync_pricing.
|
||||
|
||||
**Cache tools** (2): cache_stats, cache_flush.
|
||||
|
||||
**Compression tools** (5): compression_status, compression_configure, set_compression_engine,
|
||||
list_compression_combos, compression_combo_stats.
|
||||
|
||||
**1proxy tools** (3): oneproxy_fetch, oneproxy_rotate, oneproxy_stats.
|
||||
|
||||
**Memory tools** (3): memory_search, memory_add, memory_clear.
|
||||
|
||||
**Skill tools** (4): skills_list, skills_enable, skills_execute, skills_executions.
|
||||
|
||||
**Agent-skill tools** (3): A2A skill discovery / invocation bridges.
|
||||
|
||||
**Gamification tools** (8): levels, badges, leaderboard, and community-federation queries.
|
||||
|
||||
**Plugin tools** (8): plugin marketplace listing, install/enable/disable, and runtime inspection.
|
||||
|
||||
**Notion tools** (6) + **Obsidian tools** (22): knowledge-base read/write integrations (the largest tool family — vault search, note CRUD, WebDAV-backed file ops).
|
||||
|
||||
#### MCP Internals
|
||||
|
||||
- **Tool registration**: Each tool is an object with `{ name, description, inputSchema: ZodSchema,
|
||||
handler: async (args) => {...} }`. Zod validates inputs before the handler fires.
|
||||
- **`createMcpServer()`** and **`startMcpStdio()`** exported from `mcp-server/index.ts`.
|
||||
`createMcpServer()` wires all tool sets; `startMcpStdio()` launches the stdio transport.
|
||||
- **Transports**: stdio (CLI `omniroute --mcp`), SSE (`/api/mcp/sse`), Streamable HTTP
|
||||
(`/api/mcp/stream`). All share the same tool/scope engine.
|
||||
- **Scopes** (30): Control which tool categories an API key can access. Enforcement happens
|
||||
before handler dispatch.
|
||||
- **Audit**: Every tool invocation is logged to SQLite (`mcp_audit` table) with tool name,
|
||||
args, success/failure, API key attribution, and timestamp.
|
||||
|
||||
### A2A Server (`src/lib/a2a/`)
|
||||
|
||||
JSON-RPC 2.0, SSE streaming, Task Manager with TTL cleanup.
|
||||
Agent Card at `/.well-known/agent.json`.
|
||||
Skills (6): `smartRouting.ts`, `quotaManagement.ts`, `providerDiscovery.ts`, `costAnalysis.ts`, `healthReport.ts`, `listCapabilities.ts`.
|
||||
|
||||
#### A2A Internals
|
||||
|
||||
- **`taskManager.ts`**: State machine lifecycle for tasks: `submitted → working →
|
||||
completed | failed | canceled`. Tasks have TTL and are cleaned up automatically.
|
||||
- **JSON-RPC methods**: `message/send` (sync), `message/stream` (SSE), `tasks/get`,
|
||||
`tasks/cancel`. Dispatched via `POST /a2a`.
|
||||
- **Skills**: Registered in a DB-backed registry. Each skill receives task context
|
||||
(messages, metadata) and returns structured results. `quotaManagement.ts` summarizes
|
||||
quota; `smartRouting.ts` recommends routing decisions.
|
||||
- **Agent Card**: `/.well-known/agent.json` exposes capabilities, skills, and metadata
|
||||
for client auto-discovery.
|
||||
|
||||
### ACP Module (`src/lib/acp/`)
|
||||
|
||||
Agent Communication Protocol registry and manager.
|
||||
|
||||
### Memory System (`src/lib/memory/`)
|
||||
|
||||
Extraction, injection, retrieval, summarization, and store modules for persistent
|
||||
conversational memory across sessions.
|
||||
|
||||
### Skills System (`src/lib/skills/`)
|
||||
|
||||
Extensible skill framework: registry, executor, sandbox, built-in skills,
|
||||
custom skill support, interception, and injection.
|
||||
|
||||
#### Skills Internals
|
||||
|
||||
- **`registry.ts`**: DB-backed skill registration and discovery. Skills have metadata
|
||||
(name, description, version, enabled status) stored in SQLite.
|
||||
- **`executor.ts`**: Execution engine with configurable timeout and retry logic.
|
||||
Receives skill name + input, looks up the skill, runs it in the sandbox.
|
||||
- **`sandbox.ts`**: Isolation layer for custom (user-provided) skills. Limits resource
|
||||
access and execution time.
|
||||
- **Built-in skills**: Ship with OmniRoute (e.g., quota management, routing). Located
|
||||
alongside the registry.
|
||||
- **Interception/Injection**: Skills can intercept requests in the pipeline (pre/post
|
||||
processing) or inject context into prompts.
|
||||
|
||||
### Compliance (`src/lib/compliance/`)
|
||||
|
||||
Policy index for compliance enforcement.
|
||||
|
||||
### MITM Proxy (`src/mitm/`)
|
||||
|
||||
MITM proxy capability with certificate management, DNS handling, and target routing.
|
||||
|
||||
### Middleware (`src/middleware/`)
|
||||
|
||||
Request middleware including `promptInjectionGuard.ts`.
|
||||
|
||||
### Guardrails (`src/lib/guardrails/`)
|
||||
|
||||
Hot-reloadable guardrails framework (3 built-in: pii-masker, prompt-injection, vision-bridge). Fail-open. The `pii-masker` guardrail is registered and runs on every request, but its data-mutating logic is **opt-in** and OFF by default — it only redacts when `PII_REDACTION_ENABLED` (request) / `PII_RESPONSE_SANITIZATION` (response + streaming) are enabled (both `defaultValue: "false"`); with them off, payloads pass through untouched. A request can additionally opt OUT of any guardrail via header (`x-omniroute-disabled-guardrails`). Never make PII default-on (Hard Rule #20). See [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md).
|
||||
|
||||
### Cloud Agents (`src/lib/cloudAgent/`)
|
||||
|
||||
`CloudAgentBase` abstract class + 3 agents (codex-cloud, devin, jules). Tasks persisted in `cloud_agent_tasks`; management auth required. See [`docs/frameworks/CLOUD_AGENT.md`](docs/frameworks/CLOUD_AGENT.md).
|
||||
|
||||
### Evals (`src/lib/evals/`)
|
||||
|
||||
Generic eval framework: `evalRunner.ts`, `runtime.ts`. Targets: combo / model / suite-default. See [`docs/frameworks/EVALS.md`](docs/frameworks/EVALS.md).
|
||||
|
||||
### Webhooks (`src/lib/webhookDispatcher.ts`)
|
||||
|
||||
HMAC-signed delivery, exponential backoff, auto-disable after 10 failures. 7 event types. See [`docs/frameworks/WEBHOOKS.md`](docs/frameworks/WEBHOOKS.md).
|
||||
|
||||
### Authorization Pipeline (`src/server/authz/`)
|
||||
|
||||
`classify → policies → enforce`. 3 route classes (PUBLIC / CLIENT_API / MANAGEMENT). See [`docs/architecture/AUTHZ_GUIDE.md`](docs/architecture/AUTHZ_GUIDE.md).
|
||||
|
||||
### Reasoning Replay (`src/lib/db/reasoningCache.ts` + `open-sse/services/reasoningCache.ts`)
|
||||
|
||||
Hybrid in-memory + SQLite cache for `reasoning_content`. Re-injects on multi-turn for strict providers (DeepSeek V4, Kimi K2, Qwen-Thinking, GLM, xiaomi-mimo). See [`docs/routing/REASONING_REPLAY.md`](docs/routing/REASONING_REPLAY.md).
|
||||
|
||||
### Tunnels (`src/lib/{cloudflaredTunnel,ngrokTunnel}.ts` + `src/app/api/tunnels/`)
|
||||
|
||||
Cloudflare Quick/Named, ngrok, Tailscale Funnel. See [`docs/ops/TUNNELS_GUIDE.md`](docs/ops/TUNNELS_GUIDE.md).
|
||||
|
||||
### Adding a New Provider
|
||||
|
||||
1. Register in `src/shared/constants/providers.ts`
|
||||
2. Add executor in `open-sse/executors/` (if custom logic needed)
|
||||
3. Add translator in `open-sse/translator/` (if non-OpenAI format)
|
||||
4. Add OAuth config in `src/lib/oauth/constants/oauth.ts` (if OAuth-based)
|
||||
5. Add models in `open-sse/config/providerRegistry.ts`
|
||||
|
||||
---
|
||||
|
||||
## Subdirectory AGENTS.md Files
|
||||
|
||||
- **[`src/lib/db/AGENTS.md`](src/lib/db/AGENTS.md)** — SQLite persistence, domain modules, migrations
|
||||
- **[`open-sse/services/AGENTS.md`](open-sse/services/AGENTS.md)** — Routing engine, combo resolution, strategy selection
|
||||
|
||||
## Reference Documentation (docs/)
|
||||
|
||||
For any non-trivial change, read the matching deep-dive first:
|
||||
|
||||
| Area | Doc |
|
||||
| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
|
||||
| Repo navigation | [`docs/architecture/REPOSITORY_MAP.md`](docs/architecture/REPOSITORY_MAP.md) |
|
||||
| Architecture | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
|
||||
| Engineering reference | [`docs/architecture/CODEBASE_DOCUMENTATION.md`](docs/architecture/CODEBASE_DOCUMENTATION.md) |
|
||||
| Auto-Combo (12-factor, 18 strategies) | [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md) |
|
||||
| Resilience (3 layers) | [`docs/architecture/RESILIENCE_GUIDE.md`](docs/architecture/RESILIENCE_GUIDE.md) |
|
||||
| Skills | [`docs/frameworks/SKILLS.md`](docs/frameworks/SKILLS.md) |
|
||||
| Memory | [`docs/frameworks/MEMORY.md`](docs/frameworks/MEMORY.md) |
|
||||
| Cloud agents | [`docs/frameworks/CLOUD_AGENT.md`](docs/frameworks/CLOUD_AGENT.md) |
|
||||
| Guardrails | [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md) |
|
||||
| Evals | [`docs/frameworks/EVALS.md`](docs/frameworks/EVALS.md) |
|
||||
| Compliance | [`docs/security/COMPLIANCE.md`](docs/security/COMPLIANCE.md) |
|
||||
| Webhooks | [`docs/frameworks/WEBHOOKS.md`](docs/frameworks/WEBHOOKS.md) |
|
||||
| Authz | [`docs/architecture/AUTHZ_GUIDE.md`](docs/architecture/AUTHZ_GUIDE.md) |
|
||||
| Stealth | [`docs/security/STEALTH_GUIDE.md`](docs/security/STEALTH_GUIDE.md) |
|
||||
| Reasoning replay | [`docs/routing/REASONING_REPLAY.md`](docs/routing/REASONING_REPLAY.md) |
|
||||
| Agent protocols (A2A / ACP / Cloud) | [`docs/frameworks/AGENT_PROTOCOLS_GUIDE.md`](docs/frameworks/AGENT_PROTOCOLS_GUIDE.md) |
|
||||
| MCP server | [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md) |
|
||||
| A2A server | [`docs/frameworks/A2A-SERVER.md`](docs/frameworks/A2A-SERVER.md) |
|
||||
| API reference | [`docs/reference/API_REFERENCE.md`](docs/reference/API_REFERENCE.md) + [`docs/openapi.yaml`](docs/openapi.yaml) |
|
||||
| Provider catalog (auto-generated) | [`docs/reference/PROVIDER_REFERENCE.md`](docs/reference/PROVIDER_REFERENCE.md) |
|
||||
| Tunnels | [`docs/ops/TUNNELS_GUIDE.md`](docs/ops/TUNNELS_GUIDE.md) |
|
||||
| Electron desktop | [`docs/guides/ELECTRON_GUIDE.md`](docs/guides/ELECTRON_GUIDE.md) |
|
||||
| Release flow | [`docs/ops/RELEASE_CHECKLIST.md`](docs/ops/RELEASE_CHECKLIST.md) |
|
||||
| Quality gates (35 gates, allowlist policy) | [`docs/architecture/QUALITY_GATES.md`](docs/architecture/QUALITY_GATES.md) |
|
||||
| Cluster opt-in profiles (memory, bifrost) | [`docs/architecture/cluster-decisions.md`](docs/architecture/cluster-decisions.md) |
|
||||
|
||||
---
|
||||
|
||||
## Fork / Upstream Workflow
|
||||
|
||||
This repository is a fork of `diegosouzapw/OmniRoute`. Keep fork-only operational
|
||||
changes (for example GHCR image publishing, personal deployment workflows, or local
|
||||
automation) out of upstream contribution PRs.
|
||||
|
||||
When preparing a PR for upstream, always start the work branch from the upstream
|
||||
**default branch** — the active `release/vX.Y.Z` line (today `release/v3.8.49`).
|
||||
Never branch from `main`: `main` only receives release squash-merges, so a branch
|
||||
cut there is weeks behind and produces conflict-heavy PRs
|
||||
(see `CONTRIBUTING.md` and `docs/ops/BRANCHING_MODEL.md`):
|
||||
OmniRoute is a unified AI proxy/router. The repository contains the Next.js application
|
||||
(`src/`), streaming engine workspace (`open-sse/`), Electron desktop app (`electron/`),
|
||||
CLI (`bin/`), and tests (`tests/`).
|
||||
|
||||
## Setup and focused checks
|
||||
|
||||
- Runtime: Node.js `>=22.22.3 <23` or `>=24.0.0 <27`; npm 10+.
|
||||
- Install dependencies: `npm install`.
|
||||
- Start development: `npm run dev`.
|
||||
- Build: `npm run build`; release build: `npm run build:release`.
|
||||
- Lint: `npm run lint`.
|
||||
- Core type check: `npm run typecheck:core`.
|
||||
- Run the most focused test for changed code first:
|
||||
`node --import tsx/esm --test tests/unit/<file>.test.ts`.
|
||||
- Other suites: `npm run test:vitest`, `npm run test:e2e`,
|
||||
`npm run test:protocols:e2e`, and `npm run test:ecosystem`.
|
||||
- Run `npm run check:docs-all` after changing documentation.
|
||||
|
||||
For the complete test matrix, coverage requirements, and pull-request gates, read
|
||||
[`CONTRIBUTING.md`](CONTRIBUTING.md#running-tests).
|
||||
|
||||
## Documentation accuracy
|
||||
|
||||
Documentation must describe verified behavior, not plausible behavior.
|
||||
|
||||
1. Before documenting an API name, endpoint, path, CLI command, or environment variable,
|
||||
search for it: `rg -n "name" src/ open-sse/ bin/`. If it has no source match, do not
|
||||
document it.
|
||||
2. Measure mutable counts instead of writing them from memory: use `wc -l <file>` or a
|
||||
directory-specific count command.
|
||||
3. Copy code examples from working usage or run them. Prefer a source link such as
|
||||
`path/to/file.ts:line` to an invented signature.
|
||||
4. Run `npm run check:docs-all` for edits under `docs/`; it includes the fabricated-docs
|
||||
validation.
|
||||
|
||||
## Code conventions
|
||||
|
||||
- Format with Prettier: two spaces, semicolons, double quotes, 100-character line width,
|
||||
and ES5 trailing commas. Run Prettier on changed files.
|
||||
- TypeScript target is ES2022 with bundler module resolution. Prefer explicit types.
|
||||
- Import order: external, internal (`@/` and `@omniroute/open-sse`), then relative.
|
||||
- Do not add logic to `src/lib/localDb.ts`; import from the owning `src/lib/db/` module.
|
||||
- Use specific errors and contextual logging. Do not silently swallow SSE-stream failures;
|
||||
use abort signals for cleanup and return appropriate HTTP status codes.
|
||||
|
||||
## Security requirements
|
||||
|
||||
- Never commit credentials or log SQLite encryption keys.
|
||||
- Validate API inputs with Zod and use the route's required authentication path.
|
||||
- Sanitize user HTML with DOMPurify.
|
||||
- Use `resolvePublicCred()` for public upstream OAuth identifiers; never add them as string
|
||||
literals. See [`docs/security/PUBLIC_CREDS.md`](docs/security/PUBLIC_CREDS.md).
|
||||
- Use `buildErrorBody()` or `sanitizeErrorMessage()` for HTTP, SSE, executor, and MCP errors;
|
||||
do not return raw `err.stack` or `err.message`. See
|
||||
[`docs/security/ERROR_SANITIZATION.md`](docs/security/ERROR_SANITIZATION.md).
|
||||
- Pass runtime values to `exec()` or `spawn()` through `env`, not interpolation into a script.
|
||||
|
||||
## Repository map
|
||||
|
||||
Read the nearest `AGENTS.md` and the linked deep-dive before making a non-trivial change.
|
||||
|
||||
| Area | Location | Start here |
|
||||
| ---------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| API routes | `src/app/api/v1/` | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
|
||||
| Streaming request handling | `open-sse/handlers/` | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
|
||||
| Provider execution and translation | `open-sse/executors/`, `open-sse/translator/` | [`docs/architecture/CODEBASE_DOCUMENTATION.md`](docs/architecture/CODEBASE_DOCUMENTATION.md) |
|
||||
| Routing and resilience | `open-sse/services/` | [`open-sse/services/AGENTS.md`](open-sse/services/AGENTS.md), [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md) |
|
||||
| Database and migrations | `src/lib/db/`, `db/migrations/` | [`src/lib/db/AGENTS.md`](src/lib/db/AGENTS.md) |
|
||||
| Domain policy | `src/domain/` | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
|
||||
| MCP and A2A | `open-sse/mcp-server/`, `src/lib/a2a/` | [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md), [`docs/frameworks/A2A-SERVER.md`](docs/frameworks/A2A-SERVER.md) |
|
||||
| Agent features | `src/lib/{acp,memory,skills,cloudAgent}/` | [`docs/frameworks/AGENT_PROTOCOLS_GUIDE.md`](docs/frameworks/AGENT_PROTOCOLS_GUIDE.md), [`docs/frameworks/SKILLS.md`](docs/frameworks/SKILLS.md) |
|
||||
| Safety and governance | `src/lib/{guardrails,compliance}/`, `src/server/authz/` | [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md), [`docs/architecture/AUTHZ_GUIDE.md`](docs/architecture/AUTHZ_GUIDE.md) |
|
||||
| Operations | `src/mitm/`, tunnel modules, `electron/` | [`docs/ops/TUNNELS_GUIDE.md`](docs/ops/TUNNELS_GUIDE.md), [`docs/guides/ELECTRON_GUIDE.md`](docs/guides/ELECTRON_GUIDE.md) |
|
||||
|
||||
## Review focus
|
||||
|
||||
- Keep database operations in `src/lib/db/`; do not issue raw SQL from routes.
|
||||
- Send provider requests through `open-sse/handlers/`.
|
||||
- Keep MCP and A2A pages as tabs inside `/dashboard/endpoint`.
|
||||
- Preserve SSE cleanup, rate-limit header parsing, Zod validation, and provider-schema
|
||||
validation.
|
||||
- Treat Memory and Skills as cross-cutting changes that can affect MCP tools, the request
|
||||
pipeline, and A2A skills.
|
||||
- Do not close a contributor pull request after using its code; merge it through GitHub so
|
||||
the contributor receives credit.
|
||||
|
||||
## Upstream contributions
|
||||
|
||||
This checkout is a fork of `diegosouzapw/OmniRoute`. Keep fork-only deployment and personal
|
||||
automation changes out of upstream PRs.
|
||||
|
||||
Start upstream work from the active upstream default branch, not `main`:
|
||||
|
||||
```bash
|
||||
git fetch upstream
|
||||
# the default branch is the active release line, e.g. release/v3.8.49
|
||||
git switch -c <branch-name> upstream/release/vX.Y.Z
|
||||
git switch -c <branch-name> upstream/<default-branch>
|
||||
```
|
||||
|
||||
Only cherry-pick or reapply the changes intended for the upstream PR.
|
||||
Target that same release branch in the pull request. Stage only the intended files, run the
|
||||
focused checks, and use a Conventional Commit message (for example, `docs: slim AGENTS.md`).
|
||||
|
||||
---
|
||||
## Reference documentation
|
||||
|
||||
## Review Focus
|
||||
Use the source of truth for the area you are changing:
|
||||
|
||||
- **DB ops** go through `src/lib/db/` modules, never raw SQL in routes
|
||||
- **Provider requests** flow through `open-sse/handlers/`
|
||||
- **MCP/A2A pages** are tabs inside `/dashboard/endpoint`, not standalone routes
|
||||
- **No memory leaks** in SSE streams (abort signals, cleanup)
|
||||
- **Rate limit headers** must be parsed correctly
|
||||
- All API inputs validated with **Zod schemas**
|
||||
- **Provider constants** validated at module load via Zod (`src/shared/validation/providerSchema.ts`)
|
||||
- **Pricing data** syncs from LiteLLM via `src/lib/pricingSync.ts`
|
||||
- **Memory/Skills** are cross-cutting: affect MCP tools, request pipeline, and A2A skills
|
||||
- **⛔ NEVER close a contributor's PR** after using their code — always merge via GitHub so they get credit. See `.agents/workflows/review-prs.md` for full policy.
|
||||
| Area | Reference |
|
||||
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Repository navigation and architecture | [`docs/architecture/REPOSITORY_MAP.md`](docs/architecture/REPOSITORY_MAP.md), [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
|
||||
| API and providers | [`docs/reference/API_REFERENCE.md`](docs/reference/API_REFERENCE.md), [`docs/reference/PROVIDER_REFERENCE.md`](docs/reference/PROVIDER_REFERENCE.md), [`docs/openapi.yaml`](docs/openapi.yaml) |
|
||||
| Routing, resilience, and reasoning | [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md), [`docs/architecture/RESILIENCE_GUIDE.md`](docs/architecture/RESILIENCE_GUIDE.md), [`docs/routing/REASONING_REPLAY.md`](docs/routing/REASONING_REPLAY.md) |
|
||||
| Security | [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md), [`docs/security/COMPLIANCE.md`](docs/security/COMPLIANCE.md), [`docs/security/STEALTH_GUIDE.md`](docs/security/STEALTH_GUIDE.md) |
|
||||
| Platform features | [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md), [`docs/frameworks/A2A-SERVER.md`](docs/frameworks/A2A-SERVER.md), [`docs/frameworks/SKILLS.md`](docs/frameworks/SKILLS.md), [`docs/frameworks/MEMORY.md`](docs/frameworks/MEMORY.md) |
|
||||
| Releases and quality | [`docs/ops/RELEASE_CHECKLIST.md`](docs/ops/RELEASE_CHECKLIST.md), [`docs/architecture/QUALITY_GATES.md`](docs/architecture/QUALITY_GATES.md) |
|
||||
|
||||
1
changelog.d/fixes/9022-stream-error-diagnostic.md
Normal file
1
changelog.d/fixes/9022-stream-error-diagnostic.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(sse):** error-only streams now preserve sanitized executor diagnostics for operators without changing stream-readiness fallback classification ([#9022](https://github.com/diegosouzapw/OmniRoute/pull/9022)) — thanks @shixi-li
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(translator):** pass `output_config.effort="max"` through verbatim instead of unconditionally rewriting it to `xhigh`, so Anthropic → OpenAI-shape upstream calls reach `sanitizeReasoningEffortForProvider` with the carrier intact and providers that accept `max` literally (Ollama Cloud, opencode-go DeepSeek, Moonshot K3, native Claude) no longer 400 on `invalid reasoning value: 'xhigh'`. Regression guard: end-to-end test in `tests/unit/base-executor-sanitize-effort.test.ts`.
|
||||
@@ -1 +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
|
||||
@@ -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
|
||||
1
changelog.d/fixes/9250-cli-compatible-provider-apply.md
Normal file
1
changelog.d/fixes/9250-cli-compatible-provider-apply.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(cli-tools):** keep Apply enabled for active OpenAI-compatible and Anthropic-compatible providers without static catalog entries. (thanks @lazysaltyfish)
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(translator):** harden Claude format detection for relative message endpoints and kebab-case version metadata. (thanks @ervareza)
|
||||
@@ -385,7 +385,8 @@
|
||||
"src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": 1573,
|
||||
"src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx": 1028,
|
||||
"src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2148,
|
||||
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1109,
|
||||
"_rebaseline_2026_07_30_8916_quota_compact_layout": "PR #8916 (apoapostolov, feat/improve-provider-quota-layouts) own growth: src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx 1109->1153 (+44) adds Full/Compact layout toggle — LS_LAYOUT_MODE constant, LayoutMode type, layoutMode state, toggleLayoutMode callback, toggle button with icon. At existing filter/settings chokepoint. Not extractable without splitting state + toolbar away from data-fetching. Covered by tests/unit/quota-card-grid-compact-layout-8916.test.ts.",
|
||||
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1153,
|
||||
"src/app/api/providers/[id]/models/route.ts": 2250,
|
||||
"src/app/api/v1/models/catalog.ts": 1549,
|
||||
"src/lib/db/apiKeys.ts": 1529,
|
||||
@@ -413,6 +414,6 @@
|
||||
"_rebaseline_2026_07_28_8860_tokenrefresh_projectid": "PR #8860 (fix/antigravity-projectid-centralized) own test growth: tests/unit/token-refresh-service.test.ts 1311->1378 (+67 = 4 cases covering projectId discovery on the tokenRefresh.ts path — the Dashboard/health-check refresh route, which #8842 did not reach since that fixed the executor path). Covered by the same file.",
|
||||
"_rebaseline_2026_07_28_8861_xiaomi_token_plan": "PR #8861 (feat/xiaomi-token-plan-protocol-selector) own growth: EditConnectionModal.tsx 1283->1316 (+33 = the per-connection API-protocol selector field) and open-sse/executors/base.ts 1540->1562 (+22 = alternate-format resolution at the existing buildUrl/headers chokepoint). Both are irreducible wiring at existing call sites.",
|
||||
"_rebaseline_2026_07_28_8863_firefly_detail_level": "PR #8863 (fix/adobe-firefly-gpt-detail-level-max) own growth: adobeFireflyClient.ts 2317->2322 (+5 = gpt-image detailLevel defaulting to maximal at the existing payload-build site). Covered by tests/unit/adobe-firefly.test.ts.",
|
||||
"_rebaseline_2026_07_29_8281_home_quickstart_prefetch": "Release v3.8.49 base-red fix (no PR — captain sweep): src/app/(dashboard)/dashboard/HomePageClient.tsx 1377->1381 (+4). #8292 added prefetch={false} to the sidebar but left /home's five quick-start Links prefetching, so first paint still fired 12 speculative RSC requests — caught by navigation.spec.ts only after the e2e helper bug (APP_ROUTE_PATTERN missing /home) was repaired in the same cycle. Growth is the five prefetch attributes; it was offset first by extracting the repeated className literals (INLINE_LINK x4, DOCS_LINK x1), which collapsed five wrapped <Link> blocks back to one line each — a naive fix measured 1391. Guard: tests/unit/sidebar-prefetch-policy-8281.test.ts.",
|
||||
"_rebaseline_2026_07_29_8281_home_quickstart_prefetch": "Release v3.8.49 base-red fix (no PR — captain sweep): src/app/(dashboard)/dashboard/HomePageClient.tsx 1377->1381 (+4). #8292 added prefetch={false} to the sidebar but left /home's five quick-start Links prefetching, so first paint still fired 12 speculative RSC requests — caught by navigation.spec.ts only after the e2e helper bug (APP_ROUTE_PATTERN missing /home) was repaired in the same cycle. Growth is the five prefetch attributes; it was offset first by extracting the repeated className literals (INLINE_LINK x4, DOCS_LINK x1), which collapsed five wrapped <Link> blocks back to one line each — a naive fix measured 1391. Guard: tests/unit/sidebar-prefetch-policy-8281.test.ts.",
|
||||
"_rebaseline_2026_08_02_v3850_agentrouter_responses": "Release v3.8.50 AgentRouter/Codex compatibility reconciliation. open-sse/executors/base.ts 1562->1578: #9190 wires AgentRouter's selected Claude/OpenAI/Responses protocol through the existing executor URL, auth, identity-header and fingerprint chokepoints; the reusable alternate resolver remains outside base.ts. open-sse/utils/stream.ts 2887->2889: #9213 evaluates Responses ID and usage normalization independently so response.completed always receives finite usage.total_tokens instead of short-circuiting after an ID rewrite. tests/unit/chatcore-translation-paths.test.ts 2769->2776: #9191 updates the existing Claude-Code bridge assertions for the dynamic AgentRouter wire image. PR #9224 offsets its own chatCore growth by extracting the AgentRouter protocol decisions into chatCore/agentRouterProtocol.ts, leaving chatCore below its frozen ceiling. Covered by agentrouter executor/chatCore protocol tests, chatcore translation-path tests, and responses-commentary-passthrough tests."
|
||||
}
|
||||
|
||||
916
docs/architecture/mysql-conformance-semantics.md
Normal file
916
docs/architecture/mysql-conformance-semantics.md
Normal file
@@ -0,0 +1,916 @@
|
||||
---
|
||||
title: "MySQL conformance semantics and failure-mode matrix"
|
||||
status: proposed-test-specification
|
||||
lastUpdated: 2026-07-30
|
||||
---
|
||||
|
||||
# MySQL conformance semantics and failure-mode matrix
|
||||
|
||||
- **Tracking issue:** [#8075](https://github.com/diegosouzapw/OmniRoute/issues/8075)
|
||||
- **Governing proposal:** [Pluggable persistence boundary](persistence-backend-boundary.md)
|
||||
- **Measured baseline:** [SQLite coupling inventory](sqlite-coupling-inventory.md)
|
||||
- **Target:** MySQL 8.0 with InnoDB
|
||||
- **Runtime impact:** None. This document adds no driver, dependency, configuration, schema,
|
||||
migration, or support claim.
|
||||
|
||||
## 1. Purpose and normative language
|
||||
|
||||
The persistence-boundary ADR requires conformance tests to compare observable behavior, not only
|
||||
repository method signatures. This document turns the MySQL/InnoDB differences that can change
|
||||
OmniRoute behavior into an implementation-ready specification. It provides:
|
||||
|
||||
- a required server and session profile;
|
||||
- evidence from the current SQLite implementation;
|
||||
- minimal SQL probes that reviewers can reproduce independently;
|
||||
- a backend-neutral error and retry taxonomy;
|
||||
- normative decisions that a repository contract must make;
|
||||
- executable acceptance specifications for a future shared conformance harness;
|
||||
- a focused acceptance profile for combo definitions and model-to-combo mappings.
|
||||
|
||||
The terms **MUST**, **MUST NOT**, **SHOULD**, and **MAY** are normative. A proposed MySQL adapter is
|
||||
not conformant merely because its SQL succeeds. It is conformant only when the same repository
|
||||
fixture produces the same domain result, durable state, atomicity, ordering, and classified failure
|
||||
as the SQLite implementation.
|
||||
|
||||
## 2. Scope and non-goals
|
||||
|
||||
### 2.1 In scope
|
||||
|
||||
This specification covers portable durable-state behavior for:
|
||||
|
||||
- create, read, update, delete, and missing-row results;
|
||||
- uniqueness, collation, case and accent sensitivity, and `NULL`;
|
||||
- stable ordering and pagination;
|
||||
- no-op writes and affected-row reporting;
|
||||
- insert, identity-preserving upsert, and replacement;
|
||||
- IDs, JSON, exact numerics, and timestamps;
|
||||
- transactions, deadlocks, lock waits, disconnects, and retry boundaries;
|
||||
- foreign keys and atomic related-record changes;
|
||||
- migration ownership, implicit DDL commits, recovery, and readiness.
|
||||
|
||||
### 2.2 Out of scope
|
||||
|
||||
This specification does not:
|
||||
|
||||
- approve PostgreSQL or MySQL runtime support;
|
||||
- select a Node.js MySQL driver or pool;
|
||||
- define a public environment variable or configuration UI;
|
||||
- define final TypeScript repository interfaces;
|
||||
- add physical MySQL schema or migration files;
|
||||
- make SQLite maintenance, FTS5, `sqlite-vec`, backup files, or WAL portable;
|
||||
- replace domain-specific acceptance criteria;
|
||||
- permit runtime work while the governing ADR remains unapproved.
|
||||
|
||||
## 3. Evidence from the current repository
|
||||
|
||||
The current implementation establishes behavior that a portable contract must either preserve or
|
||||
explicitly revise. These are source-backed observations, not proposed MySQL schema.
|
||||
|
||||
### 3.1 Combo identity and lookup
|
||||
|
||||
`src/lib/db/migrations/001_initial_schema.sql` defines `combos.id` as the primary key and
|
||||
`combos.name` as unique. `src/lib/db/combos.ts` currently:
|
||||
|
||||
- generates UUIDs in the application;
|
||||
- generates timestamps with `new Date().toISOString()`;
|
||||
- performs exact name lookup first;
|
||||
- provides a separate `COLLATE NOCASE` fallback lookup;
|
||||
- lists by `sort_order ASC, name COLLATE NOCASE ASC`;
|
||||
- treats an update of a missing ID as `null`;
|
||||
- treats deletion of a missing ID as `false`;
|
||||
- updates the JSON payload and deduplicated columns together;
|
||||
- reorders all selected rows in one SQLite transaction.
|
||||
|
||||
Those choices imply that a future MySQL slice does not need database-generated numeric IDs for
|
||||
combos, but it must still define Unicode collation, complete tie-breakers, update/delete results, and
|
||||
reorder concurrency.
|
||||
|
||||
### 3.2 Model-to-combo mapping behavior
|
||||
|
||||
`src/lib/db/migrations/010_model_combo_mappings.sql` defines a foreign key from
|
||||
`model_combo_mappings.combo_id` to `combos.id` with `ON DELETE CASCADE`.
|
||||
`src/lib/db/modelComboMappings.ts` currently:
|
||||
|
||||
- generates mapping UUIDs and ISO timestamps in the application;
|
||||
- lists by `priority DESC, created_at ASC`;
|
||||
- returns a separate total count for paginated results;
|
||||
- maps integer `0`/`1` values to booleans;
|
||||
- treats a missing update as `null` and a missing delete as `false`;
|
||||
- resolves the first enabled matching pattern;
|
||||
- skips malformed combo JSON rather than failing resolution.
|
||||
|
||||
The current list and resolution order lacks a unique final tie-breaker. The MySQL implementation
|
||||
MUST NOT preserve that accidental nondeterminism. Before portability is claimed, the contract must
|
||||
add `id ASC` (or another unique stable key) after `created_at ASC` and the SQLite implementation
|
||||
must adopt the same order.
|
||||
|
||||
### 3.3 Existing SQLite-specific signals
|
||||
|
||||
The measured SQLite coupling inventory records widespread use of synchronous prepared statements,
|
||||
`INSERT OR REPLACE`, `lastInsertRowid`, SQLite transactions, and SQLite lifecycle operations. A
|
||||
future adapter must not translate those tokens mechanically. In particular:
|
||||
|
||||
- `INSERT OR REPLACE` is delete-then-insert conflict handling, not an update;
|
||||
- `changes` is a driver result, not a portable domain result;
|
||||
- `COLLATE NOCASE` is not equivalent to a modern MySQL Unicode collation;
|
||||
- SQLite numbered migration SQL is not reusable as MySQL migration SQL.
|
||||
|
||||
## 4. Required MySQL deployment and session profile
|
||||
|
||||
A conformance run MUST fail during backend initialization if the effective profile is outside the
|
||||
supported envelope. Silently inheriting server defaults would make behavior depend on an operator's
|
||||
installation history.
|
||||
|
||||
| Property | Required profile | Verification | Failure class |
|
||||
| ------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | --------------------- |
|
||||
| Server family | Oracle MySQL 8.0.x until another family passes the same suite | `SELECT VERSION()` and server metadata | `unsupported` |
|
||||
| Storage engine | `InnoDB` for every portable table | `information_schema.tables` | `schema_incompatible` |
|
||||
| Character set | `utf8mb4` for schema, tables, and portable text columns | `information_schema.schemata`, `tables`, and `columns` | `schema_incompatible` |
|
||||
| Identity collation | Explicit per identity column; never inherited | `information_schema.columns.collation_name` | `schema_incompatible` |
|
||||
| SQL mode | Strict mode and the engine-substitution guard; adapter records the effective value | `SELECT @@SESSION.sql_mode` | `unsupported` |
|
||||
| Transaction isolation | Explicitly selected and verified by the backend | `SELECT @@SESSION.transaction_isolation` | `unsupported` |
|
||||
| Session time zone | UTC | `SELECT @@SESSION.time_zone` | `unsupported` |
|
||||
| Autocommit | Known pool default; repository transactions set boundaries explicitly | `SELECT @@SESSION.autocommit` | `unsupported` |
|
||||
| Connection character set | `utf8mb4` | `SELECT @@character_set_client, @@character_set_connection, @@character_set_results` | `unsupported` |
|
||||
| Found-rows behavior | One fixed pool setting, but repository results remain independent of it | Driver/pool configuration plus conformance probe | `unsupported` |
|
||||
| Foreign-key checks | Enabled for normal runtime and conformance tests | `SELECT @@SESSION.foreign_key_checks` | `unsupported` |
|
||||
| InnoDB page size | Recorded before validating indexed key lengths | `SELECT @@innodb_page_size` | `schema_incompatible` |
|
||||
|
||||
The backend readiness report SHOULD expose the verified profile without credentials. It MUST NOT
|
||||
log connection strings or secrets.
|
||||
|
||||
### 4.1 Initialization probe
|
||||
|
||||
The adapter acceptance suite should run an equivalent of the following read-only probe on a newly
|
||||
leased connection:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
VERSION() AS server_version,
|
||||
@@SESSION.sql_mode AS sql_mode,
|
||||
@@SESSION.transaction_isolation AS transaction_isolation,
|
||||
@@SESSION.time_zone AS time_zone,
|
||||
@@SESSION.autocommit AS autocommit,
|
||||
@@SESSION.foreign_key_checks AS foreign_key_checks,
|
||||
@@character_set_client AS character_set_client,
|
||||
@@character_set_connection AS character_set_connection,
|
||||
@@character_set_results AS character_set_results,
|
||||
@@innodb_page_size AS innodb_page_size;
|
||||
```
|
||||
|
||||
A pool MUST apply and verify session settings on every newly created physical connection. Applying
|
||||
settings only to the first connection is insufficient.
|
||||
|
||||
## 5. Normative semantic matrix
|
||||
|
||||
### 5.0 Observable SQLite/MySQL difference summary
|
||||
|
||||
This table is the review index for the detailed rules below. It distinguishes current or common
|
||||
backend behavior from the portable result the repository must expose. The MySQL column describes
|
||||
InnoDB under the verified session profile; it must not be read as permission to inherit an
|
||||
unverified server default.
|
||||
|
||||
| Concern | SQLite-shaped behavior | MySQL/InnoDB behavior | Required repository contract |
|
||||
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Text identity | Binary comparison by default; current code opts into ASCII-oriented `NOCASE` for selected reads and sorts | Equality, uniqueness, and sort order follow the selected column/expression collation | Declare byte-exact identity separately from named insensitive lookup and display order |
|
||||
| Nullable unique key | Multiple SQL `NULL` values can pass a plain unique constraint | Multiple SQL `NULL` values can pass a plain unique index | Enforce any "one logical null" invariant atomically outside a plain unique key |
|
||||
| Unordered/tied results | No total order without a complete `ORDER BY` | No total order without a complete `ORDER BY` | Define `NULL` position and a unique final tie-breaker for every portable list |
|
||||
| No-op update | Driver change count reflects SQLite's statement behavior | Changed-row count differs from matched-row mode for identical assignments | Return domain outcomes independently of raw affected-row counts |
|
||||
| Conflict write | `INSERT OR REPLACE` can delete then insert | Duplicate-key upsert updates one selected conflict | Classify every operation as insert-only, identity-preserving upsert, or replacement |
|
||||
| Generated identity | SQLite row IDs and driver-local last-insert state are connection-bound | Generated IDs and last-insert state are connection-bound | Retrieve identity in the insert operation/lease and use stable idempotency identity on retry |
|
||||
| JSON | Existing combo payloads are text and malformed legacy text can be observed | Native `JSON` validates and normalizes its representation | Choose text or typed JSON deliberately and compare the declared domain representation |
|
||||
| Exact values/time | Current modules commonly serialize JavaScript values and ISO UTC text | Driver conversion can lose large integers/decimals; temporal types depend on type and session zone | Fix exact representations, UTC policy, and precision across backends |
|
||||
| Concurrency/isolation | Deferred transactions and a database-wide single-writer model shape conflicts; read visibility depends on transaction mode and WAL state | InnoDB defaults to `REPEATABLE READ`, uses MVCC snapshots for consistent reads, and permits concurrent writers on different locked records | Select and verify isolation, then test domain-visible reads, conflicts, and retry boundaries rather than relying on either default |
|
||||
| DDL/migrations | SQLite migration sequences can be wrapped according to SQLite transaction rules | DDL commonly commits implicitly; one atomic DDL statement does not make a multi-step migration atomic | Use distributed ownership, durable phase checkpoints, postcondition inspection, and readiness gating |
|
||||
|
||||
### 5.1 Text identity, collation, and uniqueness
|
||||
|
||||
MySQL equality and unique indexes use the effective collation of the indexed expression. A `_ci`
|
||||
collation is case-insensitive; an `_ai` collation is also accent-insensitive. SQLite's default text
|
||||
comparison and `COLLATE NOCASE` do not provide an equivalent Unicode contract.
|
||||
|
||||
| Concern | SQLite-shaped risk | Required portable decision | MySQL implementation rule |
|
||||
| ---------------- | -------------------------------------------------------- | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| IDs | Text IDs can inherit an unintended collation | IDs are byte-exact and case-sensitive | Use an explicit binary collation or binary representation |
|
||||
| Combo names | Exact lookup and insensitive fallback are separate today | Exact lookup remains exact; insensitive lookup is a named operation | Exact and insensitive queries use explicit, different collations or normalized keys |
|
||||
| Unique names | A server default can collapse case or accents | The domain declares whether case/accent variants conflict | Unique index uses the declared collation, never the database default |
|
||||
| Pattern text | Pattern matching occurs in application code | Stored pattern bytes round-trip unchanged | Store with an explicit case-sensitive collation |
|
||||
| User-facing sort | SQLite `NOCASE` order is not portable Unicode order | List order is defined by a normalized sort key or explicit collation policy | Schema and query use the selected policy and a unique tie-breaker |
|
||||
|
||||
Minimum probe:
|
||||
|
||||
```sql
|
||||
CREATE TEMPORARY TABLE conformance_text (
|
||||
id VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin PRIMARY KEY,
|
||||
name VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci UNIQUE
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
INSERT INTO conformance_text (id, name) VALUES ('A', 'Résumé');
|
||||
-- The next statement conflicts under utf8mb4_0900_ai_ci.
|
||||
INSERT INTO conformance_text (id, name) VALUES ('a', 'resume');
|
||||
```
|
||||
|
||||
The harness MUST repeat the probe for the exact collation selected by the eventual schema; the
|
||||
example collation above is evidence, not an approval for combo names.
|
||||
|
||||
### 5.2 `NULL`, missing rows, and nullable unique keys
|
||||
|
||||
MySQL unique indexes permit multiple `NULL` values. SQLite does likewise for unique columns.
|
||||
However, neither behavior implements a domain invariant such as "only one active row may have no
|
||||
owner."
|
||||
|
||||
Repository contracts MUST distinguish:
|
||||
|
||||
- no row found;
|
||||
- a row found with a nullable field set to SQL `NULL`;
|
||||
- a JSON document containing JSON `null`;
|
||||
- a missing JSON member.
|
||||
|
||||
Minimum probe:
|
||||
|
||||
```sql
|
||||
CREATE TEMPORARY TABLE conformance_null (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
optional_key VARCHAR(64) NULL,
|
||||
UNIQUE KEY uq_optional_key (optional_key)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
INSERT INTO conformance_null VALUES ('one', NULL), ('two', NULL);
|
||||
SELECT COUNT(*) AS row_count FROM conformance_null;
|
||||
-- Expected: 2.
|
||||
```
|
||||
|
||||
If a domain allows at most one logical `NULL`, it MUST use an explicit atomic invariant rather than
|
||||
rely on a plain unique index.
|
||||
|
||||
### 5.3 Ordering, ties, and pagination
|
||||
|
||||
Without `ORDER BY`, result order is undefined. With a non-unique `ORDER BY`, tied rows still have an
|
||||
undefined relative order. Offset pagination can therefore duplicate or omit records if the complete
|
||||
order is not stable.
|
||||
|
||||
Every portable list MUST specify:
|
||||
|
||||
1. every user-visible sort expression;
|
||||
2. the position of `NULL` values;
|
||||
3. a unique final tie-breaker;
|
||||
4. the cursor comparison tuple, if cursor pagination is used;
|
||||
5. the snapshot/concurrency expectation across pages.
|
||||
|
||||
For the proposed combo/mapping slice:
|
||||
|
||||
```sql
|
||||
-- Combo list contract candidate.
|
||||
ORDER BY sort_order ASC, normalized_name ASC, id ASC
|
||||
|
||||
-- Mapping list and resolution contract candidate.
|
||||
ORDER BY priority DESC, created_at ASC, id ASC
|
||||
```
|
||||
|
||||
The exact `normalized_name` representation remains a contract decision. It MUST NOT be implemented
|
||||
by relying on an unspecified database default.
|
||||
|
||||
For nullable values, use an explicit sort key rather than a backend default:
|
||||
|
||||
```sql
|
||||
ORDER BY nullable_column IS NULL ASC, nullable_column ASC, id ASC
|
||||
```
|
||||
|
||||
### 5.4 Update, no-op, delete, and affected rows
|
||||
|
||||
MySQL `UPDATE` reports rows actually changed by default. With the C API found-rows connection flag,
|
||||
it reports rows matched. `INSERT ... ON DUPLICATE KEY UPDATE` reports 1 for insert, 2 for an actual
|
||||
update, and 0 for an update to identical values; the found-rows flag changes the last value to 1.
|
||||
These numbers MUST NOT become repository semantics.
|
||||
|
||||
| Repository outcome | Required meaning | Forbidden implementation shortcut |
|
||||
| ------------------ | ------------------------------------------------------ | --------------------------------------------- |
|
||||
| `updated` | Target existed and the operation's postcondition holds | `affectedRows > 0` alone |
|
||||
| `unchanged` | Target existed and already satisfied the postcondition | Treating 0 changed rows as missing |
|
||||
| `not_found` | Target identity did not exist | Treating every 0 count as unchanged |
|
||||
| `conflict` | Compare/update version or invariant failed | Returning generic `false` |
|
||||
| delete `true` | A row existed and was deleted | Assuming a successful statement deleted a row |
|
||||
| delete `false` | No row existed | Throwing a backend-specific error |
|
||||
|
||||
Minimum probe, run once with each supported connection mode:
|
||||
|
||||
```sql
|
||||
CREATE TEMPORARY TABLE conformance_update (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
value_text VARCHAR(64) NOT NULL,
|
||||
version_no BIGINT NOT NULL
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
INSERT INTO conformance_update VALUES ('row', 'same', 1);
|
||||
UPDATE conformance_update SET value_text = 'same' WHERE id = 'row';
|
||||
UPDATE conformance_update SET value_text = 'changed' WHERE id = 'row';
|
||||
UPDATE conformance_update SET value_text = 'missing' WHERE id = 'missing';
|
||||
```
|
||||
|
||||
The harness asserts repository results and final rows, not raw driver counts. A versioned
|
||||
compare/update SHOULD use a predicate such as `WHERE id = ? AND version_no = ?`, then distinguish a
|
||||
missing identity from a stale version according to the domain contract.
|
||||
|
||||
### 5.5 Insert, upsert, and replacement
|
||||
|
||||
SQLite `INSERT OR REPLACE` deletes rows that conflict with a unique or primary key before inserting
|
||||
the new row. MySQL `INSERT ... ON DUPLICATE KEY UPDATE` updates one conflicting row. The two forms
|
||||
differ in foreign-key cascades, triggers, omitted columns, IDs, timestamps, and affected-row counts.
|
||||
|
||||
Every write method MUST be classified as exactly one of:
|
||||
|
||||
1. **insert-only:** duplicate identity returns `unique_violation`;
|
||||
2. **identity-preserving upsert:** duplicate identity updates an explicit allowlist of mutable fields;
|
||||
3. **replacement:** old identity is deleted and a new row is inserted, with cascade effects included
|
||||
in the contract.
|
||||
|
||||
A generic helper MUST NOT choose among these behaviors based on SQL convenience.
|
||||
|
||||
Minimum difference probe. This uses ordinary InnoDB tables because MySQL temporary tables cannot
|
||||
serve as the parent/child foreign-key fixture. Run it in an isolated conformance schema; cleanup is
|
||||
included so the probe is repeatable:
|
||||
|
||||
```sql
|
||||
DROP TABLE IF EXISTS conformance_child;
|
||||
DROP TABLE IF EXISTS conformance_parent;
|
||||
|
||||
CREATE TABLE conformance_parent (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
immutable_value VARCHAR(64) NOT NULL,
|
||||
mutable_value VARCHAR(64) NOT NULL
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE conformance_child (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
parent_id VARCHAR(64) NOT NULL,
|
||||
CONSTRAINT fk_conformance_child_parent
|
||||
FOREIGN KEY (parent_id) REFERENCES conformance_parent(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
INSERT INTO conformance_parent VALUES ('p', 'keep', 'old');
|
||||
INSERT INTO conformance_child VALUES ('c', 'p');
|
||||
INSERT INTO conformance_parent (id, immutable_value, mutable_value)
|
||||
VALUES ('p', 'replacement', 'new')
|
||||
ON DUPLICATE KEY UPDATE mutable_value = VALUES(mutable_value);
|
||||
|
||||
SELECT immutable_value, mutable_value FROM conformance_parent WHERE id = 'p';
|
||||
SELECT COUNT(*) AS child_count FROM conformance_child WHERE parent_id = 'p';
|
||||
-- Expected: immutable_value='keep', mutable_value='new', child_count=1.
|
||||
|
||||
DROP TABLE conformance_child;
|
||||
DROP TABLE conformance_parent;
|
||||
```
|
||||
|
||||
The `VALUES(mutable_value)` form is used here because the target remains MySQL 8.0 as a family and
|
||||
no minimum 8.0 patch release has been approved. It is deprecated in later MySQL 8.0 releases, so an
|
||||
adapter that establishes a newer minimum MAY use the supported row-alias form instead. The harness
|
||||
asserts identity-preserving behavior, not either SQL spelling.
|
||||
|
||||
Tables with multiple unique indexes require special care because a duplicate can select an
|
||||
unexpected conflicting row. Portable upsert schema SHOULD have one unambiguous conflict identity.
|
||||
|
||||
### 5.6 Unicode and index-size constraints
|
||||
|
||||
`utf8mb4` uses up to four bytes per character. InnoDB's maximum index key is 3072 bytes for common
|
||||
`DYNAMIC` or `COMPRESSED` row formats with a 16 KiB page, and is lower for smaller page sizes or
|
||||
legacy row formats. A prefix unique index is not equivalent to full-value uniqueness.
|
||||
|
||||
Schema acceptance MUST:
|
||||
|
||||
- set bounded lengths for all indexed identity strings;
|
||||
- calculate the worst-case byte length of every composite index;
|
||||
- verify the actual page size and row format;
|
||||
- reject a prefix unique index for a full-identity contract;
|
||||
- test maximum-length non-ASCII values before migration is accepted;
|
||||
- classify an incompatible definition as `schema_incompatible`, not `unique_violation`.
|
||||
|
||||
Example boundary probe for a 16 KiB/DYNAMIC profile:
|
||||
|
||||
```sql
|
||||
CREATE TEMPORARY TABLE conformance_index (
|
||||
value_text VARCHAR(768) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||
UNIQUE KEY uq_value_text (value_text)
|
||||
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC;
|
||||
```
|
||||
|
||||
The exact accepted length MUST be derived from all key parts and the verified deployment profile;
|
||||
this example is deliberately near a physical boundary and is not a proposed production column.
|
||||
|
||||
### 5.7 IDs and connection-local state
|
||||
|
||||
The current combo and mapping modules generate UUIDs in the application. A MySQL implementation
|
||||
SHOULD preserve this strategy for those domains.
|
||||
|
||||
If another domain uses a database-generated incrementing ID, the adapter MUST observe these rules:
|
||||
|
||||
- ID retrieval is part of the same driver operation and physical connection as the insert;
|
||||
- callers never issue a later connection-level `LAST_INSERT_ID()` query;
|
||||
- multi-row inserts define whether one ID or all IDs are returned;
|
||||
- an error or rollback makes a previously observed `LAST_INSERT_ID()` unsuitable as proof of commit;
|
||||
- retries use a stable domain idempotency key;
|
||||
- upsert defines whether it returns an existing or newly generated identity.
|
||||
|
||||
MySQL documents `LAST_INSERT_ID()` as per-connection state and leaves it undefined after some errors
|
||||
or error-driven rollbacks. Pool leases are therefore part of correctness, not merely performance.
|
||||
|
||||
### 5.8 JSON representation
|
||||
|
||||
Current combo data is JSON text, and malformed JSON is observable: combo reads can skip malformed
|
||||
rows and mapping resolution skips malformed combo payloads. Switching the MySQL column directly to
|
||||
native `JSON` would reject malformed rows at write/import time and normalize duplicate keys,
|
||||
whitespace, and key order.
|
||||
|
||||
Before choosing `LONGTEXT` or `JSON`, the combo contract MUST decide:
|
||||
|
||||
- whether malformed stored payloads remain representable for compatibility tests;
|
||||
- whether equality is structural or byte-for-byte;
|
||||
- whether duplicate object keys are rejected before persistence;
|
||||
- whether serialization order is stable and application-owned;
|
||||
- which fields are duplicated into typed columns and which representation is authoritative.
|
||||
|
||||
For the first slice, an identity-preserving migration SHOULD keep application serialization as the
|
||||
domain boundary. If native `JSON` is selected, imports MUST parse and validate before writing, and
|
||||
tests MUST compare parsed domain values rather than raw JSON text.
|
||||
|
||||
Minimum normalization probe:
|
||||
|
||||
```sql
|
||||
CREATE TEMPORARY TABLE conformance_json (id VARCHAR(64) PRIMARY KEY, payload JSON) ENGINE=InnoDB;
|
||||
INSERT INTO conformance_json VALUES ('j', '{"b": 2, "a": 1, "a": 3}');
|
||||
SELECT payload FROM conformance_json WHERE id = 'j';
|
||||
-- The value is normalized; original whitespace/key duplication is not preserved.
|
||||
```
|
||||
|
||||
### 5.9 Exact numerics and timestamps
|
||||
|
||||
| Type | Risk | Required contract |
|
||||
| ----------- | ----------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| `BIGINT` | Values can exceed JavaScript's safe integer range | Return a string or validated bigint representation across every backend |
|
||||
| `DECIMAL` | Driver options may return strings or lossy numbers | Fix precision/scale and use an exact domain representation |
|
||||
| `TIMESTAMP` | Session time zone conversion and fractional precision | Force UTC session time zone and specify fractional precision |
|
||||
| `DATETIME` | No intrinsic time zone | Use only for explicitly zone-free civil time |
|
||||
| ISO text | Lexical ordering depends on one canonical format | Validate UTC suffix and exact precision before persistence |
|
||||
|
||||
Combo and mapping timestamps are currently application-generated ISO strings. The first slice SHOULD
|
||||
preserve their exact domain format rather than introducing server-generated local time.
|
||||
|
||||
### 5.10 Transaction isolation and observable concurrency
|
||||
|
||||
MySQL InnoDB uses `REPEATABLE READ` as its default isolation level. Within an explicit transaction,
|
||||
its consistent non-locking reads normally establish and reuse an MVCC snapshot, while locking reads
|
||||
and writes inspect and lock current index records or ranges. SQLite instead combines snapshot/read
|
||||
transaction behavior with a database-wide single-writer model; transaction mode and WAL state affect
|
||||
when a writer is admitted and when a read transaction can be upgraded. These mechanisms are not
|
||||
interchangeable even when a simple CRUD fixture produces the same final row.
|
||||
|
||||
The backend profile MUST select and verify an isolation level rather than silently accept either
|
||||
backend's default. The repository contract MUST then define observable results for each atomic
|
||||
operation. It MUST NOT promise the implementation mechanism itself, such as gap locks or a
|
||||
SQLite-wide writer lock.
|
||||
|
||||
| Scenario | SQLite-shaped risk | InnoDB `REPEATABLE READ` risk | Required conformance decision |
|
||||
| --------------------------------- | --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| Two reads in one transaction | Snapshot timing depends on when the read transaction begins and the active journal mode | Consistent reads normally reuse the transaction's first established read view | State whether the operation requires one stable snapshot or deliberately performs a current read |
|
||||
| Range read plus concurrent insert | A concurrent writer may be serialized by SQLite's writer admission rules | A plain consistent read can retain its snapshot; a locking range read can lock index gaps | Define whether a later read sees the insert and whether the operation requires a locking predicate |
|
||||
| Read-modify-write | Single-writer serialization can mask an unsafe application sequence | Concurrent transactions can read the same value and later contend or overwrite without a version predicate | Require compare/update, a locking read, or another explicit invariant; never rely on backend serialization |
|
||||
| Writers touching different rows | SQLite still admits only one writer at a time | InnoDB can execute both until their record/range locks conflict | Do not infer portable throughput or lock order; assert only atomic effects and classified conflicts |
|
||||
| Pagination across transactions | Separate page reads can observe different committed states | Separate autocommit reads get separate views; one transaction may retain one view | Declare snapshot pagination or documented live pagination and test that policy |
|
||||
| Retry after conflict | Busy/locked outcomes and transaction upgrade failures are SQLite-shaped | Deadlocks and lock timeouts have different rollback scopes | Normalize the error, discard the failed context, and retry the complete idempotent operation only |
|
||||
|
||||
Minimum two-connection visibility probe for the selected MySQL profile:
|
||||
|
||||
```text
|
||||
Connection A Connection B
|
||||
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
|
||||
START TRANSACTION;
|
||||
SELECT value_no FROM conformance_isolation
|
||||
WHERE id = 1; -- establishes read view: 0
|
||||
START TRANSACTION;
|
||||
UPDATE conformance_isolation
|
||||
SET value_no = 1 WHERE id = 1;
|
||||
COMMIT;
|
||||
SELECT value_no FROM conformance_isolation
|
||||
WHERE id = 1; -- same consistent-read view: 0
|
||||
COMMIT;
|
||||
SELECT value_no FROM conformance_isolation
|
||||
WHERE id = 1; -- new transaction/view: 1
|
||||
```
|
||||
|
||||
The shared harness MUST NOT assert that every backend reproduces this internal sequence. It must use
|
||||
it to prove that the chosen repository operation either requests a stable snapshot explicitly or
|
||||
avoids depending on repeat-read visibility. If an operation uses a current/locking read, that choice
|
||||
and its conflict behavior need a separate test.
|
||||
|
||||
## 6. Transactions, failures, and retry policy
|
||||
|
||||
### 6.1 Transaction states
|
||||
|
||||
The backend contract should expose only opaque transaction contexts, but its implementation must
|
||||
maintain the following lifecycle:
|
||||
|
||||
```text
|
||||
idle
|
||||
-> active
|
||||
-> committed
|
||||
-> rolled_back
|
||||
-> failed_statement -> rolled_back
|
||||
-> failed_transaction -> rolled_back
|
||||
-> outcome_unknown -> reconciled | escalated
|
||||
```
|
||||
|
||||
A context in `committed`, `rolled_back`, `failed_transaction`, or `outcome_unknown` MUST reject new
|
||||
repository work. A context with a failed statement SHOULD be explicitly rolled back before its
|
||||
connection returns to the pool, even when MySQL would technically permit more statements.
|
||||
|
||||
### 6.2 Error classification matrix
|
||||
|
||||
Numeric codes and SQLSTATE values below are MySQL 8.0 server signals. A Node.js driver can also
|
||||
produce transport-specific codes; those MUST be normalized without leaking raw messages to callers.
|
||||
|
||||
| Condition | MySQL signal | Rollback scope | Portable class | Retry policy |
|
||||
| ------------------------------ | -------------------------------------- | ------------------------------------------------- | ------------------------ | -------------------------------------------------------------- |
|
||||
| Duplicate key | `1062`, SQLSTATE `23000` | Statement | `unique_violation` | No, unless contract defines idempotent create |
|
||||
| Missing referenced parent | `1452`, SQLSTATE `23000` | Statement | `foreign_key_violation` | No |
|
||||
| Parent still referenced | `1451`, SQLSTATE `23000` | Statement | `foreign_key_violation` | No |
|
||||
| Deadlock victim | `1213`, SQLSTATE `40001` | Entire transaction | `transaction_conflict` | Retry whole atomic operation |
|
||||
| Lock wait timeout | `1205`, SQLSTATE `HY000` | Statement by default; server option can change it | `lock_timeout` | Roll back explicitly, then retry whole operation if idempotent |
|
||||
| Invalid JSON text | `3140`, SQLSTATE `22032` | Statement | `invalid_data` | No |
|
||||
| Data too long | `1406`, SQLSTATE `22001` | Statement | `invalid_data` | No |
|
||||
| Check constraint | `3819`, SQLSTATE `HY000` | Statement | `constraint_violation` | No |
|
||||
| Server gone before request | Driver/server transport signal | No operation or unknown | `unavailable` | Retry only if operation definitely was not sent |
|
||||
| Connection lost during request | Driver transport signal | Unknown | `outcome_unknown` | Reconcile by idempotency key; do not blind retry |
|
||||
| Pool acquisition timeout | Driver/pool signal | None | `unavailable` | Bounded retry outside transaction |
|
||||
| Unsupported profile | Initialization probe mismatch | None | `unsupported` | No; fail readiness |
|
||||
| Migration lock timeout | Named-lock acquisition returns timeout | None | `migration_lock_timeout` | Wait/back off according to startup policy |
|
||||
| Migration lock error | Named-lock acquisition returns error | None | `migration_lock_failed` | No blind retry; inspect connection state |
|
||||
|
||||
The adapter MUST classify by structured code and SQLSTATE where available, never by localized message
|
||||
text. Public HTTP/SSE/MCP responses must still pass through the repository's existing sanitized error
|
||||
helpers.
|
||||
|
||||
### 6.3 Retry rules
|
||||
|
||||
A retryable classification does not automatically make an operation safe to retry.
|
||||
|
||||
A retry loop MUST:
|
||||
|
||||
1. own the entire repository atomic operation;
|
||||
2. discard the failed transaction context;
|
||||
3. acquire a valid connection and begin a new transaction;
|
||||
4. preserve a stable operation or entity identity;
|
||||
5. use bounded attempts with jitter;
|
||||
6. stop on non-retryable classifications;
|
||||
7. reconcile `outcome_unknown` before issuing another write;
|
||||
8. emit structured diagnostics without credentials or raw SQL values.
|
||||
|
||||
MySQL explicitly recommends retrying the entire transaction after a deadlock. A lock wait timeout
|
||||
rolls back only the current statement by default, so explicit rollback is required to make the retry
|
||||
boundary independent of server configuration.
|
||||
|
||||
### 6.4 Reproducible two-connection deadlock probe
|
||||
|
||||
Use two physical connections, not two logical operations that might share one pool connection:
|
||||
|
||||
```sql
|
||||
CREATE TABLE conformance_deadlock (
|
||||
id INT PRIMARY KEY,
|
||||
value_no INT NOT NULL
|
||||
) ENGINE=InnoDB;
|
||||
INSERT INTO conformance_deadlock VALUES (1, 0), (2, 0);
|
||||
```
|
||||
|
||||
```text
|
||||
Connection A Connection B
|
||||
START TRANSACTION; START TRANSACTION;
|
||||
UPDATE ... WHERE id = 1; UPDATE ... WHERE id = 2;
|
||||
UPDATE ... WHERE id = 2; UPDATE ... WHERE id = 1;
|
||||
```
|
||||
|
||||
Exactly one transaction should become the deadlock victim. The harness asserts that the victim is
|
||||
classified as retryable, its whole transaction is retried with a new context, both logical updates
|
||||
occur once, and no partial result remains.
|
||||
|
||||
## 7. Migration ownership and DDL recovery
|
||||
|
||||
### 7.1 Why a normal transaction is insufficient
|
||||
|
||||
MySQL DDL statements commonly commit the current transaction implicitly before execution and often
|
||||
afterward. Atomic DDL protects one supported DDL statement; it does not make a sequence of DDL,
|
||||
data backfill, and schema-history updates one user transaction.
|
||||
|
||||
A MySQL migration runner therefore MUST model a migration as recoverable phases:
|
||||
|
||||
```text
|
||||
lock acquired
|
||||
-> current schema inspected
|
||||
-> intent/checkpoint recorded
|
||||
-> DDL phase applied and verified
|
||||
-> data phase applied in bounded transactions
|
||||
-> postconditions verified
|
||||
-> logical milestone recorded
|
||||
-> readiness allowed
|
||||
-> lock released
|
||||
```
|
||||
|
||||
A process crash at any arrow must have a deterministic resume or stop condition.
|
||||
|
||||
### 7.2 Ownership alternatives
|
||||
|
||||
| Option | Strengths | Failure modes | Decision |
|
||||
| ------------------------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| Process-local mutex | Simple and useful for one process | Does not coordinate replicas | Rejected for external-backend migration ownership |
|
||||
| Row lock held in a transaction | Uses normal InnoDB locking | DDL implicit commit releases transaction ownership | Rejected as the sole DDL migration lock |
|
||||
| Lease row with owner and expiry | Survives pooled connections and can support takeover | Requires clock/expiry/fencing design; stale owner may continue | Candidate for scheduled jobs, not first migration mechanism |
|
||||
| MySQL named lock | Server-wide, exclusive, tied to physical session, released on disconnect | Must pin one connection; not transaction-scoped; one-server scope; undefined waiter order | Recommended first MySQL migration mutex, combined with durable history |
|
||||
| External coordinator | Can coordinate across database topologies | Adds an operational dependency outside the database contract | Deferred unless deployment topology requires it |
|
||||
|
||||
### 7.3 Recommended first mechanism
|
||||
|
||||
For a single writable MySQL primary, the migration runner SHOULD:
|
||||
|
||||
1. lease and pin one physical connection;
|
||||
2. acquire one application-and-database-specific named lock of at most 64 characters;
|
||||
3. distinguish acquired (`1`), timeout (`0`), and error (`NULL`);
|
||||
4. inspect a durable migration-history table after acquiring the lock;
|
||||
5. execute idempotent physical phases with explicit postcondition checks;
|
||||
6. record completion only after all postconditions pass;
|
||||
7. release the named lock explicitly in `finally`;
|
||||
8. close/discard the pinned connection if release cannot be confirmed.
|
||||
|
||||
Named locks are released when the session ends, not on commit or rollback. They are server-wide on one
|
||||
`mysqld`; topology and failover behavior must be validated before active-active support is advertised.
|
||||
A durable history/checkpoint table remains necessary because lock ownership alone says nothing about
|
||||
partially completed DDL.
|
||||
|
||||
### 7.4 Migration failure matrix
|
||||
|
||||
| Injection point | Required durable evidence | Restart behavior | Readiness |
|
||||
| ------------------------------- | --------------------------------------------- | ----------------------------------- | --------------------------------------------- |
|
||||
| Before lock | No intent | Retry lock acquisition | Not ready while required migration is pending |
|
||||
| After lock, before intent | No schema change | Reinspect and restart | Not ready |
|
||||
| After DDL, before checkpoint | Schema postcondition reveals DDL applied | Mark/continue only after validation | Not ready |
|
||||
| During data backfill | Bounded checkpoint identifies completed range | Resume from verified checkpoint | Not ready |
|
||||
| After data, before milestone | Postconditions prove completion | Record milestone idempotently | Not ready until recorded |
|
||||
| After milestone, before release | History proves complete | New owner verifies and proceeds | Ready if all required milestones pass |
|
||||
|
||||
## 8. SQLite-to-MySQL migration validation
|
||||
|
||||
An offline migration tool is required before database switching can be advertised. For each migrated
|
||||
domain it MUST provide a dry run and a post-import report.
|
||||
|
||||
### 8.1 Preflight
|
||||
|
||||
- verify supported SQLite and MySQL schema milestones;
|
||||
- validate every source JSON payload according to the chosen target representation;
|
||||
- detect names that collide under the target collation;
|
||||
- validate UTF-8 and maximum indexed byte lengths;
|
||||
- detect orphaned foreign keys even if the source connection had checks disabled;
|
||||
- validate timestamps and numeric ranges;
|
||||
- count source rows by table and logical domain;
|
||||
- refuse to mutate either database during dry run.
|
||||
|
||||
### 8.2 Import
|
||||
|
||||
- preserve application-generated IDs;
|
||||
- use deterministic batches and checkpoints;
|
||||
- import parents before children;
|
||||
- do not use replacement semantics to hide conflicts;
|
||||
- classify every rejected row with a stable reason;
|
||||
- keep encrypted credential ciphertext opaque and never log it;
|
||||
- stop on an unclassified difference.
|
||||
|
||||
### 8.3 Postconditions
|
||||
|
||||
- row counts match for every migrated table;
|
||||
- identity sets match exactly;
|
||||
- foreign-key orphan counts are zero;
|
||||
- canonical domain digests match for JSON-backed records;
|
||||
- list ordering and mapping resolution produce the same results;
|
||||
- a second dry run reports no pending changes;
|
||||
- SQLite remains unchanged and available for operator rollback until cutover is accepted.
|
||||
|
||||
## 9. Backend-neutral conformance catalog
|
||||
|
||||
Each test below runs the same repository fixture against SQLite and MySQL. MySQL-specific probes may
|
||||
assert error metadata internally, but the shared assertion compares only domain results and durable
|
||||
state.
|
||||
|
||||
### 9.1 Core CRUD and representation
|
||||
|
||||
| Test name | Fixture/action | Required assertion |
|
||||
| --------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------ |
|
||||
| `create_round_trips_domain_values` | Create Unicode, nullable, JSON, and timestamp fields | Parsed domain object equals normalized input |
|
||||
| `find_missing_distinguishes_absent_from_null` | Read an absent ID and a present nullable row | Results are distinct |
|
||||
| `update_missing_returns_not_found` | Update an absent ID | Stable `not_found` result |
|
||||
| `delete_is_idempotent_as_declared` | Delete the same ID twice | First and second results match the repository contract |
|
||||
| `json_round_trips_structurally` | Write equivalent JSON with different whitespace/order | Parsed values are equal; raw text is not asserted |
|
||||
| `timestamp_round_trips_in_utc` | Change MySQL session default before leasing a verified connection | Domain serialization remains canonical UTC |
|
||||
| `decimal_round_trips_without_float_loss` | Write precision/scale boundaries | Exact representation is unchanged |
|
||||
| `large_integer_does_not_cross_number_lossily` | Write beyond JavaScript safe integer range | String/bigint domain representation is exact |
|
||||
|
||||
### 9.2 Identity and collation
|
||||
|
||||
| Test name | Fixture/action | Required assertion |
|
||||
| ---------------------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| `id_is_byte_exact` | Create IDs differing only by case | Both remain distinct if the ID contract is binary |
|
||||
| `exact_name_lookup_is_case_sensitive` | Store `MASTER-LIGHT`, query exact lowercase | Exact lookup misses |
|
||||
| `insensitive_name_lookup_uses_declared_policy` | Query the same row through the named insensitive operation | One deterministic row is returned |
|
||||
| `unique_name_case_policy_is_explicit` | Insert case variants | Result matches the selected name policy on both backends |
|
||||
| `unique_name_accent_policy_is_explicit` | Insert accent variants | Result matches the selected policy |
|
||||
| `unique_violation_is_classified` | Concurrently create one identity | One wins; loser is `unique_violation` without backend text |
|
||||
| `nullable_unique_policy_is_explicit` | Insert two `NULL` logical keys | Result matches domain rule, not accidental index behavior |
|
||||
|
||||
### 9.3 Ordering and pagination
|
||||
|
||||
| Test name | Fixture/action | Required assertion |
|
||||
| --------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------ |
|
||||
| `list_uses_unique_final_tiebreaker` | Insert rows with identical primary sort values | Repeated list order is identical and ID-ordered |
|
||||
| `pagination_has_no_gaps_or_duplicates` | Traverse small pages across tied rows | Union equals full ID set; page intersections are empty |
|
||||
| `nullable_sort_position_is_fixed` | Mix `NULL` and non-`NULL` values | `NULL` appears at the contract-defined end |
|
||||
| `cursor_predicate_matches_sort_tuple` | Page forward through mixed sort keys | Every row appears exactly once in declared order |
|
||||
| `concurrent_insert_pagination_behavior_is_declared` | Insert between page reads | Result matches snapshot or documented live-page policy |
|
||||
|
||||
### 9.4 Writes and affected rows
|
||||
|
||||
| Test name | Fixture/action | Required assertion |
|
||||
| ------------------------------------------- | ------------------------------------------ | -------------------------------------------------- |
|
||||
| `same_value_update_is_not_missing` | Update an existing row to identical values | `unchanged` or declared success, never `not_found` |
|
||||
| `same_value_result_ignores_found_rows_mode` | Run fixture with both connection modes | Domain result is identical |
|
||||
| `compare_update_detects_stale_version` | Two writers use one old version | One succeeds; one returns `conflict` |
|
||||
| `batch_count_uses_contract_definition` | Mix changed and unchanged matches | Count means the same thing on both backends |
|
||||
| `upsert_preserves_identity_and_children` | Upsert parent with a child row | ID, immutable fields, and child survive |
|
||||
| `insert_only_never_silently_updates` | Repeat insert-only identity | Second call is `unique_violation` |
|
||||
|
||||
### 9.5 Transactions, isolation, and failure injection
|
||||
|
||||
| Test name | Fixture/action | Required assertion |
|
||||
| ----------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `related_changes_commit_atomically` | Update parent and children | All postconditions commit together |
|
||||
| `related_changes_roll_back_atomically` | Inject a child constraint failure | All tables equal pre-operation state |
|
||||
| `stable_snapshot_behavior_is_declared` | Read, commit a concurrent update, then read in the same operation | Result follows the operation's declared snapshot/current-read policy |
|
||||
| `range_insert_visibility_is_declared` | Read a range while another transaction inserts a matching row | Later visibility matches the declared snapshot/live policy |
|
||||
| `read_modify_write_prevents_lost_update` | Two transactions read one version and attempt distinct updates | One declared winner; loser conflicts/retries without overwriting |
|
||||
| `independent_writers_preserve_atomic_effects` | Two transactions update different identities concurrently | Both logical effects commit; no contract depends on backend lock order |
|
||||
| `deadlock_retries_whole_operation` | Two physical connections lock in opposite order | One victim; final logical effect occurs once |
|
||||
| `lock_timeout_discards_context` | Hold a row lock past timeout | Explicit rollback; old context rejects work |
|
||||
| `duplicate_and_foreign_key_errors_are_distinct` | Trigger each constraint | Stable distinct classes |
|
||||
| `disconnect_before_send_is_unavailable` | Fail connection before dispatch | Safe bounded retry is permitted |
|
||||
| `disconnect_during_commit_is_outcome_unknown` | Drop connection at commit boundary | No blind retry; reconciliation is required |
|
||||
| `retry_uses_stable_operation_identity` | Fail first attempt after durable write | At most one logical effect exists |
|
||||
|
||||
### 9.6 Migration and readiness
|
||||
|
||||
| Test name | Fixture/action | Required assertion |
|
||||
| --------------------------------------- | ------------------------------------------- | -------------------------------------------------- |
|
||||
| `only_one_instance_owns_migration` | Two backend instances acquire one name | Exactly one executes migration phases |
|
||||
| `lock_timeout_is_not_reported_as_ready` | Hold migration lock from another connection | Startup waits/fails with classified state |
|
||||
| `disconnect_releases_named_lock` | Terminate owner connection | Another instance can acquire and reinspect |
|
||||
| `ddl_checkpoint_recovers_after_crash` | Stop after DDL before history update | Restart detects postcondition and continues safely |
|
||||
| `backfill_resumes_without_duplication` | Stop between deterministic batches | Completed rows are neither skipped nor duplicated |
|
||||
| `partial_migration_blocks_readiness` | Leave required milestone incomplete | Health may be alive; readiness is false |
|
||||
| `completed_history_is_idempotent` | Start against fully migrated schema | No DDL/data mutation occurs |
|
||||
|
||||
## 10. First-slice acceptance profile: combos and model mappings
|
||||
|
||||
This section specializes the general catalog for the candidate first slice discussed in #8075 and
|
||||
implemented experimentally in Draft PR #8757. It does not approve that runtime PR.
|
||||
|
||||
### 10.1 Contract decisions required before adapter code
|
||||
|
||||
| Decision | Current evidence | Required resolution |
|
||||
| --------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
|
||||
| Combo ID | Application UUID | Preserve as byte-exact text/binary identity |
|
||||
| Combo name uniqueness | SQLite unique name; exact and insensitive reads differ | Select explicit uniqueness collation independently from insensitive fallback |
|
||||
| Combo list | `sort_order`, then `name NOCASE` | Add `id` as final tie-breaker and define Unicode name order |
|
||||
| Next sort order | `MAX(sort_order) + 1` | Replace race-prone read-then-insert with an atomic allocation or retryable unique invariant |
|
||||
| Reorder | One SQLite transaction updates all parseable rows | Define concurrent reorder serialization and all-or-nothing behavior |
|
||||
| Corrupt combo JSON | Reads/resolution skip malformed payloads | Decide whether MySQL schema can represent malformed legacy rows during migration |
|
||||
| Mapping order | `priority DESC, created_at ASC` | Add `id ASC` final tie-breaker |
|
||||
| Mapping delete | Boolean from affected rows | Preserve `true` then `false` behavior independent of found-rows mode |
|
||||
| Combo delete | Foreign key cascade removes mappings | Preserve one-operation atomic cascade |
|
||||
| Timestamps | Application ISO strings | Preserve canonical UTC text or define an exact typed conversion |
|
||||
|
||||
### 10.2 Required combo fixtures
|
||||
|
||||
The shared fixture MUST include:
|
||||
|
||||
- combo names `Alpha`, `alpha`, `Résumé`, and `resume` to exercise selected collation policy;
|
||||
- three combos with the same requested `sortOrder` to exercise the unique final order;
|
||||
- one missing ID for update and delete results;
|
||||
- one payload with explicit JSON `null` and one with a missing member;
|
||||
- one intentionally malformed legacy payload if compatibility requires it;
|
||||
- mappings with identical `priority` and `createdAt` but different IDs;
|
||||
- enabled, disabled, inactive-target, and corrupt-target mappings;
|
||||
- one combo with at least two dependent mappings for cascade verification.
|
||||
|
||||
### 10.3 Required combo assertions
|
||||
|
||||
A MySQL implementation cannot claim the first slice complete until the shared harness proves:
|
||||
|
||||
1. application UUIDs and ISO timestamps round-trip unchanged;
|
||||
2. exact and insensitive combo-name lookups remain distinct operations;
|
||||
3. uniqueness follows the approved name policy, not server defaults;
|
||||
4. combo and mapping lists have a total deterministic order;
|
||||
5. every offset page is a contiguous slice of that order;
|
||||
6. update of a missing combo/mapping returns `null`;
|
||||
7. first delete returns `true`, repeated delete returns `false`;
|
||||
8. reorder filters unknown/duplicate requested IDs exactly as the accepted contract specifies;
|
||||
9. reorder either commits every intended row or none;
|
||||
10. mapping resolution uses the deterministic order and skips disabled, inactive, and malformed targets;
|
||||
11. deleting a combo atomically removes all dependent mappings;
|
||||
12. errors are classified without raw MySQL messages;
|
||||
13. SQLite starts without loading a MySQL dependency;
|
||||
14. no external-backend support is advertised by the presence of this slice alone.
|
||||
|
||||
### 10.4 Concurrency probes specific to the slice
|
||||
|
||||
#### Concurrent combo creation
|
||||
|
||||
Two connections create different UUIDs with the same contract-equivalent name. Exactly one succeeds;
|
||||
the other receives `unique_violation`. If case/accent variants are allowed by the approved policy,
|
||||
both succeed and exact lookup returns the correct identity.
|
||||
|
||||
#### Concurrent sort allocation
|
||||
|
||||
Two connections create combos without an explicit sort order. The final values MUST follow the
|
||||
contract without duplicates caused by both transactions reading the same `MAX(sort_order)`. The
|
||||
implementation may serialize allocation, use a separate sequence, or retry a protected invariant;
|
||||
the contract must not require one specific SQL mechanism.
|
||||
|
||||
#### Concurrent reorder
|
||||
|
||||
Two connections reorder the same set in opposite orders. The accepted outcome MUST be one complete
|
||||
order or the other, never a mixed sequence or mismatched JSON/column `sortOrder`. The loser may wait,
|
||||
return conflict, or retry according to the approved contract.
|
||||
|
||||
#### Delete versus mapping creation
|
||||
|
||||
One connection deletes a combo while another creates a mapping to it. The final state MUST be either
|
||||
an existing combo with a valid mapping or no combo and no mapping. An orphan mapping is forbidden.
|
||||
|
||||
## 11. Implementation gate checklist
|
||||
|
||||
A MySQL adapter PR for any domain MUST NOT start until reviewers can answer all applicable items:
|
||||
|
||||
- [ ] Identity, case, accent, and collation semantics are explicit.
|
||||
- [ ] Every list has a complete order, `NULL` position, and unique tie-breaker.
|
||||
- [ ] Missing, unchanged, conflict, and delete results are distinguishable.
|
||||
- [ ] Every write is classified as insert-only, identity-preserving upsert, or replacement.
|
||||
- [ ] ID generation and idempotency ownership are explicit.
|
||||
- [ ] JSON and temporal representations are selected with migration compatibility in mind.
|
||||
- [ ] Error codes map to the backend-neutral taxonomy.
|
||||
- [ ] Retry ownership and maximum scope are explicit.
|
||||
- [ ] Migration mutex, durable checkpoints, and readiness rules are approved.
|
||||
- [ ] SQLite and MySQL fixtures run through one behavior harness.
|
||||
- [ ] Offline migration preflight and postconditions exist before cutover is advertised.
|
||||
- [ ] SQLite remains the zero-configuration default and clean startup path.
|
||||
|
||||
## 12. Reference sources
|
||||
|
||||
### 12.1 OmniRoute sources
|
||||
|
||||
- `docs/architecture/persistence-backend-boundary.md`
|
||||
- `docs/architecture/sqlite-coupling-inventory.md`
|
||||
- `src/lib/db/combos.ts`
|
||||
- `src/lib/db/modelComboMappings.ts`
|
||||
- `src/lib/db/migrations/001_initial_schema.sql`
|
||||
- `src/lib/db/migrations/010_model_combo_mappings.sql`
|
||||
- `src/lib/db/migrations/020_combo_sort_order.sql`
|
||||
|
||||
### 12.2 MySQL 8.0 reference manual
|
||||
|
||||
- [Character sets and collations](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/charset.html)
|
||||
- [CREATE TABLE](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/create-table.html)
|
||||
- [UPDATE](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/update.html)
|
||||
- [INSERT ... ON DUPLICATE KEY UPDATE](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/insert-on-duplicate.html)
|
||||
- [Information functions](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/information-functions.html)
|
||||
- [The JSON data type](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/json.html)
|
||||
- [InnoDB transaction isolation](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/innodb-transaction-isolation-levels.html)
|
||||
- [InnoDB error handling](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/innodb-error-handling.html)
|
||||
- [Handling deadlocks](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/innodb-deadlocks-handling.html)
|
||||
- [Statements that cause an implicit commit](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/implicit-commit.html)
|
||||
- [Locking functions](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/locking-functions.html)
|
||||
- [InnoDB limits](https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/innodb-limits.html)
|
||||
|
||||
### 12.3 SQLite references
|
||||
|
||||
- [ON CONFLICT](https://sqlite.org/lang_conflict.html)
|
||||
- [`NULL` handling](https://sqlite.org/nulls.html)
|
||||
- [Transactions](https://sqlite.org/lang_transaction.html)
|
||||
- [SELECT and ordering](https://sqlite.org/lang_select.html#orderby)
|
||||
|
||||
## 13. Open decisions
|
||||
|
||||
This specification deliberately leaves the following decisions to the accepted first-slice design:
|
||||
|
||||
1. the exact collation and normalization policy for combo names;
|
||||
2. the typed or text representation of combo JSON in MySQL;
|
||||
3. the repository result type for an existing same-value update;
|
||||
4. the isolation level selected by the backend profile;
|
||||
5. the concurrency mechanism for sort-order allocation and reorder;
|
||||
6. the physical MySQL migration schema and durable checkpoint format;
|
||||
7. the exact retry budget and backoff policy;
|
||||
8. the topology boundary within which a MySQL named migration lock is sufficient.
|
||||
|
||||
These are not adapter implementation details. Each changes observable behavior or operational
|
||||
correctness and therefore requires explicit review before runtime support proceeds.
|
||||
278
docs/guides/ANTIGRAVITY-ONBOARDING.md
Normal file
278
docs/guides/ANTIGRAVITY-ONBOARDING.md
Normal file
@@ -0,0 +1,278 @@
|
||||
---
|
||||
title: "Antigravity (Google One AI) — Onboarding with OmniRoute"
|
||||
version: 3.8.50
|
||||
lastUpdated: 2026-07-31
|
||||
---
|
||||
|
||||
# OmniRoute Antigravity (Google One AI) Onboarding Guide
|
||||
|
||||
> **What you get**: Access to Gemini 3.1 Pro, Gemini 3.5 Flash, Claude Sonnet 4.6, and other models through your Google One AI Pro subscription — routed through OmniRoute as a unified gateway.
|
||||
|
||||
**Official references**:
|
||||
|
||||
- [Google Antigravity](https://antigravity.google) — product homepage
|
||||
- [Antigravity Plans & Pricing](https://antigravity.google/pricing) — subscription tiers
|
||||
- [Antigravity Docs: Plans](https://antigravity.google/docs/plans) — baseline quota details
|
||||
- [Google One AI Plans](https://one.google.com/about/google-ai-plans/) — Google One subscription comparison
|
||||
- [Antigravity CLI Blog](https://antigravity.google/blog/introducing-google-antigravity-cli) — CLI announcement
|
||||
|
||||
---
|
||||
|
||||
## 1. Antigravity vs Antigravity CLI (agy)
|
||||
|
||||
Both providers share the **same Google backend** — identical OAuth client, token refresh, endpoints, and Google accounts. The difference is what models you see.
|
||||
|
||||
> See [Antigravity CLI announcement](https://antigravity.google/blog/introducing-google-antigravity-cli) for Google's official comparison.
|
||||
|
||||
| Aspect | `antigravity` (IDE) | `agy` (CLI) |
|
||||
| -------------------- | ----------------------------------------- | --------------------------------------------------- |
|
||||
| **Google product** | Antigravity 2.0 / Antigravity IDE | Antigravity CLI |
|
||||
| **Backend** | Same Google Cloud Code API | Same Google Cloud Code API |
|
||||
| **OAuth / Token** | Same client, same refresh | Same client, same refresh |
|
||||
| **Model catalog** | Static curated list (OmniRoute hardcoded) | Live-probed from Google via `:fetchAvailableModels` |
|
||||
| **Claude models** | Sonnet 4.6, Opus 4.6 (4 variants each) | Sonnet 4.6, Opus 4.6 (4 variants each) |
|
||||
| **Gemini naming** | Clean labels (Low/Medium/High) | Upstream IDs (extra-low/low/agent) |
|
||||
| **Extra models** | `gpt-oss-120b-medium` | May include additional models from Google |
|
||||
| **Default use case** | IDE integration (VS Code, JetBrains) | CLI / API access |
|
||||
| **Quota** | Shared with agy (same Google account) | Shared with antigravity (same Google account) |
|
||||
|
||||
**Available models (verified via experiment, 2026-07-29)**:
|
||||
|
||||
- Gemini: 3.6 Flash, 3.5 Flash, 3.1 Pro, 3 Flash, 2.5 Flash (various thinking levels)
|
||||
- Claude: Sonnet 4.6, Opus 4.6 (each with default/low/medium/high variants)
|
||||
- Other: GPT-OSS 120B Medium
|
||||
- **Claude Sonnet 5 is NOT available** — only 4.6 variants are supported
|
||||
|
||||
**Why the model catalog differs**: Google's CLI is "optimized for speed and low overhead" and "co-optimized with Gemini models" (per Google's official blog). The Web/IDE product is "optimized for comprehensiveness." The CLI uses `:fetchAvailableModels` to dynamically discover models, while the IDE uses a static curated list.
|
||||
|
||||
**In practice**: Use `agy/` prefix for Gemini models (e.g. `agy/gemini-3.5-flash-high`). Use `antigravity/` for the static curated list. Both hit the same Google backend, but expose different model naming. The quota is shared — using either provider counts against the same Google account's limits.
|
||||
|
||||
---
|
||||
|
||||
## 2. Google One AI Pro: Quota System
|
||||
|
||||
> See [Antigravity Docs: Plans](https://antigravity.google/docs/plans) for official quota details and [Changes to Antigravity Plans](https://antigravity.google/blog/changes-to-antigravity-plans) for the latest pricing updates.
|
||||
|
||||
Google Antigravity uses a **dual-layer quota** based on "Work Done" (computational weight), not message count.
|
||||
|
||||
### The Two Layers
|
||||
|
||||
| Layer | What it is | Refresh cycle |
|
||||
| ------------------ | ----------------------------- | ------------------------------------------------------------------ |
|
||||
| **5-hour sprint** | Immediate pool of "work done" | Resets 5 hours after first request in a session |
|
||||
| **7-day baseline** | Weekly hard cap | Overrides 5-hour refresh if hit; locks out until next 7-day period |
|
||||
|
||||
**How "Work Done" is calculated**: Agent-heavy tasks (e.g. "Refactor this entire repository") drain quota much faster than simple tasks (e.g. "Fix this function"). There is no real-time dashboard showing consumption.
|
||||
|
||||
### Plan Tiers
|
||||
|
||||
| Plan | Price | Quota | Weekly limit |
|
||||
| ------------ | ---------- | ---------------------------------- | ----------------------------- |
|
||||
| Free | $0 | Meaningful quota, refreshed weekly | Yes |
|
||||
| AI Pro | $19.99/mo | High quota, 5-hour rolling refresh | Yes (overrides 5-hour if hit) |
|
||||
| AI Ultra 5x | $99.99/mo | 5x Pro quota | No weekly limit |
|
||||
| AI Ultra 20x | $199.99/mo | 20x Pro quota | No weekly limit |
|
||||
|
||||
### Gemini vs Non-Gemini Models
|
||||
|
||||
- **Gemini models** (Flash + Pro): Share a single rate limit, drawn down by API pricing. If Flash is 8x cheaper than Pro, you get 8x more Flash tokens.
|
||||
- **Non-Gemini models** (Claude, GPT-OSS): Have **separate** rate limits. May remain available even when Gemini is locked out.
|
||||
|
||||
### AI Credits (Overage)
|
||||
|
||||
> See [Google One AI credits](https://support.google.com/googleone/answer/14534406) for how credits work.
|
||||
|
||||
When baseline quota is exhausted:
|
||||
|
||||
- **Never**: Wait for quota to refresh; shows "Baseline model quota reached"
|
||||
- **Always**: Auto-use AI credits; switches back to baseline when it refreshes
|
||||
|
||||
Credits are purchased separately and deducted at standard API pricing.
|
||||
|
||||
### Key Details
|
||||
|
||||
- Quota is **account-level shared** — the same Google account in Antigravity IDE, CLI, and OmniRoute shares one quota pool
|
||||
- Each Google account has its own independent quota — multiple accounts = multiple quota pools
|
||||
- AI Pro users have reported **7-day lockouts** instead of 5-hour resets when weekly baseline is hit (Google confirmed this is by design for high demand)
|
||||
|
||||
**When your account is exhausted**: OmniRoute automatically retries with the next available account in the combo route. No manual intervention needed.
|
||||
|
||||
---
|
||||
|
||||
## 3. How to Get a projectId
|
||||
|
||||
Every antigravity/agy connection needs a Google Cloud Code `projectId`. Without it, the `/v1internal:models` endpoint returns 404.
|
||||
|
||||
### Method A: Automatic (Recommended)
|
||||
|
||||
OmniRoute handles this automatically. When you add a new Google account via Dashboard OAuth:
|
||||
|
||||
1. OmniRoute refreshes the token
|
||||
2. Calls `loadCodeAssist` to discover the projectId
|
||||
3. If no project exists, calls `onboardUser` to create one
|
||||
4. Retries `loadCodeAssist` to get the newly created projectId
|
||||
5. Saves it to the database
|
||||
|
||||
**This works for most accounts** — no manual steps needed.
|
||||
|
||||
### Method B: Manual via agy CLI
|
||||
|
||||
If automatic discovery fails (see Section 5 for when this happens):
|
||||
|
||||
```bash
|
||||
# Install agy CLI (if not already)
|
||||
npm install -g @anthropic-ai/agy
|
||||
|
||||
# Login with your Google account
|
||||
agy login
|
||||
|
||||
# Select the account that needs onboarding
|
||||
# This triggers Cloud Code registration and assigns a projectId
|
||||
```
|
||||
|
||||
After `agy login` succeeds, refresh the token in OmniRoute Dashboard. The projectId will be discovered automatically.
|
||||
|
||||
### How to verify
|
||||
|
||||
Check the database:
|
||||
|
||||
```bash
|
||||
# Inside OmniRoute container
|
||||
node -e "const db=require('better-sqlite3')('/app/data/storage.sqlite'); \
|
||||
console.log(JSON.stringify(db.prepare(\
|
||||
'SELECT email,project_id FROM provider_connections WHERE provider=\"agy\"'\
|
||||
).all(), null, 2))"
|
||||
```
|
||||
|
||||
Or check the logs:
|
||||
|
||||
```
|
||||
podman logs omniroute 2>&1 | grep "projectId discovered"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. OAuth Redirect URI
|
||||
|
||||
### The Problem
|
||||
|
||||
Google OAuth requires a valid redirect URI. OmniRoute's default uses `http://127.0.0.1:20128/callback` (loopback). This works for local builds but **fails for remote deployments** (e.g., a server accessed via LAN IP).
|
||||
|
||||
Google rejects redirect URIs that:
|
||||
|
||||
- Use IP addresses (must be a domain ending in `.com`, `.org`, etc.)
|
||||
- Don't match the registered redirect URIs in the OAuth client config
|
||||
|
||||
### The Solution
|
||||
|
||||
**Option A: Use the built-in OAuth flow (default)**
|
||||
|
||||
- Works when you access OmniRoute from `localhost` or `127.0.0.1`
|
||||
- No configuration needed
|
||||
|
||||
**Option B: Custom OAuth credentials**
|
||||
|
||||
- Set `ANTIGRAVITY_OAUTH_CLIENT_TYPE=web` in your environment
|
||||
- Provide your own Google OAuth credentials:
|
||||
```
|
||||
GOOGLE_OAUTH_CLIENT_ID=your-client-id
|
||||
GOOGLE_OAUTH_CLIENT_SECRET=your-client-secret
|
||||
```
|
||||
- Register `https://your-domain.com/callback` as an authorized redirect URI in Google Cloud Console
|
||||
|
||||
**Option C: Use agy CLI for initial login**
|
||||
|
||||
- Run `agy login` on the machine that will access OmniRoute
|
||||
- The OAuth flow completes locally, tokens are stored
|
||||
- Import the connection into OmniRoute via Dashboard
|
||||
|
||||
### Limitations
|
||||
|
||||
- Custom OAuth credentials require a domain name (Google does not accept IP addresses as redirect URIs)
|
||||
- If you don't have a domain, use Option A or C instead
|
||||
|
||||
---
|
||||
|
||||
## 5. Troubleshooting: When Automatic Setup Fails
|
||||
|
||||
OmniRoute handles projectId discovery and onboarding automatically for most accounts. When it fails, the root cause is usually one of these:
|
||||
|
||||
### Account region is blocked
|
||||
|
||||
**Symptom**: `agy login` returns "Eligibility check failed: Your current account is not eligible for Antigravity, because it is not currently available in your location."
|
||||
|
||||
**Root cause**: Google accounts have a backend "Country Association" field set at registration time. The agy CLI and Cloud Code API check this field strictly — unlike web Gemini which only checks your current IP.
|
||||
|
||||
> To check or change your account's associated region, visit [Google Country Association Form](https://policies.google.com/country-association-form).
|
||||
|
||||
**Why web Gemini works but agy doesn't**:
|
||||
|
||||
- Web Gemini / Google One: checks current IP only (proxy passes)
|
||||
- agy CLI / Cloud Code API: reads backend Country Association field (proxy doesn't help)
|
||||
|
||||
**Fix**:
|
||||
|
||||
1. Visit [Google Country Association Form](https://policies.google.com/country-association-form) while on a US IP
|
||||
2. Submit region change request (select "I live in a different country")
|
||||
3. Wait 1-24 hours for Google to process + email notification
|
||||
4. Then `agy login` should succeed
|
||||
|
||||
### Account has no Cloud Code project
|
||||
|
||||
**Symptom**: Logs show `loadCodeAssist returned no project id` and `onboardUser failed (400)`.
|
||||
|
||||
**Root cause**: The account has never been registered with Google Cloud Code, and the automatic onboarding failed.
|
||||
|
||||
**Fix**: Run `agy login` manually to trigger Cloud Code registration, then refresh the token in OmniRoute Dashboard.
|
||||
|
||||
### Token expired or revoked
|
||||
|
||||
**Symptom**: 401 errors in logs, or "Token has expired" messages.
|
||||
|
||||
**Fix**: Refresh the token in Dashboard → Providers → agy → Click refresh icon. If the refresh token itself is revoked, you'll need to re-authenticate via OAuth.
|
||||
|
||||
---
|
||||
|
||||
## Decision Flowchart
|
||||
|
||||
```
|
||||
Account not working?
|
||||
│
|
||||
├─ Does it have a projectId in the database?
|
||||
│ ├─ YES → Problem is elsewhere (token expired, rate limit, etc.)
|
||||
│ └─ NO ↓
|
||||
│
|
||||
├─ Is the account's Country Association set to a restricted region?
|
||||
│ ├─ YES → Change region at Google Country Association Form
|
||||
│ │ (https://policies.google.com/country-association-form)
|
||||
│ │ Wait 1-24 hours, then retry
|
||||
│ └─ NO ↓
|
||||
│
|
||||
├─ Does the account have Google One AI Pro subscription?
|
||||
│ ├─ NO → Subscribe first at one.google.com
|
||||
│ └─ YES ↓
|
||||
│
|
||||
├─ Try automatic discovery (refresh token in Dashboard)
|
||||
│ ├─ Works → Done
|
||||
│ └─ Still fails ↓
|
||||
│
|
||||
└─ Manual: Run `agy login` on the machine
|
||||
├─ Works → Refresh token in Dashboard, projectId discovered
|
||||
└─ Fails → Check error message, likely region or subscription issue
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Task | Command / URL |
|
||||
| --------------------- | --------------------------------------------------------------------------------------- |
|
||||
| Change account region | [Google Country Association Form](https://policies.google.com/country-association-form) |
|
||||
| agy CLI login | `agy login` |
|
||||
| Check projectId in DB | `SELECT email,project_id FROM provider_connections WHERE provider='agy'` |
|
||||
| Check logs | `podman logs omniroute 2>&1 \| grep projectId` |
|
||||
| Refresh token | Dashboard → Providers → agy → Click refresh icon |
|
||||
|
||||
---
|
||||
|
||||
_Last updated: 2026-07-31. Based on OmniRoute v3.8.50._
|
||||
@@ -7,10 +7,11 @@ import { supportsClaudeMaxEffort, supportsXHighEffort } from "../../config/provi
|
||||
/**
|
||||
* Sanitize reasoning_effort for providers that don't accept all values.
|
||||
*
|
||||
* The claude→openai translator may emit reasoning_effort=max/xhigh when the
|
||||
* client sends output_config.effort=max on a Claude-shape request. Combined with
|
||||
* runtime alias remapping (e.g. claude-opus-4-6 → mimo/mimo-v2.5-pro), this
|
||||
* routes xhigh to OpenAI-shape providers that don't accept the value:
|
||||
* The claude→openai translator passes output_config.effort through verbatim
|
||||
* (including max) and only performs form conversion; provider-aware effort
|
||||
* policy is owned here. Combined with runtime alias remapping (e.g.
|
||||
* claude-opus-4-6 → mimo/mimo-v2.5-pro), this routes a client's effort value
|
||||
* to OpenAI-shape providers that don't accept it:
|
||||
*
|
||||
* xiaomi-mimo : low|medium|high only — 400 literal_error on xhigh
|
||||
* mistral : devstral models reject reasoning_effort entirely
|
||||
@@ -216,10 +217,7 @@ function writeEffortValue(
|
||||
}
|
||||
|
||||
/** Strip the effort field from every carrier that was present. */
|
||||
function stripEffortValue(
|
||||
b: Record<string, unknown>,
|
||||
c: EffortCarriers
|
||||
): Record<string, unknown> {
|
||||
function stripEffortValue(b: Record<string, unknown>, c: EffortCarriers): Record<string, unknown> {
|
||||
const next: Record<string, unknown> = { ...b };
|
||||
if (c.hasTopLevelReasoningEffort) delete next.reasoning_effort;
|
||||
if (c.hasReasoningEffort && c.reasoning) {
|
||||
|
||||
@@ -4651,12 +4651,7 @@ export async function handleChatCore({
|
||||
});
|
||||
if (streamReadiness.ok === false) {
|
||||
const { response: failureResponse, reason } = streamReadiness;
|
||||
const failure = {
|
||||
status: failureResponse.status,
|
||||
message: reason,
|
||||
code: streamReadiness.code,
|
||||
type: streamReadiness.type,
|
||||
};
|
||||
const { classificationReason, upstreamDiagnostic } = streamReadiness;
|
||||
trackPendingRequest(model, provider, connectionId, false);
|
||||
appendRequestLog({
|
||||
model,
|
||||
@@ -4668,7 +4663,11 @@ export async function handleChatCore({
|
||||
status: failureResponse.status,
|
||||
error: reason,
|
||||
providerRequest: finalBody || translatedBody,
|
||||
clientResponse: buildErrorBody(failureResponse.status, reason),
|
||||
clientResponse: buildErrorBody(
|
||||
failureResponse.status,
|
||||
classificationReason,
|
||||
upstreamDiagnostic ? { error: { message: upstreamDiagnostic } } : undefined
|
||||
),
|
||||
claudeCacheMeta: claudePromptCacheLogMeta,
|
||||
cacheSource: "upstream",
|
||||
});
|
||||
@@ -4680,6 +4679,7 @@ export async function handleChatCore({
|
||||
success: false,
|
||||
status: failureResponse.status,
|
||||
error: reason,
|
||||
classificationError: classificationReason,
|
||||
errorType: streamReadiness.type,
|
||||
errorCode: streamReadiness.code,
|
||||
response: failureResponse,
|
||||
|
||||
@@ -135,7 +135,17 @@ export function detectFormatFromEndpoint(body, endpointPath = "") {
|
||||
// Thin wrapper for call sites that only have the full request URL (not the bare endpoint
|
||||
// path chatCore already threads) — single source of truth stays detectFormatFromEndpoint.
|
||||
export function detectFormatFromUrl(body, requestUrl) {
|
||||
return detectFormatFromEndpoint(body, new URL(requestUrl).pathname);
|
||||
const rawUrl = typeof requestUrl === "string" ? requestUrl : "";
|
||||
let pathname = rawUrl;
|
||||
try {
|
||||
// Supplying a base URL keeps relative client endpoints (for example,
|
||||
// `/v1/messages`) valid while preserving pathname-only detection.
|
||||
pathname = new URL(rawUrl || "/", "http://omniroute.local").pathname;
|
||||
} catch {
|
||||
// Fall back to the raw value; detectFormatFromEndpoint is intentionally
|
||||
// safe for unknown or malformed paths.
|
||||
}
|
||||
return detectFormatFromEndpoint(body, pathname);
|
||||
}
|
||||
|
||||
// Detect request format from body structure
|
||||
@@ -193,7 +203,7 @@ export function detectFormat(body) {
|
||||
if (firstContent?.type === "text" && !body.model?.includes("/")) {
|
||||
// Could be Claude or OpenAI multimodal
|
||||
// Check for Claude-specific fields
|
||||
if (body.system || body.anthropic_version) {
|
||||
if (body.system || body.anthropic_version || body["anthropic-version"]) {
|
||||
return "claude";
|
||||
}
|
||||
// Check if image format is Claude (source.type) vs OpenAI (image_url.url)
|
||||
@@ -216,7 +226,7 @@ export function detectFormat(body) {
|
||||
|
||||
// If content is string, it's likely OpenAI (Claude also supports this)
|
||||
// Check for other Claude-specific indicators
|
||||
if (body.system !== undefined || body.anthropic_version) {
|
||||
if (body.system !== undefined || body.anthropic_version || body["anthropic-version"]) {
|
||||
return "claude";
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ function normalizeToolSchema(schema: unknown): Record<string, unknown> {
|
||||
function normalizeOpenAIReasoningEffort(effort: unknown): string | undefined {
|
||||
if (typeof effort !== "string") return undefined;
|
||||
const normalized = effort.toLowerCase();
|
||||
if (normalized === "max") return "xhigh";
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { HTTP_STATUS } from "../config/constants.ts";
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "./error.ts";
|
||||
|
||||
type StreamReadinessLogger = {
|
||||
debug?: (tag: string, message: string) => void;
|
||||
@@ -7,7 +8,18 @@ type StreamReadinessLogger = {
|
||||
|
||||
export type StreamReadinessResult =
|
||||
| { ok: true; response: Response }
|
||||
| { ok: false; response: Response; reason: string; code: string; type: string };
|
||||
| {
|
||||
ok: false;
|
||||
response: Response;
|
||||
/** Sanitized operator-facing context for logs and persisted diagnostics. */
|
||||
reason: string;
|
||||
/** Stable internal text for retry, quota, and account-health classification. */
|
||||
classificationReason: string;
|
||||
/** First non-empty sanitized message from an error-only SSE payload. */
|
||||
upstreamDiagnostic?: string;
|
||||
code: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
@@ -233,6 +245,7 @@ type StreamReadinessSignalState = {
|
||||
currentEvent: string;
|
||||
dataLines: string[];
|
||||
pendingLine: string;
|
||||
upstreamDiagnostic: string | null;
|
||||
};
|
||||
|
||||
function resetCurrentEvent(state: StreamReadinessSignalState): void {
|
||||
@@ -248,7 +261,23 @@ function processStreamReadinessEvent(state: StreamReadinessSignalState): boolean
|
||||
if (isPingEventType(eventType) || !data || data === "[DONE]") return false;
|
||||
|
||||
try {
|
||||
return hasNonPingStructuredPayload(JSON.parse(data), eventType);
|
||||
const payload: unknown = JSON.parse(data);
|
||||
if (
|
||||
!state.upstreamDiagnostic &&
|
||||
isRecord(payload) &&
|
||||
isErrorOnlyStructuredPayload(payload)
|
||||
) {
|
||||
const error = payload.error;
|
||||
const rawMessage =
|
||||
typeof error === "string"
|
||||
? error
|
||||
: isRecord(error) && typeof error.message === "string"
|
||||
? error.message
|
||||
: "";
|
||||
const diagnostic = sanitizeErrorMessage(rawMessage).trim();
|
||||
if (diagnostic) state.upstreamDiagnostic = diagnostic;
|
||||
}
|
||||
return hasNonPingStructuredPayload(payload, eventType);
|
||||
} catch {
|
||||
return data.length > 0;
|
||||
}
|
||||
@@ -294,6 +323,7 @@ export function hasStreamReadinessSignal(text: string): boolean {
|
||||
currentEvent: "",
|
||||
dataLines: [],
|
||||
pendingLine: "",
|
||||
upstreamDiagnostic: null,
|
||||
};
|
||||
if (appendStreamReadinessSignal(state, text)) return true;
|
||||
return finishStreamReadinessSignal(state);
|
||||
@@ -303,16 +333,18 @@ function createErrorResponse(
|
||||
status: number,
|
||||
message: string,
|
||||
code: string,
|
||||
type: string
|
||||
type: string,
|
||||
upstreamDiagnostic?: string
|
||||
): Response {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
JSON.stringify(
|
||||
buildErrorBody(
|
||||
status,
|
||||
message,
|
||||
type,
|
||||
code,
|
||||
},
|
||||
}),
|
||||
upstreamDiagnostic ? { error: { message: upstreamDiagnostic } } : undefined,
|
||||
{ code, type }
|
||||
)
|
||||
),
|
||||
{ status, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
@@ -385,6 +417,7 @@ export async function ensureStreamReadiness(
|
||||
currentEvent: "",
|
||||
dataLines: [],
|
||||
pendingLine: "",
|
||||
upstreamDiagnostic: null,
|
||||
};
|
||||
const startedAt = Date.now();
|
||||
const effectiveTimeoutMs = Math.max(0, Math.floor(options.timeoutMs));
|
||||
@@ -414,6 +447,7 @@ export async function ensureStreamReadiness(
|
||||
return {
|
||||
ok: false,
|
||||
reason,
|
||||
classificationReason: reason,
|
||||
code: "STREAM_READINESS_TIMEOUT",
|
||||
type: "stream_timeout",
|
||||
response: createErrorResponse(
|
||||
@@ -438,6 +472,7 @@ export async function ensureStreamReadiness(
|
||||
return {
|
||||
ok: false,
|
||||
reason,
|
||||
classificationReason: reason,
|
||||
code: "STREAM_READINESS_TIMEOUT",
|
||||
type: "stream_timeout",
|
||||
response: createErrorResponse(
|
||||
@@ -460,7 +495,11 @@ export async function ensureStreamReadiness(
|
||||
return { ok: true, response: buildReadyResponse() };
|
||||
}
|
||||
|
||||
const reason = "Stream ended before producing a non-ping SSE event";
|
||||
const classificationReason = "Stream ended before producing a non-ping SSE event";
|
||||
const upstreamDiagnostic = readinessState.upstreamDiagnostic || undefined;
|
||||
const reason = upstreamDiagnostic
|
||||
? `${classificationReason}: ${upstreamDiagnostic}`
|
||||
: classificationReason;
|
||||
options.log?.warn?.(
|
||||
"STREAM",
|
||||
`${reason} (${options.provider || "provider"}/${options.model || "unknown"})`
|
||||
@@ -468,13 +507,16 @@ export async function ensureStreamReadiness(
|
||||
return {
|
||||
ok: false,
|
||||
reason,
|
||||
classificationReason,
|
||||
...(upstreamDiagnostic ? { upstreamDiagnostic } : {}),
|
||||
code: "STREAM_EARLY_EOF",
|
||||
type: "stream_early_eof",
|
||||
response: createErrorResponse(
|
||||
HTTP_STATUS.BAD_GATEWAY,
|
||||
reason,
|
||||
classificationReason,
|
||||
"STREAM_EARLY_EOF",
|
||||
"stream_early_eof"
|
||||
"stream_early_eof",
|
||||
upstreamDiagnostic
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
62
quality-ratchet/quality-ratchet.md
Normal file
62
quality-ratchet/quality-ratchet.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Quality Ratchet
|
||||
|
||||
| Métrica | Baseline | Atual | Status |
|
||||
|---|---|---|---|
|
||||
| eslintWarnings | 0 | 0 | ok |
|
||||
| eslintErrors | 0 | 0 | ok |
|
||||
| coverage.statements | 80.8 | — | SKIP (ausente) |
|
||||
| coverage.lines | 80.8 | — | SKIP (ausente) |
|
||||
| coverage.functions | 86.42 | — | SKIP (ausente) |
|
||||
| coverage.branches | 78.1 | — | SKIP (ausente) |
|
||||
| coverage.chatCore.lines | 72.45 | — | SKIP (ausente) |
|
||||
| coverage.combo.lines | 85.42 | — | SKIP (ausente) |
|
||||
| coverage.accountFallback.lines | 96.78 | — | SKIP (ausente) |
|
||||
| coverage.auth.lines | 92.55 | — | SKIP (ausente) |
|
||||
| coverage.routeGuard.lines | 98.73 | — | SKIP (ausente) |
|
||||
| coverage.error.lines | 92.13 | — | SKIP (ausente) |
|
||||
| coverage.publicCreds.lines | 99.07 | — | SKIP (ausente) |
|
||||
| coverage.circuitBreaker.lines | 95.09 | — | SKIP (ausente) |
|
||||
| openapiCoverage.pct | 38 | 38 | ok |
|
||||
| i18nUiCoverage.pct | 99 | 99 | ok |
|
||||
| deadExports | 227 | — | SKIP (dedicated gate) |
|
||||
| cognitiveComplexity | 1223 | — | SKIP (dedicated gate) |
|
||||
| typeCoveragePct | 92.17 | — | SKIP (dedicated gate) |
|
||||
| codeqlAlerts | 0 | — | SKIP (dedicated gate) |
|
||||
| secretFindings | 0 | — | SKIP (dedicated gate) |
|
||||
| zizmorFindings | 190 | — | SKIP (dedicated gate) |
|
||||
| vulnCount | 10 | — | SKIP (dedicated gate) |
|
||||
| bundleSize | 7666 | — | SKIP (dedicated gate) |
|
||||
| openapiBreaking | 0 | — | SKIP (dedicated gate) |
|
||||
| mutationScore.src/sse/services/auth.ts | 52.57 | — | SKIP (dedicated gate) |
|
||||
| mutationScore.open-sse/services/accountFallback.ts | 68.38 | — | SKIP (dedicated gate) |
|
||||
| mutationScore.src/server/authz/routeGuard.ts | 76.08 | — | SKIP (dedicated gate) |
|
||||
| mutationScore.src/shared/utils/circuitBreaker.ts | 56.94 | — | SKIP (dedicated gate) |
|
||||
| mutationScore.open-sse/utils/error.ts | 43.83 | — | SKIP (dedicated gate) |
|
||||
| mutationScore.open-sse/utils/publicCreds.ts | 59.76 | — | SKIP (dedicated gate) |
|
||||
| mutationScore.open-sse/services/combo/autoStrategy.ts | 41.33 | — | SKIP (dedicated gate) |
|
||||
| mutationScore.open-sse/services/combo/comboStructure.ts | 57.82 | — | SKIP (dedicated gate) |
|
||||
| mutationScore.open-sse/services/combo/validateQuality.ts | 61.33 | — | SKIP (dedicated gate) |
|
||||
| mutationScore.open-sse/services/combo/comboPredicates.ts | 56.62 | — | SKIP (dedicated gate) |
|
||||
| mutationScore.open-sse/services/combo/rrState.ts | 70.88 | — | SKIP (dedicated gate) |
|
||||
| mutationScore.open-sse/services/combo/shadowRouting.ts | 48 | — | SKIP (dedicated gate) |
|
||||
| mutationScore.open-sse/services/combo/targetSorters.ts | 68.3 | — | SKIP (dedicated gate) |
|
||||
| mutationScore.open-sse/services/combo/comboData.ts | 76.94 | — | SKIP (dedicated gate) |
|
||||
| mutationScore.open-sse/services/combo/quotaScoring.ts | 39.73 | — | SKIP (dedicated gate) |
|
||||
| mutationScore.open-sse/services/combo/quotaStrategies.ts | 50.3 | — | SKIP (dedicated gate) |
|
||||
| mutationScore.open-sse/handlers/chatCore/passthroughHelpers.ts | 80.89 | — | SKIP (dedicated gate) |
|
||||
| mutationScore.open-sse/handlers/chatCore/sanitization.ts | 70.15 | — | SKIP (dedicated gate) |
|
||||
| mutationScore.open-sse/handlers/chatCore/upstreamTimeouts.ts | 33 | — | SKIP (dedicated gate) |
|
||||
| mutationScore.open-sse/handlers/chatCore/comboContextCache.ts | 13.62 | — | SKIP (dedicated gate) |
|
||||
| mutationScore.open-sse/handlers/chatCore/idempotency.ts | 42.82 | — | SKIP (dedicated gate) |
|
||||
| mutationScore.open-sse/handlers/chatCore/responseHeaders.ts | 62.7 | — | SKIP (dedicated gate) |
|
||||
| mutationScore.open-sse/handlers/chatCore/executorHelpers.ts | 70.39 | — | SKIP (dedicated gate) |
|
||||
| mutationScore.open-sse/handlers/chatCore/memoryExtraction.ts | 62.06 | — | SKIP (dedicated gate) |
|
||||
| mutationScore.open-sse/handlers/chatCore/nonStreamingSse.ts | 72.82 | — | SKIP (dedicated gate) |
|
||||
| mutationScore.open-sse/handlers/chatCore/passthroughToolNames.ts | 66.42 | — | SKIP (dedicated gate) |
|
||||
| mutationScore.open-sse/handlers/chatCore/headers.ts | 94.29 | — | SKIP (dedicated gate) |
|
||||
| mutationScore.open-sse/handlers/chatCore/logTruncation.ts | 77.64 | — | SKIP (dedicated gate) |
|
||||
| mutationScore.open-sse/handlers/chatCore/memorySkillsInjection.ts | 13.49 | — | SKIP (dedicated gate) |
|
||||
| mutationScore.open-sse/handlers/chatCore/semanticCache.ts | 60.16 | — | SKIP (dedicated gate) |
|
||||
| mutationScore.open-sse/handlers/chatCore/telemetryHelpers.ts | 83.18 | — | SKIP (dedicated gate) |
|
||||
|
||||
**Sem regressões — gate OK.**
|
||||
@@ -3,7 +3,7 @@
|
||||
//
|
||||
// Two tiers of checks:
|
||||
// • STRICT (always blocking — exit 1 on drift): high-confidence, slow-moving counts
|
||||
// that historically caused the worst drift across README / AGENTS / docs.
|
||||
// that historically caused the worst drift across user-facing documentation.
|
||||
// - provider count (source of truth: docs/reference/PROVIDER_REFERENCE.md total,
|
||||
// which is auto-generated from src/shared/constants/providers.ts)
|
||||
// - i18n locale count (source of truth: config/i18n.json `locales`)
|
||||
@@ -259,14 +259,14 @@ export function buildChecks() {
|
||||
actual: readProviderTotal(),
|
||||
docKey: "providers",
|
||||
strict: true,
|
||||
files: ["README.md", "AGENTS.md", "CLAUDE.md"],
|
||||
files: ["README.md", "CLAUDE.md"],
|
||||
},
|
||||
{
|
||||
label: "i18n locales count",
|
||||
actual: countLocales(),
|
||||
docKey: "i18n locales",
|
||||
strict: true,
|
||||
files: ["docs/README.md", "docs/guides/I18N.md", "AGENTS.md"],
|
||||
files: ["docs/README.md", "docs/guides/I18N.md"],
|
||||
},
|
||||
...(() => {
|
||||
const f = readCodeFacts();
|
||||
@@ -317,19 +317,10 @@ export function buildChecks() {
|
||||
skipBefore: /(tools?|definitions?)\s*\(\s*$/i,
|
||||
skipAfter: /^\s*\(\d+ CLI/,
|
||||
},
|
||||
["README.md", "CLAUDE.md", "AGENTS.md", "docs/frameworks/MCP-SERVER.md"]
|
||||
),
|
||||
claim(f.mcpScopes, "MCP scopes", { pattern: /(\d+) scopes/gi }, [
|
||||
"README.md",
|
||||
"CLAUDE.md",
|
||||
"AGENTS.md",
|
||||
]),
|
||||
claim(
|
||||
f.cliTotal,
|
||||
"CLI tools",
|
||||
{ pattern: /(\d+) tools(?=\s*\(\d+ CLI)/gi },
|
||||
["README.md"]
|
||||
["README.md", "CLAUDE.md", "docs/frameworks/MCP-SERVER.md"]
|
||||
),
|
||||
claim(f.mcpScopes, "MCP scopes", { pattern: /(\d+) scopes/gi }, ["README.md", "CLAUDE.md"]),
|
||||
claim(f.cliTotal, "CLI tools", { pattern: /(\d+) tools(?=\s*\(\d+ CLI)/gi }, ["README.md"]),
|
||||
];
|
||||
})(),
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)}%
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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")}`}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -411,7 +411,7 @@ export default function ModelRow({
|
||||
: testStatus === "ok"
|
||||
? "OK"
|
||||
: testStatus === "error"
|
||||
? providerText(t, "errorShort", "Error")
|
||||
? "Error"
|
||||
: t("testModel")
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ export default function PassthroughModelRow({
|
||||
: testStatus === "ok"
|
||||
? "OK"
|
||||
: testStatus === "error"
|
||||
? providerText(t, "errorShort", "Error")
|
||||
? "Error"
|
||||
: t("testModel")
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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" : ""}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -320,7 +320,7 @@ export default function AuthzSection() {
|
||||
onClick={() => handleRemovePrefix(prefix)}
|
||||
disabled={locked || submitting}
|
||||
>
|
||||
{t("authz.remove")}
|
||||
Remove
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]) => (
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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 "Sync Now" 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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")} & 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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 / localhost(SS/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>clash(Clash.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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -23,6 +23,7 @@ interface Props {
|
||||
quotaVisibility?: Record<string, { hidden?: string[] }>;
|
||||
onHideQuota?: (provider: string, quota: any) => void;
|
||||
onShowQuota?: (provider: string, quota: any) => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export default function QuotaCardGrid({
|
||||
@@ -44,9 +45,41 @@ export default function QuotaCardGrid({
|
||||
quotaVisibility,
|
||||
onHideQuota,
|
||||
onShowQuota,
|
||||
compact = false,
|
||||
}: Props) {
|
||||
if (connections.length === 0) return null;
|
||||
|
||||
const renderCard = (conn: (typeof connections)[number]) => (
|
||||
<QuotaCard
|
||||
key={conn.id}
|
||||
connection={conn}
|
||||
quota={quotaData[conn.id]}
|
||||
loading={!!loading[conn.id]}
|
||||
error={errors[conn.id] || null}
|
||||
refreshedAt={lastRefreshedAt[conn.id]}
|
||||
emailsVisible={emailsVisible}
|
||||
providerLabel={providerLabels[conn.provider] || conn.provider}
|
||||
onRefresh={() => onRefresh(conn.id, conn.provider)}
|
||||
onOpenCutoff={() => onOpenCutoff(conn)}
|
||||
onRedeemResetCredit={() => onRedeemResetCredit?.(conn.id, conn.provider)}
|
||||
onToggleActive={(nextActive) => onToggleActive(conn.id, nextActive)}
|
||||
togglingActive={togglingActiveId === conn.id}
|
||||
redeemingResetCredit={redeemingResetCreditId === conn.id}
|
||||
loadingResetCredits={loadingResetCreditsId === conn.id}
|
||||
quotaVisibility={quotaVisibility}
|
||||
onHideQuota={onHideQuota ? (q) => onHideQuota(conn.provider, q) : undefined}
|
||||
onShowQuota={onShowQuota ? (q) => onShowQuota(conn.provider, q) : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(17rem,1fr))] gap-3">
|
||||
{connections.map(renderCard)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Group connections by provider, preserving the order from sortedConnections.
|
||||
const groups = new Map<string, typeof connections>();
|
||||
for (const conn of connections) {
|
||||
@@ -66,28 +99,7 @@ export default function QuotaCardGrid({
|
||||
</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,280px),1fr))] gap-3">
|
||||
{conns.map((conn) => (
|
||||
<QuotaCard
|
||||
key={conn.id}
|
||||
connection={conn}
|
||||
quota={quotaData[conn.id]}
|
||||
loading={!!loading[conn.id]}
|
||||
error={errors[conn.id] || null}
|
||||
refreshedAt={lastRefreshedAt[conn.id]}
|
||||
emailsVisible={emailsVisible}
|
||||
providerLabel={providerLabels[conn.provider] || conn.provider}
|
||||
onRefresh={() => onRefresh(conn.id, conn.provider)}
|
||||
onOpenCutoff={() => onOpenCutoff(conn)}
|
||||
onOpenResetCredits={() => onOpenResetCredits?.(conn.id, conn.provider)}
|
||||
onToggleActive={(nextActive) => onToggleActive(conn.id, nextActive)}
|
||||
togglingActive={togglingActiveId === conn.id}
|
||||
redeemingResetCredit={redeemingResetCreditId === conn.id}
|
||||
loadingResetCredits={loadingResetCreditsId === conn.id}
|
||||
quotaVisibility={quotaVisibility}
|
||||
onHideQuota={onHideQuota ? (q) => onHideQuota(conn.provider, q) : undefined}
|
||||
onShowQuota={onShowQuota ? (q) => onShowQuota(conn.provider, q) : undefined}
|
||||
/>
|
||||
))}
|
||||
{conns.map(renderCard)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -44,6 +44,7 @@ const LS_PURCHASE_FILTER = "omniroute:limits:purchaseFilter";
|
||||
const LS_STATUS_FILTER = "omniroute:limits:statusFilter";
|
||||
const LS_ENV_FILTER = "omniroute:limits:envFilter";
|
||||
const LS_PROVIDER_FILTER = "omniroute:limits:providerFilter";
|
||||
const LS_LAYOUT_MODE = "omniroute:limits:layoutMode";
|
||||
|
||||
const MIN_FETCH_INTERVAL_MS = 30000;
|
||||
const QUOTA_BAR_GREEN_THRESHOLD = 50;
|
||||
@@ -51,6 +52,7 @@ const QUOTA_BAR_YELLOW_THRESHOLD = 20;
|
||||
|
||||
type PurchaseTypeKey = "all" | "oauth-free" | "oauth-sub" | "apikey";
|
||||
type StatusKey = "all" | "critical" | "alert" | "ok" | "empty";
|
||||
type LayoutMode = "full" | "compact";
|
||||
|
||||
const PURCHASE_TYPES: Array<{ key: PurchaseTypeKey; labelKey: string; fallback: string }> = [
|
||||
{ key: "all", labelKey: "purchaseAll", fallback: "All" },
|
||||
@@ -231,6 +233,10 @@ export default function ProviderLimits({
|
||||
if (typeof window === "undefined") return "all";
|
||||
return localStorage.getItem(LS_PROVIDER_FILTER) || "all";
|
||||
});
|
||||
const [layoutMode, setLayoutMode] = useState<LayoutMode>(() => {
|
||||
if (typeof window === "undefined") return "full";
|
||||
return localStorage.getItem(LS_LAYOUT_MODE) === "compact" ? "compact" : "full";
|
||||
});
|
||||
|
||||
const lastFetchTimeRef = useRef<Record<string, number>>({});
|
||||
const staleProbeRef = useRef<Record<string, number>>({});
|
||||
@@ -746,6 +752,18 @@ export default function ProviderLimits({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleLayoutMode = useCallback(() => {
|
||||
setLayoutMode((current) => {
|
||||
const next = current === "full" ? "compact" : "full";
|
||||
try {
|
||||
localStorage.setItem(LS_LAYOUT_MODE, next);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const renderInlineQuotaSummary = (quotas: any[]) => {
|
||||
if (!quotas || quotas.length === 0) return null;
|
||||
return (
|
||||
@@ -816,30 +834,55 @@ export default function ProviderLimits({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={refreshAll}
|
||||
disabled={refreshingAll}
|
||||
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg bg-bg-subtle border border-border text-text-main text-[13px] disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
title={
|
||||
autoRefreshIntervalMs > 0 ? tr("autoRefreshing", "Auto-refreshing") : t("refreshAll")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[16px] ${refreshingAll ? "animate-spin" : ""}`}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleLayoutMode}
|
||||
aria-pressed={layoutMode === "compact"}
|
||||
aria-label={
|
||||
layoutMode === "compact"
|
||||
? "Switch to full quota layout"
|
||||
: "Switch to compact quota layout"
|
||||
}
|
||||
title={
|
||||
layoutMode === "compact"
|
||||
? "Switch to full quota layout"
|
||||
: "Switch to compact quota layout"
|
||||
}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-bg-subtle border border-border text-text-main text-[13px] cursor-pointer"
|
||||
>
|
||||
{autoRefreshIntervalMs > 0 ? "schedule" : "refresh"}
|
||||
</span>
|
||||
{refreshingAll
|
||||
? tr("refreshing", "Refreshing")
|
||||
: autoRefreshIntervalMs > 0
|
||||
? `${tr("autoRefreshing", "Auto-refreshing")} ${formatAutoRefreshCountdown(
|
||||
Math.max(
|
||||
0,
|
||||
autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAtRef.current)
|
||||
)
|
||||
)}`
|
||||
: t("refreshAll")}
|
||||
</button>
|
||||
<span className="material-symbols-outlined text-[16px]" aria-hidden>
|
||||
{layoutMode === "compact" ? "view_agenda" : "grid_view"}
|
||||
</span>
|
||||
<span className="hidden sm:inline">
|
||||
{layoutMode === "compact" ? "Compact" : "Full"}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={refreshAll}
|
||||
disabled={refreshingAll}
|
||||
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg bg-bg-subtle border border-border text-text-main text-[13px] disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
title={
|
||||
autoRefreshIntervalMs > 0 ? tr("autoRefreshing", "Auto-refreshing") : t("refreshAll")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[16px] ${refreshingAll ? "animate-spin" : ""}`}
|
||||
>
|
||||
{autoRefreshIntervalMs > 0 ? "schedule" : "refresh"}
|
||||
</span>
|
||||
{refreshingAll
|
||||
? tr("refreshing", "Refreshing")
|
||||
: autoRefreshIntervalMs > 0
|
||||
? `${tr("autoRefreshing", "Auto-refreshing")} ${formatAutoRefreshCountdown(
|
||||
Math.max(
|
||||
0,
|
||||
autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAtRef.current)
|
||||
)
|
||||
)}`
|
||||
: t("refreshAll")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFilters && (
|
||||
@@ -1048,6 +1091,7 @@ export default function ProviderLimits({
|
||||
onShowQuota={handleShowQuota}
|
||||
redeemingResetCreditId={resetCreditRedemption.redeemingResetCreditId}
|
||||
loadingResetCreditsId={resetCreditRedemption.loadingResetCreditsId}
|
||||
compact={layoutMode === "compact"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,26 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Card from "@/shared/components/Card";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
|
||||
import QuotaMiniBar from "../dashboard/usage/components/ProviderLimits/QuotaMiniBar";
|
||||
import { PROVIDER_LABEL } from "../dashboard/usage/components/ProviderLimits/constants";
|
||||
import { translateUsageOrFallback } from "../dashboard/usage/components/ProviderLimits/i18nFallback";
|
||||
import { isProviderQuotaVisible } from "@/shared/utils/providerQuotaVisibility";
|
||||
import { parseQuotaData } from "../dashboard/usage/components/ProviderLimits/quotaParsing";
|
||||
import {
|
||||
formatCountdown,
|
||||
formatQuotaLabel,
|
||||
getBarColor,
|
||||
getQuotaRemainingPercentage,
|
||||
} from "../dashboard/usage/components/ProviderLimits/utils";
|
||||
|
||||
const PRIMARY_QUOTA_COUNT = 3;
|
||||
|
||||
type Connection = {
|
||||
id: string;
|
||||
provider: string;
|
||||
authType?: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
quotaVisible?: boolean;
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
type QuotaData = Record<string, any>;
|
||||
|
||||
interface ProviderQuotaWidgetProps {
|
||||
autoRefreshInterval?: number;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
function formatUpdatedAt(updatedAt: number | null): string | null {
|
||||
if (!updatedAt) return null;
|
||||
return new Date(updatedAt).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function formatAutoRefreshCountdown(ms: number): string {
|
||||
@@ -30,23 +50,172 @@ function formatAutoRefreshCountdown(ms: number): string {
|
||||
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function AutoRefreshButtonLabel({
|
||||
autoRefreshIntervalMs,
|
||||
lastRefreshAllAt,
|
||||
refreshingAll,
|
||||
tr,
|
||||
}: {
|
||||
autoRefreshIntervalMs: number;
|
||||
lastRefreshAllAt: number;
|
||||
refreshingAll: boolean;
|
||||
tr: (key: string, fallback: string) => string;
|
||||
}) {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
function QuotaRow({ quota }: { quota: any }) {
|
||||
const t = useTranslations("usage");
|
||||
const percentage = Math.round(getQuotaRemainingPercentage(quota));
|
||||
const colors = getBarColor(percentage);
|
||||
const label = quota.displayName || formatQuotaLabel(quota.name) || quota.name;
|
||||
const reset = formatCountdown(quota.resetAt);
|
||||
|
||||
if (quota.isCredits || quota.isResetCredits) {
|
||||
const amount = Number(quota.creditCount ?? quota.remaining ?? 0).toLocaleString(undefined, {
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
return (
|
||||
<div className="flex min-w-0 items-center justify-between gap-3 py-1.5">
|
||||
<span className="min-w-0 truncate text-xs font-medium text-text-main">{label}</span>
|
||||
<span className="shrink-0 text-xs font-bold tabular-nums" style={{ color: colors.text }}>
|
||||
{amount}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1 py-1.5" title={quota.modelKey || quota.name}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="min-w-0 truncate text-xs font-medium text-text-main">{label}</span>
|
||||
<span className="shrink-0 text-xs font-bold tabular-nums" style={{ color: colors.text }}>
|
||||
{quota.unlimited
|
||||
? "∞"
|
||||
: translateUsageOrFallback(t, "percentLeft", `${percentage}% left`, {
|
||||
pct: percentage,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{!quota.unlimited && <QuotaMiniBar percent={percentage} />}
|
||||
{reset && <span className="text-[10px] text-text-muted">⏱ {reset}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionQuotas({ connection, cache }: { connection: Connection; cache: any }) {
|
||||
const t = useTranslations("usage");
|
||||
const [showOptional, setShowOptional] = useState(false);
|
||||
const quotas = useMemo(
|
||||
() => parseQuotaData(connection.provider, cache),
|
||||
[cache, connection.provider]
|
||||
);
|
||||
const primaryQuotas = quotas.slice(0, PRIMARY_QUOTA_COUNT);
|
||||
const optionalQuotas = quotas.slice(PRIMARY_QUOTA_COUNT);
|
||||
const accountLabel = connection.name || connection.displayName || connection.email;
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
{accountLabel && <p className="mb-1 text-[11px] text-text-muted truncate">{accountLabel}</p>}
|
||||
{quotas.length === 0 ? (
|
||||
<p className="py-1.5 text-xs italic text-text-muted">
|
||||
{cache?.message || t("noQuotaData")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-x-6 sm:grid-cols-2">
|
||||
{primaryQuotas.map((quota, index) => (
|
||||
<div
|
||||
key={`${quota.name}-${quota.modelKey || ""}-${index}`}
|
||||
className="border-b border-border/40"
|
||||
>
|
||||
<QuotaRow quota={quota} />
|
||||
</div>
|
||||
))}
|
||||
{showOptional &&
|
||||
optionalQuotas.map((quota, index) => (
|
||||
<div
|
||||
key={`${quota.name}-${quota.modelKey || ""}-${index}`}
|
||||
className="border-b border-border/40"
|
||||
>
|
||||
<QuotaRow quota={quota} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{optionalQuotas.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOptional((current) => !current)}
|
||||
className="mt-2 inline-flex items-center gap-1 rounded-md border border-border bg-bg-subtle px-2 py-1 text-[11px] font-medium text-text-main hover:bg-surface transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]" aria-hidden="true">
|
||||
{showOptional ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
{showOptional
|
||||
? t("showLessQuotas")
|
||||
: t("showMoreQuotas", { count: optionalQuotas.length })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProviderQuotaWidget({
|
||||
autoRefreshInterval = 0,
|
||||
compact = false,
|
||||
}: ProviderQuotaWidgetProps) {
|
||||
const t = useTranslations("usage");
|
||||
const tr = useCallback(
|
||||
(key: string, fallback: string) => translateUsageOrFallback(t, key, fallback),
|
||||
[t]
|
||||
);
|
||||
const [connections, setConnections] = useState<Connection[]>([]);
|
||||
const [quotaData, setQuotaData] = useState<QuotaData>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshingAll, setRefreshingAll] = useState(false);
|
||||
const [updatedAt, setUpdatedAt] = useState<number | null>(null);
|
||||
const refreshingAllRef = useRef(false);
|
||||
const lastRefreshAllAtRef = useRef(Date.now());
|
||||
const autoRefreshIntervalMs = autoRefreshInterval > 0 ? autoRefreshInterval * 1000 : 0;
|
||||
const [autoRefreshClock, setAutoRefreshClock] = useState(() => Date.now());
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [connectionsResponse, quotasResponse] = await Promise.all([
|
||||
fetch("/api/providers/client"),
|
||||
fetch("/api/usage/provider-limits"),
|
||||
]);
|
||||
const connectionData = connectionsResponse.ok ? await connectionsResponse.json() : {};
|
||||
const quotaResponseData = quotasResponse.ok ? await quotasResponse.json() : {};
|
||||
const relevant = ((connectionData.connections || []) as Connection[]).filter(
|
||||
(connection) =>
|
||||
USAGE_SUPPORTED_PROVIDERS.includes(connection.provider) &&
|
||||
(connection.authType === "oauth" || connection.authType === "apikey")
|
||||
);
|
||||
setConnections(relevant);
|
||||
setQuotaData(quotaResponseData.caches || {});
|
||||
setUpdatedAt(Date.now());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoRefreshIntervalMs <= 0 || refreshingAll) return;
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const tick = () => setNow(Date.now());
|
||||
const refreshAll = useCallback(async () => {
|
||||
if (refreshingAllRef.current) return;
|
||||
refreshingAllRef.current = true;
|
||||
const now = Date.now();
|
||||
lastRefreshAllAtRef.current = now;
|
||||
setAutoRefreshClock(now);
|
||||
setRefreshingAll(true);
|
||||
try {
|
||||
const response = await fetch("/api/usage/provider-limits", { method: "POST" });
|
||||
if (!response.ok) throw new Error("Failed to refresh provider quotas");
|
||||
const data = await response.json();
|
||||
setQuotaData(data.caches || {});
|
||||
setUpdatedAt(Date.now());
|
||||
} catch (error) {
|
||||
console.error("ProviderQuotaWidget refreshAll error:", error);
|
||||
} finally {
|
||||
refreshingAllRef.current = false;
|
||||
setRefreshingAll(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoRefreshIntervalMs <= 0) return;
|
||||
|
||||
const tick = () => setAutoRefreshClock(Date.now());
|
||||
tick();
|
||||
|
||||
const timer = window.setInterval(tick, 1000);
|
||||
@@ -59,248 +228,131 @@ export function AutoRefreshButtonLabel({
|
||||
window.clearInterval(timer);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [autoRefreshIntervalMs, refreshingAll, lastRefreshAllAt]);
|
||||
|
||||
if (refreshingAll) {
|
||||
return <>{tr("refreshing", "Refreshing")}</>;
|
||||
}
|
||||
|
||||
if (autoRefreshIntervalMs <= 0) {
|
||||
return <>{tr("refreshAll", "Refresh All")}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{tr("autoRefreshing", "Auto-refreshing")}{" "}
|
||||
{formatAutoRefreshCountdown(Math.max(0, autoRefreshIntervalMs - (now - lastRefreshAllAt)))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProviderQuotaWidget({ autoRefreshInterval = 0 }: ProviderQuotaWidgetProps) {
|
||||
const t = useTranslations("usage");
|
||||
const tr = useCallback(
|
||||
(key: string, fallback: string) => translateUsageOrFallback(t, key, fallback),
|
||||
[t]
|
||||
);
|
||||
|
||||
const [connections, setConnections] = useState<Connection[]>([]);
|
||||
const [quotaData, setQuotaData] = useState<QuotaData>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshingAll, setRefreshingAll] = useState(false);
|
||||
|
||||
const refreshingAllRef = useRef(false);
|
||||
const lastRefreshAllAtRef = useRef(Date.now());
|
||||
const [lastRefreshAllAt, setLastRefreshAllAt] = useState(() => lastRefreshAllAtRef.current);
|
||||
const autoRefreshIntervalMs = autoRefreshInterval > 0 ? autoRefreshInterval * 1000 : 0;
|
||||
|
||||
const fetchConnections = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/providers/client");
|
||||
if (!res.ok) throw new Error("Failed to load connections");
|
||||
const data = await res.json();
|
||||
return (data.connections || []) as Connection[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchCached = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/usage/provider-limits");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const data = await res.json();
|
||||
return data.caches || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const [conns, caches] = await Promise.all([fetchConnections(), fetchCached()]);
|
||||
|
||||
// Only keep connections that are usage/quota supported
|
||||
const relevant = conns.filter(
|
||||
(c) =>
|
||||
isProviderQuotaVisible(c) &&
|
||||
USAGE_SUPPORTED_PROVIDERS.includes(c.provider) &&
|
||||
(c.authType === "oauth" || c.authType === "apikey")
|
||||
);
|
||||
|
||||
setConnections(relevant);
|
||||
setQuotaData(caches);
|
||||
setLoading(false);
|
||||
}, [fetchConnections, fetchCached]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
if (refreshingAllRef.current) return;
|
||||
refreshingAllRef.current = true;
|
||||
const now = Date.now();
|
||||
lastRefreshAllAtRef.current = now;
|
||||
setLastRefreshAllAt(now);
|
||||
setRefreshingAll(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/usage/provider-limits", { method: "POST" });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || "Refresh failed");
|
||||
}
|
||||
const data = await res.json();
|
||||
setQuotaData(data.caches || {});
|
||||
} catch (e) {
|
||||
console.error("ProviderQuotaWidget refreshAll error:", e);
|
||||
} finally {
|
||||
refreshingAllRef.current = false;
|
||||
setRefreshingAll(false);
|
||||
}
|
||||
}, []);
|
||||
}, [autoRefreshIntervalMs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoRefreshIntervalMs <= 0) return;
|
||||
if (document.visibilityState !== "visible") return;
|
||||
if (refreshingAllRef.current) return;
|
||||
|
||||
const maybeRefresh = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
if (refreshingAllRef.current) return;
|
||||
if (Date.now() - lastRefreshAllAtRef.current >= autoRefreshIntervalMs) {
|
||||
void refreshAll();
|
||||
}
|
||||
};
|
||||
if (autoRefreshClock - lastRefreshAllAtRef.current >= autoRefreshIntervalMs) {
|
||||
void refreshAll();
|
||||
}
|
||||
}, [autoRefreshClock, autoRefreshIntervalMs, refreshAll]);
|
||||
|
||||
maybeRefresh();
|
||||
const timer = window.setInterval(maybeRefresh, 1000);
|
||||
const handleVisibilityChange = () => maybeRefresh();
|
||||
const providerGroups = useMemo(() => {
|
||||
const groups = new Map<string, Connection[]>();
|
||||
for (const connection of connections) {
|
||||
const group = groups.get(connection.provider) || [];
|
||||
group.push(connection);
|
||||
groups.set(connection.provider, group);
|
||||
}
|
||||
return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||
}, [connections]);
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [autoRefreshIntervalMs, refreshAll]);
|
||||
|
||||
// Simple summary: group by provider for display
|
||||
const providerGroups = connections.reduce<Record<string, Connection[]>>((acc, conn) => {
|
||||
if (!acc[conn.provider]) acc[conn.provider] = [];
|
||||
acc[conn.provider].push(conn);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const providerEntries = Object.entries(providerGroups).sort(([a], [b]) => a.localeCompare(b));
|
||||
const updatedLabel = formatUpdatedAt(updatedAt);
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
{/* Header with title + Refresh All in upper right */}
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3 bg-surface/60">
|
||||
<Card className="w-full overflow-hidden">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border bg-surface/60 px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-primary text-[20px]">
|
||||
<span className="material-symbols-outlined text-[20px] text-primary" aria-hidden="true">
|
||||
account_balance
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="font-semibold text-base">{tr("providerQuota", "Provider Quota")}</h3>
|
||||
<p className="text-[11px] text-text-muted -mt-0.5">
|
||||
{tr("providerQuotaHomeHint", "Live status across connected accounts")}
|
||||
</p>
|
||||
<h2 className="text-base font-semibold text-text-main">
|
||||
{tr("providerQuota", "Provider Quota")}
|
||||
</h2>
|
||||
{updatedLabel && (
|
||||
<p className="text-[11px] text-text-muted">
|
||||
{tr("updatedShort", "Updated")} {updatedLabel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={refreshAll}
|
||||
disabled={refreshingAll || loading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-bg-subtle text-xs font-medium text-text-main disabled:opacity-50 disabled:cursor-not-allowed hover:bg-surface transition-colors"
|
||||
title={
|
||||
autoRefreshIntervalMs > 0
|
||||
? tr("autoRefreshing", "Auto-refreshing")
|
||||
: tr("refreshAll", "Refresh All")
|
||||
}
|
||||
disabled={loading || refreshingAll}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-bg-subtle px-3 py-1.5 text-xs font-medium text-text-main transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[16px] ${refreshingAll ? "animate-spin" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{autoRefreshIntervalMs > 0 ? "schedule" : "refresh"}
|
||||
</span>
|
||||
<span>
|
||||
<AutoRefreshButtonLabel
|
||||
autoRefreshIntervalMs={autoRefreshIntervalMs}
|
||||
lastRefreshAllAt={lastRefreshAllAt}
|
||||
refreshingAll={refreshingAll}
|
||||
tr={tr}
|
||||
/>
|
||||
</span>
|
||||
{refreshingAll
|
||||
? tr("refreshing", "Refreshing")
|
||||
: autoRefreshIntervalMs > 0
|
||||
? `${tr("autoRefreshing", "Auto-refreshing")} ${formatAutoRefreshCountdown(
|
||||
Math.max(
|
||||
0,
|
||||
autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAtRef.current)
|
||||
)
|
||||
)}`
|
||||
: tr("forceRefresh", "Refresh now")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8 text-text-muted text-sm">
|
||||
<span className="material-symbols-outlined animate-spin mr-2">progress_activity</span>
|
||||
{tr("loadingQuotas", "Loading...")}
|
||||
</div>
|
||||
) : providerEntries.length === 0 ? (
|
||||
<div className="text-center py-6 text-sm text-text-muted">
|
||||
{tr("noProviders", "No Providers Connected")}
|
||||
<div className="mt-1 text-xs">
|
||||
{tr(
|
||||
"connectProvidersForQuota",
|
||||
"Connect to providers with OAuth to track your API quota limits and usage."
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{providerEntries.map(([provider, conns]) => {
|
||||
const firstConn = conns[0];
|
||||
const cache = quotaData[firstConn?.id];
|
||||
const hasQuota = cache?.quotas && Object.keys(cache.quotas).length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={provider}
|
||||
className="rounded-lg border border-border bg-surface/40 p-3 flex flex-col gap-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon providerId={provider} size={18} />
|
||||
<span className="font-medium text-sm truncate">
|
||||
{provider.charAt(0).toUpperCase() + provider.slice(1)}
|
||||
</span>
|
||||
<span className="text-[10px] text-text-muted ml-auto tabular-nums">
|
||||
{conns.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hasQuota ? (
|
||||
<div className="text-xs text-text-muted" title={tr("details", "Details")}>
|
||||
{Object.keys(cache.quotas).length}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={refreshAll}
|
||||
className="text-left text-xs text-amber-600 dark:text-amber-500 hover:underline"
|
||||
>
|
||||
{tr("refreshAll", "Refresh All")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Future: embed small QuotaProgressBar for the primary window here */}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 text-[11px] text-right text-text-muted">
|
||||
<a href="/dashboard/usage?tab=limits" className="hover:text-primary hover:underline">
|
||||
{tr("viewDetails", "View details")}
|
||||
<span aria-hidden="true"> →</span>
|
||||
</a>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 px-4 py-8 text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin text-[16px]" aria-hidden="true">
|
||||
progress_activity
|
||||
</span>
|
||||
{tr("loadingQuotas", "Loading...")}
|
||||
</div>
|
||||
</div>
|
||||
) : providerGroups.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-text-muted">
|
||||
{tr("noProviders", "No Providers Connected")}
|
||||
</div>
|
||||
) : compact ? (
|
||||
/* Compact mode: 3-column card grid, flat across all connections */
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3 p-4">
|
||||
{connections.map((connection) => (
|
||||
<div key={connection.id} className="border border-border rounded-lg p-3 bg-bg-subtle">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<ProviderIcon providerId={connection.provider} size={16} />
|
||||
<span className="text-xs font-semibold text-text-main truncate">
|
||||
{PROVIDER_LABEL[connection.provider] || connection.provider}
|
||||
</span>
|
||||
</div>
|
||||
<ConnectionQuotas connection={connection} cache={quotaData[connection.id]} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{providerGroups.map(([provider, providerConnections]) => (
|
||||
<section
|
||||
key={provider}
|
||||
className="grid grid-cols-1 gap-4 px-4 py-4 lg:grid-cols-[12rem_minmax(0,1fr)]"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2 lg:items-start">
|
||||
<ProviderIcon providerId={provider} size={20} />
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-sm font-semibold text-text-main">
|
||||
{PROVIDER_LABEL[provider] || provider}
|
||||
</h3>
|
||||
<p className="text-[11px] text-text-muted">
|
||||
{providerConnections.length}{" "}
|
||||
{providerConnections.length === 1 ? "account" : "accounts"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{providerConnections.map((connection) => (
|
||||
<ConnectionQuotas
|
||||
key={connection.id}
|
||||
connection={connection}
|
||||
cache={quotaData[connection.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user