mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
chore: merge release/v3.8.0 into feat/features-batch — resolve all conflicts with release version
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -154,6 +154,10 @@ http-client.private.env.json
|
||||
# Feature-triage ephemeral artifact (regenerated each run)
|
||||
_ideia/_triage.json
|
||||
|
||||
# i18n audit artifact (generated by scripts/i18n/audit-dashboard-pages.mjs)
|
||||
scripts/i18n/_audit.json
|
||||
scripts/i18n/_pending-keys.json
|
||||
|
||||
# Private workflow / skill / command implementations
|
||||
# These contain proprietary multi-phase logic and should not be committed
|
||||
.agents/workflows/implement-features-ag.md
|
||||
|
||||
@@ -206,12 +206,12 @@
|
||||
}
|
||||
},
|
||||
"docs/architecture/ARCHITECTURE.md": {
|
||||
"source_hash": "7e691870a4f6f25a535a023206cff5ef88aed619c9734851ebdacdd3a3bafcc8",
|
||||
"source_hash": "a065bbff5e461cca6cd1a81d2923626040548c3b3af98ecb2a69a99709bd8059",
|
||||
"locales": {
|
||||
"pt-BR": {
|
||||
"source_hash": "7e691870a4f6f25a535a023206cff5ef88aed619c9734851ebdacdd3a3bafcc8",
|
||||
"target_hash": "3e2a58f341f20b29cf245330b094bec40ffeede43526b4d416c660c37abca8db",
|
||||
"updated_at": "2026-05-13T22:58:05.984Z"
|
||||
"source_hash": "a065bbff5e461cca6cd1a81d2923626040548c3b3af98ecb2a69a99709bd8059",
|
||||
"target_hash": "7ee10dc8cba2668fd279af64ecca3a1760e974b6dc9672aee81e69c63ee41175",
|
||||
"updated_at": "2026-05-20T05:34:41.826Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ rules:
|
||||
- pattern: new Database(...)
|
||||
paths:
|
||||
include:
|
||||
- "bin/**"
|
||||
- "/bin/**"
|
||||
exclude:
|
||||
- "bin/cli/sqlite.mjs"
|
||||
- "/bin/cli/sqlite.mjs"
|
||||
message: >
|
||||
Direct SQLite access in bin/ is banned. Use src/lib/db/* helpers or
|
||||
withRuntime() from bin/cli/runtime.mjs. See CLAUDE.md hard rule #5 and
|
||||
@@ -21,9 +21,9 @@ rules:
|
||||
- pattern: $DB.prepare("UPDATE $TABLE SET ...")
|
||||
paths:
|
||||
include:
|
||||
- "bin/**"
|
||||
- "/bin/**"
|
||||
exclude:
|
||||
- "bin/cli/sqlite.mjs"
|
||||
- "/bin/cli/sqlite.mjs"
|
||||
message: >
|
||||
Raw SQL in bin/ is banned. Use src/lib/db/* helpers. See CLAUDE.md
|
||||
hard rule #5 and bin/cli/CONVENTIONS.md.
|
||||
|
||||
236
CHANGELOG.md
236
CHANGELOG.md
@@ -2,7 +2,201 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
---
|
||||
|
||||
## [3.8.0] — 2026-05-06
|
||||
|
||||
### 🚀 Post-release hotfixes e contribuições (2026-05-06 → 2026-05-20)
|
||||
|
||||
#### 2026-05-20
|
||||
|
||||
- **feat(batch):** implement 10 feature requests harvested from issues — T3 Chat Web executor (cookie-based), per-request exhausted-provider tracking (#1731) to skip quota-drained providers mid-combo, Zed Docker detection, API key rotator health dashboard, Kiro multi-account isolation, context-window model filtering, cost blending in combos, combo config tests, provider validation branches, and postinstall support scripts. ([#2414](https://github.com/diegosouzapw/OmniRoute/pull/2414))
|
||||
- **feat(combos):** add `falloverBeforeRetry` strategy — combo routing now falls over to the next target before retrying the same model, eliminating the tail-latency spike from exhausting all per-model retries on a failing endpoint. Also wraps the retry loop in a `setTry` outer loop for per-target retry coordination. ([#2417](https://github.com/diegosouzapw/OmniRoute/pull/2417) — thanks @hartmark)
|
||||
- **fix(gamification):** resolve 6 implementation gaps — missing `SELECT` in `checkActionCountBadges` SQL (was silently skipping 8 badges), federation leaderboard auth enforcement, pagination `offset` parameter no longer silently discarded, admin anomaly view now computes real z-scores, `addXp` correctly calculates initial level from XP amount, and barrel `index.ts` for clean module exports. 72-test suite covering all fixes. ([#2421](https://github.com/diegosouzapw/OmniRoute/pull/2421) — thanks @oyi77)
|
||||
- **docs:** add AgentRouter provider setup guide — step-by-step instructions for connecting OmniRoute to AgentRouter.org's Claude-compatible relay endpoint, covering API key configuration and wire-image headers. ([#2422](https://github.com/diegosouzapw/OmniRoute/pull/2422) — thanks @leninejunior)
|
||||
- **fix(claude):** drop orphan `tool_result` blocks left behind when `fixToolAdjacency` strips a dangling `tool_use` — resolves HTTP 400 "unexpected tool_use_id in tool_result blocks" from the Anthropic API on truncated histories. `fixToolPairs` now re-runs after every `fixToolAdjacency` pass across all three call sites (`contextManager.ts`, `base.ts`, `claudeCodeCompatible.ts`). (discussion [#2410](https://github.com/diegosouzapw/OmniRoute/discussions/2410))
|
||||
- **fix(playground):** guard against `null`/non-string model IDs in Playground dropdowns — `typeof m?.id !== "string"` check prevents a silent crash in the provider discovery loop and `filteredModels` computation that was leaving all Playground dropdowns empty when `/v1/models` returned entries with `id: null`; adds deduplication via `Set` to eliminate duplicate React key warnings.
|
||||
- **fix(mitm):** point MITM runtime manager re-export to the compiled `.js` entrypoint — fixes module resolution after build when the `.ts` source is no longer present.
|
||||
- **fix(storage):** persist `STORAGE_ENCRYPTION_KEY` across upgrades (closes #1622) — ensures that SQLite encryption keys are preserved during version upgrades. ([#2428](https://github.com/diegosouzapw/OmniRoute/pull/2428) — thanks @Chewji9875)
|
||||
- **fix(auth):** auto-reset credential `apiKeyHealth` status on successful connection test. ([#2427](https://github.com/diegosouzapw/OmniRoute/pull/2427) — thanks @clousky2020)
|
||||
- **fix(mitm):** drop `.js` extension on `manager.runtime` re-export to fix webpack packaging issue. ([#2425](https://github.com/diegosouzapw/OmniRoute/pull/2425) — thanks @NomenAK)
|
||||
- **fix(image):** support Antigravity image generation and add Gemini 3.5 Flash support. ([#2423](https://github.com/diegosouzapw/OmniRoute/pull/2423) — thanks @backryun)
|
||||
|
||||
#### 2026-05-19
|
||||
|
||||
- **chore(i18n):** comprehensive dashboard i18n coverage — 6 rounds of parallel refactoring replacing hardcoded English/Portuguese text with `t()` calls across 57+ dashboard pages; 420+ new keys added to `en.json` covering `settings`, `playground`, `analytics`, `apiManager`, `providers`, `skills`, `memory`, `agents`, and 15 other namespaces (coverage: ~88%, up from ~20%).
|
||||
- **fix(offline):** avoid SSR/CSR hydration mismatch on the offline status page — switches from a `useState` lazy initializer (which accessed `navigator.onLine` on the server) to `useSyncExternalStore` with a distinct `false` server snapshot, eliminating the React hydration warning.
|
||||
- **fix(cli-tools):** guard `modelId` type before calling `.indexOf()` — prevents a `TypeError` when a model entry without a string `id` reaches the comparison logic in CLI Tools.
|
||||
- **fix(providers):** add missing `isLocalProvider` import and update changelog.
|
||||
- **fix(resilience):** add API Key health tracking with automatic rotation and UI toast alerts. ([#2412](https://github.com/diegosouzapw/OmniRoute/pull/2412) — thanks @clousky2020)
|
||||
- **feat(providers):** support Gemini API keys for Gemini CLI executor. ([#2408](https://github.com/diegosouzapw/OmniRoute/pull/2408) — thanks @benzntech)
|
||||
- **feat(gamification):** implement Gamification & Leaderboard System with non-blocking event-driven updates. ([#2405](https://github.com/diegosouzapw/OmniRoute/pull/2405) — thanks @oyi77)
|
||||
- **fix(providers):** Kilo Code provider no longer blocks on a missing local `kilocode` CLI binary — the provider uses OAuth device flow + direct HTTPS to `api.kilo.ai` and never required the CLI at runtime; the connection test was hard-failing with "Local CLI runtime is not installed" even when the OAuth token was valid. CLI Tools integration (`/api/cli-tools/kilo-settings`) keeps its own runtime check. ([#2404](https://github.com/diegosouzapw/OmniRoute/issues/2404) — thanks @Flexible78)
|
||||
- **fix(db):** `bun add -g omniroute` (and other runtimes that skip postinstall) no longer surfaces a generic 500 — `isNativeSqliteLoadError` now also detects "Could not locate the bindings file" / `MODULE_NOT_FOUND`, so the user gets the friendly rebuild guide instead. ([#2358](https://github.com/diegosouzapw/OmniRoute/issues/2358) — thanks @yamansin)
|
||||
- **fix(kiro):** enable Google OAuth login option in the Kiro auth modal — surfaces the Google SSO button alongside the existing identity providers. ([#2392](https://github.com/diegosouzapw/OmniRoute/pull/2392) — thanks @congvc-dev)
|
||||
- **fix(security):** drop hashing layer in `sessionPoolKey` after switching to a non-cryptographic key derivation strategy that clears CodeQL alert #247. ([#2396](https://github.com/diegosouzapw/OmniRoute/pull/2396))
|
||||
- **feat(providers):** Gemini Web cookie-based provider — proxies google.com chat through a session cookie, allowing free Gemini access without API keys. ([#2380](https://github.com/diegosouzapw/OmniRoute/pull/2380) — thanks @oyi77)
|
||||
- **model:** add Composer 2.5 to the Cursor provider catalog. ([#2381](https://github.com/diegosouzapw/OmniRoute/pull/2381) — thanks @backryun)
|
||||
- **fix:** `tool_use` without adjacent `tool_result` causes Claude 400 — adjacency guard now also applies inside `compressContext`. ([#2383](https://github.com/diegosouzapw/OmniRoute/pull/2383) — thanks @oyi77)
|
||||
- **build(deps):** bump `electron` from 42.0.1 to 42.1.0 in `/electron`. ([#2397](https://github.com/diegosouzapw/OmniRoute/pull/2397))
|
||||
- **build(deps):** production group bumps — 4 updates. ([#2398](https://github.com/diegosouzapw/OmniRoute/pull/2398))
|
||||
- **build(deps):** development group bumps — 4 updates. ([#2399](https://github.com/diegosouzapw/OmniRoute/pull/2399))
|
||||
- **chore:** sync `release/v3.8.0` with `main` (CodeQL hotfixes + Dependabot bumps) via merge commit.
|
||||
|
||||
#### 2026-05-18
|
||||
|
||||
- **fix(security):** resolve CodeQL alerts #243/#244/#245 — incomplete URL substring sanitization and weak crypto signal hardening. ([#2391](https://github.com/diegosouzapw/OmniRoute/pull/2391))
|
||||
- **fix(security):** switch `sessionPoolKey` derivation to HMAC-SHA256 to clear CodeQL alert #246 (insecure hash for sensitive data). ([#2394](https://github.com/diegosouzapw/OmniRoute/pull/2394))
|
||||
- **docs(readme):** restore the 9router acknowledgment that was inadvertently dropped during the v3.8.0 README rework. ([#2393](https://github.com/diegosouzapw/OmniRoute/pull/2393))
|
||||
- **refactor(dashboard):** comprehensive nav, providers, endpoint, runtime, quota, pricing, budget redesign + quota sharing preview (sidebar restructure → 12 collapsible sections, 22 new routes). ([#2384](https://github.com/diegosouzapw/OmniRoute/pull/2384))
|
||||
- **fix(dashboard):** PR #2384 follow-up review fixes — Runtime quota i18n, Budget projection logic, QuotaShare i18n externalization, Provider Limits semantic markup, bulk endpoint usage. ([#2389](https://github.com/diegosouzapw/OmniRoute/pull/2389))
|
||||
- **feat(content):** add Haiper, Leonardo, Ideogram, Suno, and Udio as content/media providers. ([#2377](https://github.com/diegosouzapw/OmniRoute/pull/2377) — thanks @oyi77)
|
||||
- **feat(@omniroute/opencode-provider):** expand config helpers, add MCP entry, live model fetch, and combo builder. ([#2375](https://github.com/diegosouzapw/OmniRoute/pull/2375) — thanks @mrmm)
|
||||
- **fix(claude-oauth):** enable system-transforms pipeline for the native Claude executor (closes 400 billing-gate). ([#2370](https://github.com/diegosouzapw/OmniRoute/pull/2370) — thanks @thepigdestroyer)
|
||||
- **feat(content):** extend providers with video, audio, TTS, music capabilities — Pollinations, MiniMax, Together, Replicate across Audio TTS and Transcription registries. ([#2369](https://github.com/diegosouzapw/OmniRoute/pull/2369) — thanks @oyi77)
|
||||
- **feat(providers):** add Veo AI Free as a web-wrapper provider for generating video, image, and TTS without an API key. ([#2366](https://github.com/diegosouzapw/OmniRoute/pull/2366) — thanks @oyi77)
|
||||
- **feat(providers):** add Replicate as a free provider for OpenAI-compatible inference with community models. ([#2364](https://github.com/diegosouzapw/OmniRoute/pull/2364) — thanks @oyi77)
|
||||
- **fix(claude):** avoided redundant deep cloning of Claude Code messages during semantic passthrough preparation, improving memory/CPU efficiency for large histories. ([#2362](https://github.com/diegosouzapw/OmniRoute/pull/2362) — thanks @terence71-glitch)
|
||||
- **fix(providers):** register `llm7` in the executor registry and route Cohere via OpenAI-compatible layer. ([#2361](https://github.com/diegosouzapw/OmniRoute/pull/2361), [#2360](https://github.com/diegosouzapw/OmniRoute/pull/2360))
|
||||
- **fix(rate-limiter):** Redis is now opt-in — when `REDIS_URL` is unset, the rate limiter falls back to the in-memory store instead of spamming `ECONNREFUSED`. ([#2357](https://github.com/diegosouzapw/OmniRoute/pull/2357))
|
||||
- **fix(streaming):** emit protocol-aware stream errors — `createDisconnectAwareStream()` now emits native Responses API or Claude API SSE error blocks based on the client protocol. ([#2355](https://github.com/diegosouzapw/OmniRoute/pull/2355) — thanks @dhaern)
|
||||
- **fix(combos):** allow bracketed combo names (e.g. `Claude [1m]`) by updating validation schemas. ([#2354](https://github.com/diegosouzapw/OmniRoute/pull/2354) — thanks @congvc-dev)
|
||||
- **fix(claude-code):** semantic passthrough — preserve Claude Code `messages[]` structure for native Claude OAuth and relay routes. ([#2351](https://github.com/diegosouzapw/OmniRoute/pull/2351) — thanks @terence71-glitch)
|
||||
- **fix(usage):** extract flat `cached_tokens` and `reasoning_tokens` from OpenAI-compatible usage objects. ([#2350](https://github.com/diegosouzapw/OmniRoute/pull/2350) — thanks @TF0rd)
|
||||
- **fix(translator):** DeepSeek tool-call response lookup reads cached reasoning before falling back to empty string. ([#2349](https://github.com/diegosouzapw/OmniRoute/pull/2349) — thanks @herjarsa)
|
||||
- **fix(ui/tooltip):** render in portal + clamp to viewport so tooltips aren't clipped in modal dialogs. ([#2352](https://github.com/diegosouzapw/OmniRoute/pull/2352) — thanks @slider23)
|
||||
- **fix(auto-routing):** replace bare `getSettings()` with `getCachedSettings` to stop 500 on `auto/*` requests. ([#2346](https://github.com/diegosouzapw/OmniRoute/pull/2346))
|
||||
- **fix(docker):** ship Dashboard Docs markdown in the container image. ([#2348](https://github.com/diegosouzapw/OmniRoute/pull/2348))
|
||||
- **fix(combo/validator):** treat upstream responses carrying a non-empty `reasoning_content` as valid output. ([#2341](https://github.com/diegosouzapw/OmniRoute/pull/2341))
|
||||
- **fix(account-fallback):** classify Anthropic `Usage Limit Reached` as `QUOTA_EXHAUSTED` with a 1h cooldown. ([#2321](https://github.com/diegosouzapw/OmniRoute/pull/2321))
|
||||
- **feat(providers):** add GitHub Models as a free provider — GPT-5, o-series, DeepSeek-R1, Llama 4, Grok 3. ([#2344](https://github.com/diegosouzapw/OmniRoute/pull/2344) — thanks @oyi77)
|
||||
- **feat(providers):** add Hackclub AI as a free provider — 30+ models, no credit card required. ([#2339](https://github.com/diegosouzapw/OmniRoute/pull/2339) — thanks @oyi77)
|
||||
- **feat(providers):** add Microsoft Copilot Web executor — WebSocket-based provider. ([#2340](https://github.com/diegosouzapw/OmniRoute/pull/2340) — thanks @oyi77)
|
||||
- **feat(routing):** LKGP stores last known good account `connectionId` alongside provider. ([#2338](https://github.com/diegosouzapw/OmniRoute/pull/2338) — thanks @oyi77)
|
||||
- **feat(dashboard):** add Claude Code auth import/export UI + i18n (three-PR series: libs, API routes, dashboard UI).
|
||||
- **feat(dashboard):** add Gemini CLI auth import/export UI + i18n (three-PR series: libs, API routes, dashboard UI).
|
||||
- **fix(routing):** implement embedding combos, local provider validation bypass, and resolve migration collisions.
|
||||
- **fix(build):** import Monaco ESM API to fix webpack `nls.messages-loader` error.
|
||||
- **fix(ui):** v3.8.0 polish — connections border, sticky tabs, EN translations, save toasts, auto-combo catalog. ([#2305](https://github.com/diegosouzapw/OmniRoute/pull/2305) — thanks @mrmm)
|
||||
- **fix(auth+build):** Bearer manage scope on management routes + lazy-load deepseek PoW solver. ([#2308](https://github.com/diegosouzapw/OmniRoute/pull/2308) — thanks @mrmm)
|
||||
- **fix(claude):** guard orphan tool_use/tool_result pairs before upstream send. ([#2312](https://github.com/diegosouzapw/OmniRoute/pull/2312) — thanks @mrmm)
|
||||
- **fix(ui):** remove count from batch removal button. ([#2309](https://github.com/diegosouzapw/OmniRoute/pull/2309) — thanks @hartmark)
|
||||
- **fix:** remove implicit API key request caps — removes default 1K/5K/20K rate caps. ([#2289](https://github.com/diegosouzapw/OmniRoute/pull/2289) — thanks @josephvoxone)
|
||||
- **fix(sse):** strip stale `Content-Encoding`, `Content-Length`, and `Transfer-Encoding` headers on non-streaming forward. ([#2264](https://github.com/diegosouzapw/OmniRoute/pull/2264) — thanks @gleber)
|
||||
- **chore(providers):** refresh provider model metadata and ordering. ([#2318](https://github.com/diegosouzapw/OmniRoute/pull/2318) — thanks @backryun)
|
||||
- **chore(providers):** consolidate Alibaba provider entries. ([#2319](https://github.com/diegosouzapw/OmniRoute/pull/2319) — thanks @backryun)
|
||||
- **fix(streaming):** harden stream readiness detection. ([#2317](https://github.com/diegosouzapw/OmniRoute/pull/2317) — thanks @dhaern)
|
||||
- **fix(v1/messages):** default to non-streaming when `stream` field is absent for Anthropic format. ([#2326](https://github.com/diegosouzapw/OmniRoute/pull/2326) — thanks @thepigdestroyer)
|
||||
- **fix(claude):** `fitThinkingToMaxTokens` caps thinking budget to model's output ceiling. ([#2327](https://github.com/diegosouzapw/OmniRoute/pull/2327) — thanks @thepigdestroyer)
|
||||
- **fix(codex):** Codex reasoning priority resolves `modelEffort` before `explicitReasoning`. ([#2335](https://github.com/diegosouzapw/OmniRoute/pull/2335) — thanks @terence71-glitch)
|
||||
- **fix(providers):** providers page no longer deadlocks when no providers are configured. ([#2329](https://github.com/diegosouzapw/OmniRoute/pull/2329) — thanks @slider23)
|
||||
- **chore(providers):** update HuggingFace to use the new `/v1/` router endpoint. ([#2322](https://github.com/diegosouzapw/OmniRoute/pull/2322) — thanks @backryun)
|
||||
- **fix(security):** resolve CodeQL ReDoS + URL sanitization alerts.
|
||||
- **fix(auth):** stop retrying unrecoverable token refresh failures and include connection id in token health check credentials.
|
||||
- **fix(auth):** return synthetic credentials for noAuth free providers and show no-auth card in dashboard instead of OAuth modal.
|
||||
- **fix(endpoint):** replace nested `<button>` with `<div role=button>` in tunnel toggle rows to fix hydration warnings.
|
||||
- **fix(migrations):** resolve version collision at migration slot 056 and add batch deletion API. ([#2294](https://github.com/diegosouzapw/OmniRoute/pull/2294) — thanks @hartmark)
|
||||
- **feat(batch):** global rate-limit header cache with 60s TTL + 24h retry window. ([#2299](https://github.com/diegosouzapw/OmniRoute/pull/2299) — thanks @hartmark)
|
||||
- **feat(cc-bridge):** config-driven per-provider system-block transform DSL. ([#2286](https://github.com/diegosouzapw/OmniRoute/pull/2286), closes #2260 — thanks @mrmm)
|
||||
- **feat(deepseek-web):** full DeepSeek web API executor with Keccak PoW solver. ([#2295](https://github.com/diegosouzapw/OmniRoute/pull/2295) — thanks @oyi77)
|
||||
- **feat(i18n):** add Azerbaijani (az / 🇦🇿) language support — new locale in `config/i18n.json`, 42 total supported languages.
|
||||
- **build(deps):** bump `actions/checkout` from 4 to 6 in CI workflows. ([#2288](https://github.com/diegosouzapw/OmniRoute/pull/2288))
|
||||
|
||||
#### 2026-05-17
|
||||
|
||||
- **fix(codex):** bulk import Codex `auth.json` — multi-file upload, paste-from-clipboard, and ZIP archive support. ([#2343](https://github.com/diegosouzapw/OmniRoute/pull/2343))
|
||||
- **feat(codex):** import single Codex `auth.json` as an OAuth connection (one-click migration from Codex Desktop). ([#2336](https://github.com/diegosouzapw/OmniRoute/pull/2336))
|
||||
- **feat(codex-auth):** rename `export` action + gate "Apply Local" behind a confirmation modal to prevent accidental local config overwrite. ([#2332](https://github.com/diegosouzapw/OmniRoute/pull/2332))
|
||||
- **fix(providers):** providers page empty-state — missing i18n keys and "Add Provider" CTA so first-time users can add a provider. ([#2333](https://github.com/diegosouzapw/OmniRoute/pull/2333), [#2337](https://github.com/diegosouzapw/OmniRoute/pull/2337))
|
||||
- **fix(providers):** Fix Providers empty state blocking first provider setup. (thanks @slider23)
|
||||
- **feat(providers):** bulk add API keys with Single/Bulk tabs.
|
||||
- **feat(ui):** comprehensive dashboard UX rework including simple/advanced modes for RTK/Caveman, human-readable error badges, InfoTooltip/PresetSlider shared components, sidebar subtitles, and provider category filters. ([#2315](https://github.com/diegosouzapw/OmniRoute/pull/2315), [#2316](https://github.com/diegosouzapw/OmniRoute/pull/2316) — thanks @oyi77)
|
||||
- **feat(provider):** add Gitlawb Opengateway provider (xiaomi-mimo + gmi-cloud) with hasFree flag support. ([#2314](https://github.com/diegosouzapw/OmniRoute/pull/2314) — thanks @oyi77)
|
||||
- **feat(i18n):** add simple/advanced mode keys and missing provider filter keys (`allProviders`, `audioProviders`, `showFreeOnly`).
|
||||
|
||||
#### 2026-05-16
|
||||
|
||||
- **feat(deepseek-web):** full DeepSeek web API executor with PoW solver — also landed via PR #2295. (thanks @oyi77)
|
||||
- **feat(batch):** global rate-limit header cache with 60s TTL — also via #2299.
|
||||
- **feat(cc-bridge):** config-driven per-provider system-block transform DSL — also via #2286.
|
||||
- **feat(dashboard):** provider summary card, free test button, sidebar order, i18n fix.
|
||||
- **feat(dashboard):** A2A audit page, stats bar on MCP audit, sidebar deduplication.
|
||||
- **feat(skills):** add 5 CLI skill manifests + AgentSkills / OmniSkills dashboard pages. ([#2284](https://github.com/diegosouzapw/OmniRoute/pull/2284))
|
||||
- **fix(translator):** map `developer` → `system` by default for non-OpenAI-family providers. ([#2281](https://github.com/diegosouzapw/OmniRoute/pull/2281))
|
||||
- **fix(api/combos):** add API-key-safe `GET /v1/combos` endpoint. ([#2300](https://github.com/diegosouzapw/OmniRoute/pull/2300))
|
||||
- **fix(embeddings/registry):** add DeepInfra to the embedding provider registry. ([#2298](https://github.com/diegosouzapw/OmniRoute/pull/2298))
|
||||
- **fix(opencode-zen):** flag `qwen3.6-plus` and `qwen3.6-plus-free` with `targetFormat: "claude"`. ([#2292](https://github.com/diegosouzapw/OmniRoute/pull/2292))
|
||||
- **fix(settings):** default `debugMode` to `true` on fresh installations.
|
||||
- **fix(sse):** remove dead-code flag leak in `claudeCodeToolRemapper`. ([#2290](https://github.com/diegosouzapw/OmniRoute/pull/2290) — thanks @thepigdestroyer)
|
||||
- **fix(sse):** strip stale `Content-Encoding`, `Content-Length`, `Transfer-Encoding` from upstream responses. ([#2291](https://github.com/diegosouzapw/OmniRoute/pull/2291) — thanks @thepigdestroyer)
|
||||
- **fix(migrations):** resolve version collisions and add schema repair for quota thresholds.
|
||||
|
||||
#### 2026-05-15
|
||||
|
||||
- **feat(cli):** CLI v4 — Commander.js architecture, 50+ commands, interactive TUI, full i18n (42 locales), plugin system (Fases 0–9). ([#2280](https://github.com/diegosouzapw/OmniRoute/pull/2280))
|
||||
- **feat(skills):** publish 3 operational SKILL.md manifests + AI Skills dashboard entry. ([#2276](https://github.com/diegosouzapw/OmniRoute/pull/2276))
|
||||
- **feat(termux):** Android/Termux headless support — auto-detect Android platform for headless mode. ([#2273](https://github.com/diegosouzapw/OmniRoute/pull/2273) — thanks @t-way666)
|
||||
- **feat(limits):** per-window quota cutoffs across all providers with usage data. ([#2267](https://github.com/diegosouzapw/OmniRoute/pull/2267) — thanks @payne0420)
|
||||
- **feat(api-keys):** configurable default rate limits via `DEFAULT_RATE_LIMIT_PER_DAY` env var. ([#2266](https://github.com/diegosouzapw/OmniRoute/pull/2266) — thanks @gleber)
|
||||
- **feat(authz):** `managementPolicy` accepts API keys with `manage` scope. ([#2265](https://github.com/diegosouzapw/OmniRoute/pull/2265) — thanks @gleber)
|
||||
- **feat(mcp):** MCP accessibility-tree smart filter engine — collapses ≥30 repeated sibling lines, 60-80% token savings.
|
||||
- **feat(auth):** CLI machine-ID HMAC-SHA256 token for zero-friction local auth without JWT/password.
|
||||
- **feat(security):** route protection tiers — 5 tiers: public/read-only/protected/always/local-only.
|
||||
- **feat(compression):** Caveman `SHARED_BOUNDARIES` — all 6 languages × 3 intensities embed boundary clause.
|
||||
- **feat(runtime):** dynamic SQLite 5-step fallback chain — bundled → runtime-installed → lazy-install → node:sqlite → sql.js.
|
||||
- **feat(cli):** standalone system tray with PowerShell fallback on Windows (`omniroute --tray`).
|
||||
- **fix(providers/command-code):** send required `skills` and `stream` payload fields. ([#2271](https://github.com/diegosouzapw/OmniRoute/pull/2271) — thanks @ddarkr)
|
||||
- **chore:** ignore `.playwright-mcp/` generated artifacts. ([#2269](https://github.com/diegosouzapw/OmniRoute/pull/2269) — thanks @backryun)
|
||||
- **chore:** tidy up deprecated models from Windsurf provider registry. ([#2279](https://github.com/diegosouzapw/OmniRoute/pull/2279) — thanks @backryun)
|
||||
- **chore(deps):** node dependency updates. ([#2259](https://github.com/diegosouzapw/OmniRoute/pull/2259) — thanks @backryun)
|
||||
- **build(deps):** bump `mermaid` from 11.14.0 to 11.15.0. ([#2178](https://github.com/diegosouzapw/OmniRoute/pull/2178))
|
||||
|
||||
#### 2026-05-08 a 2026-05-14
|
||||
|
||||
- **feat(guardrails/vision-bridge):** add `VISION_BRIDGE_BASE_URL` + `VISION_BRIDGE_API_KEY` env overrides for non-Anthropic vision-bridge routing. ([#2232](https://github.com/diegosouzapw/OmniRoute/pull/2232))
|
||||
- **feat(claude-web):** implement session-based Claude Web executor with auto-refresh authentication. ([#2283](https://github.com/diegosouzapw/OmniRoute/pull/2283) — thanks @oyi77)
|
||||
- **refactor(@omniroute/opencode-provider):** complete rewrite of the npm helper — tsup build (CJS + ESM + `.d.ts`), schema-correct output, `baseURL` deduplication, input validation, 13 unit tests. Versioned as `0.1.0`.
|
||||
- **BREAKING:** dropped Node 20.x support. Minimum Node version is now 22.22.2 (or 24.0.0+).
|
||||
- **fix(auth):** accept `x-api-key` header in `extractApiKey` so Anthropic-native clients hit the same per-key policy enforcement. ([#2225](https://github.com/diegosouzapw/OmniRoute/pull/2225))
|
||||
- **fix(translator/claude-to-openai):** stop including `cache_creation_input_tokens` in `prompt_tokens`. ([#2215](https://github.com/diegosouzapw/OmniRoute/pull/2215))
|
||||
- **fix(kiro):** harden OpenAI-to-Kiro translator for API compliance. ([#2251](https://github.com/diegosouzapw/OmniRoute/pull/2251) — thanks @8mbe)
|
||||
- **fix(models):** sync managed model aliases with provider model visibility. ([#2250](https://github.com/diegosouzapw/OmniRoute/pull/2250) — thanks @InkshadeWoods)
|
||||
- **fix(models/cleanup):** align managed model cleanup for imported models. ([#2261](https://github.com/diegosouzapw/OmniRoute/pull/2261) — thanks @InkshadeWoods)
|
||||
- **fix(executor/claude-code):** store tool-name round-trip metadata in non-enumerable `_toolNameMap`. ([#2254](https://github.com/diegosouzapw/OmniRoute/pull/2254) — thanks @Rikonorus)
|
||||
- **fix(streaming):** strip upstream `Content-Encoding`, `Content-Length`, `Transfer-Encoding` headers from SSE responses. ([#2253](https://github.com/diegosouzapw/OmniRoute/pull/2253) — thanks @Rikonorus)
|
||||
- **fix(security):** remediate CodeQL vulnerabilities (ReDoS, cryptographic bias, stack trace exposure, weak password hashing). ([#216](https://github.com/diegosouzapw/OmniRoute/issues/216), [#215](https://github.com/diegosouzapw/OmniRoute/issues/215), [#211](https://github.com/diegosouzapw/OmniRoute/issues/211), [#208](https://github.com/diegosouzapw/OmniRoute/issues/208), [#206](https://github.com/diegosouzapw/OmniRoute/issues/206), [#210](https://github.com/diegosouzapw/OmniRoute/issues/210))
|
||||
- **fix(providers/blackbox-web):** add `BLACKBOX_WEB_VALIDATED_TOKEN` env override and 403 token-error disambiguation. ([#2252](https://github.com/diegosouzapw/OmniRoute/pull/2252))
|
||||
- **fix(auth):** `REQUIRE_API_KEY=false` invalid Bearer no longer 401s the whole request. ([#2257](https://github.com/diegosouzapw/OmniRoute/pull/2257))
|
||||
- **feat(resilience):** add model cooldowns dashboard card with real-time list, individual/bulk re-enable, and auto-refresh.
|
||||
- **feat(resilience):** `useUpstream429BreakerHints` toggle. ([#2133](https://github.com/diegosouzapw/OmniRoute/pull/2133) — thanks @eleata)
|
||||
- **feat(auto):** zero-config auto-routing with `auto/` prefix — dynamic virtual combo from connected providers with 6 variant profiles. ([#2131](https://github.com/diegosouzapw/OmniRoute/pull/2131) — thanks @oyi77)
|
||||
- **feat(kiro):** headless auth via kiro-cli SQLite, image support, tool overflow handling, model list sync. ([#2129](https://github.com/diegosouzapw/OmniRoute/pull/2129) — thanks @christlau)
|
||||
- **feat(cursor):** surface Cursor Pro plan usage on provider-limits dashboard. ([#2128](https://github.com/diegosouzapw/OmniRoute/pull/2128) — thanks @payne0420)
|
||||
- **feat(mitm):** dynamic Linux certificate path detection for multi-distro MITM cert trust. ([#2134](https://github.com/diegosouzapw/OmniRoute/pull/2134) — thanks @flyingmongoose)
|
||||
- **feat(1proxy):** add dedicated settings tab with proxy rotation support. ([#2135](https://github.com/diegosouzapw/OmniRoute/pull/2135) — thanks @oyi77)
|
||||
- **feat(responses):** degrade `background: true` to synchronous execution with a warning. ([#2164](https://github.com/diegosouzapw/OmniRoute/pull/2164) — thanks @Yosee11)
|
||||
- **feat(api):** aggregate combo model metadata in catalog endpoint. ([#2166](https://github.com/diegosouzapw/OmniRoute/pull/2166) — thanks @faisalill)
|
||||
- **feat(oauth):** complete Windsurf and Devin CLI OAuth + API-token flows. ([#2168](https://github.com/diegosouzapw/OmniRoute/pull/2168) — thanks @Zhaba1337228)
|
||||
- **feat(antigravity):** support custom Google Cloud project ID. ([#2227](https://github.com/diegosouzapw/OmniRoute/pull/2227) — thanks @nickwizard)
|
||||
- **feat(cli):** CLI Integration Suite — 5 new management commands, 3 API endpoints, config generators for 6 tools. ([#2240](https://github.com/diegosouzapw/OmniRoute/pull/2240) — thanks @oyi77)
|
||||
- **fix(sanitizer):** preserve `reasoning_content` on assistant messages with `tool_calls`. ([#2140](https://github.com/diegosouzapw/OmniRoute/pull/2140) — thanks @DavyMassoneto)
|
||||
- **fix(catalog):** ensure individual models expose `context_length` via `getTokenLimit()` fallback chain. ([#2136](https://github.com/diegosouzapw/OmniRoute/pull/2136) — thanks @herjarsa)
|
||||
- **fix(docker):** remove docs directory from `.dockerignore`. ([#2137](https://github.com/diegosouzapw/OmniRoute/pull/2137), [#2120](https://github.com/diegosouzapw/OmniRoute/pull/2120) — thanks @hartmark)
|
||||
- **fix(providers):** restore cloud agent provider exports and logger import. ([#2138](https://github.com/diegosouzapw/OmniRoute/pull/2138) — thanks @backryun)
|
||||
- **fix(providers):** remove duplicate `CLOUD_AGENT_PROVIDERS` declaration. ([#2141](https://github.com/diegosouzapw/OmniRoute/pull/2141) — thanks @backryun)
|
||||
- **fix(translator):** preserve `body.system` in openai→claude when Claude Code sends native format. ([#2130](https://github.com/diegosouzapw/OmniRoute/pull/2130))
|
||||
- **fix(authz):** classify `/dashboard/onboarding` as PUBLIC to unblock setup wizard. ([#2127](https://github.com/diegosouzapw/OmniRoute/pull/2127))
|
||||
- **fix(i18n):** complete Simplified Chinese translations. ([#2115](https://github.com/diegosouzapw/OmniRoute/pull/2115) — thanks @boa-z)
|
||||
- **fix(sse):** classify hour quota errors as QUOTA_EXHAUSTED. ([#2119](https://github.com/diegosouzapw/OmniRoute/pull/2119) — thanks @clousky2020)
|
||||
- **fix(sse):** fix CC-compatible streaming bridge. ([#2118](https://github.com/diegosouzapw/OmniRoute/pull/2118) — thanks @rdself)
|
||||
- **fix(cliproxyapi):** detect Anthropic-shaped request bodies and route to `/v1/messages`. ([#2165](https://github.com/diegosouzapw/OmniRoute/pull/2165) — thanks @Brkic-Nikola)
|
||||
- **fix(claudeHelper):** preserve latest assistant thinking blocks verbatim. ([#2224](https://github.com/diegosouzapw/OmniRoute/pull/2224) — thanks @NomenAK)
|
||||
- **fix(deepseek):** preserve `reasoning_content` through full pipeline for DeepSeek V4 models. ([#2231](https://github.com/diegosouzapw/OmniRoute/pull/2231) — thanks @kang-heewon)
|
||||
- **fix(chatcore):** stop leaking provider credentials in response headers.
|
||||
- **fix(export):** exclude telemetry/usage-history tables from JSON config backups by default. ([#2125](https://github.com/diegosouzapw/OmniRoute/pull/2125))
|
||||
- **build(deps):** regenerate `package-lock.json` to match `http-proxy-middleware` 4.x bump. ([#2228](https://github.com/diegosouzapw/OmniRoute/pull/2228) — thanks @NomenAK)
|
||||
|
||||
#### 2026-05-06 a 2026-05-07 (lançamento inicial v3.8.0)
|
||||
|
||||
- **feat(zed):** Zed IDE Docker support — when OmniRoute runs in Docker and Zed is on the host, the Import flow now returns a 422 with `zedDockerEnvironment: true` and the dashboard auto-expands a Manual Token Import panel (new `POST /api/providers/zed/manual-import` endpoint with Zod validation). Includes Docker detection utility (`/.dockerenv` + cgroup heuristics) and a setup guide at [`docs/providers/ZED-DOCKER.md`](docs/providers/ZED-DOCKER.md). ([#2306])
|
||||
- **feat(workflow):** `/implement-features` gains pre-flight triage script (`scripts/features/feature-triage.mjs`) classifying open feature requests into 8 buckets — fresh issues (<14d) stay dormant to give the community time to react, engagement override (≥5 👍 or ≥3 unique non-bot commenters) absorbs early, already-delivered detection via merged PRs + CHANGELOG + git log closes issues with version + PR reference, stale `need_details/` (>30d) is closed politely, aged `defer/` (>90d) is re-evaluated, and externally-closed issues clean up `_ideia/` automatically. Idea files now carry a YAML frontmatter snapshot enabling incremental comment re-sync. 53 unit tests cover the new logic.
|
||||
@@ -115,38 +309,6 @@
|
||||
- **chore:** narrow `.claude/` gitignore to runtime files only and untrack `scheduled_tasks.lock`.
|
||||
- **Docs:** 270 broken internal markdown links repaired.
|
||||
|
||||
### Post-release hotfixes (2026-05-18 → 2026-05-19)
|
||||
|
||||
- **fix(kiro):** enable Google OAuth login option in the Kiro auth modal — surfaces the Google SSO button alongside the existing identity providers. ([#2392](https://github.com/diegosouzapw/OmniRoute/pull/2392) — thanks @congvc-dev)
|
||||
- **fix(security):** resolve CodeQL alerts #243/#244/#245 — incomplete URL substring sanitization and weak crypto signal hardening. ([#2391](https://github.com/diegosouzapw/OmniRoute/pull/2391))
|
||||
- **fix(security):** switch `sessionPoolKey` derivation to HMAC-SHA256 to clear CodeQL alert #246 (insecure hash for sensitive data). ([#2394](https://github.com/diegosouzapw/OmniRoute/pull/2394))
|
||||
- **fix(security):** drop hashing layer in `sessionPoolKey` after switching to a non-cryptographic key derivation strategy that clears CodeQL alert #247. ([#2396](https://github.com/diegosouzapw/OmniRoute/pull/2396))
|
||||
- **docs(readme):** restore the 9router acknowledgment that was inadvertently dropped during the v3.8.0 README rework. ([#2393](https://github.com/diegosouzapw/OmniRoute/pull/2393))
|
||||
- **refactor(dashboard):** comprehensive nav, providers, endpoint, runtime, quota, pricing, budget redesign + quota sharing preview (sidebar restructure → 12 collapsible sections, 22 new routes). ([#2384](https://github.com/diegosouzapw/OmniRoute/pull/2384))
|
||||
- **fix(dashboard):** PR #2384 follow-up review fixes — Runtime quota i18n, Budget projection logic, QuotaShare i18n externalization, Provider Limits semantic markup, bulk endpoint usage. ([#2389](https://github.com/diegosouzapw/OmniRoute/pull/2389))
|
||||
- **fix:** `tool_use` without adjacent `tool_result` causes Claude 400 — adjacency guard now also applies inside `compressContext`. ([#2383](https://github.com/diegosouzapw/OmniRoute/pull/2383) — thanks @oyi77)
|
||||
- **model:** add Composer 2.5 to the Cursor provider catalog. ([#2381](https://github.com/diegosouzapw/OmniRoute/pull/2381) — thanks @backryun)
|
||||
- **feat(providers):** Gemini Web cookie-based provider — proxies google.com chat through a session cookie, allowing free Gemini access without API keys. ([#2380](https://github.com/diegosouzapw/OmniRoute/pull/2380) — thanks @oyi77)
|
||||
- **feat(content):** add Haiper, Leonardo, Ideogram, Suno, and Udio as content/media providers. ([#2377](https://github.com/diegosouzapw/OmniRoute/pull/2377) — thanks @oyi77)
|
||||
- **feat(@omniroute/opencode-provider):** expand config helpers, add MCP entry, live model fetch, and combo builder. ([#2375](https://github.com/diegosouzapw/OmniRoute/pull/2375) — thanks @mrmm)
|
||||
- **fix(claude-oauth):** enable system-transforms pipeline for the native Claude executor (closes 400 billing-gate). ([#2370](https://github.com/diegosouzapw/OmniRoute/pull/2370) — thanks @thepigdestroyer)
|
||||
- **feat(codex):** bulk import Codex `auth.json` — multi-file upload, paste-from-clipboard, and ZIP archive support. ([#2343](https://github.com/diegosouzapw/OmniRoute/pull/2343))
|
||||
- **feat(codex):** import single Codex `auth.json` as an OAuth connection (one-click migration from Codex Desktop). ([#2336](https://github.com/diegosouzapw/OmniRoute/pull/2336))
|
||||
- **feat(codex-auth):** rename `export` action + gate "Apply Local" behind a confirmation modal to prevent accidental local config overwrite. ([#2332](https://github.com/diegosouzapw/OmniRoute/pull/2332))
|
||||
- **fix(providers):** providers page empty-state — missing i18n keys and "Add Provider" CTA so first-time users can add a provider. ([#2333](https://github.com/diegosouzapw/OmniRoute/pull/2333), [#2337](https://github.com/diegosouzapw/OmniRoute/pull/2337))
|
||||
- **feat(cli):** CLI v4 — Commander.js architecture, 50+ commands, interactive TUI, full i18n (42 locales), plugin system (Fases 0–9). ([#2280](https://github.com/diegosouzapw/OmniRoute/pull/2280))
|
||||
- **feat(skills):** publish 3 operational SKILL.md manifests + AI Skills dashboard entry — covers operator-facing workflows on top of the developer-facing manifests shipped in #2284. ([#2276](https://github.com/diegosouzapw/OmniRoute/pull/2276))
|
||||
- **build(deps):** bump `mermaid` from 11.14.0 to 11.15.0. ([#2178](https://github.com/diegosouzapw/OmniRoute/pull/2178))
|
||||
- **build(deps):** bump `electron` from 42.0.1 to 42.1.0 in `/electron`. ([#2397](https://github.com/diegosouzapw/OmniRoute/pull/2397))
|
||||
- **build(deps):** production group bumps — 4 updates. ([#2398](https://github.com/diegosouzapw/OmniRoute/pull/2398))
|
||||
- **build(deps):** development group bumps — 4 updates. ([#2399](https://github.com/diegosouzapw/OmniRoute/pull/2399))
|
||||
- **chore:** sync `release/v3.8.0` with `main` (CodeQL hotfixes + Dependabot bumps) via merge commit.
|
||||
- **fix(providers):** Kilo Code provider no longer blocks on a missing local `kilocode` CLI binary — the provider uses OAuth device flow + direct HTTPS to `api.kilo.ai` and never required the CLI at runtime; the connection test was hard-failing with "Local CLI runtime is not installed" even when the OAuth token was valid. CLI Tools integration (`/api/cli-tools/kilo-settings`) keeps its own runtime check. ([#2404](https://github.com/diegosouzapw/OmniRoute/issues/2404) — thanks @Flexible78)
|
||||
- **fix(db):** `bun add -g omniroute` (and other runtimes that skip postinstall) no longer surfaces a generic 500 — `isNativeSqliteLoadError` now also detects "Could not locate the bindings file" / `MODULE_NOT_FOUND`, so the user gets the friendly rebuild guide instead. ([#2358](https://github.com/diegosouzapw/OmniRoute/issues/2358) — thanks @yamansin)
|
||||
- **feat(gamification):** implement Gamification & Leaderboard System with non-blocking event-driven updates. ([#2405](https://github.com/diegosouzapw/OmniRoute/pull/2405))
|
||||
- **feat(providers):** support Gemini API keys for Gemini CLI executor. ([#2408](https://github.com/diegosouzapw/OmniRoute/pull/2408) — thanks @benzntech)
|
||||
- **fix(resilience):** add API Key health tracking with automatic rotation and UI toast alerts. ([#2412](https://github.com/diegosouzapw/OmniRoute/pull/2412) — thanks @clousky2020)
|
||||
|
||||
### 🏆 v3.8.0 Hall of Fame — extended credits (post-release)
|
||||
|
||||
The following contributions landed after the initial v3.8.0 cut and supplement the 55+ community hall of fame below. Updated tallies:
|
||||
@@ -177,9 +339,7 @@ Thanks also to **@app/dependabot** for keeping our dependency tree current via #
|
||||
|
||||
---
|
||||
|
||||
## [3.8.0] - 2026-05-15
|
||||
|
||||
### Added
|
||||
### Detalhes completos — features do release (2026-05-15)
|
||||
|
||||
- **feat(providers):** expanded capabilities for Pollinations, MiniMax, Together, and Replicate across Video, Audio TTS, and Transcription registries. ([#2369](https://github.com/diegosouzapw/OmniRoute/pull/2369) — thanks @oyi77)
|
||||
- **feat(providers):** added Veo AI Free as a web-wrapper provider for generating video, image, and TTS without an API key. ([#2366](https://github.com/diegosouzapw/OmniRoute/pull/2366) — thanks @oyi77)
|
||||
@@ -223,9 +383,9 @@ Thanks also to **@app/dependabot** for keeping our dependency tree current via #
|
||||
|
||||
- **chore(deps):** node dependency updates — bump multiple runtime and dev dependencies to latest patch/minor versions. ([#2259](https://github.com/diegosouzapw/OmniRoute/pull/2259) — thanks @backryun)
|
||||
|
||||
## [3.8.0] — 2026-05-06
|
||||
### Detalhes completos — features e fixes do lançamento (2026-05-06 a 2026-05-14)
|
||||
|
||||
### ✨ New Features
|
||||
#### ✨ New Features
|
||||
|
||||
- **feat(providers):** add Command Code provider (#2199 — thanks @ddarkr)
|
||||
- **feat(providers):** add ModelScope provider-specific 429 handling and retry logic (#2202 — thanks @InkshadeWoods)
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
|
||||
@@ -11,6 +12,7 @@ import { dirname, join, extname, basename } from "node:path";
|
||||
import { resolveDataDir } from "../data-dir.mjs";
|
||||
import { apiFetch, isServerUp } from "../api.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { backupSqliteFile } from "../sqlite.mjs";
|
||||
|
||||
function getBackupDir() {
|
||||
return join(resolveDataDir(), "backups");
|
||||
@@ -187,13 +189,6 @@ export async function runBackupCommand(opts = {}) {
|
||||
try {
|
||||
if (!existsSync(backupDir)) mkdirSync(backupDir, { recursive: true });
|
||||
|
||||
let Database;
|
||||
try {
|
||||
Database = (await import("better-sqlite3")).default;
|
||||
} catch {
|
||||
Database = null;
|
||||
}
|
||||
|
||||
let backedUp = 0;
|
||||
let skipped = 0;
|
||||
|
||||
@@ -207,14 +202,11 @@ export async function runBackupCommand(opts = {}) {
|
||||
const destName = opts.encrypt ? `${file.name}.enc` : file.name;
|
||||
const destPath = join(backupPath, destName);
|
||||
mkdirSync(dirname(destPath), { recursive: true });
|
||||
if (file.name.endsWith(".sqlite") && Database) {
|
||||
const db = new Database(sourcePath, { readonly: true });
|
||||
if (file.name.endsWith(".sqlite")) {
|
||||
const tmpPath = destPath.replace(/\.enc$/, "");
|
||||
await db.backup(tmpPath);
|
||||
db.close();
|
||||
await backupSqliteFile(sourcePath, tmpPath);
|
||||
if (opts.encrypt) {
|
||||
encryptFile(tmpPath, destPath, passphrase);
|
||||
const { unlinkSync } = await import("node:fs");
|
||||
unlinkSync(tmpPath);
|
||||
}
|
||||
} else if (opts.encrypt) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { pathToFileURL } from "node:url";
|
||||
import { resolveDataDir, resolveStoragePath } from "../data-dir.mjs";
|
||||
import { printHeading } from "../io.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { readDatabaseHealth, readEncryptedCredentialSamples } from "../sqlite.mjs";
|
||||
|
||||
const STATIC_SALT = "omniroute-field-encryption-v1";
|
||||
const KEY_LENGTH = 32;
|
||||
@@ -78,14 +79,6 @@ function checkConfig(dataDir) {
|
||||
return ok("Config", `.env found at ${envFile}`, { envFile });
|
||||
}
|
||||
|
||||
async function loadBetterSqlite() {
|
||||
try {
|
||||
return (await import("better-sqlite3")).default;
|
||||
} catch (error) {
|
||||
return { error };
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMigrationsDir(rootDir) {
|
||||
const configured = process.env.OMNIROUTE_MIGRATIONS_DIR;
|
||||
const candidates = [
|
||||
@@ -115,18 +108,9 @@ async function checkDatabase(dbPath, rootDir) {
|
||||
return warn("Database", `SQLite database not found at ${dbPath}`, { dbPath });
|
||||
}
|
||||
|
||||
const Database = await loadBetterSqlite();
|
||||
if (Database.error) {
|
||||
return fail("Database", "better-sqlite3 could not be loaded", {
|
||||
error: Database.error instanceof Error ? Database.error.message : String(Database.error),
|
||||
});
|
||||
}
|
||||
|
||||
let db;
|
||||
try {
|
||||
db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
const quickCheck = db.prepare("PRAGMA quick_check").get();
|
||||
const quickCheckValue = Object.values(quickCheck || {})[0];
|
||||
const { quickCheckValue, hasMigrationTable, appliedMigrationVersions } =
|
||||
await readDatabaseHealth(dbPath);
|
||||
if (quickCheckValue !== "ok") {
|
||||
return fail("Database", `SQLite quick_check failed: ${quickCheckValue}`, { dbPath });
|
||||
}
|
||||
@@ -137,18 +121,11 @@ async function checkDatabase(dbPath, rootDir) {
|
||||
return ok("Database", "SQLite quick_check passed", { dbPath, migrations: "not_checked" });
|
||||
}
|
||||
|
||||
const table = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get("_omniroute_migrations");
|
||||
if (!table) {
|
||||
if (!hasMigrationTable) {
|
||||
return warn("Database", "SQLite is readable, but migration table is missing", { dbPath });
|
||||
}
|
||||
|
||||
const appliedRows = db
|
||||
.prepare("SELECT version FROM _omniroute_migrations")
|
||||
.all()
|
||||
.map((row) => row.version);
|
||||
const applied = new Set(appliedRows);
|
||||
const applied = new Set(appliedMigrationVersions);
|
||||
const pending = migrationFiles.filter((migration) => !applied.has(migration.version));
|
||||
|
||||
if (pending.length > 0) {
|
||||
@@ -164,8 +141,6 @@ async function checkDatabase(dbPath, rootDir) {
|
||||
dbPath,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
if (db) db.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,42 +178,14 @@ async function checkStorageEncryption(dbPath) {
|
||||
: warn("Storage/encryption", "No STORAGE_ENCRYPTION_KEY configured; passthrough mode");
|
||||
}
|
||||
|
||||
const Database = await loadBetterSqlite();
|
||||
if (Database.error) {
|
||||
return fail("Storage/encryption", "Could not inspect encrypted credentials", {
|
||||
error: Database.error instanceof Error ? Database.error.message : String(Database.error),
|
||||
});
|
||||
}
|
||||
|
||||
let db;
|
||||
try {
|
||||
db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
const hasProviderTable = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get("provider_connections");
|
||||
const { hasProviderTable, encryptedValues } = await readEncryptedCredentialSamples(dbPath);
|
||||
if (!hasProviderTable) {
|
||||
return secret
|
||||
? ok("Storage/encryption", "Encryption key is configured; provider table not initialized")
|
||||
: warn("Storage/encryption", "No STORAGE_ENCRYPTION_KEY configured; passthrough mode");
|
||||
}
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT api_key, access_token, refresh_token, id_token
|
||||
FROM provider_connections
|
||||
WHERE api_key LIKE 'enc:v1:%'
|
||||
OR access_token LIKE 'enc:v1:%'
|
||||
OR refresh_token LIKE 'enc:v1:%'
|
||||
OR id_token LIKE 'enc:v1:%'
|
||||
LIMIT 20`
|
||||
)
|
||||
.all();
|
||||
const encryptedValues = rows.flatMap((row) =>
|
||||
["api_key", "access_token", "refresh_token", "id_token"]
|
||||
.filter((key) => typeof row[key] === "string" && row[key].startsWith("enc:v1:"))
|
||||
.map((key) => row[key])
|
||||
);
|
||||
|
||||
if (encryptedValues.length === 0) {
|
||||
return secret
|
||||
? ok("Storage/encryption", "Encryption key is configured; no encrypted samples found")
|
||||
@@ -268,8 +215,6 @@ async function checkStorageEncryption(dbPath) {
|
||||
return fail("Storage/encryption", "Encrypted credential check failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
if (db) db.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -344,7 +344,10 @@ export async function runProvidersRotateCommand(selector, opts = {}) {
|
||||
const readline = await import("node:readline");
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
newKey = await new Promise((resolve) =>
|
||||
rl.question(`New API key for ${connection.name}: `, (a) => { rl.close(); resolve(a.trim()); })
|
||||
rl.question(`New API key for ${connection.name}: `, (a) => {
|
||||
rl.close();
|
||||
resolve(a.trim());
|
||||
})
|
||||
);
|
||||
if (!newKey) {
|
||||
console.error("No key provided.");
|
||||
@@ -354,7 +357,9 @@ export async function runProvidersRotateCommand(selector, opts = {}) {
|
||||
|
||||
// --- Dry-run ---
|
||||
if (opts.dryRun) {
|
||||
console.log(t("providers.rotate.dryRunResult", { name: connection.name, id: connection.id.slice(0, 8) }));
|
||||
console.log(
|
||||
t("providers.rotate.dryRunResult", { name: connection.name, id: connection.id.slice(0, 8) })
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -363,7 +368,13 @@ export async function runProvidersRotateCommand(selector, opts = {}) {
|
||||
const readline = await import("node:readline");
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await new Promise((resolve) =>
|
||||
rl.question(t("providers.rotate.confirmPrompt", { name: connection.name, id: connection.id.slice(0, 8) }), resolve)
|
||||
rl.question(
|
||||
t("providers.rotate.confirmPrompt", {
|
||||
name: connection.name,
|
||||
id: connection.id.slice(0, 8),
|
||||
}),
|
||||
resolve
|
||||
)
|
||||
);
|
||||
rl.close();
|
||||
if (!/^y(es|s)?$/i.test(answer)) {
|
||||
@@ -378,7 +389,13 @@ export async function runProvidersRotateCommand(selector, opts = {}) {
|
||||
try {
|
||||
const res = await apiFetch(`/api/providers/${encodeURIComponent(connection.id)}`, {
|
||||
method: "PATCH",
|
||||
body: { apiKey: newKey, testStatus: "unknown", lastError: null, rateLimitedUntil: null, backoffLevel: 0 },
|
||||
body: {
|
||||
apiKey: newKey,
|
||||
testStatus: "unknown",
|
||||
lastError: null,
|
||||
rateLimitedUntil: null,
|
||||
backoffLevel: 0,
|
||||
},
|
||||
retry: false,
|
||||
acceptNotOk: true,
|
||||
});
|
||||
@@ -404,7 +421,9 @@ export async function runProvidersRotateCommand(selector, opts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(t("providers.rotate.success", { name: connection.name, id: connection.id.slice(0, 8) }));
|
||||
console.log(
|
||||
t("providers.rotate.success", { name: connection.name, id: connection.id.slice(0, 8) })
|
||||
);
|
||||
|
||||
// --- Post-rotation test ---
|
||||
if (!opts.skipTest) {
|
||||
@@ -468,8 +487,8 @@ export async function runProvidersStatusCommand(opts = {}) {
|
||||
const testColor = statusColor(testStatus);
|
||||
console.log(
|
||||
`${shortId.padEnd(10)} ${String(item.provider || "").padEnd(14)} ${String(item.name || "").padEnd(24)} ` +
|
||||
`${expiry.padEnd(12)} ${expiryColor}${expiryStatus.padEnd(8)}\x1b[0m ` +
|
||||
`${testColor}${testStatus.padEnd(12)}\x1b[0m ${cooldown}`
|
||||
`${expiry.padEnd(12)} ${expiryColor}${expiryStatus.padEnd(8)}\x1b[0m ` +
|
||||
`${testColor}${testStatus.padEnd(12)}\x1b[0m ${cooldown}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -543,7 +562,10 @@ export function registerProviders(program) {
|
||||
.option("--dry-run", t("providers.rotate.dryRunOpt"))
|
||||
.action(async (idOrName, opts, cmd) => {
|
||||
const globalOpts = cmd.parent.optsWithGlobals();
|
||||
const exitCode = await runProvidersRotateCommand(idOrName, { ...opts, output: globalOpts.output });
|
||||
const exitCode = await runProvidersRotateCommand(idOrName, {
|
||||
...opts,
|
||||
output: globalOpts.output,
|
||||
});
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolveDataDir } from "../data-dir.mjs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const ENCRYPTED_PATTERN = "enc:v1:%";
|
||||
const ENCRYPTED_COLUMNS = ["api_key", "access_token", "refresh_token", "id_token"];
|
||||
|
||||
/**
|
||||
* Direct SQLite implementation for reset-encrypted-columns.
|
||||
* Uses better-sqlite3 directly to avoid TypeScript source dependencies in production builds.
|
||||
*/
|
||||
export async function runResetEncryptedColumns(argv) {
|
||||
const dataDir = resolveDataDir();
|
||||
const dbPath = join(dataDir, "storage.sqlite");
|
||||
@@ -38,22 +41,37 @@ export async function runResetEncryptedColumns(argv) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let db;
|
||||
try {
|
||||
const { countEncryptedCredentials, resetEncryptedColumns } = await import(
|
||||
`${PROJECT_ROOT}/src/lib/db/recovery.ts`
|
||||
// Use createRequire to load better-sqlite3 (works in both dev and production)
|
||||
const { createRequire } = await import("node:module");
|
||||
const require = createRequire(import.meta.url);
|
||||
const Database = require("better-sqlite3");
|
||||
|
||||
db = new Database(dbPath);
|
||||
|
||||
// Build WHERE clause for encrypted values
|
||||
const whereClause = ENCRYPTED_COLUMNS.map((col) => `${col} LIKE '${ENCRYPTED_PATTERN}'`).join(
|
||||
" OR "
|
||||
);
|
||||
|
||||
const count = countEncryptedCredentials();
|
||||
// Count affected rows
|
||||
const countResult = db
|
||||
.prepare(`SELECT COUNT(*) AS cnt FROM provider_connections WHERE ${whereClause}`)
|
||||
.get();
|
||||
const count = countResult?.cnt ?? 0;
|
||||
|
||||
if (count === 0) {
|
||||
console.log("\x1b[32m✔ No encrypted credentials found — nothing to reset.\x1b[0m");
|
||||
return 0;
|
||||
}
|
||||
|
||||
const { affected } = resetEncryptedColumns({ dryRun: false });
|
||||
// Reset columns
|
||||
const nullCols = ENCRYPTED_COLUMNS.map((col) => `${col} = NULL`).join(", ");
|
||||
db.prepare(`UPDATE provider_connections SET ${nullCols} WHERE ${whereClause}`).run();
|
||||
|
||||
console.log(
|
||||
`\x1b[32m✔ Reset ${affected} provider connection(s).\x1b[0m\n` +
|
||||
`\x1b[32m✔ Reset ${count} provider connection(s).\x1b[0m\n` +
|
||||
` Re-authenticate your providers in the dashboard or re-add API keys.\n`
|
||||
);
|
||||
return 0;
|
||||
@@ -62,5 +80,9 @@ export async function runResetEncryptedColumns(argv) {
|
||||
`\x1b[31m✖ Failed to reset encrypted columns:\x1b[0m ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
return 1;
|
||||
} finally {
|
||||
if (db) {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,42 @@
|
||||
import fs from "node:fs";
|
||||
import { resolveDataDir, resolveStoragePath } from "./data-dir.mjs";
|
||||
import { ensureProviderSchema } from "./provider-store.mjs";
|
||||
import { ensureSettingsSchema } from "./settings-store.mjs";
|
||||
import { ensureSettingsSchema, hashManagementPassword, updateSettings } from "./settings-store.mjs";
|
||||
|
||||
async function loadBetterSqlite() {
|
||||
try {
|
||||
return (await import("better-sqlite3")).default;
|
||||
} catch {
|
||||
throw new Error("better-sqlite3 is not installed. Run npm install before using setup.");
|
||||
}
|
||||
}
|
||||
|
||||
function createSqliteNativeError(error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("NODE_MODULE_VERSION") || message.includes("ERR_DLOPEN_FAILED")) {
|
||||
return new Error(
|
||||
"better-sqlite3 native binding is incompatible with this Node.js runtime. " +
|
||||
"Run `npm rebuild better-sqlite3` in the OmniRoute project and try again."
|
||||
);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
async function openSqliteDatabase(dbPath, options = {}) {
|
||||
const Database = await loadBetterSqlite();
|
||||
try {
|
||||
return new Database(dbPath, options);
|
||||
} catch (error) {
|
||||
throw createSqliteNativeError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function openOmniRouteDb() {
|
||||
const dataDir = resolveDataDir();
|
||||
const dbPath = resolveStoragePath(dataDir);
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
|
||||
let Database;
|
||||
try {
|
||||
Database = (await import("better-sqlite3")).default;
|
||||
} catch {
|
||||
throw new Error("better-sqlite3 is not installed. Run npm install before using setup.");
|
||||
}
|
||||
|
||||
let db;
|
||||
try {
|
||||
db = new Database(dbPath);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("NODE_MODULE_VERSION") || message.includes("ERR_DLOPEN_FAILED")) {
|
||||
throw new Error(
|
||||
"better-sqlite3 native binding is incompatible with this Node.js runtime. " +
|
||||
"Run `npm rebuild better-sqlite3` in the OmniRoute project and try again."
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const db = await openSqliteDatabase(dbPath);
|
||||
|
||||
db.pragma("journal_mode = WAL");
|
||||
ensureSettingsSchema(db);
|
||||
@@ -35,3 +44,113 @@ export async function openOmniRouteDb() {
|
||||
|
||||
return { db, dataDir, dbPath };
|
||||
}
|
||||
|
||||
export async function withReadonlySqlite(dbPath, callback) {
|
||||
const db = await openSqliteDatabase(dbPath, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
return await callback(db);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function backupSqliteFile(sourcePath, destPath) {
|
||||
const db = await openSqliteDatabase(sourcePath, { readonly: true });
|
||||
try {
|
||||
await db.backup(destPath);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function readDatabaseHealth(dbPath) {
|
||||
return withReadonlySqlite(dbPath, (db) => {
|
||||
const quickCheck = db.prepare("PRAGMA quick_check").get();
|
||||
const quickCheckValue = Object.values(quickCheck || {})[0];
|
||||
const hasMigrationTable = !!db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get("_omniroute_migrations");
|
||||
const appliedMigrationVersions = hasMigrationTable
|
||||
? db
|
||||
.prepare("SELECT version FROM _omniroute_migrations")
|
||||
.all()
|
||||
.map((row) => row.version)
|
||||
: [];
|
||||
|
||||
return { quickCheckValue, hasMigrationTable, appliedMigrationVersions };
|
||||
});
|
||||
}
|
||||
|
||||
export async function readEncryptedCredentialSamples(dbPath) {
|
||||
return withReadonlySqlite(dbPath, (db) => {
|
||||
const hasProviderTable = !!db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get("provider_connections");
|
||||
if (!hasProviderTable) {
|
||||
return { hasProviderTable: false, encryptedValues: [] };
|
||||
}
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT api_key, access_token, refresh_token, id_token
|
||||
FROM provider_connections
|
||||
WHERE api_key LIKE 'enc:v1:%'
|
||||
OR access_token LIKE 'enc:v1:%'
|
||||
OR refresh_token LIKE 'enc:v1:%'
|
||||
OR id_token LIKE 'enc:v1:%'
|
||||
LIMIT 20`
|
||||
)
|
||||
.all();
|
||||
|
||||
const encryptedValues = rows.flatMap((row) =>
|
||||
["api_key", "access_token", "refresh_token", "id_token"]
|
||||
.filter((key) => typeof row[key] === "string" && row[key].startsWith("enc:v1:"))
|
||||
.map((key) => row[key])
|
||||
);
|
||||
|
||||
return { hasProviderTable: true, encryptedValues };
|
||||
});
|
||||
}
|
||||
|
||||
export async function readManagementPasswordState(dbPath = resolveStoragePath(resolveDataDir())) {
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
return { exists: false, hasPassword: false };
|
||||
}
|
||||
|
||||
return withReadonlySqlite(dbPath, (db) => {
|
||||
const hasSettingsTable = !!db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get("key_value");
|
||||
if (!hasSettingsTable) {
|
||||
return { exists: true, hasPassword: false };
|
||||
}
|
||||
const row = db
|
||||
.prepare("SELECT value FROM key_value WHERE namespace = 'settings' AND key = ?")
|
||||
.get("password");
|
||||
let password = row?.value;
|
||||
if (typeof password === "string") {
|
||||
try {
|
||||
password = JSON.parse(password);
|
||||
} catch {}
|
||||
}
|
||||
return {
|
||||
exists: true,
|
||||
hasPassword: typeof password === "string" && password.length > 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function resetManagementPassword(
|
||||
password,
|
||||
dbPath = resolveStoragePath(resolveDataDir())
|
||||
) {
|
||||
const db = await openSqliteDatabase(dbPath);
|
||||
try {
|
||||
db.pragma("journal_mode = WAL");
|
||||
ensureSettingsSchema(db);
|
||||
const hashedPassword = await hashManagementPassword(password);
|
||||
updateSettings(db, { password: hashedPassword, requireLogin: true });
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,47 @@ function loadEnvFile() {
|
||||
|
||||
loadEnvFile();
|
||||
|
||||
// Generate STORAGE_ENCRYPTION_KEY if not set (persisted to ~/.omniroute/.env)
|
||||
// This ensures the key survives across upgrades and is not regenerated on each install.
|
||||
// See: https://github.com/diegosouzapw/OmniRoute/issues/1622
|
||||
{
|
||||
const { randomBytes } = await import("node:crypto");
|
||||
const { existsSync, mkdirSync, readFileSync, writeFileSync } = await import("node:fs");
|
||||
const { join } = await import("node:path");
|
||||
const { homedir } = await import("node:os");
|
||||
|
||||
if (!process.env.STORAGE_ENCRYPTION_KEY) {
|
||||
const dataDir = join(homedir(), ".omniroute");
|
||||
const envPath = join(dataDir, ".env");
|
||||
|
||||
// Ensure data directory exists
|
||||
if (!existsSync(dataDir)) {
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
const key = randomBytes(32).toString("hex");
|
||||
|
||||
// Read existing .env content or start fresh
|
||||
let content = "";
|
||||
if (existsSync(envPath)) {
|
||||
content = readFileSync(envPath, "utf-8");
|
||||
}
|
||||
|
||||
// Append key if not already present
|
||||
if (!content.includes("STORAGE_ENCRYPTION_KEY=")) {
|
||||
const separator = content.trim() ? "\n" : "";
|
||||
const newContent = content.trimEnd() + separator + `STORAGE_ENCRYPTION_KEY=${key}`;
|
||||
writeFileSync(envPath, newContent + "\n", "utf-8");
|
||||
console.log(
|
||||
" \x1b[2m✨ Generated STORAGE_ENCRYPTION_KEY in ~/.omniroute/.env\x1b[0m"
|
||||
);
|
||||
}
|
||||
|
||||
// Set in process.env for immediate use
|
||||
process.env.STORAGE_ENCRYPTION_KEY = key;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply --lang before Commander parses (program descriptions call t() during setup)
|
||||
{
|
||||
const langIdx = process.argv.findIndex((a) => a === "--lang");
|
||||
|
||||
@@ -14,16 +14,12 @@
|
||||
*/
|
||||
|
||||
import { createInterface } from "node:readline";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { existsSync } from "node:fs";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
import { resolveDataDir, resolveStoragePath } from "./cli/data-dir.mjs";
|
||||
import { readManagementPasswordState, resetManagementPassword } from "./cli/sqlite.mjs";
|
||||
|
||||
// Resolve data directory — same logic as the server
|
||||
const DATA_DIR = process.env.DATA_DIR || resolve(__dirname, "..", "data");
|
||||
const DB_PATH = resolve(DATA_DIR, "settings.db");
|
||||
const DATA_DIR = resolveDataDir();
|
||||
const DB_PATH = resolveStoragePath(DATA_DIR);
|
||||
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
@@ -34,37 +30,19 @@ function ask(question) {
|
||||
return new Promise((resolve) => rl.question(question, resolve));
|
||||
}
|
||||
|
||||
function generateSecretDigest(input) {
|
||||
// Use bcrypt with a salt round of 10 to match login/route.ts expectations
|
||||
// and resolve CodeQL js/insufficient-password-hash warning.
|
||||
return bcrypt.hashSync(input, 10);
|
||||
}
|
||||
|
||||
console.log("\n🔑 OmniRoute — Password Reset\n");
|
||||
|
||||
async function main() {
|
||||
// Check if database exists
|
||||
if (!existsSync(DB_PATH)) {
|
||||
const passwordState = await readManagementPasswordState(DB_PATH);
|
||||
if (!passwordState.exists) {
|
||||
console.error(`❌ Database not found at: ${DB_PATH}`);
|
||||
console.error(` Make sure OmniRoute has been started at least once.`);
|
||||
console.error(` Or set DATA_DIR env var to your data directory.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let Database;
|
||||
try {
|
||||
Database = (await import("better-sqlite3")).default;
|
||||
} catch {
|
||||
console.error("❌ better-sqlite3 not installed. Run: npm install");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
|
||||
// Check current settings
|
||||
const row = db.prepare("SELECT value FROM settings WHERE key = 'password'").get();
|
||||
|
||||
if (row) {
|
||||
if (passwordState.hasPassword) {
|
||||
console.log("ℹ️ A password is currently set.");
|
||||
} else {
|
||||
console.log("ℹ️ No password is currently set.");
|
||||
@@ -74,7 +52,6 @@ async function main() {
|
||||
|
||||
if (!password || password.length < 8) {
|
||||
console.error("\n❌ Password must be at least 8 characters.\n");
|
||||
db.close();
|
||||
rl.close();
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -83,28 +60,11 @@ async function main() {
|
||||
|
||||
if (password !== confirm) {
|
||||
console.error("\n❌ Passwords do not match.\n");
|
||||
db.close();
|
||||
rl.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const hashed = generateSecretDigest(password);
|
||||
|
||||
// Upsert the password
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO settings (key, value) VALUES ('password', ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||
`);
|
||||
stmt.run(hashed);
|
||||
|
||||
// Also ensure requireLogin is true
|
||||
const loginStmt = db.prepare(`
|
||||
INSERT INTO settings (key, value) VALUES ('requireLogin', 'true')
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||
`);
|
||||
loginStmt.run();
|
||||
|
||||
db.close();
|
||||
await resetManagementPassword(password, DB_PATH);
|
||||
rl.close();
|
||||
|
||||
console.log("\n✅ Password reset successfully!");
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
services:
|
||||
# ── Redis (Rate Limiter Backend) ──────────────────────────────────
|
||||
redis:
|
||||
image: redis:8.6.2
|
||||
image: redis:8.6.2-alpine
|
||||
container_name: omniroute-redis-prod
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
|
||||
@@ -6,14 +6,11 @@
|
||||
# base → minimal image, no CLI tools
|
||||
# cli → CLIs installed inside the container (portable)
|
||||
# host → runner-base + host-mounted CLI binaries (Linux-first)
|
||||
# cliproxyapi → CLIProxyAPI sidecar on port 8317
|
||||
#
|
||||
# Usage:
|
||||
# docker compose --profile base up -d
|
||||
# docker compose --profile cli up -d
|
||||
# docker compose --profile host up -d
|
||||
# docker compose --profile cliproxyapi up -d
|
||||
# docker compose --profile cli --profile cliproxyapi up -d
|
||||
#
|
||||
# Before first run, copy .env.example → .env and edit your secrets.
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
@@ -130,30 +127,6 @@ services:
|
||||
profiles:
|
||||
- host
|
||||
|
||||
# ── Profile: cliproxyapi (CLIProxyAPI as sidecar) ─────────────────
|
||||
cliproxyapi:
|
||||
container_name: cliproxyapi
|
||||
image: ghcr.io/router-for-me/cliproxyapi:v6.9.7
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${CLIPROXYAPI_PORT:-8317}:${CLIPROXYAPI_PORT:-8317}"
|
||||
volumes:
|
||||
- cliproxyapi-data:/root/.cli-proxy-api
|
||||
environment:
|
||||
- PORT=${CLIPROXYAPI_PORT:-8317}
|
||||
- HOST=0.0.0.0
|
||||
healthcheck:
|
||||
test:
|
||||
["CMD", "wget", "--spider", "-q", "http://127.0.0.1:${CLIPROXYAPI_PORT:-8317}/v1/models"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
profiles:
|
||||
- cliproxyapi
|
||||
|
||||
volumes:
|
||||
cliproxyapi-data:
|
||||
name: cliproxyapi-data
|
||||
redis-data:
|
||||
name: omniroute-redis-data
|
||||
|
||||
@@ -6,11 +6,36 @@ designed as a drop-in `ANTHROPIC_BASE_URL` replacement for the official Claude C
|
||||
client, so it only accepts traffic that matches the Claude Code wire image (specific
|
||||
User-Agent, `anthropic-beta` flags, Stainless SDK headers, etc.).
|
||||
|
||||
OmniRoute supports AgentRouter through the **Claude Code compatible** provider type
|
||||
(`anthropic-compatible-cc-*`), which speaks the Anthropic Messages API with the
|
||||
correct wire image. A generic `openai-compatible-chat` provider pointing at
|
||||
`https://agentrouter.org` will **not** work — the upstream WAF rejects requests that
|
||||
do not look like Claude Code.
|
||||
## Quick start — use the native `agentrouter` provider (recommended)
|
||||
|
||||
For most users, **no special setup is required**. OmniRoute ships a built-in
|
||||
`agentrouter` provider with the full Claude Code wire image already baked in (see
|
||||
`open-sse/config/providerRegistry.ts` → `agentrouter`). To use it:
|
||||
|
||||
1. Open **Dashboard → Providers → Add Provider**.
|
||||
2. Select **AgentRouter** from the list.
|
||||
3. Paste your `sk-...` API key and save.
|
||||
|
||||
That's it — no environment variables, no custom provider type. Built-in models
|
||||
include `claude-opus-4-6`, `claude-haiku-4-5-20251001`, `glm-5.1`, and
|
||||
`deepseek-v3.2`.
|
||||
|
||||
The rest of this guide covers the **advanced path**: using the
|
||||
`anthropic-compatible-cc-*` provider type. Use that when you need more control
|
||||
over the wire image — for example, when connecting to other AgentRouter-style
|
||||
relays that are not yet in the native provider registry, or when overriding the
|
||||
base URL, chat path, or header set.
|
||||
|
||||
---
|
||||
|
||||
## Advanced: connecting via the Claude Code compatible provider type
|
||||
|
||||
OmniRoute also supports AgentRouter (and similar relays) through the **Claude Code
|
||||
compatible** provider type (`anthropic-compatible-cc-*`), which speaks the
|
||||
Anthropic Messages API with the correct wire image. A generic
|
||||
`openai-compatible-chat` provider pointing at `https://agentrouter.org` will
|
||||
**not** work — the upstream WAF rejects requests that do not look like Claude
|
||||
Code.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ It provides a single OpenAI-compatible endpoint (`/v1/*`) and routes traffic acr
|
||||
|
||||
Core capabilities:
|
||||
|
||||
- OpenAI-compatible API surface for CLI/tools (177 providers, 31 executors)
|
||||
- OpenAI-compatible API surface for CLI/tools (177 providers, 38 executors)
|
||||
- Request/response translation across provider formats
|
||||
- Model combo fallback (multi-model sequence)
|
||||
- Structured combo steps (`provider + model + connection`) with runtime ordering by `compositeTiers`
|
||||
|
||||
@@ -411,7 +411,7 @@ open-sse/
|
||||
├── types.d.ts
|
||||
├── config/ Provider registries, header profiles, identity, …
|
||||
├── handlers/ Request handlers (chat, embeddings, audio, image, …)
|
||||
├── executors/ 31 provider-specific HTTP executors
|
||||
├── executors/ 38 provider-specific HTTP executors
|
||||
├── translator/ Format conversion (OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro)
|
||||
├── transformer/ Responses API ↔ Chat Completions stream transformer
|
||||
├── services/ 80+ service modules (combos, fallback, quotas, identity, …)
|
||||
@@ -441,7 +441,7 @@ open-sse/
|
||||
|
||||
### 4.2 `open-sse/executors/`
|
||||
|
||||
31 provider executors, each extending `BaseExecutor` (`base.ts`):
|
||||
38 provider executors, each extending `BaseExecutor` (`base.ts`):
|
||||
|
||||
`antigravity`, `azure-openai`, `blackbox-web`, `chatgpt-web`, `cliproxyapi`,
|
||||
`cloudflare-ai`, `codex`, `commandCode`, `cursor`, `default`, `devin-cli`,
|
||||
|
||||
@@ -64,23 +64,17 @@ docker compose --profile cli up -d
|
||||
|
||||
# Host profile (Linux-first; mounts host CLI binaries read-only)
|
||||
docker compose --profile host up -d
|
||||
|
||||
# Combine CLI + CLIProxyAPI sidecar
|
||||
docker compose --profile cli --profile cliproxyapi up -d
|
||||
```
|
||||
|
||||
## Available Profiles
|
||||
|
||||
OmniRoute ships four Compose profiles. Pick the one that matches your environment.
|
||||
OmniRoute ships three Compose profiles. Pick the one that matches your environment.
|
||||
|
||||
| Profile | Service | When to use | Command |
|
||||
| ---------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
|
||||
| `base` (default) | `omniroute-base` | Headless server / minimal runtime, no provider CLIs bundled | `docker compose --profile base up -d` |
|
||||
| `cli` | `omniroute-cli` | Agentic workflows that call `omniroute providers/setup/doctor` and bundled CLIs (Codex, Claude Code, Droid, OpenClaw) | `docker compose --profile cli up -d` |
|
||||
| `host` | `omniroute-host` | Linux hosts that want `network_mode`-like access to host CLIs by mounting `~/.local/bin`, `~/.codex`, `~/.claude`, etc. read-only | `docker compose --profile host up -d` |
|
||||
| `cliproxyapi` | `cliproxyapi` | Run the [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) sidecar on port `8317` for upstream CLI proxying | `docker compose --profile cliproxyapi up -d` |
|
||||
|
||||
> Multiple profiles can be combined: `docker compose --profile cli --profile cliproxyapi up -d`.
|
||||
| Profile | Service | When to use | Command |
|
||||
| ---------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
|
||||
| `base` (default) | `omniroute-base` | Headless server / minimal runtime, no provider CLIs bundled | `docker compose --profile base up -d` |
|
||||
| `cli` | `omniroute-cli` | Agentic workflows that call `omniroute providers/setup/doctor` and bundled CLIs (Codex, Claude Code, Droid, OpenClaw) | `docker compose --profile cli up -d` |
|
||||
| `host` | `omniroute-host` | Linux hosts that want `network_mode`-like access to host CLIs by mounting `~/.local/bin`, `~/.codex`, `~/.claude`, etc. read-only | `docker compose --profile host up -d` |
|
||||
|
||||
## Redis Sidecar
|
||||
|
||||
@@ -167,7 +161,6 @@ Beyond the defaults documented in [ENVIRONMENT.md](../reference/ENVIRONMENT.md),
|
||||
| `OMNIROUTE_MEMORY_MB` | Node heap ceiling (`NODE_OPTIONS=--max-old-space-size`) baked into the image | `256` (set in Dockerfile) |
|
||||
| `DASHBOARD_PORT` / `API_PORT` | Override exposed ports for dashboard (20128) and API (20129) | `20128` / `20129` |
|
||||
| `PROD_DASHBOARD_PORT` | Host-side dashboard port for `docker-compose.prod.yml` | `20130` |
|
||||
| `CLIPROXYAPI_PORT` | Host-side port for the `cliproxyapi` sidecar | `8317` |
|
||||
|
||||
## Docker Compose with Caddy (HTTPS Auto-TLS)
|
||||
|
||||
|
||||
@@ -27,12 +27,12 @@ re-authenticating one account does not affect any other account's refresh token.
|
||||
|
||||
The isolation applies to all three import methods:
|
||||
|
||||
| Import method | Isolation status |
|
||||
|---|---|
|
||||
| AWS Builder ID / IDC device-code flow | Isolated since the device-code flow was introduced |
|
||||
| **Import Token** (manual refresh token paste) | Isolated from v3.8.0 |
|
||||
| **Google / GitHub social login** | Isolated from v3.8.0 |
|
||||
| **Auto-Import** (kiro-cli SQLite) | Isolated from v3.8.0 (SQLite path was already isolated; SSO-cache fallback is now also isolated) |
|
||||
| Import method | Isolation status |
|
||||
| --------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| AWS Builder ID / IDC device-code flow | Isolated since the device-code flow was introduced |
|
||||
| **Import Token** (manual refresh token paste) | Isolated from v3.8.0 |
|
||||
| **Google / GitHub social login** | Isolated from v3.8.0 |
|
||||
| **Auto-Import** (kiro-cli SQLite) | Isolated from v3.8.0 (SQLite path was already isolated; SSO-cache fallback is now also isolated) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ARCHITECTURE (Português (Brasil))
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](../../../../architecture/ARCHITECTURE.md) · 🇸🇦 [ar](../../../ar/docs/architecture/ARCHITECTURE.md) · 🇧🇬 [bg](../../../bg/docs/architecture/ARCHITECTURE.md) · 🇧🇩 [bn](../../../bn/docs/architecture/ARCHITECTURE.md) · 🇨🇿 [cs](../../../cs/docs/architecture/ARCHITECTURE.md) · 🇩🇰 [da](../../../da/docs/architecture/ARCHITECTURE.md) · 🇩🇪 [de](../../../de/docs/architecture/ARCHITECTURE.md) · 🇪🇸 [es](../../../es/docs/architecture/ARCHITECTURE.md) · 🇮🇷 [fa](../../../fa/docs/architecture/ARCHITECTURE.md) · 🇫🇮 [fi](../../../fi/docs/architecture/ARCHITECTURE.md) · 🇫🇷 [fr](../../../fr/docs/architecture/ARCHITECTURE.md) · 🇮🇳 [gu](../../../gu/docs/architecture/ARCHITECTURE.md) · 🇮🇱 [he](../../../he/docs/architecture/ARCHITECTURE.md) · 🇮🇳 [hi](../../../hi/docs/architecture/ARCHITECTURE.md) · 🇭🇺 [hu](../../../hu/docs/architecture/ARCHITECTURE.md) · 🇮🇩 [id](../../../id/docs/architecture/ARCHITECTURE.md) · 🇮🇩 [in](../../../in/docs/architecture/ARCHITECTURE.md) · 🇮🇹 [it](../../../it/docs/architecture/ARCHITECTURE.md) · 🇯🇵 [ja](../../../ja/docs/architecture/ARCHITECTURE.md) · 🇰🇷 [ko](../../../ko/docs/architecture/ARCHITECTURE.md) · 🇮🇳 [mr](../../../mr/docs/architecture/ARCHITECTURE.md) · 🇲🇾 [ms](../../../ms/docs/architecture/ARCHITECTURE.md) · 🇳🇱 [nl](../../../nl/docs/architecture/ARCHITECTURE.md) · 🇳🇴 [no](../../../no/docs/architecture/ARCHITECTURE.md) · 🇵🇭 [phi](../../../phi/docs/architecture/ARCHITECTURE.md) · 🇵🇱 [pl](../../../pl/docs/architecture/ARCHITECTURE.md) · 🇵🇹 [pt](../../../pt/docs/architecture/ARCHITECTURE.md) · 🇷🇴 [ro](../../../ro/docs/architecture/ARCHITECTURE.md) · 🇷🇺 [ru](../../../ru/docs/architecture/ARCHITECTURE.md) · 🇸🇰 [sk](../../../sk/docs/architecture/ARCHITECTURE.md) · 🇸🇪 [sv](../../../sv/docs/architecture/ARCHITECTURE.md) · 🇰🇪 [sw](../../../sw/docs/architecture/ARCHITECTURE.md) · 🇮🇳 [ta](../../../ta/docs/architecture/ARCHITECTURE.md) · 🇮🇳 [te](../../../te/docs/architecture/ARCHITECTURE.md) · 🇹🇭 [th](../../../th/docs/architecture/ARCHITECTURE.md) · 🇹🇷 [tr](../../../tr/docs/architecture/ARCHITECTURE.md) · 🇺🇦 [uk-UA](../../../uk-UA/docs/architecture/ARCHITECTURE.md) · 🇵🇰 [ur](../../../ur/docs/architecture/ARCHITECTURE.md) · 🇻🇳 [vi](../../../vi/docs/architecture/ARCHITECTURE.md) · 🇨🇳 [zh-CN](../../../zh-CN/docs/architecture/ARCHITECTURE.md)
|
||||
🌐 **Languages:** 🇺🇸 [English](../../../../architecture/ARCHITECTURE.md) · 🇸🇦 [ar](../../../ar/docs/architecture/ARCHITECTURE.md) · 🇦🇿 [az](../../../az/docs/architecture/ARCHITECTURE.md) · 🇧🇬 [bg](../../../bg/docs/architecture/ARCHITECTURE.md) · 🇧🇩 [bn](../../../bn/docs/architecture/ARCHITECTURE.md) · 🇨🇿 [cs](../../../cs/docs/architecture/ARCHITECTURE.md) · 🇩🇰 [da](../../../da/docs/architecture/ARCHITECTURE.md) · 🇩🇪 [de](../../../de/docs/architecture/ARCHITECTURE.md) · 🇪🇸 [es](../../../es/docs/architecture/ARCHITECTURE.md) · 🇮🇷 [fa](../../../fa/docs/architecture/ARCHITECTURE.md) · 🇫🇮 [fi](../../../fi/docs/architecture/ARCHITECTURE.md) · 🇫🇷 [fr](../../../fr/docs/architecture/ARCHITECTURE.md) · 🇮🇳 [gu](../../../gu/docs/architecture/ARCHITECTURE.md) · 🇮🇱 [he](../../../he/docs/architecture/ARCHITECTURE.md) · 🇮🇳 [hi](../../../hi/docs/architecture/ARCHITECTURE.md) · 🇭🇺 [hu](../../../hu/docs/architecture/ARCHITECTURE.md) · 🇮🇩 [id](../../../id/docs/architecture/ARCHITECTURE.md) · 🇮🇩 [in](../../../in/docs/architecture/ARCHITECTURE.md) · 🇮🇹 [it](../../../it/docs/architecture/ARCHITECTURE.md) · 🇯🇵 [ja](../../../ja/docs/architecture/ARCHITECTURE.md) · 🇰🇷 [ko](../../../ko/docs/architecture/ARCHITECTURE.md) · 🇮🇳 [mr](../../../mr/docs/architecture/ARCHITECTURE.md) · 🇲🇾 [ms](../../../ms/docs/architecture/ARCHITECTURE.md) · 🇳🇱 [nl](../../../nl/docs/architecture/ARCHITECTURE.md) · 🇳🇴 [no](../../../no/docs/architecture/ARCHITECTURE.md) · 🇵🇭 [phi](../../../phi/docs/architecture/ARCHITECTURE.md) · 🇵🇱 [pl](../../../pl/docs/architecture/ARCHITECTURE.md) · 🇵🇹 [pt](../../../pt/docs/architecture/ARCHITECTURE.md) · 🇷🇴 [ro](../../../ro/docs/architecture/ARCHITECTURE.md) · 🇷🇺 [ru](../../../ru/docs/architecture/ARCHITECTURE.md) · 🇸🇰 [sk](../../../sk/docs/architecture/ARCHITECTURE.md) · 🇸🇪 [sv](../../../sv/docs/architecture/ARCHITECTURE.md) · 🇰🇪 [sw](../../../sw/docs/architecture/ARCHITECTURE.md) · 🇮🇳 [ta](../../../ta/docs/architecture/ARCHITECTURE.md) · 🇮🇳 [te](../../../te/docs/architecture/ARCHITECTURE.md) · 🇹🇭 [th](../../../th/docs/architecture/ARCHITECTURE.md) · 🇹🇷 [tr](../../../tr/docs/architecture/ARCHITECTURE.md) · 🇺🇦 [uk-UA](../../../uk-UA/docs/architecture/ARCHITECTURE.md) · 🇵🇰 [ur](../../../ur/docs/architecture/ARCHITECTURE.md) · 🇻🇳 [vi](../../../vi/docs/architecture/ARCHITECTURE.md) · 🇨🇳 [zh-CN](../../../zh-CN/docs/architecture/ARCHITECTURE.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -20,17 +20,17 @@ _Última atualização: 2026-05-13_
|
||||
|
||||
## Resumo Executivo
|
||||
|
||||
OmniRoute é um gateway de roteamento de IA local e um painel construído sobre Next.js.
|
||||
OmniRoute é um gateway de roteamento de IA local e um painel construído sobre Next.js.
|
||||
Ele fornece um único endpoint compatível com OpenAI (`/v1/*`) e roteia o tráfego entre vários provedores upstream com tradução, fallback, atualização de token e rastreamento de uso.
|
||||
|
||||
Principais capacidades:
|
||||
Capacidades principais:
|
||||
|
||||
- Superfície de API compatível com OpenAI para CLI/ferramentas (177 provedores, 31 executores)
|
||||
- Superfície de API compatível com OpenAI para CLI/ferramentas (177 provedores, 38 executores)
|
||||
- Tradução de solicitação/resposta entre formatos de provedores
|
||||
- Fallback de combinação de modelos (sequência de múltiplos modelos)
|
||||
- Etapas de combinação estruturadas (`provedor + modelo + conexão`) com ordenação em tempo de execução por `compositeTiers`
|
||||
- Fallback em nível de conta (múltiplas contas por provedor)
|
||||
- Pré-verificação de cota e seleção de conta P2C ciente da cota no caminho principal do chat
|
||||
- Passos de combinação estruturados (`provedor + modelo + conexão`) com ordenação em tempo de execução por `compositeTiers`
|
||||
- Fallback em nível de conta (multi-conta por provedor)
|
||||
- Pré-verificação de cota e seleção de conta P2C ciente da cota no caminho principal de chat
|
||||
- Gerenciamento de conexão de provedor OAuth + chave de API (14 módulos OAuth)
|
||||
- Geração de embeddings via `/v1/embeddings` (6 provedores, 9 modelos)
|
||||
- Geração de imagens via `/v1/images/generations` (10+ provedores, 20+ modelos)
|
||||
@@ -45,11 +45,11 @@ Principais capacidades:
|
||||
- Sanitização de resposta para compatibilidade estrita com o SDK da OpenAI
|
||||
- Normalização de papéis (desenvolvedor→sistema, sistema→usuário) para compatibilidade entre provedores
|
||||
- Conversão de saída estruturada (json_schema → Gemini responseSchema)
|
||||
- Persistência local para provedores, chaves, aliases, combinações, configurações, preços (26 módulos de DB)
|
||||
- Persistência local para provedores, chaves, aliases, combos, configurações, preços (26 módulos de DB)
|
||||
- Rastreamento de uso/custo e registro de solicitações
|
||||
- Sincronização em nuvem opcional para sincronização de múltiplos dispositivos/estados
|
||||
- Sincronização em nuvem opcional para sincronização multi-dispositivo/estado
|
||||
- Lista de permissão/bloqueio de IP para controle de acesso à API
|
||||
- Gerenciamento de orçamento de pensamento (passagem/automático/customizado/adaptativo)
|
||||
- Gerenciamento de orçamento de pensamento (pass-through/auto/custom/adaptive)
|
||||
- Injeção de prompt global
|
||||
- Rastreamento de sessão e identificação
|
||||
- Limitação de taxa aprimorada por conta com perfis específicos de provedores
|
||||
@@ -59,12 +59,12 @@ Principais capacidades:
|
||||
- Camada de domínio: regras de custo, política de fallback, política de bloqueio
|
||||
- Context Relay: resumos de transferência de sessão para continuidade de rotação de conta
|
||||
- Persistência de estado de domínio (cache de gravação SQLite para fallbacks, orçamentos, bloqueios, disjuntores)
|
||||
- Motor de políticas para avaliação centralizada de solicitações (bloqueio → orçamento → fallback)
|
||||
- Telemetria de solicitações com agregação de latência p50/p95/p99
|
||||
- Telemetria de alvo de combinação e saúde histórica do alvo de combinação via `combo_execution_key` / `combo_step_id`
|
||||
- Motor de política para avaliação centralizada de solicitações (bloqueio → orçamento → fallback)
|
||||
- Telemetria de solicitação com agregação de latência p50/p95/p99
|
||||
- Telemetria de alvo de combo e saúde histórica do alvo de combo via `combo_execution_key` / `combo_step_id`
|
||||
- ID de correlação (X-Request-Id) para rastreamento de ponta a ponta
|
||||
- Registro de auditoria de conformidade com opção de exclusão por chave de API
|
||||
- Framework de avaliação para garantia de qualidade de LLM
|
||||
- Estrutura de avaliação para garantia de qualidade de LLM
|
||||
- Painel de saúde com status de disjuntor de provedor em tempo real
|
||||
- Servidor MCP (37 ferramentas) com 3 transportes (stdio/SSE/Streamable HTTP)
|
||||
- Servidor A2A (JSON-RPC 2.0 + SSE) com habilidades e ciclo de vida de tarefas
|
||||
@@ -72,19 +72,19 @@ Principais capacidades:
|
||||
- Sistema de habilidades (registro, executor, sandbox, habilidades integradas)
|
||||
- Proxy MITM com gerenciamento de certificados e manipulação de DNS
|
||||
- Middleware de proteção contra injeção de prompt
|
||||
- Pipeline de compressão de prompt com Caveman, RTK, pipelines empilhados, combinações de compressão, pacotes de idiomas e análises
|
||||
- Pipeline de compressão de prompt com Caveman, RTK, pipelines empilhados, combos de compressão, pacotes de idioma e análises
|
||||
- Registro de ACP (Agent Communication Protocol)
|
||||
- Provedores OAuth modulares (14 módulos individuais sob `src/lib/oauth/providers/`)
|
||||
- Scripts de desinstalação/desinstalação completa
|
||||
- Ação de reparo de ambiente OAuth
|
||||
- Ponte WebSocket para clientes WS compatíveis com OpenAI (`/v1/ws`)
|
||||
- Gerenciamento de tokens de sincronização (emissão/revogação, download de pacote de configuração versionado por ETag)
|
||||
- GLM Thinking (`glmt`) preset de provedor de primeira classe
|
||||
- Contagem híbrida de tokens (contagem de tokens do lado do provedor `/messages/count_tokens` com fallback de estimativa)
|
||||
- Gerenciamento de token de sincronização (emissão/revogação, download de pacote de configuração versionado por ETag)
|
||||
- Pensamento GLM (`glmt`) como preset de provedor de primeira classe
|
||||
- Contagem híbrida de tokens (lado do provedor `/messages/count_tokens` com fallback de estimativa)
|
||||
- Auto-semeadura de alias de modelo (30+ normalizações de dialeto cross-proxy na inicialização)
|
||||
- Busca segura de saída com proteção SSRF, bloqueio de URL privada e retry configurável
|
||||
- Repetições de chat cientes de cooldown com `requestRetry` e `maxRetryIntervalSec` configuráveis
|
||||
- Validação do ambiente em tempo de execução com Zod na inicialização
|
||||
- Validação do ambiente de execução com Zod na inicialização
|
||||
- Auditoria de conformidade v2 com paginação, eventos CRUD de provedores e registro de validação bloqueada por SSRF
|
||||
|
||||
Modelo de execução principal:
|
||||
@@ -102,7 +102,7 @@ os demais estão vinculados a seus guias específicos de domínio.
|
||||
|
||||
> Fonte: [diagrams/request-pipeline.mmd](../diagrams/request-pipeline.mmd)
|
||||
|
||||

|
||||

|
||||
|
||||
> Fonte: [diagrams/resilience-3layers.mmd](../diagrams/resilience-3layers.mmd) — também vinculado a
|
||||
> [RESILIENCE_GUIDE.md](./RESILIENCE_GUIDE.md) e à referência de resiliência `CLAUDE.md`.
|
||||
@@ -116,13 +116,13 @@ os demais estão vinculados a seus guias específicos de domínio.
|
||||
- Autenticação de provedor e atualização de token
|
||||
- Tradução de requisições e streaming SSE
|
||||
- Persistência de estado local + uso
|
||||
- Orquestração de sincronização em nuvem opcional
|
||||
- Orquestração opcional de sincronização em nuvem
|
||||
|
||||
### Fora do Escopo
|
||||
|
||||
- Implementação de serviço em nuvem por trás de `NEXT_PUBLIC_CLOUD_URL`
|
||||
- SLA do provedor/plano de controle fora do processo local
|
||||
- Binaries CLI externas em si (Claude CLI, Codex CLI, etc.)
|
||||
- Binaries CLI externos em si (Claude CLI, Codex CLI, etc.)
|
||||
|
||||
## Superfície do Painel (Atual)
|
||||
|
||||
@@ -146,8 +146,8 @@ Páginas principais em `src/app/(dashboard)/dashboard/`:
|
||||
- `/dashboard/cache` — estatísticas de cache de leitura e raciocínio, controles de expulsão
|
||||
- `/dashboard/playground` — playground de chat interativo contra qualquer combo/modelo configurado
|
||||
- `/dashboard/changelog` — visualizador de changelog no aplicativo (renderiza `CHANGELOG.md`)
|
||||
- `/dashboard/system` — diagnósticos de tempo de execução, informações de versão, superfície de validação do ambiente
|
||||
- `/dashboard/onboarding` — assistente de configuração para primeira execução em novas instalações
|
||||
- `/dashboard/system` — diagnósticos de tempo de execução, informações de versão, superfície de validação de ambiente
|
||||
- `/dashboard/onboarding` — assistente de configuração para primeira execução para novas instalações
|
||||
- `/dashboard/media` — playground de imagem/vídeo/música
|
||||
- `/dashboard/search-tools` — teste de provedor de busca e histórico
|
||||
- `/dashboard/health` — tempo de atividade, disjuntores, limites de taxa, sessões monitoradas por cota
|
||||
@@ -155,7 +155,7 @@ Páginas principais em `src/app/(dashboard)/dashboard/`:
|
||||
- `/dashboard/settings` — abas de configurações do sistema (geral, roteamento, padrões de combo, etc.)
|
||||
- `/dashboard/context/caveman` — regras de compressão Caveman, pacotes de idioma, visualização e modo de saída
|
||||
- `/dashboard/context/rtk` — filtros de saída de comando RTK, visualização e configurações de segurança em tempo de execução
|
||||
- `/dashboard/context/combos` — pipelines de compressão nomeadas atribuídas a combos de roteamento
|
||||
- `/dashboard/context/combos` — pipelines de compressão nomeados atribuídos a combos de roteamento
|
||||
- `/dashboard/translator` — inspeção de tradutor e visualização de conversão de formato de requisição
|
||||
- `/dashboard/audit` — navegador de log de auditoria de conformidade com paginação e metadados estruturados
|
||||
- `/dashboard/usage` — navegador de uso por requisição vinculado a `usage_history`
|
||||
@@ -212,7 +212,7 @@ flowchart LR
|
||||
|
||||
## Componentes Centrais de Execução
|
||||
|
||||
## 1) API e Camada de Roteamento (Rotas do App Next.js)
|
||||
## 1) Camada de API e Roteamento (Rotas do App Next.js)
|
||||
|
||||
Principais diretórios:
|
||||
|
||||
@@ -247,7 +247,7 @@ Domínios de gerenciamento:
|
||||
- Chaves/aliases/combos/preços: `src/app/api/keys*`, `src/app/api/models/alias`, `src/app/api/combos*`, `src/app/api/pricing`
|
||||
- Uso: `src/app/api/usage/*`
|
||||
- Síncrono/nuvem: `src/app/api/sync/*`, `src/app/api/cloud/*`
|
||||
- Ferramentas de CLI: `src/app/api/cli-tools/*`
|
||||
- Ferramentas auxiliares de CLI: `src/app/api/cli-tools/*`
|
||||
- Filtro de IP: `src/app/api/settings/ip-filter` (GET/PUT)
|
||||
- Orçamento de pensamento: `src/app/api/settings/thinking-budget` (GET/PUT)
|
||||
- Prompt do sistema: `src/app/api/settings/system-prompt` (GET/PUT)
|
||||
@@ -256,7 +256,7 @@ Domínios de gerenciamento:
|
||||
- Sessões: `src/app/api/sessions` (GET)
|
||||
- Limites de taxa: `src/app/api/rate-limits` (GET)
|
||||
- Resiliência: `src/app/api/resilience` (GET/PATCH) — fila de requisições, cooldown de conexão, quebra de provedor, configuração de espera por cooldown
|
||||
- Redefinição de resiliência: `src/app/api/resilience/reset` (POST) — redefinir quebras de provedores
|
||||
- Reset de resiliência: `src/app/api/resilience/reset` (POST) — resetar quebras de provedores
|
||||
- Estatísticas de cache: `src/app/api/cache/stats` (GET/DELETE)
|
||||
- Telemetria: `src/app/api/telemetry/summary` (GET)
|
||||
- Orçamento: `src/app/api/usage/budget` (GET/POST)
|
||||
@@ -265,8 +265,8 @@ Domínios de gerenciamento:
|
||||
- Avaliações: `src/app/api/evals` (GET/POST), `src/app/api/evals/[suiteId]` (GET)
|
||||
- Políticas: `src/app/api/policies` (GET/POST)
|
||||
- Tokens de síncrono: `src/app/api/sync/tokens` (GET/POST), `src/app/api/sync/tokens/[id]` (GET/DELETE)
|
||||
- Pacote de configuração: `src/app/api/sync/bundle` (GET, snapshot versionado por ETag de configurações/provedores/combos/chaves)
|
||||
- WebSocket: `src/app/api/v1/ws/route.ts` — manipulador de upgrade para clientes WS compatíveis com OpenAI
|
||||
- Pacote de configuração: `src/app/api/sync/bundle` (GET, snapshot versionado em ETag de configurações/provedores/combos/chaves)
|
||||
- WebSocket: `src/app/api/v1/ws/route.ts` — Manipulador de upgrade para clientes WS compatíveis com OpenAI
|
||||
|
||||
## 2) SSE + Núcleo de Tradução
|
||||
|
||||
@@ -304,13 +304,13 @@ Serviços (lógica de negócios):
|
||||
- Transferência de contexto: `open-sse/services/contextHandoff.ts` — geração e injeção de resumo de transferência para estratégia de retransmissão de contexto
|
||||
- Compressão: `open-sse/services/compression/*` — compressão proativa antes da tradução do provedor;
|
||||
inclui regras de Caveman, filtros RTK, pipelines empilhados, combos de compressão, estatísticas e validação
|
||||
- Recuperador de cota Codex: `open-sse/services/codexQuotaFetcher.ts` — recupera a cota Codex para decisões de transferência de contexto
|
||||
- Retry ciente de cooldown: `src/sse/services/cooldownAwareRetry.ts` — retries de cooldown por modelo com `requestRetry` / `maxRetryIntervalSec` configuráveis
|
||||
- Fetch seguro de saída: `src/shared/network/safeOutboundFetch.ts` — fetch protegido de provedor/modelo com proteção SSRF, bloqueio de URL privada, retry e timeout
|
||||
- Guarda de URL de saída: `src/shared/network/outboundUrlGuard.ts` — valida URLs de provedores contra intervalos CIDR de privado/localhost
|
||||
- Recuperador de cota do Codex: `open-sse/services/codexQuotaFetcher.ts` — recupera a cota do Codex para decisões de transferência de contexto
|
||||
- Retentativa ciente de cooldown: `src/sse/services/cooldownAwareRetry.ts` — retentativas de cooldown por modelo com `requestRetry` / `maxRetryIntervalSec` configuráveis
|
||||
- Busca segura de saída: `src/shared/network/safeOutboundFetch.ts` — busca protegida de provedor/modelo com proteção SSRF, bloqueio de URL privada, retentativa e timeout
|
||||
- Guarda de URL de saída: `src/shared/network/outboundUrlGuard.ts` — valida URLs de provedores contra faixas CIDR privadas/localhost
|
||||
- Padrões de requisição do provedor: `open-sse/services/providerRequestDefaults.ts` — padrões de `maxTokens`, `temperature`, `thinkingBudgetTokens` a nível de provedor
|
||||
- Constantes do provedor GLM: `open-sse/config/glmProvider.ts` — modelos GLM compartilhados, URLs de cota, timeout/padrões GLMT
|
||||
- Upstream Antigravity: `open-sse/config/antigravityUpstream.ts` — constantes de URL base e caminho de descoberta
|
||||
- Upstream de antigravidade: `open-sse/config/antigravityUpstream.ts` — constantes de URL base e caminho de descoberta
|
||||
- Constantes do cliente Codex: `open-sse/config/codexClient.ts` — valores de user-agent e versão do cliente versionados
|
||||
- Semente de alias de modelo: `src/lib/modelAliasSeed.ts` — semeia 30+ aliases de dialetos cross-proxy na inicialização
|
||||
|
||||
@@ -323,15 +323,15 @@ Módulos da camada de domínio:
|
||||
- Motor de políticas: `src/domain/policyEngine.ts` — avaliação centralizada de bloqueio → orçamento → fallback
|
||||
- Catálogo de códigos de erro: `src/lib/domain/errorCodes.ts`
|
||||
- ID da requisição: `src/lib/domain/requestId.ts`
|
||||
- Timeout de fetch: `src/lib/domain/fetchTimeout.ts`
|
||||
- Timeout de busca: `src/lib/domain/fetchTimeout.ts`
|
||||
- Telemetria de requisição: `src/lib/domain/requestTelemetry.ts`
|
||||
- Conformidade/auditoria: `src/lib/domain/compliance/index.ts`
|
||||
- Executor de eval: `src/lib/domain/evalRunner.ts`
|
||||
- Executor de avaliação: `src/lib/domain/evalRunner.ts`
|
||||
- Persistência do estado do domínio: `src/lib/db/domainState.ts` — CRUD SQLite para cadeias de fallback, orçamentos, histórico de custos, estado de bloqueio, disjuntores
|
||||
|
||||
Módulos do provedor OAuth (14 arquivos individuais sob `src/lib/oauth/providers/`):
|
||||
Módulos do provedor OAuth (14 arquivos individuais em `src/lib/oauth/providers/`):
|
||||
|
||||
- Índice de registro: `src/lib/oauth/providers/index.ts`
|
||||
- Índice do registro: `src/lib/oauth/providers/index.ts`
|
||||
- Provedores individuais: `claude.ts`, `codex.ts`, `gemini.ts`, `antigravity.ts`, `qoder.ts`, `qwen.ts`, `kimi-coding.ts`, `github.ts`, `kiro.ts`, `cursor.ts`, `kilocode.ts`, `cline.ts`, `windsurf.ts`, `gitlab-duo.ts`
|
||||
- Wrapper fino: `src/lib/oauth/providers.ts` — re-exportações de módulos individuais
|
||||
|
||||
@@ -349,25 +349,25 @@ O Motor de Combinação Automática pontua e escolhe dinamicamente os alvos de r
|
||||
|
||||
Principais capacidades:
|
||||
|
||||
- **14 estratégias de roteamento** (prioridade, ponderada, preenchimento primeiro, round-robin, P2C, aleatória,
|
||||
menos utilizada, otimizada por custo, estritamente aleatória, **automática**, lkgp, otimizada por contexto,
|
||||
relé de contexto, além de um caminho de fallback) — automática é a adição principal na v3.8.0.
|
||||
- **14 estratégias de roteamento** (prioridade, ponderada, preenchimento primeiro, round-robin, P2C, aleatório,
|
||||
menos utilizado, otimizado por custo, estritamente aleatório, **auto**, lkgp, otimizado por contexto,
|
||||
retransmissão de contexto, além de um caminho de fallback) — auto é a adição principal na v3.8.0.
|
||||
- **Pontuação de 9 fatores**: custo, latência p95, taxa de sucesso, margem de cota, proximidade de bloqueio,
|
||||
estado do disjuntor, falhas recentes, disponibilidade do modelo e afinidade de tags.
|
||||
- **Fábrica virtual** materializa combinações efêmeras quando nenhuma combinação nomeada correspondente
|
||||
existe, buscando candidatos de conexões de provedores ativos e saudáveis.
|
||||
existe, buscando candidatos de conexões de provedores ativos saudáveis.
|
||||
- **Prefixos automáticos**: `auto/coding`, `auto/cheap`, `auto/fast`, `auto/offline`,
|
||||
`auto/smart`, `auto/lkgp` — cada um respaldado por um perfil de peso ajustado.
|
||||
- **4 pacotes de modo**: coding, fast, cheap, smart — enviados como configurações de peso pré-definidas
|
||||
`auto/smart`, `auto/lkgp` — cada um apoiado por um perfil de peso ajustado.
|
||||
- **4 pacotes de modo**: coding, fast, cheap, smart — enviados como configurações de peso predefinidas
|
||||
chamáveis a partir do painel.
|
||||
|
||||
Para detalhes algorítmicos completos (fórmulas de fatores, ajuste de peso), consulte
|
||||
Para detalhes algorítmicos completos (fórmulas de fatores, ajuste de peso), veja
|
||||
[`docs/routing/AUTO-COMBO.md`](../routing/AUTO-COMBO.md).
|
||||
|
||||
### B. Agentes de Nuvem
|
||||
|
||||
Os Agentes de Nuvem envolvem plataformas de código-agente hospedadas de terceiros (Codex Cloud, Devin,
|
||||
Jules) por trás de um ciclo de vida de tarefa uniforme baseado em DB. Todos os pontos finais de criação/inspeção
|
||||
Jules) por trás de um ciclo de vida de tarefa uniforme baseado em DB. Todos os pontos de criação/inspeção
|
||||
de tarefas requerem autenticação de gerenciamento.
|
||||
|
||||
- Raiz do módulo: `src/lib/cloudAgent/` (`baseAgent.ts`, `registry.ts`, `api.ts`,
|
||||
@@ -378,23 +378,23 @@ de tarefas requerem autenticação de gerenciamento.
|
||||
- Painel: `/dashboard/cloud-agents`
|
||||
- Armazenamento: tabela `cloud_agent_tasks`
|
||||
|
||||
Para detalhes de provisionamento por agente e especificidades do OAuth, consulte
|
||||
Para detalhes de provisionamento por agente e especificidades do OAuth, veja
|
||||
[`docs/frameworks/CLOUD_AGENT.md`](../frameworks/CLOUD_AGENT.md).
|
||||
|
||||
### C. Guardrails
|
||||
|
||||
O módulo de guardrails é uma camada de middleware recarregável que inspeciona solicitações
|
||||
e respostas em busca de PII, injeção de prompt e conteúdo visual inseguro. Violações
|
||||
interrompem a solicitação com HTTP **503** mais um código de erro estruturado, permitindo
|
||||
e respostas em busca de PII, injeção de prompt e conteúdo de visão inseguro. Violações
|
||||
interrompem a solicitação com HTTP **503** além de um código de erro estruturado, permitindo
|
||||
que chamadores subsequentes tentem novamente ou ramifiquem.
|
||||
|
||||
- Raiz do módulo: `src/lib/guardrails/` (`base.ts`, `registry.ts`, `piiMasker.ts`,
|
||||
`promptInjection.ts`, `visionBridge.ts`, `visionBridgeHelpers.ts`)
|
||||
- Recarregamento a quente: o registro observa mudanças de configuração e reconstrói a cadeia no local
|
||||
- Recarregamento a quente: o registro observa alterações de configuração e reconstrói a cadeia no local
|
||||
- Pontos de conexão: entrada do manipulador de chat, manipulador de geração de imagem, sanitizador de resposta
|
||||
- Contrato HTTP: violações aparecem como `503` com `error.code = "GUARDRAIL_VIOLATION"`
|
||||
|
||||
Para autoria de regras e ajuste de limiares, consulte
|
||||
Para autoria de regras e ajuste de limiares, veja
|
||||
[`docs/security/GUARDRAILS.md`](../security/GUARDRAILS.md).
|
||||
|
||||
### D. Camada de Domínio
|
||||
@@ -425,24 +425,24 @@ O pipeline de autorização classifica cada solicitação recebida e aplica a
|
||||
cadeia de políticas apropriada antes do despacho.
|
||||
|
||||
- Entrada do pipeline: `src/server/authz/pipeline.ts`
|
||||
- Classificador de solicitações: `src/server/authz/classify.ts` — distingue rotas de compatibilidade pública
|
||||
- Classificador de solicitações: `src/server/authz/classify.ts` — distingue rotas de compatibilidade públicas
|
||||
de rotas de gerenciamento
|
||||
- Inventário de rotas públicas: `src/shared/constants/publicApiRoutes.ts`
|
||||
- Políticas: `src/server/authz/policies/` — predicados compostáveis
|
||||
(`requireApiKey`, `requireManagement`, `requireFreshAuth`, etc.)
|
||||
- Utilitários de cabeçalho: `src/server/authz/headers.ts`
|
||||
- Auxiliar de asserção: `src/server/authz/assertAuth.ts`
|
||||
- Helper de asserção: `src/server/authz/assertAuth.ts`
|
||||
- Contexto da solicitação: `src/server/authz/context.ts`
|
||||
|
||||
Rotas públicas vs rotas de gerenciamento são uma fronteira rígida: APIs de agente/cooldown e
|
||||
mutações de provedores requerem autenticação de gerenciamento (HTTP 401 se ausente).
|
||||
|
||||
Para as regras completas de classificação de rotas, consulte
|
||||
Para as regras completas de classificação de rotas, veja
|
||||
[`docs/architecture/AUTHZ_GUIDE.md`](./AUTHZ_GUIDE.md).
|
||||
|
||||
### F. FSM de Workflow e Roteador Consciente de Tarefas
|
||||
|
||||
Um roteador impulsionado por máquina de estados finitos, posicionado acima da seleção de combinações para direcionar
|
||||
Um roteador acionado por máquina de estados finitos, posicionado acima da seleção de combinações para direcionar
|
||||
o tráfego com base na fase de workflow detectada (planejamento, execução,
|
||||
revisão) e afinidade de tarefas em segundo plano.
|
||||
|
||||
@@ -451,43 +451,43 @@ revisão) e afinidade de tarefas em segundo plano.
|
||||
- Detector de tarefas em segundo plano: `open-sse/services/backgroundTaskDetector.ts`
|
||||
- Classificador de intenção: `open-sse/services/intentClassifier.ts`
|
||||
|
||||
As transições da FSM alimentam a pontuação do Motor de Combinação Automática, tendendo a modelos mais baratos
|
||||
para tarefas de background/automação e a modelos mais fortes para turnos interativos de planejamento/revisão.
|
||||
As transições da FSM alimentam a pontuação do Auto Combo, tendendo a modelos mais baratos
|
||||
para tarefas de automação/em segundo plano e a modelos mais fortes para planejamento/revisão interativa.
|
||||
|
||||
### G. Resiliência Específica do Provedor
|
||||
|
||||
Vários provedores enviam módulos dedicados de resiliência e furtividade que se aproveitam das
|
||||
camadas globais de disjuntor / cooldown de conexão / bloqueio de modelo:
|
||||
|
||||
- Motor Antigravidade 429: `open-sse/services/antigravity429Engine.ts` (rotaciona
|
||||
- Motor Antigravity 429: `open-sse/services/antigravity429Engine.ts` (rotaciona
|
||||
identidade, limpa cabeçalhos de resposta, controla créditos/rastreamento de versão via
|
||||
`antigravityCredits.ts`, `antigravityHeaderScrub.ts`, `antigravityHeaders.ts`,
|
||||
`antigravityIdentity.ts`, `antigravityObfuscation.ts`, `antigravityVersion.ts`)
|
||||
- Política de cota ModelScope: `open-sse/services/modelscopePolicy.ts`
|
||||
- CCH de Código Claude (Handshake de Canal de Compatibilidade): `open-sse/services/claudeCodeCCH.ts`,
|
||||
- Claude Code CCH (Handshake de Canal de Compatibilidade): `open-sse/services/claudeCodeCCH.ts`,
|
||||
além de `claudeCodeCompatible.ts`, `claudeCodeConstraints.ts`, `claudeCodeExtraRemap.ts`,
|
||||
`claudeCodeToolRemapper.ts`
|
||||
- Modelagem de impressão digital de Código Claude: `open-sse/services/claudeCodeFingerprint.ts`
|
||||
- Ofuscação de Código Claude: `open-sse/services/claudeCodeObfuscation.ts`
|
||||
- Cliente TLS do ChatGPT: `open-sse/services/chatgptTlsClient.ts` (estilo curl-impersonate
|
||||
para sessões do ChatGPT-Web)
|
||||
- Modelagem de impressão digital do Claude Code: `open-sse/services/claudeCodeFingerprint.ts`
|
||||
- Ofuscação do Claude Code: `open-sse/services/claudeCodeObfuscation.ts`
|
||||
- Cliente TLS do ChatGPT: `open-sse/services/chatgptTlsClient.ts` (estilo de
|
||||
impersonação curl para sessões do ChatGPT-Web)
|
||||
- Cache de imagem do ChatGPT: `open-sse/services/chatgptImageCache.ts`
|
||||
|
||||
Para o guia completo de furtividade e orientações operacionais, consulte
|
||||
Para o guia completo de furtividade e orientações operacionais, veja
|
||||
[`docs/security/STEALTH_GUIDE.md`](../security/STEALTH_GUIDE.md).
|
||||
|
||||
### H. Webhooks, Cache de Raciocínio, Cache de Leitura
|
||||
|
||||
- **Webhooks** — despacho de saída para eventos de provedor/conta/tarefa.
|
||||
- Despachante: `src/lib/webhookDispatcher.ts`
|
||||
- Dispatcher: `src/lib/webhookDispatcher.ts`
|
||||
- Armazenamento: tabela SQLite `webhooks` (via `src/lib/db/webhooks.ts`)
|
||||
- Painel: `/dashboard/webhooks` (assinaturas, segredos, histórico de tentativas)
|
||||
- Para taxonomia de eventos e semântica de tentativas, consulte [`docs/frameworks/WEBHOOKS.md`](../frameworks/WEBHOOKS.md).
|
||||
- **Cache de Raciocínio** — blocos de raciocínio reproduzíveis para provedores que emitem
|
||||
- Para taxonomia de eventos e semântica de tentativas, veja [`docs/frameworks/WEBHOOKS.md`](../frameworks/WEBHOOKS.md).
|
||||
- **Cache de Raciocínio** — blocos de raciocínio replays para provedores que emitem
|
||||
tokens de pensamento (Claude, GLMT, etc.) para que turnos consecutivos possam pular o re-pensamento.
|
||||
- Camada de DB: `src/lib/db/reasoningCache.ts`
|
||||
- Camada de serviço: `open-sse/services/reasoningCache.ts`
|
||||
- Para semântica de reprodução, consulte [`docs/routing/REASONING_REPLAY.md`](../routing/REASONING_REPLAY.md).
|
||||
- Para semântica de replay, veja [`docs/routing/REASONING_REPLAY.md`](../routing/REASONING_REPLAY.md).
|
||||
- **Cache de Leitura** — cache de resposta de curta duração indexado por assinatura e usado para
|
||||
colapsar tentativas idênticas de SDKs upstream quebrados.
|
||||
- Camada de DB: `src/lib/db/readCache.ts`
|
||||
@@ -513,7 +513,7 @@ Banco de dados de estado de domínio (SQLite):
|
||||
|
||||
- `src/lib/db/domainState.ts` — operações CRUD para estado de domínio
|
||||
- Tabelas (criadas em `src/lib/db/core.ts`): `domain_fallback_chains`, `domain_budgets`, `domain_cost_history`, `domain_lockout_state`, `domain_circuit_breakers`
|
||||
- Padrão de cache write-through: Maps em memória são autoritativos em tempo de execução; mutações são escritas de forma síncrona no SQLite; estado é restaurado do DB na inicialização a frio
|
||||
- Padrão de cache write-through: Maps em memória são autoritativos em tempo de execução; mutações são escritas de forma síncrona no SQLite; o estado é restaurado do DB na inicialização a frio
|
||||
|
||||
## 4) Superfícies de Autenticação + Segurança
|
||||
|
||||
@@ -610,7 +610,7 @@ flowchart TD
|
||||
Q -- Não --> R[Retornar todas indisponíveis]
|
||||
```
|
||||
|
||||
As decisões de fallback são impulsionadas por `open-sse/services/accountFallback.ts` usando códigos de status e heurísticas de mensagens de erro. O roteamento de combo adiciona uma proteção extra: 400s específicos do provedor, como bloqueio de conteúdo upstream e falhas de validação de função, são tratados como falhas locais do modelo para que os alvos de combo posteriores ainda possam ser executados.
|
||||
As decisões de fallback são impulsionadas por `open-sse/services/accountFallback.ts` usando códigos de status e heurísticas de mensagens de erro. O roteamento de combo adiciona uma proteção extra: 400s específicos do provedor, como bloqueio de conteúdo upstream e falhas de validação de função, são tratados como falhas locais do modelo, para que os alvos de combo posteriores ainda possam ser executados.
|
||||
|
||||
## Ciclo de Vida de Onboarding OAuth e Atualização de Token
|
||||
|
||||
@@ -630,7 +630,7 @@ sequenceDiagram
|
||||
OAuth-->>UI: dados do fluxo
|
||||
|
||||
UI->>OAuth: POST trocar ou consultar
|
||||
OAuth->>ProvAuth: troca de token/consulta
|
||||
OAuth->>ProvAuth: troca/consulta de token
|
||||
ProvAuth-->>OAuth: tokens de acesso/atualização
|
||||
OAuth->>DB: createProviderConnection(dados oauth)
|
||||
OAuth-->>UI: sucesso + id da conexão
|
||||
@@ -656,7 +656,7 @@ sequenceDiagram
|
||||
participant Claude as ~/.claude/settings.json
|
||||
|
||||
UI->>Sync: POST ação=habilitar
|
||||
Sync->>DB: definir cloudEnabled=true
|
||||
Sync->>DB: set cloudEnabled=true
|
||||
Sync->>DB: garantir que a chave da API exista
|
||||
Sync->>Cloud: POST /sync/{machineId} (provedores/aliases/combos/chaves)
|
||||
Cloud-->>Sync: resultado da sincronização
|
||||
@@ -670,7 +670,7 @@ sequenceDiagram
|
||||
Sync-->>UI: sincronizado
|
||||
|
||||
UI->>Sync: POST ação=desabilitar
|
||||
Sync->>DB: definir cloudEnabled=false
|
||||
Sync->>DB: set cloudEnabled=false
|
||||
Sync->>Cloud: DELETE /sync/{machineId}
|
||||
Sync->>Claude: mudar ANTHROPIC_BASE_URL de volta para local (se necessário)
|
||||
Sync-->>UI: desabilitado
|
||||
@@ -781,8 +781,8 @@ erDiagram
|
||||
|
||||
Arquivos de armazenamento físico:
|
||||
|
||||
- banco de dados de runtime principal: `${DATA_DIR}/storage.sqlite`
|
||||
- linhas de log de requisições: `${DATA_DIR}/log.txt` (artefato de compatibilidade/debug)
|
||||
- banco de dados de runtime primário: `${DATA_DIR}/storage.sqlite`
|
||||
- linhas de log de requisições: `${DATA_DIR}/log.txt` (artefato de compat/debug)
|
||||
- arquivos de payload de chamadas estruturadas: `${DATA_DIR}/call_logs/`
|
||||
- sessões de depuração de tradutor/requisição opcionais: `<repo>/logs/...`
|
||||
|
||||
@@ -795,7 +795,7 @@ flowchart LR
|
||||
Browser[Navegador do Dashboard]
|
||||
end
|
||||
|
||||
subgraph ContainerOrProcess[Runtime OmniRoute]
|
||||
subgraph ContainerOrProcess[Runtime do OmniRoute]
|
||||
Next[Servidor Next.js\nPORT=20128]
|
||||
Core[Núcleo SSE + Executores]
|
||||
MainDB[(storage.sqlite)]
|
||||
@@ -823,7 +823,7 @@ flowchart LR
|
||||
|
||||
- `src/app/api/v1/*`, `src/app/api/v1beta/*`: APIs de compatibilidade
|
||||
- `src/app/api/v1/providers/[provider]/*`: rotas dedicadas por provedor (chat, embeddings, imagens)
|
||||
- `src/app/api/providers*`: CRUD de provedor, validação, teste
|
||||
- `src/app/api/providers*`: CRUD de provedores, validação, teste
|
||||
- `src/app/api/provider-nodes*`: gerenciamento de nós compatíveis personalizados
|
||||
- `src/app/api/provider-models`: gerenciamento de modelos personalizados (CRUD)
|
||||
- `src/app/api/models/route.ts`: API de catálogo de modelos (aliases + modelos personalizados)
|
||||
@@ -831,7 +831,7 @@ flowchart LR
|
||||
- `src/app/api/keys*`: ciclo de vida da chave API local
|
||||
- `src/app/api/models/alias`: gerenciamento de alias
|
||||
- `src/app/api/combos*`: gerenciamento de combos de fallback
|
||||
- `src/app/api/pricing`: substituições de preços para cálculo de custo
|
||||
- `src/app/api/pricing`: substituições de preços para cálculo de custos
|
||||
- `src/app/api/settings/proxy`: configuração de proxy (GET/PUT/DELETE)
|
||||
- `src/app/api/settings/proxy/test`: teste de conectividade de proxy de saída (POST)
|
||||
- `src/app/api/usage/*`: APIs de uso e logs
|
||||
@@ -842,7 +842,7 @@ flowchart LR
|
||||
- `src/app/api/settings/system-prompt`: prompt do sistema global (GET/PUT)
|
||||
- `src/app/api/settings/compression`: configurações de compressão global (GET/PUT)
|
||||
- `src/app/api/compression/*`: visualização de compressão, metadados de regras e pacotes de idioma
|
||||
- `src/app/api/context/caveman/config`: alias de configurações Caveman (GET/PUT)
|
||||
- `src/app/api/context/caveman/config`: alias de configurações do Caveman (GET/PUT)
|
||||
- `src/app/api/context/rtk/*`: configuração RTK, catálogo de filtros, endpoint de teste e recuperação de saída bruta
|
||||
- `src/app/api/context/combos*`: CRUD de combos de compressão e atribuições de combos de roteamento
|
||||
- `src/app/api/context/analytics`: alias de análises de compressão
|
||||
@@ -871,7 +871,7 @@ flowchart LR
|
||||
|
||||
### Persistência
|
||||
|
||||
- `src/lib/db/*`: configuração/persistência de estado persistente e persistência de domínio no SQLite
|
||||
- `src/lib/db/*`: configuração/persistência de estado e domínio persistente no SQLite
|
||||
- `src/lib/localDb.ts`: re-exportação de compatibilidade para módulos de DB
|
||||
- `src/lib/usageDb.ts`: fachada de histórico de uso/logs de chamadas sobre tabelas SQLite
|
||||
|
||||
@@ -883,13 +883,13 @@ Cada provedor tem um executor especializado que estende `BaseExecutor` (em `open
|
||||
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `DefaultExecutor` | OpenAI, Claude, Gemini, Qwen, OpenRouter, GLM, Kimi, MiniMax, DeepSeek, Groq, xAI, Mistral, Perplexity, Together, Fireworks, Cerebras, Cohere, NVIDIA, etc. | Configuração dinâmica de URL/cabeçalho por provedor |
|
||||
| `AntigravityExecutor` | Google Antigravity | IDs de projeto/sessão personalizados, análise de Retry-After, ofuscação de 429 |
|
||||
| `AzureOpenAIExecutor` | Azure OpenAI | Roteamento baseado em implantação, imposição de consulta de api-version |
|
||||
| `AzureOpenAIExecutor` | Azure OpenAI | Roteamento baseado em implantação, aplicação de consulta de api-version |
|
||||
| `BlackboxWebExecutor` | Blackbox AI (modo web) | Reversão de sessão web com emulação de impressão digital TLS |
|
||||
| `ChatGPTWebExecutor` | ChatGPT web | Gerenciamento de cliente TLS + cookie de sessão (`chatgptTlsClient.ts`) |
|
||||
| `ClaudeIdentityExecutor` | Claude.ai (caminho CCH) | Pipelines de restrição + remapeamento de ferramentas, modelagem de impressão digital |
|
||||
| `CliProxyApiExecutor` | Provedores compatíveis com CLIProxyAPI | Manipulação personalizada de autenticação e protocolo |
|
||||
| `CloudflareAiExecutor` | Cloudflare Workers AI | Injeção de ID de conta, rastreamento de uso baseado em Neurons |
|
||||
| `CodexExecutor` | OpenAI Codex | Injeta instruções do sistema, força esforço de raciocínio |
|
||||
| `CodexExecutor` | OpenAI Codex | Injeções de instruções do sistema, força de esforço de raciocínio |
|
||||
| `CommandCodeExecutor` | Código de Comando | Rotação de cabeçalho por sessão + OAuth |
|
||||
| `CursorExecutor` | Cursor IDE | Protocolo ConnectRPC, codificação Protobuf, assinatura de requisições via checksum |
|
||||
| `DevinCliExecutor` | Devin CLI | Conexão do ciclo de vida da tarefa Devin via módulo de agente em nuvem |
|
||||
@@ -900,14 +900,14 @@ Cada provedor tem um executor especializado que estende `BaseExecutor` (em `open
|
||||
| `GrokWebExecutor` | xAI Grok web | Reversão de sessão web, seleção de modo (pensar/padrão) |
|
||||
| `KieExecutor` | KIE | Emissão de token personalizada com âncoras de sessão rotativas |
|
||||
| `KiroExecutor` | AWS CodeWhisperer/Kiro | Formato binário do AWS EventStream → conversão para SSE |
|
||||
| `MuseSparkWebExecutor` | Muse Spark (web) | Reversão de sessão web com integração de mensagem de imagem |
|
||||
| `MuseSparkWebExecutor` | Muse Spark (web) | Reversão de sessão web com integração de imagem-mensagem |
|
||||
| `NlpCloudExecutor` | NLP Cloud | Formato de corpo de requisição específico do provedor |
|
||||
| `OpenCodeExecutor` | OpenCode | Configuração de provedor compatível com AI SDK |
|
||||
| `PerplexityWebExecutor` | Perplexity web | Reversão de sessão web para continuidade de chat |
|
||||
| `PetalsExecutor` | Inferência distribuída Petals | Roteamento de enxame descentralizado |
|
||||
| `PollinationsExecutor` | Pollinations AI | Nenhuma chave de API necessária, requisições limitadas por taxa |
|
||||
| `PuterExecutor` | Puter | Integração de provedor baseada em navegador |
|
||||
| `QoderExecutor` | Qoder AI | Suporte a PAT e OAuth, nível gratuito de múltiplos modelos |
|
||||
| `QoderExecutor` | Qoder AI | Suporte a PAT e OAuth, nível gratuito multi-modelo |
|
||||
| `VertexExecutor` | Google Vertex AI | Autenticação de conta de serviço, endpoints baseados em região |
|
||||
| `WindsurfExecutor` | Windsurf (Codeium) | Atualização de token de sessão + OAuth do Codeium |
|
||||
|
||||
@@ -918,69 +918,69 @@ Todos os outros provedores (incluindo nós compatíveis personalizados) usam o `
|
||||
> **Nota:** A matriz abaixo é uma amostra representativa dos 177 provedores registrados no
|
||||
> OmniRoute v3.8.0. Para a lista canônica e continuamente atualizada, consulte
|
||||
> [`docs/reference/PROVIDER_REFERENCE.md`](../reference/PROVIDER_REFERENCE.md) (gerada automaticamente) ou a fonte
|
||||
> de verdade em `src/shared/constants/providers.ts` (validada pelo Zod no carregamento).
|
||||
> de verdade em `src/shared/constants/providers.ts` (validada pelo Zod na carga).
|
||||
|
||||
| Provedor | Formato | Autenticação | Stream | Não-Stream | Atualização de Token | API de Uso |
|
||||
| ----------------- | ---------------- | -------------------------- | ---------------- | ---------- | -------------------- | ----------------------- |
|
||||
| Claude | claude | Chave de API / OAuth | ✅ | ✅ | ✅ | ⚠️ Somente Admin |
|
||||
| Gemini | gemini | Chave de API / OAuth | ✅ | ✅ | ✅ | ⚠️ Console da Nuvem |
|
||||
| Gemini CLI | gemini-cli | OAuth | ✅ | ✅ | ✅ | ⚠️ Console da Nuvem |
|
||||
| Antigravity | antigravity | OAuth | ✅ | ✅ | ✅ | ✅ API de cota total |
|
||||
| OpenAI | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| Codex | openai-responses | OAuth | ✅ forçado | ❌ | ✅ | ✅ Limites de taxa |
|
||||
| GitHub Copilot | openai | OAuth + Token Copilot | ✅ | ✅ | ✅ | ✅ Instantâneas de cota |
|
||||
| Cursor | cursor | Checksum personalizado | ✅ | ✅ | ❌ | ❌ |
|
||||
| Kiro | kiro | AWS SSO OIDC | ✅ (EventStream) | ❌ | ✅ | ✅ Limites de uso |
|
||||
| Qwen | openai | OAuth | ✅ | ✅ | ✅ | ⚠️ Por solicitação |
|
||||
| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ Por solicitação |
|
||||
| Kilo Code | openai | OAuth | ✅ | ✅ | ✅ | ❌ |
|
||||
| Cline | openai | OAuth | ✅ | ✅ | ✅ | ❌ |
|
||||
| Kimi Coding | openai | OAuth | ✅ | ✅ | ✅ | ❌ |
|
||||
| OpenRouter | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| GLM/Kimi/MiniMax | claude | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| DeepSeek | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| Groq | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| xAI (Grok) | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| Mistral | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| Perplexity | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| Together AI | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| Fireworks AI | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| Cerebras | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| Cohere | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| NVIDIA NIM | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| Cloudflare AI | openai | Token de API + ID da conta | ✅ | ✅ | ❌ | ❌ |
|
||||
| Pollinations | openai | Nenhum (sem chave) | ✅ | ✅ | ❌ | ❌ |
|
||||
| Scaleway AI | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| LongCat | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| Ollama Cloud | openai | Chave de API (opcional) | ✅ | ✅ | ❌ | ❌ |
|
||||
| HuggingFace | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| Nebius | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| SiliconFlow | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| Hyperbolic | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| Vertex AI | gemini | Conta de Serviço | ✅ | ✅ | ✅ | ⚠️ Console da Nuvem |
|
||||
| Puter | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| Command Code | openai | OAuth | ✅ | ✅ | ✅ | ⚠️ Por solicitação |
|
||||
| Z.AI / GLM | openai | Chave de API / OAuth | ✅ | ✅ | ❌ | ❌ |
|
||||
| GLMT (preset) | claude | Chave de API | ✅ | ✅ | ❌ | ⚠️ Por solicitação |
|
||||
| Kimi Coding | openai | OAuth / Chave de API | ✅ | ✅ | ✅ | ❌ |
|
||||
| KIE | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| Windsurf | openai | OAuth (Codeium) | ✅ | ✅ | ✅ | ⚠️ Por solicitação |
|
||||
| GitLab Duo | openai | OAuth (GitLab) | ✅ | ✅ | ✅ | ❌ |
|
||||
| Devin CLI | openai | OAuth | ✅ | ✅ | ✅ | ✅ API de Tarefas |
|
||||
| Codex Cloud | openai-responses | OAuth | ✅ | ❌ | ✅ | ✅ Limites de taxa |
|
||||
| Jules | openai | OAuth | ✅ | ✅ | ✅ | ✅ API de Tarefas |
|
||||
| AgentRouter | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| ChatGPT-Web | openai | Cookie de sessão + TLS | ✅ | ✅ | ❌ | ❌ |
|
||||
| Grok-Web | openai | Cookie de sessão | ✅ | ✅ | ❌ | ❌ |
|
||||
| Perplexity-Web | openai | Cookie de sessão | ✅ | ✅ | ❌ | ❌ |
|
||||
| BlackBox-Web | openai | Cookie de sessão + TLS | ✅ | ✅ | ❌ | ❌ |
|
||||
| Muse-Spark-Web | openai | Cookie de sessão | ✅ | ✅ | ❌ | ❌ |
|
||||
| ModelScope | openai | Chave de API | ✅ | ✅ | ❌ | ⚠️ Política de cota |
|
||||
| BazaarLink | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| Petals | openai | Nenhum | ✅ | ✅ | ❌ | ❌ |
|
||||
| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ Por solicitação |
|
||||
| OpenCode (Go/Zen) | openai | OAuth | ✅ | ✅ | ✅ | ❌ |
|
||||
| CLIProxyAPI | openai | Personalizado | ✅ | ✅ | ❌ | ❌ |
|
||||
| Provedor | Formato | Autenticação | Stream | Não-Stream | Atualização de Token | API de Uso |
|
||||
| ----------------- | ---------------- | -------------------------- | ---------------- | ---------- | -------------------- | -------------------- |
|
||||
| Claude | claude | Chave de API / OAuth | ✅ | ✅ | ✅ | ⚠️ Somente Admin |
|
||||
| Gemini | gemini | Chave de API / OAuth | ✅ | ✅ | ✅ | ⚠️ Console da Nuvem |
|
||||
| Gemini CLI | gemini-cli | OAuth | ✅ | ✅ | ✅ | ⚠️ Console da Nuvem |
|
||||
| Antigravity | antigravity | OAuth | ✅ | ✅ | ✅ | ✅ API de cota total |
|
||||
| OpenAI | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| Codex | openai-responses | OAuth | ✅ forçado | ❌ | ✅ | ✅ Limites de taxa |
|
||||
| GitHub Copilot | openai | OAuth + Token Copilot | ✅ | ✅ | ✅ | ✅ Capturas de cota |
|
||||
| Cursor | cursor | Checksum personalizado | ✅ | ✅ | ❌ | ❌ |
|
||||
| Kiro | kiro | AWS SSO OIDC | ✅ (EventStream) | ❌ | ✅ | ✅ Limites de uso |
|
||||
| Qwen | openai | OAuth | ✅ | ✅ | ✅ | ⚠️ Por solicitação |
|
||||
| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ Por solicitação |
|
||||
| Kilo Code | openai | OAuth | ✅ | ✅ | ✅ | ❌ |
|
||||
| Cline | openai | OAuth | ✅ | ✅ | ✅ | ❌ |
|
||||
| Kimi Coding | openai | OAuth | ✅ | ✅ | ✅ | ❌ |
|
||||
| OpenRouter | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| GLM/Kimi/MiniMax | claude | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| DeepSeek | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| Groq | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| xAI (Grok) | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| Mistral | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| Perplexity | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| Together AI | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| Fireworks AI | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| Cerebras | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| Cohere | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| NVIDIA NIM | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| Cloudflare AI | openai | Token de API + ID da conta | ✅ | ✅ | ❌ | ❌ |
|
||||
| Pollinations | openai | Nenhum (sem chave) | ✅ | ✅ | ❌ | ❌ |
|
||||
| Scaleway AI | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| LongCat | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| Ollama Cloud | openai | Chave de API (opcional) | ✅ | ✅ | ❌ | ❌ |
|
||||
| HuggingFace | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| Nebius | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| SiliconFlow | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| Hyperbolic | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| Vertex AI | gemini | Conta de Serviço | ✅ | ✅ | ✅ | ⚠️ Console da Nuvem |
|
||||
| Puter | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| Command Code | openai | OAuth | ✅ | ✅ | ✅ | ⚠️ Por solicitação |
|
||||
| Z.AI / GLM | openai | Chave de API / OAuth | ✅ | ✅ | ❌ | ❌ |
|
||||
| GLMT (preset) | claude | Chave de API | ✅ | ✅ | ❌ | ⚠️ Por solicitação |
|
||||
| Kimi Coding | openai | OAuth / Chave de API | ✅ | ✅ | ✅ | ❌ |
|
||||
| KIE | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| Windsurf | openai | OAuth (Codeium) | ✅ | ✅ | ✅ | ⚠️ Por solicitação |
|
||||
| GitLab Duo | openai | OAuth (GitLab) | ✅ | ✅ | ✅ | ❌ |
|
||||
| Devin CLI | openai | OAuth | ✅ | ✅ | ✅ | ✅ API de Tarefas |
|
||||
| Codex Cloud | openai-responses | OAuth | ✅ | ❌ | ✅ | ✅ Limites de taxa |
|
||||
| Jules | openai | OAuth | ✅ | ✅ | ✅ | ✅ API de Tarefas |
|
||||
| AgentRouter | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| ChatGPT-Web | openai | Cookie de sessão + TLS | ✅ | ✅ | ❌ | ❌ |
|
||||
| Grok-Web | openai | Cookie de sessão | ✅ | ✅ | ❌ | ❌ |
|
||||
| Perplexity-Web | openai | Cookie de sessão | ✅ | ✅ | ❌ | ❌ |
|
||||
| BlackBox-Web | openai | Cookie de sessão + TLS | ✅ | ✅ | ❌ | ❌ |
|
||||
| Muse-Spark-Web | openai | Cookie de sessão | ✅ | ✅ | ❌ | ❌ |
|
||||
| ModelScope | openai | Chave de API | ✅ | ✅ | ❌ | ⚠️ Política de cota |
|
||||
| BazaarLink | openai | Chave de API | ✅ | ✅ | ❌ | ❌ |
|
||||
| Petals | openai | Nenhum | ✅ | ✅ | ❌ | ❌ |
|
||||
| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ Por solicitação |
|
||||
| OpenCode (Go/Zen) | openai | OAuth | ✅ | ✅ | ✅ | ❌ |
|
||||
| CLIProxyAPI | openai | Personalizado | ✅ | ✅ | ❌ | ❌ |
|
||||
|
||||
## Cobertura de Tradução de Formato
|
||||
|
||||
@@ -1005,36 +1005,36 @@ As traduções usam **OpenAI como o formato central** — todas as conversões p
|
||||
Formato de Origem → OpenAI (central) → Formato de Destino
|
||||
```
|
||||
|
||||
As traduções são selecionadas dinamicamente com base na forma da carga útil de origem e no formato de destino do provedor.
|
||||
As traduções são selecionadas dinamicamente com base na forma do payload de origem e no formato de destino do provedor.
|
||||
|
||||
Camadas de processamento adicionais no pipeline de tradução:
|
||||
|
||||
- **Sanitização de resposta** — Remove campos não padrão das respostas no formato OpenAI (tanto streaming quanto não streaming) para garantir conformidade estrita com o SDK
|
||||
- **Normalização de função** — Converte `developer` → `system` para destinos que não são OpenAI; mescla `system` → `user` para modelos que rejeitam a função de sistema (GLM, ERNIE)
|
||||
- **Normalização de função** — Converte `developer` → `system` para alvos que não são OpenAI; mescla `system` → `user` para modelos que rejeitam a função de sistema (GLM, ERNIE)
|
||||
- **Extração de tag de pensamento** — Analisa blocos ``do conteúdo para o campo`reasoning_content`
|
||||
- **Saída estruturada** — Converte `response_format.json_schema` do OpenAI para `responseMimeType` + `responseSchema` do Gemini
|
||||
|
||||
## Endpoints da API Suportados
|
||||
## Endpoints de API Suportados
|
||||
|
||||
| Endpoint | Formato | Manipulador |
|
||||
| -------------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| `POST /v1/chat/completions` | OpenAI Chat | `src/sse/handlers/chat.ts` |
|
||||
| `POST /v1/messages` | Claude Messages | Mesmo manipulador (detecção automática) |
|
||||
| `POST /v1/responses` | OpenAI Responses | `open-sse/handlers/responsesHandler.ts` |
|
||||
| `POST /v1/embeddings` | OpenAI Embeddings | `open-sse/handlers/embeddings.ts` |
|
||||
| `GET /v1/embeddings` | Listagem de Modelos | Rota da API |
|
||||
| `POST /v1/images/generations` | OpenAI Images | `open-sse/handlers/imageGeneration.ts` |
|
||||
| `GET /v1/images/generations` | Listagem de Modelos | Rota da API |
|
||||
| `POST /v1/providers/{provider}/chat/completions` | OpenAI Chat | Dedicado por provedor com validação de modelo |
|
||||
| `POST /v1/providers/{provider}/embeddings` | OpenAI Embeddings | Dedicado por provedor com validação de modelo |
|
||||
| `POST /v1/providers/{provider}/images/generations` | OpenAI Images | Dedicado por provedor com validação de modelo |
|
||||
| `POST /v1/messages/count_tokens` | Contagem de Tokens Claude | Rota da API |
|
||||
| `GET /v1/models` | Lista de Modelos OpenAI | Rota da API (chat + embedding + image + modelos personalizados) |
|
||||
| `GET /api/models/catalog` | Catálogo | Todos os modelos agrupados por provedor + tipo |
|
||||
| `POST /v1beta/models/*:streamGenerateContent` | Nativo do Gemini | Rota da API |
|
||||
| `GET/PUT/DELETE /api/settings/proxy` | Configuração de Proxy | Configuração de proxy de rede |
|
||||
| `POST /api/settings/proxy/test` | Conectividade de Proxy | Endpoint de teste de saúde/conectividade do proxy |
|
||||
| `GET/POST/DELETE /api/provider-models` | Modelos de Provedor | Metadados do modelo do provedor que suportam modelos disponíveis personalizados e gerenciados |
|
||||
| Endpoint | Formato | Manipulador |
|
||||
| -------------------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `POST /v1/chat/completions` | OpenAI Chat | `src/sse/handlers/chat.ts` |
|
||||
| `POST /v1/messages` | Claude Messages | Mesmo manipulador (detecção automática) |
|
||||
| `POST /v1/responses` | OpenAI Responses | `open-sse/handlers/responsesHandler.ts` |
|
||||
| `POST /v1/embeddings` | OpenAI Embeddings | `open-sse/handlers/embeddings.ts` |
|
||||
| `GET /v1/embeddings` | Listagem de modelos | Rota da API |
|
||||
| `POST /v1/images/generations` | OpenAI Images | `open-sse/handlers/imageGeneration.ts` |
|
||||
| `GET /v1/images/generations` | Listagem de modelos | Rota da API |
|
||||
| `POST /v1/providers/{provider}/chat/completions` | OpenAI Chat | Dedicado por provedor com validação de modelo |
|
||||
| `POST /v1/providers/{provider}/embeddings` | OpenAI Embeddings | Dedicado por provedor com validação de modelo |
|
||||
| `POST /v1/providers/{provider}/images/generations` | OpenAI Images | Dedicado por provedor com validação de modelo |
|
||||
| `POST /v1/messages/count_tokens` | Contagem de Tokens Claude | Rota da API |
|
||||
| `GET /v1/models` | Lista de Modelos OpenAI | Rota da API (chat + embedding + imagem + modelos personalizados) |
|
||||
| `GET /api/models/catalog` | Catálogo | Todos os modelos agrupados por provedor + tipo |
|
||||
| `POST /v1beta/models/*:streamGenerateContent` | Nativo do Gemini | Rota da API |
|
||||
| `GET/PUT/DELETE /api/settings/proxy` | Configuração de Proxy | Configuração de proxy de rede |
|
||||
| `POST /api/settings/proxy/test` | Conectividade de Proxy | Endpoint de teste de saúde/conectividade do proxy |
|
||||
| `GET/POST/DELETE /api/provider-models` | Modelos de Provedor | Metadados do modelo do provedor que sustentam modelos disponíveis personalizados e gerenciados |
|
||||
|
||||
## Manipulador de Bypass
|
||||
|
||||
@@ -1042,9 +1042,9 @@ O manipulador de bypass (`open-sse/utils/bypassHandler.ts`) intercepta solicita
|
||||
|
||||
## Registro de Solicitações e Artefatos
|
||||
|
||||
O antigo registrador de solicitações baseado em arquivo (`open-sse/utils/requestLogger.ts`) é mantido apenas para compatibilidade com versões anteriores. O contrato de tempo de execução atual utiliza:
|
||||
O antigo registrador de solicitações baseado em arquivo (`open-sse/utils/requestLogger.ts`) é mantido apenas para compatibilidade com versões anteriores. O contrato de tempo de execução atual usa:
|
||||
|
||||
- `APP_LOG_TO_FILE=true` para logs de aplicação e auditoria escritos em `<repo>/logs/`
|
||||
- `APP_LOG_TO_FILE=true` para logs de aplicação e auditoria gravados em `<repo>/logs/`
|
||||
- Registros de log de chamadas com suporte a SQLite em `call_logs`
|
||||
- Artefatos em `${DATA_DIR}/call_logs/YYYY-MM-DD/...` quando o pipeline de log de chamadas está habilitado
|
||||
|
||||
@@ -1061,18 +1061,18 @@ O antigo registrador de solicitações baseado em arquivo (`open-sse/utils/reque
|
||||
- pré-verificação e atualização com nova tentativa para provedores atualizáveis
|
||||
- nova tentativa 401/403 após tentativa de atualização no caminho principal
|
||||
|
||||
## 3) Segurança de Stream
|
||||
## 3) Segurança do Stream
|
||||
|
||||
- controlador de stream ciente de desconexões
|
||||
- stream de tradução com descarte de fim de stream e tratamento de `[DONE]`
|
||||
- fallback de estimativa de uso quando os metadados de uso do provedor estão ausentes
|
||||
|
||||
## 4) Degradação de Sincronização em Nuvem
|
||||
## 4) Degradação da Sincronização na Nuvem
|
||||
|
||||
- erros de sincronização são exibidos, mas o tempo de execução local continua
|
||||
- o agendador possui lógica capaz de nova tentativa, mas a execução periódica atualmente chama a sincronização de tentativa única por padrão
|
||||
|
||||
## 5) Integridade de Dados
|
||||
## 5) Integridade dos Dados
|
||||
|
||||
- migrações de esquema SQLite e ganchos de autoatualização na inicialização
|
||||
- caminho de compatibilidade de migração legado JSON → SQLite
|
||||
@@ -1100,15 +1100,15 @@ A captura detalhada do payload da solicitação armazena até quatro estágios d
|
||||
- solicitação bruta recebida do cliente
|
||||
- solicitação traduzida realmente enviada para upstream
|
||||
- resposta do provedor reconstruída como JSON; respostas transmitidas são compactadas para o resumo final mais metadados do stream
|
||||
- resposta final do cliente retornada pelo OmniRoute; respostas transmitidas são armazenadas na mesma forma de resumo compactado
|
||||
- resposta final do cliente retornada pelo OmniRoute; respostas transmitidas são armazenadas na mesma forma de resumo compacto
|
||||
|
||||
## Limites Sensíveis à Segurança
|
||||
|
||||
- O segredo do JWT (`JWT_SECRET`) protege a verificação/assinatura do cookie de sessão do painel
|
||||
- A senha inicial de bootstrap (`INITIAL_PASSWORD`) deve ser configurada explicitamente para o provisionamento na primeira execução
|
||||
- O segredo HMAC da chave da API (`API_KEY_SECRET`) protege o formato da chave da API local gerada
|
||||
- Segredos do provedor (chaves/tokens da API) são persistidos no banco de dados local e devem ser protegidos a nível de sistema de arquivos
|
||||
- Os endpoints de sincronização em nuvem dependem da autenticação da chave da API + semântica do id da máquina
|
||||
- Segredos do provedor (chaves/token da API) são persistidos no banco de dados local e devem ser protegidos a nível de sistema de arquivos
|
||||
- Os endpoints de sincronização na nuvem dependem da autenticação da chave da API + semântica do ID da máquina
|
||||
|
||||
## Matriz de Ambiente e Tempo de Execução
|
||||
|
||||
@@ -1117,7 +1117,7 @@ Variáveis de ambiente ativamente usadas pelo código:
|
||||
- App/auth: `JWT_SECRET`, `INITIAL_PASSWORD`
|
||||
- Armazenamento: `DATA_DIR`
|
||||
- Comportamento compatível do node: `ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE`
|
||||
- Substituição opcional da base de armazenamento (Linux/macOS quando `DATA_DIR` não definido): `XDG_CONFIG_HOME`
|
||||
- Substituição opcional da base de armazenamento (Linux/macOS quando `DATA_DIR` não estiver definido): `XDG_CONFIG_HOME`
|
||||
- Hashing de segurança: `API_KEY_SECRET`, `MACHINE_ID_SALT`
|
||||
- Registro: `APP_LOG_TO_FILE`, `APP_LOG_RETENTION_DAYS`, `CALL_LOG_RETENTION_DAYS`
|
||||
- URL de sincronização/nuvem: `NEXT_PUBLIC_BASE_URL`, `NEXT_PUBLIC_CLOUD_URL`
|
||||
@@ -1128,15 +1128,15 @@ Variáveis de ambiente ativamente usadas pelo código:
|
||||
## Notas Arquitetônicas Conhecidas
|
||||
|
||||
1. `usageDb` e `localDb` compartilham a mesma política de diretório base (`DATA_DIR` -> `XDG_CONFIG_HOME/omniroute` -> `~/.omniroute`) com migração de arquivos legados.
|
||||
2. `/api/v1/route.ts` delega para o mesmo construtor de catálogo unificado usado por `/api/v1/models` (`src/app/api/v1/models/catalog.ts`) para evitar desvios semânticos.
|
||||
3. O logger de requisições escreve cabeçalhos/corpo completos quando habilitado; trate o diretório de logs como sensível.
|
||||
4. O comportamento em nuvem depende do correto `NEXT_PUBLIC_BASE_URL` e da acessibilidade do endpoint em nuvem.
|
||||
5. O diretório `open-sse/` é publicado como o pacote **npm workspace** `@omniroute/open-sse`. O código-fonte o importa via `@omniroute/open-sse/...` (resolvido pelo Next.js `transpilePackages`). Os caminhos de arquivos neste documento ainda usam o nome do diretório `open-sse/` para consistência.
|
||||
6. Gráficos no painel usam **Recharts** (baseado em SVG) para visualizações analíticas interativas e acessíveis (gráficos de barras de uso de modelo, tabelas de quebra de provedor com taxas de sucesso).
|
||||
2. `/api/v1/route.ts` delega ao mesmo construtor de catálogo unificado usado por `/api/v1/models` (`src/app/api/v1/models/catalog.ts`) para evitar desvios semânticos.
|
||||
3. O registrador de requisições escreve cabeçalhos/corpo completos quando habilitado; trate o diretório de logs como sensível.
|
||||
4. O comportamento na nuvem depende do correto `NEXT_PUBLIC_BASE_URL` e da acessibilidade do endpoint da nuvem.
|
||||
5. O diretório `open-sse/` é publicado como o pacote de **workspace npm** `@omniroute/open-sse`. O código-fonte o importa via `@omniroute/open-sse/...` (resolvido pelo Next.js `transpilePackages`). Os caminhos de arquivo neste documento ainda usam o nome do diretório `open-sse/` para consistência.
|
||||
6. Gráficos no painel usam **Recharts** (baseado em SVG) para visualizações analíticas acessíveis e interativas (gráficos de barras de uso de modelo, tabelas de quebra de provedor com taxas de sucesso).
|
||||
7. Testes E2E usam **Playwright** (`tests/e2e/`), executados via `npm run test:e2e`. Testes unitários usam **Node.js test runner** (`tests/unit/`), executados via `npm run test:unit`. O código-fonte sob `src/` é **TypeScript** (`.ts`/`.tsx`); o workspace `open-sse/` permanece em JavaScript (`.js`).
|
||||
8. A página de configurações é organizada em 7 abas: Geral, Aparência, IA, Segurança, Roteamento, Resiliência, Avançado. A página de Resiliência configura apenas a fila de requisições, o tempo de espera de conexão, o quebra-provedor e o comportamento de espera; o estado de tempo de execução do quebra ao vivo é mostrado na página de Saúde.
|
||||
9. A estratégia **Context Relay** (`context-relay`) é dividida em duas camadas: `combo.ts` decide se uma transferência deve ser gerada, `chat.ts` injeta a transferência após a resolução da conta. Os dados da transferência vivem na tabela SQLite `context_handoffs`. Essa divisão é intencional porque apenas `chat.ts` sabe se a conta real mudou.
|
||||
10. A **imposição de proxy** agora é abrangente: `tokenHealthCheck.ts` resolve o proxy por conexão, `/api/providers/validate` usa `runWithProxyContext`, e `proxyFetch.ts` usa `undici.fetch()` para manter a compatibilidade do despachante no Node 22.
|
||||
8. A página de configurações é organizada em 7 abas: Geral, Aparência, IA, Segurança, Roteamento, Resiliência, Avançado. A página de Resiliência configura apenas a fila de requisições, o tempo de espera de conexão, o quebra-provedor e o comportamento de espera pelo tempo de espera; o estado de tempo de execução do quebra ao vivo é mostrado na página de Saúde.
|
||||
9. A estratégia de **Context Relay** (`context-relay`) é dividida em duas camadas: `combo.ts` decide se uma transferência deve ser gerada, `chat.ts` injeta a transferência após a resolução da conta. Os dados da transferência vivem na tabela SQLite `context_handoffs`. Essa divisão é intencional porque apenas `chat.ts` sabe se a conta real mudou.
|
||||
10. A **aplicação de proxy** agora é abrangente: `tokenHealthCheck.ts` resolve o proxy por conexão, `/api/providers/validate` usa `runWithProxyContext`, e `proxyFetch.ts` usa `undici.fetch()` para manter a compatibilidade do despachante no Node 22.
|
||||
11. **Detecção de política de tempo de execução do Node.js**: `/api/settings/require-login` retorna os campos `nodeVersion` e `nodeCompatible`. A página de login renderiza um banner de aviso quando o tempo de execução está fora das linhas seguras suportadas do Node.js.
|
||||
|
||||
## Lista de Verificação de Verificação Operacional
|
||||
|
||||
346
docs/ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md
Normal file
346
docs/ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md
Normal file
@@ -0,0 +1,346 @@
|
||||
# E2E Dashboard Shakedown — v3.8.0
|
||||
|
||||
**Branch alvo:** `release/v3.8.0`
|
||||
**Objetivo:** validar manualmente, em modo dev (Turbopack), que toda página renderiza sem erro de runtime ou de backend antes de fechar a versão 3.8.0. Para cada erro encontrado, o operador **corrige na própria página** e segue para a próxima — esse documento é o roteiro vivo da sessão.
|
||||
|
||||
> Este é um plano de **smoke test manual operacional**, não uma suíte automatizada. Pareceu didático demais? É proposital: o objetivo é que outro mantenedor consiga retomar do meio se a sessão for interrompida.
|
||||
|
||||
---
|
||||
|
||||
## 0. Pré-requisitos (rodar uma vez)
|
||||
|
||||
### 0.1 Estado do repositório
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git checkout release/v3.8.0
|
||||
git pull origin release/v3.8.0 --ff-only
|
||||
git status # working tree limpo
|
||||
```
|
||||
|
||||
### 0.2 Conflito conhecido — diretório `app/` na raiz
|
||||
|
||||
O `npm pack` e `npm run build` geram `app/` na raiz (gitignored, mirror de `src/app/`). Se ele existir, **o Next.js dev prefere a raiz e quebra todas as rotas** (Turbopack devolve `PageNotFoundError: Cannot find module for page: route not found /(dashboard)/...`).
|
||||
|
||||
```bash
|
||||
[ -d app ] && mv app /tmp/omniroute-pack-artifact-$(date +%s)
|
||||
ls -d app 2>/dev/null && echo "STILL THERE — abortar" || echo "ok"
|
||||
```
|
||||
|
||||
### 0.3 Cache do Turbopack
|
||||
|
||||
```bash
|
||||
rm -rf .next/dev
|
||||
```
|
||||
|
||||
### 0.4 Dev server
|
||||
|
||||
Em um terminal dedicado:
|
||||
|
||||
```bash
|
||||
npm run dev 2>&1 | tee /tmp/omniroute-dev.log
|
||||
```
|
||||
|
||||
Esperar `Ready` e `Local: http://localhost:20128`. Mantenha o terminal visível durante toda a sessão — é a fonte primária de erros de backend.
|
||||
|
||||
### 0.5 Browser
|
||||
|
||||
- Chrome com **DevTools aberto** (F12), aba **Console** ativa, **filtro `error|warning`**, e a aba **Network** com "Preserve log" marcado.
|
||||
- Limpar o console entre páginas (`Ctrl+L`) para isolar o ruído.
|
||||
- Login com a conta admin antes de começar (algumas páginas só carregam autenticado).
|
||||
|
||||
### 0.6 Side-channel — busca por erros no backend
|
||||
|
||||
Em outro terminal:
|
||||
|
||||
```bash
|
||||
tail -F /tmp/omniroute-dev.log | grep --line-buffered -iE "error|warn|cannot|undefined|TypeError|PageNotFoundError"
|
||||
```
|
||||
|
||||
Mantenha aberto. Se algo aparecer enquanto você está em uma página, anote na coluna **Erros** da linha correspondente.
|
||||
|
||||
---
|
||||
|
||||
## 1. O que conta como "passou"
|
||||
|
||||
Uma página passa quando **todas** estas condições são atendidas:
|
||||
|
||||
1. HTTP final é `200` (não `4xx`/`5xx`). Redirects (`307`/`302`) só são aceitáveis se intencionais (ex.: `/dashboard` → `/home`).
|
||||
2. Nenhum **error overlay** do Turbopack/React aparece na tela.
|
||||
3. Nenhum `console.error` no DevTools (warnings são toleráveis, mas anote os novos).
|
||||
4. Nenhuma stack trace nova no `/tmp/omniroute-dev.log`. Erros pré-existentes recorrentes (refresh de token de provider sem credencial, p.ex.) podem ser ignorados — mas confirme que são os mesmos de antes.
|
||||
5. Conteúdo principal da página renderiza (não apenas o layout/sidebar vazio).
|
||||
6. Pelo menos uma interação básica funciona (clique em uma aba, filtro, ou link interno) sem erro.
|
||||
|
||||
Se qualquer um falhar → **status `❌`**, descreva o sintoma na coluna **Erros**, corrija, recarregue, marque `✅` quando passar.
|
||||
|
||||
---
|
||||
|
||||
## 2. Categorias de erro mais comuns e padrão de correção
|
||||
|
||||
| Sintoma | Lugar onde aparece | Causa típica | Onde corrigir |
|
||||
| ---------------------------------------------------------------- | -------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ |
|
||||
| `PageNotFoundError: route not found /(group)/...` | Tela vermelha do Turbopack | `app/` na raiz, ou `.next/dev/` stale | Voltar à §0.2/0.3 |
|
||||
| `Cannot find module 'X'` em runtime | Tela ou log | Import inexistente, alias quebrado | Corrigir o import; checar `tsconfig.json` |
|
||||
| `Hydration failed because the server rendered HTML didn't match` | Console | Date/Math.random no render server, ou `useEffect` fora de `"use client"` | Mover lógica para `useEffect`, ou marcar a sub-árvore como `dynamic="force-dynamic"` |
|
||||
| `500` em rota de API chamada pela página | Network + log | Zod parse fail, DB error, validador de input | Ver o handler em `src/app/api/.../route.ts` e o módulo em `src/lib/db/` |
|
||||
| `Error: Text content does not match server-rendered HTML` | Console | i18n key faltando, ou string diferente entre SSR/CSR | `npm run i18n:run -- --files=<arquivo>` ou adicionar a chave |
|
||||
| Skeleton infinito | Tela | `useEffect` busca dados de uma API que retorna 401/500 | Conferir auth/proxy/middleware; testar a rota com `curl -H "cookie:..."` |
|
||||
| Sidebar/layout não renderiza | Tela | `(dashboard)/layout.tsx` falhando | Olhar o `DashboardLayout` em `src/shared/components/` |
|
||||
| Botão/tab dispara erro ao clicar | Console | Provider/context ausente, mock removido | Verificar providers no `(dashboard)/layout.tsx` |
|
||||
|
||||
**Regra de ouro:** se a correção exigir mais de ~20 linhas ou cruza módulos do `open-sse/`, anote como `bloqueador` e siga para a próxima página — não trave a release por um refactor.
|
||||
|
||||
---
|
||||
|
||||
## 3. Checklist de páginas (ordem sugerida)
|
||||
|
||||
Marque conforme avança. A ordem segue a sidebar (top → bottom) com as páginas órfãs no final. URLs assumem `http://localhost:20128`.
|
||||
|
||||
### 3.1 Auth & público (rodar **deslogado** primeiro, depois logar)
|
||||
|
||||
| Status | URL | O que validar | Erros |
|
||||
| ------ | ---------------------------------------------------------------------------- | ---------------------------------------------------- | ----- |
|
||||
| ☐ | `/` | Redireciona para `/login` ou `/home` conforme sessão | |
|
||||
| ☐ | `/login` | Form aparece, validação de campo vazio funciona | |
|
||||
| ☐ | `/forgot-password` | Form aparece | |
|
||||
| ☐ | `/landing` | Renderiza sem CSS quebrado | |
|
||||
| ☐ | `/docs` | Index dos docs carrega | |
|
||||
| ☐ | `/docs/api-explorer` | OpenAPI explorer carrega | |
|
||||
| ☐ | `/docs/quickstart` (ex. slug) | Markdown renderiza | |
|
||||
| ☐ | `/status` | Status page renderiza | |
|
||||
| ☐ | `/terms` | Texto carrega | |
|
||||
| ☐ | `/privacy` | Texto carrega | |
|
||||
| ☐ | `/maintenance` | Página estática | |
|
||||
| ☐ | `/offline` | Página estática | |
|
||||
| ☐ | `/forbidden`, `/400`, `/401`, `/403`, `/408`, `/429`, `/500`, `/502`, `/503` | Cada uma renderiza sem erro recursivo | |
|
||||
|
||||
> Após confirmar o `/login`, autentique e siga.
|
||||
|
||||
### 3.2 Sidebar — Home
|
||||
|
||||
| Status | URL | Validação | Erros |
|
||||
| ------ | ------------ | --------------------------------------------------- | ----- |
|
||||
| ☐ | `/dashboard` | Redireciona para `/home` (HTTP 307) | |
|
||||
| ☐ | `/home` | Cards de overview renderizam, sem skeleton infinito | |
|
||||
|
||||
### 3.3 Sidebar — OmniProxy
|
||||
|
||||
| Status | URL | Validação | Erros |
|
||||
| ------ | --------------------------------------------------------- | ------------------------------------------------------------- | ----- |
|
||||
| ☐ | `/dashboard/endpoint` | Lista de endpoints + tabs | |
|
||||
| ☐ | `/dashboard/api-manager` | Lista de API keys, botão "Create" abre modal | |
|
||||
| ☐ | `/dashboard/providers` | Tabela de providers carrega; filtro de status funciona | |
|
||||
| ☐ | `/dashboard/providers/new` | Form de novo provider; campos condicionais respondem | |
|
||||
| ☐ | `/dashboard/providers/anthropic` (qualquer `[id]` válido) | Detalhe do provider; abas "Connections", "Models", "Validate" | |
|
||||
| ☐ | `/dashboard/combos` | Lista de combos, drag/drop, modo cost-optimized | |
|
||||
| ☐ | `/dashboard/quota` | Quotas globais por provider/modelo | |
|
||||
|
||||
#### 3.3.1 Compressão e Contexto
|
||||
|
||||
| Status | URL | Validação | Erros |
|
||||
| ------ | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ----- |
|
||||
| ☐ | `/dashboard/context/caveman` | Regras Caveman + preview ao vivo | |
|
||||
| ☐ | `/dashboard/context/rtk` | DSL editor (Monaco) — **atenção:** se Monaco falhar com `vs/nls.messages-loader`, é regressão do fix do `MonacoEditor.tsx` | |
|
||||
| ☐ | `/dashboard/context/combos` | Pipeline RTK→Caveman | |
|
||||
|
||||
#### 3.3.2 Ferramentas
|
||||
|
||||
| Status | URL | Validação | Erros |
|
||||
| ------ | ------------------------- | -------------------------------------------------------- | ----- |
|
||||
| ☐ | `/dashboard/cli-tools` | Lista de tools, botão "Generate config" responde sem 500 | |
|
||||
| ☐ | `/dashboard/agents` | Lista de agents | |
|
||||
| ☐ | `/dashboard/cloud-agents` | 3 cloud agents listados (codex-cloud, devin, jules) | |
|
||||
|
||||
#### 3.3.3 Integrações
|
||||
|
||||
| Status | URL | Validação | Erros |
|
||||
| ------ | -------------------------- | --------------------------------- | ----- |
|
||||
| ☐ | `/dashboard/api-endpoints` | OpenAPI auto-doc renderiza | |
|
||||
| ☐ | `/dashboard/webhooks` | Form de webhook + lista de events | |
|
||||
|
||||
#### 3.3.4 Proxy
|
||||
|
||||
| Status | URL | Validação | Erros |
|
||||
| ------ | ------------------------------ | ----------------------------------- | ----- |
|
||||
| ☐ | `/dashboard/system/proxy` | Config de proxy global/per-provider | |
|
||||
| ☐ | `/dashboard/system/mitm-proxy` | Cert install button, status | |
|
||||
| ☐ | `/dashboard/system/1proxy` | UI da feature 1proxy | |
|
||||
|
||||
### 3.4 Sidebar — Analytics
|
||||
|
||||
| Status | URL | Validação | Erros |
|
||||
| ------ | ----------------------------------- | ----------------------------------------- | ----- |
|
||||
| ☐ | `/dashboard/analytics` | Dashboard de uso geral, gráficos carregam | |
|
||||
| ☐ | `/dashboard/analytics/combo-health` | Tabela + sparklines por combo | |
|
||||
| ☐ | `/dashboard/analytics/utilization` | Heatmap | |
|
||||
| ☐ | `/dashboard/costs` | Custo total + breakdown | |
|
||||
| ☐ | `/dashboard/cache` | Métricas de cache, hit-rate | |
|
||||
| ☐ | `/dashboard/analytics/compression` | Métricas de RTK/Caveman | |
|
||||
| ☐ | `/dashboard/analytics/search` | Métricas de search providers | |
|
||||
| ☐ | `/dashboard/analytics/evals` | Suítes de eval + run history | |
|
||||
|
||||
### 3.5 Sidebar — Monitoring
|
||||
|
||||
| Status | URL | Validação | Erros |
|
||||
| ------ | -------------------------- | ---------------------------------------------------------- | ----- |
|
||||
| ☐ | `/dashboard/logs` | Hub de logs | |
|
||||
| ☐ | `/dashboard/logs/proxy` | Tail de requisições (live) — confirmar reconexão se houver | |
|
||||
| ☐ | `/dashboard/logs/console` | Console capturado | |
|
||||
| ☐ | `/dashboard/logs/activity` | Atividade do usuário | |
|
||||
| ☐ | `/dashboard/health` | Status dos providers (circuit breakers, cooldowns) | |
|
||||
| ☐ | `/dashboard/runtime` | Métricas runtime + memória/CPU | |
|
||||
|
||||
#### 3.5.1 Costs / Parameters
|
||||
|
||||
| Status | URL | Validação | Erros |
|
||||
| ------ | ------------------------------ | ---------------------------------------- | ----- |
|
||||
| ☐ | `/dashboard/costs/pricing` | Tabela pricing por modelo, edição inline | |
|
||||
| ☐ | `/dashboard/costs/budget` | Limites mensais + alertas | |
|
||||
| ☐ | `/dashboard/costs/quota-share` | Preview de quota sharing | |
|
||||
|
||||
#### 3.5.2 Audit
|
||||
|
||||
| Status | URL | Validação | Erros |
|
||||
| ------ | ---------------------- | ------------------------------- | ----- |
|
||||
| ☐ | `/dashboard/audit` | Lista de eventos audit, filtros | |
|
||||
| ☐ | `/dashboard/audit/mcp` | Audit do MCP server | |
|
||||
| ☐ | `/dashboard/audit/a2a` | Audit do A2A | |
|
||||
|
||||
### 3.6 Sidebar — DevTools
|
||||
|
||||
| Status | URL | Validação | Erros |
|
||||
| ------ | ------------------------- | ------------------------------------- | ----- |
|
||||
| ☐ | `/dashboard/translator` | OpenAI ↔ Claude ↔ Gemini side-by-side | |
|
||||
| ☐ | `/dashboard/playground` | Chat playground, streaming funciona | |
|
||||
| ☐ | `/dashboard/search-tools` | Lista de search providers | |
|
||||
|
||||
### 3.7 Sidebar — Agentic Features
|
||||
|
||||
| Status | URL | Validação | Erros |
|
||||
| ------ | ------------------------- | ------------------------------------ | ----- |
|
||||
| ☐ | `/dashboard/mcp` | MCP server config, 37 tools listadas | |
|
||||
| ☐ | `/dashboard/memory` | Memory store, FTS5 search | |
|
||||
| ☐ | `/dashboard/skills` | 10 skills publicadas | |
|
||||
| ☐ | `/dashboard/agent-skills` | Skill assignment per agent | |
|
||||
| ☐ | `/dashboard/a2a` | A2A registry + 5 skills | |
|
||||
|
||||
### 3.8 Sidebar — Other Features
|
||||
|
||||
| Status | URL | Validação | Erros |
|
||||
| ------ | ------------------------------- | --------------------------------- | ----- |
|
||||
| ☐ | `/dashboard/leaderboard` | Ranking + filtros | |
|
||||
| ☐ | `/dashboard/profile` | Perfil do user, edição básica | |
|
||||
| ☐ | `/dashboard/tokens` | Tokens & API keys do user | |
|
||||
| ☐ | `/dashboard/gamification/admin` | Admin-only — só logado como admin | |
|
||||
| ☐ | `/dashboard/cache/media` | Cache de mídia (imagens/áudio) | |
|
||||
| ☐ | `/dashboard/batch` | Batch jobs | |
|
||||
| ☐ | `/dashboard/batch/files` | Files API | |
|
||||
|
||||
### 3.9 Sidebar — Configuration
|
||||
|
||||
| Status | URL | Validação | Erros |
|
||||
| ------ | -------------------------------- | ------------------------------ | ----- |
|
||||
| ☐ | `/dashboard/settings` | Hub redireciona/exibe sub-tabs | |
|
||||
| ☐ | `/dashboard/settings/general` | Form salva sem 500 | |
|
||||
| ☐ | `/dashboard/settings/appearance` | Trocar tema aplica | |
|
||||
| ☐ | `/dashboard/settings/ai` | Config de AI models | |
|
||||
| ☐ | `/dashboard/settings/routing` | Estratégias de combo | |
|
||||
| ☐ | `/dashboard/settings/resilience` | Circuit breaker / cooldown | |
|
||||
| ☐ | `/dashboard/settings/advanced` | Toggles avançados | |
|
||||
| ☐ | `/dashboard/settings/security` | Auth, sessões, 2FA | |
|
||||
| ☐ | `/dashboard/settings/pricing` | Settings de pricing (legado) | |
|
||||
|
||||
### 3.10 Sidebar — Help
|
||||
|
||||
| Status | URL | Validação | Erros |
|
||||
| ------ | --------------------------- | ---------------------------------- | ----- |
|
||||
| ☐ | `/docs` (já testado em 3.1) | — | |
|
||||
| ☐ | `/dashboard/changelog` | Renderiza markdown do CHANGELOG.md | |
|
||||
|
||||
### 3.11 Páginas órfãs (existem como rota mas não estão na sidebar)
|
||||
|
||||
Testar para garantir que não estão quebradas (alguém pode ter link antigo bookmarkado).
|
||||
|
||||
| Status | URL | Validação | Erros |
|
||||
| ------ | ------------------------ | -------------------------------------------------------------- | ------------------------ |
|
||||
| ☐ | `/dashboard/auto-combo` | Página de Auto-Combo (9-factor scoring) | |
|
||||
| ☐ | `/dashboard/compression` | (legado — pode ter sido absorvido por `analytics/compression`) | |
|
||||
| ☐ | `/dashboard/limits` | Rate limits | |
|
||||
| ☐ | `/dashboard/onboarding` | Wizard de primeiro setup | |
|
||||
| ☐ | `/dashboard/usage` | Stats de uso (legado) | |
|
||||
| ☐ | `/auth/callback` | OAuth callback — só funciona via flow real | (não testar manualmente) |
|
||||
| ☐ | `/callback` | Mesmo do anterior | (não testar manualmente) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Procedimento por página
|
||||
|
||||
Para cada linha do checklist:
|
||||
|
||||
1. **Limpar console do DevTools** (`Ctrl+L`).
|
||||
2. **Navegar** clicando na sidebar (preferível a digitar URL — testa também a navegação).
|
||||
3. **Esperar carregar** (até o spinner sumir e o conteúdo principal aparecer; timeout subjetivo: 10s).
|
||||
4. **Olhar o DevTools Console**: qualquer `error` vermelho conta.
|
||||
5. **Olhar o terminal do `tail -F /tmp/omniroute-dev.log`**: stack trace nova = falha.
|
||||
6. **Interagir** com o elemento óbvio da página (1 clique em filtro/aba/CTA). Se o clique disparar erro, falha.
|
||||
7. **Marcar `✅`** se ok, **`❌` + nota** se falhar.
|
||||
8. **Se falhar**:
|
||||
a. Categorizar pela tabela §2.
|
||||
b. Corrigir.
|
||||
c. Salvar; aguardar Turbopack recompilar (~2-5s; olhar o terminal do dev server).
|
||||
d. Recarregar a página; refazer §1–6.
|
||||
e. Quando passar, atualizar a coluna **Erros** desta linha com "Fix: <resumo do que mudou>" e marcar `✅`.
|
||||
9. **Próxima linha.**
|
||||
|
||||
---
|
||||
|
||||
## 5. Commits durante a sessão
|
||||
|
||||
Não acumular commits enormes. **Um fix por página**, com escopo claro:
|
||||
|
||||
```bash
|
||||
# exemplo
|
||||
git add src/app/\(dashboard\)/dashboard/<página>/page.tsx
|
||||
git commit -m "fix(<area>): <descrição curta>
|
||||
|
||||
E2E shakedown v3.8.0: <página> quebrava com <sintoma>.
|
||||
<o que mudou e por quê>"
|
||||
```
|
||||
|
||||
Não usar `Co-Authored-By` (hard rule #16). Não rodar `--no-verify`.
|
||||
|
||||
Ao final da sessão, **push único** com todos os fixes:
|
||||
|
||||
```bash
|
||||
git push origin release/v3.8.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Encerramento da sessão
|
||||
|
||||
Quando todas as linhas tiverem `✅`:
|
||||
|
||||
1. Rodar a suíte rápida de sanidade:
|
||||
```bash
|
||||
npm run lint
|
||||
npm run typecheck:core
|
||||
npm run test:unit
|
||||
```
|
||||
2. Anexar este arquivo (preenchido) ao PR de release ou ao tag `v3.8.0` como evidência.
|
||||
3. Atualizar `CHANGELOG.md` com a linha:
|
||||
> E2E dashboard shakedown completed — see `docs/ops/E2E_DASHBOARD_SHAKEDOWN_v3.8.0.md`.
|
||||
4. Subir para `main` e disparar o release.
|
||||
|
||||
---
|
||||
|
||||
## 7. Tabela "página → ajuste aplicado" (preencher na sessão)
|
||||
|
||||
| Página | Sintoma | Causa-raiz | Correção | Commit |
|
||||
| ------------------------------- | ----------------------------------- | --------------------- | --------------------------- | -------- |
|
||||
| _exemplo: /dashboard/cli-tools_ | _500 no POST /api/cli-tools/config_ | _Zod schema faltando_ | _Adicionado `.safeParse()`_ | _abc123_ |
|
||||
| | | | | |
|
||||
| | | | | |
|
||||
|
||||
Mantenha a tabela crescendo conforme corrige. Esse é o trail de auditoria do shakedown.
|
||||
@@ -114,9 +114,9 @@ On success it returns:
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| 422 + `zedDockerEnvironment: true` | Running inside Docker | Use Manual Token Import tab |
|
||||
| 404 + `zedInstalled: false` | Zed not installed on host | Install Zed or use manual import |
|
||||
| 403 + keychain access denied | OS denied keychain access | Grant permission in OS prompt |
|
||||
| 404 + keychain service not available | `libsecret` missing on Linux | Install `libsecret-1-dev` |
|
||||
| Symptom | Cause | Fix |
|
||||
| ------------------------------------ | ---------------------------- | -------------------------------- |
|
||||
| 422 + `zedDockerEnvironment: true` | Running inside Docker | Use Manual Token Import tab |
|
||||
| 404 + `zedInstalled: false` | Zed not installed on host | Install Zed or use manual import |
|
||||
| 403 + keychain access denied | OS denied keychain access | Grant permission in OS prompt |
|
||||
| 404 + keychain service not available | `libsecret` missing on Linux | Install `libsecret-1-dev` |
|
||||
|
||||
@@ -59,14 +59,14 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
|
||||
|
||||
## Web Cookie Providers (7)
|
||||
|
||||
| ID | Alias | Name | Tags | Website | Notes |
|
||||
| ---------------- | ---------- | --------------------------- | ---------- | --------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| `blackbox-web` | `bb-web` | Blackbox Web (Subscription) | Web cookie | [link](https://app.blackbox.ai) | Paste your \_\_Secure-authjs.session-token value or full cookie header from app.blackbox.ai |
|
||||
| `chatgpt-web` | `cgpt-web` | ChatGPT Web (Plus/Pro) | Web cookie | [link](https://chatgpt.com) | Paste your \_\_Secure-next-auth.session-token cookie value from chatgpt.com |
|
||||
| `deepseek-web` | `ds-web` | DeepSeek Web | Web cookie | [link](https://chat.deepseek.com) | Paste your ds_session_id cookie from chat.deepseek.com |
|
||||
| `grok-web` | `gw` | Grok Web (Subscription) | Web cookie | [link](https://grok.com) | Paste your sso= cookie value from grok.com |
|
||||
| `muse-spark-web` | `ms-web` | Muse Spark Web (Meta AI) | Web cookie | [link](https://www.meta.ai) | Paste your abra_sess value or full cookie header from meta.ai |
|
||||
| `perplexity-web` | `pplx-web` | Perplexity Web (Pro/Max) | Web cookie | [link](https://www.perplexity.ai) | Paste your \_\_Secure-next-auth.session-token cookie value from perplexity.ai |
|
||||
| ID | Alias | Name | Tags | Website | Notes |
|
||||
| ---------------- | ---------- | --------------------------- | ---------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `blackbox-web` | `bb-web` | Blackbox Web (Subscription) | Web cookie | [link](https://app.blackbox.ai) | Paste your \_\_Secure-authjs.session-token value or full cookie header from app.blackbox.ai |
|
||||
| `chatgpt-web` | `cgpt-web` | ChatGPT Web (Plus/Pro) | Web cookie | [link](https://chatgpt.com) | Paste your \_\_Secure-next-auth.session-token cookie value from chatgpt.com |
|
||||
| `deepseek-web` | `ds-web` | DeepSeek Web | Web cookie | [link](https://chat.deepseek.com) | Paste your ds_session_id cookie from chat.deepseek.com |
|
||||
| `grok-web` | `gw` | Grok Web (Subscription) | Web cookie | [link](https://grok.com) | Paste your sso= cookie value from grok.com |
|
||||
| `muse-spark-web` | `ms-web` | Muse Spark Web (Meta AI) | Web cookie | [link](https://www.meta.ai) | Paste your abra_sess value or full cookie header from meta.ai |
|
||||
| `perplexity-web` | `pplx-web` | Perplexity Web (Pro/Max) | Web cookie | [link](https://www.perplexity.ai) | Paste your \_\_Secure-next-auth.session-token cookie value from perplexity.ai |
|
||||
| `t3-web` | `t3chat` | t3.chat (Pro/Free) | Web cookie | [link](https://t3.chat) | Pro: $8/mo, 50+ models. Free tier: limited models. Requires Cookie header + convex-session-id from DevTools. **Skeleton — endpoint URL not yet confirmed (TODO post-devtools-capture).** |
|
||||
|
||||
## API Key Providers (paid / paid-with-free-credits) (122)
|
||||
|
||||
@@ -87,17 +87,17 @@ The Auto-Combo Engine dynamically selects the best provider/model for each reque
|
||||
|
||||
> Source: [diagrams/auto-combo-9factor.mmd](../diagrams/auto-combo-9factor.mmd)
|
||||
|
||||
| Factor | Default Weight | Description |
|
||||
| :----------------- | :------------- | :---------------------------------------------------------------------- |
|
||||
| `health` | 0.22 | Health score from circuit breaker (CLOSED=1.0, HALF_OPEN=0.5, OPEN=0.0) |
|
||||
| `quota` | 0.17 | Remaining quota / rate-limit headroom [0..1] |
|
||||
| Factor | Default Weight | Description |
|
||||
| :----------------- | :------------- | :------------------------------------------------------------------------------------------------- |
|
||||
| `health` | 0.22 | Health score from circuit breaker (CLOSED=1.0, HALF_OPEN=0.5, OPEN=0.0) |
|
||||
| `quota` | 0.17 | Remaining quota / rate-limit headroom [0..1] |
|
||||
| `costInv` | 0.17 | Inverse **blended** cost (60% input + 40% output token price, normalized) — cheaper = higher score |
|
||||
| `latencyInv` | 0.13 | Inverse p95 latency normalized to pool — faster = higher score |
|
||||
| `taskFit` | 0.08 | Task-type fitness (coding, review, planning, analysis, debugging, docs) |
|
||||
| `specificityMatch` | 0.08 | Match between request specificity (manifest hint) and model tier |
|
||||
| `stability` | 0.05 | Variance-based stability (low latency stdDev / error rate) |
|
||||
| `tierPriority` | 0.05 | Account-tier priority — Ultra=1.0, Pro=0.67, Standard=0.33, Free=0.0 |
|
||||
| `tierAffinity` | 0.05 | Affinity between the candidate's tier and the manifest-recommended tier |
|
||||
| `latencyInv` | 0.13 | Inverse p95 latency normalized to pool — faster = higher score |
|
||||
| `taskFit` | 0.08 | Task-type fitness (coding, review, planning, analysis, debugging, docs) |
|
||||
| `specificityMatch` | 0.08 | Match between request specificity (manifest hint) and model tier |
|
||||
| `stability` | 0.05 | Variance-based stability (low latency stdDev / error rate) |
|
||||
| `tierPriority` | 0.05 | Account-tier priority — Ultra=1.0, Pro=0.67, Standard=0.33, Free=0.0 |
|
||||
| `tierAffinity` | 0.05 | Affinity between the candidate's tier and the manifest-recommended tier |
|
||||
|
||||
**Sum:** `0.22 + 0.17 + 0.17 + 0.13 + 0.08 + 0.08 + 0.05 + 0.05 + 0.05 = 1.0` (validated by `validateWeights()`).
|
||||
|
||||
@@ -210,16 +210,16 @@ Including the bare `auto` (default) plus the 6 `AutoVariant` values declared in
|
||||
The 9-factor scoring function (`open-sse/services/autoCombo/scoring.ts`) treats tier
|
||||
membership as one signal via the `tierPriority` weight. Default weights (from `DEFAULT_WEIGHTS`):
|
||||
|
||||
| Factor | Default weight | Notes |
|
||||
| ------------------------ | -------------- | --------------------------------- |
|
||||
| Tier priority | 0.05 | Tier 1 premium → higher score |
|
||||
| Latency (p50 inverse) | 0.35 | Fastest wins |
|
||||
| Factor | Default weight | Notes |
|
||||
| ------------------------ | -------------- | -------------------------------------------------------------- |
|
||||
| Tier priority | 0.05 | Tier 1 premium → higher score |
|
||||
| Latency (p50 inverse) | 0.35 | Fastest wins |
|
||||
| Cost ($/1M inverse) | 0.20 | Cheapest **blended** price wins (60% input + 40% output ratio) |
|
||||
| Recent health/error rate | 0.15 | Unhealthy deprioritized |
|
||||
| Quota remaining | 0.10 | Near-exhausted deprioritized |
|
||||
| Context window match | 0.08 | Penalizes short windows |
|
||||
| Task fitness | 0.10 | Coding → coding-specialist models |
|
||||
| Stability | 0.00 | Disabled by default |
|
||||
| Recent health/error rate | 0.15 | Unhealthy deprioritized |
|
||||
| Quota remaining | 0.10 | Near-exhausted deprioritized |
|
||||
| Context window match | 0.08 | Penalizes short windows |
|
||||
| Task fitness | 0.10 | Coding → coding-specialist models |
|
||||
| Stability | 0.00 | Disabled by default |
|
||||
|
||||
Tier alone does **not** force Tier 1 first — if Tier 1 latency is bad or
|
||||
cost-vs-quality is suboptimal, Tier 2 wins. To force tier ordering, use combo
|
||||
|
||||
@@ -139,6 +139,7 @@ parsed body from the upstream provider). When provided, it is sanitized by
|
||||
`sanitizeUpstreamDetails` before inclusion in the response as `upstream_details`.
|
||||
|
||||
Sanitization rules applied to `upstreamDetails`:
|
||||
|
||||
1. String leaves: run through `sanitizeErrorMessage` (strips stacks + absolute paths).
|
||||
2. Key blocklist: keys matching `/stack|trace|path|file|cwd|dir|password|secret|token|key/i`
|
||||
are removed.
|
||||
|
||||
@@ -50,6 +50,8 @@ const eslintConfig = [
|
||||
"bin/**",
|
||||
// Dependencies
|
||||
"node_modules/**",
|
||||
".worktrees/**",
|
||||
".omnivscodeagent/**",
|
||||
// VS Code extension and its large test fixtures
|
||||
"vscode-extension/**",
|
||||
"_references/**",
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
export const ANTIGRAVITY_PUBLIC_MODELS = Object.freeze([
|
||||
{ id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 Thinking" },
|
||||
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 Thinking" },
|
||||
{ id: "gemini-3-pro-preview", name: "Gemini 3.1 Pro (High)" },
|
||||
{ id: "gemini-3-flash-agent", name: "Gemini 3.5 Flash (High)" },
|
||||
{ id: "gemini-3.5-flash-low", name: "Gemini 3.5 Flash (Medium)" },
|
||||
{ id: "gemini-pro-agent", name: "Gemini 3.1 Pro (High)" },
|
||||
{ id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro (Low)" },
|
||||
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash" },
|
||||
{ id: "gpt-oss-120b-medium", name: "GPT OSS 120B Medium" },
|
||||
{ id: "gemini-3-pro-image-preview", name: "Gemini 3 Pro Image" },
|
||||
{ id: "gemini-3.1-flash-image", name: "Gemini 3.1 Flash Image" },
|
||||
{
|
||||
id: "gemini-2.5-computer-use-preview-10-2025",
|
||||
name: "Gemini 2.5 Computer Use Preview (10/2025)",
|
||||
|
||||
@@ -37,6 +37,12 @@ interface ImageModelAliasEntry {
|
||||
}
|
||||
|
||||
const IMAGE_MODEL_ALIASES: Record<string, ImageModelAliasEntry> = {
|
||||
"gemini-3.1-flash-image-preview": {
|
||||
provider: "antigravity",
|
||||
model: "gemini-3.1-flash-image",
|
||||
name: "Gemini 3.1 Flash Image",
|
||||
listInCatalog: false,
|
||||
},
|
||||
"flux-kontext": {
|
||||
provider: "black-forest-labs",
|
||||
model: "flux-kontext-pro",
|
||||
@@ -223,11 +229,11 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderConfig> = {
|
||||
|
||||
antigravity: {
|
||||
id: "antigravity",
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/models",
|
||||
baseUrl: "https://daily-cloudcode-pa.googleapis.com/v1internal:generateContent",
|
||||
authType: "oauth",
|
||||
authHeader: "bearer",
|
||||
format: "gemini-image", // Special format: uses Gemini generateContent API
|
||||
models: [],
|
||||
models: [{ id: "gemini-3.1-flash-image", name: "Gemini 3.1 Flash Image" }],
|
||||
supportedSizes: ["1024x1024"],
|
||||
},
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { BaseExecutor } from "./base.ts";
|
||||
import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.ts";
|
||||
import { getAccessToken } from "../services/tokenRefresh.ts";
|
||||
import { getRotatingApiKey, getValidApiKey } from "../services/apiKeyRotator.ts";
|
||||
import {
|
||||
getRotatingApiKey,
|
||||
getValidApiKey,
|
||||
resolveKeyForRequest,
|
||||
} from "../services/apiKeyRotator.ts";
|
||||
import type { KeyHealth } from "../services/apiKeyRotator.ts";
|
||||
import {
|
||||
buildClaudeCodeCompatibleHeaders,
|
||||
@@ -254,14 +258,22 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
// T07: resolve extra keys round-robin locally since DefaultExecutor overrides BaseExecutor buildHeaders
|
||||
const extraKeys =
|
||||
(credentials.providerSpecificData?.extraApiKeys as string[] | undefined) ?? [];
|
||||
const health = credentials.providerSpecificData?.apiKeyHealth as
|
||||
| Record<string, KeyHealth>
|
||||
| undefined;
|
||||
const effectiveKey =
|
||||
extraKeys.length > 0 && credentials.connectionId && credentials.apiKey
|
||||
? getValidApiKey(credentials.connectionId, credentials.apiKey, extraKeys, health) ||
|
||||
credentials.apiKey
|
||||
: credentials.apiKey;
|
||||
const selectedKeyId = (credentials.providerSpecificData as Record<string, unknown> | undefined)
|
||||
?.selectedKeyId as string | undefined;
|
||||
let effectiveKey = credentials.apiKey;
|
||||
if (extraKeys.length > 0 && credentials.connectionId && credentials.apiKey) {
|
||||
const resolved = resolveKeyForRequest(
|
||||
credentials.connectionId,
|
||||
credentials.apiKey,
|
||||
extraKeys,
|
||||
selectedKeyId ?? null
|
||||
);
|
||||
effectiveKey = resolved?.key ?? credentials.apiKey;
|
||||
if (resolved && credentials.providerSpecificData) {
|
||||
(credentials.providerSpecificData as Record<string, unknown>).selectedKeyId =
|
||||
resolved.keyId;
|
||||
}
|
||||
}
|
||||
|
||||
switch (this.provider) {
|
||||
case "gemini":
|
||||
|
||||
@@ -58,8 +58,7 @@ export interface T3ChatCredentials {
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function validateCredentials(creds: unknown): creds is T3ChatCredentials {
|
||||
const raw =
|
||||
typeof creds === "object" && creds !== null ? (creds as Record<string, unknown>) : {};
|
||||
const raw = typeof creds === "object" && creds !== null ? (creds as Record<string, unknown>) : {};
|
||||
return (
|
||||
typeof raw.cookies === "string" &&
|
||||
raw.cookies.length > 0 &&
|
||||
@@ -373,10 +372,7 @@ export class T3ChatWebExecutor extends BaseExecutor {
|
||||
// TODO(post-devtools-capture): Map the actual t3.chat non-streaming response
|
||||
// shape to OpenAI format once the real field names are confirmed.
|
||||
const content =
|
||||
(json as any)?.content ??
|
||||
(json as any)?.text ??
|
||||
(json as any)?.message?.content ??
|
||||
"";
|
||||
(json as any)?.content ?? (json as any)?.text ?? (json as any)?.message?.content ?? "";
|
||||
const openaiResponse = {
|
||||
id: `chatcmpl-t3-${Date.now()}`,
|
||||
object: "chat.completion",
|
||||
|
||||
@@ -17,6 +17,14 @@ import { randomUUID } from "crypto";
|
||||
*/
|
||||
|
||||
import { getImageProvider, parseImageModel } from "../config/imageRegistry.ts";
|
||||
import { HTTP_STATUS } from "../config/constants.ts";
|
||||
import { antigravityUserAgent } from "../services/antigravityHeaders.ts";
|
||||
import {
|
||||
deriveAntigravityMachineId,
|
||||
getAntigravityEnvelopeUserAgent,
|
||||
getAntigravityVscodeSessionId,
|
||||
} from "../services/antigravityIdentity.ts";
|
||||
import { getCachedAntigravityVersion } from "../services/antigravityVersion.ts";
|
||||
import { kieExecutor } from "../executors/kie.ts";
|
||||
import { mapImageSize } from "../translator/image/sizeMapper.ts";
|
||||
import { getCodexClientVersion, getCodexUserAgent } from "../config/codexClient.ts";
|
||||
@@ -38,7 +46,7 @@ import {
|
||||
extractComfyOutputFiles,
|
||||
} from "../utils/comfyuiClient.ts";
|
||||
import { fetchRemoteImage } from "@/shared/network/remoteImageFetch";
|
||||
import { sanitizeErrorMessage } from "../utils/error.ts";
|
||||
import { sanitizeErrorMessage, sanitizeUpstreamDetails } from "../utils/error.ts";
|
||||
|
||||
interface KieImageOptions {
|
||||
model: string;
|
||||
@@ -81,6 +89,32 @@ const OPENAI_IMAGE_TO_IMAGE_MODELS = new Set([
|
||||
"qwen-image",
|
||||
]);
|
||||
|
||||
const IMAGE_ASPECT_RATIO_PATTERN = /^\d+:\d+$/;
|
||||
|
||||
function normalizeImageAspectRatio(value: unknown, fallbackSize: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
const trimmedValue = value.trim();
|
||||
if (IMAGE_ASPECT_RATIO_PATTERN.test(trimmedValue)) return trimmedValue;
|
||||
}
|
||||
return mapImageSize(typeof fallbackSize === "string" ? fallbackSize : null);
|
||||
}
|
||||
|
||||
function parseJsonOrNull(value: string): unknown | null {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeImageProviderError(errorText: string): unknown {
|
||||
const parsed = parseJsonOrNull(errorText);
|
||||
if (parsed !== null) {
|
||||
return sanitizeUpstreamDetails(parsed) || sanitizeErrorMessage(errorText);
|
||||
}
|
||||
return sanitizeErrorMessage(errorText);
|
||||
}
|
||||
|
||||
const BFL_MODEL_ENDPOINTS = {
|
||||
"flux-2-max": "/v1/flux-2-max",
|
||||
"flux-2-pro": "/v1/flux-2-pro",
|
||||
@@ -602,42 +636,79 @@ async function handleKieImageGeneration({
|
||||
*/
|
||||
async function handleGeminiImageGeneration({ model, providerConfig, body, credentials, log }) {
|
||||
const startTime = Date.now();
|
||||
const url = `${providerConfig.baseUrl}/${model}:generateContent`;
|
||||
const url = providerConfig.baseUrl;
|
||||
const provider = "antigravity";
|
||||
const credentialRecord = credentials || {};
|
||||
const token = credentialRecord.accessToken || credentialRecord.apiKey;
|
||||
const providerSpecificData = credentialRecord.providerSpecificData;
|
||||
const providerSpecificProjectId =
|
||||
providerSpecificData && typeof providerSpecificData === "object"
|
||||
? (providerSpecificData as Record<string, unknown>).projectId
|
||||
: null;
|
||||
const credentialProjectId =
|
||||
typeof credentialRecord.projectId === "string" ? credentialRecord.projectId.trim() : "";
|
||||
const providerProjectId =
|
||||
typeof providerSpecificProjectId === "string" ? providerSpecificProjectId.trim() : "";
|
||||
const projectId = credentialProjectId || providerProjectId || null;
|
||||
const candidateCount =
|
||||
typeof body.n === "number" && Number.isFinite(body.n) && body.n > 0 ? Math.floor(body.n) : 1;
|
||||
const promptText = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? "");
|
||||
|
||||
// Summarized request for call log
|
||||
const logRequestBody = {
|
||||
model: body.model,
|
||||
prompt:
|
||||
typeof body.prompt === "string"
|
||||
? body.prompt.slice(0, 200)
|
||||
: String(body.prompt ?? "").slice(0, 200),
|
||||
prompt: promptText.slice(0, 200),
|
||||
size: body.size || "default",
|
||||
n: body.n || 1,
|
||||
n: candidateCount,
|
||||
};
|
||||
|
||||
const geminiBody = {
|
||||
contents: [
|
||||
{
|
||||
parts: [{ text: body.prompt }],
|
||||
if (!projectId || typeof projectId !== "string") {
|
||||
return saveImageErrorResult({
|
||||
provider,
|
||||
model,
|
||||
status: 400,
|
||||
startTime,
|
||||
error:
|
||||
"Missing Google projectId for Antigravity account. Please reconnect OAuth in Providers so OmniRoute can fetch your Cloud Code project.",
|
||||
requestBody: logRequestBody,
|
||||
});
|
||||
}
|
||||
|
||||
const antigravityBody = {
|
||||
project: projectId,
|
||||
requestId: `image_gen/${Date.now()}/${randomUUID()}/0`,
|
||||
request: {
|
||||
contents: [
|
||||
{
|
||||
role: "user",
|
||||
parts: [{ text: promptText }],
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
candidateCount,
|
||||
imageConfig: {
|
||||
aspectRatio: normalizeImageAspectRatio(body.aspect_ratio, body.size),
|
||||
},
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
responseModalities: ["TEXT", "IMAGE"],
|
||||
},
|
||||
model,
|
||||
userAgent: getAntigravityEnvelopeUserAgent(credentialRecord),
|
||||
requestType: "image_gen",
|
||||
};
|
||||
|
||||
const token = credentials.accessToken || credentials.apiKey;
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"User-Agent": antigravityUserAgent(),
|
||||
"x-client-name": "antigravity",
|
||||
"x-client-version": getCachedAntigravityVersion(),
|
||||
"x-machine-id": deriveAntigravityMachineId(credentialRecord),
|
||||
"x-vscode-sessionid": getAntigravityVscodeSessionId(),
|
||||
"x-goog-user-project": projectId,
|
||||
};
|
||||
|
||||
if (log) {
|
||||
const promptPreview =
|
||||
typeof body.prompt === "string"
|
||||
? body.prompt.slice(0, 60)
|
||||
: String(body.prompt ?? "").slice(0, 60);
|
||||
const promptPreview = promptText.slice(0, 60);
|
||||
log.info(
|
||||
"IMAGE",
|
||||
`antigravity/${model} (gemini) | prompt: "${promptPreview}..." | format: gemini-image`
|
||||
@@ -645,16 +716,32 @@ async function handleGeminiImageGeneration({ model, providerConfig, body, creden
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
let response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(geminiBody),
|
||||
body: JSON.stringify(antigravityBody),
|
||||
});
|
||||
|
||||
if (response.status === HTTP_STATUS.FORBIDDEN && headers["x-goog-user-project"]) {
|
||||
const retryHeaders = { ...headers };
|
||||
delete retryHeaders["x-goog-user-project"];
|
||||
if (log) {
|
||||
log.info("IMAGE", "antigravity image 403 with x-goog-user-project; retrying without it");
|
||||
}
|
||||
response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: retryHeaders,
|
||||
body: JSON.stringify(antigravityBody),
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
const safeError = sanitizeImageProviderError(errorText);
|
||||
const safeErrorLog =
|
||||
typeof safeError === "string" ? safeError : JSON.stringify(safeError ?? {});
|
||||
if (log) {
|
||||
log.error("IMAGE", `antigravity error ${response.status}: ${errorText.slice(0, 200)}`);
|
||||
log.error("IMAGE", `antigravity error ${response.status}: ${safeErrorLog.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
saveCallLog({
|
||||
@@ -664,25 +751,26 @@ async function handleGeminiImageGeneration({ model, providerConfig, body, creden
|
||||
model: `antigravity/${model}`,
|
||||
provider,
|
||||
duration: Date.now() - startTime,
|
||||
error: errorText.slice(0, 500),
|
||||
error: safeErrorLog.slice(0, 500),
|
||||
requestBody: logRequestBody,
|
||||
}).catch(() => {});
|
||||
|
||||
return { success: false, status: response.status, error: errorText };
|
||||
return { success: false, status: response.status, error: safeError };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const responseBody = data.response || data;
|
||||
|
||||
// Extract image data from Gemini response
|
||||
// Extract image data from Antigravity's wrapped Gemini response.
|
||||
const images = [];
|
||||
const candidates = data.candidates || [];
|
||||
const candidates = responseBody.candidates || [];
|
||||
for (const candidate of candidates) {
|
||||
const parts = candidate.content?.parts || [];
|
||||
for (const part of parts) {
|
||||
if (part.inlineData) {
|
||||
images.push({
|
||||
b64_json: part.inlineData.data,
|
||||
revised_prompt: parts.find((p) => p.text)?.text || body.prompt,
|
||||
revised_prompt: parts.find((p) => p.text)?.text || promptText,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -726,7 +814,7 @@ async function handleGeminiImageGeneration({ model, providerConfig, body, creden
|
||||
return {
|
||||
success: false,
|
||||
status: 502,
|
||||
error: sanitizeErrorMessage(err) || "Image provider error",
|
||||
error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1306,7 +1394,7 @@ async function handleFalAIImageGeneration({
|
||||
model,
|
||||
status: 502,
|
||||
startTime,
|
||||
error: sanitizeErrorMessage(err) || "Image provider error",
|
||||
error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1496,7 +1584,7 @@ async function handleStabilityAIImageGeneration({
|
||||
model,
|
||||
status: 502,
|
||||
startTime,
|
||||
error: sanitizeErrorMessage(err) || "Image provider error",
|
||||
error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1615,7 +1703,7 @@ async function handleBlackForestLabsImageGeneration({
|
||||
model,
|
||||
status: 502,
|
||||
startTime,
|
||||
error: sanitizeErrorMessage(err) || "Image provider error",
|
||||
error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1690,7 +1778,7 @@ async function handleRecraftImageGeneration({
|
||||
model,
|
||||
status: 502,
|
||||
startTime,
|
||||
error: sanitizeErrorMessage(err) || "Image provider error",
|
||||
error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1778,7 +1866,7 @@ async function handleTopazImageGeneration({
|
||||
model,
|
||||
status: 502,
|
||||
startTime,
|
||||
error: sanitizeErrorMessage(err) || "Image provider error",
|
||||
error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2358,7 +2446,7 @@ async function fetchImageEndpoint(url, headers, body, provider, log) {
|
||||
return {
|
||||
success: false,
|
||||
status: 502,
|
||||
error: sanitizeErrorMessage(err) || "Image provider error",
|
||||
error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2456,7 +2544,7 @@ async function handleHyperbolicImageGeneration({
|
||||
return {
|
||||
success: false,
|
||||
status: 502,
|
||||
error: sanitizeErrorMessage(err) || "Image provider error",
|
||||
error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2714,7 +2802,7 @@ async function handleNanoBananaImageGeneration({
|
||||
return {
|
||||
success: false,
|
||||
status: 502,
|
||||
error: sanitizeErrorMessage(err) || "Image provider error",
|
||||
error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2910,7 +2998,7 @@ async function handleSDWebUIImageGeneration({ model, provider, providerConfig, b
|
||||
return {
|
||||
success: false,
|
||||
status: 502,
|
||||
error: sanitizeErrorMessage(err) || "Image provider error",
|
||||
error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3016,7 +3104,7 @@ async function handleComfyUIImageGeneration({ model, provider, providerConfig, b
|
||||
return {
|
||||
success: false,
|
||||
status: 502,
|
||||
error: sanitizeErrorMessage(err) || "Image provider error",
|
||||
error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3128,7 +3216,7 @@ async function handleHaiperImageGeneration({
|
||||
return {
|
||||
success: false,
|
||||
status: 502,
|
||||
error: sanitizeErrorMessage(err) || "Image provider error",
|
||||
error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3260,7 +3348,7 @@ async function handleLeonardoImageGeneration({
|
||||
return {
|
||||
success: false,
|
||||
status: 502,
|
||||
error: sanitizeErrorMessage(err) || "Image provider error",
|
||||
error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3350,7 +3438,7 @@ async function handleIdeogramImageGeneration({
|
||||
return {
|
||||
success: false,
|
||||
status: 502,
|
||||
error: sanitizeErrorMessage(err) || "Image provider error",
|
||||
error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,7 +378,11 @@ export async function buildAndSignClaudeCodeRequest(
|
||||
if (Array.isArray(b.messages)) {
|
||||
const fixed = fixToolPairs(b.messages as Record<string, unknown>[]);
|
||||
const adjacent = fixToolAdjacency(fixed);
|
||||
b.messages = stripTrailingAssistantOrphanToolUse(adjacent);
|
||||
// fixToolAdjacency can leave orphan tool_result blocks behind when it
|
||||
// strips a tool_use whose tool_result wasn't in the next message.
|
||||
// Re-pair to drop those orphans (discussion #2410).
|
||||
const cleaned = fixToolPairs(adjacent);
|
||||
b.messages = stripTrailingAssistantOrphanToolUse(cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1158,7 +1162,9 @@ function readNestedString(
|
||||
if (!current || typeof current !== "object" || Array.isArray(current)) {
|
||||
return null;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
if (key === "__proto__" || key === "constructor" || key === "prototype") return null;
|
||||
if (!Object.prototype.hasOwnProperty.call(current, key)) return null;
|
||||
current = Reflect.get(current as object, key);
|
||||
}
|
||||
return toNonEmptyString(current);
|
||||
}
|
||||
|
||||
@@ -2658,10 +2658,7 @@ async function handleRoundRobinCombo({
|
||||
// same-provider targets are skipped immediately.
|
||||
if (provider && isProviderExhaustedReason(fallbackResult)) {
|
||||
exhaustedProviders.add(provider);
|
||||
log.info(
|
||||
"COMBO-RR",
|
||||
`Provider ${provider} quota exhausted — marking for skip (#1731)`
|
||||
);
|
||||
log.info("COMBO-RR", `Provider ${provider} quota exhausted — marking for skip (#1731)`);
|
||||
}
|
||||
|
||||
const isAllAccountsRateLimited = isAllAccountsRateLimitedResponse(
|
||||
|
||||
@@ -267,6 +267,9 @@ function purifyHistory(messages: Record<string, unknown>[], targetTokens: number
|
||||
let candidate = [...system, ...nonSystem.slice(-keep)];
|
||||
candidate = fixToolPairs(candidate);
|
||||
candidate = fixToolAdjacency(candidate);
|
||||
// Re-run pair fix: fixToolAdjacency may have stripped tool_use blocks, leaving
|
||||
// orphan tool_results that Claude rejects ("tool_result without preceding tool_use").
|
||||
candidate = fixToolPairs(candidate);
|
||||
candidate = stripTrailingAssistantOrphanToolUse(candidate);
|
||||
const tokens = estimateTokens(JSON.stringify(candidate));
|
||||
if (tokens <= targetTokens) break;
|
||||
@@ -276,6 +279,9 @@ function purifyHistory(messages: Record<string, unknown>[], targetTokens: number
|
||||
let result = [...system, ...nonSystem.slice(-keep)];
|
||||
result = fixToolPairs(result);
|
||||
result = fixToolAdjacency(result);
|
||||
// Re-run pair fix to drop any tool_result whose matching tool_use was removed by
|
||||
// fixToolAdjacency (discussion #2410 — orphan tool_result -> upstream 400).
|
||||
result = fixToolPairs(result);
|
||||
result = stripTrailingAssistantOrphanToolUse(result);
|
||||
|
||||
// Add summary of dropped messages
|
||||
|
||||
@@ -420,7 +420,12 @@ async function resolveModelByProviderInference(modelId: string, extendedContext:
|
||||
}
|
||||
|
||||
// Preserve historical behavior: OpenAI stays default when model exists there
|
||||
if (providers.includes("openai")) {
|
||||
if (
|
||||
providers.includes("openai") ||
|
||||
/^gpt-/i.test(modelId) ||
|
||||
/^o1/i.test(modelId) ||
|
||||
/^o3/i.test(modelId)
|
||||
) {
|
||||
return {
|
||||
provider: "openai",
|
||||
model: modelId,
|
||||
|
||||
26
scripts/ad-hoc/delete-non-green-runs.mjs
Normal file
26
scripts/ad-hoc/delete-non-green-runs.mjs
Normal file
@@ -0,0 +1,26 @@
|
||||
import { execSync } from "child_process";
|
||||
|
||||
try {
|
||||
console.log("Fetching workflow runs...");
|
||||
const output = execSync("gh run list --limit 100 --json status,conclusion,databaseId", {
|
||||
encoding: "utf8",
|
||||
});
|
||||
const runs = JSON.parse(output);
|
||||
|
||||
console.log(`Found ${runs.length} runs.`);
|
||||
let count = 0;
|
||||
for (const run of runs) {
|
||||
if (run.conclusion !== "success") {
|
||||
console.log(`Deleting run ID ${run.databaseId} with conclusion '${run.conclusion}'...`);
|
||||
try {
|
||||
execSync(`gh run delete ${run.databaseId}`);
|
||||
count++;
|
||||
} catch (err) {
|
||||
console.error(`Failed to delete run ID ${run.databaseId}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`Deleted ${count} runs successfully.`);
|
||||
} catch (error) {
|
||||
console.error("Error executing script:", error);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -17,22 +18,29 @@ const __dirname: string = dirname(__filename);
|
||||
const ROOT: string = join(__dirname, "..", "..");
|
||||
const npmCommand: string = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
|
||||
function runPackDryRun(): any {
|
||||
function runNpm(args: string[], stdio: "inherit" | "pipe" = "pipe"): string {
|
||||
const npmExecPath = process.env.npm_execpath;
|
||||
const command = npmExecPath ? process.execPath : npmCommand;
|
||||
const args = [
|
||||
...(npmExecPath ? [npmExecPath] : []),
|
||||
"pack",
|
||||
"--dry-run",
|
||||
"--json",
|
||||
"--ignore-scripts",
|
||||
];
|
||||
|
||||
const output = execFileSync(command, args, {
|
||||
return execFileSync(command, [...(npmExecPath ? [npmExecPath] : []), ...args], {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
stdio: stdio === "inherit" ? "inherit" : ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
function ensureAppStagingReady(): void {
|
||||
const missingAppRequiredPaths = PACK_ARTIFACT_REQUIRED_PATHS.filter((requiredPath) =>
|
||||
requiredPath.startsWith("app/")
|
||||
).filter((requiredPath) => !existsSync(join(ROOT, requiredPath)));
|
||||
|
||||
if (missingAppRequiredPaths.length === 0) return;
|
||||
|
||||
console.log("📦 app/ staging is missing required runtime files; running npm run build:cli...");
|
||||
runNpm(["run", "build:cli"], "inherit");
|
||||
}
|
||||
|
||||
function runPackDryRun(): any {
|
||||
const output = runNpm(["pack", "--dry-run", "--json", "--ignore-scripts"]);
|
||||
|
||||
const jsonStart = output.indexOf("[");
|
||||
const jsonEnd = output.lastIndexOf("]");
|
||||
@@ -66,6 +74,7 @@ function formatBytes(bytes: number): string {
|
||||
}
|
||||
|
||||
try {
|
||||
ensureAppStagingReady();
|
||||
const packReport = runPackDryRun();
|
||||
const artifactPaths: string[] = packReport.files.map((file: any) => file.path);
|
||||
const unexpectedPaths: string[] = findUnexpectedArtifactPaths(artifactPaths, {
|
||||
|
||||
@@ -24,16 +24,23 @@ const require = createRequire(import.meta.url);
|
||||
const CRYPTO_SECRETS = {
|
||||
JWT_SECRET: () => randomBytes(64).toString("hex"),
|
||||
API_KEY_SECRET: () => randomBytes(32).toString("hex"),
|
||||
STORAGE_ENCRYPTION_KEY: () => randomBytes(32).toString("hex"),
|
||||
// STORAGE_ENCRYPTION_KEY: Generated at server startup instead of postinstall.
|
||||
// Generated in bin/omniroute.mjs:ensureStorageEncryptionKey() and persisted to
|
||||
// ~/.omniroute/.env to survive across upgrades. This prevents credential loss
|
||||
// when upgrading OmniRoute (issue #1622).
|
||||
MACHINE_ID_SALT: () => `omniroute-${randomBytes(8).toString("hex")}`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Keys that MUST NOT be regenerated when existing encrypted data exists in the DB.
|
||||
* Generating a new key would make all previously-encrypted credentials unrecoverable.
|
||||
*
|
||||
* Note: STORAGE_ENCRYPTION_KEY is no longer auto-generated in postinstall.
|
||||
* It's generated at server startup in bin/omniroute.mjs and persisted to
|
||||
* ~/.omniroute/.env to survive across upgrades.
|
||||
* @see https://github.com/diegosouzapw/OmniRoute/issues/1622
|
||||
*/
|
||||
const ENCRYPTION_BOUND_KEYS = new Set(["STORAGE_ENCRYPTION_KEY"]);
|
||||
const ENCRYPTION_BOUND_KEYS = new Set([]);
|
||||
|
||||
// ── Resolve DATA_DIR (mirrors bootstrap-env.mjs / dataPaths.ts) ─────────────
|
||||
function resolveDataDir(env = process.env) {
|
||||
|
||||
284
scripts/i18n/audit-dashboard-pages.mjs
Normal file
284
scripts/i18n/audit-dashboard-pages.mjs
Normal file
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Audit every dashboard page for:
|
||||
* 1. Hardcoded user-visible strings in JSX (any language)
|
||||
* 2. t("...") calls that reference keys NOT present in en.json
|
||||
*
|
||||
* Output: a JSON report at scripts/i18n/_audit.json + a human summary on stdout.
|
||||
*
|
||||
* Scope: src/app/(dashboard)/**\/*.tsx
|
||||
*/
|
||||
import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join, dirname, relative, basename } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = join(__dirname, "..", "..");
|
||||
const EN_JSON_PATH = join(REPO_ROOT, "src/i18n/messages/en.json");
|
||||
const DASHBOARD_ROOT = join(REPO_ROOT, "src/app/(dashboard)");
|
||||
const OUT_PATH = join(__dirname, "_audit.json");
|
||||
|
||||
const enJsonRaw = JSON.parse(readFileSync(EN_JSON_PATH, "utf8"));
|
||||
|
||||
/**
|
||||
* Deep-convert a parsed JSON object into a Map tree.
|
||||
* Using Map sidesteps prototype-pollution concerns when keys are
|
||||
* derived from source-code scraping (CWE-915).
|
||||
*/
|
||||
function toMapTree(value) {
|
||||
if (value === null || typeof value !== "object") return value;
|
||||
if (Array.isArray(value)) return value.map(toMapTree);
|
||||
const m = new Map();
|
||||
for (const [k, v] of Object.entries(value)) m.set(k, toMapTree(v));
|
||||
return m;
|
||||
}
|
||||
const enJson = toMapTree(enJsonRaw);
|
||||
|
||||
/** Walk a directory recursively and return all .tsx files */
|
||||
function walkTsx(dir) {
|
||||
const out = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) out.push(...walkTsx(full));
|
||||
else if (entry.endsWith(".tsx")) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Walk a dotted path through the Map tree, returning the leaf or undefined */
|
||||
function walkPath(parts) {
|
||||
let cursor = enJson;
|
||||
for (const p of parts) {
|
||||
if (!(cursor instanceof Map) || !cursor.has(p)) return undefined;
|
||||
cursor = cursor.get(p);
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
/** Check if a dotted key exists, supporting dotted namespaces too */
|
||||
function keyExists(namespace, key) {
|
||||
const nsParts = namespace ? namespace.split(".") : [];
|
||||
const keyParts = key.split(".");
|
||||
return walkPath([...nsParts, ...keyParts]) !== undefined;
|
||||
}
|
||||
|
||||
/** Extract useTranslations("ns") and getTranslations("ns") calls */
|
||||
function extractNamespaces(source) {
|
||||
const namespaces = [];
|
||||
const reNs = /(?:useTranslations|getTranslations)\s*\(\s*["'`]([^"'`]+)["'`]\s*\)/g;
|
||||
for (const m of source.matchAll(reNs)) {
|
||||
namespaces.push(m[1]);
|
||||
}
|
||||
// Without arg: bare, key must be fully qualified
|
||||
if (/(?:useTranslations|getTranslations)\s*\(\s*\)/.test(source)) namespaces.push(null);
|
||||
return namespaces;
|
||||
}
|
||||
|
||||
/** Extract every t("key", ...) and t.rich("key", ...) — capture the literal key only */
|
||||
function extractTCalls(source) {
|
||||
const out = new Set();
|
||||
// t("..."), t('...') — match only quoted string literals (skip template literals)
|
||||
const re = /\bt(?:Or[A-Z][A-Za-z]*)?(?:\.\w+)?\s*\(\s*["']([^"']+)["']/g;
|
||||
for (const m of source.matchAll(re)) out.add(m[1]);
|
||||
// translateOrFallback("key", fallback) — also t("key") style
|
||||
for (const m of source.matchAll(/translateOrFallback\s*\(\s*["']([^"']+)["']/g)) {
|
||||
out.add(m[1]);
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find candidate hardcoded user-visible strings in JSX.
|
||||
*
|
||||
* Patterns we detect:
|
||||
* A. JSX text: >Some text< (must contain a letter, length >= 2)
|
||||
* B. JSX attrs: title="..." placeholder="..." aria-label="..." alt="..."
|
||||
* C. Toast/error literals: toast.error("...") toast.success("...")
|
||||
*
|
||||
* We DELIBERATELY skip:
|
||||
* - strings inside {t(...)} or {tOrFallback(...)}
|
||||
* - strings inside import statements
|
||||
* - strings inside CSS class names (className/style)
|
||||
* - URLs (start with /, http, mailto:, #)
|
||||
* - All-uppercase/snake constants (CONSTANT_VAR)
|
||||
* - pure-symbol strings (no letters)
|
||||
* - lonely-word JSX text that is a console.log / pino call argument
|
||||
*/
|
||||
// TS / JS noise that the naive regex picks up between two `>` chars
|
||||
const TS_TYPE_NOISE = new Set([
|
||||
"Promise",
|
||||
"Record",
|
||||
"Map",
|
||||
"Set",
|
||||
"Array",
|
||||
"ReadonlyArray",
|
||||
"Partial",
|
||||
"Pick",
|
||||
"Omit",
|
||||
"Required",
|
||||
"Readonly",
|
||||
"Awaited",
|
||||
"ReturnType",
|
||||
"Parameters",
|
||||
"void | Promise",
|
||||
"Promise | undefined",
|
||||
]);
|
||||
|
||||
function findHardcodedStrings(source) {
|
||||
const findings = [];
|
||||
const lines = source.split("\n");
|
||||
|
||||
// (A) JSX text between > and < — fragile but good enough for an audit pass
|
||||
let lineIdx = 0;
|
||||
for (const line of lines) {
|
||||
lineIdx++;
|
||||
// Skip obvious non-JSX lines (imports, single-line comments, JSDoc lines)
|
||||
if (
|
||||
/^\s*(import|export\s+(type|interface)|\/\/|\*|\/\*|type\s+\w+\s*=|interface\s+)/.test(line)
|
||||
)
|
||||
continue;
|
||||
// Skip lines that are *clearly* TypeScript signatures
|
||||
if (
|
||||
/:\s*(Promise|Record|Map|Set|Array|Partial|Pick|Omit|Required|Readonly|Awaited)\s*</.test(
|
||||
line
|
||||
)
|
||||
)
|
||||
continue;
|
||||
// Neutralise arrow functions and TS generics that confuse the regex
|
||||
const safe = line
|
||||
.replace(/=>/g, "__ARROW__")
|
||||
.replace(/<\/?\w[\w.-]*\s*\/?>/g, (m) => m) // keep JSX tags
|
||||
.replace(/<\w[\w.,?:|\s]*?>/g, "__GEN__"); // strip TS generics like <T> or <T, U>
|
||||
|
||||
let m;
|
||||
const re = />([^<>{}\n]+)</g;
|
||||
while ((m = re.exec(safe)) !== null) {
|
||||
const raw = m[1].trim();
|
||||
if (!raw) continue;
|
||||
if (!/[A-Za-zÀ-ÿ一-鿿Ѐ-ӿ--ۿ]/.test(raw)) continue;
|
||||
if (raw.length < 2) continue;
|
||||
if (raw.startsWith("{") || raw.endsWith("}")) continue;
|
||||
if (/^[A-Z0-9_]+$/.test(raw)) continue; // CONSTANT
|
||||
if (/^(https?:\/\/|\/|mailto:|#)/.test(raw)) continue; // URL/path
|
||||
if (/^[a-z][a-z_0-9]*$/.test(raw) && raw.length < 22) continue; // icon/id slug
|
||||
if (TS_TYPE_NOISE.has(raw)) continue;
|
||||
// operator/expression debris like "0 && foo.size", "x !== y"
|
||||
if (/(&&|\|\||==|!=|<=|>=|=>|\?\s*:|\?\.|\.\w+\()/.test(raw)) continue;
|
||||
// pure number or simple variable.member with no spaces
|
||||
if (/^[\w.]+$/.test(raw) && !raw.includes(" ")) continue;
|
||||
findings.push({ kind: "jsx-text", line: lineIdx, value: raw });
|
||||
}
|
||||
}
|
||||
|
||||
// (B) JSX attributes that are user-visible
|
||||
const attrRe = /\b(title|placeholder|aria-label|alt|label)\s*=\s*["'`]([^"'`{}\n]{2,})["'`]/g;
|
||||
lineIdx = 0;
|
||||
for (const line of lines) {
|
||||
lineIdx++;
|
||||
if (/^\s*(import|\/\/|\*|\/\*)/.test(line)) continue;
|
||||
let m;
|
||||
const re = new RegExp(attrRe.source, "g");
|
||||
while ((m = re.exec(line)) !== null) {
|
||||
const value = m[2].trim();
|
||||
if (!/[A-Za-zÀ-ÿ一-鿿]/.test(value)) continue;
|
||||
if (/^[a-z][a-z_0-9-]*$/.test(value) && value.length < 16) continue; // probably an id/key
|
||||
findings.push({ kind: `attr:${m[1]}`, line: lineIdx, value });
|
||||
}
|
||||
}
|
||||
|
||||
// (C) toast.* and Error() arguments that look like user copy (length > 5, has a space)
|
||||
lineIdx = 0;
|
||||
const callRe =
|
||||
/\b(toast\.(error|success|info|warn|warning|message))\s*\(\s*["'`]([^"'`]{3,})["'`]/g;
|
||||
for (const line of lines) {
|
||||
lineIdx++;
|
||||
let m;
|
||||
const re = new RegExp(callRe.source, "g");
|
||||
while ((m = re.exec(line)) !== null) {
|
||||
findings.push({ kind: `toast:${m[2]}`, line: lineIdx, value: m[3].trim() });
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
/** Find t() calls whose key does NOT exist in en.json */
|
||||
function findMissingKeys(namespaces, tKeys) {
|
||||
const missing = [];
|
||||
for (const key of tKeys) {
|
||||
// A key may itself contain dots — handle namespace dotted into key
|
||||
let found = false;
|
||||
for (const ns of namespaces) {
|
||||
if (keyExists(ns, key)) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Also accept fully qualified bare-namespace keys (like "common.foo" with no useTranslations)
|
||||
if (!found && key.includes(".")) {
|
||||
const [maybeNs, ...rest] = key.split(".");
|
||||
if (keyExists(maybeNs, rest.join("."))) found = true;
|
||||
}
|
||||
if (!found) missing.push(key);
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
const files = walkTsx(DASHBOARD_ROOT);
|
||||
const report = [];
|
||||
|
||||
for (const file of files) {
|
||||
const rel = relative(REPO_ROOT, file);
|
||||
const src = readFileSync(file, "utf8");
|
||||
const namespaces = extractNamespaces(src);
|
||||
const tCalls = extractTCalls(src);
|
||||
const hardcoded = findHardcodedStrings(src);
|
||||
const missing = findMissingKeys(namespaces.length ? namespaces : [null], tCalls);
|
||||
// Only include files that have ANY user-visible content
|
||||
if (!namespaces.length && !tCalls.length && !hardcoded.length) continue;
|
||||
report.push({
|
||||
file: rel,
|
||||
namespaces,
|
||||
tCallCount: tCalls.length,
|
||||
missingKeys: missing,
|
||||
hardcodedCount: hardcoded.length,
|
||||
hardcoded,
|
||||
});
|
||||
}
|
||||
|
||||
writeFileSync(OUT_PATH, JSON.stringify(report, null, 2));
|
||||
|
||||
// Human summary
|
||||
let totalMissing = 0;
|
||||
let totalHardcoded = 0;
|
||||
console.log("# i18n audit — dashboard pages\n");
|
||||
console.log(`Scanned ${report.length} files with user-visible content.\n`);
|
||||
const onlyIssues = report
|
||||
.map((r) => ({ ...r, total: r.missingKeys.length + r.hardcoded.length }))
|
||||
.filter((r) => r.total > 0)
|
||||
.sort((a, b) => b.total - a.total);
|
||||
|
||||
for (const r of onlyIssues) {
|
||||
totalMissing += r.missingKeys.length;
|
||||
totalHardcoded += r.hardcoded.length;
|
||||
console.log(`\n## ${r.file}`);
|
||||
console.log(` ns=${JSON.stringify(r.namespaces)} t() calls=${r.tCallCount}`);
|
||||
if (r.missingKeys.length) {
|
||||
console.log(` ✗ missing keys (${r.missingKeys.length}):`);
|
||||
for (const k of r.missingKeys) console.log(` - ${k}`);
|
||||
}
|
||||
if (r.hardcoded.length) {
|
||||
console.log(` ✗ hardcoded strings (${r.hardcoded.length}):`);
|
||||
for (const h of r.hardcoded.slice(0, 30)) {
|
||||
console.log(` L${h.line} [${h.kind}] ${JSON.stringify(h.value)}`);
|
||||
}
|
||||
if (r.hardcoded.length > 30) console.log(` ... (+${r.hardcoded.length - 30} more)`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n# Summary`);
|
||||
console.log(`Files with issues: ${onlyIssues.length}`);
|
||||
console.log(`Missing keys total: ${totalMissing}`);
|
||||
console.log(`Hardcoded strings: ${totalHardcoded}`);
|
||||
console.log(`Report file: ${relative(REPO_ROOT, OUT_PATH)}`);
|
||||
104
scripts/i18n/build-pending-from-missing.mjs
Normal file
104
scripts/i18n/build-pending-from-missing.mjs
Normal file
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* For every missing key in _audit.json, locate its English value by
|
||||
* 1) finding the t("key") call site in the file
|
||||
* 2) grabbing the line(s) immediately around it in the HEAD version of the file
|
||||
*
|
||||
* This rebuilds a complete _pending-keys.json after subagents have already
|
||||
* rewritten .tsx files but en.json edits were lost.
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
const audit = JSON.parse(readFileSync("scripts/i18n/_audit.json", "utf8"));
|
||||
|
||||
function loadHead(file) {
|
||||
try {
|
||||
return execSync(`git show "HEAD:${file}"`, {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const SKIP = new Set([
|
||||
"templatePayloads.vision.system",
|
||||
"templatePayloads.vision.userPrompt",
|
||||
"templatePayloads.vision.imageUrl",
|
||||
"templatePayloads.schemaCoercion.userPrompt",
|
||||
"templatePayloads.schemaCoercion.toolDescription",
|
||||
"templatePayloads.schemaCoercion.cityDescription",
|
||||
]);
|
||||
|
||||
/** Find the line in NEW source where t("key") is used, then derive English value from HEAD */
|
||||
function valueForKey(file, key) {
|
||||
const cur = readFileSync(file, "utf8").split("\n");
|
||||
const head = loadHead(file)?.split("\n") ?? [];
|
||||
// String search avoids ReDoS — key is our own audit data but better safe
|
||||
const needle1 = `t("${key}")`;
|
||||
const needle2 = `t('${key}')`;
|
||||
for (let i = 0; i < cur.length; i++) {
|
||||
const line = cur[i];
|
||||
if (!line.includes(needle1) && !line.includes(needle2)) continue;
|
||||
// The original line in HEAD should be similar around the same area
|
||||
// Find the most-recently corresponding HEAD line: scan backwards and forwards from i
|
||||
const probe = [i, i - 1, i + 1, i - 2, i + 2, i - 3, i + 3];
|
||||
for (const j of probe) {
|
||||
if (j < 0 || j >= head.length) continue;
|
||||
const line = head[j];
|
||||
// Try to extract the English literal: between > <, or attribute value
|
||||
const jsxMatch = line.match(/>([^<>{}\n]{2,})</);
|
||||
if (jsxMatch && !jsxMatch[1].includes("{") && !jsxMatch[1].includes("=>")) {
|
||||
const v = jsxMatch[1].trim();
|
||||
if (v && /[A-Za-z]/.test(v) && v !== key) return v;
|
||||
}
|
||||
const attrMatch = line.match(
|
||||
/\b(?:title|placeholder|aria-label|alt|label)\s*=\s*["']([^"']+)["']/
|
||||
);
|
||||
if (attrMatch) {
|
||||
const v = attrMatch[1].trim();
|
||||
if (v && /[A-Za-z]/.test(v) && v !== key) return v;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const inferredNamespaces = new Map();
|
||||
function ensureNs(ns) {
|
||||
if (!inferredNamespaces.has(ns)) inferredNamespaces.set(ns, {});
|
||||
return inferredNamespaces.get(ns);
|
||||
}
|
||||
|
||||
for (const entry of audit) {
|
||||
if (!entry.missingKeys.length) continue;
|
||||
const ns = entry.namespaces[0];
|
||||
if (!ns) continue; // exampleTemplates etc. — skip
|
||||
const target = ensureNs(ns);
|
||||
for (const key of entry.missingKeys) {
|
||||
if (SKIP.has(key)) continue;
|
||||
if (target[key]) continue;
|
||||
const value = valueForKey(entry.file, key);
|
||||
if (value) {
|
||||
target[key] = value;
|
||||
} else {
|
||||
// Could not infer — leave a TODO marker so we notice
|
||||
target[key] = `__TODO__${key}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const out = Object.fromEntries(inferredNamespaces);
|
||||
writeFileSync("scripts/i18n/_pending-keys.json", JSON.stringify(out, null, 2) + "\n");
|
||||
|
||||
let total = 0;
|
||||
let todo = 0;
|
||||
for (const [ns, keys] of Object.entries(out)) {
|
||||
const cnt = Object.keys(keys).length;
|
||||
total += cnt;
|
||||
for (const v of Object.values(keys)) if (String(v).startsWith("__TODO__")) todo++;
|
||||
console.log(`${ns}: ${cnt} keys`);
|
||||
}
|
||||
console.log(`\nTotal: ${total} keys (${todo} need manual resolution)`);
|
||||
130
scripts/i18n/extract-keys-from-diff.mjs
Normal file
130
scripts/i18n/extract-keys-from-diff.mjs
Normal file
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Extract NEW i18n keys created by subagents from git diff.
|
||||
*
|
||||
* For each modified .tsx file, walk the unified diff and pair "-" lines
|
||||
* (containing literal English text) with their following "+" lines
|
||||
* (now using `t("key")`). When we find a stable pairing, record the key
|
||||
* and its original English value.
|
||||
*
|
||||
* The output is a per-namespace map ready to merge into en.json.
|
||||
*/
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
const diff = execSync('git diff --unified=0 "src/app/(dashboard)"', {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
});
|
||||
|
||||
const blocks = diff.split(/^diff --git /m).slice(1);
|
||||
|
||||
const allPairs = []; // { file, removed, added }
|
||||
|
||||
for (const block of blocks) {
|
||||
const headLine = block.split("\n")[0];
|
||||
const fileMatch = headLine.match(/b\/(.+?)$/);
|
||||
const file = fileMatch ? fileMatch[1] : "?";
|
||||
const lines = block.split("\n");
|
||||
|
||||
// Walk in groups: consecutive "-" lines, then consecutive "+" lines.
|
||||
// Pair them positionally (removed[i] ↔ added[i]).
|
||||
let removed = [];
|
||||
let added = [];
|
||||
function flush() {
|
||||
const n = Math.min(removed.length, added.length);
|
||||
for (let i = 0; i < n; i++) allPairs.push({ file, removed: removed[i], added: added[i] });
|
||||
// If removed.length > added.length, pair remaining removed with last added (multi-string in one new line)
|
||||
if (removed.length > added.length && added.length > 0) {
|
||||
for (let i = added.length; i < removed.length; i++) {
|
||||
allPairs.push({ file, removed: removed[i], added: added[added.length - 1] });
|
||||
}
|
||||
}
|
||||
removed = [];
|
||||
added = [];
|
||||
}
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("---") || line.startsWith("+++")) continue;
|
||||
if (line.startsWith("-")) {
|
||||
if (added.length) flush();
|
||||
removed.push(line.slice(1));
|
||||
} else if (line.startsWith("+")) {
|
||||
added.push(line.slice(1));
|
||||
} else {
|
||||
flush();
|
||||
}
|
||||
}
|
||||
flush();
|
||||
}
|
||||
|
||||
/** Patterns inside JSX or attribute strings */
|
||||
const T_CALL_RE = /\bt\(\s*["']([^"']+)["']\s*\)/g;
|
||||
const JSX_TEXT_RE = />([^<>{}\n]+)</g;
|
||||
const ATTR_RE = /\b(?:title|placeholder|aria-label|alt|label)\s*=\s*["']([^"']+)["']/g;
|
||||
|
||||
const newKeys = new Map(); // key -> { value, file }
|
||||
|
||||
function recordKey(key, value, file) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
// Ignore if value contains JSX expression syntax — those were dynamic
|
||||
if (/^\{|\}$/.test(trimmed)) return;
|
||||
if (!newKeys.has(key)) {
|
||||
newKeys.set(key, { value: trimmed, file });
|
||||
}
|
||||
}
|
||||
|
||||
for (const { file, removed, added } of allPairs) {
|
||||
// Extract every t("xxx") key from the "added" line
|
||||
const tKeys = [...added.matchAll(T_CALL_RE)].map((m) => m[1]);
|
||||
if (!tKeys.length) continue;
|
||||
|
||||
// Extract candidate strings from the "removed" line: JSX text + attr values
|
||||
const candidates = [];
|
||||
for (const m of removed.matchAll(JSX_TEXT_RE)) candidates.push(m[1]);
|
||||
for (const m of removed.matchAll(ATTR_RE)) candidates.push(m[1]);
|
||||
|
||||
// Direct strings without surrounding markup (rare)
|
||||
const stripped = removed
|
||||
.replace(/<[^<>]+>/g, "")
|
||||
.replace(/\bt\([^)]*\)/g, "")
|
||||
.trim();
|
||||
if (stripped && /[A-Za-z]/.test(stripped)) candidates.push(stripped);
|
||||
|
||||
// For each tKey in added, try to align with the candidate at the same index.
|
||||
// The agents typically replaced strings in the same left-to-right order.
|
||||
for (let i = 0; i < tKeys.length; i++) {
|
||||
const candidate = candidates[i] ?? candidates[candidates.length - 1];
|
||||
if (!candidate) continue;
|
||||
recordKey(tKeys[i], candidate, file);
|
||||
}
|
||||
}
|
||||
|
||||
// Group by file's primary namespace via useTranslations() call in file
|
||||
import { readFileSync } from "node:fs";
|
||||
function inferNamespaceForFile(file) {
|
||||
try {
|
||||
const src = readFileSync(file, "utf8");
|
||||
const m = src.match(/useTranslations\s*\(\s*["']([^"']+)["']\s*\)/);
|
||||
return m?.[1] ?? "common";
|
||||
} catch {
|
||||
return "common";
|
||||
}
|
||||
}
|
||||
|
||||
const byNamespace = new Map();
|
||||
for (const [key, { value, file }] of newKeys) {
|
||||
const ns = inferNamespaceForFile(file);
|
||||
if (!byNamespace.has(ns)) byNamespace.set(ns, []);
|
||||
byNamespace.get(ns).push({ key, value });
|
||||
}
|
||||
|
||||
for (const [ns, items] of byNamespace) {
|
||||
console.log(`\nNEW_KEYS_FOR_NAMESPACE: ${ns}`);
|
||||
console.log("{");
|
||||
for (const { key, value } of items) {
|
||||
// Escape value for JSON
|
||||
console.log(` ${JSON.stringify(key)}: ${JSON.stringify(value)},`);
|
||||
}
|
||||
console.log("}");
|
||||
}
|
||||
console.log(`\nTotal extracted: ${newKeys.size} keys across ${byNamespace.size} namespaces`);
|
||||
38
scripts/i18n/merge-keys.mjs
Normal file
38
scripts/i18n/merge-keys.mjs
Normal file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Merge keys from scripts/i18n/_pending-keys.json into src/i18n/messages/en.json
|
||||
*
|
||||
* Format of _pending-keys.json:
|
||||
* { "namespace": { "key": "value", ... }, ... }
|
||||
*
|
||||
* Keys are appended to the END of each namespace block. Existing keys with
|
||||
* the same name are preserved (we never overwrite).
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const SRC = "src/i18n/messages/en.json";
|
||||
const PENDING = "scripts/i18n/_pending-keys.json";
|
||||
|
||||
const enJson = JSON.parse(readFileSync(SRC, "utf8"));
|
||||
const pending = JSON.parse(readFileSync(PENDING, "utf8"));
|
||||
|
||||
let added = 0;
|
||||
let skipped = 0;
|
||||
for (const [ns, keys] of Object.entries(pending)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(enJson, ns)) {
|
||||
console.warn(`! namespace missing in en.json: ${ns} — skipping`);
|
||||
continue;
|
||||
}
|
||||
const target = enJson[ns];
|
||||
for (const [k, v] of Object.entries(keys)) {
|
||||
if (Object.prototype.hasOwnProperty.call(target, k)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
target[k] = v;
|
||||
added++;
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(SRC, JSON.stringify(enJson, null, 2) + "\n");
|
||||
console.log(`✓ merged ${added} new keys (${skipped} skipped — already present)`);
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
/**
|
||||
* Shown when OmniRoute was started with auto-generated secrets (zero-config mode).
|
||||
* The banner is dismissable and persists only for the current session.
|
||||
*/
|
||||
export default function BootstrapBanner() {
|
||||
const t = useTranslations("common");
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
if (dismissed) return null;
|
||||
@@ -46,7 +48,7 @@ export default function BootstrapBanner() {
|
||||
<button
|
||||
onClick={() => setDismissed(true)}
|
||||
className="shrink-0 text-amber-600/60 hover:text-amber-700 dark:text-amber-400/60 dark:hover:text-amber-300 transition-colors ml-1"
|
||||
aria-label="Dismiss"
|
||||
aria-label={t("bootstrapBannerDismiss")}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
@@ -136,13 +136,15 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// T07: Check for invalid API keys and show notification (once per session)
|
||||
const notifiedInvalidKeys = useRef<Set<string>>(new Set());
|
||||
// T07: Check for unhealthy API keys and show notification (once per session)
|
||||
const notifiedUnhealthyKeys = useRef<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
const checkApiKeyHealth = () => {
|
||||
const newInvalidKeys = new Set<string>();
|
||||
const invalidConnections: string[] = [];
|
||||
let firstInvalidProviderId: string | null = null;
|
||||
const newUnhealthyKeys = new Set<string>();
|
||||
const unhealthyProviderIds = new Set<string>();
|
||||
const unhealthyConnections: string[] = [];
|
||||
let firstUnhealthyProviderId: string | null = null;
|
||||
let hasWarning = false;
|
||||
|
||||
for (const conn of providerConnections) {
|
||||
const health = conn.providerSpecificData?.apiKeyHealth as
|
||||
@@ -157,41 +159,63 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
| undefined;
|
||||
if (!health) continue;
|
||||
|
||||
const invalidKeys = Object.entries(health).filter(([_, h]) => h.status === "invalid");
|
||||
// Defense-in-depth: skip stale extra_N health entries whose index
|
||||
// is out of range of the current extraApiKeys list.
|
||||
// The backend cleans this up on PATCH, but existing stale data from
|
||||
// before the fix or other code paths could still have orphan entries.
|
||||
const extras: string[] = conn.providerSpecificData?.extraApiKeys ?? [];
|
||||
const extraKeyCount = Array.isArray(extras) ? extras.length : 0;
|
||||
|
||||
if (invalidKeys.length > 0) {
|
||||
for (const [keyId] of invalidKeys) {
|
||||
newInvalidKeys.add(`${conn.id}:${keyId}`);
|
||||
const unhealthyKeys = Object.entries(health).filter(([keyId, h]) => {
|
||||
if (h.status !== "invalid" && h.status !== "warning") return false;
|
||||
// extra_N entries: only flag if the index is still within bounds
|
||||
if (keyId.startsWith("extra_")) {
|
||||
const idx = parseInt(keyId.slice(6), 10);
|
||||
if (isNaN(idx) || idx >= extraKeyCount) return false;
|
||||
}
|
||||
if (firstInvalidProviderId === null) {
|
||||
firstInvalidProviderId = conn.provider;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (unhealthyKeys.length > 0) {
|
||||
for (const [, h] of unhealthyKeys) {
|
||||
if (h.status === "warning") hasWarning = true;
|
||||
break;
|
||||
}
|
||||
invalidConnections.push(conn.name || conn.id);
|
||||
for (const [keyId] of unhealthyKeys) {
|
||||
newUnhealthyKeys.add(`${conn.id}:${keyId}`);
|
||||
}
|
||||
if (firstUnhealthyProviderId === null) {
|
||||
firstUnhealthyProviderId = conn.provider;
|
||||
}
|
||||
unhealthyConnections.push(conn.name || conn.id);
|
||||
unhealthyProviderIds.add(conn.provider);
|
||||
}
|
||||
}
|
||||
|
||||
// Only notify for newly invalid keys (not already notified)
|
||||
const hasNewInvalid = Array.from(newInvalidKeys).some(
|
||||
(k) => !notifiedInvalidKeys.current.has(k)
|
||||
// Only notify for newly unhealthy keys (not already notified)
|
||||
const hasNewUnhealthy = Array.from(newUnhealthyKeys).some(
|
||||
(k) => !notifiedUnhealthyKeys.current.has(k)
|
||||
);
|
||||
if (hasNewInvalid) {
|
||||
if (hasNewUnhealthy) {
|
||||
const navigateTo =
|
||||
newInvalidKeys.size === 1 && firstInvalidProviderId
|
||||
? `/dashboard/providers/${firstInvalidProviderId}`
|
||||
: "/dashboard/providers";
|
||||
newUnhealthyKeys.size === 1 && firstUnhealthyProviderId
|
||||
? `/dashboard/providers/${firstUnhealthyProviderId}`
|
||||
: `/dashboard/providers?search=${encodeURIComponent(Array.from(unhealthyProviderIds).join(" "))}`;
|
||||
|
||||
const notificationType = hasWarning ? "warning" : "error";
|
||||
|
||||
useNotificationStore.getState().addNotification({
|
||||
type: "warning",
|
||||
message: tp("apiKeyInvalidAlert", {
|
||||
count: newInvalidKeys.size,
|
||||
connections: invalidConnections.join(", "),
|
||||
type: notificationType,
|
||||
message: tp(hasWarning ? "apiKeyWarningAlert" : "apiKeyInvalidAlert", {
|
||||
count: newUnhealthyKeys.size,
|
||||
connections: unhealthyConnections.join(", "),
|
||||
}),
|
||||
title: tp("apiKeyInvalidAlertTitle"),
|
||||
title: tp(hasWarning ? "apiKeyWarningAlertTitle" : "apiKeyInvalidAlertTitle"),
|
||||
duration: 10000,
|
||||
onClick: () => router.push(navigateTo),
|
||||
});
|
||||
// Mark all current invalid keys as notified
|
||||
newInvalidKeys.forEach((k) => notifiedInvalidKeys.current.add(k));
|
||||
// Mark all current unhealthy keys as notified
|
||||
newUnhealthyKeys.forEach((k) => notifiedUnhealthyKeys.current.add(k));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -603,7 +627,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
<span className="material-symbols-outlined text-[18px]">check_circle</span>
|
||||
{updateSteps.find((s) => s.step === "complete")?.message || "Update complete!"}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-1">Reloading page automatically...</p>
|
||||
<p className="text-xs text-text-muted mt-1">{t("reloadingPageAutomatically")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -788,7 +812,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">Provider Topology</h2>
|
||||
<h2 className="text-base font-semibold">{t("providerTopology")}</h2>
|
||||
<p className="text-xs text-text-muted">
|
||||
Connected providers routing through OmniRoute in real time
|
||||
</p>
|
||||
|
||||
@@ -65,8 +65,8 @@ export function TierCoverageWidget() {
|
||||
<div className="rounded-xl border border-white/[0.06] bg-surface p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">Tier coverage</h3>
|
||||
<p className="text-xs text-text-muted mt-0.5">Providers configured per fallback tier</p>
|
||||
<h3 className="font-semibold text-sm">{t("tierCoverageTitle")}</h3>
|
||||
<p className="text-xs text-text-muted mt-0.5">{t("tierCoverageSubtitle")}</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/providers"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/shared/components";
|
||||
import A2ADashboardPage from "../endpoint/components/A2ADashboard";
|
||||
|
||||
@@ -118,6 +119,7 @@ function DisabledPanel() {
|
||||
}
|
||||
|
||||
export default function A2APage() {
|
||||
const t = useTranslations("a2aDashboard");
|
||||
const [a2aStatus, setA2aStatus] = useState<ServiceStatus>({ online: false, loading: true });
|
||||
const [a2aEnabled, setA2aEnabled] = useState(false);
|
||||
const [a2aToggling, setA2aToggling] = useState(false);
|
||||
@@ -192,19 +194,19 @@ export default function A2APage() {
|
||||
Discover the agent card at <code className="text-xs">/.well-known/agent.json</code>.
|
||||
</li>
|
||||
<li>
|
||||
Send JSON-RPC to <code className="text-xs">POST /a2a</code> using{" "}
|
||||
<code className="text-xs">message/send</code> or{" "}
|
||||
<code className="text-xs">message/stream</code>.
|
||||
Send JSON-RPC to <code className="text-xs">{t("rpcEndpoint")}</code> using{" "}
|
||||
<code className="text-xs">{t("rpcMethodSend")}</code> or{" "}
|
||||
<code className="text-xs">{t("rpcMethodStream")}</code>.
|
||||
</li>
|
||||
<li>
|
||||
Track and cancel tasks with <code className="text-xs">tasks/get</code> and{" "}
|
||||
<code className="text-xs">tasks/cancel</code>.
|
||||
Track and cancel tasks with <code className="text-xs">{t("rpcMethodGet")}</code> and{" "}
|
||||
<code className="text-xs">{t("rpcMethodCancel")}</code>.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<ServiceToggle
|
||||
label="A2A"
|
||||
label={t("serviceLabel")}
|
||||
status={a2aStatus}
|
||||
enabled={a2aEnabled}
|
||||
onToggle={() => void toggleA2a()}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
AGENT_SKILLS,
|
||||
AGENT_SKILLS_REPO_URL,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
|
||||
function CopyButton({ url }: { url: string }) {
|
||||
const t = useTranslations("agents");
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
const isCopied = copied === url;
|
||||
|
||||
@@ -21,17 +23,18 @@ function CopyButton({ url }: { url: string }) {
|
||||
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400"
|
||||
: "bg-bg-subtle text-text-muted hover:text-text-main"
|
||||
}`}
|
||||
title="Copy raw URL to clipboard"
|
||||
title={t("copyRawUrlTitle")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{isCopied ? "check" : "content_copy"}
|
||||
</span>
|
||||
{isCopied ? "Copied!" : "Copy URL"}
|
||||
{isCopied ? t("copied") : t("copyUrl")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillRow({ skill }: { skill: AgentSkill }) {
|
||||
const t = useTranslations("agents");
|
||||
const rawUrl = getAgentSkillRawUrl(skill.id);
|
||||
const blobUrl = getAgentSkillBlobUrl(skill.id);
|
||||
|
||||
@@ -46,12 +49,12 @@ function SkillRow({ skill }: { skill: AgentSkill }) {
|
||||
<span className="text-sm font-semibold text-text-main">{skill.name}</span>
|
||||
{skill.isEntry && (
|
||||
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary">
|
||||
Start Here
|
||||
{t("startHere")}
|
||||
</span>
|
||||
)}
|
||||
{skill.isNew && (
|
||||
<span className="rounded-full bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-amber-700 dark:text-amber-400">
|
||||
New
|
||||
{t("badgeNew")}
|
||||
</span>
|
||||
)}
|
||||
{skill.endpoint && (
|
||||
@@ -69,7 +72,7 @@ function SkillRow({ skill }: { skill: AgentSkill }) {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 rounded px-2 py-1 text-xs font-medium text-text-muted transition-colors hover:bg-bg-subtle hover:text-text-main"
|
||||
title="View on GitHub"
|
||||
title={t("viewOnGithub")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
|
||||
</a>
|
||||
@@ -109,6 +112,7 @@ function SkillSection({
|
||||
}
|
||||
|
||||
export default function AgentSkillsPage() {
|
||||
const t = useTranslations("agents");
|
||||
const apiSkills = AGENT_SKILLS.filter((s) => s.category === "api");
|
||||
const cliSkills = AGENT_SKILLS.filter((s) => s.category === "cli");
|
||||
|
||||
@@ -118,12 +122,12 @@ export default function AgentSkillsPage() {
|
||||
<div className="rounded-xl border border-border bg-bg-subtle/50 p-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-primary">info</span>
|
||||
<span className="text-sm font-semibold text-text-main">How to use</span>
|
||||
<span className="text-sm font-semibold text-text-main">{t("howToUse")}</span>
|
||||
</div>
|
||||
<ol className="space-y-1 text-xs text-text-muted">
|
||||
<li>
|
||||
1. Click <strong className="text-text-main">Copy URL</strong> on the skill you want your
|
||||
agent to know about.
|
||||
1. Click <strong className="text-text-main">{t("copyUrl")}</strong> on the skill you
|
||||
want your agent to know about.
|
||||
</li>
|
||||
<li>
|
||||
2. In your AI agent (Claude, Cursor, Cline…), say:
|
||||
@@ -144,20 +148,20 @@ export default function AgentSkillsPage() {
|
||||
className="mt-3 inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
|
||||
Browse all skills on GitHub
|
||||
{t("browseAllSkillsOnGithub")}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Two-column grid: API Skills | CLI Skills */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<SkillSection
|
||||
title="API Skills"
|
||||
title={t("apiSkills")}
|
||||
subtitle={`${apiSkills.length} skills — control OmniRoute via REST / HTTP`}
|
||||
icon="api"
|
||||
skills={apiSkills}
|
||||
/>
|
||||
<SkillSection
|
||||
title="CLI Skills"
|
||||
title={t("cliSkills")}
|
||||
subtitle={`${cliSkills.length} skills — control OmniRoute via the omniroute terminal binary`}
|
||||
icon="terminal"
|
||||
skills={cliSkills}
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function AutoRoutingAnalyticsTab() {
|
||||
<span className="material-symbols-outlined text-[20px]">auto_awesome</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-text-muted">Total Auto Requests</p>
|
||||
<p className="text-sm text-text-muted">{t("autoRoutingTotalAutoRequests")}</p>
|
||||
<p className="text-2xl font-bold">{stats.totalRequests.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -71,7 +71,7 @@ export default function AutoRoutingAnalyticsTab() {
|
||||
<span className="material-symbols-outlined text-[20px]">target</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-text-muted">Avg Selection Score</p>
|
||||
<p className="text-sm text-text-muted">{t("autoRoutingAvgSelectionScore")}</p>
|
||||
<p className="text-2xl font-bold">{(stats.avgSelectionScore * 100).toFixed(1)}%</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,7 +83,7 @@ export default function AutoRoutingAnalyticsTab() {
|
||||
<span className="material-symbols-outlined text-[20px]">explore</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-text-muted">Exploration Rate</p>
|
||||
<p className="text-sm text-text-muted">{t("autoRoutingExplorationRate")}</p>
|
||||
<p className="text-2xl font-bold">{(stats.explorationRate * 100).toFixed(1)}%</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,7 +95,7 @@ export default function AutoRoutingAnalyticsTab() {
|
||||
<span className="material-symbols-outlined text-[20px]">history</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-text-muted">LKGP Hit Rate</p>
|
||||
<p className="text-sm text-text-muted">{t("autoRoutingLkgpHitRate")}</p>
|
||||
<p className="text-2xl font-bold">{(stats.lkgpHitRate * 100).toFixed(1)}%</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,7 +104,7 @@ export default function AutoRoutingAnalyticsTab() {
|
||||
|
||||
{/* Variant Breakdown */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Requests by Variant</h3>
|
||||
<h3 className="text-lg font-semibold mb-4">{t("autoRoutingRequestsByVariant")}</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(stats.variantBreakdown).map(([variant, count]) => {
|
||||
const percentage = stats.totalRequests > 0 ? (count / stats.totalRequests) * 100 : 0;
|
||||
@@ -128,7 +128,7 @@ export default function AutoRoutingAnalyticsTab() {
|
||||
|
||||
{/* Top Providers */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Top Routed Providers</h3>
|
||||
<h3 className="text-lg font-semibold mb-4">{t("autoRoutingTopRoutedProviders")}</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Card from "@/shared/components/Card";
|
||||
import Badge from "@/shared/components/Badge";
|
||||
import { Skeleton, Spinner } from "@/shared/components/Loading";
|
||||
@@ -92,6 +93,7 @@ function DistributionBar({ label, value, meta }: { label: string; value: number;
|
||||
}
|
||||
|
||||
function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) {
|
||||
const t = useTranslations("analytics");
|
||||
const sortedDistribution = useMemo(
|
||||
() =>
|
||||
[...combo.usageSkew.modelDistribution].sort(
|
||||
@@ -119,18 +121,18 @@ function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) {
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 lg:min-w-[420px]">
|
||||
<MetricBlock
|
||||
icon="battery_status_good"
|
||||
label="Worst quota left"
|
||||
label={t("comboHealthWorstQuotaLeft")}
|
||||
value={formatPercent(combo.quotaHealth.worstRemainingPct)}
|
||||
/>
|
||||
<MetricBlock
|
||||
icon="balance"
|
||||
label="Usage skew"
|
||||
label={t("comboHealthUsageSkew")}
|
||||
value={combo.usageSkew.giniCoefficient.toFixed(2)}
|
||||
subValue="Gini coefficient"
|
||||
/>
|
||||
<MetricBlock
|
||||
icon="bolt"
|
||||
label="Success rate"
|
||||
label={t("comboHealthSuccessRate")}
|
||||
value={formatPercent(combo.performance.successRate * 100, 1)}
|
||||
subValue={`${combo.performance.totalRequests.toLocaleString()} requests`}
|
||||
/>
|
||||
@@ -141,7 +143,9 @@ function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) {
|
||||
<div className="grid gap-6 px-6 py-5 xl:grid-cols-[1.1fr_1fr_0.95fr]">
|
||||
<section className="flex flex-col gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-text-main">Quota health</div>
|
||||
<div className="text-sm font-semibold text-text-main">
|
||||
{t("comboHealthQuotaHealth")}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-text-muted">
|
||||
Lowest remaining quota across providers with short trend signals.
|
||||
</div>
|
||||
@@ -189,7 +193,7 @@ function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) {
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-text-main">Usage skew</div>
|
||||
<div className="text-sm font-semibold text-text-main">{t("comboHealthUsageSkew")}</div>
|
||||
<div className="mt-1 text-xs text-text-muted">
|
||||
Model request share and token share within this combo.
|
||||
</div>
|
||||
@@ -214,12 +218,12 @@ function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) {
|
||||
|
||||
<div className="mt-3 grid gap-2">
|
||||
<DistributionBar
|
||||
label="Requests"
|
||||
label={t("comboHealthRequests")}
|
||||
value={entry.requestShare}
|
||||
meta={formatShare(entry.requestShare)}
|
||||
/>
|
||||
<DistributionBar
|
||||
label="Tokens"
|
||||
label={t("comboHealthTokens")}
|
||||
value={entry.tokenShare}
|
||||
meta={formatShare(entry.tokenShare)}
|
||||
/>
|
||||
@@ -240,17 +244,17 @@ function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) {
|
||||
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
|
||||
<MetricBlock
|
||||
icon="timer"
|
||||
label="Avg latency"
|
||||
label={t("comboHealthAvgLatency")}
|
||||
value={formatLatency(combo.performance.avgLatencyMs)}
|
||||
/>
|
||||
<MetricBlock
|
||||
icon="task_alt"
|
||||
label="Success rate"
|
||||
label={t("comboHealthSuccessRate")}
|
||||
value={formatPercent(combo.performance.successRate * 100, 1)}
|
||||
/>
|
||||
<MetricBlock
|
||||
icon="stacked_line_chart"
|
||||
label="Total requests"
|
||||
label={t("comboHealthTotalRequests")}
|
||||
value={combo.performance.totalRequests.toLocaleString()}
|
||||
/>
|
||||
</div>
|
||||
@@ -260,7 +264,9 @@ function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) {
|
||||
{targetHealth.length > 0 ? (
|
||||
<div className="border-t border-black/5 px-6 py-5 dark:border-white/5">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-text-main">Execution targets</div>
|
||||
<div className="text-sm font-semibold text-text-main">
|
||||
{t("comboHealthExecutionTargets")}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-text-muted">
|
||||
Step-level runtime metrics and quota visibility for structured combo targets.
|
||||
</div>
|
||||
@@ -297,17 +303,17 @@ function ComboHealthCard({ combo }: { combo: ComboHealthMetrics }) {
|
||||
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-3">
|
||||
<DistributionBar
|
||||
label="Success"
|
||||
label={t("comboHealthSuccess")}
|
||||
value={Math.max(target.successRate, 0) / 100}
|
||||
meta={formatPercent(target.successRate, 0)}
|
||||
/>
|
||||
<DistributionBar
|
||||
label="Latency"
|
||||
label={t("comboHealthLatency")}
|
||||
value={target.avgLatencyMs > 0 ? 1 : 0}
|
||||
meta={formatLatency(target.avgLatencyMs)}
|
||||
/>
|
||||
<DistributionBar
|
||||
label="Quota"
|
||||
label={t("comboHealthQuota")}
|
||||
value={Math.max(target.quotaRemainingPct || 0, 0) / 100}
|
||||
meta={formatPercentOrDash(target.quotaRemainingPct)}
|
||||
/>
|
||||
@@ -361,6 +367,7 @@ function ComboHealthSkeleton() {
|
||||
}
|
||||
|
||||
export default function ComboHealthTab() {
|
||||
const t = useTranslations("analytics");
|
||||
const [range, setRange] = useState<UtilizationTimeRange>("24h");
|
||||
const [data, setData] = useState<ComboHealthResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -421,7 +428,7 @@ export default function ComboHealthTab() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4 rounded-xl border border-black/5 bg-surface p-5 shadow-sm dark:border-white/5 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text-main">Combo health</h2>
|
||||
<h2 className="text-lg font-semibold text-text-main">{t("comboHealthTitle")}</h2>
|
||||
<p className="mt-1 text-sm text-text-muted">
|
||||
Monitor quota pressure, skewed model usage, and delivery performance by combo.
|
||||
</p>
|
||||
@@ -436,7 +443,7 @@ export default function ComboHealthTab() {
|
||||
<div className="flex flex-col items-center justify-center gap-4 text-center">
|
||||
<span className="material-symbols-outlined text-[40px] text-error">sync_problem</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="font-medium text-text-main">Unable to load combo health</div>
|
||||
<div className="font-medium text-text-main">{t("comboHealthUnableToLoad")}</div>
|
||||
<div className="text-sm text-text-muted">{error}</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -477,7 +484,7 @@ export default function ComboHealthTab() {
|
||||
flowing.
|
||||
</div>
|
||||
<div className="rounded-lg border border-black/5 bg-black/[0.02] p-4 dark:border-white/5 dark:bg-white/[0.02]">
|
||||
<p className="text-xs font-medium text-text-main">Getting started</p>
|
||||
<p className="text-xs font-medium text-text-main">{t("comboHealthGettingStarted")}</p>
|
||||
<ul className="mt-2 text-left text-xs text-text-muted">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="material-symbols-outlined text-[14px] text-primary">
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface CompressionAnalyticsSummary {
|
||||
totalRequests: number;
|
||||
@@ -116,6 +117,7 @@ function ProviderBar({
|
||||
}
|
||||
|
||||
export default function CompressionAnalyticsTab() {
|
||||
const t = useTranslations("analytics");
|
||||
const [stats, setStats] = useState<CompressionAnalyticsSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -192,25 +194,33 @@ export default function CompressionAnalyticsTab() {
|
||||
<div className="grid grid-cols-2 md:grid-cols-6 gap-4">
|
||||
<StatCard
|
||||
icon="compress"
|
||||
label="Total Requests"
|
||||
label={t("compressionAnalyticsTotalRequests")}
|
||||
value={stats.totalRequests.toLocaleString()}
|
||||
/>
|
||||
<StatCard
|
||||
icon="token"
|
||||
label="Tokens Saved"
|
||||
label={t("compressionAnalyticsTokensSaved")}
|
||||
value={stats.totalTokensSaved.toLocaleString()}
|
||||
/>
|
||||
<StatCard icon="percent" label="Avg Savings" value={`${stats.avgSavingsPct}%`} />
|
||||
<StatCard icon="timer" label="Avg Duration" value={`${stats.avgDurationMs}ms`} />
|
||||
<StatCard
|
||||
icon="percent"
|
||||
label={t("compressionAnalyticsAvgSavings")}
|
||||
value={`${stats.avgSavingsPct}%`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="timer"
|
||||
label={t("compressionAnalyticsAvgDuration")}
|
||||
value={`${stats.avgDurationMs}ms`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="receipt_long"
|
||||
label="Receipts"
|
||||
label={t("compressionAnalyticsReceipts")}
|
||||
value={stats.realUsage.requestsWithReceipts.toLocaleString()}
|
||||
sub={`${stats.realUsage.totalTokens.toLocaleString()} real tokens`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="verified"
|
||||
label="Fallbacks"
|
||||
label={t("compressionAnalyticsFallbacks")}
|
||||
value={stats.validationFallbacks.toLocaleString()}
|
||||
sub="validation restores"
|
||||
/>
|
||||
@@ -224,25 +234,25 @@ export default function CompressionAnalyticsTab() {
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-text-muted">Prompt tokens</div>
|
||||
<div className="text-text-muted">{t("compressionAnalyticsPromptTokens")}</div>
|
||||
<div className="text-lg font-semibold text-text">
|
||||
{stats.realUsage.promptTokens.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-text-muted">Completion tokens</div>
|
||||
<div className="text-text-muted">{t("compressionAnalyticsCompletionTokens")}</div>
|
||||
<div className="text-lg font-semibold text-text">
|
||||
{stats.realUsage.completionTokens.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-text-muted">Total tokens</div>
|
||||
<div className="text-text-muted">{t("compressionAnalyticsTotalTokens")}</div>
|
||||
<div className="text-lg font-semibold text-text">
|
||||
{stats.realUsage.totalTokens.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-text-muted">Cache tokens</div>
|
||||
<div className="text-text-muted">{t("compressionAnalyticsCacheTokens")}</div>
|
||||
<div className="text-lg font-semibold text-text">
|
||||
{(
|
||||
(stats.realUsage.cacheReadTokens ?? 0) + (stats.realUsage.cacheWriteTokens ?? 0)
|
||||
@@ -345,7 +355,7 @@ export default function CompressionAnalyticsTab() {
|
||||
<span className="material-symbols-outlined text-[48px] mb-3 block text-primary opacity-50">
|
||||
compress
|
||||
</span>
|
||||
<p className="font-medium text-text">No compression data yet</p>
|
||||
<p className="font-medium text-text">{t("compressionAnalyticsNoDataYet")}</p>
|
||||
<p className="text-sm mt-1">
|
||||
Use <code className="bg-bg-muted px-1 rounded">POST /v1/chat/completions</code> with
|
||||
compression configuration to start tracking compression analytics.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
CartesianGrid,
|
||||
@@ -89,6 +90,7 @@ function getLatestPoints(points: ProviderUtilizationPoint[]) {
|
||||
}
|
||||
|
||||
export default function ProviderUtilizationTab() {
|
||||
const t = useTranslations("analytics");
|
||||
const [range, setRange] = useState<UtilizationTimeRange>("24h");
|
||||
const [aggregateBy, setAggregateBy] = useState<"provider" | "connection">("provider");
|
||||
const [data, setData] = useState<ProviderUtilizationResponse | null>(null);
|
||||
@@ -191,7 +193,7 @@ export default function ProviderUtilizationTab() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card
|
||||
title="Provider utilization"
|
||||
title={t("providerUtilizationTitle")}
|
||||
subtitle={RANGE_LABELS[range]}
|
||||
icon="monitoring"
|
||||
action={
|
||||
@@ -236,7 +238,9 @@ export default function ProviderUtilizationTab() {
|
||||
<div className="flex min-h-80 flex-col items-center justify-center gap-4 text-center">
|
||||
<span className="material-symbols-outlined text-[32px] text-error">error</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">Failed to load utilization data</p>
|
||||
<p className="text-sm font-medium text-text-main">
|
||||
{t("providerUtilizationFailedToLoad")}
|
||||
</p>
|
||||
<p className="text-sm text-text-muted">{error}</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -266,13 +270,15 @@ export default function ProviderUtilizationTab() {
|
||||
timeline
|
||||
</span>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium text-text-main">No utilization data available</p>
|
||||
<p className="text-sm font-medium text-text-main">{t("providerUtilizationNoData")}</p>
|
||||
<p className="max-w-md text-sm text-text-muted">
|
||||
Provider quota snapshots will appear here after utilization data is collected.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-black/5 bg-black/[0.02] p-4 dark:border-white/5 dark:bg-white/[0.02]">
|
||||
<p className="text-xs font-medium text-text-main">Getting started</p>
|
||||
<p className="text-xs font-medium text-text-main">
|
||||
{t("providerUtilizationGettingStarted")}
|
||||
</p>
|
||||
<ul className="mt-2 text-left text-xs text-text-muted">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="material-symbols-outlined text-[14px] text-primary">
|
||||
@@ -369,7 +375,9 @@ export default function ProviderUtilizationTab() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-text-main">{point.provider}</p>
|
||||
<p className="text-xs text-text-muted">Latest quota snapshot</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
{t("providerUtilizationLatestSnapshot")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
@@ -390,7 +398,9 @@ export default function ProviderUtilizationTab() {
|
||||
<p className="text-3xl font-bold text-text-main">
|
||||
{point.remainingPct.toFixed(point.remainingPct < 10 ? 1 : 0)}%
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-text-muted">Remaining capacity</p>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
{t("providerUtilizationRemainingCapacity")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right text-xs text-text-muted">
|
||||
<p>{formatTooltipTimestamp(point.timestamp, range)}</p>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface SearchStats {
|
||||
@@ -76,6 +77,7 @@ function ProviderBar({
|
||||
}
|
||||
|
||||
export default function SearchAnalyticsTab() {
|
||||
const t = useTranslations("analytics");
|
||||
const [stats, setStats] = useState<SearchStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -122,25 +124,25 @@ export default function SearchAnalyticsTab() {
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
icon="manage_search"
|
||||
label="Total Searches"
|
||||
label={t("searchAnalyticsTotalSearches")}
|
||||
value={stats.total.toLocaleString()}
|
||||
sub={`${stats.today} today`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="cached"
|
||||
label="Cache Hit Rate"
|
||||
label={t("searchAnalyticsCacheHitRate")}
|
||||
value={`${stats.cacheHitRate}%`}
|
||||
sub={`${stats.cached} cached requests`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="attach_money"
|
||||
label="Total Cost"
|
||||
label={t("searchAnalyticsTotalCost")}
|
||||
value={`$${stats.totalCostUsd.toFixed(4)}`}
|
||||
sub="search API costs"
|
||||
/>
|
||||
<StatCard
|
||||
icon="timer"
|
||||
label="Avg Response"
|
||||
label={t("searchAnalyticsAvgResponse")}
|
||||
value={`${stats.avgDurationMs}ms`}
|
||||
sub={stats.errors > 0 ? `${stats.errors} errors` : "No errors"}
|
||||
/>
|
||||
@@ -173,7 +175,7 @@ export default function SearchAnalyticsTab() {
|
||||
<span className="material-symbols-outlined text-[48px] mb-3 block text-primary opacity-50">
|
||||
travel_explore
|
||||
</span>
|
||||
<p className="font-medium text-text">No searches yet</p>
|
||||
<p className="font-medium text-text">{t("searchAnalyticsNoSearchesYet")}</p>
|
||||
<p className="text-sm mt-1">
|
||||
Use <code className="bg-bg-muted px-1 rounded">POST /v1/search</code> to start routing
|
||||
web searches.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
|
||||
@@ -15,6 +16,7 @@ interface DiversityReport {
|
||||
}
|
||||
|
||||
export default function DiversityScoreCard() {
|
||||
const t = useTranslations("analytics");
|
||||
const [data, setData] = useState<DiversityReport | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -82,7 +84,7 @@ export default function DiversityScoreCard() {
|
||||
<div className="flex items-center justify-between gap-3 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[20px] text-primary">pie_chart</span>
|
||||
<h3 className="font-semibold text-text-main">Provider Diversity</h3>
|
||||
<h3 className="font-semibold text-text-main">{t("diversityScoreTitle")}</h3>
|
||||
<span className="text-xs text-text-muted hidden sm:inline">
|
||||
— Provider concentration snapshot for the recent traffic window.
|
||||
</span>
|
||||
|
||||
@@ -1279,7 +1279,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
{/* Max Sessions Limit (T08) */}
|
||||
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">Max Active Sessions</p>
|
||||
<p className="text-sm font-medium text-text-main">{t("maxActiveSessions")}</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
0 = unlimited. Return 429 when this key exceeds concurrent sticky sessions.
|
||||
</p>
|
||||
@@ -1302,10 +1302,10 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">Custom Rate Limits</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Override global default limits. Leave empty to use defaults.
|
||||
<p className="text-sm font-medium text-text-main">
|
||||
{t("apiManagerCustomRateLimits")}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">{t("apiManagerCustomRateLimitsDesc")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1332,9 +1332,11 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
placeholder="Requests"
|
||||
placeholder={t("apiManagerRateLimitRequestsPlaceholder")}
|
||||
/>
|
||||
<span className="text-sm text-text-muted shrink-0">req /</span>
|
||||
<span className="text-sm text-text-muted shrink-0">
|
||||
{t("apiManagerRateLimitReqPer")}
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -1347,14 +1349,14 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
placeholder="Seconds"
|
||||
placeholder={t("apiManagerRateLimitSecondsPlaceholder")}
|
||||
/>
|
||||
<span className="text-sm text-text-muted shrink-0">sec</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRateLimits((prev) => prev.filter((_, i) => i !== index))}
|
||||
className="p-2 text-red-500 hover:bg-red-500/10 rounded transition-colors shrink-0"
|
||||
title="Remove limit"
|
||||
title={t("apiManagerRemoveLimitTitle")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">delete</span>
|
||||
</button>
|
||||
@@ -1454,7 +1456,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
type="text"
|
||||
value={scheduleTz}
|
||||
onChange={(e) => setScheduleTz(e.target.value)}
|
||||
placeholder="America/Sao_Paulo"
|
||||
placeholder={t("apiManagerTimezonePlaceholder")}
|
||||
className="w-full px-2 py-1.5 text-sm border border-border rounded-md bg-background text-text-main font-mono"
|
||||
/>
|
||||
<p className="text-[10px] text-text-muted mt-1">{t("scheduleTimezoneHint")}</p>
|
||||
@@ -1466,7 +1468,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
{/* Privacy Toggle */}
|
||||
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">No-Log Payload Privacy</p>
|
||||
<p className="text-sm font-medium text-text-main">{t("noLogPayloadPrivacy")}</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Disable request/response payload persistence for this API key.
|
||||
</p>
|
||||
@@ -1516,7 +1518,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
{/* Ban Toggle (SECURITY) */}
|
||||
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-red-500/20 bg-red-500/5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-bold text-red-700 dark:text-red-400">Banned Status</p>
|
||||
<p className="text-sm font-bold text-red-700 dark:text-red-400">{t("bannedStatus")}</p>
|
||||
<p className="text-xs text-red-600 dark:text-red-300">
|
||||
Immediately revoke all access. Used for suspected abuse or compromised keys.
|
||||
</p>
|
||||
@@ -1540,7 +1542,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
{/* Management API Access Toggle */}
|
||||
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">Management API Access</p>
|
||||
<p className="text-sm font-medium text-text-main">{t("managementApiAccess")}</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Allow this key to call management routes (providers, combos, settings) via{" "}
|
||||
<code className="font-mono">Authorization: Bearer</code>. Use for LLM agents only.
|
||||
@@ -1565,7 +1567,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
{/* Expiration Date */}
|
||||
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">Expiration Date</p>
|
||||
<p className="text-sm font-medium text-text-main">{t("expirationDate")}</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Key will automatically stop working after this date.
|
||||
</p>
|
||||
@@ -1583,7 +1585,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
{/* Management Access */}
|
||||
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">Management Access</p>
|
||||
<p className="text-sm font-medium text-text-main">{t("managementAccess")}</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Allow this API key to manage OmniRoute configuration.
|
||||
</p>
|
||||
@@ -1772,7 +1774,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
{allConnections.length > 0 && (
|
||||
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-text-main">Allowed Connections</p>
|
||||
<p className="text-sm font-medium text-text-main">{t("allowedConnections")}</p>
|
||||
<div className="flex gap-1 p-0.5 bg-surface rounded-md">
|
||||
<button
|
||||
onClick={() => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
function relativeTime(ts: number): string {
|
||||
const diffMs = Date.now() - ts * 1000;
|
||||
@@ -135,6 +136,7 @@ function formatTs(ts: number | null | undefined): string {
|
||||
}
|
||||
|
||||
export default function BatchDetailModal({ batch, files, onClose }: BatchDetailModalProps) {
|
||||
const t = useTranslations("common");
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
@@ -182,7 +184,7 @@ export default function BatchDetailModal({ batch, files, onClose }: BatchDetailM
|
||||
navigator.clipboard.writeText(batch.id);
|
||||
}}
|
||||
className="text-[var(--color-text-muted)] hover:text-[var(--color-text-main)] transition-colors"
|
||||
title="Copy ID"
|
||||
title={t("batchDetailCopyId")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]">content_copy</span>
|
||||
</button>
|
||||
@@ -191,7 +193,7 @@ export default function BatchDetailModal({ batch, files, onClose }: BatchDetailM
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
aria-label={t("batchDetailClose")}
|
||||
className="p-1.5 rounded-lg text-[var(--color-text-muted)] hover:bg-[var(--color-bg-alt)] transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">close</span>
|
||||
@@ -208,11 +210,11 @@ export default function BatchDetailModal({ batch, files, onClose }: BatchDetailM
|
||||
</span>
|
||||
<StatusBadge batch={batch} />
|
||||
</div>
|
||||
<Field label="Endpoint" value={batch.endpoint} />
|
||||
{batch.model && <Field label="Model" value={batch.model} />}
|
||||
<Field label="Window" value={batch.completionWindow} />
|
||||
<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="Created"
|
||||
label={t("batchDetailCreated")}
|
||||
value={<span title={formatTs(batch.createdAt)}>{relativeTime(batch.createdAt)}</span>}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import BatchDetailModal from "./BatchDetailModal";
|
||||
|
||||
function relativeTime(ts: number): string {
|
||||
@@ -131,6 +132,7 @@ export default function BatchListTab({
|
||||
loading,
|
||||
onRefresh,
|
||||
}: Readonly<BatchListTabProps>) {
|
||||
const t = useTranslations("common");
|
||||
const [selectedBatch, setSelectedBatch] = useState<BatchRecord | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@@ -199,7 +201,7 @@ export default function BatchListTab({
|
||||
<div className="flex flex-wrap gap-3 p-4 rounded-xl bg-[var(--color-surface)] border border-[var(--color-border)]">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by ID, endpoint, model…"
|
||||
placeholder={t("batchListSearchPlaceholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="flex-1 min-w-[200px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]"
|
||||
@@ -219,7 +221,7 @@ export default function BatchListTab({
|
||||
onClick={handleRemoveCompleted}
|
||||
disabled={removingCompleted}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-red-500/10 border border-red-500/25 text-red-400 hover:text-red-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
|
||||
title="Delete all completed batches"
|
||||
title={t("batchListDeleteAllCompletedTitle")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">
|
||||
{removingCompleted ? "hourglass_empty" : "delete_sweep"}
|
||||
@@ -230,7 +232,7 @@ export default function BatchListTab({
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto overflow-y-hidden rounded-xl border border-[var(--color-border)]">
|
||||
<table className="w-full text-sm" role="table" aria-label="Batches">
|
||||
<table className="w-full text-sm" role="table" aria-label={t("batchListBatchesTable")}>
|
||||
<thead>
|
||||
<tr className="bg-[var(--color-bg-alt)] border-b border-[var(--color-border)]">
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
|
||||
@@ -338,7 +340,7 @@ export default function BatchListTab({
|
||||
onClick={(e) => handleDeleteBatch(e, batch)}
|
||||
disabled={deletingId === batch.id}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-red-500/10 border border-red-500/25 text-red-400 hover:text-red-300 transition-colors whitespace-nowrap disabled:opacity-50"
|
||||
title="Delete batch and its files"
|
||||
title={t("batchListDeleteBatchTitle")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">
|
||||
{deletingId === batch.id ? "hourglass_empty" : "delete"}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/shared/components";
|
||||
|
||||
function relativeTime(ts: number): string {
|
||||
@@ -69,6 +70,7 @@ export default function FileDetailModal({
|
||||
batches,
|
||||
onClose,
|
||||
}: Readonly<FileDetailModalProps>) {
|
||||
const t = useTranslations("common");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const relatedBatches = (batches ?? []).filter(
|
||||
@@ -140,7 +142,7 @@ export default function FileDetailModal({
|
||||
navigator.clipboard.writeText(file.id);
|
||||
}}
|
||||
className="text-[var(--color-text-muted)] hover:text-[var(--color-text-main)] transition-colors"
|
||||
title="Copy ID"
|
||||
title={t("batchFileDetailCopyId")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]">content_copy</span>
|
||||
</button>
|
||||
@@ -149,7 +151,7 @@ export default function FileDetailModal({
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
aria-label={t("batchFileDetailClose")}
|
||||
className="p-1.5 rounded-lg text-[var(--color-text-muted)] hover:bg-[var(--color-bg-alt)] transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">close</span>
|
||||
@@ -266,7 +268,7 @@ export default function FileDetailModal({
|
||||
<span className="material-symbols-outlined text-[40px] mb-2 opacity-20">
|
||||
find_in_page
|
||||
</span>
|
||||
<p className="text-sm">Failed to load file contents</p>
|
||||
<p className="text-sm">{t("batchFileDetailFailedToLoad")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import FileDetailModal from "./FileDetailModal";
|
||||
|
||||
function relativeTime(ts: number): string {
|
||||
@@ -84,6 +85,7 @@ export default function FilesListTab({
|
||||
onRefresh,
|
||||
batches,
|
||||
}: Readonly<FilesListTabProps>) {
|
||||
const t = useTranslations("common");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [purposeFilter, setPurposeFilter] = useState("all");
|
||||
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
|
||||
@@ -129,7 +131,7 @@ export default function FilesListTab({
|
||||
<div className="flex flex-wrap gap-3 p-4 rounded-xl bg-[var(--color-surface)] border border-[var(--color-border)]">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by ID or filename…"
|
||||
placeholder={t("batchFilesListSearchPlaceholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="flex-1 min-w-[200px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]"
|
||||
@@ -149,7 +151,7 @@ export default function FilesListTab({
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto overflow-y-hidden rounded-xl border border-[var(--color-border)]">
|
||||
<table className="w-full text-sm" role="table" aria-label="Files">
|
||||
<table className="w-full text-sm" role="table" aria-label={t("batchFilesListFilesTable")}>
|
||||
<thead>
|
||||
<tr className="bg-[var(--color-bg-alt)] border-b border-[var(--color-border)]">
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import BatchListTab from "./BatchListTab";
|
||||
import { FileRecord } from "@/lib/db/files";
|
||||
import { BatchRecord } from "@/lib/db/batches";
|
||||
import { mapBatchApiToRecord, mapFileApiToRecord } from "./batch-utils";
|
||||
|
||||
export default function BatchPage() {
|
||||
const t = useTranslations("common");
|
||||
const [batches, setBatches] = useState<BatchRecord[]>([]);
|
||||
const [files, setFiles] = useState<FileRecord[]>([]);
|
||||
const [batchesTotal, setBatchesTotal] = useState(0);
|
||||
@@ -174,7 +176,7 @@ export default function BatchPage() {
|
||||
onRefresh={() => fetchData(false)}
|
||||
/>
|
||||
{loadingMore && batchesCount > 0 && (
|
||||
<div className="text-center text-sm">Loading more…</div>
|
||||
<div className="text-center text-sm">{t("batchPageLoadingMore")}</div>
|
||||
)}
|
||||
<div ref={bottomRefBatches} className="h-10" />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/shared/components";
|
||||
|
||||
interface CachePerformanceProps {
|
||||
@@ -68,6 +69,7 @@ export default function CachePerformance({
|
||||
onRetry,
|
||||
stats,
|
||||
}: CachePerformanceProps) {
|
||||
const t = useTranslations("cache");
|
||||
// Parse hitRate string (e.g. "85.0%") to number for the bar
|
||||
const hitRateNum = hitRate ? parseFloat(hitRate) : 0;
|
||||
|
||||
@@ -87,7 +89,7 @@ export default function CachePerformance({
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="self-start text-xs px-3 py-1.5 rounded bg-surface border border-border/50 hover:bg-surface/80 transition-colors"
|
||||
aria-label="Retry"
|
||||
aria-label={t("cachePerformanceRetry")}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
@@ -117,7 +119,9 @@ export default function CachePerformance({
|
||||
{!loading && !error && stats !== null && (
|
||||
<>
|
||||
{/* Hit rate bar */}
|
||||
{hitRate !== undefined && <HitRateBar hitRate={hitRateNum} label="Hit Rate" />}
|
||||
{hitRate !== undefined && (
|
||||
<HitRateBar hitRate={hitRateNum} label={t("cachePerformanceHitRate")} />
|
||||
)}
|
||||
|
||||
{/* Hit / Miss / Total breakdown */}
|
||||
<div className="grid grid-cols-3 gap-4 pt-3 border-t border-border/30 text-center">
|
||||
@@ -141,13 +145,17 @@ export default function CachePerformance({
|
||||
{avgLatencyMs !== undefined && (
|
||||
<div>
|
||||
<div className="text-lg font-semibold tabular-nums">{avgLatencyMs}</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">Avg Latency (ms)</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">
|
||||
{t("cachePerformanceAvgLatency")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{p95LatencyMs !== undefined && (
|
||||
<div>
|
||||
<div className="text-lg font-semibold tabular-nums">{p95LatencyMs}</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">P95 Latency (ms)</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">
|
||||
{t("cachePerformanceP95Latency")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function IdempotencyLayer({
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="self-start text-sm px-3 py-1 rounded bg-surface/50 hover:bg-surface/80 transition-colors"
|
||||
aria-label="Retry"
|
||||
aria-label={t("retry")}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
|
||||
@@ -336,7 +336,7 @@ export default function ReasoningCacheTab() {
|
||||
<tr className="border-b border-border/20 text-left text-[11px] uppercase tracking-[0.12em] text-text-muted">
|
||||
<th className="px-4 py-3">Model</th>
|
||||
<th className="px-4 py-3">{t("reasoningEntries")}</th>
|
||||
<th className="px-4 py-3">Avg Chars</th>
|
||||
<th className="px-4 py-3">{t("reasoningAvgChars")}</th>
|
||||
<th className="px-4 py-3">{t("reasoningChars")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import { Button } from "@/shared/components";
|
||||
import {
|
||||
@@ -80,6 +81,7 @@ const markdownComponents: Components = {
|
||||
};
|
||||
|
||||
export default function ChangelogViewer() {
|
||||
const t = useTranslations("common");
|
||||
const [markdown, setMarkdown] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
@@ -109,7 +111,7 @@ export default function ChangelogViewer() {
|
||||
<span className="material-symbols-outlined animate-spin text-[32px] text-text-muted/50">
|
||||
sync
|
||||
</span>
|
||||
<p className="text-sm text-text-muted">Loading changelog from GitHub...</p>
|
||||
<p className="text-sm text-text-muted">{t("changelogViewerLoading")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -649,8 +649,8 @@ openai_base_url = "${getEffectiveBaseUrl()}"
|
||||
onChange={(e) => setWireApi(e.target.value)}
|
||||
className="flex-1 px-2 py-1.5 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
>
|
||||
<option value="chat">Chat Completions (/chat/completions)</option>
|
||||
<option value="responses">Responses API (/responses)</option>
|
||||
<option value="chat">{t("wireApiChatCompletions")}</option>
|
||||
<option value="responses">{t("wireApiResponses")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -193,7 +193,7 @@ export default function CopilotToolCard({
|
||||
<div className="flex items-start gap-3 p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
|
||||
<span className="material-symbols-outlined text-blue-500 text-lg">info</span>
|
||||
<div className="text-sm text-blue-700 dark:text-blue-300">
|
||||
<p className="font-medium">GitHub Copilot Config Generator</p>
|
||||
<p className="font-medium">{t("copilotConfigGenerator")}</p>
|
||||
<p className="mt-1 text-xs opacity-80">
|
||||
Generates the{" "}
|
||||
<code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/10">
|
||||
@@ -226,7 +226,7 @@ export default function CopilotToolCard({
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<span className="font-medium text-sm">API Key</span>
|
||||
<span className="font-medium text-sm">{t("copilotApiKey")}</span>
|
||||
</div>
|
||||
<select
|
||||
value={selectedApiKeyId}
|
||||
@@ -278,7 +278,7 @@ export default function CopilotToolCard({
|
||||
type="text"
|
||||
value={searchFilter}
|
||||
onChange={(e) => setSearchFilter(e.target.value)}
|
||||
placeholder="Filter models..."
|
||||
placeholder={t("copilotFilterModelsPlaceholder")}
|
||||
className="w-full px-3 py-1.5 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
@@ -327,7 +327,9 @@ export default function CopilotToolCard({
|
||||
</summary>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 pl-6">
|
||||
<div>
|
||||
<label className="text-xs text-text-muted block mb-1">Max Input Tokens</label>
|
||||
<label className="text-xs text-text-muted block mb-1">
|
||||
{t("copilotMaxInputTokens")}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={maxInputTokens}
|
||||
@@ -336,7 +338,9 @@ export default function CopilotToolCard({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted block mb-1">Max Output Tokens</label>
|
||||
<label className="text-xs text-text-muted block mb-1">
|
||||
{t("copilotMaxOutputTokens")}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={maxOutputTokens}
|
||||
@@ -351,7 +355,7 @@ export default function CopilotToolCard({
|
||||
onChange={(e) => setToolCalling(e.target.checked)}
|
||||
className="rounded border-border accent-[#1F6FEB]"
|
||||
/>
|
||||
<span className="text-sm">Tool Calling</span>
|
||||
<span className="text-sm">{t("copilotToolCalling")}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
@@ -401,7 +405,7 @@ export default function CopilotToolCard({
|
||||
{/* Usage instructions */}
|
||||
<div className="mt-3 p-3 bg-bg-secondary rounded-lg border border-border">
|
||||
<p className="text-xs text-text-muted">
|
||||
<span className="font-medium text-text-main">Paste into: </span>
|
||||
<span className="font-medium text-text-main">{t("copilotPasteInto")} </span>
|
||||
<code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/10">
|
||||
~/.config/Code/User/chatLanguageModels.json
|
||||
</code>
|
||||
|
||||
@@ -328,14 +328,14 @@ export default function CloudAgentsPage() {
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Repository name"
|
||||
label={t("repositoryName")}
|
||||
placeholder="omniroute"
|
||||
value={newTask.repoName}
|
||||
onChange={(e) => setNewTask({ ...newTask, repoName: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Repository URL"
|
||||
label={t("repositoryUrl")}
|
||||
placeholder="https://github.com/owner/repo"
|
||||
value={newTask.repoUrl}
|
||||
onChange={(e) => setNewTask({ ...newTask, repoUrl: e.target.value })}
|
||||
@@ -344,7 +344,7 @@ export default function CloudAgentsPage() {
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Branch"
|
||||
label={t("branch")}
|
||||
placeholder="main"
|
||||
value={newTask.branch}
|
||||
onChange={(e) => setNewTask({ ...newTask, branch: e.target.value })}
|
||||
|
||||
@@ -287,7 +287,9 @@ export default function IntelligentComboPanel({
|
||||
|
||||
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<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">Mode Pack</p>
|
||||
<p className="text-[11px] uppercase tracking-wide text-text-muted">
|
||||
{t("modePack")}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-semibold text-text-main">
|
||||
{normalizedConfig.modePack}
|
||||
</p>
|
||||
|
||||
@@ -1738,7 +1738,7 @@ function ComboCard({
|
||||
onChange={(e) => handleCompressionOverrideChange(e.target.value)}
|
||||
disabled={isSavingCompression}
|
||||
className="text-xs py-1 px-2 rounded border border-black/10 dark:border-white/10 bg-white dark:bg-bg-main text-text-main focus:border-primary focus:outline-none transition-colors disabled:opacity-50 max-w-[130px] md:max-w-none"
|
||||
title="Compression Override"
|
||||
title={t("compressionOverride")}
|
||||
>
|
||||
<option value="">Default</option>
|
||||
<option value="off">Off</option>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface BadgeUnlockEvent {
|
||||
badgeId: string;
|
||||
@@ -20,6 +21,7 @@ const RECONNECT_BASE_MS = 1000;
|
||||
const RECONNECT_MAX_MS = 30000;
|
||||
|
||||
export function BadgeToast({ apiKeyId }: { apiKeyId: string }) {
|
||||
const t = useTranslations("common");
|
||||
const [toasts, setToasts] = useState<BadgeUnlockEvent[]>([]);
|
||||
const timeoutIds = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
|
||||
|
||||
@@ -92,7 +94,7 @@ export function BadgeToast({ apiKeyId }: { apiKeyId: string }) {
|
||||
>
|
||||
<span className="text-2xl">🏆</span>
|
||||
<div>
|
||||
<div className="font-semibold text-white">Badge Unlocked!</div>
|
||||
<div className="font-semibold text-white">{t("badgeToastUnlocked")}</div>
|
||||
<div className="text-sm text-text-muted">{toast.badgeName}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -339,11 +339,8 @@ export default function QuotaSharePageClient() {
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-xs text-amber-700 dark:text-amber-300 flex items-start gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] shrink-0">science</span>
|
||||
<div>
|
||||
<strong>Beta — UI preview.</strong> A configuração é salva em <code>localStorage</code>{" "}
|
||||
(não persiste no servidor ainda). A aplicação dos caps por request ainda não está
|
||||
conectada ao pipeline da proxy. Esta tela permite desenhar e visualizar a divisão de cota;
|
||||
a aplicação real virá em uma próxima iteração com persistência no banco e interceptação na
|
||||
chamada upstream.
|
||||
<strong>{t("betaPreviewLabel")}</strong> {t("betaConfigSavedPrefix")}{" "}
|
||||
<code>localStorage</code> {t("betaConfigSavedSuffix")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -598,7 +595,9 @@ function PoolCard({
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-2 flex-wrap text-[11px]">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-text-muted font-semibold uppercase tracking-wide">Policy:</span>
|
||||
<span className="text-text-muted font-semibold uppercase tracking-wide">
|
||||
{t("policyLabel")}
|
||||
</span>
|
||||
{(["hard", "soft", "burst"] as PoolPolicy[]).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/shared/components";
|
||||
import { useDisplayBaseUrl } from "@/shared/hooks";
|
||||
|
||||
@@ -44,6 +45,7 @@ const METHOD_COLORS: Record<string, string> = {
|
||||
|
||||
/* ─── Main Component ─────────────────────────────────── */
|
||||
export default function ApiEndpointsTab() {
|
||||
const t = useTranslations("endpoint");
|
||||
const baseUrl = useDisplayBaseUrl();
|
||||
const [catalog, setCatalog] = useState<CatalogData | null>(null);
|
||||
const [catalogError, setCatalogError] = useState<string | null>(null);
|
||||
@@ -224,7 +226,9 @@ export default function ApiEndpointsTab() {
|
||||
<span className="material-symbols-outlined text-[20px] text-red-500">error</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-main">API catalog unavailable</h3>
|
||||
<h3 className="text-sm font-semibold text-text-main">
|
||||
{t("apiEndpointsCatalogUnavailable")}
|
||||
</h3>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
{catalogError || "The OpenAPI specification could not be loaded."}
|
||||
</p>
|
||||
@@ -254,7 +258,7 @@ export default function ApiEndpointsTab() {
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search endpoints..."
|
||||
placeholder={t("apiEndpointsSearchPlaceholder")}
|
||||
className="w-full pl-9 pr-3 py-2 text-xs rounded-lg border border-black/10 dark:border-white/10
|
||||
bg-white dark:bg-black/20 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
@@ -334,7 +338,7 @@ export default function ApiEndpointsTab() {
|
||||
{ep.security && (
|
||||
<span
|
||||
className="material-symbols-outlined text-[12px] text-amber-500"
|
||||
title="Requires auth"
|
||||
title={t("apiEndpointsRequiresAuth")}
|
||||
>
|
||||
lock
|
||||
</span>
|
||||
@@ -482,7 +486,7 @@ export default function ApiEndpointsTab() {
|
||||
<span className="material-symbols-outlined text-[32px] text-text-muted">
|
||||
search_off
|
||||
</span>
|
||||
<p className="text-sm text-text-muted mt-2">No endpoints match your filter</p>
|
||||
<p className="text-sm text-text-muted mt-2">{t("apiEndpointsNoMatch")}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1281,7 +1281,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-1 flex-wrap">
|
||||
<span className="text-sm font-medium">Local Server</span>
|
||||
<span className="text-sm font-medium">{t("localServer")}</span>
|
||||
{resolvedMachineId && (
|
||||
<span className="text-xs text-text-muted">· {resolvedMachineId.slice(0, 8)}</span>
|
||||
)}
|
||||
@@ -1335,7 +1335,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
cloud
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium">Cloud OmniRoute</span>
|
||||
<span className="text-sm font-medium">{t("cloudOmniroute")}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium border shrink-0 ${
|
||||
@@ -2387,7 +2387,7 @@ function EndpointCard({
|
||||
<button
|
||||
onClick={() => void copy(fullUrl, copyId)}
|
||||
className="shrink-0 flex items-center justify-center size-6 rounded hover:bg-sidebar transition-colors"
|
||||
title="Copy URL"
|
||||
title={t("copyUrl")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px] text-text-muted">
|
||||
{copied === copyId ? "check" : "content_copy"}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, Toggle } from "@/shared/components";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
|
||||
@@ -111,6 +112,7 @@ function EngineRow({
|
||||
}
|
||||
|
||||
export default function TokenSaverCard() {
|
||||
const t = useTranslations("endpoint");
|
||||
const notify = useNotificationStore();
|
||||
const [config, setConfig] = useState<CompressionConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -182,14 +184,14 @@ export default function TokenSaverCard() {
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<p className="text-sm text-text-muted mt-1">Spend less tokens on every request.</p>
|
||||
<p className="text-sm text-text-muted mt-1">{t("tokenSaverSubtitle")}</p>
|
||||
</div>
|
||||
<Toggle size="md" checked={masterEnabled} onChange={(v) => save({ enabled: v })} />
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border mt-4">
|
||||
<EngineRow
|
||||
title="Tool output"
|
||||
title={t("tokenSaverToolOutput")}
|
||||
badge="RTK"
|
||||
href="/dashboard/context/rtk"
|
||||
description="git/grep/ls/tree/logs cleaner → 60-90% fewer input tokens"
|
||||
@@ -206,7 +208,7 @@ export default function TokenSaverCard() {
|
||||
}
|
||||
/>
|
||||
<EngineRow
|
||||
title="LLM output"
|
||||
title={t("tokenSaverLlmOutput")}
|
||||
badge="Caveman"
|
||||
href="/dashboard/context/caveman"
|
||||
description="Terse-style system prompt → ~65% fewer output tokens (up to 87%)"
|
||||
@@ -223,7 +225,7 @@ export default function TokenSaverCard() {
|
||||
}
|
||||
/>
|
||||
<EngineRow
|
||||
title="Input compression"
|
||||
title={t("tokenSaverInputCompression")}
|
||||
badge="Caveman"
|
||||
href="/dashboard/context/caveman"
|
||||
description="Rewrite chat history → ~50% fewer input tokens"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/shared/components";
|
||||
|
||||
interface Anomaly {
|
||||
@@ -10,6 +11,7 @@ interface Anomaly {
|
||||
}
|
||||
|
||||
export default function GamificationAdminPage() {
|
||||
const t = useTranslations("common");
|
||||
const [anomalies, setAnomalies] = useState<Anomaly[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -33,24 +35,24 @@ export default function GamificationAdminPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Gamification Admin</h1>
|
||||
<p className="text-sm text-text-muted mt-1">Monitor anomalies and system health</p>
|
||||
<h1 className="text-2xl font-bold">{t("gamificationAdmin")}</h1>
|
||||
<p className="text-sm text-text-muted mt-1">{t("monitorAnomaliesAndHealth")}</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold mb-3">Flagged Anomalies</h2>
|
||||
<h2 className="text-lg font-semibold mb-3">{t("flaggedAnomalies")}</h2>
|
||||
{loading ? (
|
||||
<div className="text-text-muted">Loading...</div>
|
||||
) : anomalies.length === 0 ? (
|
||||
<div className="text-text-muted">No anomalies detected</div>
|
||||
<div className="text-text-muted">{t("noAnomaliesDetected")}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left p-2 font-medium text-text-muted">API Key</th>
|
||||
<th className="text-right p-2 font-medium text-text-muted">XP (1h)</th>
|
||||
<th className="text-right p-2 font-medium text-text-muted">Z-Score</th>
|
||||
<th className="text-left p-2 font-medium text-text-muted">{t("apiKey")}</th>
|
||||
<th className="text-right p-2 font-medium text-text-muted">{t("xpLastHour")}</th>
|
||||
<th className="text-right p-2 font-medium text-text-muted">{t("zScore")}</th>
|
||||
<th className="text-center p-2 font-medium text-text-muted">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -255,7 +255,7 @@ export default function HealthPage() {
|
||||
<span className="material-symbols-outlined text-[18px]">database</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text-main">Database Health</h2>
|
||||
<h2 className="text-lg font-semibold text-text-main">{t("databaseHealth")}</h2>
|
||||
<p className="text-sm text-text-muted">
|
||||
Diagnose and repair stale quota/domain rows and broken combo references.
|
||||
</p>
|
||||
@@ -417,13 +417,13 @@ export default function HealthPage() {
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||
<div className="rounded-xl border border-border/40 bg-surface/30 p-3">
|
||||
<div className="text-xs text-text-muted">Sticky-bound sessions</div>
|
||||
<div className="text-xs text-text-muted">{t("stickyBoundSessions")}</div>
|
||||
<div className="text-2xl font-semibold text-text-main mt-1">
|
||||
{sessions?.stickyBoundCount ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/40 bg-surface/30 p-3">
|
||||
<div className="text-xs text-text-muted">Sessions by API key</div>
|
||||
<div className="text-xs text-text-muted">{t("sessionsByApiKey")}</div>
|
||||
<div className="text-2xl font-semibold text-text-main mt-1">
|
||||
{Object.keys(sessions?.byApiKey || {}).length}
|
||||
</div>
|
||||
@@ -453,7 +453,7 @@ export default function HealthPage() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">No active sessions tracked yet.</p>
|
||||
<p className="text-sm text-text-muted">{t("noActiveSessionsTracked")}</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -532,14 +532,14 @@ export default function HealthPage() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">No session quota monitors active.</p>
|
||||
<p className="text-sm text-text-muted">{t("noSessionQuotaMonitorsActive")}</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Graceful Degradation Status */}
|
||||
{degradation && degradation.features && degradation.features.length > 0 && (
|
||||
<Card className="p-5" role="region" aria-label="Graceful Degradation Status">
|
||||
<Card className="p-5" role="region" aria-label={t("gracefulDegradationStatus")}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-text-main flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[20px] text-primary">healing</span>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/shared/components";
|
||||
|
||||
type LeaderboardScope = "global" | "weekly" | "monthly" | "tokens_shared";
|
||||
@@ -26,6 +27,7 @@ const MEDAL_COLORS = [
|
||||
const MEDAL_EMOJI = ["🥇", "🥈", "🥉"];
|
||||
|
||||
export default function LeaderboardPage() {
|
||||
const t = useTranslations("common");
|
||||
const [scope, setScope] = useState<LeaderboardScope>("global");
|
||||
const [entries, setEntries] = useState<LeaderboardEntry[]>([]);
|
||||
const [myRank, setMyRank] = useState<number | null>(null);
|
||||
@@ -110,7 +112,7 @@ export default function LeaderboardPage() {
|
||||
<Card>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-text-muted">Your Rank</p>
|
||||
<p className="text-sm text-text-muted">{t("leaderboardYourRank")}</p>
|
||||
<p className="text-3xl font-bold mt-1">#{myRank}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
@@ -125,7 +127,7 @@ export default function LeaderboardPage() {
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center min-h-[200px]">
|
||||
<div className="text-text-muted">Loading leaderboard...</div>
|
||||
<div className="text-text-muted">{t("leaderboardLoading")}</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/shared/components";
|
||||
import { copyToClipboard } from "@/shared/utils/clipboard";
|
||||
import McpDashboardPage from "../endpoint/components/MCPDashboard";
|
||||
@@ -98,6 +99,7 @@ function TransportSelector({
|
||||
disabled: boolean;
|
||||
baseUrl: string;
|
||||
}) {
|
||||
const t = useTranslations("mcpDashboard");
|
||||
const options: { value: McpTransport; label: string; desc: string }[] = [
|
||||
{ value: "stdio", label: "stdio", desc: "Local — IDE spawns process via omniroute --mcp" },
|
||||
{ value: "sse", label: "SSE", desc: "Remote — Server-Sent Events over HTTP" },
|
||||
@@ -181,7 +183,7 @@ function TransportSelector({
|
||||
className="ml-auto text-xs px-2 py-0.5 rounded border hover:opacity-80 transition-opacity"
|
||||
style={{ borderColor: "var(--color-border)", color: "var(--color-text-muted)" }}
|
||||
onClick={() => void copyToClipboard(urlMap[value])}
|
||||
title="Copy URL"
|
||||
title={t("mcpDashboardCopyUrl")}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Image from "next/image";
|
||||
|
||||
export function TierFlowDiagram() {
|
||||
const t = useTranslations("onboarding");
|
||||
const { resolvedTheme } = useTheme();
|
||||
const src =
|
||||
resolvedTheme === "dark" ? "/images/tier-flow-dark.svg" : "/images/tier-flow-light.svg";
|
||||
@@ -12,7 +14,7 @@ export function TierFlowDiagram() {
|
||||
<div className="flex flex-col items-center gap-3 my-4">
|
||||
<Image
|
||||
src={src}
|
||||
alt="OmniRoute 3-tier fallback diagram"
|
||||
alt={t("tierFlowDiagramAlt")}
|
||||
width={800}
|
||||
height={420}
|
||||
priority
|
||||
|
||||
@@ -48,9 +48,18 @@ export default function ChatPlayground({
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const filteredModels = models
|
||||
.filter((m) => !selectedProvider || m.id.startsWith(selectedProvider + "/"))
|
||||
.map((m) => ({ value: m.id, label: m.id }));
|
||||
const filteredModels = (() => {
|
||||
const seen = new Set<string>();
|
||||
const out: Array<{ value: string; label: string }> = [];
|
||||
for (const m of models) {
|
||||
if (typeof m?.id !== "string") continue;
|
||||
if (selectedProvider && !m.id.startsWith(selectedProvider + "/")) continue;
|
||||
if (seen.has(m.id)) continue;
|
||||
seen.add(m.id);
|
||||
out.push({ value: m.id, label: m.id });
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
@@ -222,7 +231,7 @@ export default function ChatPlayground({
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted">chat</span>
|
||||
<h3 className="text-sm font-semibold text-text-main">Conversational Chat</h3>
|
||||
<h3 className="text-sm font-semibold text-text-main">{t("conversationalChat")}</h3>
|
||||
{responseStatus !== null && (
|
||||
<Badge variant={responseStatus < 400 ? "success" : "error"} size="sm">
|
||||
{responseStatus}
|
||||
@@ -235,7 +244,7 @@ export default function ChatPlayground({
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="p-1.5 rounded hover:bg-red-500/10 text-text-muted hover:text-red-500 transition-colors"
|
||||
title="Clear chat"
|
||||
title={t("clearChat")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">delete</span>
|
||||
</button>
|
||||
@@ -292,7 +301,7 @@ export default function ChatPlayground({
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message... (Shift+Enter for new line)"
|
||||
placeholder={t("typeMessagePlaceholder")}
|
||||
className="flex-1 min-h-[44px] max-h-[120px] bg-surface border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary resize-y"
|
||||
rows={1}
|
||||
disabled={loading || noModels}
|
||||
|
||||
@@ -175,7 +175,7 @@ export default function SearchPlayground() {
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(requestBody)}
|
||||
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
|
||||
title="Copy"
|
||||
title={t("copy")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">content_copy</span>
|
||||
</button>
|
||||
@@ -194,7 +194,7 @@ export default function SearchPlayground() {
|
||||
)
|
||||
}
|
||||
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
|
||||
title="Reset to default"
|
||||
title={t("resetToDefault")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">restart_alt</span>
|
||||
</button>
|
||||
|
||||
@@ -259,6 +259,7 @@ export default function PlaygroundPage() {
|
||||
|
||||
const providerSet = new Set<string>();
|
||||
modelList.forEach((m) => {
|
||||
if (typeof m?.id !== "string") return;
|
||||
const parts = m.id.split("/");
|
||||
if (parts.length >= 2) providerSet.add(parts[0]);
|
||||
});
|
||||
@@ -270,7 +271,9 @@ export default function PlaygroundPage() {
|
||||
setSelectedProvider(providerOpts[0].value);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch((err) => {
|
||||
console.error("[playground] Failed to load models:", err);
|
||||
});
|
||||
|
||||
// Fetch ALL connections (once)
|
||||
fetch("/api/providers/client")
|
||||
@@ -291,9 +294,18 @@ export default function PlaygroundPage() {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const filteredModels = models
|
||||
.filter((m) => !selectedProvider || m.id.startsWith(selectedProvider + "/"))
|
||||
.map((m) => ({ value: m.id, label: m.id }));
|
||||
const filteredModels = (() => {
|
||||
const seen = new Set<string>();
|
||||
const out: Array<{ value: string; label: string }> = [];
|
||||
for (const m of models) {
|
||||
if (typeof m?.id !== "string") continue;
|
||||
if (selectedProvider && !m.id.startsWith(selectedProvider + "/")) continue;
|
||||
if (seen.has(m.id)) continue;
|
||||
seen.add(m.id);
|
||||
out.push({ value: m.id, label: m.id });
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
|
||||
const generateDefaultBody = (endpoint: string, model: string) => {
|
||||
const template = { ...DEFAULT_BODIES[endpoint] };
|
||||
@@ -307,7 +319,9 @@ export default function PlaygroundPage() {
|
||||
setSelectedProvider(newProvider);
|
||||
setSelectedConnection("");
|
||||
const providerModels = models
|
||||
.filter((m) => !newProvider || m.id.startsWith(newProvider + "/"))
|
||||
.filter(
|
||||
(m) => typeof m?.id === "string" && (!newProvider || m.id.startsWith(newProvider + "/"))
|
||||
)
|
||||
.map((m) => m.id);
|
||||
const firstModel = providerModels[0] || "";
|
||||
setSelectedModel(firstModel);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, Badge } from "@/shared/components";
|
||||
import {
|
||||
xpForLevel,
|
||||
@@ -57,6 +58,7 @@ const RARITY_COLORS: Record<string, string> = {
|
||||
};
|
||||
|
||||
export default function ProfilePage() {
|
||||
const t = useTranslations("common");
|
||||
const [userLevel, setUserLevel] = useState<UserLevel | null>(null);
|
||||
const [allBadges, setAllBadges] = useState<BadgeDef[]>([]);
|
||||
const [earnedBadges, setEarnedBadges] = useState<UserBadge[]>([]);
|
||||
@@ -99,7 +101,7 @@ export default function ProfilePage() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-text-muted">Loading profile...</div>
|
||||
<div className="text-text-muted">{t("profileLoading")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -269,7 +271,7 @@ export default function ProfilePage() {
|
||||
|
||||
{selectedBadge.criteria && (
|
||||
<div className="p-3 rounded-lg bg-surface/50 border border-border/50">
|
||||
<p className="text-xs font-medium text-text-muted mb-1">How to earn</p>
|
||||
<p className="text-xs font-medium text-text-muted mb-1">{t("profileHowToEarn")}</p>
|
||||
<p className="text-sm">{selectedBadge.criteria}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3373,8 +3373,8 @@ export default function ProviderDetailPage() {
|
||||
Import from Zed Keychain
|
||||
</h2>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
Discover AI provider credentials (OpenAI, Anthropic, Google, Mistral, xAI) that Zed
|
||||
IDE stored in the OS keychain and import them as connections. Requires Zed IDE
|
||||
Discover AI provider credentials (OpenAI, Anthropic, Google, Mistral, xAI) that
|
||||
Zed IDE stored in the OS keychain and import them as connections. Requires Zed IDE
|
||||
installed on this machine.
|
||||
</p>
|
||||
</div>
|
||||
@@ -3408,8 +3408,8 @@ export default function ProviderDetailPage() {
|
||||
<p className="text-sm text-text-muted">
|
||||
Use this when OmniRoute runs in Docker or the keychain is unavailable. Paste the
|
||||
API key that Zed stored under{" "}
|
||||
<code className="font-mono text-xs">~/.config/zed/settings.json</code> or copy it
|
||||
from the Zed AI settings panel.
|
||||
<code className="font-mono text-xs">~/.config/zed/settings.json</code> or copy
|
||||
it from the Zed AI settings panel.
|
||||
</p>
|
||||
<div className="flex gap-2 flex-col sm:flex-row">
|
||||
<select
|
||||
@@ -3530,13 +3530,13 @@ export default function ProviderDetailPage() {
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">{t("connections")}</h2>
|
||||
{providerId === "codex" && (
|
||||
<div title="Apply Codex Fast tier to all Codex connections by default">
|
||||
<div title={t("providerDetailFastTierTooltip")}>
|
||||
<Toggle
|
||||
size="sm"
|
||||
checked={codexGlobalFastServiceTier}
|
||||
onChange={handleToggleCodexGlobalFastServiceTier}
|
||||
disabled={savingCodexGlobalFastServiceTier}
|
||||
label="Fast default"
|
||||
label={t("providerDetailFastDefaultLabel")}
|
||||
ariaLabel="Toggle Codex Fast default"
|
||||
className="rounded-lg border border-sky-500/20 bg-sky-500/5 px-2 py-1"
|
||||
/>
|
||||
@@ -7274,7 +7274,9 @@ function AddApiKeyModal({
|
||||
open_in_new
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-text-main">Browser/manual connect</p>
|
||||
<p className="font-medium text-text-main">
|
||||
{t("providerDetailBrowserManualConnect")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
Open Command Code Studio, then paste the returned key/JSON/URL into the API
|
||||
key field below.
|
||||
@@ -7287,7 +7289,9 @@ function AddApiKeyModal({
|
||||
{commandCodeAuthState?.authUrl && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-text-main">Auth URL</p>
|
||||
<p className="mb-1 text-xs font-medium text-text-main">
|
||||
{t("providerDetailAuthUrl")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={commandCodeAuthState.authUrl}
|
||||
@@ -7306,7 +7310,9 @@ function AddApiKeyModal({
|
||||
</div>
|
||||
{commandCodeAuthState.callbackUrl && (
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-text-main">Callback URL</p>
|
||||
<p className="mb-1 text-xs font-medium text-text-main">
|
||||
{t("providerDetailCallbackUrl")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={commandCodeAuthState.callbackUrl}
|
||||
@@ -8253,9 +8259,7 @@ function ApplyCodexAuthModal({
|
||||
<code className="block rounded bg-sidebar px-2 py-1.5 text-xs font-mono text-text-main">
|
||||
~/.codex/auth.json
|
||||
</code>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
Path is auto-detected per OS (Linux/Mac/Windows).
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-text-muted">{t("providerDetailPathAutoDetectedAllOs")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs uppercase text-text-muted mb-1">{backupLabel}</div>
|
||||
@@ -8709,7 +8713,9 @@ function ImportClaudeAuthModal({ onClose, onSuccess }: ImportClaudeAuthModalProp
|
||||
className="block w-full text-sm"
|
||||
/>
|
||||
{singleJson && previewClaudeJson(singleJson).valid && (
|
||||
<p className="mt-1 text-xs text-emerald-500">Valid Claude credentials file</p>
|
||||
<p className="mt-1 text-xs text-emerald-500">
|
||||
{t("providerDetailValidClaudeCredentialsFile")}
|
||||
</p>
|
||||
)}
|
||||
{singleJson && !previewClaudeJson(singleJson).valid && (
|
||||
<p className="mt-1 text-xs text-red-500">
|
||||
@@ -9753,7 +9759,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
|
||||
? "text-red-400"
|
||||
: health?.status === "warning"
|
||||
? "text-yellow-400"
|
||||
: "text-green-400";
|
||||
: "text-text-muted";
|
||||
const statusIcon =
|
||||
health?.status === "invalid" ? "🔴" : health?.status === "warning" ? "🟡" : "🟢";
|
||||
const statusLabel =
|
||||
@@ -9766,7 +9772,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`flex-1 font-mono text-xs bg-sidebar/50 px-3 py-2 rounded border border-border text-text-muted truncate ${statusColor}`}
|
||||
className={`flex-1 font-mono text-xs bg-sidebar/50 px-3 py-2 rounded border border-border truncate ${statusColor}`}
|
||||
>
|
||||
{statusIcon} {t("primaryKey")}: {connection.apiKey.slice(0, 6)}...
|
||||
{connection.apiKey.slice(-4)}
|
||||
@@ -9820,7 +9826,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
|
||||
? "text-red-400"
|
||||
: health?.status === "warning"
|
||||
? "text-yellow-400"
|
||||
: "text-green-400";
|
||||
: "text-text-muted";
|
||||
const statusIcon =
|
||||
health?.status === "invalid"
|
||||
? "🔴"
|
||||
@@ -9837,7 +9843,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
|
||||
return (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<span
|
||||
className={`flex-1 font-mono text-xs bg-sidebar/50 px-3 py-2 rounded border border-border text-text-muted truncate ${statusColor}`}
|
||||
className={`flex-1 font-mono text-xs bg-sidebar/50 px-3 py-2 rounded border border-border truncate ${statusColor}`}
|
||||
>
|
||||
{statusIcon}{" "}
|
||||
{t("extraApiKeyMasked", {
|
||||
|
||||
@@ -17,6 +17,7 @@ interface ProviderStats {
|
||||
total?: number;
|
||||
connected?: number;
|
||||
error?: number;
|
||||
warning?: number;
|
||||
errorCode?: string | null;
|
||||
errorTime?: string | null;
|
||||
allDisabled?: boolean;
|
||||
@@ -57,6 +58,7 @@ const DOT_COLORS: Record<string, string> = {
|
||||
function getStatusDisplay(
|
||||
connected: number,
|
||||
error: number,
|
||||
warning: number,
|
||||
errorCode: string | null | undefined,
|
||||
t: ReturnType<typeof useTranslations>,
|
||||
afterConnected?: ReactNode
|
||||
@@ -70,6 +72,13 @@ function getStatusDisplay(
|
||||
);
|
||||
if (afterConnected) parts.push(afterConnected);
|
||||
}
|
||||
if (warning > 0) {
|
||||
parts.push(
|
||||
<Badge key="warning" variant="warning" size="sm" dot>
|
||||
{t("warningCount", { count: warning })}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (error > 0) {
|
||||
const errText = errorCode
|
||||
? t("errorCount", { count: error, code: errorCode })
|
||||
@@ -206,7 +215,14 @@ export default function ProviderCard({
|
||||
</Badge>
|
||||
) : (
|
||||
<>
|
||||
{getStatusDisplay(connected, error, stats.errorCode, t, codexFastChip)}
|
||||
{getStatusDisplay(
|
||||
connected,
|
||||
error,
|
||||
Number(stats.warning || 0),
|
||||
stats.errorCode,
|
||||
t,
|
||||
codexFastChip
|
||||
)}
|
||||
{stats.expiryStatus === "expired" && (
|
||||
<Badge variant="error" size="sm" dot>
|
||||
{t("expiredBadge")}
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
isClaudeCodeCompatibleProvider,
|
||||
CLOUD_AGENT_PROVIDERS,
|
||||
} from "@/shared/constants/providers";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { getErrorCode, getRelativeTime } from "@/shared/utils";
|
||||
import { pickDisplayValue } from "@/shared/utils/maskEmail";
|
||||
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
|
||||
@@ -142,12 +142,20 @@ export default function ProvidersPage() {
|
||||
const tc = useTranslations("common");
|
||||
const ccCompatibleLabel = t("ccCompatibleLabel");
|
||||
const addCcCompatibleLabel = t("addCcCompatible");
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
setShowConfiguredOnly(readConfiguredOnlyPreference());
|
||||
setConfiguredOnlyPreferenceReady(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const searchFromUrl = searchParams.get("search");
|
||||
if (searchFromUrl) {
|
||||
setSearchQuery(searchFromUrl);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
@@ -282,9 +290,19 @@ export default function ProvidersPage() {
|
||||
getCodexEffectiveFastServiceTier(connection.providerSpecificData, false)
|
||||
));
|
||||
|
||||
// Count API keys in "warning" state across all connections
|
||||
const warning = providerConnections.reduce((warnCount, conn) => {
|
||||
const health = (conn as any).providerSpecificData?.apiKeyHealth as
|
||||
| Record<string, { status: string }>
|
||||
| undefined;
|
||||
if (!health) return warnCount;
|
||||
return warnCount + Object.values(health).filter((h) => h.status === "warning").length;
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
connected,
|
||||
error,
|
||||
warning,
|
||||
total,
|
||||
errorCode,
|
||||
errorTime,
|
||||
|
||||
@@ -492,7 +492,7 @@ export default function AppearanceTab() {
|
||||
{(settings.customLogoUrl || settings.customLogoBase64) && (
|
||||
<img
|
||||
src={settings.customLogoBase64 || settings.customLogoUrl}
|
||||
alt="Logo preview"
|
||||
alt={t("appearanceLogoPreviewAlt")}
|
||||
className="h-10 w-10 rounded border border-border object-contain bg-surface"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = "none";
|
||||
@@ -562,7 +562,7 @@ export default function AppearanceTab() {
|
||||
<p className="text-xs text-text-muted mb-2">{t("logoPreview")}</p>
|
||||
<img
|
||||
src={settings.customLogoBase64 || settings.customLogoUrl}
|
||||
alt="Logo preview"
|
||||
alt={t("appearanceLogoPreviewAlt")}
|
||||
className="h-12 w-auto max-w-full rounded"
|
||||
/>
|
||||
</div>
|
||||
@@ -586,7 +586,7 @@ export default function AppearanceTab() {
|
||||
{(settings.customFaviconUrl || settings.customFaviconBase64) && (
|
||||
<img
|
||||
src={settings.customFaviconBase64 || settings.customFaviconUrl}
|
||||
alt="Favicon preview"
|
||||
alt={t("appearanceFaviconPreviewAlt")}
|
||||
className="h-10 w-10 rounded border border-border object-contain bg-surface"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = "none";
|
||||
@@ -658,7 +658,7 @@ export default function AppearanceTab() {
|
||||
<p className="text-xs text-text-muted mb-2">{t("faviconPreview")}</p>
|
||||
<img
|
||||
src={settings.customFaviconBase64 || settings.customFaviconUrl}
|
||||
alt="Favicon preview"
|
||||
alt={t("appearanceFaviconPreviewAlt")}
|
||||
className="h-8 w-8 rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, Button, Input, Toggle } from "@/shared/components";
|
||||
|
||||
interface Settings {
|
||||
@@ -28,6 +29,7 @@ function isValidUrl(value: string): boolean {
|
||||
}
|
||||
|
||||
export default function CliproxyapiSettingsTab() {
|
||||
const t = useTranslations("settings");
|
||||
const [settings, setSettings] = useState<Settings>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -139,7 +141,7 @@ export default function CliproxyapiSettingsTab() {
|
||||
<span className="material-symbols-outlined text-indigo-500 text-xl">swap_horiz</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-sm">CLIProxyAPI Fallback</h3>
|
||||
<h3 className="font-medium text-sm">{t("cliproxyapiFallback")}</h3>
|
||||
<p className="text-xs text-text-muted">
|
||||
When enabled, failed requests are retried through CLIProxyAPI (localhost:8317)
|
||||
</p>
|
||||
@@ -148,7 +150,7 @@ export default function CliproxyapiSettingsTab() {
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm text-text-main">Enable CLIProxyAPI Fallback</label>
|
||||
<label className="text-sm text-text-main">{t("cliproxyapiEnableFallback")}</label>
|
||||
<Toggle
|
||||
checked={cpaEnabled}
|
||||
onChange={(checked) => updateSetting("cliproxyapi_fallback_enabled", checked)}
|
||||
@@ -158,7 +160,9 @@ export default function CliproxyapiSettingsTab() {
|
||||
{cpaEnabled && (
|
||||
<>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1.5 block">CLIProxyAPI URL</label>
|
||||
<label className="text-xs text-text-muted mb-1.5 block">
|
||||
{t("cliproxyapiUrl")}
|
||||
</label>
|
||||
<Input
|
||||
value={cpaUrl}
|
||||
onChange={(e) => updateSetting("cliproxyapi_url", e.target.value)}
|
||||
@@ -184,7 +188,7 @@ export default function CliproxyapiSettingsTab() {
|
||||
</Card>
|
||||
|
||||
<Card padding="md">
|
||||
<h3 className="font-medium text-sm mb-4">CLIProxyAPI Status</h3>
|
||||
<h3 className="font-medium text-sm mb-4">{t("cliproxyapiStatus")}</h3>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-text-muted text-sm">
|
||||
<span className="material-symbols-outlined animate-spin text-base">
|
||||
@@ -237,7 +241,7 @@ export default function CliproxyapiSettingsTab() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">CLIProxyAPI not detected</p>
|
||||
<p className="text-sm text-text-muted">{t("cliproxyapiNotDetected")}</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -340,7 +340,9 @@ export default function CompressionSettingsTab() {
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-sm text-text-muted">Auto trigger mode</span>
|
||||
<span className="text-sm text-text-muted">
|
||||
{t("compressionSettingsAutoTriggerMode")}
|
||||
</span>
|
||||
<select
|
||||
value={config.autoTriggerMode ?? "lite"}
|
||||
onChange={(e) => save({ autoTriggerMode: e.target.value as CompressionMode })}
|
||||
@@ -386,7 +388,9 @@ export default function CompressionSettingsTab() {
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-sm text-text-muted">MCP description compression</span>
|
||||
<span className="text-sm text-text-muted">
|
||||
{t("compressionSettingsMcpDescriptionCompression")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
save({
|
||||
@@ -484,7 +488,9 @@ export default function CompressionSettingsTab() {
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-sm text-text-muted">Caveman intensity</span>
|
||||
<span className="text-sm text-text-muted">
|
||||
{t("compressionSettingsCavemanIntensity")}
|
||||
</span>
|
||||
<select
|
||||
value={config.cavemanConfig.intensity}
|
||||
onChange={(e) =>
|
||||
@@ -556,7 +562,9 @@ export default function CompressionSettingsTab() {
|
||||
<div className="space-y-3 pt-4 border-t border-border/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-text-main">Caveman output mode</h4>
|
||||
<h4 className="text-sm font-medium text-text-main">
|
||||
{t("compressionSettingsCavemanOutputMode")}
|
||||
</h4>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
Injects terse response instructions without rewriting provider output.
|
||||
</p>
|
||||
@@ -583,7 +591,9 @@ export default function CompressionSettingsTab() {
|
||||
</div>
|
||||
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-sm text-text-muted">Output intensity</span>
|
||||
<span className="text-sm text-text-muted">
|
||||
{t("compressionSettingsOutputIntensity")}
|
||||
</span>
|
||||
<select
|
||||
value={config.cavemanOutputMode.intensity}
|
||||
onChange={(e) =>
|
||||
@@ -603,7 +613,9 @@ export default function CompressionSettingsTab() {
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-sm text-text-muted">Auto clarity bypass</span>
|
||||
<span className="text-sm text-text-muted">
|
||||
{t("compressionSettingsAutoClarityBypass")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
save({
|
||||
|
||||
@@ -757,7 +757,7 @@ export default function MemorySkillsTab() {
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">SkillsMP Marketplace</h3>
|
||||
<h3 className="text-lg font-semibold">{t("memorySkillsSkillsmpMarketplace")}</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
Connect to SkillsMP to discover and install skills from the marketplace.
|
||||
</p>
|
||||
@@ -769,12 +769,14 @@ export default function MemorySkillsTab() {
|
||||
</span>
|
||||
)}
|
||||
{skillsmpStatus === "error" && (
|
||||
<span className="ml-auto text-xs font-medium text-red-500">Failed to save</span>
|
||||
<span className="ml-auto text-xs font-medium text-red-500">
|
||||
{t("memorySkillsFailedToSave")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-surface/30 border border-border/30">
|
||||
<label className="text-sm font-medium block mb-2">API Key</label>
|
||||
<label className="text-sm font-medium block mb-2">{t("memorySkillsApiKey")}</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
@@ -807,7 +809,7 @@ export default function MemorySkillsTab() {
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Active Skills Provider</h3>
|
||||
<h3 className="text-lg font-semibold">{t("memorySkillsActiveSkillsProvider")}</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
Choose which provider the Skills page uses for search and install.
|
||||
</p>
|
||||
@@ -819,7 +821,9 @@ export default function MemorySkillsTab() {
|
||||
</span>
|
||||
)}
|
||||
{skillsProviderStatus === "error" && (
|
||||
<span className="ml-auto text-xs font-medium text-red-500">Failed to save</span>
|
||||
<span className="ml-auto text-xs font-medium text-red-500">
|
||||
{t("memorySkillsFailedToSave")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button, Card } from "@/shared/components";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
|
||||
@@ -20,6 +21,7 @@ function formatRemaining(ms: number): string {
|
||||
}
|
||||
|
||||
export default function ModelCooldownsCard() {
|
||||
const t = useTranslations("settings");
|
||||
const notify = useNotificationStore();
|
||||
const [items, setItems] = useState<CooldownItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -95,7 +97,7 @@ export default function ModelCooldownsCard() {
|
||||
<Card className="p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-text-main">Models in cooldown</h2>
|
||||
<h2 className="text-lg font-bold text-text-main">{t("modelCooldownsTitle")}</h2>
|
||||
<p className="mt-1 text-sm text-text-muted">
|
||||
Models temporarily isolated after a failure. When the cooldown expires they come back
|
||||
automatically.
|
||||
@@ -120,7 +122,7 @@ export default function ModelCooldownsCard() {
|
||||
{loading ? (
|
||||
<p className="text-sm text-text-muted">Loading...</p>
|
||||
) : !hasItems ? (
|
||||
<p className="text-sm text-text-muted">No models in cooldown right now.</p>
|
||||
<p className="text-sm text-text-muted">{t("modelCooldownsEmpty")}</p>
|
||||
) : (
|
||||
sorted.map((item) => {
|
||||
const rowKey = `${item.provider}::${item.model}`;
|
||||
|
||||
@@ -140,7 +140,7 @@ export default function OneproxyTab() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text-main">1proxy Free Proxy Marketplace</h2>
|
||||
<h2 className="text-lg font-semibold text-text-main">{t("oneproxyTitle")}</h2>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
Fetch and rotate free validated proxies from the 1proxy community platform
|
||||
</p>
|
||||
@@ -173,7 +173,7 @@ export default function OneproxyTab() {
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="text-2xl font-bold text-text-main">{stats.total}</div>
|
||||
<div className="text-sm text-text-muted">Total Proxies</div>
|
||||
<div className="text-sm text-text-muted">{t("oneproxyTotalProxies")}</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="text-2xl font-bold text-green-600">{stats.active}</div>
|
||||
@@ -183,13 +183,15 @@ export default function OneproxyTab() {
|
||||
<div className="text-2xl font-bold text-text-main">
|
||||
{stats.avgQuality != null ? `${stats.avgQuality}` : "—"}
|
||||
</div>
|
||||
<div className="text-sm text-text-muted">Avg Quality</div>
|
||||
<div className="text-sm text-text-muted">{t("oneproxyAvgQuality")}</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="text-2xl font-bold text-text-main">
|
||||
{status?.lastSyncAt ? new Date(status.lastSyncAt).toLocaleTimeString() : "Never"}
|
||||
{status?.lastSyncAt
|
||||
? new Date(status.lastSyncAt).toLocaleTimeString()
|
||||
: t("oneproxyNever")}
|
||||
</div>
|
||||
<div className="text-sm text-text-muted">Last Sync</div>
|
||||
<div className="text-sm text-text-muted">{t("oneproxyLastSync")}</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
@@ -201,7 +203,7 @@ export default function OneproxyTab() {
|
||||
onChange={(e) => setFilterProtocol(e.target.value)}
|
||||
className="px-3 py-2 rounded-lg bg-black/5 dark:bg-white/5 text-text-main text-sm border border-border"
|
||||
>
|
||||
<option value="">All Protocols</option>
|
||||
<option value="">{t("oneproxyAllProtocols")}</option>
|
||||
<option value="http">HTTP</option>
|
||||
<option value="https">HTTPS</option>
|
||||
<option value="socks4">SOCKS4</option>
|
||||
@@ -209,14 +211,14 @@ export default function OneproxyTab() {
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Country code (e.g. US)"
|
||||
placeholder={t("oneproxyCountryCodePlaceholder")}
|
||||
value={filterCountry}
|
||||
onChange={(e) => setFilterCountry(e.target.value)}
|
||||
className="px-3 py-2 rounded-lg bg-black/5 dark:bg-white/5 text-text-main text-sm border border-border w-40"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Min quality"
|
||||
placeholder={t("oneproxyMinQualityPlaceholder")}
|
||||
value={minQuality}
|
||||
onChange={(e) => setMinQuality(e.target.value)}
|
||||
className="px-3 py-2 rounded-lg bg-black/5 dark:bg-white/5 text-text-main text-sm border border-border w-32"
|
||||
@@ -226,7 +228,7 @@ export default function OneproxyTab() {
|
||||
|
||||
<Card className="p-4">
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-text-muted">Loading proxies...</div>
|
||||
<div className="text-center py-8 text-text-muted">{t("oneproxyLoadingProxies")}</div>
|
||||
) : proxies.length === 0 ? (
|
||||
<div className="text-center py-8 text-text-muted">
|
||||
No 1proxy proxies found. Click "Sync Now" to fetch free proxies.
|
||||
@@ -300,25 +302,27 @@ export default function OneproxyTab() {
|
||||
|
||||
{status && (
|
||||
<Card className="p-4">
|
||||
<h3 className="text-sm font-semibold text-text-main mb-2">Sync Status</h3>
|
||||
<h3 className="text-sm font-semibold text-text-main mb-2">
|
||||
{t("oneproxySyncStatusTitle")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-text-muted">Last sync: </span>
|
||||
<span className="text-text-muted">{t("oneproxyLastSyncLabel")} </span>
|
||||
<span className={status.lastSyncSuccess ? "text-green-600" : "text-red-600"}>
|
||||
{status.lastSyncSuccess ? "Success" : "Failed"}
|
||||
{status.lastSyncSuccess ? t("oneproxySuccess") : t("oneproxyFailed")}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted">Proxies fetched: </span>
|
||||
<span className="text-text-muted">{t("oneproxyProxiesFetched")} </span>
|
||||
<span className="text-text-main">{status.lastSyncCount}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-muted">Consecutive failures: </span>
|
||||
<span className="text-text-muted">{t("oneproxyConsecutiveFailures")} </span>
|
||||
<span className="text-text-main">{status.consecutiveFailures}</span>
|
||||
</div>
|
||||
{status.lastSyncError && (
|
||||
<div className="col-span-full">
|
||||
<span className="text-text-muted">Error: </span>
|
||||
<span className="text-text-muted">{t("oneproxyErrorLabel")} </span>
|
||||
<span className="text-red-600 text-xs">{status.lastSyncError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, Button } from "@/shared/components";
|
||||
|
||||
const EMPTY_PAYLOAD_RULES_TEMPLATE = {
|
||||
@@ -55,6 +56,7 @@ function getErrorMessage(payload: unknown): string {
|
||||
}
|
||||
|
||||
export default function PayloadRulesTab() {
|
||||
const t = useTranslations("settings");
|
||||
const [editorValue, setEditorValue] = useState(EMPTY_PAYLOAD_RULES_TEXT);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -162,7 +164,7 @@ export default function PayloadRulesTab() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold">Payload Rules</h3>
|
||||
<h3 className="text-lg font-semibold">{t("payloadRulesTitle")}</h3>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
Configure request payload mutations by model and protocol. Changes are persisted in
|
||||
settings and hot reloaded into the runtime immediately after save.
|
||||
|
||||
@@ -849,7 +849,7 @@ export default function ProxyRegistryManager() {
|
||||
onClose={() => {
|
||||
if (!bulkSaving) setBulkOpen(false);
|
||||
}}
|
||||
title="Bulk Proxy Assignment"
|
||||
title={t("bulkProxyAssignment")}
|
||||
maxWidth="lg"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -874,7 +874,7 @@ export default function ProxyRegistryManager() {
|
||||
value={bulkProxyId}
|
||||
onChange={(e) => setBulkProxyId(e.target.value)}
|
||||
>
|
||||
<option value="">(clear assignment)</option>
|
||||
<option value="">{t("clearAssignment")}</option>
|
||||
{items.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name} ({item.type}://{item.host}:{item.port})
|
||||
|
||||
@@ -62,16 +62,17 @@ function SectionDescription({
|
||||
trigger: string;
|
||||
effect: string;
|
||||
}) {
|
||||
const t = useTranslations("settings");
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-2 text-xs text-text-muted sm:grid-cols-3">
|
||||
<div>
|
||||
<span className="font-semibold text-text-main">Scope:</span> {scope}
|
||||
<span className="font-semibold text-text-main">{t("resilienceScope")}</span> {scope}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-text-main">Trigger:</span> {trigger}
|
||||
<span className="font-semibold text-text-main">{t("resilienceTrigger")}</span> {trigger}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-text-main">Effect:</span> {effect}
|
||||
<span className="font-semibold text-text-main">{t("resilienceEffect")}</span> {effect}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -203,6 +204,7 @@ function RequestQueueCard({
|
||||
onSave: (next: RequestQueueSettings) => Promise<void>;
|
||||
saving: boolean;
|
||||
}) {
|
||||
const t = useTranslations("settings");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
|
||||
@@ -216,7 +218,7 @@ function RequestQueueCard({
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-xl text-primary">speed</span>
|
||||
<h2 className="text-lg font-bold">Request Queue & Rate</h2>
|
||||
<h2 className="text-lg font-bold">{t("resilienceRequestQueueTitle")}</h2>
|
||||
</div>
|
||||
<SectionDescription
|
||||
scope="Per request queue"
|
||||
@@ -248,7 +250,7 @@ function RequestQueueCard({
|
||||
{editing ? (
|
||||
<>
|
||||
<BooleanField
|
||||
label="Auto-enable for API-key providers"
|
||||
label={t("resilienceAutoEnableApiKeyProviders")}
|
||||
description="Enable queue protection by default for active API-key connections."
|
||||
checked={draft.autoEnableApiKeyProviders}
|
||||
onChange={(autoEnableApiKeyProviders) =>
|
||||
@@ -256,13 +258,13 @@ function RequestQueueCard({
|
||||
}
|
||||
/>
|
||||
<NumberField
|
||||
label="Requests per minute"
|
||||
label={t("resilienceRequestsPerMinute")}
|
||||
value={draft.requestsPerMinute}
|
||||
min={1}
|
||||
onChange={(requestsPerMinute) => setDraft((prev) => ({ ...prev, requestsPerMinute }))}
|
||||
/>
|
||||
<NumberField
|
||||
label="Minimum time between requests"
|
||||
label={t("resilienceMinTimeBetweenRequests")}
|
||||
value={draft.minTimeBetweenRequestsMs}
|
||||
suffix="ms"
|
||||
onChange={(minTimeBetweenRequestsMs) =>
|
||||
@@ -270,7 +272,7 @@ function RequestQueueCard({
|
||||
}
|
||||
/>
|
||||
<NumberField
|
||||
label="Concurrent requests"
|
||||
label={t("resilienceConcurrentRequests")}
|
||||
value={draft.concurrentRequests}
|
||||
min={1}
|
||||
onChange={(concurrentRequests) =>
|
||||
@@ -278,7 +280,7 @@ function RequestQueueCard({
|
||||
}
|
||||
/>
|
||||
<NumberField
|
||||
label="Maximum queue wait time"
|
||||
label={t("resilienceMaxQueueWaitTime")}
|
||||
value={draft.maxWaitMs}
|
||||
min={1}
|
||||
suffix="ms"
|
||||
@@ -288,31 +290,33 @@ function RequestQueueCard({
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">Auto-enable for API-key providers</div>
|
||||
<div className="text-xs text-text-muted">
|
||||
{t("resilienceAutoEnableApiKeyProviders")}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">
|
||||
{value.autoEnableApiKeyProviders ? "Enabled" : "Disabled"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">Requests per minute</div>
|
||||
<div className="text-xs text-text-muted">{t("resilienceRequestsPerMinute")}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">
|
||||
{value.requestsPerMinute}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">Minimum time between requests</div>
|
||||
<div className="text-xs text-text-muted">{t("resilienceMinTimeBetweenRequests")}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">
|
||||
{formatMs(value.minTimeBetweenRequestsMs)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">Concurrent requests</div>
|
||||
<div className="text-xs text-text-muted">{t("resilienceConcurrentRequests")}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">
|
||||
{value.concurrentRequests}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">Maximum queue wait time</div>
|
||||
<div className="text-xs text-text-muted">{t("resilienceMaxQueueWaitTime")}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">
|
||||
{formatMs(value.maxWaitMs)}
|
||||
</div>
|
||||
@@ -333,6 +337,7 @@ function ConnectionCooldownCard({
|
||||
onSave: (next: ResilienceResponse["connectionCooldown"]) => Promise<void>;
|
||||
saving: boolean;
|
||||
}) {
|
||||
const t = useTranslations("settings");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
|
||||
@@ -347,7 +352,7 @@ function ConnectionCooldownCard({
|
||||
{editing ? (
|
||||
<>
|
||||
<NumberField
|
||||
label="Base cooldown"
|
||||
label={t("resilienceBaseCooldown")}
|
||||
value={current.baseCooldownMs}
|
||||
min={0}
|
||||
suffix="ms"
|
||||
@@ -356,7 +361,7 @@ function ConnectionCooldownCard({
|
||||
}
|
||||
/>
|
||||
<BooleanField
|
||||
label="Use upstream retry hints"
|
||||
label={t("resilienceUseUpstreamRetryHints")}
|
||||
description="Use upstream retry-after/reset values when available."
|
||||
checked={current.useUpstreamRetryHints}
|
||||
onChange={(useUpstreamRetryHints) =>
|
||||
@@ -368,7 +373,9 @@ function ConnectionCooldownCard({
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="text-text-muted">Use upstream 429 hints for breaker cooldown</span>
|
||||
<span className="text-text-muted">
|
||||
{t("resilienceUseUpstream429HintsForBreaker")}
|
||||
</span>
|
||||
<select
|
||||
className="rounded border border-border-default bg-surface-1 px-2 py-1 text-sm font-mono"
|
||||
value={
|
||||
@@ -396,9 +403,9 @@ function ConnectionCooldownCard({
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="default">Default (per provider)</option>
|
||||
<option value="on">Always on</option>
|
||||
<option value="off">Always off</option>
|
||||
<option value="default">{t("resilienceDefaultPerProvider")}</option>
|
||||
<option value="on">{t("resilienceAlwaysOn")}</option>
|
||||
<option value="off">{t("resilienceAlwaysOff")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<p className="text-xs text-text-muted">
|
||||
@@ -409,7 +416,7 @@ function ConnectionCooldownCard({
|
||||
</p>
|
||||
</div>
|
||||
<NumberField
|
||||
label="Max backoff steps"
|
||||
label={t("resilienceMaxBackoffSteps")}
|
||||
value={current.maxBackoffSteps}
|
||||
min={0}
|
||||
onChange={(maxBackoffSteps) =>
|
||||
@@ -420,27 +427,27 @@ function ConnectionCooldownCard({
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-text-muted">Base cooldown</span>
|
||||
<span className="text-text-muted">{t("resilienceBaseCooldownLabel")}</span>
|
||||
<span className="font-mono text-text-main">{formatMs(current.baseCooldownMs)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-text-muted">Use upstream retry hints</span>
|
||||
<span className="text-text-muted">{t("resilienceUseUpstreamRetryHintsLabel")}</span>
|
||||
<span className="font-mono text-text-main">
|
||||
{current.useUpstreamRetryHints ? "Yes" : "No"}
|
||||
{current.useUpstreamRetryHints ? t("resilienceYes") : t("resilienceNo")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-text-muted">Use upstream 429 hints (breaker)</span>
|
||||
<span className="text-text-muted">{t("resilienceUseUpstream429BreakerLabel")}</span>
|
||||
<span className="font-mono text-text-main">
|
||||
{current.useUpstream429BreakerHints === true
|
||||
? "Yes"
|
||||
? t("resilienceYes")
|
||||
: current.useUpstream429BreakerHints === false
|
||||
? "No"
|
||||
: "Default"}
|
||||
? t("resilienceNo")
|
||||
: t("resilienceDefault")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-text-muted">Max backoff steps</span>
|
||||
<span className="text-text-muted">{t("resilienceMaxBackoffStepsLabel")}</span>
|
||||
<span className="font-mono text-text-main">{current.maxBackoffSteps}</span>
|
||||
</div>
|
||||
</>
|
||||
@@ -455,7 +462,7 @@ function ConnectionCooldownCard({
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-xl text-primary">timer_off</span>
|
||||
<h2 className="text-lg font-bold">Connection Cooldown</h2>
|
||||
<h2 className="text-lg font-bold">{t("resilienceConnectionCooldownTitle")}</h2>
|
||||
</div>
|
||||
<SectionDescription
|
||||
scope="Individual connection"
|
||||
@@ -519,6 +526,7 @@ function ProviderBreakerCard({
|
||||
onSave: (next: ResilienceResponse["providerBreaker"]) => Promise<void>;
|
||||
saving: boolean;
|
||||
}) {
|
||||
const t = useTranslations("settings");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
|
||||
@@ -533,7 +541,7 @@ function ProviderBreakerCard({
|
||||
{editing ? (
|
||||
<>
|
||||
<NumberField
|
||||
label="Failure threshold"
|
||||
label={t("resilienceFailureThreshold")}
|
||||
value={current.failureThreshold}
|
||||
min={1}
|
||||
onChange={(failureThreshold) =>
|
||||
@@ -541,7 +549,7 @@ function ProviderBreakerCard({
|
||||
}
|
||||
/>
|
||||
<NumberField
|
||||
label="Reset timeout"
|
||||
label={t("resilienceResetTimeout")}
|
||||
value={current.resetTimeoutMs}
|
||||
min={1000}
|
||||
suffix="ms"
|
||||
@@ -553,11 +561,11 @@ function ProviderBreakerCard({
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-text-muted">Failure threshold</span>
|
||||
<span className="text-text-muted">{t("resilienceFailureThresholdLabel")}</span>
|
||||
<span className="font-mono text-text-main">{current.failureThreshold}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-text-muted">Reset timeout</span>
|
||||
<span className="text-text-muted">{t("resilienceResetTimeoutLabel")}</span>
|
||||
<span className="font-mono text-text-main">{formatMs(current.resetTimeoutMs)}</span>
|
||||
</div>
|
||||
</>
|
||||
@@ -574,7 +582,7 @@ function ProviderBreakerCard({
|
||||
<span className="material-symbols-outlined text-xl text-primary">
|
||||
electrical_services
|
||||
</span>
|
||||
<h2 className="text-lg font-bold">Circuit Breaker per Provider</h2>
|
||||
<h2 className="text-lg font-bold">{t("resilienceProviderBreakerTitle")}</h2>
|
||||
</div>
|
||||
<SectionDescription
|
||||
scope="Entire provider"
|
||||
@@ -619,6 +627,7 @@ function WaitForCooldownCard({
|
||||
onSave: (next: WaitForCooldownSettings) => Promise<void>;
|
||||
saving: boolean;
|
||||
}) {
|
||||
const t = useTranslations("settings");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
|
||||
@@ -632,7 +641,7 @@ function WaitForCooldownCard({
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-xl text-primary">hourglass_top</span>
|
||||
<h2 className="text-lg font-bold">Wait for Cooldown</h2>
|
||||
<h2 className="text-lg font-bold">{t("resilienceWaitForCooldown")}</h2>
|
||||
</div>
|
||||
<SectionDescription
|
||||
scope="Current client request"
|
||||
@@ -663,19 +672,19 @@ function WaitForCooldownCard({
|
||||
{editing ? (
|
||||
<>
|
||||
<BooleanField
|
||||
label="Enable server-side wait"
|
||||
label={t("resilienceEnableServerSideWait")}
|
||||
description="When enabled, OmniRoute waits for the first cooldown to expire and retries automatically."
|
||||
checked={draft.enabled}
|
||||
onChange={(enabled) => setDraft((prev) => ({ ...prev, enabled }))}
|
||||
/>
|
||||
<NumberField
|
||||
label="Maximum retries"
|
||||
label={t("resilienceMaximumRetries")}
|
||||
value={draft.maxRetries}
|
||||
min={0}
|
||||
onChange={(maxRetries) => setDraft((prev) => ({ ...prev, maxRetries }))}
|
||||
/>
|
||||
<NumberField
|
||||
label="Maximum wait per retry"
|
||||
label={t("resilienceMaximumWaitPerRetry")}
|
||||
value={draft.maxRetryWaitSec}
|
||||
min={0}
|
||||
suffix="sec"
|
||||
@@ -685,17 +694,17 @@ function WaitForCooldownCard({
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">Enable server-side wait</div>
|
||||
<div className="text-xs text-text-muted">{t("resilienceEnableServerSideWait")}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">
|
||||
{value.enabled ? "Enabled" : "Disabled"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">Maximum retries</div>
|
||||
<div className="text-xs text-text-muted">{t("resilienceMaximumRetries")}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">{value.maxRetries}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-bg-subtle p-4">
|
||||
<div className="text-xs text-text-muted">Maximum wait per retry</div>
|
||||
<div className="text-xs text-text-muted">{t("resilienceMaximumWaitPerRetry")}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-text-main">
|
||||
{value.maxRetryWaitSec}s
|
||||
</div>
|
||||
|
||||
@@ -303,6 +303,7 @@ function StringListEditor({
|
||||
onChange: (next: string[]) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const t = useTranslations("settings");
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium text-text-main">{label}</span>
|
||||
@@ -324,7 +325,7 @@ function StringListEditor({
|
||||
size="sm"
|
||||
icon="close"
|
||||
disabled={disabled}
|
||||
aria-label="Remove entry"
|
||||
aria-label={t("routingRemoveEntry")}
|
||||
onClick={() => {
|
||||
const next = [...items];
|
||||
next.splice(idx, 1);
|
||||
@@ -356,6 +357,7 @@ function OpEditor({
|
||||
onChange: (next: any) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const t = useTranslations("settings");
|
||||
const updateField = (field: string, value: any) => onChange({ ...op, [field]: value });
|
||||
const kind = op?.kind as TransformOpKind | undefined;
|
||||
const opDescription = kind ? OP_KIND_DESCRIPTIONS[kind] : null;
|
||||
@@ -376,14 +378,14 @@ function OpEditor({
|
||||
return wrap(
|
||||
<div className="flex flex-col gap-2">
|
||||
<StringListEditor
|
||||
label="Needles (substrings to match)"
|
||||
label={t("routingNeedlesSubstrings")}
|
||||
hint={FIELD_HINTS.needles}
|
||||
items={op.needles || []}
|
||||
onChange={(next) => updateField("needles", next)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Toggle
|
||||
label="Case sensitive"
|
||||
label={t("routingCaseSensitive")}
|
||||
description={FIELD_HINTS.caseSensitive}
|
||||
checked={op.caseSensitive !== false}
|
||||
onChange={(c) => updateField("caseSensitive", c)}
|
||||
@@ -396,14 +398,14 @@ function OpEditor({
|
||||
return wrap(
|
||||
<div className="flex flex-col gap-2">
|
||||
<StringListEditor
|
||||
label="Prefixes"
|
||||
label={t("routingPrefixes")}
|
||||
hint={FIELD_HINTS.prefixes}
|
||||
items={op.prefixes || []}
|
||||
onChange={(next) => updateField("prefixes", next)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Toggle
|
||||
label="Case sensitive"
|
||||
label={t("routingCaseSensitive")}
|
||||
description={FIELD_HINTS.caseSensitive}
|
||||
checked={op.caseSensitive !== false}
|
||||
onChange={(c) => updateField("caseSensitive", c)}
|
||||
@@ -416,21 +418,21 @@ function OpEditor({
|
||||
return wrap(
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
label="Match"
|
||||
label={t("routingMatch")}
|
||||
hint={FIELD_HINTS.matchLiteral}
|
||||
value={op.match || ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateField("match", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Replacement"
|
||||
label={t("routingReplacement")}
|
||||
hint={FIELD_HINTS.replacementText}
|
||||
value={op.replacement || ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateField("replacement", e.target.value)}
|
||||
/>
|
||||
<Toggle
|
||||
label="Replace all occurrences"
|
||||
label={t("routingReplaceAllOccurrences")}
|
||||
description={FIELD_HINTS.allOccurrences}
|
||||
checked={op.allOccurrences !== false}
|
||||
onChange={(c) => updateField("allOccurrences", c)}
|
||||
@@ -443,21 +445,21 @@ function OpEditor({
|
||||
return wrap(
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
label="Pattern (regex)"
|
||||
label={t("routingPatternRegex")}
|
||||
hint={FIELD_HINTS.pattern}
|
||||
value={op.pattern || ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateField("pattern", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Flags"
|
||||
label={t("routingFlags")}
|
||||
hint={FIELD_HINTS.regexFlags}
|
||||
value={op.flags || "g"}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateField("flags", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Replacement"
|
||||
label={t("routingReplacement")}
|
||||
hint={FIELD_HINTS.replacementText}
|
||||
value={op.replacement || ""}
|
||||
disabled={disabled}
|
||||
@@ -468,7 +470,7 @@ function OpEditor({
|
||||
case "drop_block_if_contains":
|
||||
return wrap(
|
||||
<StringListEditor
|
||||
label="Needles"
|
||||
label={t("routingNeedles")}
|
||||
hint={FIELD_HINTS.needles}
|
||||
items={op.needles || []}
|
||||
onChange={(next) => updateField("needles", next)}
|
||||
@@ -480,7 +482,7 @@ function OpEditor({
|
||||
return wrap(
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-text-main">Block text</label>
|
||||
<label className="text-sm font-medium text-text-main">{t("routingBlockText")}</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={op.text || ""}
|
||||
@@ -491,7 +493,7 @@ function OpEditor({
|
||||
<p className="text-xs text-text-muted">{FIELD_HINTS.blockText}</p>
|
||||
</div>
|
||||
<Input
|
||||
label="Idempotency key"
|
||||
label={t("routingIdempotencyKey")}
|
||||
hint={FIELD_HINTS.idempotencyKey}
|
||||
value={op.idempotencyKey || ""}
|
||||
disabled={disabled}
|
||||
@@ -503,14 +505,14 @@ function OpEditor({
|
||||
return wrap(
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
label="Entrypoint"
|
||||
label={t("routingEntrypoint")}
|
||||
hint={FIELD_HINTS.billingEntrypoint}
|
||||
value={op.entrypoint || "sdk-cli"}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateField("entrypoint", e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
label="Version format"
|
||||
label={t("routingVersionFormat")}
|
||||
hint={FIELD_HINTS.billingVersionFormat}
|
||||
value={op.versionFormat || "ex-machina"}
|
||||
disabled={disabled}
|
||||
@@ -521,7 +523,7 @@ function OpEditor({
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="CCH algorithm"
|
||||
label={t("routingCchAlgorithm")}
|
||||
hint={FIELD_HINTS.billingCchAlgo}
|
||||
value={op.cchAlgo || "sha256-first-user"}
|
||||
disabled={disabled}
|
||||
@@ -538,7 +540,7 @@ function OpEditor({
|
||||
return wrap(
|
||||
<div className="flex flex-col gap-2">
|
||||
<StringListEditor
|
||||
label="Words to obfuscate (ZWJ inserted after first char)"
|
||||
label={t("routingWordsToObfuscate")}
|
||||
hint={FIELD_HINTS.obfuscateWords}
|
||||
items={op.words || []}
|
||||
onChange={(next) => updateField("words", next)}
|
||||
@@ -994,7 +996,7 @@ export default function RoutingTab() {
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Antigravity Signature Cache Mode</h3>
|
||||
<h3 className="text-lg font-semibold">{t("routingAntigravitySignatureTitle")}</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
Control whether OmniRoute reuses only stored Gemini thought signatures or accepts
|
||||
validated client-provided signatures in Antigravity-compatible tool-call flows.
|
||||
@@ -1068,7 +1070,7 @@ export default function RoutingTab() {
|
||||
</div>
|
||||
|
||||
<div className="mb-5">
|
||||
<h4 className="text-sm font-semibold mb-2">Header fingerprint (per provider)</h4>
|
||||
<h4 className="text-sm font-semibold mb-2">{t("routingHeaderFingerprintTitle")}</h4>
|
||||
<p className="text-xs text-text-muted mb-2">
|
||||
{t("cliFingerprintEnabled", { count: cliCompatProviderSet.size })}
|
||||
</p>
|
||||
@@ -1228,7 +1230,7 @@ export default function RoutingTab() {
|
||||
role="alert"
|
||||
className="mb-3 rounded border border-red-500/40 bg-red-500/10 p-2 text-xs text-red-300"
|
||||
>
|
||||
<span className="font-medium">⚠ Server rejected save:</span>{" "}
|
||||
<span className="font-medium">{t("routingServerRejectedSave")}</span>{" "}
|
||||
<span className="break-words font-mono">{providerSaveErrors[providerId]}</span>
|
||||
<p className="mt-1 text-[11px] text-red-200/80">
|
||||
Your local edits are kept. Fix the field above and the next change will
|
||||
@@ -1299,7 +1301,7 @@ export default function RoutingTab() {
|
||||
{/* Add op row */}
|
||||
<div className="flex items-end gap-2 mb-3">
|
||||
<Select
|
||||
label="Add a transform op"
|
||||
label={t("routingAddTransformOp")}
|
||||
className="flex-1"
|
||||
value={selectedKind}
|
||||
onChange={(e) =>
|
||||
@@ -1398,7 +1400,7 @@ export default function RoutingTab() {
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Client Cache Control</h3>
|
||||
<h3 className="text-lg font-semibold">{t("routingClientCacheControlTitle")}</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
Configure whether OmniRoute preserves client-provided cache_control markers
|
||||
</p>
|
||||
@@ -1466,7 +1468,7 @@ export default function RoutingTab() {
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Zero-Config Auto-Routing</h3>
|
||||
<h3 className="text-lg font-semibold">{t("routingZeroConfigTitle")}</h3>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
Enable automatic provider selection using the auto/ prefix. When enabled, requests
|
||||
to auto, auto/coding, auto/fast, etc. will dynamically route across all connected
|
||||
@@ -1488,7 +1490,7 @@ export default function RoutingTab() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-border/30">
|
||||
<label className="block text-sm font-medium mb-2">Default Auto Variant</label>
|
||||
<label className="block text-sm font-medium mb-2">{t("routingDefaultAutoVariant")}</label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{[
|
||||
{ value: "lkgp", label: "LKGP", desc: "Last Known Good Provider" },
|
||||
|
||||
@@ -551,7 +551,7 @@ export default function SystemStorageTab() {
|
||||
<div className="p-3 rounded-lg bg-bg border border-border mb-4">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-main">Logs Settings</p>
|
||||
<p className="text-sm font-medium text-text-main">{t("logsSettingsTitle")}</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Configure detailed logging and call log pipeline settings
|
||||
</p>
|
||||
@@ -560,26 +560,26 @@ export default function SystemStorageTab() {
|
||||
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm">
|
||||
<span className="font-medium">Detailed Logs Enabled</span>
|
||||
<p className="text-xs text-text-muted">Enable detailed request/response logging</p>
|
||||
<span className="font-medium">{t("detailedLogsLabel")}</span>
|
||||
<p className="text-xs text-text-muted">{t("detailedLogsDesc")}</p>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm">
|
||||
<span className="font-medium">Call Log Pipeline</span>
|
||||
<p className="text-xs text-text-muted">Enable call log processing pipeline</p>
|
||||
<span className="font-medium">{t("callLogPipelineLabel")}</span>
|
||||
<p className="text-xs text-text-muted">{t("callLogPipelineDesc")}</p>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm">
|
||||
<span className="font-medium">Max Detail Size (KB)</span>
|
||||
<p className="text-xs text-text-muted">Maximum size for detailed log entries</p>
|
||||
<span className="font-medium">{t("maxDetailSizeLabel")}</span>
|
||||
<p className="text-xs text-text-muted">{t("maxDetailSizeDesc")}</p>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm">
|
||||
<span className="font-medium">Ring Buffer Size</span>
|
||||
<p className="text-xs text-text-muted">Size of the ring buffer for logs</p>
|
||||
<span className="font-medium">{t("ringBufferSizeLabel")}</span>
|
||||
<p className="text-xs text-text-muted">{t("ringBufferSizeDesc")}</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -589,7 +589,7 @@ export default function SystemStorageTab() {
|
||||
<div className="p-3 rounded-lg bg-bg border border-border mb-4">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-main">Cache Settings</p>
|
||||
<p className="text-sm font-medium text-text-main">{t("cacheSettings")}</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Configure semantic and prompt caching behavior
|
||||
</p>
|
||||
@@ -598,7 +598,7 @@ export default function SystemStorageTab() {
|
||||
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm">
|
||||
<span className="font-medium">Semantic Cache Enabled</span>
|
||||
<span className="font-medium">{t("semanticCacheEnabledLabel")}</span>
|
||||
<p className="text-xs text-text-muted">
|
||||
Enable semantic caching for similar requests
|
||||
</p>
|
||||
@@ -606,13 +606,13 @@ export default function SystemStorageTab() {
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm">
|
||||
<span className="font-medium">Semantic Cache Max Size</span>
|
||||
<p className="text-xs text-text-muted">Maximum number of semantic cache entries</p>
|
||||
<span className="font-medium">{t("semanticCacheMaxSizeLabel")}</span>
|
||||
<p className="text-xs text-text-muted">{t("semanticCacheMaxSizeDesc")}</p>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm">
|
||||
<span className="font-medium">Semantic Cache TTL</span>
|
||||
<span className="font-medium">{t("semanticCacheTTLLabel")}</span>
|
||||
<p className="text-xs text-text-muted">
|
||||
Time-to-live for semantic cache entries (ms)
|
||||
</p>
|
||||
@@ -620,20 +620,20 @@ export default function SystemStorageTab() {
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm">
|
||||
<span className="font-medium">Prompt Cache Enabled</span>
|
||||
<p className="text-xs text-text-muted">Enable prompt caching</p>
|
||||
<span className="font-medium">{t("promptCacheEnabledLabel")}</span>
|
||||
<p className="text-xs text-text-muted">{t("promptCacheEnabledDesc")}</p>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm">
|
||||
<span className="font-medium">Prompt Cache Strategy</span>
|
||||
<p className="text-xs text-text-muted">Strategy for prompt caching</p>
|
||||
<span className="font-medium">{t("promptCacheStrategyLabel")}</span>
|
||||
<p className="text-xs text-text-muted">{t("promptCacheStrategyDesc")}</p>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm">
|
||||
<span className="font-medium">Always Preserve Client Cache</span>
|
||||
<p className="text-xs text-text-muted">Client cache preservation policy</p>
|
||||
<span className="font-medium">{t("alwaysPreserveClientCacheLabel")}</span>
|
||||
<p className="text-xs text-text-muted">{t("alwaysPreserveClientCacheDesc")}</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -642,7 +642,7 @@ export default function SystemStorageTab() {
|
||||
<div className="p-3 rounded-lg bg-bg border border-border mb-4">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-main">Log retention policy</p>
|
||||
<p className="text-sm font-medium text-text-main">{t("logRetentionPolicyTitle")}</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Request logs retain up to <code>CALL_LOGS_TABLE_MAX_ROWS</code> rows (default:
|
||||
100,000). Proxy logs retain up to <code>PROXY_LOGS_TABLE_MAX_ROWS</code> rows. Older
|
||||
@@ -666,7 +666,9 @@ export default function SystemStorageTab() {
|
||||
<div className="p-3 rounded-lg bg-bg border border-border mb-4">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap mb-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-main">Database backup retention</p>
|
||||
<p className="text-sm font-medium text-text-main">
|
||||
{t("storageDatabaseBackupRetention")}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Automatic SQLite backups are stored in <code>db_backups</code>. Configure how many
|
||||
snapshots to keep and optionally delete backups older than N days.
|
||||
@@ -1050,7 +1052,7 @@ export default function SystemStorageTab() {
|
||||
>
|
||||
delete_forever
|
||||
</span>
|
||||
<p className="font-medium">Purge Data</p>
|
||||
<p className="font-medium">{t("storagePurgeData")}</p>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
Immediately delete all records (no retention check). Use with caution.
|
||||
@@ -1389,7 +1391,9 @@ export default function SystemStorageTab() {
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-text-muted mb-1">Quota Snapshots (days)</label>
|
||||
<label className="block text-xs text-text-muted mb-1">
|
||||
{t("retentionQuotaSnapshots")}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
@@ -1429,7 +1433,7 @@ export default function SystemStorageTab() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-text-muted mb-1">MCP Audit (days)</label>
|
||||
<label className="block text-xs text-text-muted mb-1">{t("retentionMcpAudit")}</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
@@ -1448,7 +1452,9 @@ export default function SystemStorageTab() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-text-muted mb-1">A2A Events (days)</label>
|
||||
<label className="block text-xs text-text-muted mb-1">
|
||||
{t("retentionA2aEvents")}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
@@ -1467,7 +1473,7 @@ export default function SystemStorageTab() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-text-muted mb-1">Call Logs (days)</label>
|
||||
<label className="block text-xs text-text-muted mb-1">{t("retentionCallLogs")}</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
@@ -1486,7 +1492,9 @@ export default function SystemStorageTab() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-text-muted mb-1">Usage History (days)</label>
|
||||
<label className="block text-xs text-text-muted mb-1">
|
||||
{t("retentionUsageHistory")}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
@@ -1505,7 +1513,9 @@ export default function SystemStorageTab() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-text-muted mb-1">Memory Entries (days)</label>
|
||||
<label className="block text-xs text-text-muted mb-1">
|
||||
{t("retentionMemoryEntries")}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
@@ -1633,7 +1643,9 @@ export default function SystemStorageTab() {
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-text-muted mb-1">Auto Vacuum Mode</label>
|
||||
<label className="block text-xs text-text-muted mb-1">
|
||||
{t("storageAutoVacuumMode")}
|
||||
</label>
|
||||
<select
|
||||
value={dbSettings.optimization.autoVacuumMode}
|
||||
onChange={(e) =>
|
||||
@@ -1653,7 +1665,9 @@ export default function SystemStorageTab() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-text-muted mb-1">Scheduled Vacuum</label>
|
||||
<label className="block text-xs text-text-muted mb-1">
|
||||
{t("storageScheduledVacuum")}
|
||||
</label>
|
||||
<select
|
||||
value={dbSettings.optimization.scheduledVacuum}
|
||||
onChange={(e) =>
|
||||
@@ -1674,7 +1688,9 @@ export default function SystemStorageTab() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-text-muted mb-1">Vacuum Hour (0-23)</label>
|
||||
<label className="block text-xs text-text-muted mb-1">
|
||||
{t("storageVacuumHour")}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -1693,7 +1709,7 @@ export default function SystemStorageTab() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-text-muted mb-1">Page Size (bytes)</label>
|
||||
<label className="block text-xs text-text-muted mb-1">{t("storagePageSize")}</label>
|
||||
<input
|
||||
type="number"
|
||||
min="512"
|
||||
@@ -1790,23 +1806,23 @@ export default function SystemStorageTab() {
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="p-3 rounded-lg bg-black/[0.02] dark:bg-white/[0.02]">
|
||||
<p className="text-xs text-text-muted mb-1">Database Size</p>
|
||||
<p className="text-xs text-text-muted mb-1">{t("storageDatabaseSize")}</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{formatBytes(dbSettings.stats.databaseSizeBytes)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-black/[0.02] dark:bg-white/[0.02]">
|
||||
<p className="text-xs text-text-muted mb-1">Page Count</p>
|
||||
<p className="text-xs text-text-muted mb-1">{t("storagePageCount")}</p>
|
||||
<p className="text-sm font-semibold">{dbSettings.stats.pageCount.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-black/[0.02] dark:bg-white/[0.02]">
|
||||
<p className="text-xs text-text-muted mb-1">Freelist Count</p>
|
||||
<p className="text-xs text-text-muted mb-1">{t("storageFreelistCount")}</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{dbSettings.stats.freelistCount.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-black/[0.02] dark:bg-white/[0.02]">
|
||||
<p className="text-xs text-text-muted mb-1">Last Vacuum</p>
|
||||
<p className="text-xs text-text-muted mb-1">{t("storageLastVacuum")}</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{dbSettings.stats.lastVacuumAt
|
||||
? new Date(dbSettings.stats.lastVacuumAt).toLocaleString(locale)
|
||||
@@ -1814,7 +1830,7 @@ export default function SystemStorageTab() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-black/[0.02] dark:bg-white/[0.02]">
|
||||
<p className="text-xs text-text-muted mb-1">Last Optimization</p>
|
||||
<p className="text-xs text-text-muted mb-1">{t("storageLastOptimization")}</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{dbSettings.stats.lastOptimizationAt
|
||||
? new Date(dbSettings.stats.lastOptimizationAt).toLocaleString(locale)
|
||||
@@ -1822,12 +1838,12 @@ export default function SystemStorageTab() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-black/[0.02] dark:bg-white/[0.02]">
|
||||
<p className="text-xs text-text-muted mb-1">Integrity Check</p>
|
||||
<p className="text-xs text-text-muted mb-1">{t("storageIntegrityCheck")}</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{dbSettings.stats.integrityCheck === "ok" ? (
|
||||
<span className="text-green-500">✓ OK</span>
|
||||
<span className="text-green-500">{t("storageIntegrityOk")}</span>
|
||||
) : dbSettings.stats.integrityCheck === "error" ? (
|
||||
<span className="text-red-500">✗ Error</span>
|
||||
<span className="text-red-500">{t("storageIntegrityError")}</span>
|
||||
) : (
|
||||
"Not checked"
|
||||
)}
|
||||
@@ -1866,7 +1882,7 @@ export default function SystemStorageTab() {
|
||||
pin
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium">Usage Token Buffer</p>
|
||||
<p className="font-medium">{t("storageUsageTokenBuffer")}</p>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
Extra tokens added to reported usage to account for system prompt overhead. Set to 0
|
||||
to report raw provider token counts. Default: 2000.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, Toggle } from "@/shared/components";
|
||||
import { VISION_BRIDGE_DEFAULTS } from "@/shared/constants/visionBridgeDefaults";
|
||||
|
||||
@@ -13,6 +14,7 @@ type SettingsState = {
|
||||
};
|
||||
|
||||
export default function VisionBridgeSettingsTab() {
|
||||
const t = useTranslations("settings");
|
||||
const [settings, setSettings] = useState<SettingsState>({
|
||||
visionBridgeEnabled: VISION_BRIDGE_DEFAULTS.enabled,
|
||||
visionBridgeModel: VISION_BRIDGE_DEFAULTS.model,
|
||||
@@ -63,7 +65,7 @@ export default function VisionBridgeSettingsTab() {
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Vision Bridge</h3>
|
||||
<h3 className="text-lg font-semibold">{t("visionBridge")}</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
Run an automatic vision-to-text fallback before routing image requests to text-only
|
||||
models.
|
||||
@@ -88,7 +90,7 @@ export default function VisionBridgeSettingsTab() {
|
||||
|
||||
<div className="pt-4 border-t border-border space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Bridge Model</label>
|
||||
<label className="block text-sm font-medium mb-1">{t("visionBridgeModel")}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.visionBridgeModel}
|
||||
@@ -97,7 +99,7 @@ export default function VisionBridgeSettingsTab() {
|
||||
}
|
||||
onBlur={() => updateSetting({ visionBridgeModel: settings.visionBridgeModel.trim() })}
|
||||
className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"
|
||||
placeholder="openai/gpt-4o-mini"
|
||||
placeholder={t("visionBridgeModelPlaceholder")}
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Any OmniRoute model ID that supports vision can be used here.
|
||||
@@ -105,7 +107,7 @@ export default function VisionBridgeSettingsTab() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Bridge Prompt</label>
|
||||
<label className="block text-sm font-medium mb-1">{t("visionBridgePrompt")}</label>
|
||||
<textarea
|
||||
value={settings.visionBridgePrompt}
|
||||
onChange={(e) =>
|
||||
@@ -115,7 +117,7 @@ export default function VisionBridgeSettingsTab() {
|
||||
updateSetting({ visionBridgePrompt: settings.visionBridgePrompt.trim() })
|
||||
}
|
||||
className="min-h-[100px] w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"
|
||||
placeholder="Describe this image concisely."
|
||||
placeholder={t("visionBridgePromptPlaceholder")}
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Sent to the vision model before the extracted description is injected back into the
|
||||
@@ -125,7 +127,7 @@ export default function VisionBridgeSettingsTab() {
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Timeout (ms)</label>
|
||||
<label className="block text-sm font-medium mb-1">{t("visionBridgeTimeoutMs")}</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1000}
|
||||
@@ -152,7 +154,9 @@ export default function VisionBridgeSettingsTab() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Max Images Per Request</label>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{t("visionBridgeMaxImagesPerRequest")}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
|
||||
@@ -372,7 +372,7 @@ export default function SkillsPage() {
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Filter skills by name, description, or tag"
|
||||
placeholder={t("filterSkillsPlaceholder")}
|
||||
className="px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
/>
|
||||
<select
|
||||
@@ -380,7 +380,7 @@ export default function SkillsPage() {
|
||||
onChange={(e) => setModeFilter(e.target.value as "all" | "on" | "off" | "auto")}
|
||||
className="px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
>
|
||||
<option value="all">All modes</option>
|
||||
<option value="all">{t("allModes")}</option>
|
||||
<option value="on">On</option>
|
||||
<option value="auto">Auto</option>
|
||||
<option value="off">Off</option>
|
||||
@@ -659,7 +659,7 @@ export default function SkillsPage() {
|
||||
{activeTab === "marketplace" && (
|
||||
<div className="grid gap-4">
|
||||
<Card>
|
||||
<h3 className="font-semibold mb-2">Skills Marketplace</h3>
|
||||
<h3 className="font-semibold mb-2">{t("skillsMarketplace")}</h3>
|
||||
<p className="text-sm text-text-muted mb-4">
|
||||
Active provider:{" "}
|
||||
<span className="font-medium">
|
||||
@@ -775,7 +775,7 @@ export default function SkillsPage() {
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-surface border border-border rounded-xl p-6 w-full max-w-lg mx-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Install Skill</h2>
|
||||
<h2 className="text-lg font-semibold">{t("installSkill")}</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowInstallModal(false);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/shared/components";
|
||||
|
||||
interface TokenLedgerEntry {
|
||||
@@ -34,6 +35,7 @@ interface ServerConnection {
|
||||
}
|
||||
|
||||
export default function TokensPage() {
|
||||
const t = useTranslations("common");
|
||||
// Balance & History
|
||||
const [balance, setBalance] = useState(0);
|
||||
const [history, setHistory] = useState<TokenLedgerEntry[]>([]);
|
||||
@@ -239,7 +241,7 @@ export default function TokensPage() {
|
||||
<Card>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-text-muted">Token Balance</p>
|
||||
<p className="text-sm text-text-muted">{t("tokensTokenBalance")}</p>
|
||||
<p className="text-4xl font-bold mt-1">{balance.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="text-6xl opacity-20">🪙</div>
|
||||
@@ -247,15 +249,17 @@ export default function TokensPage() {
|
||||
</Card>
|
||||
|
||||
{/* Transfer Form */}
|
||||
<Card title="Send Tokens" icon="send">
|
||||
<Card title={t("tokensSendTokens")} icon="send">
|
||||
<form onSubmit={handleTransfer} className="grid gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">Recipient API Key ID</label>
|
||||
<label className="block text-sm text-text-muted mb-1">
|
||||
{t("tokensRecipientApiKeyId")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={toApiKeyId}
|
||||
onChange={(e) => setToApiKeyId(e.target.value)}
|
||||
placeholder="Enter recipient API key ID"
|
||||
placeholder={t("tokensRecipientApiKeyIdPlaceholder")}
|
||||
required
|
||||
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
/>
|
||||
@@ -274,12 +278,14 @@ export default function TokensPage() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">Reason (optional)</label>
|
||||
<label className="block text-sm text-text-muted mb-1">
|
||||
{t("tokensReasonOptional")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="e.g. bonus, reward"
|
||||
placeholder={t("tokensReasonPlaceholder")}
|
||||
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
/>
|
||||
</div>
|
||||
@@ -306,11 +312,11 @@ export default function TokensPage() {
|
||||
</Card>
|
||||
|
||||
{/* Transaction History */}
|
||||
<Card title="Transaction History" icon="history">
|
||||
<Card title={t("tokensTransactionHistory")} icon="history">
|
||||
{historyLoading ? (
|
||||
<div className="text-center py-8 text-text-muted">Loading...</div>
|
||||
) : history.length === 0 ? (
|
||||
<div className="text-center py-8 text-text-muted">No transactions yet</div>
|
||||
<div className="text-center py-8 text-text-muted">{t("tokensNoTransactionsYet")}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
@@ -364,11 +370,11 @@ export default function TokensPage() {
|
||||
</Card>
|
||||
|
||||
{/* Invite Panel */}
|
||||
<Card title="Invite Codes" icon="mail">
|
||||
<Card title={t("tokensInviteCodes")} icon="mail">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm text-text-muted mb-1">Max Uses</label>
|
||||
<label className="block text-sm text-text-muted mb-1">{t("tokensMaxUses")}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={inviteMaxUses}
|
||||
@@ -398,12 +404,12 @@ export default function TokensPage() {
|
||||
className="flex items-end gap-3 border-t border-border pt-4"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm text-text-muted mb-1">Redeem Code</label>
|
||||
<label className="block text-sm text-text-muted mb-1">{t("tokensRedeemCode")}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={redeemCode}
|
||||
onChange={(e) => setRedeemCode(e.target.value)}
|
||||
placeholder="Enter invite code"
|
||||
placeholder={t("tokensRedeemCodePlaceholder")}
|
||||
required
|
||||
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
/>
|
||||
@@ -432,7 +438,7 @@ export default function TokensPage() {
|
||||
{/* Active Invites */}
|
||||
{invites.length > 0 && (
|
||||
<div className="border-t border-border pt-4">
|
||||
<p className="text-sm text-text-muted mb-3">Your Active Invites</p>
|
||||
<p className="text-sm text-text-muted mb-3">{t("tokensYourActiveInvites")}</p>
|
||||
<div className="space-y-2">
|
||||
{invites.map((inv) => (
|
||||
<div
|
||||
@@ -463,7 +469,7 @@ export default function TokensPage() {
|
||||
</Card>
|
||||
|
||||
{/* Server Panel */}
|
||||
<Card title="Community Servers" icon="dns">
|
||||
<Card title={t("tokensCommunityServers")} icon="dns">
|
||||
<div className="flex flex-col gap-4">
|
||||
<form onSubmit={handleConnectServer} className="grid gap-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
@@ -471,7 +477,7 @@ export default function TokensPage() {
|
||||
type="text"
|
||||
value={serverName}
|
||||
onChange={(e) => setServerName(e.target.value)}
|
||||
placeholder="Server name"
|
||||
placeholder={t("tokensServerNamePlaceholder")}
|
||||
required
|
||||
className="px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
/>
|
||||
@@ -487,7 +493,7 @@ export default function TokensPage() {
|
||||
type="password"
|
||||
value={serverApiKey}
|
||||
onChange={(e) => setServerApiKey(e.target.value)}
|
||||
placeholder="API key"
|
||||
placeholder={t("tokensApiKeyPlaceholder")}
|
||||
required
|
||||
className="px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
/>
|
||||
|
||||
@@ -573,7 +573,7 @@ export default function PlaygroundMode() {
|
||||
</div>
|
||||
{compressionResult.techniquesUsed.length > 0 && (
|
||||
<div className="text-xs text-text-muted">
|
||||
<span className="font-semibold">Techniques:</span>{" "}
|
||||
<span className="font-semibold">{t("techniques")}</span>{" "}
|
||||
{compressionResult.techniquesUsed.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -461,26 +461,29 @@ export default function BudgetTab() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-6 gap-2">
|
||||
<KpiBlock label="Today" value={formatCurrency(stats.today)} />
|
||||
<KpiBlock label="This month" value={formatCurrency(stats.month)} />
|
||||
<KpiBlock label={t("budgetKpiToday")} value={formatCurrency(stats.today)} />
|
||||
<KpiBlock label={t("budgetKpiThisMonth")} value={formatCurrency(stats.month)} />
|
||||
<KpiBlock
|
||||
label="Proj EOM"
|
||||
label={t("budgetKpiProjEom")}
|
||||
value={formatCurrency(stats.projectionEom)}
|
||||
tone={projectionOverBudget ? "amber" : undefined}
|
||||
hint={projectionOverBudget ? "above limit ⚠" : "on track"}
|
||||
/>
|
||||
<KpiBlock
|
||||
label="Blocked"
|
||||
label={t("budgetKpiBlocked")}
|
||||
value={String(stats.counts.blocked)}
|
||||
tone={stats.counts.blocked > 0 ? "red" : undefined}
|
||||
/>
|
||||
<KpiBlock
|
||||
label="At risk"
|
||||
label={t("budgetKpiAtRisk")}
|
||||
value={String(stats.counts.alerting)}
|
||||
tone={stats.counts.alerting > 0 ? "amber" : undefined}
|
||||
hint="≥ warning"
|
||||
/>
|
||||
<KpiBlock label="Active keys" value={`${stats.counts.active} / ${rows.length}`} />
|
||||
<KpiBlock
|
||||
label={t("budgetKpiActiveKeys")}
|
||||
value={`${stats.counts.active} / ${rows.length}`}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -493,7 +496,7 @@ export default function BudgetTab() {
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search keys..."
|
||||
placeholder={t("budgetSearchKeysPlaceholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-3 py-2 bg-bg-base border border-border rounded-lg focus:outline-none focus:border-primary text-sm"
|
||||
@@ -504,10 +507,10 @@ export default function BudgetTab() {
|
||||
onChange={(e) => setSortKey(e.target.value as typeof sortKey)}
|
||||
className="bg-bg-base border border-border rounded-md px-2 py-1.5 text-xs text-text-main cursor-pointer"
|
||||
>
|
||||
<option value="usedDesc">Sort: % Used ↓</option>
|
||||
<option value="todayDesc">Sort: Today $ ↓</option>
|
||||
<option value="monthDesc">Sort: Month $ ↓</option>
|
||||
<option value="name">Sort: Name (A–Z)</option>
|
||||
<option value="usedDesc">{t("budgetSortPctUsed")}</option>
|
||||
<option value="todayDesc">{t("budgetSortTodayDollar")}</option>
|
||||
<option value="monthDesc">{t("budgetSortMonthDollar")}</option>
|
||||
<option value="name">{t("budgetSortNameAZ")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -601,14 +604,14 @@ export default function BudgetTab() {
|
||||
<div>Key</div>
|
||||
<div className="text-right">Today</div>
|
||||
<div className="text-right">Month</div>
|
||||
<div className="text-right">Daily lim</div>
|
||||
<div className="text-right">Monthly lim</div>
|
||||
<div className="text-right">Used %</div>
|
||||
<div className="text-right">{t("budgetColDailyLim")}</div>
|
||||
<div className="text-right">{t("budgetColMonthlyLim")}</div>
|
||||
<div className="text-right">{t("budgetColUsedPct")}</div>
|
||||
<div className="text-center">Status</div>
|
||||
</div>
|
||||
|
||||
{visibleRows.length === 0 ? (
|
||||
<div className="py-10 text-center text-text-muted text-sm">No keys match filters</div>
|
||||
<div className="py-10 text-center text-text-muted text-sm">{t("budgetNoKeysMatch")}</div>
|
||||
) : (
|
||||
visibleRows.map((row, idx) => (
|
||||
<BudgetRow
|
||||
@@ -853,16 +856,16 @@ function BudgetRowExpanded({
|
||||
<h4 className="text-[11px] uppercase tracking-wide font-bold text-text-muted">
|
||||
Projection
|
||||
</h4>
|
||||
<span className="text-[10px] text-text-muted">linear extrapolation</span>
|
||||
<span className="text-[10px] text-text-muted">{t("budgetLinearExtrapolation")}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-3">
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted">This month so far</div>
|
||||
<div className="text-[10px] text-text-muted">{t("budgetThisMonthSoFar")}</div>
|
||||
<div className="text-lg font-bold tabular-nums">{formatCurrency(month)}</div>
|
||||
</div>
|
||||
<span className="text-text-muted">→</span>
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted">Projected end of month</div>
|
||||
<div className="text-[10px] text-text-muted">{t("budgetProjectedEndOfMonth")}</div>
|
||||
<div
|
||||
className={`text-lg font-bold tabular-nums ${
|
||||
projectionOver ? "text-amber-400" : "text-emerald-400"
|
||||
@@ -882,12 +885,14 @@ function BudgetRowExpanded({
|
||||
<h4 className="text-[11px] uppercase tracking-wide font-bold text-text-muted">
|
||||
Cost breakdown (30d)
|
||||
</h4>
|
||||
<span className="text-[10px] text-text-muted">by provider</span>
|
||||
<span className="text-[10px] text-text-muted">{t("budgetByProvider")}</span>
|
||||
</div>
|
||||
{breakdown === undefined ? (
|
||||
<div className="text-[11px] text-text-muted py-2 animate-pulse">Loading…</div>
|
||||
<div className="text-[11px] text-text-muted py-2 animate-pulse">
|
||||
{t("budgetLoading")}
|
||||
</div>
|
||||
) : breakdown.length === 0 ? (
|
||||
<div className="text-[11px] text-text-muted py-2">No spend in last 30 days</div>
|
||||
<div className="text-[11px] text-text-muted py-2">{t("noSpendLast30Days")}</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{breakdown.slice(0, 5).map((b) => (
|
||||
@@ -918,7 +923,7 @@ function BudgetRowExpanded({
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3 mb-3">
|
||||
<Input
|
||||
label="Daily $"
|
||||
label={t("budgetDailyDollar")}
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
@@ -927,7 +932,7 @@ function BudgetRowExpanded({
|
||||
onChange={(e) => setForm({ ...form, dailyLimitUsd: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Weekly $"
|
||||
label={t("budgetWeeklyDollar")}
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
@@ -936,7 +941,7 @@ function BudgetRowExpanded({
|
||||
onChange={(e) => setForm({ ...form, weeklyLimitUsd: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Monthly $"
|
||||
label={t("budgetMonthlyDollar")}
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
@@ -945,7 +950,7 @@ function BudgetRowExpanded({
|
||||
onChange={(e) => setForm({ ...form, monthlyLimitUsd: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
label="Warn at %"
|
||||
label={t("budgetWarnAtPct")}
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
@@ -955,7 +960,7 @@ function BudgetRowExpanded({
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 items-end">
|
||||
<div>
|
||||
<label className="text-[11px] text-text-muted block mb-1">Reset interval</label>
|
||||
<label className="text-[11px] text-text-muted block mb-1">{t("resetInterval")}</label>
|
||||
<select
|
||||
value={form.resetInterval}
|
||||
onChange={(e) =>
|
||||
@@ -969,7 +974,7 @@ function BudgetRowExpanded({
|
||||
</select>
|
||||
</div>
|
||||
<Input
|
||||
label="Reset time (UTC)"
|
||||
label={t("resetTimeUtc")}
|
||||
type="time"
|
||||
value={form.resetTime}
|
||||
onChange={(e) => setForm({ ...form, resetTime: e.target.value })}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user