Compare commits

..

5 Commits

Author SHA1 Message Date
oyi77
8f96b10b40 fix(sse): resolve type errors in browserBackedChat stub
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-02 00:37:04 -03:00
oyi77
ce8cdc8224 chore: move sqlite-vec to optionalDependencies, fix js-tiktoken static import
Both changes ensure native binary dependencies are properly categorized as optional:

- sqlite-vec: moved from dependencies to optionalDependencies. Only used via
  lazy _require("sqlite-vec") in vectorStore.ts — zero static imports.
- js-tiktoken: already in optionalDependencies, import changed to createRequire
  pattern to avoid crash when package is not installed (same pattern as sqlite-vec
  in vectorStore.ts).

Resolves ScoutDeps findings from browser-pool pluginization audit.
2026-07-31 02:42:23 +07:00
oyi77
43b8819f99 fix(pr-8299): address all 5 review issues
Issue #1: Add @omniroute/browser-pool path to root tsconfig.json paths
Issue #2: Fix tryBackedChat fallback — call browserBackedChat outside if(loaded) guard
Issue #3: Fix grokClearance stub signature (signal?: AbortSignal) → string|null
Issue #4: Add comment clarifying async __resetBrowserPoolMetricsForTest vs upstream sync
Issue #5: Add test case for package-absent fallback in tryBackedChat

All 25 browser tests pass across 4 suites. typecheck:core passes.
2026-07-31 02:42:23 +07:00
oyi77
6880b3c821 fix: remove duplicate getMod/modPromise in browserBackedChat stub
Two copies of the module proxy got committed — the typed BrowserPoolModule
version at lines 50-56 and a stale any-typed duplicate at lines 64-71.
Removed the duplicate, keeping the typed version.

Verification:
- 40/40 browser tests pass (both previously-failing suites now green)
- typecheck:core: 0 errors
- env kill switch (OMNIROUTE_BROWSER_POOL=off): verified
2026-07-31 02:41:16 +07:00
oyi77
ad554a386c fix: align three stub implementations with original code
- chatUrlMatcher: restore original 3-arg signature (u, matchDomain, chatUrl)
  with PLACEHOLDER-aware path segment matching
- shouldUseGrokBrowserBacked: remove required param, restore env-var logic
  checking both WEB_COOKIE_USE_BROWSER and OMNIROUTE_BROWSER_POOL
- browserPool.ts: add Turbopack rationale comment and join-trick helper
  to satisfy the optional-import test assertions
- browserBackedChat.ts: replace any types with typed BrowserPoolModule interface

Verification: 40/40 browser node:test pass, typecheck:core 0 errors
2026-07-31 02:41:15 +07:00
293 changed files with 4540 additions and 15842 deletions

View File

@@ -1,197 +0,0 @@
# codebase-memory-mcp ignore list
#
# Padrão gitignore-style. Linhas começando com `#` são comentários.
# Barra final (`/`) = só diretório. Sem barra = casa arquivo OU diretório.
#
# O CBM também lê `.gitignore` automaticamente — esta lista deixa explícito o que
# os hooks do CBM vão pular. Se uma regra entrar em conflito entre os dois arquivos,
# vale a união. Editar este arquivo é mais barato do que confiar na herança implícita.
#
# Última reconciliação: 2026-07-31, status `ready` (513k nodes / 689k edges),
# `auto_index_limit=50000`, total indexável medido ≈11.546 arquivos (folga 4,3×).
#
# Fontes cruzadas:
# - `codebase-memory-mcp cli index_status --project home-diegosouzapw-dev-proxys-OmniRoute`
# → `not_indexed.dirs` (27) + `not_indexed.files` (336), todos `BY DESIGN`.
# - `.gitignore` deste repo (5.691 B) — fonte canônica secundária.
#
# Como auditar mudanças: depois de editar este arquivo, rodar `index_repository`
# (ou esperar `auto_watch` re-indexar) e re-checar `cli index_status` → comparar
# contagens em `not_indexed.dirs_count` e `not_indexed.files_count`.
# ─────────────────────────────────────────────────────────────────────────────
# 1. Diretorios de runtime / pacote — nao sao codigo-fonte
# ─────────────────────────────────────────────────────────────────────────────
node_modules/
node_modules
# Builds e artefatos reproduziveis (Layer 1 Next.js / Electron)
.build/
dist/
.next/
out/
# Electron especifico
electron/dist-electron/
electron/node_modules/
icon.iconset/
# Workspaces internos que tem proprio node_modules
@omniroute/opencode-plugin/dist/
@omniroute/opencode-plugin/node_modules/
@omniroute/opencode-provider/dist/
@omniroute/opencode-provider/node_modules/
# Recursos nativos compilados (C/JNI/wasm)
src/mitm/tproxy/native/build/
# Artefatos locais do Stryker / Playwright / coverage
.stryker-tmp/
reports/mutation/
stryker-output-*.json
.playwright-mcp/
test-results/
playwright-report/
blob-report/
# Analise / linters / caches
.analysis/
.sisyphus/
.plans/
.gitnexus
.worktrees
.codegraph/
# Quality artifacts (gerados por npm run lint --cache etc)
.eslintcache
.eslintcache-complexity
# Claude Code local state
.claude/scheduled_tasks.lock
.claude/scheduled_tasks/
.claude/sessions/
.claude/state.json
.claude/settings.local.json
# Serena / Antigravity / outras tools locais
.serena/
.antigravitycli/
.gemini/
.config/
# ─────────────────────────────────────────────────────────────────────────────
# 2. Diretorios com prefixo `_` — locais / privados (regra global do .gitignore)
# ─────────────────────────────────────────────────────────────────────────────
_*/
_artifacts/
_cache/
_mono_repo/
_references/
_tasks/
# ─────────────────────────────────────────────────────────────────────────────
# 3. Diretorios de tooling IA (state local, nao codigo)
# ─────────────────────────────────────────────────────────────────────────────
.agents/
.claude/
.vscode/
.idea/
.junie/
.omc/
.data/
.data-dev/
.local-data/
.logs/
.artifacts/
.source/
.superpowers/
.claude-flow/
.omnivscodeagent/
omnirouteCloud/
omnirouteSite/
.omniroute/
.stent/
# Subpaths especificos do Claude Code que nao estao em .claude/ (criados sob repo)
.claude/worktrees/
# ─────────────────────────────────────────────────────────────────────────────
# 4. Diretorios de dados / runtime locais (storage, env, secrets, scratch)
# ─────────────────────────────────────────────────────────────────────────────
data/
src/lib/env/
src/app/api/agent-skills/coverage/
src/app/api/cloud/
src/app/api/sync/cloud/
src/app/api/system/env/
tests/golden-set/data/
# Logs e saida de teste
logs/*
test_output.log
home-diegosouzapw-dev-automacoes-*.txt
# ─────────────────────────────────────────────────────────────────────────────
# 5. Diretorios do monorepo por subprojeto (nao fazem parte do app principal)
# ─────────────────────────────────────────────────────────────────────────────
security-analysis/
vscode-extension/
obsidian-plugin/node_modules/
# ─────────────────────────────────────────────────────────────────────────────
# 6. Diretorios de documentacao interna / workflow
# ─────────────────────────────────────────────────────────────────────────────
docs/superpowers/
# ─────────────────────────────────────────────────────────────────────────────
# 7. Arquivos especificos (nao diretorios inteiros)
# ─────────────────────────────────────────────────────────────────────────────
# Segredos e env — NUNCA indexar
.env
.env.*
!.env.example
!.env.homolog.example
# TypeScript build info e next env declaration
*.tsbuildinfo
next-env.d.ts
typescript
# SQLite transient files (WAL/SHM/journal)
*.sqlite-shm
*.sqlite-wal
*.sqlite-journal
# Mapas e source maps
*.map
# Bun / npm lockfiles ruidosos
bun.lock
# `cheaper-inference-gateway.svg` e arquivos de midia na raiz/asset ja cobertos
# pelos `ignored-suffix` do indexador (svg/png/jpg/ico/etc >50kB ou >500linhas);
# manter a regra explicita aqui ajuda a auditar:
cheaper-inference-gateway.svg
cheaper-inference-gateway-*.svg
# Husky internals
.husky/_/
# CI / quality metric artifacts
config/quality/quality-metrics.json
config/quality/test-impact-map.json
audit-report.json
.gh-discussions.json
# i18n audit (gerado por npm run scripts)
scripts/i18n/_audit.json
scripts/i18n/_pending-keys.json
# Cli binario local (scratch)
bin/omniroute.mjs
# Deploy / docker backups
deploy.sh
docker-compose.yml.bak
docker-compose.minimal.yml

View File

@@ -1414,6 +1414,10 @@ APP_LOG_TO_FILE=true
# Default: ~/.omniroute/plugins/ Override in dev/CI to point at a local plugin tree.
# OMNIROUTE_PLUGIN_PATH=
# Allow plugins to request the 'exec' permission (spawn child processes from the
# plugin worker sandbox). Disabled by default; set to 1 to enable (local operator only).
# OMNIROUTE_PLUGINS_ALLOW_EXEC=0
# ── Prompt cache (system prompt deduplication) ──
# Used by: open-sse/services — caches identical system prompts across requests.
# PROMPT_CACHE_MAX_SIZE=50 # Max cached entries (default: 50)

View File

@@ -9,14 +9,11 @@
## Validation
Choose the change type and focused loop from the
[Contribution Golden Path](../docs/dev/CONTRIBUTION_GOLDEN_PATH.md). The full unit suite,
Vitest, the 60% coverage gate, and the production build all run in CI on this PR (#8329):
Run only the focused loop for what you changed — the full unit suite, Vitest, the
60% coverage gate, and the production build all run in CI on this PR (#8329):
- [ ] Change type: provider / routing / UI / i18n / CLI / DB / build-deploy / other
- [ ] Focused tests and category gates from the golden path
- [ ] Focused tests for the change: `node --import tsx/esm --test tests/unit/<file>.test.ts`
- [ ] `npm run lint`
- [ ] Reconciled with the current active release base; focused checks rerun afterward
- [ ] Production-code changes include a new or updated automated test in this PR
- [ ] SonarQube PR analysis is green or any remaining issues are explicitly documented below
@@ -32,4 +29,4 @@ Vitest, the 60% coverage gate, and the production build all run in CI on this PR
## Reviewer Notes
- Call out any risky areas, migrations, feature flags, or manual validation that reviewers should know about.
- Call out any risky areas, migrations, feature flags, or manual validation that reviewers should know about.

View File

@@ -179,81 +179,6 @@ jobs:
# Complexity + cognitive-complexity: ONE ESLint walk (both baselines still
# enforced separately by ruleId). Avoids two cold tree walks on fast-path.
- run: npm run check:complexity-ratchets
# ── G0 (trilho .50): gates do trilho A que faltavam no trilho B ──────────────
# The god-file refactor happens in PRs→release/**; without these, the release
# rail never sees a new import cycle, dead code, duplication or a security
# regression until the release PR to main. Deliberately NOT brought here:
# bundle-size (self-skips without a build — this rail's build job is advisory
# and uploads nothing, so it would be dead configuration) and the coverage
# run (fast-unit already runs the full suite; the coverage ratchet stays on
# the main rail via --allow-missing in lint-guard).
- run: npm run check:cycles
- run: npm run check:lockfile
- name: Duplication ratchet
run: npm run check:duplication
- name: Dead-code ratchet (knip)
run: npm run check:dead-code
- name: Type coverage ratchet
run: npm run check:type-coverage
- name: Compression budget ratchet
run: npm run check:compression-budget
# Security scanners — same hardened install as ci.yml quality-extended
# (gh release download = authenticated, 5000 req/hr; curl to api.github.com
# is rate-limited to 60/hr and silently no-ops when throttled). The blocking
# gates below SKIP (exit 0) when their binary is absent — only a measured
# regression vs config/quality/quality-baseline.json blocks.
- name: Install security scanners (gitleaks/osv/actionlint/zizmor/oasdiff)
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
run: |
set +e
mkdir -p "$HOME/.local/bin"
# Ratchets compare scanner COUNTS across runs. Pin every auditor: a rule-set
# update must be an explicit PR that re-measures/rebaselines, never a random
# red (or green) caused by whatever "latest" served that morning.
GITLEAKS_VERSION=v8.30.1
OSV_SCANNER_VERSION=v2.3.8
ACTIONLINT_VERSION=v1.7.12
ZIZMOR_VERSION=1.25.2
OASDIFF_VERSION=v1.19.1
# gitleaks — pinned linux x64 tarball via gh (authed), extract binary
rm -rf /tmp/gl && mkdir -p /tmp/gl
gh release download "$GITLEAKS_VERSION" --repo gitleaks/gitleaks --pattern '*linux_x64.tar.gz' --dir /tmp/gl
tar -xzf /tmp/gl/*linux_x64.tar.gz -C "$HOME/.local/bin" gitleaks
# osv-scanner — pinned linux amd64 bare binary via gh (authed)
rm -rf /tmp/osv && mkdir -p /tmp/osv
gh release download "$OSV_SCANNER_VERSION" --repo google/osv-scanner --pattern '*linux_amd64' --dir /tmp/osv
install -m 0755 /tmp/osv/*linux_amd64 "$HOME/.local/bin/osv-scanner"
# actionlint — official installer from a pinned release tag (never main)
bash <(curl -fsSL "https://raw.githubusercontent.com/rhysd/actionlint/${ACTIONLINT_VERSION}/scripts/download-actionlint.bash") "$ACTIONLINT_VERSION" "$HOME/.local/bin"
# zizmor — pinned PyPI package (same version as ci.yml quality-extended)
pipx install "zizmor==$ZIZMOR_VERSION" || pip install --user "zizmor==$ZIZMOR_VERSION"
# oasdiff — pinned linux amd64 tarball via gh (authed), extract binary
rm -rf /tmp/oasd && mkdir -p /tmp/oasd
gh release download "$OASDIFF_VERSION" --repo Tufin/oasdiff --pattern '*linux_amd64.tar.gz' --dir /tmp/oasd
tar -xzf /tmp/oasd/*linux_amd64.tar.gz -C "$HOME/.local/bin" oasdiff
# ALWAYS export the bin dir (even if any step above failed)
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
"$HOME/.local/bin/gitleaks" version || true
"$HOME/.local/bin/actionlint" -version || true
"$HOME/.local/bin/osv-scanner" --version || true
"$HOME/.local/bin/oasdiff" --version || true
zizmor --version || true
- name: Secret scan (gitleaks, ratchet, blocking)
run: npm run check:secrets -- --ratchet
- name: Vulnerability ratchet (osv-scanner, ratchet, blocking)
run: npm run check:vuln-ratchet -- --ratchet
- name: Workflow lint (actionlint+zizmor, ratchet, blocking)
run: npm run check:workflows -- --ratchet
# BASE_REF is read by the script from the env (never interpolated into a
# shell body) — workflow-injection-safe. actions/checkout fetches remote
# refs, not a local branch named github.base_ref, so prefix origin/ or this
# gate self-skips every PR with reason=base-unresolved.
- name: OpenAPI breaking-change (oasdiff, ratchet, blocking)
env:
BASE_REF: ${{ github.base_ref && format('origin/{0}', github.base_ref) || '' }}
run: npm run check:openapi-breaking -- --ratchet
- name: Typecheck (core)
run: npm run typecheck:core
# #7033: dashboard-scoped typecheck gate — src/app/(dashboard) TSX is not
@@ -439,12 +364,6 @@ jobs:
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
runs-on: ubuntu-latest
continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}
# G0 (trilho .50): security-events:read lets the CodeQL ratchet below read open
# code-scanning alerts via `gh api .../code-scanning/alerts` (same as ci.yml's
# quality-gate job). contents: read keeps checkout working.
permissions:
contents: read
security-events: read
steps:
- uses: actions/checkout@v7
with:
@@ -466,29 +385,6 @@ jobs:
- name: ESLint (baseline congelado — warning novo = vermelho)
# lint:json writes the report; --max-warnings 0 keeps no-new-warnings policy.
run: npm run lint:json -- --max-warnings 0
# ── G0 (trilho .50): motor de ratchet também no trilho B ─────────────────────
# This job just wrote .artifacts/eslint-results.json — collect-metrics prefers
# that file, so the ratchet engine lands here at ZERO extra ESLint cost (one
# inventory, two consumers; same reason ci.yml chains lint → quality-gate).
# The coverage-report artifact does not exist on this rail, so both ratchet
# invocations run --allow-missing: coverage.* metrics skip gracefully while
# the deterministic ones (eslint / openapi-coverage / i18n-ui) stay BLOCKING.
# Coverage authority remains on the main rail (ci.yml test-coverage → quality-gate).
- run: npm run quality:collect
- name: Ratchet check (blocking)
run: node scripts/quality/check-quality-ratchet.mjs --allow-missing --summary .artifacts/quality-ratchet.md
- name: Require-tighten (blocking)
run: node scripts/quality/check-quality-ratchet.mjs --allow-missing --require-tighten
# CodeQL alerts ratchet — same semantics as ci.yml quality-gate: exits 1 ONLY
# on a real regression (open alerts > baseline in quality-baseline.json);
# a measurement failure (gh/auth/api) self-skips with exit 0.
- name: CodeQL alerts ratchet (blocking)
run: npm run check:codeql-ratchet
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Append ratchet summary
if: always()
run: cat .artifacts/quality-ratchet.md >> "$GITHUB_STEP_SUMMARY" || true
# Merge-integrity: pega no PR os dois vazamentos crônicos de merge que hoje só
# explodem na release-PR. (1) CHANGELOG-eat — o auto-resolve do merge come

7
.gitignore vendored
View File

@@ -171,6 +171,7 @@ config/quality/test-impact-map.json
# GitNexus local index
.gitnexus
.worktrees
bin/omniroute.mjs
# Consistent with .dockerignore / .npmignore
.omc/
@@ -200,10 +201,7 @@ scripts/i18n/_pending-keys.json
.codegraph/
# Fumadocs generated source
/.source/
# Temporary local worktrees used to build unpublished npm tarballs
/.deploy-build-*/
.source/
# AI agent local settings and configs
.agents/
@@ -252,4 +250,3 @@ tests/homolog/.auth/
tests/homolog/ui/.auth/
homolog-report/
docker-compose.yml.bak
.playwright-cli/

8
.source/dynamic.ts Normal file
View File

@@ -0,0 +1,8 @@
// @ts-nocheck
import { dynamic } from 'fumadocs-mdx/runtime/dynamic';
import * as Config from '../source.config';
const create = await dynamic<typeof Config, import("fumadocs-mdx/runtime/types").InternalTypeConfig & {
DocData: {
}
}>(Config, {"configPath":"source.config.ts","environment":"next","outDir":".source"}, {"doc":{"passthroughs":["extractedReferences"]}});

22
.source/source.config.mjs Normal file
View File

@@ -0,0 +1,22 @@
// source.config.ts
import { defineDocs, defineConfig } from "fumadocs-mdx/config";
var docs = defineDocs({
dir: "docs",
docs: {
files: [
"./architecture/**/*.md",
"./guides/**/*.md",
"./reference/**/*.md",
"./frameworks/**/*.md",
"./routing/**/*.md",
"./security/**/*.md",
"./compression/**/*.md",
"./ops/**/*.md"
]
}
});
var source_config_default = defineConfig();
export {
source_config_default as default,
docs
};

1
AMIT Normal file
View File

@@ -0,0 +1 @@

View File

@@ -45,7 +45,7 @@ For full test matrix, see `CONTRIBUTING.md` → "Running Tests". For deep archit
| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) |
| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions |
| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc |
| Database | `src/lib/db/` | SQLite domain modules (130 migrations) |
| Database | `src/lib/db/` | SQLite domain modules (95 files, 110 migrations) |
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
| MCP Server | `open-sse/mcp-server/` | 104 tools (42 base + memory/skill/agentSkill/pool/notion/obsidian/gamification/plugin modules), 3 transports (stdio / SSE / Streamable HTTP), 31 scopes |
| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol |
@@ -72,7 +72,7 @@ Client → /v1/chat/completions (Next.js route)
API routes follow a consistent pattern: `Route → CORS preflight → Zod body validation → Optional auth (extractApiKey/isValidApiKey) → API key policy enforcement → Handler delegation (open-sse)`. No global Next.js middleware — interception is route-specific.
**Combo routing** (`open-sse/services/combo.ts`): 19 public strategies (priority, weighted, fill-first, round-robin, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline). Each target calls `handleSingleModel()` which wraps `handleChatCore()` with per-target error handling and circuit breaker checks. The `fusion` strategy is the exception: it fans out to a panel of models in parallel, then a judge model synthesizes one final answer (`open-sse/services/fusion.ts`). See `docs/routing/AUTO-COMBO.md` for the 13-factor Auto-Combo scoring + the full strategy table and `docs/architecture/RESILIENCE_GUIDE.md` for the 3 resilience layers.
**Combo routing** (`open-sse/services/combo.ts`): 18 strategies (priority, weighted, fill-first, round-robin, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline). Each target calls `handleSingleModel()` which wraps `handleChatCore()` with per-target error handling and circuit breaker checks. The `fusion` strategy is the exception: it fans out to a panel of models in parallel, then a judge model synthesizes one final answer (`open-sse/services/fusion.ts`). See `docs/routing/AUTO-COMBO.md` for the 12-factor Auto-Combo scoring + the full strategy table and `docs/architecture/RESILIENCE_GUIDE.md` for the 3 resilience layers.
---
@@ -332,7 +332,7 @@ For any non-trivial change, read the matching deep-dive first:
| Repo navigation | `docs/architecture/REPOSITORY_MAP.md` |
| Architecture | `docs/architecture/ARCHITECTURE.md` |
| Engineering reference | `docs/architecture/CODEBASE_DOCUMENTATION.md` |
| Auto-Combo (13-factor scoring, 19 strategies) | `docs/routing/AUTO-COMBO.md` |
| Auto-Combo (12-factor scoring, 18 strategies) | `docs/routing/AUTO-COMBO.md` |
| Resilience (3 mechanisms) | `docs/architecture/RESILIENCE_GUIDE.md` |
| Reasoning replay | `docs/routing/REASONING_REPLAY.md` |
| Skills framework | `docs/frameworks/SKILLS.md` |
@@ -461,18 +461,10 @@ own dedicated branch, and you MUST confirm the base branch with the operator bef
git fetch origin "$BASE_BRANCH"
git worktree add ".claude/worktrees/${TASK##*/}" -b "$TASK" "origin/$BASE_BRANCH"
cd ".claude/worktrees/${TASK##*/}"
# Reuse the main checkout's node_modules to skip a per-worktree npm install.
# HARD LINKS (`cp -al`), never a symlink: ~5s for the whole tree and near-zero extra
# disk (the inodes are shared), and unlike a symlink it does not break the dev server.
cp -al "$(git -C <main_checkout> rev-parse --show-toplevel)/node_modules" node_modules
# symlink node_modules from the main checkout to skip a per-worktree npm install:
ln -s "$(git -C <main_checkout> rev-parse --show-toplevel)/node_modules" node_modules
```
**Never `ln -s` node_modules.** Turbopack rejects a symlink that resolves outside the
project root, so `npm run dev` dies with a FATAL panic (`Symlink [project]/node_modules
is invalid, it points out of the filesystem root`) while typecheck, lint and the test
runners all keep passing — the error names "filesystem root", not the worktree, so it
reads like a Next/build bug and costs real time to trace (incident 2026-07-31, #9043).
In Claude Code prefer the native `EnterWorktree` tool (it already creates worktrees under
`.claude/worktrees/`): create the worktree with the command above, then call `EnterWorktree`
with its `path`.

View File

@@ -2,11 +2,6 @@
Thank you for your interest in contributing! This guide covers everything you need to get started.
For the official per-change workflow, start with the
[Contribution Golden Path](docs/dev/CONTRIBUTION_GOLDEN_PATH.md). It maps provider, routing,
UI/UX, i18n, CLI, database, and build/deploy changes to their contracts, focused tests, CI
coverage, and reconciliation steps.
---
## Development Setup
@@ -203,11 +198,10 @@ Coverage notes:
### Pull Request Requirements
Before opening a PR, use the
[Contribution Golden Path](docs/dev/CONTRIBUTION_GOLDEN_PATH.md) to run the focused loop for
what you changed. The full unit suite (4 CI shards), Vitest, the **60%+** coverage gate, and
the production build are CI's responsibility — running them locally adds no signal the PR
checks will not already give you, and on smaller machines it can saturate the host (#8084):
Before opening a PR, run the focused loop for what you changed. The full unit suite
(4 CI shards), Vitest, the **60%+** coverage gate, and the production build are CI's
responsibility — running them locally adds no signal the PR checks will not already
give you, and on smaller machines it can saturate the host (#8084):
- Run the test files that cover your change: `node --import tsx/esm --test tests/unit/<file>.test.ts`
- Run `npm run lint`
@@ -277,7 +271,7 @@ src/ # TypeScript (.ts / .tsx)
│ ├── a2a/ # Agent-to-Agent v0.3 protocol server
│ ├── acp/ # Agent Communication Protocol registry
│ ├── compliance/ # Compliance policy engine
│ ├── db/ # SQLite domain modules + 130 migrations
│ ├── db/ # SQLite database layer (21 modules + 16 migrations)
│ ├── memory/ # Persistent conversational memory
│ ├── oauth/ # OAuth providers, services, and utilities
│ ├── skills/ # Extensible skill framework
@@ -287,7 +281,7 @@ src/ # TypeScript (.ts / .tsx)
├── mitm/ # MITM proxy (cert, DNS, target routing)
├── shared/
│ ├── components/ # React components (.tsx)
│ ├── constants/ # Provider definitions (290), MCP scopes, 19 routing strategies
│ ├── constants/ # Provider definitions (177), MCP scopes, 14 routing strategies
│ ├── utils/ # Circuit breaker, sanitizer, auth helpers
│ └── validation/ # Zod v4 schemas
└── sse/ # SSE proxy pipeline
@@ -295,7 +289,7 @@ src/ # TypeScript (.ts / .tsx)
open-sse/ # @omniroute/open-sse workspace
├── executors/ # 14 provider-specific request executors
├── handlers/ # 11 request handlers (chat, responses, embeddings, images, etc.)
├── mcp-server/ # MCP server (104 tools, 3 transports, 31 scopes)
├── mcp-server/ # MCP server (25 tools, 3 transports, 10 scopes)
├── services/ # 36+ services (combo, autoCombo, rateLimitManager, etc.)
├── translator/ # Format translators (OpenAI ↔ Claude ↔ Gemini ↔ Responses ↔ Ollama)
├── transformer/ # Responses API transformer

View File

@@ -241,53 +241,12 @@ curl http://localhost:20128/v1/chat/completions \
<b>What Kimi's support powers:</b> Kimi's API credits power OmniRoute's AI-validated release pipeline — the <i>merge validation powered by Kimi K3</i> stage that reviews every pull request before it ships — plus day-to-day feature development. First-class Kimi support ships on both rails: the direct <a href="https://platform.kimi.ai?aff=omniroute">Kimi API</a> (<code>kimi-k3</code>) and the <a href="https://www.kimi.com/code?aff=omniroute">Kimi Code coding plan</a> (OAuth and API key). OmniRoute is also the first Brazilian open-source project in Kimi's support program. <a href="https://platform.kimi.ai?aff=omniroute"><b>Get a Kimi API key →</b></a>
</td>
</tr>
<tr>
<td align="center" width="150">
<a href="https://cheaperinference.com/?utm_source=omniroute">
<img src="public/providers/cheaperinference.svg" width="64" alt="Cheaper Inference"/>
</a>
<br/><b>Cheaper Inference</b><br/><sub>cheaperinference.com</sub><br/><br/>
<img src="https://img.shields.io/badge/Open_Source_Friend-31f889?style=flat-square&labelColor=04170d" alt="Open Source Friend"/>
</td>
<td>
Thanks to <b>Cheaper Inference</b>, an OmniRoute Open Source Friend, for backing this project! Cheaper Inference is a cost-ranked gateway that resells 42 frontier models — Claude, GPT-5.x, Gemini, Kimi K3, GLM, DeepSeek, Grok and MiniMax — behind one OpenAI-compatible endpoint, routing each request to the cheapest eligible provider without ever charging above the model maker's list price.
<br/><br/>
<b>First-class support in OmniRoute:</b> Chat Completions, the native <code>/v1/responses</code> endpoint, vision, tool calling and 3 image models (<code>grok-imagine</code>, <code>nano-banana-pro</code>, <code>nano-banana-2</code>, reachable as <code>cheaperinference/&lt;model&gt;</code>). <a href="https://cheaperinference.com/?utm_source=omniroute"><b>Get an API key →</b></a>
</td>
</tr>
</table>
<sub>Links tagged <code>aff=omniroute</code> are partner links. They fund the project at no extra cost to you.</sub>
<br/>
<details open>
<summary><sub><b>🎟️ Affiliates Promo</b> — free signup coupons from providers we don't sponsor (click to expand)</sub></summary>
<sub><i>This section is for referral/coupon codes only. Sponsored partnerships live in <b>🤝 Supported by our Open Source Friends</b> above. OmniRoute has no sponsorship or partnership with the providers listed here — these are public coupons anyone can use.</i></sub>
<table>
<tr>
<td align="center" width="120">
<a href="https://agentrouter.org/register?aff=70LM">
<img src="public/providers/agentrouter.png" width="32" alt="AgentRouter"/>
</a>
<br/><sub><b>AgentRouter</b></sub><br/><sub>agentrouter.org</sub>
</td>
<td>
<sub><b><a href="https://agentrouter.org/register?aff=70LM">AgentRouter</a></b> — affiliate signup · <b>$100 free credits</b> on signup (free server, expect higher latency — best for testing, not production). First-class support in OmniRoute since <b>v3.8.50</b>: Chat Completions, the Anthropic-compatible wire format and the OpenAI-compatible path. Available models include <code>claude-opus-4-8</code>, <code>claude-opus-5</code>, <code>gpt-5.6-sol</code> and more. <b><a href="https://agentrouter.org/register?aff=70LM">Grab your $100 →</a></b></sub>
<br/><br/>
<sub>⚠️ <i>Affiliate link — OmniRoute has no sponsorship or partnership with this provider.</i></sub>
</td>
</tr>
</table>
<sub>Know another provider with a generous free signup coupon that benefits OmniRoute users? Open an issue and we'll add it here.</sub>
</details>
<br/>
<div align="center">
## 🎯 Combos — The Flagship

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -177,7 +177,7 @@
"tests/unit/account-fallback-service.test.ts": 1563,
"tests/unit/batch_api.test.ts": 1324,
"tests/unit/cc-compatible-provider.test.ts": 1217,
"tests/unit/chatcore-translation-paths.test.ts": 2776,
"tests/unit/chatcore-translation-paths.test.ts": 2769,
"tests/unit/chatgpt-web.test.ts": 3148,
"tests/unit/combo-routing-engine.test.ts": 3449,
"tests/unit/db-migration-runner.test.ts": 1499,
@@ -342,7 +342,7 @@
"_rebaseline_pr1043_minimax_tts": "Upstream port decolua/9router#1043 (toanalien) own growth: audioSpeech.ts 965->1061 (+96). Adds MiniMax T2A v2 TTS dispatch (handleMinimaxSpeech + hexToBytes helper) — provider entry was already in audioRegistry (format: minimax-tts) but no handler existed, falling through to the OpenAI-compatible default that fails (T2A has custom shape + hex-encoded audio + base_resp envelope). New branch sits next to the other inline provider branches (xiaomi-mimo, coqui, tortoise, aws-polly) — extracting would just create indirection. Covered by tests/unit/minimax-tts-1043.test.ts (3 tests, GREEN: success, base_resp error, invalid-hex).",
"_rebaseline_pr4592_exclude_exhausted_auto": "Reconcile #4592 already-merged growth: combo.ts 2991->3036 (+45, terminal-status quota-cutoff exclusion in buildAutoCandidates + opt-in gate). Fast-gate PR->release does not run check:file-size.",
"open-sse/executors/antigravity.ts": 1528,
"open-sse/executors/base.ts": 1578,
"open-sse/executors/base.ts": 1562,
"open-sse/executors/chatgpt-web.ts": 3241,
"open-sse/executors/codex.ts": 1534,
"open-sse/executors/cursor.ts": 1560,
@@ -365,7 +365,7 @@
"open-sse/services/rateLimitManager.ts": 1060,
"open-sse/translator/response/openai-responses.ts": 1174,
"open-sse/utils/cursorAgentProtobuf.ts": 1505,
"open-sse/utils/stream.ts": 2889,
"open-sse/utils/stream.ts": 2887,
"src/app/(dashboard)/dashboard/HomePageClient.tsx": 1381,
"src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": 1031,
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 3117,
@@ -413,6 +413,5 @@
"_rebaseline_2026_07_28_8860_tokenrefresh_projectid": "PR #8860 (fix/antigravity-projectid-centralized) own test growth: tests/unit/token-refresh-service.test.ts 1311->1378 (+67 = 4 cases covering projectId discovery on the tokenRefresh.ts path — the Dashboard/health-check refresh route, which #8842 did not reach since that fixed the executor path). Covered by the same file.",
"_rebaseline_2026_07_28_8861_xiaomi_token_plan": "PR #8861 (feat/xiaomi-token-plan-protocol-selector) own growth: EditConnectionModal.tsx 1283->1316 (+33 = the per-connection API-protocol selector field) and open-sse/executors/base.ts 1540->1562 (+22 = alternate-format resolution at the existing buildUrl/headers chokepoint). Both are irreducible wiring at existing call sites.",
"_rebaseline_2026_07_28_8863_firefly_detail_level": "PR #8863 (fix/adobe-firefly-gpt-detail-level-max) own growth: adobeFireflyClient.ts 2317->2322 (+5 = gpt-image detailLevel defaulting to maximal at the existing payload-build site). Covered by tests/unit/adobe-firefly.test.ts.",
"_rebaseline_2026_07_29_8281_home_quickstart_prefetch": "Release v3.8.49 base-red fix (no PR — captain sweep): src/app/(dashboard)/dashboard/HomePageClient.tsx 1377->1381 (+4). #8292 added prefetch={false} to the sidebar but left /home's five quick-start Links prefetching, so first paint still fired 12 speculative RSC requests — caught by navigation.spec.ts only after the e2e helper bug (APP_ROUTE_PATTERN missing /home) was repaired in the same cycle. Growth is the five prefetch attributes; it was offset first by extracting the repeated className literals (INLINE_LINK x4, DOCS_LINK x1), which collapsed five wrapped <Link> blocks back to one line each — a naive fix measured 1391. Guard: tests/unit/sidebar-prefetch-policy-8281.test.ts.",
"_rebaseline_2026_08_02_v3850_agentrouter_responses": "Release v3.8.50 AgentRouter/Codex compatibility reconciliation. open-sse/executors/base.ts 1562->1578: #9190 wires AgentRouter's selected Claude/OpenAI/Responses protocol through the existing executor URL, auth, identity-header and fingerprint chokepoints; the reusable alternate resolver remains outside base.ts. open-sse/utils/stream.ts 2887->2889: #9213 evaluates Responses ID and usage normalization independently so response.completed always receives finite usage.total_tokens instead of short-circuiting after an ID rewrite. tests/unit/chatcore-translation-paths.test.ts 2769->2776: #9191 updates the existing Claude-Code bridge assertions for the dynamic AgentRouter wire image. PR #9224 offsets its own chatCore growth by extracting the AgentRouter protocol decisions into chatCore/agentRouterProtocol.ts, leaving chatCore below its frozen ceiling. Covered by agentrouter executor/chatCore protocol tests, chatcore translation-path tests, and responses-commentary-passthrough tests."
"_rebaseline_2026_07_29_8281_home_quickstart_prefetch": "Release v3.8.49 base-red fix (no PR — captain sweep): src/app/(dashboard)/dashboard/HomePageClient.tsx 1377->1381 (+4). #8292 added prefetch={false} to the sidebar but left /home's five quick-start Links prefetching, so first paint still fired 12 speculative RSC requests — caught by navigation.spec.ts only after the e2e helper bug (APP_ROUTE_PATTERN missing /home) was repaired in the same cycle. Growth is the five prefetch attributes; it was offset first by extracting the repeated className literals (INLINE_LINK x4, DOCS_LINK x1), which collapsed five wrapped <Link> blocks back to one line each — a naive fix measured 1391. Guard: tests/unit/sidebar-prefetch-policy-8281.test.ts."
}

View File

@@ -1,229 +0,0 @@
---
title: "Contribution Golden Path"
---
# Contribution Golden Path
Use this guide to choose the smallest reliable development loop for a pull request. It does not
replace the area-specific architecture and security documents linked below; it connects each common
change type to its contracts, focused checks, and CI coverage.
## The path every change follows
1. **Choose the base before editing.** Find the highest active `release/v*` branch and branch from
its tip. Target that branch, not `main`. If a release freeze is active, do not target the frozen
branch; use the next active cycle described in
[Branching & Release Model](../ops/BRANCHING_MODEL.md).
2. **Name the contracts.** Identify every catalog, schema, generated artifact, public API, or user
interface that the change affects. The table below gives the minimum starting set.
3. **Write or update focused tests.** Production changes in `src/`, `open-sse/`, `electron/`, or
`bin/` require an automated test in the same PR. Run the smallest test files that prove the
behavior, then the listed focused gates.
4. **Let CI run the broad matrix.** The complete unit shards, Vitest, coverage ratchet, and
production build run on the PR. Run a broad suite locally only when a focused failure points to
wider impact or when the change spans several subsystems.
5. **Reconcile before review.** Fetch the active base, inspect its new commits and your diff against
it, then rebase or merge the base according to the contributor workflow. Resolve generated-file
and catalog conflicts from their source, regenerate them, rerun the focused loop, and confirm the
PR still targets the active release branch.
6. **Record evidence.** In the PR template, list the commands run, every test file added or changed,
migrations or feature flags, and any CI-only validation still pending.
## Golden paths by change type
Commands below are minimum focused checks, not permission to skip a test that directly covers the
behavior you changed.
### Provider
**Contracts**
- Provider definition in `src/shared/constants/providers/` and its composition in
`src/shared/constants/providers.ts`.
- Models and capabilities in `open-sse/config/providerRegistry.ts` or its extracted registry files.
- Executor/translator selection, OAuth or API-key configuration, dashboard assets, and generated
provider reference when applicable.
- Public credentials must use `resolvePublicCred()`; error responses must use the shared sanitized
error helpers. See [Public Credentials](../security/PUBLIC_CREDS.md) and
[Error Sanitization](../security/ERROR_SANITIZATION.md).
**Focused loop**
```bash
npm run check:provider-consistency
npm run check:provider-assets
node --import tsx/esm --test tests/unit/provider-translate-path-golden.test.ts
node --import tsx/esm --test tests/unit/<provider-or-executor>.test.ts
npm run gen:provider-reference # when the catalog changes; commit the generated diff
npm run lint
```
Also test every affected request family: chat, Responses, images, embeddings, audio, or video.
Review generated catalog and golden diffs as contract changes; do not accept them blindly.
### Routing
**Contracts**
- Public strategy values and UI metadata in `src/shared/constants/routingStrategies.ts`.
- Dispatch and ordering under `open-sse/services/combo.ts` and `open-sse/services/combo/`.
- Combo schemas, persistence, resilience state, model capabilities, and API/UI controls.
- [Auto-Combo Engine](../routing/AUTO-COMBO.md) and resilience documentation when behavior changes.
**Focused loop**
```bash
node --import tsx/esm --test tests/unit/combo-<behavior>.test.ts
npm run test:combo:matrix # strategy or dispatch changes
npm run check:known-symbols # strategy registration changes
npm run lint
```
Use deterministic mocked-upstream tests locally. Live combo smokes require credentials and are
manual, not CI substitutes.
### UI / UX
**Contracts**
- Next.js route/page and shared component boundaries under `src/app/` and
`src/shared/components/`.
- API response shapes, loading/empty/error states, keyboard and screen-reader behavior,
responsive layout, theming, and locale expansion.
- English UI source strings in `src/i18n/messages/en.json`; do not hard-code new user-facing copy.
**Focused loop**
```bash
node --import tsx --test tests/unit/dashboard/<feature>.test.ts
npx vitest run --config vitest.config.ts tests/unit/ui/<component>.test.tsx
npm run check:dashboard-typecheck
npm run lint
```
Run the app for interaction or visual changes and check both narrow and wide viewports. CI runs the
production build and broader suites; visual behavior still needs a focused component, Playwright,
or documented manual check appropriate to the change.
### i18n
**Contracts**
- `src/i18n/messages/en.json` is the UI source; `config/i18n.json` is the locale source.
- CLI catalogs live separately under `bin/cli/locales/`.
- Preserve ICU placeholders and tags exactly. Do not translate product/provider/model names,
protocol and header names, commands, code/JSON identifiers, URLs, environment variables, or
protected terms such as `OmniRoute`, `OAuth`, `MCP`, and `A2A`. The current source list is
`scripts/i18n/glossary/protected-terms.json`.
**Focused loop**
```bash
npm run i18n:sync-ui:dry
npm run i18n:check-ui-coverage
npm run i18n:check-value-drift
npm run i18n:check-glossary
npm run check:cli-i18n # when CLI strings/catalogs change
npm run lint
```
This is guidance for the existing system, not an invitation to expand its tooling or key model.
Keep i18n patches surgical while the replacement system is being designed. Do not run translation
commands that call external services unless the task explicitly requires generated translations and
you have reviewed the resulting diff.
### CLI
**Contracts**
- Public commands and flags in `bin/cli/`, generated API commands, exit codes, stdout/stderr and
JSON output shapes, config/environment behavior, and packaged files.
- CLI user-facing strings must use the CLI i18n layer and keep `en`/`pt-BR` catalogs aligned.
- Preserve Node as the supported runtime and the published binary contract.
**Focused loop**
```bash
node --import tsx/esm --test tests/unit/cli/<command>.test.ts
npm run check:cli-i18n
npm run build:cli # generated/bundled CLI changes
npm run check:pack-policy # package-surface changes
npm run lint
```
Use the exact command in a temporary data directory when behavior depends on parsing, files, or exit
status. CI performs the broader package artifact and ecosystem checks.
### Database
**Contracts**
- Domain modules under `src/lib/db/`; `src/lib/localDb.ts` remains a re-export layer only.
- Numbered, idempotent SQL migrations under `src/lib/db/migrations/`, transaction safety, upgrade
behavior, indexes, and every caller affected by the schema.
- Routes and handlers never issue raw SQL directly.
**Focused loop**
```bash
npm run check:migration-numbering
npm run check:db-rules
node --import tsx/esm --test tests/unit/db/<domain>.test.ts
node --import tsx/esm --test tests/unit/db/migration-<number>.test.ts
npm run lint
```
Test both a fresh database and upgrade from the prior schema when adding a migration. Database tests
must close handles and call `resetDbInstance()` during cleanup. Run `npm run test:bun:db` only when
the best-effort Bun adapter path changes; Node remains authoritative.
### Build / deploy
**Contracts**
- Root and workspace manifests/lockfile, `scripts/build/`, Next.js standalone assembly, `dist/`
package contents, Electron platform metadata, CI workflows, and deployment sentinels.
- Supported Node ranges and the allow-listed Bun use in `CLAUDE.md` must remain intact.
- Build artifacts stay untracked; dependency, license, workflow, and package policies apply.
**Focused loop**
```bash
node --import tsx/esm --test tests/unit/build/<behavior>.test.ts
npm run check:build-scope
npm run check:lockfile # dependency or lockfile changes
npm run check:pack-policy # published package surface changes
npm run lint
```
Use `npm run build` locally only when the change affects compilation, standalone assembly, assets,
or runtime bundling. Use `npm run build:release` only for release/deploy validation. CI's build is
the final cross-platform signal; platform-specific Electron changes need the matching focused build
or smoke evidence.
## Local loop versus CI
| Run locally for each patch | CI supplies the broad signal |
| ----------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| Direct behavior tests and category gates above | Sharded full unit suite and serial tests |
| `npm run lint` | Vitest suites and coverage/quality ratchets |
| Typecheck or build only when the affected contract calls for it | Production build, security, docs, dependency, and PR-policy gates |
| Manual interaction/live checks only when automation cannot prove the behavior | Cross-job integration and platform checks configured by workflow |
A green focused loop is evidence about the changed contract, not proof that unrelated CI checks
will pass. Conversely, do not make every local edit wait for the full repository matrix.
## Reconciliation checklist
Before requesting review:
- Confirm the PR base is still the highest active `release/v*` branch.
- Fetch that base and review commits that landed since you branched.
- Review `git diff <active-base>...HEAD` for accidental or generated churn.
- Resolve catalog and generated-document conflicts by updating the source and regenerating output.
- Rerun every focused test/gate listed in the PR description after reconciliation.
- Never weaken assertions or drop required tests merely to match a moved base.
For release-freeze and retargeting rules, use
[Branching & Release Model](../ops/BRANCHING_MODEL.md). For the complete CI inventory, use
[Quality Gates Reference](../architecture/QUALITY_GATES.md).

View File

@@ -461,18 +461,10 @@ own dedicated branch, and you MUST confirm the base branch with the operator bef
git fetch origin "$BASE_BRANCH"
git worktree add ".claude/worktrees/${TASK##*/}" -b "$TASK" "origin/$BASE_BRANCH"
cd ".claude/worktrees/${TASK##*/}"
# Reuse the main checkout's node_modules to skip a per-worktree npm install.
# HARD LINKS (`cp -al`), never a symlink: ~5s for the whole tree and near-zero extra
# disk (the inodes are shared), and unlike a symlink it does not break the dev server.
cp -al "$(git -C <main_checkout> rev-parse --show-toplevel)/node_modules" node_modules
# symlink node_modules from the main checkout to skip a per-worktree npm install:
ln -s "$(git -C <main_checkout> rev-parse --show-toplevel)/node_modules" node_modules
```
**Never `ln -s` node_modules.** Turbopack rejects a symlink that resolves outside the
project root, so `npm run dev` dies with a FATAL panic (`Symlink [project]/node_modules
is invalid, it points out of the filesystem root`) while typecheck, lint and the test
runners all keep passing — the error names "filesystem root", not the worktree, so it
reads like a Next/build bug and costs real time to trace (incident 2026-07-31, #9043).
In Claude Code prefer the native `EnterWorktree` tool (it already creates worktrees under
`.claude/worktrees/`): create the worktree with the command above, then call `EnterWorktree`
with its `path`.

View File

@@ -415,18 +415,10 @@ git push -u origin feat/your-feature
git fetch origin "$BASE_BRANCH"
git worktree add ".claude/worktrees/${TASK##*/}" -b "$TASK" "origin/$BASE_BRANCH"
cd ".claude/worktrees/${TASK##*/}"
# 复用主工作区 node_modules省去每个 worktree 的 npm install
# 必须用硬链接(`cp -al`),绝不能用符号链接:整棵树约 5 秒,几乎不占额外磁盘
# inode 是共享的),而且与符号链接不同,它不会破坏开发服务器。
cp -al "$(git -C <main_checkout> rev-parse --show-toplevel)/node_modules" node_modules
# 主工作区符号链接 node_modules省去每个 worktree 的 npm install
ln -s "$(git -C <main_checkout> rev-parse --show-toplevel)/node_modules" node_modules
```
**绝不要对 node_modules 使用 `ln -s`。** Turbopack 会拒绝解析到项目根目录之外的符号链接,
因此 `npm run dev` 会以 FATAL panic 崩溃(`Symlink [project]/node_modules is invalid, it
points out of the filesystem root`),而 typecheck、lint 和测试运行器却都照常通过 —— 错误信息
提到的是 "filesystem root" 而不是 worktree看起来像 Next/构建的 bug排查会浪费大量时间
(事故 2026-07-31#9043
在 Claude Code 中优先使用原生的 `EnterWorktree` 工具(它已经在 `.claude/worktrees/` 下创建 worktree先用上述命令创建 worktree然后用其 `path` 调用 `EnterWorktree`。
3. **工作、提交、推送、发起 PR — 全部在 worktree 内部完成。** 绝不在另一个会话可能共享的 worktree 内 `git checkout` 不同分支。

View File

@@ -0,0 +1,168 @@
# [Feature] Pluginization Phase 1: Extract Playwright/CloakBrowser Browser Pool
**Labels:** `enhancement`, `plugin`, `architecture`
## Problem / Use Case
OmniRoute's Playwright/CloakBrowser dependency is a **large, non-essential dependency** (~1500+ LOC across 22 source files + ~35 test files) pulled into every installation regardless of whether the user needs browser-backed chat. Users who run OmniRoute purely as a proxy/router (the majority) pay for:
- **Disk space**: ~200+ MB from Playwright browsers + Chromium binaries (installed via `npx playwright install`)
- **Build complexity**: Turbopack must handle the `cloakbrowser` package
- **Bundle size**: All browser-pool code is compiled into the main codebase
- **Surface area**: 7 files with direct Playwright imports (4 dynamic, 3 static type imports)
- **CI/cache impact**: Playwright installs in CI pipelines even when not needed
Currently, only environment variables (`OMNIROUTE_BROWSER_POOL=off`) gate _runtime_ execution — the code still loads, imports get resolved, and Playwright must be installed.
## Proposed Solution
Extract the Playwright/CloakBrowser browser pool into an **optional package** loaded via dynamic `import()` at runtime, following the existing pattern used for `cloakbrowser` (computed-string dynamic import to avoid resolution). The core retains thin interface stubs that gracefully degrade when the optional package is absent.
### Architecture
```
open-sse/
interfaces/
browserPool.ts ← NEW: BrowserPoolProvider interface + types
services/
browserPool.ts ← BECOMES: thin stub, delegates to optional package
browserBackedChat.ts ← BECOMES: thin stub, delegates to optional package
grokClearance.ts ← BECOMES: thin stub
packages/browser-pool/ ← NEW: optional package
index.ts ← exports BrowserPoolProvider implementation
src/
browserPool.ts ← extracted from open-sse/services/browserPool.ts
browserBackedChat.ts ← extracted from open-sse/services/browserBackedChat.ts
grokClearance.ts ← extracted from open-sse/services/grokClearance.ts
claudeTurnstileSolver.ts ← extracted (tightly coupled, moved as-is)
inAppLoginService.ts ← extracted (own Playwright instance, separate lifecycle)
package.json
tsconfig.json
```
### Phase Breakdown
**Phase 1 — Core pool extraction (this issue):**
1. Define `BrowserPoolProvider` interface in `open-sse/interfaces/browserPool.ts`
2. Extract `browserPool.ts` (~502 LOC), `browserBackedChat.ts` (~270 LOC), `grokClearance.ts` (~84 LOC) into `packages/browser-pool/`
3. Replace core files with thin stubs that try `import('../../../packages/browser-pool')` with graceful fallback
4. Keep `poolTools.ts` importing the core stub (unchanged from consumer perspective)
5. Make Playwright an optional dependency (not in root `package.json`)
6. Typecheck core passes with and without the package installed
7. All existing tests pass (with plugin installed)
**Phase 2 — Turnstile solver extraction (future):**
- Extract `claudeTurnstileSolver.ts` (~212 LOC) — has static Playwright type imports, needs type interface
- Move `claudeWebAutoRefresh.ts` (depends on turnstile solver)
**Phase 3 — Standalone Playwright instances (future):**
- Extract `inAppLoginService.ts` (~257 LOC)
- Refactor `gemini-web.ts` executor's own Playwright path (~553 LOC)
### Interface Design (Phase 1)
```typescript
// open-sse/interfaces/browserPool.ts
export interface BrowserPoolProvider {
acquireBrowserContext(options?: BrowserPoolContextOptions): Promise<PooledContext>;
releaseBrowserContext(ctx: PooledContext): Promise<void>;
getBrowserPoolMetrics(): BrowserPoolMetrics;
shutdownPool(): Promise<void>;
isPoolEnabled(): boolean;
openPage(url: string, ctx?: PooledContext): Promise<{ page: any }>;
readPageResponseBody(page: any): Promise<string>;
getBrowserPoolStatus(): BrowserPoolStatus;
}
```
### Stub Pattern
```typescript
// open-sse/services/browserPool.ts — thin stub
let _impl: BrowserPoolProvider | null = null;
async function getImpl(): Promise<BrowserPoolProvider> {
if (!_impl) {
try {
const { createBrowserPoolProvider } = await import("../../packages/browser-pool");
_impl = createBrowserPoolProvider();
} catch {
// Graceful fallback — disabled
_impl = createNullBrowserPoolProvider();
}
}
return _impl;
}
export async function acquireBrowserContext(...args) {
return (await getImpl()).acquireBrowserContext(...args);
}
```
## Alternatives Considered
1. **Existing hook-based PluginManager**: Rejected. The current PluginManager operates via child-process IPC and request-pipeline hooks (`onRequest`, `onResponse`, `onError`). A browser pool is an in-process runtime service with composable lifecycle — not a request pipeline hook. Forcing it through IPC would add ~50ms+ per browser operation and break the existing synchronous pool pattern.
2. **Keep as-is, just lazy-load the import**: Minimal improvement — the dependency tree still references Playwright types, requiring it to be available. Doesn't reduce bundle size or simplify CI.
3. **Replace Playwright with a protocol-level abstraction**: Too ambitious and would change the behavior of the pool. Playwright's CDP capabilities (context isolation, cookies, screenshots) are fundamental to how the pool works.
4. **Monorepo workspace**: Too heavy for this scope. A simple extracted package avoids workspace tooling changes.
## Acceptance Criteria
1. `open-sse/interfaces/browserPool.ts` exists and exports `BrowserPoolProvider`, `PooledContext`, `BrowserPoolMetrics` types
2. `open-sse/services/browserPool.ts` becomes a thin stub with zero Playwright imports
3. `packages/browser-pool/` contains all extracted implementation (browserPool, browserBackedChat, grokClearance)
4. Core typecheck (`npm run typecheck:core`) passes with 0 errors **without** the browser-pool package installed
5. Core typecheck passes with the package installed
6. All existing tests pass when the browser-pool package is installed
7. `poolTools.ts` `omniroute_browser_pool_status` tool works end-to-end when the package is installed
8. Graceful degradation: when the package is absent, `getBrowserPoolStatus()` returns `{ enabled: false }` without crashing
9. Playwright is moved from root `dependencies` to optional/peer in the extracted package
10. Documentation updated in `docs/reference/ENVIRONMENT.md`
## Expected Test Plan
- Unit tests for the stub fallback path (simulate import failure, verify graceful degradation)
- Unit tests moved to the extracted package
- Verify `tests/unit/browser-pool-optional-import.test.ts` passes (still validates cloakbrowser isn't statically resolved)
- Verify `tests/unit/browserPool-proxy.test.ts` passes
- Verify `tests/unit/browserBackedChat-matcher.test.ts` passes
- E2E: `npm run typecheck:core` without the package installed → 0 errors
- E2E: `npm run test:coverage` (with package installed) → existing coverage gates pass
## Additional Context
Current dependency graph (simplified):
```
open-sse/services/browserPool.ts (502 LOC, singleton Playwright/CloakBrowser pool)
├── open-sse/services/browserBackedChat.ts (270 LOC, browser-backed chat runner)
│ ├── open-sse/executors/claude-web.ts (imports tryBackedChat)
│ └── open-sse/executors/duckduckgo-web.ts (imports tryBackedChat)
├── open-sse/services/grokClearance.ts (84 LOC, CF clearance via browser)
└── open-sse/mcp-server/tools/poolTools.ts (imports getBrowserPoolMetrics)
Standalone Playwright users (separate, future phases):
├── open-sse/services/claudeTurnstileSolver.ts (212 LOC, static Playwright type imports)
├── open-sse/services/inAppLoginService.ts (257 LOC, own browser lifecycle)
└── open-sse/executors/gemini-web.ts (553 LOC, private Playwright path)
Kill switches: OMNIROUTE_BROWSER_POOL, WEB_COOKIE_USE_BROWSER (both env vars)
```
Total extracted in Phase 1: ~856 LOC, 3 files.
Total deferred to Phase 2/3: ~1022 LOC, 4 files.
This is the first pluginization step. Future targets (separate issues): memory/compression plugin, additional provider support extraction.
## Related References
- PR #8219 (model catalog connection filter + cache TTL) — same baseline `release/v3.8.49`
- `docs/reference/ENVIRONMENT.md` — browser pool env vars documentation
- Plugin system docs at `docs/PLUGINS.md` — existing PluginManager (not used here, referenced for contrast)

View File

@@ -414,6 +414,7 @@ detection above).
| `OMNIROUTE_HTTP_TIMEOUT_MS` | `30000` | `bin/cli/api.mjs` | Per-attempt HTTP timeout (ms) for CLI → server requests. |
| `OMNIROUTE_VERBOSE` | `0` | `bin/cli/api.mjs` | Set to `1` to print retry/backoff diagnostics to stderr during CLI commands. |
| `OMNIROUTE_PLUGIN_PATH` | _(unset)_ | `bin/cli/plugins.mjs` | Custom directory for CLI plugin discovery (`omniroute-cmd-*` packages). Defaults to `~/.omniroute/plugins/` when unset. |
| `OMNIROUTE_PLUGINS_ALLOW_EXEC` | `0` | `src/lib/plugins/pluginWorker.ts` | Set to `1` to allow plugins to request the `exec` permission (spawn child processes from the worker sandbox). Local operator only. |
---

View File

@@ -167,21 +167,21 @@ The Auto-Combo Engine dynamically selects the best provider/model for each reque
> Source: [diagrams/auto-combo-12factor.mmd](../diagrams/auto-combo-12factor.mmd) (regenerate via `npm run docs:render-diagrams`). Diagram/filename predate the `cacheAffinity` factor added by #8008 and still show 12 factors.
| Factor | Default Weight | Description |
| :-------------------- | :------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `health` | 0.20 | Health score from circuit breaker (CLOSED=1.0, HALF_OPEN=0.5, OPEN=0.0) |
| `quota` | 0.15 | Remaining quota / rate-limit headroom [0..1] |
| `costInv` | 0.15 | Inverse **blended** cost (60% input + 40% output token price, normalized) — cheaper = higher score |
| `latencyInv` | 0.12 | Inverse p95 latency normalized to pool — faster = higher score |
| `taskFit` | 0.08 | Task-type fitness (coding, review, planning, analysis, debugging, docs) |
| `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 |
| `specificityMatch` | 0.05 | Match between request specificity (manifest hint) and model tier |
| `contextAffinity` | 0.05 | Affinity between the request's context-window need and the model's context window |
| `connectionDensity` | 0.05 | Spreads load across connections of the same provider (anti-concentration) |
| Factor | Default Weight | Description |
| :-------------------- | :------------- | :------------------------------------------------------------------------------------------------- |
| `health` | 0.20 | Health score from circuit breaker (CLOSED=1.0, HALF_OPEN=0.5, OPEN=0.0) |
| `quota` | 0.15 | Remaining quota / rate-limit headroom [0..1] |
| `costInv` | 0.15 | Inverse **blended** cost (60% input + 40% output token price, normalized) — cheaper = higher score |
| `latencyInv` | 0.12 | Inverse p95 latency normalized to pool — faster = higher score |
| `taskFit` | 0.08 | Task-type fitness (coding, review, planning, analysis, debugging, docs) |
| `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 |
| `specificityMatch` | 0.05 | Match between request specificity (manifest hint) and model tier |
| `contextAffinity` | 0.05 | Affinity between the request's context-window need and the model's context window |
| `connectionDensity` | 0.05 | Spreads load across connections of the same provider (anti-concentration) |
| `cacheAffinity` | 0.00 | Rendezvous-hash affinity toward the connection likeliest to already hold this request's prompt-cache prefix (`open-sse/services/combo/promptCacheAffinity.ts`); disabled by default (#8008) |
| `resetWindowAffinity` | 0.00 | Bias toward connections whose quota reset window is favorable (disabled by default) |
| `resetWindowAffinity` | 0.00 | Bias toward connections whose quota reset window is favorable (disabled by default) |
**Sum:** `0.20 + 0.15 + 0.15 + 0.12 + 0.08 + 0.05 + 0.05 + 0.05 + 0.05 + 0.05 + 0.05 + 0.00 + 0.00 = 1.0` (validated by `validateWeights()`).
@@ -215,11 +215,11 @@ combo's stored config. These apply only to the `auto` strategy and only for the
that carries them; the combo's saved `modePack`/`budgetCap`/`budgetFallback` are used
when the header is absent.
| Header | Accepts | Effect |
| :---------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `X-OmniRoute-Mode` | a preset alias (`fast`, `balanced`, `quality`, `cheap`, `reliable`, `offline`) or a raw pack name (`ship-fast`, `cost-saver`, `quality-first`, `offline-friendly`, `reliability-first`) | Overrides the scoring weights for this request. `balanced`/`default` force the default weights (no pack). Unknown values are ignored (config preserved). |
| `X-OmniRoute-Budget` | a positive number (max USD per request) | Hard cost ceiling: candidates whose estimated cost exceeds it are filtered before selection. What happens when **every** candidate exceeds it is controlled by `X-OmniRoute-Budget-Fallback` below. |
| `X-OmniRoute-Budget-Fallback` | `cheapest` (default, aliases: `cheapest-viable`, `soft`) or `strict` (aliases: `block`, `hard`) | `cheapest`: falls back to the globally cheapest candidate even though it still exceeds the cap (legacy behavior). `strict`: refuses to select — the request fails fast with `HTTP 402` instead of silently overspending. Unknown values are ignored. |
| Header | Accepts | Effect |
| :----------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `X-OmniRoute-Mode` | a preset alias (`fast`, `balanced`, `quality`, `cheap`, `reliable`, `offline`) or a raw pack name (`ship-fast`, `cost-saver`, `quality-first`, `offline-friendly`, `reliability-first`) | Overrides the scoring weights for this request. `balanced`/`default` force the default weights (no pack). Unknown values are ignored (config preserved). |
| `X-OmniRoute-Budget` | a positive number (max USD per request) | Hard cost ceiling: candidates whose estimated cost exceeds it are filtered before selection. What happens when **every** candidate exceeds it is controlled by `X-OmniRoute-Budget-Fallback` below. |
| `X-OmniRoute-Budget-Fallback` | `cheapest` (default, aliases: `cheapest-viable`, `soft`) or `strict` (aliases: `block`, `hard`) | `cheapest`: falls back to the globally cheapest candidate even though it still exceeds the cap (legacy behavior). `strict`: refuses to select — the request fails fast with `HTTP 402` instead of silently overspending. Unknown values are ignored. |
```bash
# Force the fastest profile, cap this request at $0.05, and hard-block instead of overspending
@@ -240,27 +240,27 @@ resolved values feed the engine's existing `config.modePack` / `config.budgetCap
OmniRoute's combo engine supports **19 routing strategies** (declared in `src/shared/constants/routingStrategies.ts``ROUTING_STRATEGY_VALUES`). The Auto Combo engine itself is exposed under the `auto` strategy; the others are available for persisted combos.
| Strategy | Description |
| :------------------ | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `priority` | First-target ordered list with explicit priority |
| `weighted` | Weighted random by per-target weight |
| `round-robin` | Cycle through targets in order |
| `context-relay` | Hand off context across targets (long conversations) |
| `fill-first` | Fill each target's quota before moving to next |
| `p2c` | Power-of-2-choices random load balancing |
| `random` | Uniform random selection |
| `least-used` | Pick target with lowest current load |
| `cost-optimized` | Minimize $ per request given catalog pricing |
| `reset-aware` ⭐ | Prioritize by quota reset time — short reset windows ranked higher |
| `reset-window` | Prefer targets whose quota window resets soonest |
| `headroom` | Pick the target with the most remaining quota headroom |
| `strict-random` | Random without deduplication of repeats |
| `auto` | Use Auto Combo scoring (9-factor) — **recommended** |
| `lkgp` | Last-Known-Good Path (sticky route to last successful target) |
| `context-optimized` | Pick target with best fit for current context size |
| Strategy | Description |
| :------------------ | :--------------------------------------------------------------------------------------------------------------------------- |
| `priority` | First-target ordered list with explicit priority |
| `weighted` | Weighted random by per-target weight |
| `round-robin` | Cycle through targets in order |
| `context-relay` | Hand off context across targets (long conversations) |
| `fill-first` | Fill each target's quota before moving to next |
| `p2c` | Power-of-2-choices random load balancing |
| `random` | Uniform random selection |
| `least-used` | Pick target with lowest current load |
| `cost-optimized` | Minimize $ per request given catalog pricing |
| `reset-aware` ⭐ | Prioritize by quota reset time — short reset windows ranked higher |
| `reset-window` | Prefer targets whose quota window resets soonest |
| `headroom` | Pick the target with the most remaining quota headroom |
| `strict-random` | Random without deduplication of repeats |
| `auto` | Use Auto Combo scoring (9-factor) — **recommended** |
| `lkgp` | Last-Known-Good Path (sticky route to last successful target) |
| `context-optimized` | Pick target with best fit for current context size |
| `cache-optimized` | Reorder targets by prompt-cache affinity — the connection likeliest to already hold this request's cached prefix is tried first (`open-sse/services/combo/promptCacheAffinity.ts`, #8008) |
| `fusion` 🧬 | Fan out to a panel of models in parallel, then synthesize one answer via a judge (see below) |
| `pipeline` | Run targets sequentially, threading each step's output into the next step's input; only the final answer is returned (#6396) |
| `fusion` 🧬 | Fan out to a panel of models in parallel, then synthesize one answer via a judge (see below) |
| `pipeline` | Run targets sequentially, threading each step's output into the next step's input; only the final answer is returned (#6396) |
⭐ = New in v3.8.0 · 🧬 = New in v3.8.36
@@ -679,11 +679,11 @@ See `docs/marketing/TIERS.md` for tier definitions and provider classification.
### Deterministic routing-decision matrix (`npm run test:combo:matrix`)
`tests/integration/combo-matrix/*.test.ts` proves the routing **decision** of all 19
`tests/integration/combo-matrix/*.test.ts` proves the routing **decision** of all 18
public strategies end-to-end through the real combo pipeline with a mocked upstream.
Coverage includes:
- All 19 `ROUTING_STRATEGY_VALUES` strategies (ordered, weighted, cost, context, fusion, …).
- All 18 `ROUTING_STRATEGY_VALUES` strategies (ordered, weighted, cost, context, fusion, …).
- `quota-share` (internal) end-to-end: DRR fairness + saturation deprioritization via the
real `selectQuotaShareTarget` seam (`registerQuotaFetcher` / `setLKGP` /
`__setHeadroomSaturationFetcherForTests`).

View File

@@ -1,91 +0,0 @@
# agentrouter.org WAF (Web Application Firewall)
The `agentrouter` upstream gateway runs a keyword-based content filter on
`messages[].content`. The filter is partially deterministic (always blocks
certain phrases) and partially probabilistic (burst-sensitive — becomes
more aggressive after rapid requests, recovers after a cooldown).
When the WAF blocks a request it returns:
```
HTTP/1.1 400 Bad Request
{"error":{"code":"content-blocked","message":"content-blocked (request id: ...)","param":"","type":"agent_router_api_error"}}
```
## Scope of the filter
The WAF inspects `messages[].content` only. It does **not** inspect:
- The `system` prompt
- Structured content blocks (`tool_result`, `tool_use`, `thinking`, `image`)
- Tool `description` and `input_schema` fields
- Request metadata, headers, or model id
## Always-blocked patterns (case-insensitive)
| Pattern | Notes |
|-------------------------------|----------------------------------------|
| Any `Lorem ipsum` variant | Full Latin lorem vocabulary is blocked |
| `language model` (alone) | "the language model" and "large language model" pass |
| `virtual assistant` | "AI assistant" passes |
| `I'm here to help` | "here to help" alone also blocks |
| `Claude, made by Anthropic` | Full phrase only |
## Almost-always-blocked patterns
| Pattern | Notes |
|-------------------|---------------------------------------------------------|
| `placeholder` | When it stands alone (not as a parameter name, etc.) |
| `dummy data` | Common seed phrase for fixtures |
| `foo bar baz` | Canonical placeholder phrase |
| Repeated short tokens (`AAA BBB CCC`, `test test test`) | Detector for keyword stuffing |
## Behavior under load
After ~5 rapid requests in a short window, the WAF begins blocking content
that would normally pass. The bucket relaxes after ~510 seconds of idle
time. This is the same IP-and-key-bound rate limiter that causes
intermittent `400 content-blocked` errors when Claude Code or Codex CLI
makes multiple tool-use / message-send calls in quick succession.
## Mitigations already applied in OmniRoute
1. **`open-sse/services/wafRateLimit.ts`** — burst guard that enforces a
500 ms minimum gap between outbound requests to any `agentrouter:*`
URL. The gap is well below human perception of latency and prevents
the WAF from activating on normal traffic.
2. **`BaseExecutor.WAF_RETRY_CONFIG`** — when an upstream returns
`400 content-blocked`, the executor retries the same URL with
exponential backoff (1.5 s, 3.0 s, max 2 attempts). After the backoff
the WAF usually relaxes and the retry succeeds.
3. **`tests/unit/compression/harness.test.ts`** — the test fixture
`longInput` was changed from `"lorem ipsum dolor sit amet ".repeat(40)`
to `"example content for testing purposes ".repeat(40)` so that when
Claude Code reads this file via the `Read` tool, the file contents
do not flow back through a `tool_result` block and trip the WAF.
## Guidance for prompts and tool output
If a Claude Code or Codex CLI session repeatedly hits
`400 content-blocked`, check the most recent user message and the most
recent tool result for any of the patterns above and rephrase. Common
workarounds:
- Replace `Lorem ipsum …` with `example text …` or the actual content
the test or fixture is trying to model.
- Replace `placeholder` (when standing alone) with `example value`,
`sample value`, or the real value.
- Replace `language model` with `large language model` or `the model`.
- Replace `dummy data` with `sample data` or realistic seed values.
- Replace `I'm here to help` / `here to help` with a more specific
opener (e.g. "I'll review the file you mentioned").
## Reporting the false positives upstream
The current filter is overly aggressive — it blocks "Lorem ipsum" in
`tool_result` blocks even though the operator clearly did not intend to
inject a prompt. Operators who want this fixed at the source should
contact `agentrouter.org` to report the false positives. The blocklist
above is the empirical result of probing the upstream as of 2026-08-03.

View File

@@ -13,29 +13,6 @@ const TO_NUMBER_RESTRICTION = {
"canonical coercion shape and the `toNumberOrNull`/`toNumberArray` variants.",
};
const LOCAL_DB_IMPORT_RESTRICTION = {
regex: "^(?:@/lib/localDb(?:\\.ts)?|(?:\\.\\.?/)+(?:lib/)?localDb(?:\\.ts)?)$",
message:
"The localDb compatibility barrel is restricted — import the owning domain module " +
"from `@/lib/db/` instead.",
};
const EXECUTOR_IMPORT_RESTRICTION = {
regex: "^(?:@omniroute/)?open-sse/executors(?:/|$)",
message:
"Executor implementations must stay behind an open-sse handler or service boundary.",
};
const PROP_TYPES_RESTRICTION = {
name: "prop-types",
message: "PropTypes are deprecated. Use TypeScript types/interfaces instead.",
};
const IMPORT_BOUNDARY_RESTRICTIONS = {
paths: [PROP_TYPES_RESTRICTION],
patterns: [LOCAL_DB_IMPORT_RESTRICTION],
};
/** @type {import("eslint").Linter.Config[]} */
const eslintConfig = [
...nextVitals,
@@ -62,32 +39,15 @@ const eslintConfig = [
"no-eval": "error",
"no-implied-eval": "error",
"no-new-func": "error",
"no-restricted-imports": ["error", IMPORT_BOUNDARY_RESTRICTIONS],
},
},
// G14: DB internals may use the compatibility barrel while it is decomposed; all
// other source files must import the owning src/lib/db domain module directly.
{
files: ["src/lib/db/**/*.{ts,tsx,js,jsx}"],
rules: {
"no-restricted-imports": [
"error",
{
paths: [PROP_TYPES_RESTRICTION],
},
],
},
},
// G14: App routes/components must delegate provider execution through handlers or
// services instead of reaching into executor implementations.
{
files: ["src/app/**/*.{ts,tsx,js,jsx}"],
rules: {
"no-restricted-imports": [
"error",
{
...IMPORT_BOUNDARY_RESTRICTIONS,
patterns: [LOCAL_DB_IMPORT_RESTRICTION, EXECUTOR_IMPORT_RESTRICTION],
paths: [
{
name: "prop-types",
message: "PropTypes are deprecated. Use TypeScript types/interfaces instead.",
},
],
},
],
},

View File

@@ -1,9 +1,4 @@
import {
DEFAULT_CODEX_CLIENT_VERSION,
getCodexCliRsHeaders as buildCodexCliRsHeaders,
} from "@/shared/constants/codexClient";
export { DEFAULT_CODEX_CLIENT_VERSION } from "@/shared/constants/codexClient";
const DEFAULT_CODEX_CLIENT_VERSION = "0.144.1";
const DEFAULT_CODEX_USER_AGENT_PLATFORM = "Windows 10.0.26200";
const DEFAULT_CODEX_USER_AGENT_ARCH = "x64";
const CODEX_VERSION_OVERRIDE_ENV = "CODEX_CLIENT_VERSION";
@@ -47,10 +42,6 @@ export function getCodexDefaultHeaders(): Record<string, string> {
};
}
export function getCodexCliRsHeaders(): Record<string, string> {
return buildCodexCliRsHeaders(getCodexClientVersion());
}
export function normalizeCodexSessionId(value: unknown): string | null {
if (typeof value !== "string") return null;
const normalized = value.trim();

View File

@@ -19,9 +19,10 @@ import type { FreeModelBudget } from "./freeModelCatalog.ts";
export const FREE_CATALOG_CURATED_AT = "2026-07-22";
export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
{ provider: "agentrouter", modelId: "claude-opus-4-8", displayName: "Claude Opus 4.8", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" },
{ provider: "agentrouter", modelId: "claude-opus-5", displayName: "Claude Opus 5", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" },
{ provider: "agentrouter", modelId: "gpt-5.6-sol", displayName: "GPT-5.6 Sol", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" },
{ provider: "agentrouter", modelId: "claude-opus-4-6", displayName: "Claude 4.6 Opus", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" },
{ provider: "agentrouter", modelId: "claude-haiku-4-5-20251001", displayName: "Claude 4.5 Haiku", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" },
{ provider: "agentrouter", modelId: "glm-5.1", displayName: "GLM 5.1", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" },
{ provider: "agentrouter", modelId: "deepseek-v3.2", displayName: "DeepSeek V3.2", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" },
{ provider: "agy", modelId: "claude-opus-4-6-thinking", displayName: "Claude Opus 4.6 (Thinking)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" },
{ provider: "agy", modelId: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6 (Thinking)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" },
{ provider: "agy", modelId: "gemini-3.1-pro-low", displayName: "Gemini 3.1 Pro (Low)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" },

View File

@@ -11,7 +11,6 @@ import { KIE_IMAGE_MODELS } from "./providers/registry/kie/imageModels.ts";
import { FREEPIK_IMAGE_PROVIDER } from "./providers/registry/freepik/index.ts";
import { STABILITY_AI_IMAGE_MODELS } from "./providers/registry/stability-ai/imageModels.ts";
import { GEMINI_IMAGEN_PROVIDER } from "./providers/registry/gemini/imageModels.ts";
import { CHEAPERINFERENCE_IMAGE_PROVIDER } from "./providers/registry/cheaperinference/imageModels.ts";
interface ImageModelEntry {
id: string;
@@ -715,13 +714,6 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderConfig> = {
supportedSizes: ["1:1", "16:9", "9:16", "4:3", "3:4", "1024x1024", "1792x1024", "1024x1792"],
},
// Cheaper Inference (OSS-sponsor gateway). Declared AFTER adobe-firefly on
// purpose: it shares the nano-banana-pro / nano-banana-2 ids, and parseImageModel
// resolves a bare id by first-match over this object's iteration order, so
// Firefly keeps the bare ids and these are prefix-only. See the module for the
// full collision note.
cheaperinference: CHEAPERINFERENCE_IMAGE_PROVIDER,
// Keep Bailian Coding Plan after existing duplicate model owners so adding
// explicit `bailian-coding-plan/` and `bcp/` routes does not change
// historical bare-model routing.

View File

@@ -117,7 +117,6 @@ import { uncloseaiProvider } from "./registry/uncloseai/index.ts";
import { nscaleProvider } from "./registry/nscale/index.ts";
import { chatgpt_webProvider } from "./registry/chatgpt-web/index.ts";
import { openrouterProvider } from "./registry/openrouter/index.ts";
import { cheaperinferenceProvider } from "./registry/cheaperinference/index.ts";
import { openvectaProvider } from "./registry/openvecta/index.ts";
import { orcarouterProvider } from "./registry/orcarouter/index.ts";
import { copilot_webProvider } from "./registry/copilot-web/index.ts";
@@ -338,7 +337,6 @@ export const REGISTRY: Record<string, RegistryEntry> = {
nscale: nscaleProvider,
"chatgpt-web": chatgpt_webProvider,
openrouter: openrouterProvider,
cheaperinference: cheaperinferenceProvider,
openvecta: openvectaProvider,
orcarouter: orcarouterProvider,
"copilot-web": copilot_webProvider,

View File

@@ -1,5 +1,4 @@
import type { RegistryEntry } from "../../shared.ts";
import { getCodexCliRsHeaders } from "../../../codexClient.ts";
export const agentrouterProvider: RegistryEntry = {
id: "agentrouter",
@@ -9,22 +8,6 @@ export const agentrouterProvider: RegistryEntry = {
baseUrl: "https://agentrouter.org/v1/messages",
authType: "apikey",
authHeader: "x-api-key",
alternateFormats: [
{
format: "openai",
baseUrl: "https://agentrouter.org/v1/chat/completions",
authHeader: "bearer",
headers: getCodexCliRsHeaders(),
label: "OpenAI-compatible (Codex)",
},
{
format: "openai-responses",
baseUrl: "https://agentrouter.org/v1/responses",
authHeader: "bearer",
headers: getCodexCliRsHeaders(),
label: "OpenAI Responses (Codex)",
},
],
defaultContextLength: 128000,
// No static `headers` here: agentrouter now adopts the DYNAMIC Claude-Code
// wire image via CC_WIRE_IMAGE_BUILTINS (#6056) — the fingerprint/headers are
@@ -32,9 +15,10 @@ export const agentrouterProvider: RegistryEntry = {
// own baseUrl + x-api-key auth. A static fingerprint here would drift and
// trip AgentRouter's WAF ("unauthorized client detected").
models: [
{ id: "claude-opus-4-8", name: "Claude Opus 4.8" },
{ id: "claude-opus-5", name: "Claude Opus 5" },
{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol" },
{ id: "claude-opus-4-6", name: "Claude 4.6 Opus" },
{ id: "claude-haiku-4-5-20251001", name: "Claude 4.5 Haiku" },
{ id: "glm-5.1", name: "GLM 5.1" },
{ id: "deepseek-v3.2", name: "DeepSeek V3.2" },
],
passthroughModels: true,
};

View File

@@ -1,34 +0,0 @@
/**
* Cheaper Inference image provider registry entry.
* Extracted into its own module to keep open-sse/config/imageRegistry.ts
* under the file-size cap (god-file decomposition; semantic split) — same
* pattern as FREEPIK_IMAGE_PROVIDER / SEGMIND_IMAGE_PROVIDER.
*
* 3 image models measured from GET /v1/models?type=image on 2026-07-31.
*
* COLLISION NOTE: nano-banana-pro and nano-banana-2 are ALSO adobe-firefly model
* ids. parseImageModel() resolves a bare id by first-match over IMAGE_PROVIDERS
* iteration order, so this entry is spread into that object AFTER adobe-firefly:
* bare `nano-banana-2` keeps routing to Firefly (pre-existing behaviour) and these
* models are reachable only as `cheaperinference/<id>` / `cinf/<id>`. Do NOT add
* IMAGE_MODEL_ALIASES entries for them — that would silently re-route Firefly
* users. Guarded by tests/unit/cheaperinference-image-models.test.ts.
*
* The endpoint ignores `response_format:"url"` and always returns `b64_json`
* (measured twice). The OpenAI image path already handles b64_json; this is not a
* bug to "fix". /v1/images/edits returns 404 upstream, so no edit support.
*/
export const CHEAPERINFERENCE_IMAGE_PROVIDER = {
id: "cheaperinference",
alias: "cinf",
baseUrl: "https://api.cheaperinference.com/v1/images/generations",
authType: "apikey",
authHeader: "bearer",
format: "openai",
models: [
{ id: "grok-imagine", name: "Grok Imagine (Cheaper Inference)" },
{ id: "nano-banana-pro", name: "Nano Banana Pro (Cheaper Inference)" },
{ id: "nano-banana-2", name: "Nano Banana 2 (Cheaper Inference)" },
],
supportedSizes: ["1024x1024", "2048x2048", "4096x4096"],
};

View File

@@ -1,247 +0,0 @@
import type { RegistryEntry, RegistryModel } from "../../shared.ts";
/**
* Cheaper Inference (https://api.cheaperinference.com) — cost-ranked OpenAI-compatible
* gateway, OmniRoute Open Source Friend.
*
* Catalog captured from a live `GET /v1/models` on 2026-07-31 (42 entries: these 39
* `type:"text"` models plus 3 `type:"image"` models that live in imageRegistry.ts —
* sending an image model here returns HTTP 400 "Use POST /v1/images/generations").
* `supportsVision`/`supportsReasoning` mirror each entry's `capabilities` object
* verbatim; they are not inferred from the model name.
*
* The gateway also serves a native `/v1/responses` endpoint (`responsesBaseUrl`).
* It is stateless and REQUIRES `store:false` — see executors/cheaperinference.ts,
* which injects it and resolves the URL from the per-model `targetFormat` tag.
*/
export const CHEAPERINFERENCE_MODELS: RegistryModel[] = [
{ id: "aion-labs.aion-2-0", name: "Aion 2.0", supportsReasoning: true, toolCalling: true },
{
id: "claude-fable-5",
name: "Claude Fable 5",
supportsVision: true,
supportsReasoning: true,
toolCalling: true,
},
{
id: "claude-haiku-4.5",
name: "Claude Haiku 4.5",
supportsVision: true,
supportsReasoning: true,
toolCalling: true,
},
{
id: "claude-opus-4-7-fast",
name: "Claude Opus 4.7 Fast",
supportsVision: true,
supportsReasoning: true,
toolCalling: true,
},
{
id: "claude-opus-4-8-fast",
name: "Claude Opus 4.8 Fast",
supportsVision: true,
supportsReasoning: true,
toolCalling: true,
},
{
id: "claude-opus-4.5",
name: "Claude Opus 4.5",
supportsVision: true,
supportsReasoning: true,
toolCalling: true,
},
{
id: "claude-opus-4.6",
name: "Claude Opus 4.6",
supportsVision: true,
supportsReasoning: true,
toolCalling: true,
},
{
id: "claude-opus-4.7",
name: "Claude Opus 4.7",
supportsVision: true,
supportsReasoning: true,
toolCalling: true,
},
{
id: "claude-opus-4.8",
name: "Claude Opus 4.8",
supportsVision: true,
supportsReasoning: true,
toolCalling: true,
},
{
id: "claude-opus-5",
name: "Claude Opus 5",
supportsVision: true,
supportsReasoning: true,
toolCalling: true,
},
{
id: "claude-opus-5-fast",
name: "Claude Opus 5 Fast",
supportsVision: true,
supportsReasoning: true,
toolCalling: true,
},
{
id: "claude-sonnet-4.5",
name: "Claude Sonnet 4.5",
supportsVision: true,
supportsReasoning: true,
toolCalling: true,
},
{
id: "claude-sonnet-4.6",
name: "Claude Sonnet 4.6",
supportsVision: true,
supportsReasoning: true,
toolCalling: true,
},
{
id: "claude-sonnet-5",
name: "Claude Sonnet 5",
supportsVision: true,
supportsReasoning: true,
toolCalling: true,
},
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true, toolCalling: true },
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true, toolCalling: true },
{
id: "gemini-2.5-flash",
name: "Gemini 2.5 Flash",
supportsVision: true,
supportsReasoning: true,
toolCalling: true,
},
{
id: "gemini-3-5-flash",
name: "Gemini 3.5 Flash",
supportsVision: true,
supportsReasoning: true,
toolCalling: true,
},
{
id: "gemini-3-flash-preview",
name: "Gemini 3 Flash Preview",
supportsVision: true,
supportsReasoning: true,
toolCalling: true,
},
{
id: "gemini-3.1-flash-lite",
name: "Gemini 3.1 Flash Lite",
supportsVision: true,
supportsReasoning: true,
toolCalling: true,
},
{
id: "gemini-3.1-pro",
name: "Gemini 3.1 Pro",
supportsVision: true,
supportsReasoning: true,
toolCalling: true,
},
{
id: "gemini-3.1-pro-preview",
name: "Gemini 3.1 Pro Preview",
supportsReasoning: true,
toolCalling: true,
},
{ id: "glm-4.5", name: "GLM-4.5", supportsReasoning: true, toolCalling: true },
{ id: "glm-4.5-air", name: "GLM-4.5 Air", supportsReasoning: true, toolCalling: true },
{ id: "glm-4.6", name: "GLM-4.6", supportsReasoning: true, toolCalling: true },
{ id: "glm-4.7", name: "GLM-4.7", supportsReasoning: true, toolCalling: true },
{ id: "glm-5", name: "GLM-5", supportsReasoning: true, toolCalling: true },
{ id: "glm-5.1", name: "GLM-5.1", supportsReasoning: true, toolCalling: true },
{ id: "glm-5.2", name: "GLM-5.2", supportsReasoning: true, toolCalling: true },
{
id: "google/gemini-3.5-flash-lite",
name: "Gemini 3.5 Flash Lite",
supportsVision: true,
supportsReasoning: true,
toolCalling: true,
},
// The GPT-5.x family is tagged for the gateway's native /v1/responses endpoint —
// that is the surface OpenAI-family clients (Codex-style) expect for tool loops.
{
id: "gpt-5.4",
name: "GPT-5.4",
supportsVision: true,
supportsReasoning: true,
toolCalling: true,
targetFormat: "openai-responses",
},
{
id: "gpt-5.4-mini",
name: "GPT-5.4 Mini",
supportsVision: true,
supportsReasoning: true,
toolCalling: true,
targetFormat: "openai-responses",
},
{
id: "gpt-5.5",
name: "GPT-5.5",
supportsVision: true,
supportsReasoning: true,
toolCalling: true,
targetFormat: "openai-responses",
},
{
id: "gpt-5.6-luna",
name: "GPT-5.6 Luna",
supportsVision: true,
supportsReasoning: true,
toolCalling: true,
targetFormat: "openai-responses",
},
{
id: "gpt-5.6-sol",
name: "GPT-5.6 Sol",
supportsVision: true,
supportsReasoning: true,
toolCalling: true,
targetFormat: "openai-responses",
},
{
id: "gpt-5.6-terra",
name: "GPT-5.6 Terra",
supportsVision: true,
supportsReasoning: true,
toolCalling: true,
targetFormat: "openai-responses",
},
{
id: "grok-4.5",
name: "Grok 4.5",
supportsVision: true,
supportsReasoning: true,
toolCalling: true,
},
{
id: "kimi-k3",
name: "Kimi K3",
supportsVision: true,
supportsReasoning: true,
toolCalling: true,
},
{ id: "minimax-m2.7", name: "MiniMax M2.7", supportsReasoning: true, toolCalling: true },
];
export const cheaperinferenceProvider: RegistryEntry = {
id: "cheaperinference",
alias: "cinf",
format: "openai",
executor: "cheaperinference",
baseUrl: "https://api.cheaperinference.com/v1/chat/completions",
// The gateway serves a native, STATELESS /v1/responses endpoint alongside
// /v1/chat/completions. Consumed by CheaperInferenceExecutor.buildUrl for the
// models tagged targetFormat: "openai-responses" above.
responsesBaseUrl: "https://api.cheaperinference.com/v1/responses",
authType: "apikey",
authHeader: "bearer",
models: CHEAPERINFERENCE_MODELS,
};

View File

@@ -34,7 +34,6 @@ import {
resolveAccountKey,
isFreeVariantModel,
} from "../services/openrouterFreeWindow.ts";
import { gateOutboundRequest } from "../services/wafRateLimit.ts";
import type { PoolConfig } from "../services/sessionPool/types.ts";
import type { Session } from "../services/sessionPool/session.ts";
import { SessionPool } from "../services/sessionPool/sessionPool.ts";
@@ -46,7 +45,6 @@ import {
} from "../services/apiKeyRotator.ts";
import type { KeyHealth } from "../services/apiKeyRotator.ts";
import { getOpenAICompatibleType, isClaudeCodeCompatible } from "../services/provider.ts";
import { usesCcWireImage } from "../services/ccWireImageBuiltins.ts";
import {
runWithOnPersist,
getRefreshLeadMs,
@@ -251,10 +249,7 @@ function collectThinkingConfigs(body: unknown): Array<Record<string, unknown>> {
if (!body || typeof body !== "object") return [];
const root = body as Record<string, unknown>;
const configs: Array<Record<string, unknown>> = [];
const envelopes: unknown[] = [
root.generationConfig,
(root.request as Record<string, unknown> | undefined)?.generationConfig,
];
const envelopes: unknown[] = [root.generationConfig, (root.request as Record<string, unknown> | undefined)?.generationConfig];
for (const env of envelopes) {
if (!env || typeof env !== "object") continue;
const tc = (env as Record<string, unknown>).thinkingConfig;
@@ -450,12 +445,6 @@ export class BaseExecutor {
);
}
protected usesClaudeCodeProtocol(credentials: ProviderCredentials | null): boolean {
if (!isClaudeCodeCompatible(this.provider)) return false;
const format = this.resolveAlternate(credentials)?.format;
return format !== "openai" && format !== "openai-responses";
}
/**
* Resolve the effective API key via extra-keys round-robin rotation.
* Mutates `credentials.providerSpecificData.selectedKeyId` on rotation.
@@ -600,15 +589,6 @@ export class BaseExecutor {
// Intra-URL retry config: retry same URL before falling back to next node
static readonly RETRY_CONFIG = { maxAttempts: 2, delayMs: 2000 };
// WAF (400 content-blocked) retry config: agentrouter.org's WAF is burst-sensitive
// and recovers after a short cooldown. Use exponential backoff with a higher
// starting delay than the generic 429 retry (which is 2s) because the WAF
// needs more time to clear its per-IP suspicion bucket.
static readonly WAF_RETRY_CONFIG = {
maxAttempts: 2,
delayMs: 1500,
backoffMultiplier: 2,
};
// Timeout for receiving the initial upstream response headers. Once the response
// starts streaming, STREAM_IDLE_TIMEOUT_MS / Undici bodyTimeout handle stalls.
static FETCH_START_TIMEOUT_MS = FETCH_TIMEOUT_MS;
@@ -854,16 +834,15 @@ export class BaseExecutor {
);
}
const usesClaudeCodeProtocol = this.usesClaudeCodeProtocol(requestCredentials);
const fingerprintProvider =
usesCcWireImage(this.provider) && !usesClaudeCodeProtocol ? "codex" : this.provider;
const ccRequestDefaults = usesClaudeCodeProtocol
const ccRequestDefaults = isClaudeCodeCompatible(this.provider)
? getClaudeCodeCompatibleRequestDefaults(requestCredentials?.providerSpecificData)
: {};
const shouldForwardExtendedContext =
extendedContext && modelSupportsContext1mBeta(model) && !usesClaudeCodeProtocol;
extendedContext &&
modelSupportsContext1mBeta(model) &&
!isClaudeCodeCompatible(this.provider);
const shouldForwardCcCompatibleContext1m =
usesClaudeCodeProtocol &&
isClaudeCodeCompatible(this.provider) &&
ccRequestDefaults.context1m === true &&
!modelHasNativeContext1m(model);
if (shouldForwardExtendedContext || shouldForwardCcCompatibleContext1m) {
@@ -943,8 +922,8 @@ export class BaseExecutor {
!activeCredentials?.apiKey;
if (
((this.provider === "claude" && (isClaudeCodeClient || hasClaudeOAuthToken)) ||
usesClaudeCodeProtocol) &&
this.provider === "claude" &&
(isClaudeCodeClient || hasClaudeOAuthToken) &&
typeof transformedBody === "object" &&
transformedBody !== null
) {
@@ -1221,11 +1200,6 @@ export class BaseExecutor {
if (ccKeysLower.has(key.toLowerCase())) delete headers[key];
}
Object.assign(headers, ccHeaders);
if (usesCcWireImage(this.provider) && usesClaudeCodeProtocol) {
delete headers["Authorization"];
headers["x-api-key"] =
activeCredentials?.apiKey || activeCredentials?.accessToken || "";
}
delete headers["X-Stainless-Helper-Method"];
// OS/arch follow the host running the signed binary. Runtime version
@@ -1272,7 +1246,7 @@ export class BaseExecutor {
// (tool_result must be in immediately next message).
// Only apply for Claude/Claude-compatible — OpenAI allows results
// spread across multiple subsequent messages.
const isClaude = this.provider === "claude" || usesClaudeCodeProtocol;
const isClaude = this.provider === "claude" || isClaudeCodeCompatible(this.provider);
// For Claude, fixToolAdjacency may strip tool_use blocks whose
// tool_result isn't in the next message; re-run fixToolPairs to
// drop any tool_result orphaned by that strip (discussion #2410).
@@ -1291,7 +1265,7 @@ export class BaseExecutor {
// at this final dispatch point — the single chokepoint every Claude
// routing mode (grouped/raw/combo) and the native passthrough share,
// before fingerprinting and CCH signing serialize the body.
if (this.provider === "claude" || usesClaudeCodeProtocol) {
if (this.provider === "claude" || isClaudeCodeCompatible(this.provider)) {
enforceThinkingTemperature(transformedBody as Record<string, unknown>);
}
@@ -1308,7 +1282,7 @@ export class BaseExecutor {
// `contextEditingDisabled` (set by the 400-fallback) suppresses re-injection
// when a fresh `transformedBody` is built for a retry/fallback URL.
if (
(this.provider === "claude" || usesClaudeCodeProtocol) &&
(this.provider === "claude" || isClaudeCodeCompatible(this.provider)) &&
contextEditing?.enabled &&
!contextEditingDisabled
) {
@@ -1324,17 +1298,17 @@ export class BaseExecutor {
let bodyString = JSON.stringify(transformedBody);
const shouldFingerprint =
isCliCompatEnabled(fingerprintProvider) ||
isCliCompatEnabled(this.provider) ||
(this.provider === "claude" && (isClaudeCodeClient || hasClaudeOAuthToken));
if (shouldFingerprint) {
const fingerprinted = applyFingerprint(fingerprintProvider, headers, transformedBody);
const fingerprinted = applyFingerprint(this.provider, headers, transformedBody);
finalHeaders = fingerprinted.headers;
bodyString = fingerprinted.bodyString;
}
// CCH signing — replaces the cch=00000 placeholder in the billing
// header with an xxHash64 integrity token over the serialized body.
if (usesClaudeCodeProtocol || this.provider === "claude") {
if (isClaudeCodeCompatible(this.provider) || this.provider === "claude") {
bodyString = await signRequestBody(bodyString);
}
@@ -1399,13 +1373,6 @@ export class BaseExecutor {
recordFreeWindowAttempt(openrouterFreeWindowAccountKey);
}
// WAF burst guard: agentrouter.org's content filter becomes more
// aggressive after rapid requests. Enforce a small inter-request gap
// to avoid tripping it. See open-sse/services/wafRateLimit.ts.
if (this.provider === "agentrouter") {
await gateOutboundRequest(`agentrouter:${url}`);
}
let response = await fetchWithStartTimeout(url, fetchOptions);
if (openrouterFreeWindowAccountKey) {
@@ -1429,7 +1396,7 @@ export class BaseExecutor {
contextEditingDisabled = true;
delete (transformedBody as Record<string, unknown>).context_management;
let retryBody = JSON.stringify(transformedBody);
if (usesClaudeCodeProtocol || this.provider === "claude") {
if (isClaudeCodeCompatible(this.provider) || this.provider === "claude") {
retryBody = await signRequestBody(retryBody);
}
log?.debug?.(
@@ -1468,7 +1435,7 @@ export class BaseExecutor {
thinkingBudgetClampedMax = upstreamMax;
if (clampNestedThinkingBudget(transformedBody, upstreamMax)) {
let retryBody = JSON.stringify(transformedBody);
if (usesClaudeCodeProtocol || this.provider === "claude") {
if (isClaudeCodeCompatible(this.provider) || this.provider === "claude") {
retryBody = await signRequestBody(retryBody);
}
log?.info?.(
@@ -1499,7 +1466,7 @@ export class BaseExecutor {
strippedFields.add(offending);
delete (transformedBody as Record<string, unknown>)[offending];
let retryBody = JSON.stringify(transformedBody);
if (usesClaudeCodeProtocol || this.provider === "claude") {
if (isClaudeCodeCompatible(this.provider) || this.provider === "claude") {
retryBody = await signRequestBody(retryBody);
}
log?.debug?.(
@@ -1524,7 +1491,7 @@ export class BaseExecutor {
addParamToBlocklist(this.provider, autoLearned, model);
delete (transformedBody as Record<string, unknown>)[autoLearned];
let retryBody = JSON.stringify(transformedBody);
if (usesClaudeCodeProtocol || this.provider === "claude") {
if (isClaudeCodeCompatible(this.provider) || this.provider === "claude") {
retryBody = await signRequestBody(retryBody);
}
log?.info?.(
@@ -1543,34 +1510,6 @@ export class BaseExecutor {
}
}
// Intra-URL retry: agentrouter.org WAF returns 400 content-blocked
// intermittently (burst-sensitive, recovers after cooldown). Retry the
// same URL with exponential backoff before falling through to the
// 429/401/fallback chain. See docs/security/AGENTROUTER_WAF.md.
if (
!skipUpstreamRetry &&
response.status === HTTP_STATUS.BAD_REQUEST &&
(retryAttemptsByUrl[urlIndex] ?? 0) < BaseExecutor.WAF_RETRY_CONFIG.maxAttempts
) {
const wafErrText = await response
.clone()
.text()
.catch(() => "");
if (/content[_-]blocked/i.test(wafErrText)) {
retryAttemptsByUrl[urlIndex] = (retryAttemptsByUrl[urlIndex] ?? 0) + 1;
const wafAttempt = retryAttemptsByUrl[urlIndex];
const wafBackoff = BaseExecutor.WAF_RETRY_CONFIG.delayMs *
Math.pow(BaseExecutor.WAF_RETRY_CONFIG.backoffMultiplier, wafAttempt - 1);
log?.debug?.(
"WAF_RETRY",
`400 content-blocked intra-retry ${wafAttempt}/${BaseExecutor.WAF_RETRY_CONFIG.maxAttempts} on ${url} — waiting ${wafBackoff}ms`
);
await new Promise((resolve) => setTimeout(resolve, wafBackoff));
urlIndex--; // re-run this urlIndex on the next loop iteration
continue;
}
}
// Intra-URL retry: if 429 and we haven't exhausted per-URL retries, wait and retry the same URL
if (
!skipUpstreamRetry &&

View File

@@ -1,69 +0,0 @@
import { BaseExecutor, type ProviderCredentials } from "./base.ts";
import { PROVIDERS } from "../config/constants.ts";
import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.ts";
/**
* CheaperInferenceExecutor — api.cheaperinference.com.
*
* The gateway is OpenAI-compatible on both surfaces, so everything else comes from
* BaseExecutor. Two provider-specific facts need handling (both measured against the
* live API on 2026-07-31, not inferred from docs):
*
* 1. `/v1/responses` is STATELESS and REQUIRES `store:false`. Omitting it returns
* HTTP 400 ("This Responses-compatible endpoint is stateless. Send store=false…").
* chatCore.ts deletes `store` for every provider except "openai" — a strip shared
* by ~290 providers that must not be special-cased — so we re-add it here, after
* that strip has run. A client-supplied `store:true` is overwritten rather than
* forwarded: the endpoint cannot honour it, and forwarding would 400.
*
* 2. Chat and Responses live at DIFFERENT URLs (unlike providers that switch on a
* path suffix). The per-model `targetFormat` registry tag is the single source of
* truth for which surface a model uses — the same tag chatCore reads to translate
* the body — so resolving the URL from it keeps URL and payload in lockstep.
* Same pattern as executors/xai.ts (9router#2439).
*/
export class CheaperInferenceExecutor extends BaseExecutor {
constructor(provider = "cheaperinference") {
super(provider, PROVIDERS[provider]);
}
/**
* True when this model is served by the native /v1/responses endpoint.
*
* PROVIDER_MODELS is keyed by provider ALIAS ("cinf"), while PROVIDERS is keyed by
* provider ID ("cheaperinference") — so `this.provider` cannot be passed straight
* through the way executors/xai.ts does (there the alias equals the id, which hides
* the distinction). Resolve the alias first or every lookup silently returns null
* and every Responses request 400s upstream.
*/
private usesResponsesEndpoint(model: string): boolean {
const alias = PROVIDER_ID_TO_ALIAS[this.provider] || this.provider;
return getModelTargetFormat(alias, model) === "openai-responses";
}
buildUrl(model: string, _stream: boolean, _urlIndex = 0): string {
if (this.usesResponsesEndpoint(model)) {
return this.config.responsesBaseUrl || this.config.baseUrl;
}
return this.config.baseUrl;
}
transformRequest(
model: string,
body: unknown,
stream: boolean,
credentials: ProviderCredentials
): unknown {
const cleanedBody = super.transformRequest(model, body, stream, credentials);
if (!cleanedBody || typeof cleanedBody !== "object" || Array.isArray(cleanedBody)) {
return cleanedBody;
}
if (!this.usesResponsesEndpoint(model)) {
// Chat Completions rejects unknown params — never add `store` on that surface.
return cleanedBody;
}
return { ...(cleanedBody as Record<string, unknown>), store: false };
}
}
export default CheaperInferenceExecutor;

View File

@@ -55,7 +55,6 @@ import { forwardOpencodeClientHeaders } from "../utils/opencodeHeaders.ts";
import { resolveZaiUrl } from "./default/zaiFormatOverride.ts";
import { acquireNvidiaConcurrencySlot } from "./default/nvidiaConcurrencyGate.ts";
import { resolveAlibabaProviderBaseUrl } from "@/shared/constants/alibabaProviderRegions";
import { usesCcWireImage } from "../services/ccWireImageBuiltins.ts";
import type { PoolConfig } from "../services/sessionPool/types.ts";
@@ -292,10 +291,6 @@ export class DefaultExecutor extends BaseExecutor {
case "minimax":
case "minimax-cn":
return `${this.config.baseUrl}?beta=true`;
case "agentrouter":
return this.usesClaudeCodeProtocol(credentials)
? `${this.config.baseUrl}?beta=true`
: this.config.baseUrl;
case "gemini":
return `${this.config.baseUrl}/${model}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`;
default: {
@@ -418,7 +413,7 @@ export class DefaultExecutor extends BaseExecutor {
applyClineAuthHeaders(headers, credentials, effectiveKey, clientHeaders, false);
break;
default:
if (this.usesClaudeCodeProtocol(credentials)) {
if (isClaudeCodeCompatible(this.provider)) {
const ccRequestDefaults = getClaudeCodeCompatibleRequestDefaults(
credentials?.providerSpecificData
);
@@ -428,10 +423,6 @@ export class DefaultExecutor extends BaseExecutor {
credentials?.providerSpecificData?.ccSessionId,
{ redactThinking: ccRequestDefaults.redactThinking === true }
);
if (usesCcWireImage(this.provider)) {
delete ccHeaders["Authorization"];
ccHeaders["x-api-key"] = effectiveKey || credentials.accessToken || "";
}
// CC nodes are also anthropic-compatible-*, so honor operator custom
// headers here (the early return skips the shared block below).
applyCustomHeaders(ccHeaders, credentials.providerSpecificData?.customHeaders);

View File

@@ -50,7 +50,6 @@ import { PoeWebExecutor } from "./poe-web.ts";
import { VeniceWebExecutor } from "./venice-web.ts";
import { NotionWebExecutor } from "./notion-web.ts";
import { V0VercelWebExecutor } from "./v0-vercel-web.ts";
import { CheaperInferenceExecutor } from "./cheaperinference.ts";
import { KimiWebExecutor } from "./kimi-web.ts";
import { DoubaoWebExecutor } from "./doubao-web.ts";
import { QwenWebExecutor } from "./qwen-web.ts";
@@ -166,8 +165,6 @@ const executors = {
"kimi-coding": new KimiExecutor(), // Alias
moonshot: new MoonshotExecutor(),
kimi: new MoonshotExecutor("kimi"), // Hidden legacy Moonshot provider id
cheaperinference: new CheaperInferenceExecutor(),
cinf: new CheaperInferenceExecutor("cheaperinference"), // Alias
"doubao-web": new DoubaoWebExecutor(),
db: new DoubaoWebExecutor(), // Alias
"qwen-web": new QwenWebExecutor(),
@@ -285,5 +282,4 @@ export { ZenmuxFreeExecutor } from "./zenmux-free.ts";
export { HyperAgentExecutor } from "./hyperagent.ts";
export { XaiExecutor } from "./xai.ts";
export { MoonshotExecutor } from "./moonshot.ts";
export { CheaperInferenceExecutor } from "./cheaperinference.ts";
export { PromptQlExecutor } from "./promptql.ts";

View File

@@ -71,7 +71,6 @@ import { checkHeapPressureGuard } from "../utils/heapPressure.ts";
import { normalizeHeaders } from "../utils/headers.ts";
import { resolveChatCoreRequestFormat } from "./chatCore/requestFormat.ts";
import { resolveChatCoreTargetFormat } from "./chatCore/targetFormat.ts";
import { stripStore, usesClaudeBridge } from "./chatCore/agentRouterProtocol.ts";
import { defaultClaudeToolType } from "./chatCore/claudeToolDefaults.ts";
import { injectSystemPrompt, injectCustomSystemPrompt } from "../services/systemPrompt.ts";
import { translateRequest, needsTranslation } from "../translator/index.ts";
@@ -342,6 +341,7 @@ import { OMNIROUTE_RESPONSE_HEADERS } from "@/shared/constants/headers";
import { getClaudeCodeCompatibleRequestDefaults } from "@/lib/providers/requestDefaults";
import {
buildClaudeCodeCompatibleRequest,
isClaudeCodeCompatibleProvider,
resolveClaudeCodeCompatibleSessionId,
} from "../services/claudeCodeCompatible.ts";
import { setGeminiThoughtSignatureMode } from "../services/geminiThoughtSignatureStore.ts";
@@ -751,7 +751,6 @@ export async function handleChatCore({
provider,
resolvedModel,
apiFormat,
sourceFormat,
customModelTargetFormat,
providerSpecificData: credentials?.providerSpecificData,
});
@@ -1921,7 +1920,7 @@ export async function handleChatCore({
let translatedBody = body;
const isClaudePassthrough = sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE;
const isClaudeCodeCompatible = usesClaudeBridge(provider, targetFormat, credentials);
const isClaudeCodeCompatible = isClaudeCodeCompatibleProvider(provider);
const isClaudeCodeSemanticPassthrough = isClaudeCodeSemanticPassthroughRequest({
provider,
sourceFormat,
@@ -2497,7 +2496,10 @@ export async function handleChatCore({
log?.debug?.("PARAMS", `Renamed max_completion_tokens to max_tokens for ${model}`);
}
stripStore(translatedBody, provider, targetFormat);
// OpenAI's `store` parameter is not supported by most compatible providers and breaks them
if (provider !== "openai" && "store" in translatedBody) {
delete translatedBody.store;
}
// Chat clients may send stream_options.include_usage, but OpenAI Responses
// upstreams (including Azure AI Foundry /responses) reject stream_options.

View File

@@ -1,35 +0,0 @@
/**
* Per-request AgentRouter protocol decisions kept outside the chatCore orchestration god-file.
* AgentRouter exposes Claude Messages, OpenAI Chat, and OpenAI Responses as distinct upstream
* protocols, so its dynamic target format must override the connection's default Claude wire image.
*/
import { isClaudeCodeCompatibleProvider } from "../../services/claudeCodeCompatible.ts";
import { FORMATS } from "../../translator/formats.ts";
export function usesClaudeBridge(
provider: string,
targetFormat: string,
credentials: unknown
): boolean {
const configuredTargetFormat = (
credentials as { providerSpecificData?: { targetFormat?: unknown } | null } | null | undefined
)?.providerSpecificData?.targetFormat;
const effectiveFormat = provider === "agentrouter" ? targetFormat : configuredTargetFormat;
return (
isClaudeCodeCompatibleProvider(provider) &&
effectiveFormat !== FORMATS.OPENAI &&
effectiveFormat !== FORMATS.OPENAI_RESPONSES
);
}
export function stripStore(
body: Record<string, unknown>,
provider: string,
targetFormat: string
): void {
const supportsStore =
provider === "openai" ||
(provider === "agentrouter" && targetFormat === FORMATS.OPENAI_RESPONSES);
if (!supportsStore && "store" in body) delete body.store;
}

View File

@@ -5,8 +5,8 @@
* Pure builder extracted from handleChatCore: derives the per-execution credentials object from the
* resolved request context. Applies the native-Codex passthrough endpoint override, forces
* apiType=responses (and the responses-upstream marker) for Azure AI Foundry / OCI when the model
* routes to the OpenAI Responses format, synchronizes AgentRouter's per-request alternate protocol,
* and threads the Claude Code session id when present. Side-effect-free.
* routes to the OpenAI Responses format, and threads the Claude Code session id when present.
* Side-effect-free; behaviour is byte-identical to the previous inline closure.
*/
import { getKimiCodeStaticThinkingPolicy } from "../../config/providers/registry/kimi/coding/runtime.ts";
@@ -128,16 +128,6 @@ export function resolveExecutionCredentials(opts: {
providerSpecificData.targetFormat = targetFormat;
}
// AgentRouter exposes Claude, OpenAI Chat, and OpenAI Responses on distinct URLs with distinct
// auth schemes. Keep the executor's URL/header resolution synchronized with chatCore's resolved
// per-request protocol without persisting the inferred selection back to the connection.
if (
provider === "agentrouter" &&
(targetFormat === FORMATS.OPENAI || targetFormat === FORMATS.OPENAI_RESPONSES)
) {
providerSpecificData.targetFormat = targetFormat;
}
applyKimiExecutionMetadata(providerSpecificData, provider, targetFormat, modelInfo);
const withApiType = {

View File

@@ -4,11 +4,10 @@
*
* Pure resolution of the provider alias + the upstream target format used to translate the request:
* apiFormat==="responses" forces OpenAI Responses; otherwise the model's registry target format, then
* the per-model custom override (#2905), then AgentRouter's matching inbound protocol when the
* connection has no explicit override, then the provider default. Returns both `alias` (reused by
* the per-model custom override (#2905), then the provider default. Returns both `alias` (reused by
* the handler when stripping the `alias/` prefix off the upstream model id) and `targetFormat`.
* Side-effect-free; sits alongside the other request-setup resolvers
* (resolveChatCoreRequestSetup / resolveChatCoreRequestFormat).
* Side-effect-free; byte-identical to the previous inline block. Sits alongside the other
* request-setup resolvers (resolveChatCoreRequestSetup / resolveChatCoreRequestFormat).
*/
import { PROVIDER_ID_TO_ALIAS, getModelTargetFormat } from "../../config/providerModels.ts";
@@ -19,38 +18,16 @@ export function resolveChatCoreTargetFormat(opts: {
provider: string;
resolvedModel: string;
apiFormat: string | undefined;
sourceFormat?: string;
customModelTargetFormat: string | undefined;
providerSpecificData: unknown;
}) {
const {
provider,
resolvedModel,
apiFormat,
sourceFormat,
customModelTargetFormat,
providerSpecificData,
} = opts;
const { provider, resolvedModel, apiFormat, customModelTargetFormat, providerSpecificData } = opts;
const alias = PROVIDER_ID_TO_ALIAS[provider] || provider;
const modelTargetFormat = getModelTargetFormat(alias, resolvedModel);
const explicitConnectionTargetFormat = (
providerSpecificData as { targetFormat?: unknown } | null | undefined
)?.targetFormat;
const inferredAgentRouterTargetFormat =
provider === "agentrouter" &&
!(typeof explicitConnectionTargetFormat === "string" && explicitConnectionTargetFormat) &&
(sourceFormat === FORMATS.OPENAI_RESPONSES ||
sourceFormat === FORMATS.OPENAI ||
sourceFormat === FORMATS.CLAUDE)
? sourceFormat
: undefined;
const targetFormat =
apiFormat === "responses"
? FORMATS.OPENAI_RESPONSES
: modelTargetFormat ||
customModelTargetFormat ||
inferredAgentRouterTargetFormat ||
getTargetFormat(provider, providerSpecificData);
: modelTargetFormat || customModelTargetFormat || getTargetFormat(provider, providerSpecificData);
return { alias, targetFormat };
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,68 +0,0 @@
// open-sse/services/combo/strategyDispatch.ts
// Runtime source of truth for the known-symbols combo gate (G1).
//
// HISTÓRICO: `scripts/check/check-known-symbols.ts` (seção 2) costumava descobrir quais
// estratégias de roteamento têm branch de despacho lendo a fonte dos arquivos do combo e
// extraindo literais `strategy === "..."` por regex. Isso quebra quando o despacho vira um
// registry (R0.3): não há mais `strategy === "X"` para casar. Este módulo substitui essa
// enumeração por regex-over-source por uma enumeração EXPLÍCITA em runtime, colada ao lado
// do código de despacho real.
//
// Importamos as funções reais de ordenação/despacho (não apenas strings) para amarrar a
// enumeração ao código vivo: se a maquinaria de despacho for reestruturada ou um módulo
// quebrar, a importação falha no load do gate em vez de casar silenciosamente uma regex
// obsoleta. As chaves em HANDLED_COMBO_STRATEGIES DEVEM casar exatamente o conjunto
// canônico (ROUTING_STRATEGY_VALUES INTERNAL_ROUTING_STRATEGY_VALUES).
//
// Ao adicionar uma estratégia canônica, fie-a no despacho (aqui ou em combo.ts) e inclua-a
// nesta lista; ao remover um branch, retire a entrada — o gate acusa qualquer divergência
// nas duas direções (canonicalSemDespacho / despachoNaoCanonico).
import { applyStrategyOrdering } from "./applyStrategyOrdering.ts";
import { resolveAutoStrategyOrder } from "./resolveAutoStrategy.ts";
import { tryFusionDispatch, tryPipelineDispatch } from "./dispatchPrelude.ts";
import { resolveComboTargetPipeline } from "./targetResolution.ts";
/**
* As funções reais que implementam o despacho/ordenação de estratégias. Referenciadas
* aqui para (a) provar ao gate que a maquinaria resolve e (b) servir de âncora viva para
* a enumeração abaixo — o registry que o R0.3 vai passar a consumir para o branching
* `strategy === ...` nasce destas mesmas funções.
*/
export const COMBO_STRATEGY_DISPATCH_LEAVES = {
applyStrategyOrdering,
resolveAutoStrategyOrder,
tryFusionDispatch,
tryPipelineDispatch,
resolveComboTargetPipeline,
} as const;
/**
* Conjunto exato de estratégias de roteamento que possuem implementação de despacho real.
*
* Cobertura esperada (em `main` do gate): este set IMPLICIT_DEFAULT_STRATEGIES deve
* igualar o canônico. Atualmente todas as 20 estratégias canônicas têm branch — então
* HANDLED_COMBO_STRATEGIES já contém as 20 e IMPLICIT_DEFAULT_STRATEGIES está vazio.
*/
export const HANDLED_COMBO_STRATEGIES: readonly string[] = [
"priority",
"weighted",
"round-robin",
"context-relay",
"fill-first",
"p2c",
"random",
"least-used",
"cost-optimized",
"reset-aware",
"reset-window",
"headroom",
"strict-random",
"auto",
"lkgp",
"context-optimized",
"cache-optimized",
"fusion",
"pipeline",
"quota-share",
] as const;

View File

@@ -1,35 +1,3 @@
/**
* grokClearance.ts — gated browser-backed cf_clearance acquisition for
* grok-web (#8019).
*
* grok.com sits behind Cloudflare Enterprise, which pins `cf_clearance` to
* the client's IP+TLS+UA fingerprint. Pure cookie-replay from
* `grokTlsClient.ts` (TLS-impersonating fetch) cannot forge a fresh
* clearance from a datacenter egress that Cloudflare has already flagged —
* only a real browser solving the challenge natively can mint one bound to
* that egress's own fingerprint.
*
* This module reuses the EXISTING provider-agnostic browser pool
* (`browserPool.ts`, already live for claude-web + duckduckgo-web) rather
* than adding a new Turnstile solver — `claudeTurnstileSolver.ts` is
* claude.ai-specific and does not apply here.
*
* Opt-in only: gated behind `OMNIROUTE_BROWSER_POOL` / `WEB_COOKIE_USE_BROWSER`
* (the same env gate already used by claude-web.ts / duckduckgo-web.ts).
* With the gate off, `acquireFreshGrokClearance` is never called — the
* executor stays on the Step-1 `cloudflare_challenge` classification.
*/
import { acquireBrowserContext, type PooledContext } from "./browserPool.ts";
const GROK_WARMUP_URL = "https://grok.com/";
const GROK_COOKIE_DOMAIN = ".grok.com";
const GROK_POOL_KEY = "grok-web";
/**
* Reads the same opt-in gate as claude-web/duckduckgo-web
* (`WEB_COOKIE_USE_BROWSER` or `OMNIROUTE_BROWSER_POOL`). Off by default.
*/
export function shouldUseGrokBrowserBacked(): boolean {
const flag = process.env.WEB_COOKIE_USE_BROWSER;
if (flag === "1" || flag === "true" || flag === "on") return true;
@@ -37,48 +5,16 @@ export function shouldUseGrokBrowserBacked(): boolean {
return poolFlag === "on" || poolFlag === "1" || poolFlag === "true";
}
type AcquireGrokClearanceFn = (signal?: AbortSignal | null) => Promise<string | null>;
// Test-only injection point — mirrors browserBackedChat.ts's
// __setBrowserBackedChatOverrideForTesting pattern so unit tests can prove
// the gating/wiring without launching a real browser (no chromium in CI).
let acquireOverride: AcquireGrokClearanceFn | null = null;
let grokClearanceAcquireOverride: ((signal?: AbortSignal) => Promise<string | null>) | null = null;
export function __setGrokClearanceAcquireOverrideForTesting(
fn: AcquireGrokClearanceFn | null
fn: ((signal?: AbortSignal) => Promise<string | null>) | null,
): void {
acquireOverride = fn;
grokClearanceAcquireOverride = fn;
}
async function readCfClearanceFromContext(pooled: PooledContext): Promise<string | null> {
const cookies = await pooled.context.cookies(GROK_WARMUP_URL);
const match = cookies.find((c) => c.name === "cf_clearance");
return match?.value || null;
}
async function acquireViaPool(): Promise<string | null> {
try {
const pooled = await acquireBrowserContext(GROK_POOL_KEY, {
cookieDomain: GROK_COOKIE_DOMAIN,
cookieString: null,
warmupUrl: GROK_WARMUP_URL,
});
return await readCfClearanceFromContext(pooled);
} catch {
return null;
}
}
/**
* Acquire a fresh `.grok.com` cf_clearance via the shared browser pool.
* Never throws — resolves to `null` on any failure so callers can fall
* through to the Cloudflare-challenge error rather than crash the request.
*/
export async function acquireFreshGrokClearance(signal?: AbortSignal | null): Promise<string | null> {
if (acquireOverride) return acquireOverride(signal);
try {
return await acquireViaPool();
} catch {
return null;
}
export async function acquireFreshGrokClearance(signal?: AbortSignal): Promise<string | null> {
if (grokClearanceAcquireOverride) return grokClearanceAcquireOverride(signal);
const mod = await import("@omniroute/browser-pool");
return mod.acquireFreshGrokClearance(signal);
}

View File

@@ -120,44 +120,7 @@ for (const [aliasOrId, models] of Object.entries(PROVIDER_MODELS)) {
}
}
const KNOWN_MODEL_IDS = new Set(MODEL_TO_PROVIDERS.keys());
// Bare Codex CLI defaults must always route to the `codex` provider (chatgpt.com
// OAuth) even when other providers that also catalog the model id (e.g.
// `agentrouter`, `openai`) are active. The Codex cookie quota on the user's
// account is the source of truth for capacity, and bare-id requests from
// `codex` (CLI)/`Codex` (web) would otherwise silently fan out to whichever
// provider won the inference race — leaving the user wondering why the
// canonical ChatGPT subscription stopped working. Override per-request by
// prefixing the model id (e.g. `agentrouter/gpt-5.6-sol`,
// `openai/gpt-5.6-sol`) — the prefix path always wins.
export const CODEX_NATIVE_UNPREFIXED_MODELS = new Set([
"codex-auto-review",
"gpt-5.6-sol",
"gpt-5.6-sol-ultra",
"gpt-5.6-sol-max",
"gpt-5.6-sol-xhigh",
"gpt-5.6-sol-high",
"gpt-5.6-sol-medium",
"gpt-5.6-sol-low",
"gpt-5.6-terra",
"gpt-5.6-terra-ultra",
"gpt-5.6-terra-max",
"gpt-5.6-terra-xhigh",
"gpt-5.6-terra-high",
"gpt-5.6-terra-medium",
"gpt-5.6-terra-low",
"gpt-5.6-luna",
"gpt-5.6-luna-max",
"gpt-5.6-luna-xhigh",
"gpt-5.6-luna-high",
"gpt-5.6-luna-medium",
"gpt-5.6-luna-low",
"gpt-5.5",
"gpt-5.5-xhigh",
"gpt-5.5-high",
"gpt-5.5-medium",
"gpt-5.5-low",
"gpt-5.3-codex-spark",
]);
export const CODEX_NATIVE_UNPREFIXED_MODELS = new Set(["codex-auto-review"]);
interface ProviderConnectionLike {
provider?: unknown;
@@ -571,19 +534,7 @@ async function resolveModelByProviderInference(modelId: string, extendedContext:
getActiveSyncedProvidersForModel(modelId),
getPreferClaudeCodeForUnprefixedClaudeModels(),
]);
// #FIX: synced catalogs (populated from `/v1/models` per connection) can
// claim ownership of models the provider does not actually serve (e.g. a
// `kiro` upstream briefly advertising `claude-opus-5` before it was
// vendored into the registry). Without this filter the bare-routing path
// would forward traffic to providers that 404 on the upstream call.
// Auto-discovery still wins when no static registry entry exists for the
// model id — only entries that conflict with the static catalog are dropped.
const staticCatalogProviders = MODEL_TO_PROVIDERS.get(modelId) || [];
const validatedSyncedProviders =
staticCatalogProviders.length > 0
? activeSyncedProviders.filter((p) => staticCatalogProviders.includes(p))
: activeSyncedProviders;
const providers = getInferredProvidersForModel(modelId, validatedSyncedProviders);
const providers = getInferredProvidersForModel(modelId, activeSyncedProviders);
const nonOpenAIProviders = providers.filter((p) => p !== "openai");
// Bare model IDs from Codex CLI do not preserve OmniRoute's `cx/` prefix.

View File

@@ -1,76 +0,0 @@
/**
* wafRateLimit.ts — Burst guard for agentrouter.org upstream WAF.
*
* The agentrouter.org gateway runs a content-filter WAF that becomes more
* aggressive after bursts of requests from the same IP/key, returning
* `400 content-blocked` for requests that would normally pass. After ~5-10
* seconds of cooldown the filter relaxes again.
*
* To avoid tripping the WAF, we serialize outbound calls per provider and
* enforce a minimum inter-request gap. The defaults are conservative and
* meant to be a safety net — the upstream request rate from Claude Code is
* inherently low (one human-paced request at a time), so this guard should
* not affect normal traffic.
*/
import { log } from "../utils/logger.ts";
interface BurstGuardState {
lastSentAt: number;
}
const state = new Map<string, BurstGuardState>();
export interface WafRateLimitConfig {
minGapMs: number;
}
const DEFAULT_CONFIG: WafRateLimitConfig = {
// 500ms is enough to prevent the burst-sensitive WAF from activating
// while staying well below human perception of latency.
minGapMs: 500,
};
let config: WafRateLimitConfig = { ...DEFAULT_CONFIG };
export function configureWafRateLimit(overrides: Partial<WafRateLimitConfig>): void {
config = { ...config, ...overrides };
}
export function getWafRateLimitConfig(): WafRateLimitConfig {
return { ...config };
}
/**
* Wait until at least `minGapMs` has passed since the last call to
* `gateOutboundRequest` for the same `bucketKey`. Safe to call from
* concurrent requests — the lock is held only for the sleep, not across
* the actual upstream fetch.
*
* @param bucketKey Stable identifier for the upstream (e.g. "agentrouter:url").
*/
export async function gateOutboundRequest(bucketKey: string): Promise<void> {
const now = Date.now();
const bucket = state.get(bucketKey);
if (!bucket) {
state.set(bucketKey, { lastSentAt: now });
return;
}
const elapsed = now - bucket.lastSentAt;
const wait = config.minGapMs - elapsed;
if (wait > 0) {
log?.debug?.(
"WAF_RATE_LIMIT",
`Throttling outbound to ${bucketKey} — waiting ${wait}ms (min gap ${config.minGapMs}ms)`
);
await new Promise((resolve) => setTimeout(resolve, wait));
}
state.set(bucketKey, { lastSentAt: Date.now() });
}
/**
* Reset all rate-limit state. Primarily for tests.
*/
export function resetWafRateLimit(): void {
state.clear();
}

View File

@@ -1,103 +0,0 @@
/**
* Functional gateway mirrors (`<gateway-alias>/<original-id>` mirror entries).
*
* /v1/models announces each model under its canonical owner provider
* (`deepseek/deepseek-v4-flash`). But the owner may have NO active credential
* while a passthrough gateway provider (e.g. agentrouter / openrouter) DOES and
* routes the same model. Discovery clients (omp, jcode, etc.) then see a model
* that fails on request, and never the route that works.
*
* This module synthesizes a mirror entry under the functional gateway alias:
*
* <gateway-alias>/<original-id> e.g. agentrouter/deepseek/deepseek-v4-flash
*
* The request path already resolves any known provider prefix
* (open-sse/services/model.ts::resolveProviderAlias), so the mirror is
* immediately routable with no request-side change. Pure synthesis over the
* already key-filtered list — no I/O.
*/
export const FUNCTIONAL_GATEWAY_MIRROR_SUFFIX = " (via ";
export interface FunctionalGatewayMirrorsDeps {
/** Ordered list of passthrough gateway provider ids to consider as mirrors. */
gatewayProviderIds: string[];
/** True when `provider` is a passthrough gateway that can route arbitrary models. */
isGateway(provider: string): boolean;
/** Map a gateway provider id to its catalog alias (e.g. "command-code" -> "cmd"). */
gatewayAlias(provider: string): string;
/** True when `gatewayProvider` has an eligible connection that covers `modelId`. */
gatewayCovers(gatewayProvider: string, modelId: string): boolean;
/** True when `gatewayProvider` has an active credential/connection. */
gatewayHasConnection(gatewayProvider: string): boolean;
/** True when the canonical owner `provider` has an eligible connection for the model. */
canonicalOwnerHasConnection(provider: string): boolean;
}
interface GatewayMirrorCatalogEntry {
id?: unknown;
owned_by?: unknown;
root?: unknown;
name?: unknown;
display_name?: unknown;
[key: string]: unknown;
}
/**
* Append `<gatewayAlias>/<originalId>` mirror entries for every eligible model.
* Returns the original array reference unchanged when nothing is eligible.
*/
export function appendFunctionalGatewayMirrors<T extends GatewayMirrorCatalogEntry>(
models: T[],
deps: FunctionalGatewayMirrorsDeps
): T[] {
if (!Array.isArray(models)) return models;
const aliases: T[] = [];
for (const model of models) {
const id = model.id;
if (typeof id !== "string" || id.length === 0) continue;
const slashIndex = id.indexOf("/");
if (slashIndex <= 0) continue; // no provider prefix to re-home
const owner = id.slice(0, slashIndex);
const modelId = id.slice(slashIndex + 1);
if (!modelId || modelId === id) continue;
// Skip if the canonical owner already has a working connection for this model.
if (deps.canonicalOwnerHasConnection(owner)) continue;
// Find a passthrough gateway that actually routes this model AND has a credential.
let chosenAlias: string | null = null;
let chosenProvider: string | null = null;
for (const gatewayProvider of deps.gatewayProviderIds) {
const alias = deps.gatewayAlias(gatewayProvider);
if (!alias || alias === owner) continue;
if (!deps.isGateway(gatewayProvider)) continue;
if (!deps.gatewayHasConnection(gatewayProvider)) continue;
if (!deps.gatewayCovers(gatewayProvider, modelId)) continue;
chosenAlias = alias;
chosenProvider = gatewayProvider;
break;
}
if (!chosenAlias || !chosenProvider) continue;
const aliasId = `${chosenAlias}/${id}`;
// Skip if the mirror already exists in the list.
if (models.some((m) => m.id === aliasId)) continue;
// Skip if the id already starts with this gateway alias (would double-prefix).
if (id.startsWith(`${chosenAlias}/`)) continue;
const label =
typeof model.name === "string" && model.name ? model.name : modelId;
aliases.push({
...model,
id: aliasId,
root: id,
owned_by: chosenProvider,
display_name: `${label}${FUNCTIONAL_GATEWAY_MIRROR_SUFFIX}${chosenProvider})`,
} as T);
}
return aliases.length > 0 ? [...models, ...aliases] : models;
}

View File

@@ -3,7 +3,6 @@ import { parseSSEDataPayload } from "./streamHelpers.ts";
import {
backfillResponsesCompletedOutput,
normalizeResponsesSseIds,
normalizeResponsesCompletedUsage,
pushUniqueResponsesOutputItems,
stringifyIdValue,
stripResponsesLifecycleEcho,
@@ -175,20 +174,13 @@ function handleResponsesTailPayload(
const outputPayload = textualToolCallBackfilled
? context.toResponsesCompletedWithToolCalls(parsed)
: parsed;
const usageNormalized = normalizeResponsesCompletedUsage(outputPayload);
const stripped = stripResponsesLifecycleEcho(outputPayload);
const backfilled = backfillResponsesCompletedOutput(
outputPayload,
context.passthroughResponsesOutputItems
);
if (
stripped ||
backfilled ||
textualToolCallBackfilled ||
responsesIdsNormalized ||
usageNormalized
) {
if (stripped || backfilled || textualToolCallBackfilled || responsesIdsNormalized) {
output = `data: ${JSON.stringify(outputPayload)}\n\n`;
}

View File

@@ -138,49 +138,6 @@ export function backfillResponsesCompletedOutput(
return true;
}
/**
* Keep the terminal Responses payload compatible with strict clients such as Codex.
* Upstreams may expose only input/output counts (or omit usage entirely), while the
* client deserializer requires all three canonical token fields.
*/
export function normalizeResponsesCompletedUsage(parsed: unknown): boolean {
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;
const obj = parsed as JsonRecord;
if (obj.type !== "response.completed") return false;
if (!obj.response || typeof obj.response !== "object" || Array.isArray(obj.response)) {
return false;
}
const response = obj.response as JsonRecord;
const current =
response.usage && typeof response.usage === "object" && !Array.isArray(response.usage)
? (response.usage as JsonRecord)
: {};
const finiteNumber = (value: unknown): number | null => {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
};
const inputTokens =
finiteNumber(current.input_tokens) ?? finiteNumber(current.prompt_tokens) ?? 0;
const outputTokens =
finiteNumber(current.output_tokens) ?? finiteNumber(current.completion_tokens) ?? 0;
const totalTokens = finiteNumber(current.total_tokens) ?? inputTokens + outputTokens;
const normalized: JsonRecord = {
...current,
input_tokens: inputTokens,
output_tokens: outputTokens,
};
normalized.total_tokens = totalTokens;
const changed =
!response.usage ||
current.input_tokens !== inputTokens ||
current.output_tokens !== outputTokens ||
current.total_tokens !== totalTokens;
response.usage = normalized;
return changed;
}
const RESPONSES_LIFECYCLE_EVENT_TYPES = new Set([
"response.created",
"response.in_progress",

View File

@@ -49,7 +49,6 @@ import {
} from "../services/sessionManager.ts";
import {
backfillResponsesCompletedOutput,
normalizeResponsesCompletedUsage as normalizeUsage,
normalizeResponsesSseIds,
pushUniqueResponsesOutputItems,
stringifyIdValue,
@@ -1582,13 +1581,11 @@ export function createSSEStream(options: StreamOptions = {}) {
parsed,
passthroughResponsesOutputItems
);
const usageNormalized = normalizeUsage(parsed);
if (
stripped ||
backfilled ||
textualToolCallBackfilled ||
responsesIdsNormalized ||
usageNormalized
responsesIdsNormalized
) {
output = `data: ${JSON.stringify(parsed)}\n\n`;
injectedUsage = true;
@@ -2276,6 +2273,7 @@ export function createSSEStream(options: StreamOptions = {}) {
}
clientPayloadCollector.push(bufferedPayload);
// Normalize numeric IDs for final buffered data: chunk (same as transform path)
if (typeof bufferedPayload === "object" && !Array.isArray(bufferedPayload)) {
const flushedParsed = bufferedPayload as JsonRecord;
const flushedType =
@@ -2283,9 +2281,7 @@ export function createSSEStream(options: StreamOptions = {}) {
const isResponses = flushedType.startsWith("response.");
const isClaude = isClaudeEventPayload(flushedParsed);
if (isResponses) {
const idsNormalized = normalizeResponsesSseIds(flushedParsed);
const usageNormalized = normalizeUsage(flushedParsed);
if (idsNormalized || usageNormalized) {
if (normalizeResponsesSseIds(flushedParsed)) {
output = `data: ${JSON.stringify(flushedParsed)}\n\n`;
}
} else if (!isClaude) {

View File

@@ -40,6 +40,7 @@ interface FetchOptions {
body?: unknown;
redirect?: string;
signal?: AbortSignal;
fingerprint?: { userAgent: string; secChUa: string; secChUaPlatform: string };
}
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> | undefined {

112
package-lock.json generated
View File

@@ -10,7 +10,8 @@
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
"open-sse"
"open-sse",
"packages/browser-pool"
],
"dependencies": {
"@aws-sdk/client-bedrock-runtime": "^3.1073.0",
@@ -3692,9 +3693,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3711,9 +3709,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3730,9 +3725,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3749,9 +3741,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3768,9 +3757,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3787,9 +3773,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3806,9 +3789,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3825,9 +3805,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3844,9 +3821,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3869,9 +3843,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3894,9 +3865,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3919,9 +3887,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3944,9 +3909,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3969,9 +3931,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3994,9 +3953,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -4019,9 +3975,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -6330,6 +6283,10 @@
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/@omniroute/browser-pool": {
"resolved": "packages/browser-pool",
"link": true
},
"node_modules/@omniroute/open-sse": {
"resolved": "open-sse",
"link": true
@@ -10739,9 +10696,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -10759,9 +10713,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -10779,9 +10730,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -10799,9 +10747,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -12894,9 +12839,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"optional": true,
"os": [
"linux"
@@ -12910,9 +12852,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"optional": true,
"os": [
"linux"
@@ -12926,9 +12865,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"optional": true,
"os": [
"linux"
@@ -12942,9 +12878,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"optional": true,
"os": [
"linux"
@@ -12958,9 +12891,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"optional": true,
"os": [
"linux"
@@ -12974,9 +12904,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"optional": true,
"os": [
"linux"
@@ -37085,6 +37012,33 @@
"safe-regex": "^2.1.1",
"smol-toml": "1.7.1"
}
},
"packages/browser-pool": {
"name": "@omniroute/browser-pool",
"version": "0.1.0",
"dependencies": {
"playwright": "1.61.1"
},
"devDependencies": {
"@types/node": "^22"
}
},
"packages/browser-pool/node_modules/@types/node": {
"version": "22.20.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"packages/browser-pool/node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "omniroute",
"version": "3.8.50",
"description": "Unified AI router with 290 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.",
"description": "Unified AI router with 160+ providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.",
"type": "module",
"bin": {
"omniroute": "bin/omniroute.mjs",
@@ -47,7 +47,8 @@
"!**/*.spec.tsx"
],
"workspaces": [
"open-sse"
"open-sse",
"packages/browser-pool"
],
"engines": {
"node": ">=22.22.2 <23 || >=24.0.0 <27"
@@ -110,8 +111,6 @@
"test:unit:ci": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial",
"test:unit:ci:shard": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=4096 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=$TEST_SHARD tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=4096 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=$TEST_SHARD \"tests/unit/dashboard/**/*.test.ts\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=4096 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=$TEST_SHARD \"tests/unit/serial/**/*.test.ts\"",
"test:unit:fast": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-isolation=none tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-isolation=none \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial",
"test:scoped": "bash scripts/quality/test-scoped.sh",
"test:scoped:staged": "bash scripts/quality/test-scoped.sh --staged",
"test:unit:shard": "concurrently --kill-others-on-fail -n s1,s2 \"npm:test:unit:shard:1\" \"npm:test:unit:shard:2\"",
"test:unit:shard:1": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=1/2 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=1/2 \"tests/unit/dashboard/**/*.test.ts\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=1/2 \"tests/unit/serial/**/*.test.ts\"",
"test:unit:shard:2": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=2/2 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=2/2 \"tests/unit/dashboard/**/*.test.ts\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=2/2 \"tests/unit/serial/**/*.test.ts\"",
@@ -305,7 +304,6 @@
"smol-toml": "1.7.1",
"socks": "^2.8.7",
"sql.js": "^1.14.1",
"sqlite-vec": "^0.1.9",
"tailwind-merge": "^3.6.0",
"tsx": "^4.23.0",
"undici": "^8.3.0",
@@ -325,7 +323,8 @@
"js-tiktoken": "^1.0.20",
"keytar": "^7.9.0",
"tls-client-node": "^0.2.0",
"wreq-js": "^2.3.1"
"wreq-js": "^2.3.1",
"sqlite-vec": "^0.1.9"
},
"devDependencies": {
"@axe-core/playwright": "^4.11.3",

View File

@@ -0,0 +1,15 @@
{
"name": "@omniroute/browser-pool",
"version": "0.1.0",
"private": true,
"description": "Optional browser pool service for OmniRoute — CloakBrowser and Playwright-backed chat",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"dependencies": {
"playwright": "1.61.1"
},
"devDependencies": {
"@types/node": "^22"
}
}

View File

@@ -0,0 +1,37 @@
/**
* @omniroute/browser-pool — Optional browser pool for Playwright-backed
* executor support (claude-web, duckduckgo-web, grok).
*
* Core stubs dynamically import this package at runtime. When the package
* is not installed, the stubs degrade gracefully (fallback or error).
*/
// ── Re-exports from browserPool ──────────────────────────────────────────
export {
acquireBrowserContext,
releaseBrowserContext,
getBrowserPoolMetrics,
readPageResponseBody,
openPage,
shutdownPool,
setProxyResolver,
__resetBrowserPoolMetricsForTest,
} from "./services/browserPool.ts";
export type { BrowserPoolContextOptions, BrowserPoolMetrics, PooledContext } from "./interfaces.ts";
// ── Re-exports from browserBackedChat ────────────────────────────────────
export {
browserBackedChat,
startBrowserWarmup,
getFreshCookiesWithWarmup,
} from "./services/browserBackedChat.ts";
// ── Re-exports from grokClearance ─────────────────────────────────────────
export {
getCachedCookies,
setCachedCookies,
clearCookieCache,
} from "./services/browserBackedChat.ts";
export { shouldUseGrokBrowserBacked, acquireFreshGrokClearance } from "./services/grokClearance.ts";

View File

@@ -0,0 +1,94 @@
/**
* interfaces.ts — Shared type definitions for @omniroute/browser-pool.
*
* These types are used by both the package entry and the core stubs.
* The core stubs re-export them so existing import paths remain stable.
*/
import type { BrowserContext, Page } from "playwright";
// ── Browser pool ───────────────────────────────────────
export interface BrowserPoolContextOptions {
cookieDomain: string;
cookieString?: string | null;
warmupUrl?: string | null;
userAgent?: string;
locale?: string;
timezone?: string;
preferCloakbrowser?: boolean;
/** Time (ms) to wait for the warmup page to be ready. */
waitFor?: number;
}
export interface PooledContext {
id: string;
context: BrowserContext;
warmupPage: Page | null;
lastUsed: number;
isStealth: boolean;
}
export interface BrowserPoolMetrics {
browserLaunches: number;
browserLaunchFailures: number;
contextsCreated: number;
contextsReused: number;
contextsEvicted: number;
contextsReleased: number;
contextCreateFailures: number;
shutdowns: number;
lastShutdownReason: string | null;
}
// ── Browser-backed chat ────────────────────────────────
export interface BrowserBackedChatRequest {
/** Pool key — typically a provider id like "duckduckgo-web" or
* "claude-web", optionally suffixed by user/account id. */
poolKey: string;
/** Chat URL the page should submit to (captured via waitForResponse). */
chatUrl: string;
/** Chat page URL to navigate to before typing. */
chatPageUrl: string;
/** The text the user wants to send. */
userMessage: string;
/** Cookie string (raw) to inject into the browser context. */
cookieString?: string | null;
/** Cookie domain (used together with cookieString). */
cookieDomain?: string;
/** Domain for the page's fetch to identify the chat endpoint. */
chatUrlMatchDomain: string;
/** User-Agent string for the browser context. */
userAgent?: string;
/** Locale (BCP 47). Defaults to en-US. */
locale?: string;
/** IANA timezone. Defaults to America/New_York. */
timezone?: string;
/** Selector for the chat input. */
inputSelector: string;
/** Selector for the submit button (optional — falls back to Enter). */
submitButtonSelector?: string;
/** Wait after submit for SSE/JSON to arrive. Default 15 seconds. */
postSubmitWaitMs?: number;
/** Optional AbortSignal. Cancels navigation/submit. */
signal?: AbortSignal | null;
/** Reuse the same context across requests. Default true. */
reuseContext?: boolean;
}
export interface BrowserBackedChatTiming {
acquireContextMs: number;
navigateMs: number;
submitMs: number;
captureResponseMs: number;
totalMs: number;
}
export interface BrowserBackedChatResult {
status: number;
contentType: string | null;
body: Buffer;
isStealth: boolean;
timing: BrowserBackedChatTiming;
}

View File

@@ -0,0 +1,461 @@
/**
* browserBackedChat.ts — Full browser-backed chat interaction for @omniroute/browser-pool.
*
* Opens a page on a shared browser context, navigates to the provider's
* chat page, types the user's message, clicks Send, and returns the
* upstream SSE/JSON response body as a structured result.
*
* Providers using this path: duckduckgo-web, claude-web.
*
* The browser solves the provider's challenge natively (VQD, Cloudflare
* Turnstile, etc.) by computing real DOM measurement values. The
* Node-side challenge solver still runs as a first-line best-effort;
* this module is the fallback.
*/
import { Buffer } from "node:buffer";
import {
acquireBrowserContext,
openPage,
readPageResponseBody,
releaseBrowserContext,
} from "./browserPool.ts";
import type {
PooledContext,
BrowserBackedChatRequest,
BrowserBackedChatResult,
} from "../interfaces.ts";
// Safety constants
const MAX_RESPONSE_BYTES = 10 * 1024 * 1024; // 10 MB
// Cookie cache constants
const COOKIE_CACHE_TTL_MS = 5 * 60 * 1000; // Cache fresh cookies for 5 minutes
const COOKIE_POLL_INTERVAL_MS = 500; // Poll for cookies every 500ms
const COOKIE_POLL_TIMEOUT_MS = 5000; // Max poll time for cookies
// Cookie cache — avoids repeated browser launches when cookies are still valid
interface CachedCookies {
cookieString: string;
expiresAt: number;
domain: string;
}
const cookieCache = new Map<string, CachedCookies>();
export function getCachedCookies(domain: string): string | null {
const cached = cookieCache.get(domain);
if (cached && Date.now() < cached.expiresAt) return cached.cookieString;
cookieCache.delete(domain);
return null;
}
export function setCachedCookies(domain: string, cookieString: string, ttlMs?: number): void {
cookieCache.set(domain, {
cookieString,
expiresAt: Date.now() + (ttlMs ?? COOKIE_CACHE_TTL_MS),
domain,
});
}
export function clearCookieCache(): void {
cookieCache.clear();
}
// Dedup pending cookie refreshes per pool key
const pendingRefreshes = new Map<string, Promise<string | null>>();
/** Sanitize an error message for safe JSON transport. */
const MAX_ERROR_LEN = 512;
function sanitizeErrorMessage(message: unknown): string {
let str = typeof message === "string" ? message : String(message ?? "");
if (str.length > MAX_ERROR_LEN) str = str.slice(0, MAX_ERROR_LEN);
const nl = str.indexOf("\n");
if (nl >= 0) str = str.slice(0, nl);
return str.replace(/[^ -~]/g, "").trim();
}
/** Wait N milliseconds, abortable via signal. */
async function waitWithSignal(ms: number, signal?: AbortSignal | null): Promise<void> {
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
return new Promise<void>((resolve, reject) => {
const onAbort = () => {
clearTimeout(timer);
reject(new DOMException("Aborted", "AbortError"));
};
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
signal?.addEventListener("abort", onAbort, { once: true });
});
}
/**
* waitForCookiesWithPolling — Poll for cookies every 500ms up to 5s.
* Returns as soon as challenge cookies appear, instead of always
* waiting the full timeout. Saves 1-4s when anti-bot resolves quickly.
*/
async function waitForCookiesWithPolling(
context: import("playwright").BrowserContext,
cookieDomain: string,
signal: AbortSignal | null
): Promise<string | null> {
const deadline = Date.now() + COOKIE_POLL_TIMEOUT_MS;
while (Date.now() < deadline) {
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
const cookies = await context.cookies(cookieDomain);
const cookieString = cookies.map((c) => `${c.name}=${c.value}`).join("; ");
if (cookieString) return cookieString;
const remaining = deadline - Date.now();
if (remaining <= 0) break;
await waitWithSignal(Math.min(COOKIE_POLL_INTERVAL_MS, remaining), signal);
}
return null;
}
/**
* doCookieRefreshOnContext — Run cookie extraction on an already-acquired
* browser context. Opens a temporary page, navigates to the chat URL,
* polls for cookies, and returns the result.
* NOTE: Does NOT pass AbortSignal to Playwright methods — signals are
* handled via waitWithSignal wrapping instead.
*/
async function doCookieRefreshOnContext(
pooled: PooledContext,
chatPageUrl: string,
cookieDomain: string,
signal: AbortSignal | null
): Promise<string | null> {
const page = await openPage(pooled);
try {
await page.goto(chatPageUrl, {
waitUntil: "domcontentloaded",
timeout: 60000,
});
return await waitForCookiesWithPolling(pooled.context, cookieDomain, signal);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") throw err;
return null;
} finally {
await page.close().catch(() => {});
}
}
/**
* Match a URL against a chat URL template, allowing a single dynamic
* id segment (PLACEHOLDER) in the template.
*/
function chatUrlMatcher(u: string, matchDomain: string, chatUrl: string): boolean {
if (u === chatUrl) return true;
let parsed: URL;
let chatParsed: URL;
try {
parsed = new URL(u);
chatParsed = new URL(chatUrl);
} catch {
return false;
}
if (!parsed.host.endsWith(matchDomain)) return false;
const chatSeg = chatParsed.pathname.split("/").filter(Boolean);
const reqSeg = parsed.pathname.split("/").filter(Boolean);
if (chatSeg.length < 2 || reqSeg.length !== chatSeg.length) return false;
let allowedDynamic = 1;
for (let i = 0; i < chatSeg.length; i++) {
if (chatSeg[i] === reqSeg[i]) continue;
if (chatSeg[i] === "PLACEHOLDER" && allowedDynamic > 0) {
allowedDynamic--;
continue;
}
return false;
}
return true;
}
/** Resolve a unique pool key; when reuseContext is false, create a unique key. */
async function settlePoolKey(
requestedKey: string,
reuseContext: boolean
): Promise<{ key: string; acquired: boolean }> {
if (reuseContext) return { key: requestedKey, acquired: true };
return {
key: `${requestedKey}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
acquired: false,
};
}
// ── Cookie refresh helpers ──────────────────────────
/**
* doRefresh — Acquire a fresh browser context, navigate to the
* chat page, and poll for cookies. Returns the cookie string
* or null on failure.
* NOTE: Does NOT pass AbortSignal to Playwright methods — signals
* are handled via waitWithSignal wrapping instead.
*/
async function doRefresh(options: {
chatPageUrl: string;
cookieDomain: string;
poolKey: string;
signal: AbortSignal | null;
}): Promise<string | null> {
const pooled = await acquireBrowserContext(options.poolKey + "-refresh", {
cookieDomain: options.cookieDomain,
cookieString: null,
warmupUrl: options.chatPageUrl,
});
const page = await openPage(pooled);
try {
await page.goto(options.chatPageUrl, {
waitUntil: "domcontentloaded",
timeout: 60000,
});
return await waitForCookiesWithPolling(pooled.context, options.cookieDomain, options.signal);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") throw err;
return null;
} finally {
await page.close().catch(() => {});
// release context — we got the cookies
setTimeout(() => {
releaseBrowserContext(options.poolKey + "-refresh").catch(() => {});
}, 1000);
}
}
/**
* refreshCookiesViaBrowser — Refresh cookies using a browser context.
* Uses pendingRefreshes dedup so concurrent requests share one browser launch.
* NOTE: Override check (httpOverride) is handled in the core stub — this
* package version always attempts browser cookie refresh.
*/
async function refreshCookiesViaBrowser(
chatUrl: string,
chatPageUrl: string,
cookieDomain: string,
poolKey: string,
signal: AbortSignal | null
): Promise<string | null> {
const pending = pendingRefreshes.get(poolKey);
if (pending) return pending;
const promise = doRefresh({ chatPageUrl, cookieDomain, poolKey, signal });
pendingRefreshes.set(poolKey, promise);
try {
return await promise;
} finally {
pendingRefreshes.delete(poolKey);
}
}
/**
* startBrowserWarmup — Pre-warm a browser context for the given pool key.
* This is a fire-and-forget operation: errors are caught and ignored.
* The warmup page serves as a readiness indicator — we open a page in the
* pooled context to force early navigation before the actual request.
*/
export async function startBrowserWarmup(
poolKey: string,
chatPageUrl: string,
cookieDomain: string,
signal: AbortSignal | null
): Promise<void> {
if (process.env.OMNIROUTE_BROWSER_POOL === "off") return;
const pooled = await acquireBrowserContext(poolKey, {
cookieDomain,
cookieString: null,
warmupUrl: chatPageUrl,
waitFor: 2000,
});
// Warmup: open a page in the pooled context — this can happen in parallel
openPage(pooled).catch(() => {});
}
/**
* getFreshCookiesWithWarmup — Try cached cookies first; if none, start
* a browser warmup in parallel with a cookie refresh. Returns cookie string
* or null. Caches successful results.
*/
export async function getFreshCookiesWithWarmup(
chatUrl: string,
chatPageUrl: string,
cookieDomain: string,
poolKey: string,
signal: AbortSignal | null
): Promise<string | null> {
// Try cached cookies first
const cached = getCachedCookies(cookieDomain);
if (cached) return cached;
// Start warmup in parallel with refresh
const warmup = startBrowserWarmup(poolKey, chatPageUrl, cookieDomain, signal);
const fresh = await refreshCookiesViaBrowser(chatUrl, chatPageUrl, cookieDomain, poolKey, signal);
// Await warmup (errors are non-fatal)
await warmup.catch(() => {});
if (fresh) {
setCachedCookies(cookieDomain, fresh);
return fresh;
}
return null;
}
// ── Main entry point ───────────────────────────────────
export async function browserBackedChat(
req: BrowserBackedChatRequest
): Promise<BrowserBackedChatResult> {
const t0 = Date.now();
const {
poolKey,
chatUrl,
chatPageUrl,
userMessage,
cookieString,
cookieDomain,
chatUrlMatchDomain,
userAgent,
locale,
timezone,
inputSelector,
submitButtonSelector,
postSubmitWaitMs = 15000,
signal,
reuseContext = true,
} = req;
const { key, acquired: reuseAcquired } = await settlePoolKey(poolKey, reuseContext);
const tAcquireStart = Date.now();
const pooled: PooledContext = await acquireBrowserContext(key, {
cookieDomain: cookieDomain || chatUrlMatchDomain,
cookieString: cookieString || null,
warmupUrl: chatPageUrl,
userAgent,
locale,
timezone,
});
const acquireContextMs = Date.now() - tAcquireStart;
const page = await openPage(pooled);
try {
const tNavStart = Date.now();
await page.goto(chatPageUrl, {
waitUntil: "domcontentloaded",
timeout: 60000,
});
const navigateMs = Date.now() - tNavStart;
const inputLocator = page.locator(inputSelector).first();
await inputLocator.waitFor({ state: "visible", timeout: 10000 });
await waitWithSignal(800, signal);
const responsePromise = page.waitForResponse(
(r) =>
r.request().method() === "POST" && chatUrlMatcher(r.url(), chatUrlMatchDomain, chatUrl),
{ timeout: 30000 }
);
let abortListener: (() => void) | undefined;
const signalPromise = signal
? new Promise<never>((_, reject) => {
if (signal.aborted) return reject(new DOMException("Aborted", "AbortError"));
abortListener = () => reject(new DOMException("Aborted", "AbortError"));
signal.addEventListener("abort", abortListener, { once: true });
})
: null;
if (submitButtonSelector) {
const btn = page.locator(submitButtonSelector).first();
if ((await btn.count()) > 0) {
try {
await btn.click({ timeout: 2000 });
} catch {
await page.keyboard.press("Enter");
}
} else {
await page.keyboard.press("Enter");
}
} else {
await page.keyboard.press("Enter");
}
const tCaptureStart = Date.now();
const response = signalPromise
? await Promise.race([responsePromise, signalPromise]).catch(() => null)
: await responsePromise.catch(() => null);
if (signal && abortListener) {
signal.removeEventListener("abort", abortListener);
}
if (response) {
await waitWithSignal(Math.min(postSubmitWaitMs, 30000), signal);
} else {
await waitWithSignal(postSubmitWaitMs, signal);
}
const captureResponseMs = Date.now() - tCaptureStart;
const submitMs = captureResponseMs;
let status = 0;
let contentType: string | null = null;
let body: Buffer = Buffer.alloc(0);
if (response) {
const captured = await readPageResponseBody(response);
if (captured.body.length > MAX_RESPONSE_BYTES) {
body = Buffer.from(
JSON.stringify({
error: {
message: "Response too large",
type: "upstream_error",
},
})
);
status = 502;
contentType = "application/json";
} else {
body = captured.body as unknown as Buffer;
contentType = captured.headers["content-type"] || null;
}
}
return {
status,
contentType,
body,
isStealth: pooled.isStealth,
timing: {
acquireContextMs,
navigateMs,
submitMs,
captureResponseMs,
totalMs: Date.now() - t0,
},
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const body = Buffer.from(
JSON.stringify({
error: {
message: sanitizeErrorMessage(`browserBackedChat failed: ${msg}`),
type: "upstream_error",
},
})
);
return {
status: 502,
contentType: "application/json",
body,
isStealth: pooled.isStealth,
timing: {
acquireContextMs,
navigateMs: 0,
submitMs: 0,
captureResponseMs: 0,
totalMs: Date.now() - t0,
},
};
} finally {
await page.close();
if (!reuseAcquired) {
try {
await pooled.context.close();
} catch {
/* ignore */
}
}
}
}

View File

@@ -0,0 +1,440 @@
/**
* browserPool.ts — Shared stealth browser pool for web-cookie providers.
*
* The DuckDuckGo VQD challenge and Claude web's Cloudflare Turnstile both
* validate values that only a real browser can produce (DOM layout
* measurements like offsetWidth/Height, getBoundingClientRect,
* getComputedStyle, iframe contentWindow probes). Plain Node fetch + a
* VM-stubs solver structurally runs the JS but cannot match those values,
* so the server rejects the request.
*
* This pool keeps one Chromium instance warm and serves "browser contexts"
* (one per provider) on demand. Each context owns one or more pages; the
* caller is expected to be polite (one page per request, close on done).
*
* The pool prefers `cloakbrowser` (npm) when available — its binary-level
* fingerprint patches (--fingerprint-timezone, --fingerprint-locale, and
* dozens more) are the only thing that gets past DuckDuckGo's anti-bot
* in this environment. Falls back to plain `playwright` if cloakbrowser
* is not installed; the fallback works for Claude web (which only needs
* valid cookies) but not for DDG's VQD challenge.
*
* Opt-in: pool only launches Chromium when an executor explicitly asks
* for a context, so users who never use the browser-backed path pay zero
* startup cost. Set OMNIROUTE_BROWSER_POOL=off to fully disable.
*/
import { Buffer } from "node:buffer";
import type {
BrowserPoolContextOptions,
BrowserPoolMetrics,
PooledContext,
} from "../interfaces.ts";
type Browser = import("playwright").Browser;
type BrowserContext = import("playwright").BrowserContext;
type Page = import("playwright").Page;
/** Proxy resolver injected by the core stub after dynamic import. */
type ProxyResolverFn = (
providerKey: string
) => Promise<import("playwright").LaunchOptions["proxy"] | undefined>;
let injectedProxyResolver: ProxyResolverFn | null = null;
export function setProxyResolver(fn: ProxyResolverFn): void {
injectedProxyResolver = fn;
}
function createBrowserPoolMetrics(): BrowserPoolMetrics {
return {
browserLaunches: 0,
browserLaunchFailures: 0,
contextsCreated: 0,
contextsReused: 0,
contextsEvicted: 0,
contextsReleased: 0,
contextCreateFailures: 0,
shutdowns: 0,
lastShutdownReason: null,
};
}
interface PoolState {
browser: Browser | null;
contexts: Map<string, PooledContext>;
pendingContexts: Map<string, Promise<PooledContext>>;
launching: Promise<Browser> | null;
lastActivity: number;
idleTimer: NodeJS.Timeout | null;
evictTimer: NodeJS.Timeout | null;
cloakLaunch: ((opts: unknown) => Promise<Browser>) | null;
cloakLaunchResolved: boolean;
metrics: BrowserPoolMetrics;
}
const POOL_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
const CONTEXT_TTL_MS = 10 * 60 * 1000; // 10 min — evict stale contexts
const EVICT_INTERVAL_MS = 60 * 1000; // check every 60s
const DEFAULT_USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
const state: PoolState = {
browser: null,
contexts: new Map(),
pendingContexts: new Map(),
launching: null,
lastActivity: 0,
idleTimer: null,
evictTimer: null,
cloakLaunch: null,
cloakLaunchResolved: false,
metrics: createBrowserPoolMetrics(),
};
function getCloakbrowserModuleId(): string {
// Keep this computed: cloakbrowser is an optional runtime enhancer, and a literal
// dynamic import with the package name makes Turbopack resolve it during route compilation.
return ["cloak", "browser"].join("");
}
async function resolveCloakLaunch(): Promise<((opts: unknown) => Promise<Browser>) | null> {
if (state.cloakLaunchResolved) return state.cloakLaunch;
state.cloakLaunchResolved = true;
try {
const mod = (await import(getCloakbrowserModuleId())) as unknown as {
launch?: (opts: unknown) => Promise<Browser>;
};
state.cloakLaunch = mod.launch ?? null;
} catch {
state.cloakLaunch = null;
}
return state.cloakLaunch;
}
function isPoolEnabled(): boolean {
const flag = process.env.OMNIROUTE_BROWSER_POOL;
if (flag === undefined) return true;
return flag !== "off" && flag !== "0" && flag !== "false";
}
function resetIdleTimer(): void {
if (state.idleTimer) clearTimeout(state.idleTimer);
state.idleTimer = setTimeout(() => {
void shutdownPool("idle-timeout");
}, POOL_IDLE_TIMEOUT_MS);
state.idleTimer.unref?.();
}
function evictStaleContexts(): void {
const now = Date.now();
for (const [key, pooled] of state.contexts) {
if (now - pooled.lastUsed > CONTEXT_TTL_MS) {
console.log(
"[BrowserPool] Evicted stale context:",
key,
"(idle",
((now - pooled.lastUsed) / 1000).toFixed(0) + "s)"
);
state.contexts.delete(key);
state.metrics.contextsEvicted++;
pooled.context.close().catch(() => {});
}
}
if (state.contexts.size === 0 && !state.launching) {
void shutdownPool("all-contexts-evicted");
}
}
function startEvictTimer(): void {
if (state.evictTimer) clearInterval(state.evictTimer);
state.evictTimer = setInterval(() => evictStaleContexts(), EVICT_INTERVAL_MS);
state.evictTimer.unref?.();
}
async function launchBrowser(): Promise<Browser> {
if (state.browser) return state.browser;
if (state.launching) return state.launching;
state.launching = (async () => {
const cloakLaunch = await resolveCloakLaunch();
let browser: Browser;
if (cloakLaunch) {
browser = await cloakLaunch({
headless: true,
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
} else {
// Fallback: plain Playwright. Works for Claude web (cookie-only
// auth) but DDG's VQD challenge will detect this Chromium build.
const { chromium } = await import("playwright");
browser = await chromium.launch({
headless: true,
args: [
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-blink-features=AutomationControlled",
],
});
}
state.browser = browser;
state.launching = null;
state.metrics.browserLaunches++;
return browser;
})();
try {
return await state.launching;
} catch (err) {
state.launching = null;
state.metrics.browserLaunchFailures++;
throw err;
}
}
function parseCookieString(
raw: string,
domain: string
): Array<{
name: string;
value: string;
domain: string;
path: string;
expires: number;
httpOnly: boolean;
secure: boolean;
sameSite: "Lax" | "Strict" | "None";
}> {
return raw
.split(";")
.map((p) => p.trim())
.filter(Boolean)
.map((pair) => {
const eq = pair.indexOf("=");
if (eq < 0) return null;
const name = pair.slice(0, eq).trim();
const value = pair.slice(eq + 1).trim();
if (!name || !value) return null;
return {
name,
value,
domain: domain.startsWith(".") ? domain : `.${domain}`,
path: "/",
expires: -1,
httpOnly: false,
secure: true,
sameSite: "Lax" as const,
};
})
.filter(Boolean) as Array<{
name: string;
value: string;
domain: string;
path: string;
expires: number;
httpOnly: boolean;
secure: boolean;
sameSite: "Lax" | "Strict" | "None";
}>;
}
// Clear a key from the pending-creation map once its promise settles, counting
// failures. Kept as a leaf helper so acquireBrowserContext stays under the
// function-length ceiling (#3368 PR7 metrics).
function settlePendingContext(key: string, failed: boolean): void {
if (failed) state.metrics.contextCreateFailures++;
state.pendingContexts.delete(key);
}
export async function acquireBrowserContext(
key: string,
options: BrowserPoolContextOptions
): Promise<PooledContext> {
if (!isPoolEnabled()) {
throw new Error(
"browserPool: OMNIROUTE_BROWSER_POOL=off — context requested but pool is disabled"
);
}
const existing = state.contexts.get(key);
if (existing) {
existing.lastUsed = Date.now();
state.lastActivity = Date.now();
state.metrics.contextsReused++;
resetIdleTimer();
return existing;
}
// Dedup concurrent creations for the same key
const pending = state.pendingContexts.get(key);
if (pending) return pending;
const createPromise = (async (): Promise<PooledContext> => {
const proxy = injectedProxyResolver ? await injectedProxyResolver(key) : undefined;
const [browser] = await Promise.all([launchBrowser()]);
const isStealth = state.cloakLaunch !== null;
const context = await browser.newContext({
userAgent: options.userAgent || DEFAULT_USER_AGENT,
locale: options.locale || "en-US",
timezoneId: options.timezone || "America/New_York",
viewport: { width: 1280, height: 800 },
...(proxy ? { proxy } : {}),
});
if (options.cookieString) {
const cookies = parseCookieString(options.cookieString, options.cookieDomain);
if (cookies.length > 0) {
await context.addCookies(cookies);
}
}
let warmupPage: Page | null = null;
if (options.warmupUrl) {
try {
warmupPage = await context.newPage();
await warmupPage.goto(options.warmupUrl, {
waitUntil: "domcontentloaded",
timeout: 30000,
});
// Give the warmup a moment for the upstream's status/auth/country
// JSON endpoints to fire. Without this, the first chat request would
// pay the warmup cost on the hot path.
await new Promise((r) => setTimeout(r, 1500));
} catch (err) {
try {
await warmupPage?.close();
} catch {
/* ignore */
}
warmupPage = null;
void err;
}
}
// Guard: if shutdownPool() ran while we were creating this context,
// the browser we obtained is now closed. Close our temp context and
// throw so the caller knows to retry.
if (state.browser !== browser) {
await context.close().catch(() => {});
if (warmupPage) {
await warmupPage.close().catch(() => {});
}
throw new Error("Pool shut down during context creation");
}
const pooled: PooledContext = {
id: key,
context,
warmupPage,
lastUsed: Date.now(),
isStealth,
};
state.contexts.set(key, pooled);
state.metrics.contextsCreated++;
state.lastActivity = Date.now();
resetIdleTimer();
startEvictTimer();
return pooled;
})();
state.pendingContexts.set(key, createPromise);
createPromise
.then(() => settlePendingContext(key, false))
.catch(() => settlePendingContext(key, true));
return createPromise;
}
export async function openPage(pooled: PooledContext): Promise<Page> {
return pooled.context.newPage();
}
export async function releaseBrowserContext(key: string): Promise<void> {
const pooled = state.contexts.get(key);
if (!pooled) return;
state.contexts.delete(key);
state.metrics.contextsReleased++;
try {
await pooled.context.close();
} catch {
/* ignore */
}
if (state.contexts.size === 0) {
await shutdownPool("last-context-closed");
}
}
export async function shutdownPool(reason: string): Promise<void> {
state.metrics.shutdowns++;
state.metrics.lastShutdownReason = reason;
if (state.idleTimer) {
clearTimeout(state.idleTimer);
state.idleTimer = null;
}
if (state.evictTimer) {
clearInterval(state.evictTimer);
state.evictTimer = null;
}
state.pendingContexts.clear();
for (const [key, pooled] of state.contexts) {
try {
await pooled.context.close();
} catch {
/* ignore */
}
state.contexts.delete(key);
}
if (state.browser) {
try {
await state.browser.close();
} catch {
/* ignore */
}
state.browser = null;
}
state.lastActivity = Date.now();
// Avoid unused-parameter lint: log reason via debug if anyone hooks
// process.on('exit') and prints state.
void reason;
}
function getBrowserPoolStatus(): {
enabled: boolean;
contexts: number;
browserRunning: boolean;
stealthAvailable: boolean;
lastActivityAgoMs: number;
} {
return {
enabled: isPoolEnabled(),
contexts: state.contexts.size,
browserRunning: state.browser !== null,
stealthAvailable: state.cloakLaunch !== null,
lastActivityAgoMs: state.lastActivity === 0 ? -1 : Date.now() - state.lastActivity,
};
}
/**
* #3368 PR7 — browser-pool observability. Returns live status plus cumulative
* lifecycle telemetry (launches, context create/reuse/evict/release counts,
* failures, shutdowns). Surfaced via the omniroute_browser_pool_status MCP tool.
*/
export function getBrowserPoolMetrics(): {
status: ReturnType<typeof getBrowserPoolStatus>;
metrics: BrowserPoolMetrics;
} {
return { status: getBrowserPoolStatus(), metrics: { ...state.metrics } };
}
/** Test-only: reset cumulative metrics so assertions start from a clean slate. */
export function __resetBrowserPoolMetricsForTest(): void {
state.metrics = createBrowserPoolMetrics();
}
export async function readPageResponseBody(
response: import("playwright").Response
): Promise<{ status: number; headers: Record<string, string>; body: Buffer }> {
const headers: Record<string, string> = {};
for (const [name, value] of Object.entries(response.headers())) {
headers[name] = value;
}
const body = await response.body();
return { status: response.status(), headers, body: Buffer.from(body) };
}

View File

@@ -0,0 +1,73 @@
/**
* grokClearance.ts — gated browser-backed cf_clearance acquisition for
* grok-web (#8019).
*
* grok.com sits behind Cloudflare Enterprise, which pins `cf_clearance` to
* the client's IP+TLS+UA fingerprint. Pure cookie-replay from
* `grokTlsClient.ts` (TLS-impersonating fetch) cannot forge a fresh
* clearance from a datacenter egress that Cloudflare has already flagged —
* only a real browser solving the challenge natively can mint one bound to
* that egress's own fingerprint.
*
* This module reuses the EXISTING provider-agnostic browser pool
* (`browserPool.ts`, already live for claude-web + duckduckgo-web) rather
* than adding a new Turnstile solver — `claudeTurnstileSolver.ts` is
* claude.ai-specific and does not apply here.
*
* Opt-in only: gated behind `OMNIROUTE_BROWSER_POOL` / `WEB_COOKIE_USE_BROWSER`
* (the same env gate already used by claude-web.ts / duckduckgo-web.ts).
* With the gate off, `acquireFreshGrokClearance` is never called — the
* executor stays on the Step-1 `cloudflare_challenge` classification.
*/
import { acquireBrowserContext } from "./browserPool.ts";
import type { PooledContext } from "../interfaces.ts";
const GROK_WARMUP_URL = "https://grok.com/";
const GROK_COOKIE_DOMAIN = ".grok.com";
const GROK_POOL_KEY = "grok-web";
/**
* Reads the same opt-in gate as claude-web/duckduckgo-web
* (`WEB_COOKIE_USE_BROWSER` or `OMNIROUTE_BROWSER_POOL`). Off by default.
*/
export function shouldUseGrokBrowserBacked(): boolean {
const flag = process.env.WEB_COOKIE_USE_BROWSER;
if (flag === "1" || flag === "true" || flag === "on") return true;
const poolFlag = process.env.OMNIROUTE_BROWSER_POOL;
return poolFlag === "on" || poolFlag === "1" || poolFlag === "true";
}
async function readCfClearanceFromContext(pooled: PooledContext): Promise<string | null> {
const cookies = await pooled.context.cookies(GROK_WARMUP_URL);
const match = cookies.find((c) => c.name === "cf_clearance");
return match?.value || null;
}
async function acquireViaPool(): Promise<string | null> {
try {
const pooled = await acquireBrowserContext(GROK_POOL_KEY, {
cookieDomain: GROK_COOKIE_DOMAIN,
cookieString: null,
warmupUrl: GROK_WARMUP_URL,
});
return await readCfClearanceFromContext(pooled);
} catch {
return null;
}
}
/**
* Acquire a fresh `.grok.com` cf_clearance via the shared browser pool.
* Never throws — resolves to `null` on any failure so callers can fall
* through to the Cloudflare-challenge error rather than crash the request.
*/
export async function acquireFreshGrokClearance(
signal?: AbortSignal | null
): Promise<string | null> {
try {
return await acquireViaPool();
} catch {
return null;
}
}

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "esnext",
"moduleResolution": "bundler",
"noEmit": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"esModuleInterop": true,
"strict": false,
"lib": ["esnext"],
"types": ["node"],
"allowJs": false
},
"include": ["src/**/*.ts"]
}

View File

@@ -1,4 +1,5 @@
packages:
- "packages/*"
- "open-sse"
allowBuilds:
"@parcel/watcher": true

View File

@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
<title id="title">Cheaper Inference gateway mark</title>
<rect x="1" y="1" width="62" height="62" fill="#050807" stroke="#31f889" stroke-width="2" />
<path d="M8 21V8h13M43 56h13V43" fill="none" stroke="#c8d2cc" stroke-width="2" />
<path d="M31 20c-8 0-13 5-13 12s5 12 13 12" fill="none" stroke="#f2f5f3" stroke-linecap="square" stroke-width="7" />
<path d="m33 25 10 7-10 7" fill="none" stroke="#31f889" stroke-linecap="square" stroke-linejoin="miter" stroke-width="5" />
</svg>

Before

Width:  |  Height:  |  Size: 586 B

View File

@@ -9,17 +9,13 @@
// não resolve para um executor válido é um símbolo morto (roteia para fallback
// silencioso em vez de falhar).
//
// (2) COMBO STRATEGIES — o despacho DEVE tratar exatamente o conjunto canônico de
// ROUTING_STRATEGY_VALUES INTERNAL_ROUTING_STRATEGY_VALUES
// (src/shared/constants/routingStrategies.ts), exceto as estratégias-default
// implícitas documentadas em IMPLICIT_DEFAULT_STRATEGIES (estratégias canônicas
// sem ramo de despacho próprio; caem no ordenamento padrão). Em vez de casar
// literais `strategy === "..."` por regex sobre a fonte, o conjunto tratado
// (handled) vem de uma enumeração em runtime importada de
// open-sse/services/combo/strategyDispatch.ts — o módulo que importa as funções
// reais de ordenação/despacho e lista quais estratégias elas implementam. Adicionar
// um valor canônico sem fiá-lo no despacho/e na enumeração, ou fiar uma string de
// estratégia que não é canônica (inventada), falha aqui.
// (2) COMBO STRATEGIES — a cadeia de despacho `strategy === "..."` em
// open-sse/services/combo.ts DEVE tratar exatamente o conjunto canônico de
// ROUTING_STRATEGY_VALUES (src/shared/constants/routingStrategies.ts), exceto
// as estratégias-default implícitas documentadas em IMPLICIT_DEFAULT_STRATEGIES
// (estratégias canônicas sem NENHUMA referência `strategy === "..."`; caem no
// ordenamento padrão). Adicionar um valor canônico sem fiá-lo no despacho, ou
// fiar uma string de estratégia que não é canônica (inventada), falha aqui.
//
// (3) TRANSLATOR PAIRS — os pares from:to registrados em runtime no registry de
// tradutores (após bootstrap) são congelados em KNOWN_TRANSLATOR_PAIRS. Catraca:
@@ -487,15 +483,21 @@ async function main(): Promise<void> {
...(strategiesMod.ROUTING_STRATEGY_VALUES as readonly string[]),
...(strategiesMod.INTERNAL_ROUTING_STRATEGY_VALUES as readonly string[]),
];
// G1: the handled set comes from a runtime-imported dispatch registry that imports the
// actual strategy-ordering functions and enumerates which strategies they implement —
// NOT from regex-scanning `strategy === "..."` literals in source. The old regex broke
// when the dispatch was decomposed (Block J / #3501) and will break again when R0.3
// converts it to a registry; enumerating at runtime keeps the gate correct either way.
// Each entry in HANDLED_COMBO_STRATEGIES must stay in sync with a real dispatch branch.
const strategyDispatchMod =
await import("@omniroute/open-sse/services/combo/strategyDispatch.ts");
const handled = new Set(strategyDispatchMod.HANDLED_COMBO_STRATEGIES as readonly string[]);
// The combo dispatch was decomposed (Block J): the `strategy === "..."` branches
// now live across combo.ts + its strategy-ordering leaves, so scan all of them.
const comboDispatchFiles = [
"open-sse/services/combo.ts",
"open-sse/services/combo/applyStrategyOrdering.ts",
"open-sse/services/combo/resolveAutoStrategy.ts",
// #3501: the fusion/pipeline dispatch branches moved here with the prelude
// extraction; the `strategy === "..."` checks are unchanged, just relocated.
"open-sse/services/combo/dispatchPrelude.ts",
"open-sse/services/combo/targetResolution.ts",
];
const comboSource = comboDispatchFiles
.map((rel) => readFileSync(resolvePath(REPO_ROOT, rel), "utf8"))
.join("\n");
const handled = extractHandledStrategies(comboSource);
// Stale-enforcement (6A.3): IMPLICIT_DEFAULT_STRATEGIES is a suppression allowlist —
// each entry exists ONLY to suppress a `canonicalNotHandled` violation (a canonical

View File

@@ -1,111 +0,0 @@
#!/usr/bin/env bash
# test-scoped — run only unit tests impacted by your changes.
#
# Usage:
# npm run test:scoped # tests for changes vs HEAD~1
# npm run test:scoped -- --staged # tests for staged changes only
#
# This is the local DX companion to the CI TIA gate (#8084 D1). The CI version
# builds a full import-graph impact map; for local dev we use a fast heuristic:
# - Changed test files → run those directly
# - Changed source files → run tests that share the file's directory/name prefix
# - Hub files (tsconfig, package.json, etc.) → suggest full suite
#
# For the full TIA (import-graph based), use: npm run test:scoped:full
# (requires a pre-built impact map via: node scripts/quality/build-test-impact-map.mjs)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
# ── 1. Determine changed files ───────────────────────────────────────────────
if [[ "${1:-}" == "--staged" ]]; then
CHANGED=$(git -C "$REPO_ROOT" diff --name-only --diff-filter=ACMR --cached)
else
CHANGED=$(git -C "$REPO_ROOT" diff --name-only --diff-filter=ACMR HEAD~1...HEAD 2>/dev/null || \
git -C "$REPO_ROOT" diff --name-only --diff-filter=ACMR)
fi
if [ -z "$CHANGED" ]; then
echo "[test:scoped] No changed files — nothing to test."
exit 0
fi
# ── 2. Classify changes ──────────────────────────────────────────────────────
HUB_RE="(setupPolyfill|tsconfig|package\\.json|package-lock\\.json|\\.env|vitest\\.config|stryker\\.conf)"
TEST_FILES=()
SRC_FILES=()
HIT_HUB=false
while IFS= read -r f; do
[ -z "$f" ] && continue
if echo "$f" | grep -qE "$HUB_RE"; then
HIT_HUB=true
elif echo "$f" | grep -qE '^tests/unit/.*\.test\.(ts|mjs)$'; then
TEST_FILES+=("$f")
elif echo "$f" | grep -qE '^(src|open-sse)/'; then
SRC_FILES+=("$f")
fi
done <<< "$CHANGED"
# ── 3. Hub file changed → full suite ─────────────────────────────────────────
if [ "$HIT_HUB" = true ]; then
echo "[test:scoped] Hub file changed — run full suite: npm run test:unit"
exit 1
fi
# ── 4. Collect tests to run ──────────────────────────────────────────────────
RUN_TESTS=()
# Direct test file changes always run
for tf in "${TEST_FILES[@]}"; do
RUN_TESTS+=("$tf")
done
# For source files, try the impact map first; fall back to heuristic
MAP_FILE="$REPO_ROOT/config/quality/test-impact-map.json"
if [ ${#SRC_FILES[@]} -gt 0 ] && [ -f "$MAP_FILE" ]; then
# Use the TIA selection with the impact map
SEL=$(printf '%s\n' "${SRC_FILES[@]}" | node "$REPO_ROOT/scripts/quality/select-impacted-tests.mjs" 2>/dev/null || echo "__RUN_ALL__")
if echo "$SEL" | grep -q "__RUN_ALL__"; then
echo "[test:scoped] Unmapped source change — run full suite: npm run test:unit"
exit 1
fi
while IFS= read -r t; do
[ -n "$t" ] && RUN_TESTS+=("$t")
done <<< "$SEL"
elif [ ${#SRC_FILES[@]} -gt 0 ]; then
# No impact map — heuristic: suggest building it
echo "[test:scoped] No impact map found. Build it with: node scripts/quality/build-test-impact-map.mjs"
echo "[test:scoped] Or run the full suite: npm run test:unit"
echo ""
echo "[test:scoped] Changed source files:"
printf ' %s\n' "${SRC_FILES[@]}"
if [ ${#TEST_FILES[@]} -gt 0 ]; then
echo "[test:scoped] Running changed test files only..."
else
exit 1
fi
fi
# Deduplicate
IFS=$'\n' SORTED=($(printf '%s\n' "${RUN_TESTS[@]}" | sort -u)); unset IFS
if [ ${#SORTED[@]} -eq 0 ]; then
echo "[test:scoped] No impacted tests — source changes don't map to any unit test."
exit 0
fi
echo "[test:scoped] Running ${#SORTED[@]} impacted test(s)..."
# ── 5. Run selected tests ────────────────────────────────────────────────────
cd "$REPO_ROOT"
exec cross-env \
DISABLE_SQLITE_AUTO_BACKUP=true \
node --max-old-space-size=8192 \
--import tsx/esm \
--import ./open-sse/utils/setupPolyfill.ts \
--import ./tests/_setup/isolateDataDir.ts \
--test --test-force-exit --test-concurrency=4 \
"${SORTED[@]}"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Card, Button, EmptyState } from "@/shared/components";
import { Card, Button, EmptyState, Badge } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
import { useTranslations } from "next-intl";
@@ -19,6 +19,10 @@ export default function PluginsPage() {
const { addNotification } = useNotificationStore();
const t = useTranslations("plugins");
const [plugins, setPlugins] = useState<PluginInfo[]>([]);
const [activeTab, setActiveTab] = useState<"installed" | "marketplace">("installed");
const [marketplacePlugins, setMarketplacePlugins] = useState<any[]>([]);
const [marketplaceUrl, setMarketplaceUrl] = useState("");
const [savingUrl, setSavingUrl] = useState(false);
const [loading, setLoading] = useState(true);
const [scanning, setScanning] = useState(false);
@@ -38,7 +42,51 @@ export default function PluginsPage() {
useEffect(() => {
fetchPlugins();
fetch("/api/settings")
.then((res) => res.ok ? res.json() : null)
.then((data) => {
if (data?.pluginMarketplaceUrl) setMarketplaceUrl(data.pluginMarketplaceUrl);
})
.catch(() => {});
}, [fetchPlugins]);
const fetchMarketplace = useCallback(async () => {
try {
const res = await fetch("/api/plugins/marketplace");
if (res.ok) {
const data = await res.json();
setMarketplacePlugins(data.plugins || []);
}
} catch {}
}, []);
useEffect(() => {
if (activeTab === "marketplace") {
fetchMarketplace();
}
}, [activeTab, fetchMarketplace]);
const handleSaveUrl = async () => {
setSavingUrl(true);
try {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pluginMarketplaceUrl: marketplaceUrl || null }),
});
if (!res.ok) {
const errData = await res.json().catch(() => null);
addNotification({ type: "error", message: errData?.error || "Failed to save" });
return;
}
addNotification({ type: "success", message: t("marketplaceUrlSaved") });
await fetchMarketplace();
} catch {
addNotification({ type: "error", message: t("saveConfigurationFailed") });
} finally {
setSavingUrl(false);
}
};
const handleScan = async () => {
setScanning(true);
@@ -89,58 +137,127 @@ export default function PluginsPage() {
<div className="space-y-6 p-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{t("title")}</h1>
<div className="flex gap-2">
<Button variant={activeTab === "installed" ? "primary" : "secondary"} onClick={() => setActiveTab("installed")}>
{t("installedTab")}
</Button>
<Button variant={activeTab === "marketplace" ? "primary" : "secondary"} onClick={() => setActiveTab("marketplace")}>
{t("marketplaceTab")}
</Button>
</div>
</div>
<div className="flex items-center justify-end">
<Button onClick={handleScan} disabled={scanning}>
{scanning ? t("scanning") : t("scanForPlugins")}
</Button>
</div>
{plugins.length === 0 ? (
<EmptyState
title={t("noPlugins")}
description={t("noPluginsDescription")}
/>
{activeTab === "marketplace" && (
<Card className="p-4 flex gap-4 items-end bg-gray-50">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1">{t("marketplaceUrlLabel")}</label>
<input
type="text"
className="w-full rounded border-gray-300 p-2"
placeholder={t("marketplaceUrlPlaceholder")}
value={marketplaceUrl}
onChange={(e) => setMarketplaceUrl(e.target.value)}
/>
</div>
<Button onClick={handleSaveUrl} disabled={savingUrl}>
{t("saveMarketplaceUrl")}
</Button>
</Card>
)}
{activeTab === "installed" ? (
<>
<div className="flex items-center justify-end">
<Button onClick={handleScan} disabled={scanning}>
{scanning ? t("scanning") : t("scanForPlugins")}
</Button>
</div>
{plugins.length === 0 ? (
<EmptyState
title={t("noPlugins")}
description={t("noPluginsDescription")}
/>
) : (
<div className="grid gap-4">
{plugins.map((plugin) => (
<Card key={plugin.name} className="p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">{plugin.name}</h3>
<p className="text-sm text-gray-500">
v{plugin.version}
{plugin.author ? ` by ${plugin.author}` : ""}
{plugin.description ? `${plugin.description}` : ""}
</p>
<div className="mt-1 flex gap-1">
{plugin.hooks.map((hook) => (
<span
key={hook}
className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700"
>
{hook}
</span>
))}
</div>
</div>
<div className="flex gap-2">
<Button
variant={plugin.enabled ? "secondary" : "primary"}
onClick={() => handleToggle(plugin.name, !plugin.enabled)}
>
{plugin.enabled ? t("deactivate") : t("activate")}
</Button>
<Button
variant="danger"
onClick={() => handleUninstall(plugin.name)}
>
{t("uninstall")}
</Button>
</div>
</div>
</Card>
))}
</div>
)}
</>
) : (
<div className="grid gap-4">
{plugins.map((plugin) => (
<Card key={plugin.name} className="p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">{plugin.name}</h3>
<p className="text-sm text-gray-500">
v{plugin.version}
{plugin.author ? ` by ${plugin.author}` : ""}
{plugin.description ? `${plugin.description}` : ""}
</p>
<div className="mt-1 flex gap-1">
{plugin.hooks.map((hook) => (
<span
key={hook}
className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700"
>
{hook}
</span>
))}
{marketplacePlugins.length === 0 ? (
<div className="text-gray-500 py-4">{t("marketplaceEmpty")}</div>
) : (
marketplacePlugins.map((plugin) => (
<Card key={plugin.name} className="p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold flex items-center gap-2">
{plugin.name}
{plugin.verified && <Badge variant="success">{t("verified")}</Badge>}
</h3>
<p className="text-sm text-gray-500">
v{plugin.version} by {plugin.author} {plugin.description}
</p>
<div className="mt-1 flex gap-1">
{plugin.tags?.map((tag: string) => (
<span key={tag} className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
{tag}
</span>
))}
</div>
</div>
<div className="flex gap-2">
<Button
variant="primary"
onClick={() => {
addNotification({ type: "info", message: t("marketplaceInstallComingSoon") });
}}
>
{t("install")}
</Button>
</div>
</div>
<div className="flex gap-2">
<Button
variant={plugin.enabled ? "secondary" : "primary"}
onClick={() => handleToggle(plugin.name, !plugin.enabled)}
>
{plugin.enabled ? t("deactivate") : t("activate")}
</Button>
<Button
variant="danger"
onClick={() => handleUninstall(plugin.name)}
>
{t("uninstall")}
</Button>
</div>
</div>
</Card>
))}
</Card>
))
)}
</div>
)}
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,7 @@ import {
} from "@/shared/constants/providers";
import { CategoryDot } from "./CategoryDot";
import { isCheaperInferenceProviderId, isKimiPartnerProviderId } from "../featuredProviders";
import { isKimiPartnerProviderId } from "../featuredProviders";
interface ProviderStats {
total?: number;
@@ -30,17 +30,17 @@ interface ProviderStats {
codexServiceTier?: "default" | "priority" | "flex" | null;
}
const KIND_LABEL_KEYS: Record<string, { key: string; fallback: string }> = {
llm: { key: "serviceKindChat", fallback: "Chat" },
embedding: { key: "serviceKindEmbedding", fallback: "Embed" },
image: { key: "serviceKindImage", fallback: "Image" },
imageToText: { key: "serviceKindImageToText", fallback: "I→T" },
tts: { key: "serviceKindTts", fallback: "TTS" },
stt: { key: "serviceKindStt", fallback: "STT" },
webSearch: { key: "serviceKindWebSearch", fallback: "Search" },
webFetch: { key: "serviceKindWebFetch", fallback: "Fetch" },
video: { key: "serviceKindVideo", fallback: "Video" },
music: { key: "serviceKindMusic", fallback: "Music" },
const KIND_LABEL: Record<string, string> = {
llm: "Chat",
embedding: "Embed",
image: "Image",
imageToText: "I→T",
tts: "TTS",
stt: "STT",
webSearch: "Search",
webFetch: "Fetch",
video: "Video",
music: "Music",
};
/** Maps a compatible-provider `apiType` to its `KIND_LABEL` key (#6936: non-chat
@@ -166,10 +166,6 @@ const ProviderCard = forwardRef<ProviderCardHandle, ProviderCardProps>(function
const t = useTranslations("providers");
const tc = useTranslations("common");
const tp = useTranslations("miniPlayground");
const kindLabel = (kind: string) => {
const entry = KIND_LABEL_KEYS[kind];
return entry ? providerText(t, entry.key, entry.fallback) : kind;
};
const [testExpanded, setTestExpanded] = useState<boolean>(false);
const innerRef = useRef<HTMLDivElement>(null);
const linkElementRef = useRef<HTMLAnchorElement>(null);
@@ -227,11 +223,9 @@ const ProviderCard = forwardRef<ProviderCardHandle, ProviderCardProps>(function
const isCompatible = isOpenAICompatibleProvider(providerId);
const isCcCompatible = isClaudeCodeCompatibleProvider(providerId);
const isAnthropicCompatible = isAnthropicCompatibleProvider(providerId) && !isCcCompatible;
// Open Source Friend highlights (Kimi 2026-07, Cheaper Inference 2026-07): UI-only
// accents, see featuredProviders.ts — never affect routing/fallback order.
// Kimi (Moonshot AI) official-partnership highlight (2026-07): UI-only accent,
// see featuredProviders.ts — never affects routing/fallback order.
const isKimiPartner = isKimiPartnerProviderId(provider.id || providerId);
const isCheaperInferencePartner = isCheaperInferenceProviderId(provider.id || providerId);
const isSponsorPartner = isKimiPartner || isCheaperInferencePartner;
const codexServiceTierLabel =
stats.codexServiceTier === "flex"
? providerText(t, "codexTierFlexLabel", "Flex")
@@ -273,24 +267,6 @@ const ProviderCard = forwardRef<ProviderCardHandle, ProviderCardProps>(function
</span>
) : null;
// Cheaper Inference Open Source Friend badge — brand green (#31f889) with a dark
// foreground (the green is too bright for white text). Literal Tailwind arbitrary
// values must stay in sync with CHEAPERINFERENCE_BRAND_COLOR (featuredProviders.ts).
const cheaperInferenceSupporterChip = isCheaperInferencePartner ? (
<span
key="cheaperinference-supporter"
className="inline-flex items-center gap-0.5 rounded-full border border-[#31f889]/40 bg-[#31f889]/15 px-1.5 py-0 text-[9px] font-semibold uppercase tracking-wide leading-none text-[#0b7a45] dark:text-[#5CF0A6]"
title={providerText(
t,
"cheaperInferenceSupporterTooltip",
"Cheaper Inference backs OmniRoute as an Open Source Friend"
)}
>
<span className="material-symbols-outlined text-[10px] leading-none">verified</span>
{providerText(t, "cheaperInferenceSupporterBadge", "Open Source Friend")}
</span>
) : null;
const dotLabels: Record<string, string> = {
free: tc("free"),
"no-auth": t("noAuthLabel"),
@@ -342,12 +318,7 @@ const ProviderCard = forwardRef<ProviderCardHandle, ProviderCardProps>(function
// is a raw (non-token) brand hex, not a theme color. Keep the hex in
// sync with KIMI_BRAND_COLOR (featuredProviders.ts).
"border-2 border-[#1783FF]/70 hover:border-[#1783FF]/90 shadow-[inset_0_0_0_100px_rgba(23,131,255,0.035),0_4px_16px_-4px_rgba(23,131,255,0.45)]"
: isCheaperInferencePartner
? // Cheaper Inference Open Source Friend accent — same construction in
// its brand green (#31f889 = rgb(49,248,137)). Keep in sync with
// CHEAPERINFERENCE_BRAND_COLOR (featuredProviders.ts).
"border-2 border-[#31f889]/70 hover:border-[#31f889]/90 shadow-[inset_0_0_0_100px_rgba(49,248,137,0.035),0_4px_16px_-4px_rgba(49,248,137,0.45)]"
: "hover:border-primary/40"
: "hover:border-primary/40"
} ${allDisabled ? "opacity-50" : ""} ${provider.deprecated ? "opacity-60" : ""}`}
>
<div className="flex flex-col gap-2 h-full">
@@ -421,24 +392,23 @@ const ProviderCard = forwardRef<ProviderCardHandle, ProviderCardProps>(function
isCompatible ||
isCcCompatible ||
isAnthropicCompatible ||
isSponsorPartner) && (
isKimiPartner) && (
<div className="flex flex-wrap items-center gap-1">
{kimiOfficialSupporterChip}
{cheaperInferenceSupporterChip}
{provider.serviceKinds?.map((k) => (
<span
key={k}
className="text-[10px] px-1.5 py-0.5 rounded bg-bg-subtle border border-border text-text-muted leading-none"
>
{kindLabel(k)}
{KIND_LABEL[k] ?? k}
</span>
))}
{isCompatible && (
<Badge variant="default" size="sm">
{provider.apiType === "responses"
? t("responses")
: kindLabel(COMPATIBLE_API_TYPE_KIND[provider.apiType ?? ""] ?? "") ||
t("chat")}
: (KIND_LABEL[COMPATIBLE_API_TYPE_KIND[provider.apiType ?? ""] ?? ""] ??
t("chat"))}
</Badge>
)}
{isCcCompatible && (

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