mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-09 00:32:13 +03:00
Compare commits
26 Commits
feat/plugi
...
fix/i18n-h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2925fde9f | ||
|
|
9a64e0e17d | ||
|
|
e2eba05e5c | ||
|
|
9e628e73fa | ||
|
|
9061d7c70d | ||
|
|
7163081f5e | ||
|
|
a72e1656eb | ||
|
|
84b1e5e12f | ||
|
|
92e8960f77 | ||
|
|
743ccc895a | ||
|
|
35405be602 | ||
|
|
cfb44dab91 | ||
|
|
d53f9bd813 | ||
|
|
e16865e394 | ||
|
|
8846f08c73 | ||
|
|
b532894a3e | ||
|
|
7b2e4b4837 | ||
|
|
fc35dc248f | ||
|
|
ec150a0069 | ||
|
|
564c204efe | ||
|
|
b38f3a4c02 | ||
|
|
0ef50886ef | ||
|
|
8fac6bcd48 | ||
|
|
45c91e22c2 | ||
|
|
1b5f7dd7e6 | ||
|
|
371c10ea5f |
197
.cbmignore
Normal file
197
.cbmignore
Normal file
@@ -0,0 +1,197 @@
|
||||
# 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
|
||||
@@ -1414,10 +1414,6 @@ 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)
|
||||
|
||||
11
.github/pull_request_template.md
vendored
11
.github/pull_request_template.md
vendored
@@ -9,11 +9,14 @@
|
||||
|
||||
## Validation
|
||||
|
||||
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):
|
||||
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):
|
||||
|
||||
- [ ] Focused tests for the change: `node --import tsx/esm --test tests/unit/<file>.test.ts`
|
||||
- [ ] Change type: provider / routing / UI / i18n / CLI / DB / build-deploy / other
|
||||
- [ ] Focused tests and category gates from the golden path
|
||||
- [ ] `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
|
||||
|
||||
@@ -29,4 +32,4 @@ Run only the focused loop for what you changed — the full unit suite, Vitest,
|
||||
|
||||
## 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.
|
||||
|
||||
104
.github/workflows/quality.yml
vendored
104
.github/workflows/quality.yml
vendored
@@ -179,6 +179,81 @@ 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
|
||||
@@ -364,6 +439,12 @@ 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:
|
||||
@@ -385,6 +466,29 @@ 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
7
.gitignore
vendored
@@ -171,7 +171,6 @@ config/quality/test-impact-map.json
|
||||
# GitNexus local index
|
||||
.gitnexus
|
||||
.worktrees
|
||||
bin/omniroute.mjs
|
||||
|
||||
# Consistent with .dockerignore / .npmignore
|
||||
.omc/
|
||||
@@ -201,7 +200,10 @@ scripts/i18n/_pending-keys.json
|
||||
.codegraph/
|
||||
|
||||
# Fumadocs generated source
|
||||
.source/
|
||||
/.source/
|
||||
|
||||
# Temporary local worktrees used to build unpublished npm tarballs
|
||||
/.deploy-build-*/
|
||||
|
||||
# AI agent local settings and configs
|
||||
.agents/
|
||||
@@ -250,3 +252,4 @@ tests/homolog/.auth/
|
||||
tests/homolog/ui/.auth/
|
||||
homolog-report/
|
||||
docker-compose.yml.bak
|
||||
.playwright-cli/
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
// @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"]}});
|
||||
@@ -1,22 +0,0 @@
|
||||
// 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
|
||||
};
|
||||
18
CLAUDE.md
18
CLAUDE.md
@@ -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 (95 files, 110 migrations) |
|
||||
| Database | `src/lib/db/` | SQLite domain modules (130 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`): 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.
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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 (12-factor scoring, 18 strategies) | `docs/routing/AUTO-COMBO.md` |
|
||||
| Auto-Combo (13-factor scoring, 19 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,10 +461,18 @@ 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##*/}"
|
||||
# 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
|
||||
# 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
|
||||
```
|
||||
|
||||
**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`.
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
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
|
||||
@@ -198,10 +203,11 @@ Coverage notes:
|
||||
|
||||
### Pull Request Requirements
|
||||
|
||||
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):
|
||||
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):
|
||||
|
||||
- Run the test files that cover your change: `node --import tsx/esm --test tests/unit/<file>.test.ts`
|
||||
- Run `npm run lint`
|
||||
@@ -271,7 +277,7 @@ src/ # TypeScript (.ts / .tsx)
|
||||
│ ├── a2a/ # Agent-to-Agent v0.3 protocol server
|
||||
│ ├── acp/ # Agent Communication Protocol registry
|
||||
│ ├── compliance/ # Compliance policy engine
|
||||
│ ├── db/ # SQLite database layer (21 modules + 16 migrations)
|
||||
│ ├── db/ # SQLite domain modules + 130 migrations
|
||||
│ ├── memory/ # Persistent conversational memory
|
||||
│ ├── oauth/ # OAuth providers, services, and utilities
|
||||
│ ├── skills/ # Extensible skill framework
|
||||
@@ -281,7 +287,7 @@ src/ # TypeScript (.ts / .tsx)
|
||||
├── mitm/ # MITM proxy (cert, DNS, target routing)
|
||||
├── shared/
|
||||
│ ├── components/ # React components (.tsx)
|
||||
│ ├── constants/ # Provider definitions (177), MCP scopes, 14 routing strategies
|
||||
│ ├── constants/ # Provider definitions (290), MCP scopes, 19 routing strategies
|
||||
│ ├── utils/ # Circuit breaker, sanitizer, auth helpers
|
||||
│ └── validation/ # Zod v4 schemas
|
||||
└── sse/ # SSE proxy pipeline
|
||||
@@ -289,7 +295,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 (25 tools, 3 transports, 10 scopes)
|
||||
├── mcp-server/ # MCP server (104 tools, 3 transports, 31 scopes)
|
||||
├── services/ # 36+ services (combo, autoCombo, rateLimitManager, etc.)
|
||||
├── translator/ # Format translators (OpenAI ↔ Claude ↔ Gemini ↔ Responses ↔ Ollama)
|
||||
├── transformer/ # Responses API transformer
|
||||
|
||||
41
README.md
41
README.md
@@ -241,12 +241,53 @@ 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/<model></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
|
||||
|
||||
1
changelog.d/fixes/9235-french-ui-catalog.md
Normal file
1
changelog.d/fixes/9235-french-ui-catalog.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(i18n):** completed the French UI catalog by adding all missing keys and replacing every placeholder translation ([#9235](https://github.com/diegosouzapw/OmniRoute/pull/9235)) — thanks @alex-jordan547
|
||||
1
changelog.d/fixes/9245-hardcoded-web-ui-i18n.md
Normal file
1
changelog.d/fixes/9245-hardcoded-web-ui-i18n.md
Normal file
@@ -0,0 +1 @@
|
||||
- **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
@@ -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": 2769,
|
||||
"tests/unit/chatcore-translation-paths.test.ts": 2776,
|
||||
"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": 1562,
|
||||
"open-sse/executors/base.ts": 1578,
|
||||
"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": 2887,
|
||||
"open-sse/utils/stream.ts": 2889,
|
||||
"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,5 +413,6 @@
|
||||
"_rebaseline_2026_07_28_8860_tokenrefresh_projectid": "PR #8860 (fix/antigravity-projectid-centralized) own test growth: tests/unit/token-refresh-service.test.ts 1311->1378 (+67 = 4 cases covering projectId discovery on the tokenRefresh.ts path — the Dashboard/health-check refresh route, which #8842 did not reach since that fixed the executor path). Covered by the same file.",
|
||||
"_rebaseline_2026_07_28_8861_xiaomi_token_plan": "PR #8861 (feat/xiaomi-token-plan-protocol-selector) own growth: EditConnectionModal.tsx 1283->1316 (+33 = the per-connection API-protocol selector field) and open-sse/executors/base.ts 1540->1562 (+22 = alternate-format resolution at the existing buildUrl/headers chokepoint). Both are irreducible wiring at existing call sites.",
|
||||
"_rebaseline_2026_07_28_8863_firefly_detail_level": "PR #8863 (fix/adobe-firefly-gpt-detail-level-max) own growth: adobeFireflyClient.ts 2317->2322 (+5 = gpt-image detailLevel defaulting to maximal at the existing payload-build site). Covered by tests/unit/adobe-firefly.test.ts.",
|
||||
"_rebaseline_2026_07_29_8281_home_quickstart_prefetch": "Release v3.8.49 base-red fix (no PR — captain sweep): src/app/(dashboard)/dashboard/HomePageClient.tsx 1377->1381 (+4). #8292 added prefetch={false} to the sidebar but left /home's five quick-start Links prefetching, so first paint still fired 12 speculative RSC requests — caught by navigation.spec.ts only after the e2e helper bug (APP_ROUTE_PATTERN missing /home) was repaired in the same cycle. Growth is the five prefetch attributes; it was offset first by extracting the repeated className literals (INLINE_LINK x4, DOCS_LINK x1), which collapsed five wrapped <Link> blocks back to one line each — a naive fix measured 1391. Guard: tests/unit/sidebar-prefetch-policy-8281.test.ts."
|
||||
"_rebaseline_2026_07_29_8281_home_quickstart_prefetch": "Release v3.8.49 base-red fix (no PR — captain sweep): src/app/(dashboard)/dashboard/HomePageClient.tsx 1377->1381 (+4). #8292 added prefetch={false} to the sidebar but left /home's five quick-start Links prefetching, so first paint still fired 12 speculative RSC requests — caught by navigation.spec.ts only after the e2e helper bug (APP_ROUTE_PATTERN missing /home) was repaired in the same cycle. Growth is the five prefetch attributes; it was offset first by extracting the repeated className literals (INLINE_LINK x4, DOCS_LINK x1), which collapsed five wrapped <Link> blocks back to one line each — a naive fix measured 1391. Guard: tests/unit/sidebar-prefetch-policy-8281.test.ts.",
|
||||
"_rebaseline_2026_08_02_v3850_agentrouter_responses": "Release v3.8.50 AgentRouter/Codex compatibility reconciliation. open-sse/executors/base.ts 1562->1578: #9190 wires AgentRouter's selected Claude/OpenAI/Responses protocol through the existing executor URL, auth, identity-header and fingerprint chokepoints; the reusable alternate resolver remains outside base.ts. open-sse/utils/stream.ts 2887->2889: #9213 evaluates Responses ID and usage normalization independently so response.completed always receives finite usage.total_tokens instead of short-circuiting after an ID rewrite. tests/unit/chatcore-translation-paths.test.ts 2769->2776: #9191 updates the existing Claude-Code bridge assertions for the dynamic AgentRouter wire image. PR #9224 offsets its own chatCore growth by extracting the AgentRouter protocol decisions into chatCore/agentRouterProtocol.ts, leaving chatCore below its frozen ceiling. Covered by agentrouter executor/chatCore protocol tests, chatcore translation-path tests, and responses-commentary-passthrough tests."
|
||||
}
|
||||
|
||||
229
docs/dev/CONTRIBUTION_GOLDEN_PATH.md
Normal file
229
docs/dev/CONTRIBUTION_GOLDEN_PATH.md
Normal file
@@ -0,0 +1,229 @@
|
||||
---
|
||||
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).
|
||||
@@ -461,10 +461,18 @@ 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##*/}"
|
||||
# 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
|
||||
# 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
|
||||
```
|
||||
|
||||
**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`.
|
||||
|
||||
@@ -415,10 +415,18 @@ 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:
|
||||
ln -s "$(git -C <main_checkout> rev-parse --show-toplevel)/node_modules" node_modules
|
||||
# 复用主工作区的 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 使用 `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` 不同分支。
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
# [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)
|
||||
@@ -414,7 +414,6 @@ 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. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 18
|
||||
`tests/integration/combo-matrix/*.test.ts` proves the routing **decision** of all 19
|
||||
public strategies end-to-end through the real combo pipeline with a mocked upstream.
|
||||
Coverage includes:
|
||||
|
||||
- All 18 `ROUTING_STRATEGY_VALUES` strategies (ordered, weighted, cost, context, fusion, …).
|
||||
- All 19 `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`).
|
||||
|
||||
91
docs/security/AGENTROUTER_WAF.md
Normal file
91
docs/security/AGENTROUTER_WAF.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# 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 ~5–10 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.
|
||||
@@ -13,6 +13,29 @@ 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,
|
||||
@@ -39,15 +62,32 @@ 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: [
|
||||
{
|
||||
name: "prop-types",
|
||||
message: "PropTypes are deprecated. Use TypeScript types/interfaces instead.",
|
||||
},
|
||||
],
|
||||
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],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
const DEFAULT_CODEX_CLIENT_VERSION = "0.144.1";
|
||||
import {
|
||||
DEFAULT_CODEX_CLIENT_VERSION,
|
||||
getCodexCliRsHeaders as buildCodexCliRsHeaders,
|
||||
} from "@/shared/constants/codexClient";
|
||||
|
||||
export { DEFAULT_CODEX_CLIENT_VERSION } from "@/shared/constants/codexClient";
|
||||
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";
|
||||
@@ -42,6 +47,10 @@ 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();
|
||||
|
||||
@@ -19,10 +19,9 @@ 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-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: "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: "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" },
|
||||
|
||||
@@ -11,6 +11,7 @@ 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;
|
||||
@@ -714,6 +715,13 @@ 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.
|
||||
|
||||
@@ -117,6 +117,7 @@ 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";
|
||||
@@ -337,6 +338,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
nscale: nscaleProvider,
|
||||
"chatgpt-web": chatgpt_webProvider,
|
||||
openrouter: openrouterProvider,
|
||||
cheaperinference: cheaperinferenceProvider,
|
||||
openvecta: openvectaProvider,
|
||||
orcarouter: orcarouterProvider,
|
||||
"copilot-web": copilot_webProvider,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import { getCodexCliRsHeaders } from "../../../codexClient.ts";
|
||||
|
||||
export const agentrouterProvider: RegistryEntry = {
|
||||
id: "agentrouter",
|
||||
@@ -8,6 +9,22 @@ 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
|
||||
@@ -15,10 +32,9 @@ 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-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" },
|
||||
{ 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" },
|
||||
],
|
||||
passthroughModels: true,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 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"],
|
||||
};
|
||||
247
open-sse/config/providers/registry/cheaperinference/index.ts
Normal file
247
open-sse/config/providers/registry/cheaperinference/index.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
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,
|
||||
};
|
||||
@@ -34,6 +34,7 @@ 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";
|
||||
@@ -45,6 +46,7 @@ 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,
|
||||
@@ -249,7 +251,10 @@ 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;
|
||||
@@ -445,6 +450,12 @@ 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.
|
||||
@@ -589,6 +600,15 @@ 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;
|
||||
@@ -834,15 +854,16 @@ export class BaseExecutor {
|
||||
);
|
||||
}
|
||||
|
||||
const ccRequestDefaults = isClaudeCodeCompatible(this.provider)
|
||||
const usesClaudeCodeProtocol = this.usesClaudeCodeProtocol(requestCredentials);
|
||||
const fingerprintProvider =
|
||||
usesCcWireImage(this.provider) && !usesClaudeCodeProtocol ? "codex" : this.provider;
|
||||
const ccRequestDefaults = usesClaudeCodeProtocol
|
||||
? getClaudeCodeCompatibleRequestDefaults(requestCredentials?.providerSpecificData)
|
||||
: {};
|
||||
const shouldForwardExtendedContext =
|
||||
extendedContext &&
|
||||
modelSupportsContext1mBeta(model) &&
|
||||
!isClaudeCodeCompatible(this.provider);
|
||||
extendedContext && modelSupportsContext1mBeta(model) && !usesClaudeCodeProtocol;
|
||||
const shouldForwardCcCompatibleContext1m =
|
||||
isClaudeCodeCompatible(this.provider) &&
|
||||
usesClaudeCodeProtocol &&
|
||||
ccRequestDefaults.context1m === true &&
|
||||
!modelHasNativeContext1m(model);
|
||||
if (shouldForwardExtendedContext || shouldForwardCcCompatibleContext1m) {
|
||||
@@ -922,8 +943,8 @@ export class BaseExecutor {
|
||||
!activeCredentials?.apiKey;
|
||||
|
||||
if (
|
||||
this.provider === "claude" &&
|
||||
(isClaudeCodeClient || hasClaudeOAuthToken) &&
|
||||
((this.provider === "claude" && (isClaudeCodeClient || hasClaudeOAuthToken)) ||
|
||||
usesClaudeCodeProtocol) &&
|
||||
typeof transformedBody === "object" &&
|
||||
transformedBody !== null
|
||||
) {
|
||||
@@ -1200,6 +1221,11 @@ 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
|
||||
@@ -1246,7 +1272,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" || isClaudeCodeCompatible(this.provider);
|
||||
const isClaude = this.provider === "claude" || usesClaudeCodeProtocol;
|
||||
// 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).
|
||||
@@ -1265,7 +1291,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" || isClaudeCodeCompatible(this.provider)) {
|
||||
if (this.provider === "claude" || usesClaudeCodeProtocol) {
|
||||
enforceThinkingTemperature(transformedBody as Record<string, unknown>);
|
||||
}
|
||||
|
||||
@@ -1282,7 +1308,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" || isClaudeCodeCompatible(this.provider)) &&
|
||||
(this.provider === "claude" || usesClaudeCodeProtocol) &&
|
||||
contextEditing?.enabled &&
|
||||
!contextEditingDisabled
|
||||
) {
|
||||
@@ -1298,17 +1324,17 @@ export class BaseExecutor {
|
||||
let bodyString = JSON.stringify(transformedBody);
|
||||
|
||||
const shouldFingerprint =
|
||||
isCliCompatEnabled(this.provider) ||
|
||||
isCliCompatEnabled(fingerprintProvider) ||
|
||||
(this.provider === "claude" && (isClaudeCodeClient || hasClaudeOAuthToken));
|
||||
if (shouldFingerprint) {
|
||||
const fingerprinted = applyFingerprint(this.provider, headers, transformedBody);
|
||||
const fingerprinted = applyFingerprint(fingerprintProvider, 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 (isClaudeCodeCompatible(this.provider) || this.provider === "claude") {
|
||||
if (usesClaudeCodeProtocol || this.provider === "claude") {
|
||||
bodyString = await signRequestBody(bodyString);
|
||||
}
|
||||
|
||||
@@ -1373,6 +1399,13 @@ 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) {
|
||||
@@ -1396,7 +1429,7 @@ export class BaseExecutor {
|
||||
contextEditingDisabled = true;
|
||||
delete (transformedBody as Record<string, unknown>).context_management;
|
||||
let retryBody = JSON.stringify(transformedBody);
|
||||
if (isClaudeCodeCompatible(this.provider) || this.provider === "claude") {
|
||||
if (usesClaudeCodeProtocol || this.provider === "claude") {
|
||||
retryBody = await signRequestBody(retryBody);
|
||||
}
|
||||
log?.debug?.(
|
||||
@@ -1435,7 +1468,7 @@ export class BaseExecutor {
|
||||
thinkingBudgetClampedMax = upstreamMax;
|
||||
if (clampNestedThinkingBudget(transformedBody, upstreamMax)) {
|
||||
let retryBody = JSON.stringify(transformedBody);
|
||||
if (isClaudeCodeCompatible(this.provider) || this.provider === "claude") {
|
||||
if (usesClaudeCodeProtocol || this.provider === "claude") {
|
||||
retryBody = await signRequestBody(retryBody);
|
||||
}
|
||||
log?.info?.(
|
||||
@@ -1466,7 +1499,7 @@ export class BaseExecutor {
|
||||
strippedFields.add(offending);
|
||||
delete (transformedBody as Record<string, unknown>)[offending];
|
||||
let retryBody = JSON.stringify(transformedBody);
|
||||
if (isClaudeCodeCompatible(this.provider) || this.provider === "claude") {
|
||||
if (usesClaudeCodeProtocol || this.provider === "claude") {
|
||||
retryBody = await signRequestBody(retryBody);
|
||||
}
|
||||
log?.debug?.(
|
||||
@@ -1491,7 +1524,7 @@ export class BaseExecutor {
|
||||
addParamToBlocklist(this.provider, autoLearned, model);
|
||||
delete (transformedBody as Record<string, unknown>)[autoLearned];
|
||||
let retryBody = JSON.stringify(transformedBody);
|
||||
if (isClaudeCodeCompatible(this.provider) || this.provider === "claude") {
|
||||
if (usesClaudeCodeProtocol || this.provider === "claude") {
|
||||
retryBody = await signRequestBody(retryBody);
|
||||
}
|
||||
log?.info?.(
|
||||
@@ -1510,6 +1543,34 @@ 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 &&
|
||||
|
||||
69
open-sse/executors/cheaperinference.ts
Normal file
69
open-sse/executors/cheaperinference.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
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;
|
||||
@@ -55,6 +55,7 @@ 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";
|
||||
|
||||
@@ -291,6 +292,10 @@ 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: {
|
||||
@@ -413,7 +418,7 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
applyClineAuthHeaders(headers, credentials, effectiveKey, clientHeaders, false);
|
||||
break;
|
||||
default:
|
||||
if (isClaudeCodeCompatible(this.provider)) {
|
||||
if (this.usesClaudeCodeProtocol(credentials)) {
|
||||
const ccRequestDefaults = getClaudeCodeCompatibleRequestDefaults(
|
||||
credentials?.providerSpecificData
|
||||
);
|
||||
@@ -423,6 +428,10 @@ 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);
|
||||
|
||||
@@ -50,6 +50,7 @@ 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";
|
||||
@@ -165,6 +166,8 @@ 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(),
|
||||
@@ -282,4 +285,5 @@ 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";
|
||||
|
||||
@@ -71,6 +71,7 @@ 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";
|
||||
@@ -341,7 +342,6 @@ 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,6 +751,7 @@ export async function handleChatCore({
|
||||
provider,
|
||||
resolvedModel,
|
||||
apiFormat,
|
||||
sourceFormat,
|
||||
customModelTargetFormat,
|
||||
providerSpecificData: credentials?.providerSpecificData,
|
||||
});
|
||||
@@ -1920,7 +1921,7 @@ export async function handleChatCore({
|
||||
|
||||
let translatedBody = body;
|
||||
const isClaudePassthrough = sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE;
|
||||
const isClaudeCodeCompatible = isClaudeCodeCompatibleProvider(provider);
|
||||
const isClaudeCodeCompatible = usesClaudeBridge(provider, targetFormat, credentials);
|
||||
const isClaudeCodeSemanticPassthrough = isClaudeCodeSemanticPassthroughRequest({
|
||||
provider,
|
||||
sourceFormat,
|
||||
@@ -2496,10 +2497,7 @@ export async function handleChatCore({
|
||||
log?.debug?.("PARAMS", `Renamed max_completion_tokens to max_tokens for ${model}`);
|
||||
}
|
||||
|
||||
// OpenAI's `store` parameter is not supported by most compatible providers and breaks them
|
||||
if (provider !== "openai" && "store" in translatedBody) {
|
||||
delete translatedBody.store;
|
||||
}
|
||||
stripStore(translatedBody, provider, targetFormat);
|
||||
|
||||
// Chat clients may send stream_options.include_usage, but OpenAI Responses
|
||||
// upstreams (including Azure AI Foundry /responses) reject stream_options.
|
||||
|
||||
35
open-sse/handlers/chatCore/agentRouterProtocol.ts
Normal file
35
open-sse/handlers/chatCore/agentRouterProtocol.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -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, and threads the Claude Code session id when present.
|
||||
* Side-effect-free; behaviour is byte-identical to the previous inline closure.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { getKimiCodeStaticThinkingPolicy } from "../../config/providers/registry/kimi/coding/runtime.ts";
|
||||
@@ -128,6 +128,16 @@ 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 = {
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
*
|
||||
* 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 the provider default. Returns both `alias` (reused by
|
||||
* 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 handler when stripping the `alias/` prefix off the upstream model id) and `targetFormat`.
|
||||
* Side-effect-free; byte-identical to the previous inline block. Sits alongside the other
|
||||
* request-setup resolvers (resolveChatCoreRequestSetup / resolveChatCoreRequestFormat).
|
||||
* Side-effect-free; sits alongside the other request-setup resolvers
|
||||
* (resolveChatCoreRequestSetup / resolveChatCoreRequestFormat).
|
||||
*/
|
||||
|
||||
import { PROVIDER_ID_TO_ALIAS, getModelTargetFormat } from "../../config/providerModels.ts";
|
||||
@@ -18,16 +19,38 @@ export function resolveChatCoreTargetFormat(opts: {
|
||||
provider: string;
|
||||
resolvedModel: string;
|
||||
apiFormat: string | undefined;
|
||||
sourceFormat?: string;
|
||||
customModelTargetFormat: string | undefined;
|
||||
providerSpecificData: unknown;
|
||||
}) {
|
||||
const { provider, resolvedModel, apiFormat, customModelTargetFormat, providerSpecificData } = opts;
|
||||
const {
|
||||
provider,
|
||||
resolvedModel,
|
||||
apiFormat,
|
||||
sourceFormat,
|
||||
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 || getTargetFormat(provider, providerSpecificData);
|
||||
: modelTargetFormat ||
|
||||
customModelTargetFormat ||
|
||||
inferredAgentRouterTargetFormat ||
|
||||
getTargetFormat(provider, providerSpecificData);
|
||||
return { alias, targetFormat };
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
68
open-sse/services/combo/strategyDispatch.ts
Normal file
68
open-sse/services/combo/strategyDispatch.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
// 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;
|
||||
@@ -1,3 +1,35 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -5,16 +37,48 @@ export function shouldUseGrokBrowserBacked(): boolean {
|
||||
return poolFlag === "on" || poolFlag === "1" || poolFlag === "true";
|
||||
}
|
||||
|
||||
let grokClearanceAcquireOverride: ((signal?: AbortSignal) => Promise<string | null>) | null = null;
|
||||
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;
|
||||
|
||||
export function __setGrokClearanceAcquireOverrideForTesting(
|
||||
fn: ((signal?: AbortSignal) => Promise<string | null>) | null,
|
||||
fn: AcquireGrokClearanceFn | null
|
||||
): void {
|
||||
grokClearanceAcquireOverride = fn;
|
||||
acquireOverride = fn;
|
||||
}
|
||||
|
||||
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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +120,44 @@ for (const [aliasOrId, models] of Object.entries(PROVIDER_MODELS)) {
|
||||
}
|
||||
}
|
||||
const KNOWN_MODEL_IDS = new Set(MODEL_TO_PROVIDERS.keys());
|
||||
export const CODEX_NATIVE_UNPREFIXED_MODELS = new Set(["codex-auto-review"]);
|
||||
// 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",
|
||||
]);
|
||||
|
||||
interface ProviderConnectionLike {
|
||||
provider?: unknown;
|
||||
@@ -534,7 +571,19 @@ async function resolveModelByProviderInference(modelId: string, extendedContext:
|
||||
getActiveSyncedProvidersForModel(modelId),
|
||||
getPreferClaudeCodeForUnprefixedClaudeModels(),
|
||||
]);
|
||||
const providers = getInferredProvidersForModel(modelId, activeSyncedProviders);
|
||||
// #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 nonOpenAIProviders = providers.filter((p) => p !== "openai");
|
||||
|
||||
// Bare model IDs from Codex CLI do not preserve OmniRoute's `cx/` prefix.
|
||||
|
||||
76
open-sse/services/wafRateLimit.ts
Normal file
76
open-sse/services/wafRateLimit.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
103
open-sse/utils/functionalGatewayMirrors.ts
Normal file
103
open-sse/utils/functionalGatewayMirrors.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { parseSSEDataPayload } from "./streamHelpers.ts";
|
||||
import {
|
||||
backfillResponsesCompletedOutput,
|
||||
normalizeResponsesSseIds,
|
||||
normalizeResponsesCompletedUsage,
|
||||
pushUniqueResponsesOutputItems,
|
||||
stringifyIdValue,
|
||||
stripResponsesLifecycleEcho,
|
||||
@@ -174,13 +175,20 @@ 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) {
|
||||
if (
|
||||
stripped ||
|
||||
backfilled ||
|
||||
textualToolCallBackfilled ||
|
||||
responsesIdsNormalized ||
|
||||
usageNormalized
|
||||
) {
|
||||
output = `data: ${JSON.stringify(outputPayload)}\n\n`;
|
||||
}
|
||||
|
||||
|
||||
@@ -138,6 +138,49 @@ 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",
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
} from "../services/sessionManager.ts";
|
||||
import {
|
||||
backfillResponsesCompletedOutput,
|
||||
normalizeResponsesCompletedUsage as normalizeUsage,
|
||||
normalizeResponsesSseIds,
|
||||
pushUniqueResponsesOutputItems,
|
||||
stringifyIdValue,
|
||||
@@ -1581,11 +1582,13 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
parsed,
|
||||
passthroughResponsesOutputItems
|
||||
);
|
||||
const usageNormalized = normalizeUsage(parsed);
|
||||
if (
|
||||
stripped ||
|
||||
backfilled ||
|
||||
textualToolCallBackfilled ||
|
||||
responsesIdsNormalized
|
||||
responsesIdsNormalized ||
|
||||
usageNormalized
|
||||
) {
|
||||
output = `data: ${JSON.stringify(parsed)}\n\n`;
|
||||
injectedUsage = true;
|
||||
@@ -2273,7 +2276,6 @@ 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 =
|
||||
@@ -2281,7 +2283,9 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
const isResponses = flushedType.startsWith("response.");
|
||||
const isClaude = isClaudeEventPayload(flushedParsed);
|
||||
if (isResponses) {
|
||||
if (normalizeResponsesSseIds(flushedParsed)) {
|
||||
const idsNormalized = normalizeResponsesSseIds(flushedParsed);
|
||||
const usageNormalized = normalizeUsage(flushedParsed);
|
||||
if (idsNormalized || usageNormalized) {
|
||||
output = `data: ${JSON.stringify(flushedParsed)}\n\n`;
|
||||
}
|
||||
} else if (!isClaude) {
|
||||
|
||||
@@ -40,7 +40,6 @@ 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
112
package-lock.json
generated
@@ -10,8 +10,7 @@
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"open-sse",
|
||||
"packages/browser-pool"
|
||||
"open-sse"
|
||||
],
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.1073.0",
|
||||
@@ -3693,6 +3692,9 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3709,6 +3711,9 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3725,6 +3730,9 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3741,6 +3749,9 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3757,6 +3768,9 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3773,6 +3787,9 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3789,6 +3806,9 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3805,6 +3825,9 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3821,6 +3844,9 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3843,6 +3869,9 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3865,6 +3894,9 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3887,6 +3919,9 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3909,6 +3944,9 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3931,6 +3969,9 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3953,6 +3994,9 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3975,6 +4019,9 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6283,10 +6330,6 @@
|
||||
"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
|
||||
@@ -10696,6 +10739,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -10713,6 +10759,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -10730,6 +10779,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -10747,6 +10799,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -12839,6 +12894,9 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -12852,6 +12910,9 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -12865,6 +12926,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -12878,6 +12942,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -12891,6 +12958,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -12904,6 +12974,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -37012,33 +37085,6 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
11
package.json
11
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "3.8.50",
|
||||
"description": "Unified AI router with 160+ providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.",
|
||||
"description": "Unified AI router with 290 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"omniroute": "bin/omniroute.mjs",
|
||||
@@ -47,8 +47,7 @@
|
||||
"!**/*.spec.tsx"
|
||||
],
|
||||
"workspaces": [
|
||||
"open-sse",
|
||||
"packages/browser-pool"
|
||||
"open-sse"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=22.22.2 <23 || >=24.0.0 <27"
|
||||
@@ -111,6 +110,8 @@
|
||||
"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\"",
|
||||
@@ -304,6 +305,7 @@
|
||||
"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",
|
||||
@@ -323,8 +325,7 @@
|
||||
"js-tiktoken": "^1.0.20",
|
||||
"keytar": "^7.9.0",
|
||||
"tls-client-node": "^0.2.0",
|
||||
"wreq-js": "^2.3.1",
|
||||
"sqlite-vec": "^0.1.9"
|
||||
"wreq-js": "^2.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.11.3",
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* @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";
|
||||
@@ -1,94 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,461 +0,0 @@
|
||||
/**
|
||||
* 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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,440 +0,0 @@
|
||||
/**
|
||||
* 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) };
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
packages:
|
||||
- "packages/*"
|
||||
- "open-sse"
|
||||
allowBuilds:
|
||||
"@parcel/watcher": true
|
||||
|
||||
7
public/providers/cheaperinference.svg
Normal file
7
public/providers/cheaperinference.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 586 B |
@@ -9,13 +9,17 @@
|
||||
// não resolve para um executor válido é um símbolo morto (roteia para fallback
|
||||
// silencioso em vez de falhar).
|
||||
//
|
||||
// (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.
|
||||
// (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.
|
||||
//
|
||||
// (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:
|
||||
@@ -483,21 +487,15 @@ async function main(): Promise<void> {
|
||||
...(strategiesMod.ROUTING_STRATEGY_VALUES as readonly string[]),
|
||||
...(strategiesMod.INTERNAL_ROUTING_STRATEGY_VALUES 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);
|
||||
// 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[]);
|
||||
|
||||
// Stale-enforcement (6A.3): IMPLICIT_DEFAULT_STRATEGIES is a suppression allowlist —
|
||||
// each entry exists ONLY to suppress a `canonicalNotHandled` violation (a canonical
|
||||
|
||||
111
scripts/quality/test-scoped.sh
Executable file
111
scripts/quality/test-scoped.sh
Executable file
@@ -0,0 +1,111 @@
|
||||
#!/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[@]}"
|
||||
@@ -27,22 +27,17 @@ 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">
|
||||
Running in zero-config mode
|
||||
{t("zeroConfigBannerTitle")}
|
||||
</p>
|
||||
<p className="mt-0.5 text-amber-800/80 dark:text-amber-200/80">
|
||||
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.
|
||||
{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>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -144,31 +144,31 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
const cleanLatest = latest.replace(/^v/, "");
|
||||
if (platform === "darwin") {
|
||||
return {
|
||||
label: "Download DMG (macOS)",
|
||||
label: t("downloadDmg"),
|
||||
url: `https://github.com/diegosouzapw/OmniRoute/releases/download/v${cleanLatest}/OmniRoute-${cleanLatest}.dmg`,
|
||||
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 || ""}).`,
|
||||
desc: t("downloadDmgDescription", { version: versionInfo?.current || "" }),
|
||||
};
|
||||
}
|
||||
if (platform === "win32") {
|
||||
return {
|
||||
label: "Download EXE (Windows)",
|
||||
label: t("downloadExe"),
|
||||
url: `https://github.com/diegosouzapw/OmniRoute/releases/download/v${cleanLatest}/OmniRoute.Setup.${cleanLatest}.exe`,
|
||||
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 || ""}).`,
|
||||
desc: t("downloadExeDescription", { version: versionInfo?.current || "" }),
|
||||
};
|
||||
}
|
||||
if (platform === "linux") {
|
||||
return {
|
||||
label: "Download AppImage (Linux)",
|
||||
label: t("downloadAppImage"),
|
||||
url: `https://github.com/diegosouzapw/OmniRoute/releases/download/v${cleanLatest}/OmniRoute-${cleanLatest}.AppImage`,
|
||||
desc: `A new version of the OmniRoute desktop app is available. Please download the Linux AppImage package to update (current: v${versionInfo?.current || ""}).`,
|
||||
desc: t("downloadAppImageDescription", { version: versionInfo?.current || "" }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: "Download Update",
|
||||
label: t("downloadUpdate"),
|
||||
url: `https://github.com/diegosouzapw/OmniRoute/releases/tag/v${cleanLatest}`,
|
||||
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 || ""}).`,
|
||||
desc: t("downloadUpdateDescription", { version: versionInfo?.current || "" }),
|
||||
};
|
||||
}, [platform, versionInfo?.latest, versionInfo?.current]);
|
||||
}, [platform, t, 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 || `Queued update to v${targetVersion}.`,
|
||||
message: message || t("updateQueued", { version: targetVersion }),
|
||||
},
|
||||
{
|
||||
step: "rebuild",
|
||||
status: "running",
|
||||
message: "Docker image is rebuilding in the background.",
|
||||
message: t("updateDockerRebuilding"),
|
||||
},
|
||||
{
|
||||
step: "restart",
|
||||
status: "pending",
|
||||
message: "Waiting for OmniRoute to restart with the new version.",
|
||||
message: t("updateWaitingRestart"),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
step: "install",
|
||||
status: "running",
|
||||
message: message || `Installing v${targetVersion}.`,
|
||||
message: message || t("updateInstalling", { version: targetVersion }),
|
||||
},
|
||||
{
|
||||
step: "restart",
|
||||
status: "pending",
|
||||
message: "Waiting for OmniRoute to restart with the new version.",
|
||||
message: t("updateWaitingRestart"),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -593,14 +593,14 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
next = mergeUpdateStep(next, {
|
||||
step: "complete",
|
||||
status: "done",
|
||||
message: `OmniRoute is now running v${targetVersion}.`,
|
||||
message: t("updateRunning", { version: targetVersion }),
|
||||
});
|
||||
|
||||
return next;
|
||||
});
|
||||
setUpdating(false);
|
||||
setUpdatePhase("done");
|
||||
notify.success(`OmniRoute updated to v${targetVersion}.`);
|
||||
notify.success(t("updateCompleted", { version: targetVersion }));
|
||||
await fetchData();
|
||||
return;
|
||||
}
|
||||
@@ -611,20 +611,20 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
next = mergeUpdateStep(next, {
|
||||
step: "rebuild",
|
||||
status: "running",
|
||||
message: `Docker image is still rebuilding for v${targetVersion}.`,
|
||||
message: t("updateDockerStillRebuilding", { version: targetVersion }),
|
||||
});
|
||||
} else {
|
||||
next = mergeUpdateStep(next, {
|
||||
step: "install",
|
||||
status: "running",
|
||||
message: `Installing v${targetVersion} in the background.`,
|
||||
message: t("updateInstallingBackground", { version: targetVersion }),
|
||||
});
|
||||
}
|
||||
|
||||
next = mergeUpdateStep(next, {
|
||||
step: "restart",
|
||||
status: "pending",
|
||||
message: `Waiting for OmniRoute to come back on v${targetVersion}.`,
|
||||
message: t("updateWaitingVersion", { version: targetVersion }),
|
||||
});
|
||||
|
||||
return next;
|
||||
@@ -636,20 +636,20 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
next = mergeUpdateStep(next, {
|
||||
step: "rebuild",
|
||||
status: "running",
|
||||
message: "Docker rebuild is still in progress.",
|
||||
message: t("updateDockerStillInProgress"),
|
||||
});
|
||||
} else {
|
||||
next = mergeUpdateStep(next, {
|
||||
step: "install",
|
||||
status: "running",
|
||||
message: `Installing v${targetVersion} in the background.`,
|
||||
message: t("updateInstallingBackground", { version: targetVersion }),
|
||||
});
|
||||
}
|
||||
|
||||
next = mergeUpdateStep(next, {
|
||||
step: "restart",
|
||||
status: "running",
|
||||
message: "Service restart in progress. Waiting for OmniRoute to come back online...",
|
||||
message: t("updateRestarting"),
|
||||
});
|
||||
|
||||
return next;
|
||||
@@ -661,14 +661,14 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
mergeUpdateStep(prev, {
|
||||
step: "error",
|
||||
status: "failed",
|
||||
message: `Update started, but v${targetVersion} did not become available before timeout. Refresh the page or check server logs.`,
|
||||
message: t("updateTimeout", { version: targetVersion }),
|
||||
})
|
||||
);
|
||||
setUpdating(false);
|
||||
setUpdatePhase("failed");
|
||||
notify.error(`Update to v${targetVersion} timed out.`);
|
||||
notify.error(t("updateTimedOut", { version: targetVersion }));
|
||||
},
|
||||
[fetchData]
|
||||
[fetchData, t]
|
||||
);
|
||||
|
||||
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, "Failed to start update."));
|
||||
notify.error(extractApiErrorMessage(data, t("updateStartFailed")));
|
||||
setUpdating(false);
|
||||
setUpdatePhase("idle");
|
||||
return;
|
||||
}
|
||||
notify.success(data.message || "Update started.");
|
||||
notify.success(data.message || t("updateStarted"));
|
||||
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("No response stream received.");
|
||||
notify.error(t("noResponseStream"));
|
||||
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 || "Update complete!");
|
||||
notify.success(event.message || t("updateComplete"));
|
||||
} else if (event.step === "error") {
|
||||
setUpdatePhase("failed");
|
||||
notify.error(event.message || "Update failed.");
|
||||
notify.error(event.message || t("updateFailed"));
|
||||
setUpdating(false);
|
||||
}
|
||||
} catch {
|
||||
@@ -753,7 +753,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
{
|
||||
step: "error",
|
||||
status: "failed",
|
||||
message: "Network error — connection lost during update.",
|
||||
message: t("updateNetworkError"),
|
||||
},
|
||||
]);
|
||||
setUpdating(false);
|
||||
@@ -769,11 +769,11 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
return () => clearTimeout(timer);
|
||||
}, [updatePhase]);
|
||||
const stepLabels: Record<string, string> = {
|
||||
install: "Install Package",
|
||||
rebuild: "Rebuild Native Modules",
|
||||
restart: "Restart Service",
|
||||
complete: "Complete",
|
||||
error: "Error",
|
||||
install: t("stepInstallPackage"),
|
||||
rebuild: t("stepRebuildNativeModules"),
|
||||
restart: t("stepRestartService"),
|
||||
complete: t("stepComplete"),
|
||||
error: t("stepError"),
|
||||
};
|
||||
const showUpdateOverlay = updatePhase !== "idle";
|
||||
|
||||
@@ -801,17 +801,17 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
<div>
|
||||
<h3 className="text-lg font-bold">
|
||||
{updatePhase === "done"
|
||||
? "Update Complete!"
|
||||
? t("updateCompleteTitle")
|
||||
: updatePhase === "failed"
|
||||
? "Update Failed"
|
||||
: "Updating OmniRoute..."}
|
||||
? t("updateFailedTitle")
|
||||
: t("updatingTitle")}
|
||||
</h3>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
{updatePhase === "done"
|
||||
? "The page will reload automatically in a few seconds."
|
||||
? t("reloadNotice")
|
||||
: updatePhase === "failed"
|
||||
? "Please try again or update manually via the CLI."
|
||||
: "Do not close this page. The system will restart automatically."}
|
||||
? t("retryNotice")
|
||||
: t("restartNotice")}
|
||||
</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 || "Update complete!"}
|
||||
{updateSteps.find((s) => s.step === "complete")?.message || t("updateComplete")}
|
||||
</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" ? "Reload Now" : "Close"}
|
||||
{updatePhase === "done" ? t("reloadNow") : t("closeUpdate")}
|
||||
</Button>
|
||||
{updatePhase === "failed" && (
|
||||
<Button size="sm" variant="secondary" fullWidth onClick={handleUpdate}>
|
||||
Retry
|
||||
{t("retryUpdate")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -917,30 +917,32 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-semibold text-sm">
|
||||
Update Available: v{versionInfo.latest} {isElectron && "(Desktop App)"}
|
||||
{t("updateAvailableTitle", {
|
||||
version: versionInfo.latest,
|
||||
desktop: isElectron ? ` ${t("desktopAppLabel")}` : "",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs opacity-80 mt-0.5">
|
||||
{isElectron ? (
|
||||
<>
|
||||
{electronUpdateStatus.status === "checking" && "Checking for updates..."}
|
||||
{electronUpdateStatus.status === "checking" && t("checkingForUpdates")}
|
||||
{electronUpdateStatus.status === "available" &&
|
||||
`Version v${versionInfo.latest} is available for download.`}
|
||||
t("versionAvailableForDownload", { version: versionInfo.latest })}
|
||||
{electronUpdateStatus.status === "downloading" &&
|
||||
`Downloading update... ${electronUpdateStatus.percent || 0}% complete.`}
|
||||
{electronUpdateStatus.status === "downloaded" &&
|
||||
"Update downloaded successfully! Click Restart & Install to apply."}
|
||||
t("downloadingUpdate", { percent: electronUpdateStatus.percent || 0 })}
|
||||
{electronUpdateStatus.status === "downloaded" && t("updateDownloaded")}
|
||||
{electronUpdateStatus.status === "error" &&
|
||||
`Auto-update failed: ${electronUpdateStatus.message || "Unknown error"}.`}
|
||||
t("autoUpdateFailed", {
|
||||
reason: electronUpdateStatus.message || t("unknownUpdateError"),
|
||||
})}
|
||||
{(electronUpdateStatus.status === "idle" ||
|
||||
electronUpdateStatus.status === "not-available") &&
|
||||
`Version v${versionInfo.latest} is available for the desktop app.`}
|
||||
t("versionAvailableDesktop", { version: versionInfo.latest })}
|
||||
</>
|
||||
) : versionInfo.autoUpdateSupported ? (
|
||||
t("updateAvailableDesc") ||
|
||||
`You are currently using v${versionInfo.current}. Update to access the latest features and bug fixes.`
|
||||
t("updateAvailableDesc")
|
||||
) : (
|
||||
versionInfo.autoUpdateError ||
|
||||
"Manual update required for this installation type."
|
||||
versionInfo.autoUpdateError || t("manualUpdateRequired")
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -954,7 +956,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
onClick={() => globalThis.window.electronAPI?.downloadUpdate()}
|
||||
className="font-semibold"
|
||||
>
|
||||
Download Update
|
||||
{t("downloadUpdate")}
|
||||
</Button>
|
||||
)}
|
||||
{electronUpdateStatus.status === "downloading" && (
|
||||
@@ -973,7 +975,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
onClick={() => globalThis.window.electronAPI?.installUpdate()}
|
||||
className="font-semibold animate-pulse"
|
||||
>
|
||||
Restart & Install
|
||||
{t("restartAndInstall")}
|
||||
</Button>
|
||||
)}
|
||||
{(electronUpdateStatus.status === "error" ||
|
||||
@@ -989,7 +991,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
}}
|
||||
className="font-semibold"
|
||||
>
|
||||
Check for Update
|
||||
{t("checkForUpdate")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1001,9 +1003,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
className="ml-4 shrink-0 font-semibold"
|
||||
title={versionInfo.autoUpdateError || ""}
|
||||
>
|
||||
{versionInfo.autoUpdateSupported
|
||||
? t("updateNow") || "Update Now"
|
||||
: "Manual Update"}
|
||||
{versionInfo.autoUpdateSupported ? t("updateNow") : t("manualUpdate")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1015,9 +1015,7 @@ 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">
|
||||
Or download the respective installer format directly:
|
||||
</p>
|
||||
<p className="text-xs opacity-75">{t("directDownloadHint")}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -1029,7 +1027,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
||||
}
|
||||
className="font-semibold text-xs py-1"
|
||||
>
|
||||
Release Notes
|
||||
{t("releaseNotes")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -1067,7 +1065,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 || "Ler Mais"}
|
||||
{versionInfo.news.linkLabel || t("readMore")}
|
||||
<span className="material-symbols-outlined text-[14px]">arrow_forward</span>
|
||||
</a>
|
||||
)}
|
||||
@@ -1214,7 +1212,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: "No Auth" },
|
||||
"no-auth": { color: "bg-stone-500", label: t("noAuthLabel") },
|
||||
free: { color: "bg-green-500", label: tc("free") },
|
||||
oauth: { color: "bg-blue-500", label: t("oauthLabel") },
|
||||
apikey: { color: "bg-amber-500", label: t("apiKeyLabel") },
|
||||
|
||||
@@ -42,9 +42,7 @@ export default function AutoRoutingAnalyticsTab() {
|
||||
if (!stats) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-8 text-text-muted">
|
||||
No auto-routing analytics available. Make requests using the auto/ prefix to see metrics.
|
||||
</div>
|
||||
<div className="text-center py-8 text-text-muted">{t("autoRoutingNoDataAvailable")}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -110,7 +108,9 @@ 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 || "default"}</div>
|
||||
<div className="w-32 text-sm font-medium capitalize">
|
||||
{variant || t("defaultVariantLabel")}
|
||||
</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">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>
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -50,11 +50,13 @@ 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 (
|
||||
@@ -62,7 +64,7 @@ function ProviderBar({
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium text-text">{provider}</span>
|
||||
<span className="text-text-muted">
|
||||
{count} queries · ${costUsd.toFixed(4)}
|
||||
{count} {queriesLabel} · ${costUsd.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-bg-muted overflow-hidden">
|
||||
@@ -99,7 +101,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>
|
||||
Loading search analytics…
|
||||
{t("searchAnalyticsLoading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -108,9 +110,11 @@ 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 || "No search data available yet."}
|
||||
{error || t("searchAnalyticsNoData")}
|
||||
<p className="text-xs mt-2">
|
||||
Search requests will appear here after the first search via /v1/search.
|
||||
{t.rich("searchAnalyticsNoDataDescription", {
|
||||
code: (chunks) => <code className="bg-bg-muted px-1 rounded">{chunks}</code>,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -126,25 +130,29 @@ export default function SearchAnalyticsTab() {
|
||||
icon="manage_search"
|
||||
label={t("searchAnalyticsTotalSearches")}
|
||||
value={stats.total.toLocaleString()}
|
||||
sub={`${stats.today} today`}
|
||||
sub={t("searchAnalyticsToday", { count: stats.today })}
|
||||
/>
|
||||
<StatCard
|
||||
icon="cached"
|
||||
label={t("searchAnalyticsCacheHitRate")}
|
||||
value={`${stats.cacheHitRate}%`}
|
||||
sub={`${stats.cached} cached requests`}
|
||||
sub={t("searchAnalyticsCachedRequests", { count: stats.cached })}
|
||||
/>
|
||||
<StatCard
|
||||
icon="attach_money"
|
||||
label={t("searchAnalyticsTotalCost")}
|
||||
value={`$${stats.totalCostUsd.toFixed(4)}`}
|
||||
sub="search API costs"
|
||||
sub={t("searchAnalyticsApiCosts")}
|
||||
/>
|
||||
<StatCard
|
||||
icon="timer"
|
||||
label={t("searchAnalyticsAvgResponse")}
|
||||
value={`${stats.avgDurationMs}ms`}
|
||||
sub={stats.errors > 0 ? `${stats.errors} errors` : "No errors"}
|
||||
sub={
|
||||
stats.errors > 0
|
||||
? t("searchAnalyticsErrors", { count: stats.errors })
|
||||
: t("searchAnalyticsNoErrors")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -153,7 +161,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>
|
||||
Provider Breakdown
|
||||
{t("searchAnalyticsProviderBreakdown")}
|
||||
</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
{providers.map(([prov, data]) => (
|
||||
@@ -163,6 +171,7 @@ export default function SearchAnalyticsTab() {
|
||||
count={data.count}
|
||||
total={stats.total}
|
||||
costUsd={data.costUsd}
|
||||
queriesLabel={t("searchAnalyticsQueries")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -177,8 +186,9 @@ export default function SearchAnalyticsTab() {
|
||||
</span>
|
||||
<p className="font-medium text-text">{t("searchAnalyticsNoSearchesYet")}</p>
|
||||
<p className="text-sm mt-1">
|
||||
Use <code className="bg-bg-muted px-1 rounded">POST /v1/search</code> to start routing
|
||||
web searches.
|
||||
{t.rich("searchAnalyticsEmptyDescription", {
|
||||
code: (chunks) => <code className="bg-bg-muted px-1 rounded">{chunks}</code>,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -189,8 +199,9 @@ export default function SearchAnalyticsTab() {
|
||||
check_circle
|
||||
</span>
|
||||
<span>
|
||||
<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.
|
||||
{t.rich("searchAnalyticsFreeTier", {
|
||||
strong: (chunks) => <strong>{chunks}</strong>,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -122,23 +122,23 @@ export default function McpAuditTab() {
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
{[
|
||||
{
|
||||
label: "Calls (24h)",
|
||||
label: t("mcpMetricCalls24h"),
|
||||
value: stats.totalCalls.toLocaleString(),
|
||||
icon: "terminal",
|
||||
},
|
||||
{
|
||||
label: "Success rate",
|
||||
label: t("mcpMetricSuccessRate"),
|
||||
value: `${Math.round(stats.successRate * 100)}%`,
|
||||
icon: "check_circle",
|
||||
highlight: stats.successRate >= 0.9,
|
||||
},
|
||||
{
|
||||
label: "Avg duration",
|
||||
label: t("mcpMetricAvgDuration"),
|
||||
value: `${Math.round(stats.avgDurationMs)}ms`,
|
||||
icon: "timer",
|
||||
},
|
||||
{
|
||||
label: "Top tool",
|
||||
label: t("mcpMetricTopTool"),
|
||||
value: stats.topTools[0]?.tool ?? "—",
|
||||
icon: "star",
|
||||
},
|
||||
|
||||
@@ -4,7 +4,9 @@ import { useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useBatchActions } from "./components/useBatchActions";
|
||||
|
||||
function relativeTime(ts: number): string {
|
||||
type BatchTranslator = ReturnType<typeof useTranslations>;
|
||||
|
||||
function relativeTime(ts: number, t: BatchTranslator): string {
|
||||
const diffMs = Date.now() - ts * 1000;
|
||||
const isFuture = diffMs < 0;
|
||||
const absDiffMs = Math.abs(diffMs);
|
||||
@@ -22,8 +24,9 @@ function relativeTime(ts: number): string {
|
||||
}
|
||||
}
|
||||
|
||||
if (isFuture) return `in ${res}`;
|
||||
return `${res} ago`;
|
||||
return isFuture
|
||||
? t("batchRelativeTimeIn", { value: res })
|
||||
: t("batchRelativeTimeAgo", { value: res });
|
||||
}
|
||||
|
||||
interface BatchRecord {
|
||||
@@ -93,6 +96,22 @@ 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;
|
||||
@@ -106,10 +125,12 @@ function effectiveStatus(batch: BatchRecord): string {
|
||||
return map[batch.status] ?? batch.status;
|
||||
}
|
||||
|
||||
function StatusBadge({ batch }: { batch: BatchRecord }) {
|
||||
function StatusBadge({ batch, t }: { batch: BatchRecord; t: BatchTranslator }) {
|
||||
const key = effectiveStatus(batch);
|
||||
const cls = STATUS_STYLES[key] ?? "bg-gray-500/15 text-gray-400 border-gray-500/25";
|
||||
const label = STATUS_LABELS[key] ?? key.replace(/_/g, " ");
|
||||
const label = STATUS_TRANSLATION_KEYS[key]
|
||||
? t(STATUS_TRANSLATION_KEYS[key])
|
||||
: (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}
|
||||
@@ -141,12 +162,24 @@ 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);
|
||||
@@ -195,8 +228,11 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
||||
pending_actions
|
||||
</span>
|
||||
<div>
|
||||
<h2 id="batch-detail-modal-title" className="text-base font-semibold text-[var(--color-text-main)]">
|
||||
Batch Details
|
||||
<h2
|
||||
id="batch-detail-modal-title"
|
||||
className="text-base font-semibold text-[var(--color-text-main)]"
|
||||
>
|
||||
{t("batchDetailsTitle")}
|
||||
</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>
|
||||
@@ -227,16 +263,18 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
||||
<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)]">
|
||||
Status
|
||||
{t("status")}
|
||||
</span>
|
||||
<StatusBadge batch={batch} />
|
||||
<StatusBadge batch={batch} t={t} />
|
||||
</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)}</span>}
|
||||
value={
|
||||
<span title={formatTs(batch.createdAt)}>{relativeTime(batch.createdAt, t)}</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -245,7 +283,7 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
||||
<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">
|
||||
Progress
|
||||
{t("batchProgress")}
|
||||
</span>
|
||||
<span className="text-[var(--color-text-muted)]">
|
||||
{completed} / {total} ({pct}%)
|
||||
@@ -262,17 +300,9 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs text-[var(--color-text-muted)]">
|
||||
<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>
|
||||
<span>{t("batchCompletedCount", { count: completed })}</span>
|
||||
{failed > 0 && <span>{t("batchFailedCount", { count: failed })}</span>}
|
||||
<span>{t("batchPendingCount", { count: total - completed - failed })}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -280,19 +310,19 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
||||
{/* Timestamps */}
|
||||
<div>
|
||||
<h3 className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)] mb-3">
|
||||
Timeline
|
||||
{t("batchTimeline")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 text-sm">
|
||||
{[
|
||||
{ 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 },
|
||||
{ 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 },
|
||||
]
|
||||
.filter((t) => t.ts)
|
||||
.map(({ label, ts }) => (
|
||||
@@ -315,9 +345,21 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ role: "Input", fileId: batch.inputFileId, record: inputFile },
|
||||
{ role: "Output", fileId: batch.outputFileId, record: outputFile },
|
||||
{ role: "Errors", fileId: batch.errorFileId, record: errorFile },
|
||||
{
|
||||
role: t("filesListUsedByRoleInput"),
|
||||
fileId: batch.inputFileId,
|
||||
record: inputFile,
|
||||
},
|
||||
{
|
||||
role: t("filesListUsedByRoleOutput"),
|
||||
fileId: batch.outputFileId,
|
||||
record: outputFile,
|
||||
},
|
||||
{
|
||||
role: t("filesListUsedByRoleError"),
|
||||
fileId: batch.errorFileId,
|
||||
record: errorFile,
|
||||
},
|
||||
]
|
||||
.filter((f) => f.fileId)
|
||||
.map(({ role, fileId, record }) => (
|
||||
@@ -350,7 +392,7 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
||||
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>
|
||||
Download
|
||||
{t("filesListDownload")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -362,7 +404,7 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
||||
{batch.usage && (
|
||||
<div>
|
||||
<h3 className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)] mb-3">
|
||||
Token Usage
|
||||
{t("batchTokenUsage")}
|
||||
</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)}
|
||||
@@ -374,7 +416,7 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
||||
{batch.errors && (
|
||||
<div>
|
||||
<h3 className="text-[11px] uppercase tracking-wider font-medium text-red-400 mb-3">
|
||||
Errors
|
||||
{t("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)}
|
||||
@@ -386,7 +428,7 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
||||
{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">
|
||||
Metadata
|
||||
{t("batchMetadata")}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(batch.metadata).map(([k, v]) => (
|
||||
@@ -438,7 +480,7 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
||||
if (
|
||||
window.confirm(
|
||||
t("batchDetailActionRetry") +
|
||||
` (${batch.requestCountsFailed} ${t("batchActionRetry")})?`,
|
||||
` (${batch.requestCountsFailed} ${t("batchActionRetry")})?`
|
||||
)
|
||||
) {
|
||||
const result = await retry({
|
||||
|
||||
@@ -4,22 +4,24 @@ import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/shared/components";
|
||||
|
||||
function relativeTime(ts: number): string {
|
||||
type FileTranslator = ReturnType<typeof useTranslations>;
|
||||
|
||||
function relativeTime(ts: number, t: FileTranslator): string {
|
||||
const diffMs = Date.now() - ts * 1000;
|
||||
const diffSec = Math.round(diffMs / 1000);
|
||||
if (diffSec < 60) return `${diffSec}s ago`;
|
||||
if (diffSec < 60) return t("batchRelativeTimeAgo", { value: `${diffSec}s` });
|
||||
const diffMin = Math.round(diffSec / 60);
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
if (diffMin < 60) return t("batchRelativeTimeAgo", { value: `${diffMin}m` });
|
||||
const diffHr = Math.round(diffMin / 60);
|
||||
if (diffHr < 24) return `${diffHr}h ago`;
|
||||
if (diffHr < 24) return t("batchRelativeTimeAgo", { value: `${diffHr}h` });
|
||||
const diffDays = Math.round(diffHr / 24);
|
||||
return `${diffDays}d ago`;
|
||||
return t("batchRelativeTimeAgo", { value: `${diffDays}d` });
|
||||
}
|
||||
|
||||
function relativeExpiration(ts: number | null): string {
|
||||
if (!ts) return "Never";
|
||||
function relativeExpiration(ts: number | null, t: FileTranslator): string {
|
||||
if (!ts) return t("batchFilesNeverExpires");
|
||||
const diffMs = ts * 1000 - Date.now();
|
||||
if (diffMs <= 0) return "Expired";
|
||||
if (diffMs <= 0) return t("expirationBadgeExpired");
|
||||
const diffSec = Math.round(diffMs / 1000);
|
||||
if (diffSec < 60) return `${diffSec}s`;
|
||||
const diffMin = Math.round(diffSec / 60);
|
||||
@@ -55,6 +57,22 @@ 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;
|
||||
@@ -133,7 +151,7 @@ export default function FileDetailModal({
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-main)]">
|
||||
File Contents
|
||||
{t("batchFileContents")}
|
||||
</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>
|
||||
@@ -164,7 +182,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)]">
|
||||
Size
|
||||
{t("batchFilesSizeColumn")}
|
||||
</span>
|
||||
<span className="text-sm text-[var(--color-text-main)]">
|
||||
{formatBytes(file.bytes)}
|
||||
@@ -172,24 +190,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)]">
|
||||
Purpose
|
||||
{t("batchFilesPurpose")}
|
||||
</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)]">
|
||||
Created
|
||||
{t("batchDetailCreated")}
|
||||
</span>
|
||||
<span className="text-sm text-[var(--color-text-main)]">
|
||||
{createdAtTs ? relativeTime(createdAtTs) : "—"}
|
||||
{createdAtTs ? relativeTime(createdAtTs, t) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
|
||||
Expires
|
||||
{t("batchFilesExpires")}
|
||||
</span>
|
||||
<span className="text-sm text-[var(--color-text-main)]">
|
||||
{expiresAtTs ? relativeExpiration(expiresAtTs) : "Never"}
|
||||
{expiresAtTs ? relativeExpiration(expiresAtTs, t) : t("batchFilesNeverExpires")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -198,7 +216,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">
|
||||
Used by {relatedBatches.length} batch{relatedBatches.length > 1 ? "es" : ""}
|
||||
{t("batchFileUsedByCount", { count: relatedBatches.length })}
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
{relatedBatches.map((b) => (
|
||||
@@ -219,7 +237,9 @@ export default function FileDetailModal({
|
||||
: "bg-gray-500/15 text-gray-400 border-gray-500/25"
|
||||
}`}
|
||||
>
|
||||
{b.status.replaceAll("_", " ")}
|
||||
{BATCH_STATUS_TRANSLATION_KEYS[b.status]
|
||||
? t(BATCH_STATUS_TRANSLATION_KEYS[b.status])
|
||||
: b.status.replaceAll("_", " ")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -231,7 +251,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)]">
|
||||
Preview
|
||||
{t("batchFilePreview")}
|
||||
</h3>
|
||||
{contents && (
|
||||
<button
|
||||
@@ -241,7 +261,7 @@ export default function FileDetailModal({
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{copied ? "check" : "content_copy"}
|
||||
</span>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
{copied ? t("copied") : t("copy")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -259,7 +279,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>
|
||||
Showing first 1000 lines ({lineCount} total lines)
|
||||
{t("batchFilePreviewTruncated", { shown: 1000, total: lineCount })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -281,7 +301,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>
|
||||
Download Full File
|
||||
{t("batchFileDownloadFull")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
16
src/app/(dashboard)/dashboard/chaos/chaosI18n.ts
Normal file
16
src/app/(dashboard)/dashboard/chaos/chaosI18n.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type ChaosTranslator = ((
|
||||
key: string,
|
||||
values?: Record<string, string | number>
|
||||
) => string) & {
|
||||
has?: (key: string) => boolean;
|
||||
};
|
||||
|
||||
export function chaosText(
|
||||
t: ChaosTranslator,
|
||||
key: string,
|
||||
fallback: string,
|
||||
values?: Record<string, string | number>
|
||||
): string {
|
||||
if (typeof t.has !== "function" || !t.has(key)) return fallback;
|
||||
return values ? t(key, values) : t(key);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"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.
|
||||
@@ -22,7 +23,7 @@ export function ChaosConfigActionsBar({
|
||||
onReset: () => void;
|
||||
onTest: () => void;
|
||||
}) {
|
||||
const t = useTranslations("chaosConfig");
|
||||
const t = useTranslations("chaosConfig") as ChaosTranslator;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -66,7 +67,7 @@ export function ChaosConfigActionsBar({
|
||||
) : (
|
||||
<span className="material-symbols-outlined text-[16px]">play_arrow</span>
|
||||
)}
|
||||
{testing ? "Running..." : t("testButton")}
|
||||
{testing ? chaosText(t, "running", "Running...") : t("testButton")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { chaosText, type ChaosTranslator } from "../chaosI18n";
|
||||
|
||||
export interface ChaosModelResult {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
@@ -26,14 +29,24 @@ 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">
|
||||
Test Results — {result.mode} mode ({result.totalProviders} providers)
|
||||
</h3>
|
||||
<div className="text-xs text-text-muted">
|
||||
Started: {new Date(result.startedAt).toLocaleTimeString()}
|
||||
</div>
|
||||
<h3 className="text-sm font-bold text-text-main">{resultsTitle}</h3>
|
||||
<div className="text-xs text-text-muted">{startedLabel}</div>
|
||||
{result.models.map((model, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* /dashboard/chaos/page.tsx — Chaos Mode Configuration
|
||||
*/
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import ChaosConfigPageClient from "./ChaosConfigPageClient";
|
||||
|
||||
export const metadata = {
|
||||
title: "Chaos Mode — OmniRoute",
|
||||
};
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("chaosConfig");
|
||||
return { title: `${t("pageTitle")} — OmniRoute` };
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return <ChaosConfigPageClient />;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
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";
|
||||
|
||||
/**
|
||||
@@ -13,7 +14,7 @@ export function useChaosConfigPersistence(
|
||||
config: ChaosPageConfig,
|
||||
setConfig: Dispatch<SetStateAction<ChaosPageConfig>>
|
||||
) {
|
||||
const t = useTranslations("chaosConfig");
|
||||
const t = useTranslations("chaosConfig") as ChaosTranslator;
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<ChaosPageMessage>(null);
|
||||
|
||||
@@ -49,17 +50,26 @@ export function useChaosConfigPersistence(
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setConfig(data.config);
|
||||
setMessage({ type: "success", text: "Config reset to defaults" });
|
||||
setMessage({
|
||||
type: "success",
|
||||
text: chaosText(t, "resetSuccess", "Config reset to defaults"),
|
||||
});
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({ error: "Reset failed" }));
|
||||
setMessage({ type: "error", text: err.error || "Reset failed" });
|
||||
const err = await res.json().catch(() => ({ error: null }));
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: err.error || chaosText(t, "resetFailed", "Reset failed"),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "Failed to reset config" });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: chaosText(t, "resetFailed", "Failed to reset config"),
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [setConfig]);
|
||||
}, [setConfig, t]);
|
||||
|
||||
return { t, saving, message, setMessage, saveConfig, resetConfig };
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
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";
|
||||
|
||||
@@ -10,8 +11,11 @@ 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");
|
||||
export function useChaosTestRun(
|
||||
config: ChaosPageConfig,
|
||||
setMessage: (message: ChaosPageMessage) => void
|
||||
) {
|
||||
const t = useTranslations("chaosConfig") as ChaosTranslator;
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<ChaosTestResult | null>(null);
|
||||
|
||||
@@ -34,11 +38,17 @@ export function useChaosTestRun(config: ChaosPageConfig, setMessage: (message: C
|
||||
const data: ChaosTestResult = await res.json();
|
||||
setTestResult(data);
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({ error: "Unknown error" }));
|
||||
setMessage({ type: "error", text: err.error || "Test failed" });
|
||||
const err = await res.json().catch(() => ({ error: null }));
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: err.error || chaosText(t, "testFailed", "Test failed"),
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
setMessage({ type: "error", text: err.message || "Test failed" });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: err.message || chaosText(t, "testFailed", "Test failed"),
|
||||
});
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, Button } from "@/shared/components";
|
||||
|
||||
interface ToolState {
|
||||
@@ -24,6 +25,7 @@ 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);
|
||||
@@ -71,7 +73,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: data.message || `${action} succeeded` });
|
||||
setMessage({ type: "success", text: data.message || t("cliproxyapiActionSucceeded") });
|
||||
await fetchStatus();
|
||||
if (action === "install" || action === "restart") await fetchUpdateInfo();
|
||||
} else {
|
||||
@@ -79,11 +81,14 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
||||
type: "error",
|
||||
text:
|
||||
(typeof data.error === "string" ? data.error : data.error?.message) ||
|
||||
`${action} failed`,
|
||||
t("cliproxyapiActionFailed"),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({ type: "error", text: err instanceof Error ? err.message : "Request failed" });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: err instanceof Error ? err.message : t("cliproxyapiRequestFailed"),
|
||||
});
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
@@ -93,14 +98,26 @@ 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: "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" },
|
||||
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",
|
||||
},
|
||||
not_installed: {
|
||||
label: "Not Installed",
|
||||
label: t("cliproxyapiStatusNotInstalled"),
|
||||
color: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-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" },
|
||||
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",
|
||||
},
|
||||
};
|
||||
const badge = map[s] || map.not_installed;
|
||||
return (
|
||||
@@ -125,9 +142,7 @@ 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">
|
||||
Upstream proxy fallback (Go-based OAuth)
|
||||
</p>
|
||||
<p className="text-xs text-text-muted truncate">{t("cliproxyapiDescription")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
@@ -161,7 +176,10 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
||||
system_update
|
||||
</span>
|
||||
<span className="text-sm text-yellow-700 dark:text-yellow-300">
|
||||
Update available: v{updateInfo.current} → v{updateInfo.latest}
|
||||
{t("cliproxyapiUpdateAvailable", {
|
||||
current: updateInfo.current,
|
||||
latest: updateInfo.latest,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
@@ -170,32 +188,34 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
||||
onClick={() => apiCall("install", { version: updateInfo.latest })}
|
||||
loading={loading === "install"}
|
||||
>
|
||||
Update
|
||||
{t("cliproxyapiUpdate")}
|
||||
</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">Version</p>
|
||||
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiVersion")}</p>
|
||||
<p className="text-sm font-medium">
|
||||
{toolState?.installedVersion ? `v${toolState.installedVersion}` : "Not installed"}
|
||||
{toolState?.installedVersion
|
||||
? `v${toolState.installedVersion}`
|
||||
: t("cliproxyapiNotInstalledValue")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-bg-secondary">
|
||||
<p className="text-xs text-text-muted mb-1">Health</p>
|
||||
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiHealth")}</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"
|
||||
? `Healthy`
|
||||
? t("cliproxyapiHealthy")
|
||||
: toolState?.healthStatus === "unhealthy"
|
||||
? "Unhealthy"
|
||||
: "Unknown"}
|
||||
? t("cliproxyapiUnhealthy")
|
||||
: t("cliproxyapiUnknown")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-bg-secondary">
|
||||
<p className="text-xs text-text-muted mb-1">Port</p>
|
||||
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiPort")}</p>
|
||||
<p className="text-sm font-mono">{toolState?.port || 8317}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -209,7 +229,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
||||
loading={loading === "install"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">download</span>
|
||||
Install
|
||||
{t("cliproxyapiInstall")}
|
||||
</Button>
|
||||
)}
|
||||
{toolState?.status === "running" ? (
|
||||
@@ -220,7 +240,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
||||
loading={loading === "stop"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">stop</span>
|
||||
Stop
|
||||
{t("cliproxyapiStop")}
|
||||
</Button>
|
||||
) : toolState?.installedVersion ? (
|
||||
<Button
|
||||
@@ -230,7 +250,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
||||
loading={loading === "start"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">play_arrow</span>
|
||||
Start
|
||||
{t("cliproxyapiStart")}
|
||||
</Button>
|
||||
) : null}
|
||||
{toolState?.status === "running" && (
|
||||
@@ -241,7 +261,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
||||
loading={loading === "restart"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">restart_alt</span>
|
||||
Restart
|
||||
{t("cliproxyapiRestart")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
@@ -251,7 +271,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
||||
loading={loading === "check"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">sync</span>
|
||||
Check Updates
|
||||
{t("cliproxyapiCheckUpdates")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>{allCombos.length} intelligent combo(s)</span>
|
||||
<span>{providerScopeCount} providers in scope</span>
|
||||
<span>{t("intelligentComboCount", { count: allCombos.length })}</span>
|
||||
<span>{t("providersInScope", { count: providerScopeCount })}</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">
|
||||
Candidate Pool
|
||||
{getI18nOrFallback(t, "candidatePoolLabel", "Candidate Pool")}
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-text-main">{providerScopeCount}</p>
|
||||
</div>
|
||||
@@ -183,7 +183,12 @@ export default function IntelligentComboPanel({
|
||||
</p>
|
||||
</div>
|
||||
{savingModePack && (
|
||||
<span className="text-[11px] text-text-muted">Saving {savingModePack}…</span>
|
||||
<span className="text-[11px] text-text-muted">
|
||||
{getI18nOrFallback(t, "savingModePack", "Saving {pack}…").replace(
|
||||
"{pack}",
|
||||
savingModePack
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -308,7 +313,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">
|
||||
Exploration Rate
|
||||
{getI18nOrFallback(t, "explorationRateLabel", "Exploration Rate")}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-semibold text-text-main">
|
||||
{Math.round(normalizedConfig.explorationRate * 100)}%
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function CombosError({
|
||||
error: _error,
|
||||
reset,
|
||||
@@ -7,6 +9,8 @@ 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]"
|
||||
@@ -14,14 +18,10 @@ 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">
|
||||
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>
|
||||
<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>
|
||||
{_error?.digest && (
|
||||
<p className="text-xs text-text-muted font-mono">Error ID: {_error.digest}</p>
|
||||
<p className="text-xs text-text-muted font-mono">{t("errorId", { 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"
|
||||
>
|
||||
Try Again
|
||||
{t("errorRetry")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export const metadata = {
|
||||
title: "Compression",
|
||||
description: "Configure context compression settings to reduce token usage and costs.",
|
||||
};
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("metadata");
|
||||
return {
|
||||
title: t("compressionTitle"),
|
||||
description: t("compressionDescription"),
|
||||
};
|
||||
}
|
||||
|
||||
export default function CompressionPage() {
|
||||
redirect("/dashboard/context/caveman");
|
||||
|
||||
@@ -534,7 +534,10 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
if (!cloudConfigured) {
|
||||
setCloudStatus({
|
||||
type: "warning",
|
||||
message: "Cloud sync is not configured on this instance.",
|
||||
message: translateOrFallback(
|
||||
"cloudSyncNotConfigured",
|
||||
"Cloud sync is not configured on this instance."
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1822,7 +1825,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") || "Core APIs"}
|
||||
{t("categoryCore")}
|
||||
</h3>
|
||||
<div className="flex-1 h-px bg-border/50" />
|
||||
</div>
|
||||
@@ -1843,7 +1846,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
icon="code"
|
||||
iconColor="text-indigo-500"
|
||||
iconBg="bg-indigo-500/10"
|
||||
title={t("responses") || "Responses API"}
|
||||
title={t("responses")}
|
||||
path="/v1/responses"
|
||||
models={endpointData.chat}
|
||||
copy={copy}
|
||||
@@ -1855,7 +1858,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
icon="text_fields"
|
||||
iconColor="text-orange-500"
|
||||
iconBg="bg-orange-500/10"
|
||||
title={t("completionsLegacy") || "Completions (Legacy)"}
|
||||
title={t("completionsLegacy")}
|
||||
path="/v1/completions"
|
||||
models={endpointData.chat}
|
||||
copy={copy}
|
||||
@@ -1867,7 +1870,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
icon="psychology"
|
||||
iconColor="text-violet-500"
|
||||
iconBg="bg-violet-500/10"
|
||||
title={t("messagesApi") || "Messages"}
|
||||
title={t("messagesApi")}
|
||||
path="/v1/messages"
|
||||
models={null}
|
||||
badge="Anthropic"
|
||||
@@ -1883,7 +1886,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") || "Media & Multi-Modal"}
|
||||
{t("categoryMedia")}
|
||||
</h3>
|
||||
<div className="flex-1 h-px bg-border/50" />
|
||||
</div>
|
||||
@@ -1916,7 +1919,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
icon="edit_square"
|
||||
iconColor="text-violet-500"
|
||||
iconBg="bg-violet-500/10"
|
||||
title={t("imageEdits") || "Image Edits"}
|
||||
title={t("imageEdits")}
|
||||
path="/v1/images/edits"
|
||||
models={endpointData.images}
|
||||
copy={copy}
|
||||
@@ -1952,7 +1955,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
icon="music_note"
|
||||
iconColor="text-fuchsia-500"
|
||||
iconBg="bg-fuchsia-500/10"
|
||||
title={t("musicGeneration") || "Music Generation"}
|
||||
title={t("musicGeneration")}
|
||||
path="/v1/music/generations"
|
||||
models={endpointData.music}
|
||||
copy={copy}
|
||||
@@ -1964,7 +1967,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
icon="videocam"
|
||||
iconColor="text-red-500"
|
||||
iconBg="bg-red-500/10"
|
||||
title={t("videoGeneration") || "Video Generation"}
|
||||
title={t("videoGeneration")}
|
||||
path="/v1/videos/generations"
|
||||
models={endpointData.video}
|
||||
copy={copy}
|
||||
@@ -1983,7 +1986,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") || "Search & Discovery"}
|
||||
{t("categorySearch")}
|
||||
</h3>
|
||||
<div className="flex-1 h-px bg-border/50" />
|
||||
</div>
|
||||
@@ -1992,7 +1995,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
icon="search"
|
||||
iconColor="text-cyan-500"
|
||||
iconBg="bg-cyan-500/10"
|
||||
title={t("webSearch") || "Web Search"}
|
||||
title={t("webSearch")}
|
||||
path="/v1/search"
|
||||
models={searchProviders.map((p) => ({ id: p.id, owned_by: p.id, type: "search" }))}
|
||||
copy={copy}
|
||||
@@ -2008,7 +2011,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") || "Utility & Management"}
|
||||
{t("categoryUtility")}
|
||||
</h3>
|
||||
<div className="flex-1 h-px bg-border/50" />
|
||||
</div>
|
||||
@@ -2041,7 +2044,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
icon="view_list"
|
||||
iconColor="text-teal-500"
|
||||
iconBg="bg-teal-500/10"
|
||||
title={t("batchApi") || "Batch API"}
|
||||
title={t("batchApi")}
|
||||
path="/v1/batches"
|
||||
models={null}
|
||||
badge="OpenAI"
|
||||
@@ -2053,7 +2056,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
icon="folder"
|
||||
iconColor="text-yellow-500"
|
||||
iconBg="bg-yellow-500/10"
|
||||
title={t("filesApi") || "Files API"}
|
||||
title={t("filesApi")}
|
||||
path="/v1/files"
|
||||
models={null}
|
||||
copy={copy}
|
||||
@@ -2064,7 +2067,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
icon="list"
|
||||
iconColor="text-teal-500"
|
||||
iconBg="bg-teal-500/10"
|
||||
title={t("listModels") || "List Models"}
|
||||
title={t("listModels")}
|
||||
path="/v1/models"
|
||||
models={null}
|
||||
copy={copy}
|
||||
|
||||
@@ -429,7 +429,7 @@ export default function A2ADashboardPage() {
|
||||
<th className="text-left py-2 pr-2">{t("tableTask")}</th>
|
||||
<th className="text-left py-2 pr-2">{t("tableSkill")}</th>
|
||||
<th className="text-left py-2 pr-2">{t("tableState")}</th>
|
||||
<th className="text-left py-2 pr-2">{t("tablePhase") || "FSM Status"}</th>
|
||||
<th className="text-left py-2 pr-2">{t("tablePhase")}</th>
|
||||
<th className="text-left py-2 pr-2">{t("tableUpdated")}</th>
|
||||
<th className="text-left py-2">{t("tableActions")}</th>
|
||||
</tr>
|
||||
|
||||
@@ -12,6 +12,18 @@ 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");
|
||||
@@ -37,7 +49,10 @@ export default function NotionSourceCard() {
|
||||
|
||||
const handleSaveToken = async () => {
|
||||
if (!token.trim()) {
|
||||
setMessage({ type: "error", text: "Please enter a Notion integration token" });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: translateOrFallback("notionEnterToken", "Please enter a Notion integration token"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
@@ -53,11 +68,20 @@ export default function NotionSourceCard() {
|
||||
setConnected(true);
|
||||
setMessage({ type: "success", text: data.message });
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error ?? "Failed to connect" });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: data.error ?? translateOrFallback("notionConnectFailed", "Failed to connect"),
|
||||
});
|
||||
setConnected(false);
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({ type: "error", text: err instanceof Error ? err.message : "Connection failed" });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: translateOrFallback("notionConnectionFailed", "Connection failed"),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -74,10 +98,19 @@ export default function NotionSourceCard() {
|
||||
setToken("");
|
||||
setMessage({ type: "success", text: data.message });
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error ?? "Failed to disconnect" });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: data.error ?? translateOrFallback("notionDisconnectFailed", "Failed to disconnect"),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({ type: "error", text: err instanceof Error ? err.message : "Disconnect failed" });
|
||||
setMessage({
|
||||
type: "error",
|
||||
text:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: translateOrFallback("notionDisconnectFailed", "Disconnect failed"),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -97,11 +130,16 @@ 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 ? "Connected" : "Not connected"}
|
||||
{connected
|
||||
? translateOrFallback("notionConnected", "Connected")
|
||||
: translateOrFallback("notionNotConnected", "Not connected")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
Search, read, query, and write to Notion through routed AI models
|
||||
{translateOrFallback(
|
||||
"notionDescription",
|
||||
"Search, read, query, and write to Notion through routed AI models"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
@@ -131,7 +169,10 @@ export default function NotionSourceCard() {
|
||||
{!connected ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs text-text-muted font-medium">
|
||||
Notion Internal Integration Token
|
||||
{translateOrFallback(
|
||||
"notionIntegrationToken",
|
||||
"Notion Internal Integration Token"
|
||||
)}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
@@ -143,11 +184,14 @@ export default function NotionSourceCard() {
|
||||
className="font-mono text-sm flex-1"
|
||||
/>
|
||||
<Button onClick={handleSaveToken} loading={busy} variant="primary" size="sm">
|
||||
Connect
|
||||
{translateOrFallback("notionConnect", "Connect")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-text-muted">
|
||||
Create an Internal Integration at{" "}
|
||||
{translateOrFallback(
|
||||
"notionIntegrationHelp",
|
||||
"Create an Internal Integration at"
|
||||
)}{" "}
|
||||
<code className="text-primary font-mono bg-surface/80 px-1 rounded">
|
||||
https://www.notion.so/profile/integrations
|
||||
</code>
|
||||
@@ -156,7 +200,10 @@ export default function NotionSourceCard() {
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-muted flex-1">
|
||||
Token configured. Notion tools are available via MCP.
|
||||
{translateOrFallback(
|
||||
"notionTokenConfigured",
|
||||
"Token configured. Notion tools are available via MCP."
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
onClick={handleDisconnect}
|
||||
@@ -165,7 +212,7 @@ export default function NotionSourceCard() {
|
||||
size="sm"
|
||||
className="border-red-500/30! text-red-400! hover:bg-red-500/10!"
|
||||
>
|
||||
Disconnect
|
||||
{translateOrFallback("notionDisconnect", "Disconnect")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card, Button, EmptyState, Badge } from "@/shared/components";
|
||||
import { Card, Button, EmptyState } from "@/shared/components";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
@@ -19,10 +19,6 @@ 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);
|
||||
|
||||
@@ -42,51 +38,7 @@ 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);
|
||||
@@ -137,127 +89,58 @@ 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>
|
||||
|
||||
{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="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">
|
||||
{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>
|
||||
{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>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
<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>
|
||||
|
||||
@@ -15,11 +15,7 @@ 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";
|
||||
|
||||
@@ -245,7 +241,7 @@ function getStatusPresentation(
|
||||
if (errorType === "account_deactivated") {
|
||||
return {
|
||||
statusVariant: "error",
|
||||
statusLabel: t("statusDeactivated", "Deactivated"),
|
||||
statusLabel: providerText(t, "statusDeactivated", "Deactivated"),
|
||||
errorType,
|
||||
errorBadge,
|
||||
errorTextClass: "text-red-600 font-bold",
|
||||
@@ -300,7 +296,7 @@ function getStatusPresentation(
|
||||
if (errorType === "banned") {
|
||||
return {
|
||||
statusVariant: "error",
|
||||
statusLabel: t("statusBanned", "Banned (403)"),
|
||||
statusLabel: providerText(t, "statusBanned", "Banned (403)"),
|
||||
errorType,
|
||||
errorBadge,
|
||||
errorTextClass: "text-red-600 font-bold",
|
||||
@@ -310,7 +306,7 @@ function getStatusPresentation(
|
||||
if (errorType === "credits_exhausted") {
|
||||
return {
|
||||
statusVariant: "warning",
|
||||
statusLabel: t("statusCreditsExhausted", "Out of Credits"),
|
||||
statusLabel: providerText(t, "statusCreditsExhausted", "Out of Credits"),
|
||||
errorType,
|
||||
errorBadge,
|
||||
errorTextClass: "text-amber-500",
|
||||
@@ -391,22 +387,10 @@ export default function ConnectionRow({
|
||||
t("oauthAccount")
|
||||
)
|
||||
: connection.name;
|
||||
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";
|
||||
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");
|
||||
// Use useState + useEffect for impure Date.now() to avoid calling during render
|
||||
const [isCooldown, setIsCooldown] = useState(false);
|
||||
// T12: token expiry status — lazy init avoids calling Date.now() during render;
|
||||
|
||||
@@ -22,8 +22,10 @@
|
||||
*/
|
||||
|
||||
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[];
|
||||
@@ -37,6 +39,7 @@ 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(() => {
|
||||
@@ -58,12 +61,17 @@ 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">
|
||||
Currently cooling ({cooling.length})
|
||||
{providerText(t, "coolingConnectionsTitle", "Currently cooling ({count})", {
|
||||
count: cooling.length,
|
||||
})}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
These connections returned a 429 (rate-limit) on their last request. OmniRoute will skip
|
||||
them until the timer expires — no manual disable required.
|
||||
{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."
|
||||
)}
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{cooling.map((c) => {
|
||||
@@ -72,7 +80,9 @@ export default function CoolingConnectionsPanel(props: CoolingConnectionsPanelPr
|
||||
c.displayName ||
|
||||
c.name ||
|
||||
c.email ||
|
||||
(c.id ? `connection ${c.id.slice(0, 8)}` : "connection");
|
||||
(c.id
|
||||
? `${providerText(t, "connectionFallback", "connection")} ${c.id.slice(0, 8)}`
|
||||
: providerText(t, "connectionFallback", "connection"));
|
||||
return (
|
||||
<li
|
||||
key={c.id ?? label}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
effectivePreserveForProtocol,
|
||||
effectiveUpstreamHeadersForProtocol,
|
||||
formatProviderModelsErrorResponse,
|
||||
providerText,
|
||||
targetFormatBadgeI18nKey,
|
||||
type CompatModelRow,
|
||||
type CompatByProtocolMap,
|
||||
@@ -285,17 +286,32 @@ export default function CustomModelsSection({
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = await formatProviderModelsErrorResponse(res);
|
||||
throw new Error(detail || "Failed to save model endpoint settings");
|
||||
throw new Error(
|
||||
detail ||
|
||||
providerText(
|
||||
t,
|
||||
"failedSaveModelEndpointSettings",
|
||||
"Failed to save model endpoint settings"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await fetchCustomModels();
|
||||
onModelsChanged?.();
|
||||
notify.success("Saved model endpoint settings");
|
||||
notify.success(
|
||||
providerText(t, "savedModelEndpointSettings", "Saved model endpoint settings")
|
||||
);
|
||||
cancelEdit();
|
||||
} catch (e) {
|
||||
console.error("Failed to save custom model:", e);
|
||||
notify.error(
|
||||
e instanceof Error && e.message ? e.message : "Failed to save model endpoint settings"
|
||||
e instanceof Error && e.message
|
||||
? e.message
|
||||
: providerText(
|
||||
t,
|
||||
"failedSaveModelEndpointSettings",
|
||||
"Failed to save model endpoint settings"
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
setSavingModelId(null);
|
||||
@@ -305,7 +321,9 @@ export default function CustomModelsSection({
|
||||
const saveEdit = async (modelId: string) => {
|
||||
if (!editingModelId || editingModelId !== modelId) return;
|
||||
if (!editingEndpoints.length) {
|
||||
notify.error("Select at least one supported endpoint");
|
||||
notify.error(
|
||||
providerText(t, "selectSupportedEndpoint", "Select at least one supported endpoint")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -429,7 +447,7 @@ export default function CustomModelsSection({
|
||||
: ep === "embeddings"
|
||||
? `📐 ${t("supportedEndpointEmbeddings")}`
|
||||
: ep === "rerank"
|
||||
? "Rerank"
|
||||
? providerText(t, "rerankEndpoint", "Rerank")
|
||||
: ep === "images"
|
||||
? `🖼️ ${t("supportedEndpointImages")}`
|
||||
: `🔊 ${t("supportedEndpointAudio")}`}
|
||||
@@ -661,7 +679,7 @@ export default function CustomModelsSection({
|
||||
: ep === "embeddings"
|
||||
? `📐 ${t("supportedEndpointEmbeddings")}`
|
||||
: ep === "rerank"
|
||||
? "Rerank"
|
||||
? providerText(t, "rerankEndpoint", "Rerank")
|
||||
: ep === "images"
|
||||
? `🖼️ ${t("supportedEndpointImages")}`
|
||||
: `🔊 ${t("supportedEndpointAudio")}`}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Modal } from "@/shared/components";
|
||||
import type { ProviderMessageTranslator } from "../providerPageHelpers";
|
||||
import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers";
|
||||
|
||||
type KimiCodeAuthMethodModalProps = {
|
||||
isOpen: boolean;
|
||||
@@ -50,7 +50,9 @@ 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">Kimi Code API Key</h3>
|
||||
<h3 className="mb-1 font-semibold">
|
||||
{providerText(t, "kimiCodeApiKeyLabel", "Kimi Code API Key")}
|
||||
</h3>
|
||||
<p className="text-sm text-text-muted">{t("apiKeySecure")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
UPSTREAM_HEADERS_UI_MAX,
|
||||
headerRowsToRecord,
|
||||
compatProtocolLabelKey,
|
||||
providerText,
|
||||
type HeaderDraftRow,
|
||||
} from "../providerPageHelpers";
|
||||
|
||||
@@ -353,7 +354,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">
|
||||
{t("compatParamFiltersLabel") ?? "Param Filters"}
|
||||
{providerText(t, "compatParamFiltersLabel", "Param Filters")}
|
||||
</label>
|
||||
<div>
|
||||
<input
|
||||
@@ -369,7 +370,11 @@ 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">
|
||||
{t("compatBlockedParamsHint") ?? "Blocked params (stripped from requests)"}
|
||||
{providerText(
|
||||
t,
|
||||
"compatBlockedParamsHint",
|
||||
"Blocked params (stripped from requests)"
|
||||
)}
|
||||
{paramSaving && ` ● ${t("compatSaving")}`}
|
||||
</p>
|
||||
</div>
|
||||
@@ -387,7 +392,11 @@ 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">
|
||||
{t("compatAllowedParamsHint") ?? "Allowed params (re-added after deny)"}
|
||||
{providerText(
|
||||
t,
|
||||
"compatAllowedParamsHint",
|
||||
"Allowed params (re-added after deny)"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -411,7 +411,7 @@ export default function ModelRow({
|
||||
: testStatus === "ok"
|
||||
? "OK"
|
||||
: testStatus === "error"
|
||||
? "Error"
|
||||
? providerText(t, "errorShort", "Error")
|
||||
: t("testModel")
|
||||
}
|
||||
>
|
||||
|
||||
@@ -5,6 +5,7 @@ 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",
|
||||
@@ -123,7 +124,10 @@ 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 || "Failed to create Dahl token");
|
||||
throw new Error(
|
||||
data?.error ||
|
||||
providerText(t, "createDahlTokenFailed", "Failed to create Dahl token")
|
||||
);
|
||||
}
|
||||
return data.token as string;
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ export default function PassthroughModelRow({
|
||||
: testStatus === "ok"
|
||||
? "OK"
|
||||
: testStatus === "error"
|
||||
? "Error"
|
||||
? providerText(t, "errorShort", "Error")
|
||||
: t("testModel")
|
||||
}
|
||||
>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// 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";
|
||||
@@ -18,6 +19,7 @@ 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",
|
||||
@@ -56,12 +58,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 ?? [];
|
||||
|
||||
@@ -93,7 +95,7 @@ export default function ProviderPlaygroundPanel({ providerId }: { providerId: st
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-lg font-semibold">Playground</h2>
|
||||
<h2 className="text-lg font-semibold">{providerText(t, "playgroundTitle", "Playground")}</h2>
|
||||
<ServiceKindTabs
|
||||
kinds={playgroundableKinds}
|
||||
activeKind={activeKind}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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;
|
||||
@@ -45,9 +46,11 @@ 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(() => {
|
||||
@@ -130,7 +133,10 @@ export default function EditCompatibleNodeModal({
|
||||
method: data.method ?? null,
|
||||
});
|
||||
} catch {
|
||||
setValidationResult({ valid: false, error: "Network error" });
|
||||
setValidationResult({
|
||||
valid: false,
|
||||
error: providerText(t, "networkError", "Network error"),
|
||||
});
|
||||
} finally {
|
||||
setValidating(false);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { type CommandCodeAuthFlowState } from "../providerPageHelpers";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { providerText, type CommandCodeAuthFlowState } from "../providerPageHelpers";
|
||||
|
||||
export type UseCommandCodeAuthParams = {
|
||||
providerId: string;
|
||||
@@ -15,6 +16,7 @@ export function useCommandCodeAuth({
|
||||
setShowAddApiKeyModal,
|
||||
notify,
|
||||
}: UseCommandCodeAuthParams) {
|
||||
const t = useTranslations("providers");
|
||||
const [commandCodeAuthState, setCommandCodeAuthState] = useState<CommandCodeAuthFlowState>({
|
||||
phase: "idle",
|
||||
state: "",
|
||||
@@ -62,7 +64,7 @@ export function useCommandCodeAuth({
|
||||
setCommandCodeAuthState((current) => ({
|
||||
...current,
|
||||
phase: "applying",
|
||||
message: "Applying browser-approved key…",
|
||||
message: providerText(t, "commandCodeApplyingKey", "Applying browser-approved key…"),
|
||||
}));
|
||||
|
||||
try {
|
||||
@@ -74,7 +76,9 @@ export function useCommandCodeAuth({
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMessage = data.error || "Failed to apply Command Code auth";
|
||||
const errorMessage =
|
||||
data.error ||
|
||||
providerText(t, "commandCodeApplyFailed", "Failed to apply Command Code auth");
|
||||
setCommandCodeAuthState((current) => ({
|
||||
...current,
|
||||
phase: "error",
|
||||
@@ -87,26 +91,30 @@ export function useCommandCodeAuth({
|
||||
setCommandCodeAuthState((current) => ({
|
||||
...current,
|
||||
phase: "applied",
|
||||
message: "Command Code connected",
|
||||
message: providerText(t, "commandCodeConnected", "Command Code connected"),
|
||||
}));
|
||||
commandCodeAuthWindowRef.current?.close?.();
|
||||
commandCodeAuthWindowRef.current = null;
|
||||
await fetchConnections();
|
||||
handleCloseAddApiKeyModal();
|
||||
notify.success("Command Code connection added");
|
||||
notify.success(
|
||||
providerText(t, "commandCodeConnectionAdded", "Command Code connection added")
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error applying Command Code auth:", error);
|
||||
setCommandCodeAuthState((current) => ({
|
||||
...current,
|
||||
phase: "error",
|
||||
message: "Failed to apply Command Code auth",
|
||||
message: providerText(t, "commandCodeApplyFailed", "Failed to apply Command Code auth"),
|
||||
}));
|
||||
notify.error("Failed to apply Command Code auth");
|
||||
notify.error(
|
||||
providerText(t, "commandCodeApplyFailed", "Failed to apply Command Code auth")
|
||||
);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[fetchConnections, handleCloseAddApiKeyModal, notify]
|
||||
[fetchConnections, handleCloseAddApiKeyModal, notify, t]
|
||||
);
|
||||
|
||||
const handleStartCommandCodeAuth = useCallback(async () => {
|
||||
@@ -124,7 +132,7 @@ export function useCommandCodeAuth({
|
||||
authUrl: "",
|
||||
callbackUrl: "",
|
||||
expiresAt: null,
|
||||
message: "Opening Command Code Studio…",
|
||||
message: providerText(t, "commandCodeOpeningStudio", "Opening Command Code Studio…"),
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -135,7 +143,9 @@ export function useCommandCodeAuth({
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok || !data.state || !data.authUrl) {
|
||||
const errorMessage = data.error || "Failed to start Command Code auth";
|
||||
const errorMessage =
|
||||
data.error ||
|
||||
providerText(t, "commandCodeStartFailed", "Failed to start Command Code auth");
|
||||
setCommandCodeAuthState((current) => ({
|
||||
...current,
|
||||
phase: "error",
|
||||
@@ -152,7 +162,11 @@ export function useCommandCodeAuth({
|
||||
authUrl: data.authUrl,
|
||||
callbackUrl: data.callbackUrl || "",
|
||||
expiresAt: data.expiresAt || null,
|
||||
message: "Open the auth URL, approve access, then paste the returned key/JSON/URL below…",
|
||||
message: providerText(
|
||||
t,
|
||||
"commandCodeApprovalInstructions",
|
||||
"Open the auth URL, approve access, then paste the returned key/JSON/URL below…"
|
||||
),
|
||||
});
|
||||
|
||||
if (popup) {
|
||||
@@ -169,9 +183,19 @@ export function useCommandCodeAuth({
|
||||
setCommandCodeAuthState((current) => ({
|
||||
...current,
|
||||
phase: "error",
|
||||
message: "Popup blocked. Please allow popups and try Command Code Connect again.",
|
||||
message: 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.");
|
||||
notify.error(
|
||||
providerText(
|
||||
t,
|
||||
"commandCodePopupBlocked",
|
||||
"Popup blocked. Please allow popups and try Command Code Connect again."
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
commandCodeAuthWindowRef.current = fallbackPopup;
|
||||
@@ -183,11 +207,11 @@ export function useCommandCodeAuth({
|
||||
setCommandCodeAuthState((current) => ({
|
||||
...current,
|
||||
phase: "expired",
|
||||
message: "Command Code link expired",
|
||||
message: providerText(t, "commandCodeLinkExpired", "Command Code link expired"),
|
||||
}));
|
||||
commandCodeAuthWindowRef.current?.close?.();
|
||||
commandCodeAuthWindowRef.current = null;
|
||||
notify.error("Command Code auth expired");
|
||||
notify.error(providerText(t, "commandCodeAuthExpired", "Command Code auth expired"));
|
||||
clearCommandCodeAuthTimer();
|
||||
return;
|
||||
}
|
||||
@@ -206,11 +230,11 @@ export function useCommandCodeAuth({
|
||||
setCommandCodeAuthState((current) => ({
|
||||
...current,
|
||||
phase: "expired",
|
||||
message: "Command Code link expired",
|
||||
message: providerText(t, "commandCodeLinkExpired", "Command Code link expired"),
|
||||
}));
|
||||
commandCodeAuthWindowRef.current?.close?.();
|
||||
commandCodeAuthWindowRef.current = null;
|
||||
notify.error("Command Code auth expired");
|
||||
notify.error(providerText(t, "commandCodeAuthExpired", "Command Code auth expired"));
|
||||
clearCommandCodeAuthTimer();
|
||||
return;
|
||||
}
|
||||
@@ -219,13 +243,15 @@ export function useCommandCodeAuth({
|
||||
setCommandCodeAuthState((current) => ({
|
||||
...current,
|
||||
phase: "applied",
|
||||
message: "Command Code connected",
|
||||
message: providerText(t, "commandCodeConnected", "Command Code connected"),
|
||||
}));
|
||||
commandCodeAuthWindowRef.current?.close?.();
|
||||
commandCodeAuthWindowRef.current = null;
|
||||
await fetchConnections();
|
||||
handleCloseAddApiKeyModal();
|
||||
notify.success("Command Code connection added");
|
||||
notify.success(
|
||||
providerText(t, "commandCodeConnectionAdded", "Command Code connection added")
|
||||
);
|
||||
clearCommandCodeAuthTimer();
|
||||
return;
|
||||
}
|
||||
@@ -234,7 +260,11 @@ export function useCommandCodeAuth({
|
||||
setCommandCodeAuthState((current) => ({
|
||||
...current,
|
||||
phase: "received",
|
||||
message: "Browser approved, applying…",
|
||||
message: providerText(
|
||||
t,
|
||||
"commandCodeApplyingApproval",
|
||||
"Browser approved, applying…"
|
||||
),
|
||||
}));
|
||||
clearCommandCodeAuthTimer();
|
||||
await handleCommandCodeAuthApply(
|
||||
@@ -258,9 +288,9 @@ export function useCommandCodeAuth({
|
||||
setCommandCodeAuthState((current) => ({
|
||||
...current,
|
||||
phase: "error",
|
||||
message: "Failed to start Command Code auth",
|
||||
message: providerText(t, "commandCodeStartFailed", "Failed to start Command Code auth"),
|
||||
}));
|
||||
notify.error("Failed to start Command Code auth");
|
||||
notify.error(providerText(t, "commandCodeStartFailed", "Failed to start Command Code auth"));
|
||||
popup?.close?.();
|
||||
commandCodeAuthWindowRef.current = null;
|
||||
clearCommandCodeAuthTimer();
|
||||
@@ -272,6 +302,7 @@ export function useCommandCodeAuth({
|
||||
fetchConnections,
|
||||
handleCommandCodeAuthApply,
|
||||
notify,
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleOpenCommandCodeConnect = useCallback(() => {
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { providerText } from "../providerPageHelpers";
|
||||
|
||||
export interface ConnectionDeleteConfirmTarget {
|
||||
id: string;
|
||||
@@ -34,6 +36,7 @@ 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);
|
||||
|
||||
@@ -56,24 +59,24 @@ export function useConnectionDeleteConfirm(
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${connectionId}`, { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
notify.success("Connection deleted");
|
||||
notify.success(providerText(t, "connectionDeleted", "Connection deleted"));
|
||||
await fetchConnections();
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const message =
|
||||
(typeof data?.error === "string" && data.error) ||
|
||||
data?.error?.message ||
|
||||
"Failed to delete connection";
|
||||
providerText(t, "failedDeleteConnection", "Failed to delete connection");
|
||||
notify.error(message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting connection:", error);
|
||||
notify.error("Failed to delete connection");
|
||||
notify.error(providerText(t, "failedDeleteConnection", "Failed to delete connection"));
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setConnection(null);
|
||||
}
|
||||
}, [connection, fetchConnections, notify]);
|
||||
}, [connection, fetchConnections, notify, t]);
|
||||
|
||||
return { connection, deleting, request, confirm, cancel };
|
||||
}
|
||||
|
||||
@@ -316,11 +316,13 @@ 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, "Model test failed"));
|
||||
notify.error(
|
||||
extractApiErrorMessage(data, providerText(t, "modelTestFailed", "Model test failed"))
|
||||
);
|
||||
setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" }));
|
||||
}
|
||||
} catch (err) {
|
||||
notify.error("Network error testing model");
|
||||
notify.error(providerText(t, "modelTestNetworkError", "Network error testing model"));
|
||||
setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" }));
|
||||
} finally {
|
||||
setTestingModelId(null);
|
||||
|
||||
@@ -27,7 +27,7 @@ import { useNotificationStore } from "@/store/notificationStore";
|
||||
import { isClaudeCodeCompatibleProvider } from "@/shared/constants/providers";
|
||||
import type { ConnectionRowConnection } from "../components/ConnectionRow";
|
||||
import { connectionBelongsToProviderPage } from "../../providerPageUtils";
|
||||
import { normalizeCodexLimitPolicy } from "../providerPageHelpers";
|
||||
import { normalizeCodexLimitPolicy, providerText } from "../providerPageHelpers";
|
||||
import { useProviderQuotaVisibility } from "./useProviderQuotaVisibility";
|
||||
import { useReorderByAvailability } from "./useReorderByAvailability";
|
||||
import {
|
||||
@@ -378,7 +378,14 @@ export function useProviderConnections(
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
notify.error(data.error || "Failed to update Claude extra-usage policy");
|
||||
notify.error(
|
||||
data.error ||
|
||||
providerText(
|
||||
t,
|
||||
"failedUpdateClaudeExtraUsagePolicy",
|
||||
"Failed to update Claude extra-usage policy"
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -408,12 +415,26 @@ export function useProviderConnections(
|
||||
);
|
||||
notify.success(
|
||||
enabled
|
||||
? "Claude extra-usage blocking enabled (extra usage will be blocked)"
|
||||
: "Claude extra-usage blocking disabled (extra usage is allowed)"
|
||||
? 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)"
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error toggling Claude extra-usage policy:", error);
|
||||
notify.error("Failed to update Claude extra-usage policy");
|
||||
notify.error(
|
||||
providerText(
|
||||
t,
|
||||
"failedUpdateClaudeExtraUsagePolicy",
|
||||
"Failed to update Claude extra-usage policy"
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -447,7 +468,10 @@ export function useProviderConnections(
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
notify.error(data.error || "Failed to update Codex limit policy");
|
||||
notify.error(
|
||||
data.error ||
|
||||
providerText(t, "failedUpdateCodexLimitPolicy", "Failed to update Codex limit policy")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -464,10 +488,12 @@ export function useProviderConnections(
|
||||
: connection
|
||||
)
|
||||
);
|
||||
notify.success("Codex limit policy updated");
|
||||
notify.success(providerText(t, "codexLimitPolicyUpdated", "Codex limit policy updated"));
|
||||
} catch (error) {
|
||||
console.error("Error toggling Codex quota policy:", error);
|
||||
notify.error("Failed to update Codex limit policy");
|
||||
notify.error(
|
||||
providerText(t, "failedUpdateCodexLimitPolicy", "Failed to update Codex limit policy")
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -481,18 +507,27 @@ export function useProviderConnections(
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
notify.error(data.error || "Failed to update CLIProxyAPI routing");
|
||||
notify.error(
|
||||
data.error ||
|
||||
providerText(t, "failedUpdateCliproxyRouting", "Failed to update CLIProxyAPI routing")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setCpaProviderEnabled(enabled);
|
||||
notify.success(
|
||||
enabled
|
||||
? "Requests now route through CLIProxyAPI (deeper emulation)"
|
||||
: "Requests now use native OmniRoute (direct)"
|
||||
? providerText(
|
||||
t,
|
||||
"cliproxyRoutingEnabled",
|
||||
"Requests now route through CLIProxyAPI (deeper emulation)"
|
||||
)
|
||||
: providerText(t, "cliproxyRoutingDisabled", "Requests now use native OmniRoute (direct)")
|
||||
);
|
||||
} catch {
|
||||
notify.error("Failed to update CLIProxyAPI routing");
|
||||
notify.error(
|
||||
providerText(t, "failedUpdateCliproxyRouting", "Failed to update CLIProxyAPI routing")
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -664,10 +699,10 @@ export function useProviderConnections(
|
||||
if (onAfter) await onAfter();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
notify.error(data.error || "Batch delete failed");
|
||||
notify.error(data.error || providerText(t, "batchDeleteFailed", "Batch delete failed"));
|
||||
}
|
||||
} catch {
|
||||
notify.error("Network error during batch delete");
|
||||
notify.error(providerText(t, "batchDeleteNetworkError", "Network error during batch delete"));
|
||||
} finally {
|
||||
setBatchDeleting(false);
|
||||
}
|
||||
@@ -689,7 +724,11 @@ export function useProviderConnections(
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error?.message || data.error || "Batch update failed");
|
||||
throw new Error(
|
||||
data.error?.message ||
|
||||
data.error ||
|
||||
providerText(t, "batchUpdateFailed", "Batch update failed")
|
||||
);
|
||||
}
|
||||
const data = await res.json();
|
||||
updated += data.updated ?? 0;
|
||||
@@ -710,7 +749,10 @@ export function useProviderConnections(
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
notify.error(error?.message || "Network error during batch update");
|
||||
notify.error(
|
||||
error?.message ||
|
||||
providerText(t, "batchUpdateNetworkError", "Network error during batch update")
|
||||
);
|
||||
} finally {
|
||||
setBatchUpdating(null);
|
||||
}
|
||||
@@ -805,7 +847,13 @@ export function useProviderConnections(
|
||||
const proxiesData = await proxiesRes.json();
|
||||
const savedProxies = (proxiesData?.items || []).filter((p: any) => p.status === "active");
|
||||
if (savedProxies.length === 0) {
|
||||
notify.error("No saved proxies found. Add proxies in Settings → Proxy first.");
|
||||
notify.error(
|
||||
providerText(
|
||||
t,
|
||||
"noSavedProxies",
|
||||
"No saved proxies found. Add proxies in Settings → Proxy first."
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -856,11 +904,16 @@ export function useProviderConnections(
|
||||
await fetchConnections();
|
||||
const tagLabel = tagFilter ? `"${tagFilter}" ` : "";
|
||||
notify.success(
|
||||
`Distributed ${assigned} proxy assignment(s) across ${tagLabel}${sorted.length} connection(s).`
|
||||
providerText(
|
||||
t,
|
||||
"proxiesDistributed",
|
||||
"Distributed {assigned} proxy assignment(s) across {tagLabel}{total} connection(s).",
|
||||
{ assigned, tagLabel, total: sorted.length }
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Error distributing proxies:", err);
|
||||
notify.error("Failed to distribute proxies.");
|
||||
notify.error(providerText(t, "failedDistributeProxies", "Failed to distribute proxies."));
|
||||
} finally {
|
||||
setDistributingProxies(false);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import type { CompatModelRow } from "../providerPageHelpers";
|
||||
import { providerText, type CompatModelRow } from "../providerPageHelpers";
|
||||
|
||||
// ──── types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -79,11 +79,13 @@ export function useProviderModels(
|
||||
notify.success(t("setAliasSuccess", { alias }));
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
notify.error(data?.error?.message || "Failed to set alias");
|
||||
notify.error(
|
||||
data?.error?.message || providerText(t, "failedSetAlias", "Failed to set alias")
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error setting alias:", error);
|
||||
notify.error("Network error setting alias");
|
||||
notify.error(providerText(t, "networkErrorSettingAlias", "Network error setting alias"));
|
||||
}
|
||||
},
|
||||
[fetchAliases, t, notify]
|
||||
@@ -100,11 +102,13 @@ export function useProviderModels(
|
||||
notify.success(t("deleteAliasSuccess", { alias }));
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
notify.error(data?.error?.message || "Failed to delete alias");
|
||||
notify.error(
|
||||
data?.error?.message || providerText(t, "failedDeleteAlias", "Failed to delete alias")
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error deleting alias:", error);
|
||||
notify.error("Network error deleting alias");
|
||||
notify.error(providerText(t, "networkErrorDeletingAlias", "Network error deleting alias"));
|
||||
}
|
||||
},
|
||||
[fetchAliases, t, notify]
|
||||
@@ -113,10 +117,9 @@ export function useProviderModels(
|
||||
const fetchProviderModelMeta = useCallback(async () => {
|
||||
if (isSearchProvider) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/provider-models?provider=${encodeURIComponent(providerId)}`,
|
||||
{ cache: "no-store" }
|
||||
);
|
||||
const res = await fetch(`/api/provider-models?provider=${encodeURIComponent(providerId)}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
setModelMeta({
|
||||
|
||||
@@ -115,9 +115,7 @@ 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]);
|
||||
|
||||
@@ -184,15 +182,20 @@ export function useProviderSettings(providerId: string): UseProviderSettingsRetu
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setCodexGlobalServiceMode(previousMode);
|
||||
notify.error(data.error || "Failed to update Codex service mode");
|
||||
notify.error(
|
||||
data.error ||
|
||||
providerText(t, "failedUpdateCodexServiceMode", "Failed to update Codex service mode")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
notify.success("Codex service mode updated");
|
||||
notify.success(providerText(t, "codexServiceModeUpdated", "Codex service mode updated"));
|
||||
} catch (error) {
|
||||
setCodexGlobalServiceMode(previousMode);
|
||||
console.error("Error updating Codex service mode:", error);
|
||||
notify.error("Failed to update Codex service mode");
|
||||
notify.error(
|
||||
providerText(t, "failedUpdateCodexServiceMode", "Failed to update Codex service mode")
|
||||
);
|
||||
} finally {
|
||||
setSavingCodexGlobalServiceMode(false);
|
||||
}
|
||||
@@ -215,7 +218,14 @@ export function useProviderSettings(providerId: string): UseProviderSettingsRetu
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setPreferClaudeCodeForUnprefixedClaudeModels(previous);
|
||||
notify.error(data.error || "Failed to update Claude Code routing preference");
|
||||
notify.error(
|
||||
data.error ||
|
||||
providerText(
|
||||
t,
|
||||
"failedUpdateClaudeRoutingPreference",
|
||||
"Failed to update Claude Code routing preference"
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -227,14 +237,26 @@ export function useProviderSettings(providerId: string): UseProviderSettingsRetu
|
||||
}
|
||||
notify.success(
|
||||
enabled
|
||||
? "Unprefixed Claude models now prefer Claude Code"
|
||||
: "Unprefixed Claude models no longer prefer Claude Code"
|
||||
? providerText(
|
||||
t,
|
||||
"claudeRoutingPreferenceEnabled",
|
||||
"Unprefixed Claude models now prefer Claude Code"
|
||||
)
|
||||
: providerText(
|
||||
t,
|
||||
"claudeRoutingPreferenceDisabled",
|
||||
"Unprefixed Claude models no longer prefer Claude Code"
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
setPreferClaudeCodeForUnprefixedClaudeModels(previous);
|
||||
console.error("Error updating Claude Code routing preference:", error);
|
||||
notify.error(
|
||||
providerText(t, "failedUpdateClaudeRoutingPreference", "Failed to update Claude Code routing preference")
|
||||
providerText(
|
||||
t,
|
||||
"failedUpdateClaudeRoutingPreference",
|
||||
"Failed to update Claude Code routing preference"
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
setSavingClaudeRoutingPreference(false);
|
||||
|
||||
@@ -8,6 +8,7 @@ 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>;
|
||||
@@ -100,9 +101,11 @@ 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(
|
||||
@@ -242,7 +245,10 @@ export default function AddCompatibleProviderModal({
|
||||
method: data.method ?? null,
|
||||
});
|
||||
} catch {
|
||||
setValidationResult({ valid: false, error: "Network error" });
|
||||
setValidationResult({
|
||||
valid: false,
|
||||
error: providerText(t, "networkError", "Network error"),
|
||||
});
|
||||
} finally {
|
||||
setValidating(false);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from "@/shared/constants/providers";
|
||||
|
||||
import { CategoryDot } from "./CategoryDot";
|
||||
import { isKimiPartnerProviderId } from "../featuredProviders";
|
||||
import { isCheaperInferenceProviderId, isKimiPartnerProviderId } from "../featuredProviders";
|
||||
|
||||
interface ProviderStats {
|
||||
total?: number;
|
||||
@@ -30,17 +30,17 @@ interface ProviderStats {
|
||||
codexServiceTier?: "default" | "priority" | "flex" | null;
|
||||
}
|
||||
|
||||
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",
|
||||
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" },
|
||||
};
|
||||
|
||||
/** Maps a compatible-provider `apiType` to its `KIND_LABEL` key (#6936: non-chat
|
||||
@@ -166,6 +166,10 @@ 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);
|
||||
@@ -223,9 +227,11 @@ const ProviderCard = forwardRef<ProviderCardHandle, ProviderCardProps>(function
|
||||
const isCompatible = isOpenAICompatibleProvider(providerId);
|
||||
const isCcCompatible = isClaudeCodeCompatibleProvider(providerId);
|
||||
const isAnthropicCompatible = isAnthropicCompatibleProvider(providerId) && !isCcCompatible;
|
||||
// Kimi (Moonshot AI) official-partnership highlight (2026-07): UI-only accent,
|
||||
// see featuredProviders.ts — never affects routing/fallback order.
|
||||
// Open Source Friend highlights (Kimi 2026-07, Cheaper Inference 2026-07): UI-only
|
||||
// accents, see featuredProviders.ts — never affect 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")
|
||||
@@ -267,6 +273,24 @@ 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"),
|
||||
@@ -318,7 +342,12 @@ 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)]"
|
||||
: "hover:border-primary/40"
|
||||
: 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"
|
||||
} ${allDisabled ? "opacity-50" : ""} ${provider.deprecated ? "opacity-60" : ""}`}
|
||||
>
|
||||
<div className="flex flex-col gap-2 h-full">
|
||||
@@ -392,23 +421,24 @@ const ProviderCard = forwardRef<ProviderCardHandle, ProviderCardProps>(function
|
||||
isCompatible ||
|
||||
isCcCompatible ||
|
||||
isAnthropicCompatible ||
|
||||
isKimiPartner) && (
|
||||
isSponsorPartner) && (
|
||||
<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"
|
||||
>
|
||||
{KIND_LABEL[k] ?? k}
|
||||
{kindLabel(k)}
|
||||
</span>
|
||||
))}
|
||||
{isCompatible && (
|
||||
<Badge variant="default" size="sm">
|
||||
{provider.apiType === "responses"
|
||||
? t("responses")
|
||||
: (KIND_LABEL[COMPATIBLE_API_TYPE_KIND[provider.apiType ?? ""] ?? ""] ??
|
||||
t("chat"))}
|
||||
: kindLabel(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
Reference in New Issue
Block a user