mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 17:52:31 +03:00
Compare commits
5 Commits
fix/i18n-h
...
feat/plugi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f96b10b40 | ||
|
|
ce8cdc8224 | ||
|
|
43b8819f99 | ||
|
|
6880b3c821 | ||
|
|
ad554a386c |
197
.cbmignore
197
.cbmignore
@@ -1,197 +0,0 @@
|
|||||||
# codebase-memory-mcp ignore list
|
|
||||||
#
|
|
||||||
# Padrão gitignore-style. Linhas começando com `#` são comentários.
|
|
||||||
# Barra final (`/`) = só diretório. Sem barra = casa arquivo OU diretório.
|
|
||||||
#
|
|
||||||
# O CBM também lê `.gitignore` automaticamente — esta lista deixa explícito o que
|
|
||||||
# os hooks do CBM vão pular. Se uma regra entrar em conflito entre os dois arquivos,
|
|
||||||
# vale a união. Editar este arquivo é mais barato do que confiar na herança implícita.
|
|
||||||
#
|
|
||||||
# Última reconciliação: 2026-07-31, status `ready` (513k nodes / 689k edges),
|
|
||||||
# `auto_index_limit=50000`, total indexável medido ≈11.546 arquivos (folga 4,3×).
|
|
||||||
#
|
|
||||||
# Fontes cruzadas:
|
|
||||||
# - `codebase-memory-mcp cli index_status --project home-diegosouzapw-dev-proxys-OmniRoute`
|
|
||||||
# → `not_indexed.dirs` (27) + `not_indexed.files` (336), todos `BY DESIGN`.
|
|
||||||
# - `.gitignore` deste repo (5.691 B) — fonte canônica secundária.
|
|
||||||
#
|
|
||||||
# Como auditar mudanças: depois de editar este arquivo, rodar `index_repository`
|
|
||||||
# (ou esperar `auto_watch` re-indexar) e re-checar `cli index_status` → comparar
|
|
||||||
# contagens em `not_indexed.dirs_count` e `not_indexed.files_count`.
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# 1. Diretorios de runtime / pacote — nao sao codigo-fonte
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
node_modules/
|
|
||||||
node_modules
|
|
||||||
|
|
||||||
# Builds e artefatos reproduziveis (Layer 1 Next.js / Electron)
|
|
||||||
.build/
|
|
||||||
dist/
|
|
||||||
.next/
|
|
||||||
out/
|
|
||||||
|
|
||||||
# Electron especifico
|
|
||||||
electron/dist-electron/
|
|
||||||
electron/node_modules/
|
|
||||||
icon.iconset/
|
|
||||||
|
|
||||||
# Workspaces internos que tem proprio node_modules
|
|
||||||
@omniroute/opencode-plugin/dist/
|
|
||||||
@omniroute/opencode-plugin/node_modules/
|
|
||||||
@omniroute/opencode-provider/dist/
|
|
||||||
@omniroute/opencode-provider/node_modules/
|
|
||||||
|
|
||||||
# Recursos nativos compilados (C/JNI/wasm)
|
|
||||||
src/mitm/tproxy/native/build/
|
|
||||||
|
|
||||||
# Artefatos locais do Stryker / Playwright / coverage
|
|
||||||
.stryker-tmp/
|
|
||||||
reports/mutation/
|
|
||||||
stryker-output-*.json
|
|
||||||
.playwright-mcp/
|
|
||||||
test-results/
|
|
||||||
playwright-report/
|
|
||||||
blob-report/
|
|
||||||
|
|
||||||
# Analise / linters / caches
|
|
||||||
.analysis/
|
|
||||||
.sisyphus/
|
|
||||||
.plans/
|
|
||||||
.gitnexus
|
|
||||||
.worktrees
|
|
||||||
.codegraph/
|
|
||||||
|
|
||||||
# Quality artifacts (gerados por npm run lint --cache etc)
|
|
||||||
.eslintcache
|
|
||||||
.eslintcache-complexity
|
|
||||||
|
|
||||||
# Claude Code local state
|
|
||||||
.claude/scheduled_tasks.lock
|
|
||||||
.claude/scheduled_tasks/
|
|
||||||
.claude/sessions/
|
|
||||||
.claude/state.json
|
|
||||||
.claude/settings.local.json
|
|
||||||
|
|
||||||
# Serena / Antigravity / outras tools locais
|
|
||||||
.serena/
|
|
||||||
.antigravitycli/
|
|
||||||
.gemini/
|
|
||||||
.config/
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# 2. Diretorios com prefixo `_` — locais / privados (regra global do .gitignore)
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
_*/
|
|
||||||
_artifacts/
|
|
||||||
_cache/
|
|
||||||
_mono_repo/
|
|
||||||
_references/
|
|
||||||
_tasks/
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# 3. Diretorios de tooling IA (state local, nao codigo)
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
.agents/
|
|
||||||
.claude/
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
.junie/
|
|
||||||
.omc/
|
|
||||||
.data/
|
|
||||||
.data-dev/
|
|
||||||
.local-data/
|
|
||||||
.logs/
|
|
||||||
.artifacts/
|
|
||||||
.source/
|
|
||||||
.superpowers/
|
|
||||||
.claude-flow/
|
|
||||||
.omnivscodeagent/
|
|
||||||
omnirouteCloud/
|
|
||||||
omnirouteSite/
|
|
||||||
.omniroute/
|
|
||||||
.stent/
|
|
||||||
|
|
||||||
# Subpaths especificos do Claude Code que nao estao em .claude/ (criados sob repo)
|
|
||||||
.claude/worktrees/
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# 4. Diretorios de dados / runtime locais (storage, env, secrets, scratch)
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
data/
|
|
||||||
src/lib/env/
|
|
||||||
src/app/api/agent-skills/coverage/
|
|
||||||
src/app/api/cloud/
|
|
||||||
src/app/api/sync/cloud/
|
|
||||||
src/app/api/system/env/
|
|
||||||
tests/golden-set/data/
|
|
||||||
|
|
||||||
# Logs e saida de teste
|
|
||||||
logs/*
|
|
||||||
test_output.log
|
|
||||||
home-diegosouzapw-dev-automacoes-*.txt
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# 5. Diretorios do monorepo por subprojeto (nao fazem parte do app principal)
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
security-analysis/
|
|
||||||
vscode-extension/
|
|
||||||
obsidian-plugin/node_modules/
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# 6. Diretorios de documentacao interna / workflow
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
docs/superpowers/
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# 7. Arquivos especificos (nao diretorios inteiros)
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
# Segredos e env — NUNCA indexar
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
!.env.homolog.example
|
|
||||||
|
|
||||||
# TypeScript build info e next env declaration
|
|
||||||
*.tsbuildinfo
|
|
||||||
next-env.d.ts
|
|
||||||
typescript
|
|
||||||
|
|
||||||
# SQLite transient files (WAL/SHM/journal)
|
|
||||||
*.sqlite-shm
|
|
||||||
*.sqlite-wal
|
|
||||||
*.sqlite-journal
|
|
||||||
|
|
||||||
# Mapas e source maps
|
|
||||||
*.map
|
|
||||||
|
|
||||||
# Bun / npm lockfiles ruidosos
|
|
||||||
bun.lock
|
|
||||||
|
|
||||||
# `cheaper-inference-gateway.svg` e arquivos de midia na raiz/asset ja cobertos
|
|
||||||
# pelos `ignored-suffix` do indexador (svg/png/jpg/ico/etc >50kB ou >500linhas);
|
|
||||||
# manter a regra explicita aqui ajuda a auditar:
|
|
||||||
cheaper-inference-gateway.svg
|
|
||||||
cheaper-inference-gateway-*.svg
|
|
||||||
|
|
||||||
# Husky internals
|
|
||||||
.husky/_/
|
|
||||||
|
|
||||||
# CI / quality metric artifacts
|
|
||||||
config/quality/quality-metrics.json
|
|
||||||
config/quality/test-impact-map.json
|
|
||||||
audit-report.json
|
|
||||||
.gh-discussions.json
|
|
||||||
|
|
||||||
# i18n audit (gerado por npm run scripts)
|
|
||||||
scripts/i18n/_audit.json
|
|
||||||
scripts/i18n/_pending-keys.json
|
|
||||||
|
|
||||||
# Cli binario local (scratch)
|
|
||||||
bin/omniroute.mjs
|
|
||||||
|
|
||||||
# Deploy / docker backups
|
|
||||||
deploy.sh
|
|
||||||
docker-compose.yml.bak
|
|
||||||
docker-compose.minimal.yml
|
|
||||||
@@ -1414,6 +1414,10 @@ APP_LOG_TO_FILE=true
|
|||||||
# Default: ~/.omniroute/plugins/ Override in dev/CI to point at a local plugin tree.
|
# Default: ~/.omniroute/plugins/ Override in dev/CI to point at a local plugin tree.
|
||||||
# OMNIROUTE_PLUGIN_PATH=
|
# 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) ──
|
# ── Prompt cache (system prompt deduplication) ──
|
||||||
# Used by: open-sse/services — caches identical system prompts across requests.
|
# Used by: open-sse/services — caches identical system prompts across requests.
|
||||||
# PROMPT_CACHE_MAX_SIZE=50 # Max cached entries (default: 50)
|
# 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,14 +9,11 @@
|
|||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
Choose the change type and focused loop from the
|
Run only the focused loop for what you changed — the full unit suite, Vitest, the
|
||||||
[Contribution Golden Path](../docs/dev/CONTRIBUTION_GOLDEN_PATH.md). The full unit suite,
|
60% coverage gate, and the production build all run in CI on this PR (#8329):
|
||||||
Vitest, the 60% coverage gate, and the production build all run in CI on this PR (#8329):
|
|
||||||
|
|
||||||
- [ ] Change type: provider / routing / UI / i18n / CLI / DB / build-deploy / other
|
- [ ] Focused tests for the change: `node --import tsx/esm --test tests/unit/<file>.test.ts`
|
||||||
- [ ] Focused tests and category gates from the golden path
|
|
||||||
- [ ] `npm run lint`
|
- [ ] `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
|
- [ ] 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
|
- [ ] SonarQube PR analysis is green or any remaining issues are explicitly documented below
|
||||||
|
|
||||||
@@ -32,4 +29,4 @@ Vitest, the 60% coverage gate, and the production build all run in CI on this PR
|
|||||||
|
|
||||||
## Reviewer Notes
|
## 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,81 +179,6 @@ jobs:
|
|||||||
# Complexity + cognitive-complexity: ONE ESLint walk (both baselines still
|
# Complexity + cognitive-complexity: ONE ESLint walk (both baselines still
|
||||||
# enforced separately by ruleId). Avoids two cold tree walks on fast-path.
|
# enforced separately by ruleId). Avoids two cold tree walks on fast-path.
|
||||||
- run: npm run check:complexity-ratchets
|
- 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)
|
- name: Typecheck (core)
|
||||||
run: npm run typecheck:core
|
run: npm run typecheck:core
|
||||||
# #7033: dashboard-scoped typecheck gate — src/app/(dashboard) TSX is not
|
# #7033: dashboard-scoped typecheck gate — src/app/(dashboard) TSX is not
|
||||||
@@ -439,12 +364,6 @@ jobs:
|
|||||||
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
|
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
|
runs-on: ubuntu-latest
|
||||||
continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
@@ -466,29 +385,6 @@ jobs:
|
|||||||
- name: ESLint (baseline congelado — warning novo = vermelho)
|
- name: ESLint (baseline congelado — warning novo = vermelho)
|
||||||
# lint:json writes the report; --max-warnings 0 keeps no-new-warnings policy.
|
# lint:json writes the report; --max-warnings 0 keeps no-new-warnings policy.
|
||||||
run: npm run lint:json -- --max-warnings 0
|
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ó
|
# 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
|
# explodem na release-PR. (1) CHANGELOG-eat — o auto-resolve do merge come
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -171,6 +171,7 @@ config/quality/test-impact-map.json
|
|||||||
# GitNexus local index
|
# GitNexus local index
|
||||||
.gitnexus
|
.gitnexus
|
||||||
.worktrees
|
.worktrees
|
||||||
|
bin/omniroute.mjs
|
||||||
|
|
||||||
# Consistent with .dockerignore / .npmignore
|
# Consistent with .dockerignore / .npmignore
|
||||||
.omc/
|
.omc/
|
||||||
@@ -200,10 +201,7 @@ scripts/i18n/_pending-keys.json
|
|||||||
.codegraph/
|
.codegraph/
|
||||||
|
|
||||||
# Fumadocs generated source
|
# Fumadocs generated source
|
||||||
/.source/
|
.source/
|
||||||
|
|
||||||
# Temporary local worktrees used to build unpublished npm tarballs
|
|
||||||
/.deploy-build-*/
|
|
||||||
|
|
||||||
# AI agent local settings and configs
|
# AI agent local settings and configs
|
||||||
.agents/
|
.agents/
|
||||||
@@ -252,4 +250,3 @@ tests/homolog/.auth/
|
|||||||
tests/homolog/ui/.auth/
|
tests/homolog/ui/.auth/
|
||||||
homolog-report/
|
homolog-report/
|
||||||
docker-compose.yml.bak
|
docker-compose.yml.bak
|
||||||
.playwright-cli/
|
|
||||||
|
|||||||
8
.source/dynamic.ts
Normal file
8
.source/dynamic.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { dynamic } from 'fumadocs-mdx/runtime/dynamic';
|
||||||
|
import * as Config from '../source.config';
|
||||||
|
|
||||||
|
const create = await dynamic<typeof Config, import("fumadocs-mdx/runtime/types").InternalTypeConfig & {
|
||||||
|
DocData: {
|
||||||
|
}
|
||||||
|
}>(Config, {"configPath":"source.config.ts","environment":"next","outDir":".source"}, {"doc":{"passthroughs":["extractedReferences"]}});
|
||||||
22
.source/source.config.mjs
Normal file
22
.source/source.config.mjs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// source.config.ts
|
||||||
|
import { defineDocs, defineConfig } from "fumadocs-mdx/config";
|
||||||
|
var docs = defineDocs({
|
||||||
|
dir: "docs",
|
||||||
|
docs: {
|
||||||
|
files: [
|
||||||
|
"./architecture/**/*.md",
|
||||||
|
"./guides/**/*.md",
|
||||||
|
"./reference/**/*.md",
|
||||||
|
"./frameworks/**/*.md",
|
||||||
|
"./routing/**/*.md",
|
||||||
|
"./security/**/*.md",
|
||||||
|
"./compression/**/*.md",
|
||||||
|
"./ops/**/*.md"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
var source_config_default = defineConfig();
|
||||||
|
export {
|
||||||
|
source_config_default as default,
|
||||||
|
docs
|
||||||
|
};
|
||||||
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) |
|
| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) |
|
||||||
| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions |
|
| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions |
|
||||||
| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc |
|
| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc |
|
||||||
| Database | `src/lib/db/` | SQLite domain modules (130 migrations) |
|
| Database | `src/lib/db/` | SQLite domain modules (95 files, 110 migrations) |
|
||||||
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
|
| 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 |
|
| 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 |
|
| 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.
|
API routes follow a consistent pattern: `Route → CORS preflight → Zod body validation → Optional auth (extractApiKey/isValidApiKey) → API key policy enforcement → Handler delegation (open-sse)`. No global Next.js middleware — interception is route-specific.
|
||||||
|
|
||||||
**Combo routing** (`open-sse/services/combo.ts`): 19 public strategies (priority, weighted, fill-first, round-robin, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline). Each target calls `handleSingleModel()` which wraps `handleChatCore()` with per-target error handling and circuit breaker checks. The `fusion` strategy is the exception: it fans out to a panel of models in parallel, then a judge model synthesizes one final answer (`open-sse/services/fusion.ts`). See `docs/routing/AUTO-COMBO.md` for the 13-factor Auto-Combo scoring + the full strategy table and `docs/architecture/RESILIENCE_GUIDE.md` for the 3 resilience layers.
|
**Combo routing** (`open-sse/services/combo.ts`): 18 strategies (priority, weighted, fill-first, round-robin, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline). Each target calls `handleSingleModel()` which wraps `handleChatCore()` with per-target error handling and circuit breaker checks. The `fusion` strategy is the exception: it fans out to a panel of models in parallel, then a judge model synthesizes one final answer (`open-sse/services/fusion.ts`). See `docs/routing/AUTO-COMBO.md` for the 12-factor Auto-Combo scoring + the full strategy table and `docs/architecture/RESILIENCE_GUIDE.md` for the 3 resilience layers.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -332,7 +332,7 @@ For any non-trivial change, read the matching deep-dive first:
|
|||||||
| Repo navigation | `docs/architecture/REPOSITORY_MAP.md` |
|
| Repo navigation | `docs/architecture/REPOSITORY_MAP.md` |
|
||||||
| Architecture | `docs/architecture/ARCHITECTURE.md` |
|
| Architecture | `docs/architecture/ARCHITECTURE.md` |
|
||||||
| Engineering reference | `docs/architecture/CODEBASE_DOCUMENTATION.md` |
|
| Engineering reference | `docs/architecture/CODEBASE_DOCUMENTATION.md` |
|
||||||
| Auto-Combo (13-factor scoring, 19 strategies) | `docs/routing/AUTO-COMBO.md` |
|
| Auto-Combo (12-factor scoring, 18 strategies) | `docs/routing/AUTO-COMBO.md` |
|
||||||
| Resilience (3 mechanisms) | `docs/architecture/RESILIENCE_GUIDE.md` |
|
| Resilience (3 mechanisms) | `docs/architecture/RESILIENCE_GUIDE.md` |
|
||||||
| Reasoning replay | `docs/routing/REASONING_REPLAY.md` |
|
| Reasoning replay | `docs/routing/REASONING_REPLAY.md` |
|
||||||
| Skills framework | `docs/frameworks/SKILLS.md` |
|
| Skills framework | `docs/frameworks/SKILLS.md` |
|
||||||
@@ -461,18 +461,10 @@ own dedicated branch, and you MUST confirm the base branch with the operator bef
|
|||||||
git fetch origin "$BASE_BRANCH"
|
git fetch origin "$BASE_BRANCH"
|
||||||
git worktree add ".claude/worktrees/${TASK##*/}" -b "$TASK" "origin/$BASE_BRANCH"
|
git worktree add ".claude/worktrees/${TASK##*/}" -b "$TASK" "origin/$BASE_BRANCH"
|
||||||
cd ".claude/worktrees/${TASK##*/}"
|
cd ".claude/worktrees/${TASK##*/}"
|
||||||
# Reuse the main checkout's node_modules to skip a per-worktree npm install.
|
# symlink node_modules from the main checkout to skip a per-worktree npm install:
|
||||||
# HARD LINKS (`cp -al`), never a symlink: ~5s for the whole tree and near-zero extra
|
ln -s "$(git -C <main_checkout> rev-parse --show-toplevel)/node_modules" node_modules
|
||||||
# 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
|
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`
|
`.claude/worktrees/`): create the worktree with the command above, then call `EnterWorktree`
|
||||||
with its `path`.
|
with its `path`.
|
||||||
|
|||||||
@@ -2,11 +2,6 @@
|
|||||||
|
|
||||||
Thank you for your interest in contributing! This guide covers everything you need to get started.
|
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
|
## Development Setup
|
||||||
@@ -203,11 +198,10 @@ Coverage notes:
|
|||||||
|
|
||||||
### Pull Request Requirements
|
### Pull Request Requirements
|
||||||
|
|
||||||
Before opening a PR, use the
|
Before opening a PR, run the focused loop for what you changed. The full unit suite
|
||||||
[Contribution Golden Path](docs/dev/CONTRIBUTION_GOLDEN_PATH.md) to run the focused loop for
|
(4 CI shards), Vitest, the **60%+** coverage gate, and the production build are CI's
|
||||||
what you changed. The full unit suite (4 CI shards), Vitest, the **60%+** coverage gate, and
|
responsibility — running them locally adds no signal the PR checks will not already
|
||||||
the production build are CI's responsibility — running them locally adds no signal the PR
|
give you, and on smaller machines it can saturate the host (#8084):
|
||||||
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 the test files that cover your change: `node --import tsx/esm --test tests/unit/<file>.test.ts`
|
||||||
- Run `npm run lint`
|
- Run `npm run lint`
|
||||||
@@ -277,7 +271,7 @@ src/ # TypeScript (.ts / .tsx)
|
|||||||
│ ├── a2a/ # Agent-to-Agent v0.3 protocol server
|
│ ├── a2a/ # Agent-to-Agent v0.3 protocol server
|
||||||
│ ├── acp/ # Agent Communication Protocol registry
|
│ ├── acp/ # Agent Communication Protocol registry
|
||||||
│ ├── compliance/ # Compliance policy engine
|
│ ├── compliance/ # Compliance policy engine
|
||||||
│ ├── db/ # SQLite domain modules + 130 migrations
|
│ ├── db/ # SQLite database layer (21 modules + 16 migrations)
|
||||||
│ ├── memory/ # Persistent conversational memory
|
│ ├── memory/ # Persistent conversational memory
|
||||||
│ ├── oauth/ # OAuth providers, services, and utilities
|
│ ├── oauth/ # OAuth providers, services, and utilities
|
||||||
│ ├── skills/ # Extensible skill framework
|
│ ├── skills/ # Extensible skill framework
|
||||||
@@ -287,7 +281,7 @@ src/ # TypeScript (.ts / .tsx)
|
|||||||
├── mitm/ # MITM proxy (cert, DNS, target routing)
|
├── mitm/ # MITM proxy (cert, DNS, target routing)
|
||||||
├── shared/
|
├── shared/
|
||||||
│ ├── components/ # React components (.tsx)
|
│ ├── components/ # React components (.tsx)
|
||||||
│ ├── constants/ # Provider definitions (290), MCP scopes, 19 routing strategies
|
│ ├── constants/ # Provider definitions (177), MCP scopes, 14 routing strategies
|
||||||
│ ├── utils/ # Circuit breaker, sanitizer, auth helpers
|
│ ├── utils/ # Circuit breaker, sanitizer, auth helpers
|
||||||
│ └── validation/ # Zod v4 schemas
|
│ └── validation/ # Zod v4 schemas
|
||||||
└── sse/ # SSE proxy pipeline
|
└── sse/ # SSE proxy pipeline
|
||||||
@@ -295,7 +289,7 @@ src/ # TypeScript (.ts / .tsx)
|
|||||||
open-sse/ # @omniroute/open-sse workspace
|
open-sse/ # @omniroute/open-sse workspace
|
||||||
├── executors/ # 14 provider-specific request executors
|
├── executors/ # 14 provider-specific request executors
|
||||||
├── handlers/ # 11 request handlers (chat, responses, embeddings, images, etc.)
|
├── handlers/ # 11 request handlers (chat, responses, embeddings, images, etc.)
|
||||||
├── mcp-server/ # MCP server (104 tools, 3 transports, 31 scopes)
|
├── mcp-server/ # MCP server (25 tools, 3 transports, 10 scopes)
|
||||||
├── services/ # 36+ services (combo, autoCombo, rateLimitManager, etc.)
|
├── services/ # 36+ services (combo, autoCombo, rateLimitManager, etc.)
|
||||||
├── translator/ # Format translators (OpenAI ↔ Claude ↔ Gemini ↔ Responses ↔ Ollama)
|
├── translator/ # Format translators (OpenAI ↔ Claude ↔ Gemini ↔ Responses ↔ Ollama)
|
||||||
├── transformer/ # Responses API transformer
|
├── transformer/ # Responses API transformer
|
||||||
|
|||||||
41
README.md
41
README.md
@@ -241,53 +241,12 @@ curl http://localhost:20128/v1/chat/completions \
|
|||||||
<b>What Kimi's support powers:</b> Kimi's API credits power OmniRoute's AI-validated release pipeline — the <i>merge validation powered by Kimi K3</i> stage that reviews every pull request before it ships — plus day-to-day feature development. First-class Kimi support ships on both rails: the direct <a href="https://platform.kimi.ai?aff=omniroute">Kimi API</a> (<code>kimi-k3</code>) and the <a href="https://www.kimi.com/code?aff=omniroute">Kimi Code coding plan</a> (OAuth and API key). OmniRoute is also the first Brazilian open-source project in Kimi's support program. <a href="https://platform.kimi.ai?aff=omniroute"><b>Get a Kimi API key →</b></a>
|
<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>
|
</td>
|
||||||
</tr>
|
</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>
|
</table>
|
||||||
|
|
||||||
<sub>Links tagged <code>aff=omniroute</code> are partner links. They fund the project at no extra cost to you.</sub>
|
<sub>Links tagged <code>aff=omniroute</code> are partner links. They fund the project at no extra cost to you.</sub>
|
||||||
|
|
||||||
<br/>
|
<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">
|
<div align="center">
|
||||||
|
|
||||||
## 🎯 Combos — The Flagship
|
## 🎯 Combos — The Flagship
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
- **fix(i18n):** completed the French UI catalog by adding all missing keys and replacing every placeholder translation ([#9235](https://github.com/diegosouzapw/OmniRoute/pull/9235)) — thanks @alex-jordan547
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
- **fix(i18n):** localized hardcoded web UI copy across public pages, dashboard views, and shared components, with complete French and Vietnamese coverage ([#9245](https://github.com/diegosouzapw/OmniRoute/pull/9245)) — thanks @alex-jordan547
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -177,7 +177,7 @@
|
|||||||
"tests/unit/account-fallback-service.test.ts": 1563,
|
"tests/unit/account-fallback-service.test.ts": 1563,
|
||||||
"tests/unit/batch_api.test.ts": 1324,
|
"tests/unit/batch_api.test.ts": 1324,
|
||||||
"tests/unit/cc-compatible-provider.test.ts": 1217,
|
"tests/unit/cc-compatible-provider.test.ts": 1217,
|
||||||
"tests/unit/chatcore-translation-paths.test.ts": 2776,
|
"tests/unit/chatcore-translation-paths.test.ts": 2769,
|
||||||
"tests/unit/chatgpt-web.test.ts": 3148,
|
"tests/unit/chatgpt-web.test.ts": 3148,
|
||||||
"tests/unit/combo-routing-engine.test.ts": 3449,
|
"tests/unit/combo-routing-engine.test.ts": 3449,
|
||||||
"tests/unit/db-migration-runner.test.ts": 1499,
|
"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_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.",
|
"_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/antigravity.ts": 1528,
|
||||||
"open-sse/executors/base.ts": 1578,
|
"open-sse/executors/base.ts": 1562,
|
||||||
"open-sse/executors/chatgpt-web.ts": 3241,
|
"open-sse/executors/chatgpt-web.ts": 3241,
|
||||||
"open-sse/executors/codex.ts": 1534,
|
"open-sse/executors/codex.ts": 1534,
|
||||||
"open-sse/executors/cursor.ts": 1560,
|
"open-sse/executors/cursor.ts": 1560,
|
||||||
@@ -365,7 +365,7 @@
|
|||||||
"open-sse/services/rateLimitManager.ts": 1060,
|
"open-sse/services/rateLimitManager.ts": 1060,
|
||||||
"open-sse/translator/response/openai-responses.ts": 1174,
|
"open-sse/translator/response/openai-responses.ts": 1174,
|
||||||
"open-sse/utils/cursorAgentProtobuf.ts": 1505,
|
"open-sse/utils/cursorAgentProtobuf.ts": 1505,
|
||||||
"open-sse/utils/stream.ts": 2889,
|
"open-sse/utils/stream.ts": 2887,
|
||||||
"src/app/(dashboard)/dashboard/HomePageClient.tsx": 1381,
|
"src/app/(dashboard)/dashboard/HomePageClient.tsx": 1381,
|
||||||
"src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": 1031,
|
"src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": 1031,
|
||||||
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 3117,
|
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 3117,
|
||||||
@@ -413,6 +413,5 @@
|
|||||||
"_rebaseline_2026_07_28_8860_tokenrefresh_projectid": "PR #8860 (fix/antigravity-projectid-centralized) own test growth: tests/unit/token-refresh-service.test.ts 1311->1378 (+67 = 4 cases covering projectId discovery on the tokenRefresh.ts path — the Dashboard/health-check refresh route, which #8842 did not reach since that fixed the executor path). Covered by the same file.",
|
"_rebaseline_2026_07_28_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_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_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."
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,229 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Contribution Golden Path"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Contribution Golden Path
|
|
||||||
|
|
||||||
Use this guide to choose the smallest reliable development loop for a pull request. It does not
|
|
||||||
replace the area-specific architecture and security documents linked below; it connects each common
|
|
||||||
change type to its contracts, focused checks, and CI coverage.
|
|
||||||
|
|
||||||
## The path every change follows
|
|
||||||
|
|
||||||
1. **Choose the base before editing.** Find the highest active `release/v*` branch and branch from
|
|
||||||
its tip. Target that branch, not `main`. If a release freeze is active, do not target the frozen
|
|
||||||
branch; use the next active cycle described in
|
|
||||||
[Branching & Release Model](../ops/BRANCHING_MODEL.md).
|
|
||||||
2. **Name the contracts.** Identify every catalog, schema, generated artifact, public API, or user
|
|
||||||
interface that the change affects. The table below gives the minimum starting set.
|
|
||||||
3. **Write or update focused tests.** Production changes in `src/`, `open-sse/`, `electron/`, or
|
|
||||||
`bin/` require an automated test in the same PR. Run the smallest test files that prove the
|
|
||||||
behavior, then the listed focused gates.
|
|
||||||
4. **Let CI run the broad matrix.** The complete unit shards, Vitest, coverage ratchet, and
|
|
||||||
production build run on the PR. Run a broad suite locally only when a focused failure points to
|
|
||||||
wider impact or when the change spans several subsystems.
|
|
||||||
5. **Reconcile before review.** Fetch the active base, inspect its new commits and your diff against
|
|
||||||
it, then rebase or merge the base according to the contributor workflow. Resolve generated-file
|
|
||||||
and catalog conflicts from their source, regenerate them, rerun the focused loop, and confirm the
|
|
||||||
PR still targets the active release branch.
|
|
||||||
6. **Record evidence.** In the PR template, list the commands run, every test file added or changed,
|
|
||||||
migrations or feature flags, and any CI-only validation still pending.
|
|
||||||
|
|
||||||
## Golden paths by change type
|
|
||||||
|
|
||||||
Commands below are minimum focused checks, not permission to skip a test that directly covers the
|
|
||||||
behavior you changed.
|
|
||||||
|
|
||||||
### Provider
|
|
||||||
|
|
||||||
**Contracts**
|
|
||||||
|
|
||||||
- Provider definition in `src/shared/constants/providers/` and its composition in
|
|
||||||
`src/shared/constants/providers.ts`.
|
|
||||||
- Models and capabilities in `open-sse/config/providerRegistry.ts` or its extracted registry files.
|
|
||||||
- Executor/translator selection, OAuth or API-key configuration, dashboard assets, and generated
|
|
||||||
provider reference when applicable.
|
|
||||||
- Public credentials must use `resolvePublicCred()`; error responses must use the shared sanitized
|
|
||||||
error helpers. See [Public Credentials](../security/PUBLIC_CREDS.md) and
|
|
||||||
[Error Sanitization](../security/ERROR_SANITIZATION.md).
|
|
||||||
|
|
||||||
**Focused loop**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run check:provider-consistency
|
|
||||||
npm run check:provider-assets
|
|
||||||
node --import tsx/esm --test tests/unit/provider-translate-path-golden.test.ts
|
|
||||||
node --import tsx/esm --test tests/unit/<provider-or-executor>.test.ts
|
|
||||||
npm run gen:provider-reference # when the catalog changes; commit the generated diff
|
|
||||||
npm run lint
|
|
||||||
```
|
|
||||||
|
|
||||||
Also test every affected request family: chat, Responses, images, embeddings, audio, or video.
|
|
||||||
Review generated catalog and golden diffs as contract changes; do not accept them blindly.
|
|
||||||
|
|
||||||
### Routing
|
|
||||||
|
|
||||||
**Contracts**
|
|
||||||
|
|
||||||
- Public strategy values and UI metadata in `src/shared/constants/routingStrategies.ts`.
|
|
||||||
- Dispatch and ordering under `open-sse/services/combo.ts` and `open-sse/services/combo/`.
|
|
||||||
- Combo schemas, persistence, resilience state, model capabilities, and API/UI controls.
|
|
||||||
- [Auto-Combo Engine](../routing/AUTO-COMBO.md) and resilience documentation when behavior changes.
|
|
||||||
|
|
||||||
**Focused loop**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
node --import tsx/esm --test tests/unit/combo-<behavior>.test.ts
|
|
||||||
npm run test:combo:matrix # strategy or dispatch changes
|
|
||||||
npm run check:known-symbols # strategy registration changes
|
|
||||||
npm run lint
|
|
||||||
```
|
|
||||||
|
|
||||||
Use deterministic mocked-upstream tests locally. Live combo smokes require credentials and are
|
|
||||||
manual, not CI substitutes.
|
|
||||||
|
|
||||||
### UI / UX
|
|
||||||
|
|
||||||
**Contracts**
|
|
||||||
|
|
||||||
- Next.js route/page and shared component boundaries under `src/app/` and
|
|
||||||
`src/shared/components/`.
|
|
||||||
- API response shapes, loading/empty/error states, keyboard and screen-reader behavior,
|
|
||||||
responsive layout, theming, and locale expansion.
|
|
||||||
- English UI source strings in `src/i18n/messages/en.json`; do not hard-code new user-facing copy.
|
|
||||||
|
|
||||||
**Focused loop**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
node --import tsx --test tests/unit/dashboard/<feature>.test.ts
|
|
||||||
npx vitest run --config vitest.config.ts tests/unit/ui/<component>.test.tsx
|
|
||||||
npm run check:dashboard-typecheck
|
|
||||||
npm run lint
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the app for interaction or visual changes and check both narrow and wide viewports. CI runs the
|
|
||||||
production build and broader suites; visual behavior still needs a focused component, Playwright,
|
|
||||||
or documented manual check appropriate to the change.
|
|
||||||
|
|
||||||
### i18n
|
|
||||||
|
|
||||||
**Contracts**
|
|
||||||
|
|
||||||
- `src/i18n/messages/en.json` is the UI source; `config/i18n.json` is the locale source.
|
|
||||||
- CLI catalogs live separately under `bin/cli/locales/`.
|
|
||||||
- Preserve ICU placeholders and tags exactly. Do not translate product/provider/model names,
|
|
||||||
protocol and header names, commands, code/JSON identifiers, URLs, environment variables, or
|
|
||||||
protected terms such as `OmniRoute`, `OAuth`, `MCP`, and `A2A`. The current source list is
|
|
||||||
`scripts/i18n/glossary/protected-terms.json`.
|
|
||||||
|
|
||||||
**Focused loop**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run i18n:sync-ui:dry
|
|
||||||
npm run i18n:check-ui-coverage
|
|
||||||
npm run i18n:check-value-drift
|
|
||||||
npm run i18n:check-glossary
|
|
||||||
npm run check:cli-i18n # when CLI strings/catalogs change
|
|
||||||
npm run lint
|
|
||||||
```
|
|
||||||
|
|
||||||
This is guidance for the existing system, not an invitation to expand its tooling or key model.
|
|
||||||
Keep i18n patches surgical while the replacement system is being designed. Do not run translation
|
|
||||||
commands that call external services unless the task explicitly requires generated translations and
|
|
||||||
you have reviewed the resulting diff.
|
|
||||||
|
|
||||||
### CLI
|
|
||||||
|
|
||||||
**Contracts**
|
|
||||||
|
|
||||||
- Public commands and flags in `bin/cli/`, generated API commands, exit codes, stdout/stderr and
|
|
||||||
JSON output shapes, config/environment behavior, and packaged files.
|
|
||||||
- CLI user-facing strings must use the CLI i18n layer and keep `en`/`pt-BR` catalogs aligned.
|
|
||||||
- Preserve Node as the supported runtime and the published binary contract.
|
|
||||||
|
|
||||||
**Focused loop**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
node --import tsx/esm --test tests/unit/cli/<command>.test.ts
|
|
||||||
npm run check:cli-i18n
|
|
||||||
npm run build:cli # generated/bundled CLI changes
|
|
||||||
npm run check:pack-policy # package-surface changes
|
|
||||||
npm run lint
|
|
||||||
```
|
|
||||||
|
|
||||||
Use the exact command in a temporary data directory when behavior depends on parsing, files, or exit
|
|
||||||
status. CI performs the broader package artifact and ecosystem checks.
|
|
||||||
|
|
||||||
### Database
|
|
||||||
|
|
||||||
**Contracts**
|
|
||||||
|
|
||||||
- Domain modules under `src/lib/db/`; `src/lib/localDb.ts` remains a re-export layer only.
|
|
||||||
- Numbered, idempotent SQL migrations under `src/lib/db/migrations/`, transaction safety, upgrade
|
|
||||||
behavior, indexes, and every caller affected by the schema.
|
|
||||||
- Routes and handlers never issue raw SQL directly.
|
|
||||||
|
|
||||||
**Focused loop**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run check:migration-numbering
|
|
||||||
npm run check:db-rules
|
|
||||||
node --import tsx/esm --test tests/unit/db/<domain>.test.ts
|
|
||||||
node --import tsx/esm --test tests/unit/db/migration-<number>.test.ts
|
|
||||||
npm run lint
|
|
||||||
```
|
|
||||||
|
|
||||||
Test both a fresh database and upgrade from the prior schema when adding a migration. Database tests
|
|
||||||
must close handles and call `resetDbInstance()` during cleanup. Run `npm run test:bun:db` only when
|
|
||||||
the best-effort Bun adapter path changes; Node remains authoritative.
|
|
||||||
|
|
||||||
### Build / deploy
|
|
||||||
|
|
||||||
**Contracts**
|
|
||||||
|
|
||||||
- Root and workspace manifests/lockfile, `scripts/build/`, Next.js standalone assembly, `dist/`
|
|
||||||
package contents, Electron platform metadata, CI workflows, and deployment sentinels.
|
|
||||||
- Supported Node ranges and the allow-listed Bun use in `CLAUDE.md` must remain intact.
|
|
||||||
- Build artifacts stay untracked; dependency, license, workflow, and package policies apply.
|
|
||||||
|
|
||||||
**Focused loop**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
node --import tsx/esm --test tests/unit/build/<behavior>.test.ts
|
|
||||||
npm run check:build-scope
|
|
||||||
npm run check:lockfile # dependency or lockfile changes
|
|
||||||
npm run check:pack-policy # published package surface changes
|
|
||||||
npm run lint
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `npm run build` locally only when the change affects compilation, standalone assembly, assets,
|
|
||||||
or runtime bundling. Use `npm run build:release` only for release/deploy validation. CI's build is
|
|
||||||
the final cross-platform signal; platform-specific Electron changes need the matching focused build
|
|
||||||
or smoke evidence.
|
|
||||||
|
|
||||||
## Local loop versus CI
|
|
||||||
|
|
||||||
| Run locally for each patch | CI supplies the broad signal |
|
|
||||||
| ----------------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
|
||||||
| Direct behavior tests and category gates above | Sharded full unit suite and serial tests |
|
|
||||||
| `npm run lint` | Vitest suites and coverage/quality ratchets |
|
|
||||||
| Typecheck or build only when the affected contract calls for it | Production build, security, docs, dependency, and PR-policy gates |
|
|
||||||
| Manual interaction/live checks only when automation cannot prove the behavior | Cross-job integration and platform checks configured by workflow |
|
|
||||||
|
|
||||||
A green focused loop is evidence about the changed contract, not proof that unrelated CI checks
|
|
||||||
will pass. Conversely, do not make every local edit wait for the full repository matrix.
|
|
||||||
|
|
||||||
## Reconciliation checklist
|
|
||||||
|
|
||||||
Before requesting review:
|
|
||||||
|
|
||||||
- Confirm the PR base is still the highest active `release/v*` branch.
|
|
||||||
- Fetch that base and review commits that landed since you branched.
|
|
||||||
- Review `git diff <active-base>...HEAD` for accidental or generated churn.
|
|
||||||
- Resolve catalog and generated-document conflicts by updating the source and regenerating output.
|
|
||||||
- Rerun every focused test/gate listed in the PR description after reconciliation.
|
|
||||||
- Never weaken assertions or drop required tests merely to match a moved base.
|
|
||||||
|
|
||||||
For release-freeze and retargeting rules, use
|
|
||||||
[Branching & Release Model](../ops/BRANCHING_MODEL.md). For the complete CI inventory, use
|
|
||||||
[Quality Gates Reference](../architecture/QUALITY_GATES.md).
|
|
||||||
@@ -461,18 +461,10 @@ own dedicated branch, and you MUST confirm the base branch with the operator bef
|
|||||||
git fetch origin "$BASE_BRANCH"
|
git fetch origin "$BASE_BRANCH"
|
||||||
git worktree add ".claude/worktrees/${TASK##*/}" -b "$TASK" "origin/$BASE_BRANCH"
|
git worktree add ".claude/worktrees/${TASK##*/}" -b "$TASK" "origin/$BASE_BRANCH"
|
||||||
cd ".claude/worktrees/${TASK##*/}"
|
cd ".claude/worktrees/${TASK##*/}"
|
||||||
# Reuse the main checkout's node_modules to skip a per-worktree npm install.
|
# symlink node_modules from the main checkout to skip a per-worktree npm install:
|
||||||
# HARD LINKS (`cp -al`), never a symlink: ~5s for the whole tree and near-zero extra
|
ln -s "$(git -C <main_checkout> rev-parse --show-toplevel)/node_modules" node_modules
|
||||||
# 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
|
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`
|
`.claude/worktrees/`): create the worktree with the command above, then call `EnterWorktree`
|
||||||
with its `path`.
|
with its `path`.
|
||||||
|
|||||||
@@ -415,18 +415,10 @@ git push -u origin feat/your-feature
|
|||||||
git fetch origin "$BASE_BRANCH"
|
git fetch origin "$BASE_BRANCH"
|
||||||
git worktree add ".claude/worktrees/${TASK##*/}" -b "$TASK" "origin/$BASE_BRANCH"
|
git worktree add ".claude/worktrees/${TASK##*/}" -b "$TASK" "origin/$BASE_BRANCH"
|
||||||
cd ".claude/worktrees/${TASK##*/}"
|
cd ".claude/worktrees/${TASK##*/}"
|
||||||
# 复用主工作区的 node_modules,省去每个 worktree 的 npm install。
|
# 从主工作区符号链接 node_modules,省去每个 worktree 的 npm install:
|
||||||
# 必须用硬链接(`cp -al`),绝不能用符号链接:整棵树约 5 秒,几乎不占额外磁盘
|
ln -s "$(git -C <main_checkout> rev-parse --show-toplevel)/node_modules" node_modules
|
||||||
# (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`。
|
在 Claude Code 中优先使用原生的 `EnterWorktree` 工具(它已经在 `.claude/worktrees/` 下创建 worktree):先用上述命令创建 worktree,然后用其 `path` 调用 `EnterWorktree`。
|
||||||
|
|
||||||
3. **工作、提交、推送、发起 PR — 全部在 worktree 内部完成。** 绝不在另一个会话可能共享的 worktree 内 `git checkout` 不同分支。
|
3. **工作、提交、推送、发起 PR — 全部在 worktree 内部完成。** 绝不在另一个会话可能共享的 worktree 内 `git checkout` 不同分支。
|
||||||
|
|||||||
168
docs/issues/plugin-browser-pool-proposal.md
Normal file
168
docs/issues/plugin-browser-pool-proposal.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# [Feature] Pluginization Phase 1: Extract Playwright/CloakBrowser Browser Pool
|
||||||
|
|
||||||
|
**Labels:** `enhancement`, `plugin`, `architecture`
|
||||||
|
|
||||||
|
## Problem / Use Case
|
||||||
|
|
||||||
|
OmniRoute's Playwright/CloakBrowser dependency is a **large, non-essential dependency** (~1500+ LOC across 22 source files + ~35 test files) pulled into every installation regardless of whether the user needs browser-backed chat. Users who run OmniRoute purely as a proxy/router (the majority) pay for:
|
||||||
|
|
||||||
|
- **Disk space**: ~200+ MB from Playwright browsers + Chromium binaries (installed via `npx playwright install`)
|
||||||
|
- **Build complexity**: Turbopack must handle the `cloakbrowser` package
|
||||||
|
- **Bundle size**: All browser-pool code is compiled into the main codebase
|
||||||
|
- **Surface area**: 7 files with direct Playwright imports (4 dynamic, 3 static type imports)
|
||||||
|
- **CI/cache impact**: Playwright installs in CI pipelines even when not needed
|
||||||
|
|
||||||
|
Currently, only environment variables (`OMNIROUTE_BROWSER_POOL=off`) gate _runtime_ execution — the code still loads, imports get resolved, and Playwright must be installed.
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
|
||||||
|
Extract the Playwright/CloakBrowser browser pool into an **optional package** loaded via dynamic `import()` at runtime, following the existing pattern used for `cloakbrowser` (computed-string dynamic import to avoid resolution). The core retains thin interface stubs that gracefully degrade when the optional package is absent.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
open-sse/
|
||||||
|
interfaces/
|
||||||
|
browserPool.ts ← NEW: BrowserPoolProvider interface + types
|
||||||
|
services/
|
||||||
|
browserPool.ts ← BECOMES: thin stub, delegates to optional package
|
||||||
|
browserBackedChat.ts ← BECOMES: thin stub, delegates to optional package
|
||||||
|
grokClearance.ts ← BECOMES: thin stub
|
||||||
|
|
||||||
|
packages/browser-pool/ ← NEW: optional package
|
||||||
|
index.ts ← exports BrowserPoolProvider implementation
|
||||||
|
src/
|
||||||
|
browserPool.ts ← extracted from open-sse/services/browserPool.ts
|
||||||
|
browserBackedChat.ts ← extracted from open-sse/services/browserBackedChat.ts
|
||||||
|
grokClearance.ts ← extracted from open-sse/services/grokClearance.ts
|
||||||
|
claudeTurnstileSolver.ts ← extracted (tightly coupled, moved as-is)
|
||||||
|
inAppLoginService.ts ← extracted (own Playwright instance, separate lifecycle)
|
||||||
|
package.json
|
||||||
|
tsconfig.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase Breakdown
|
||||||
|
|
||||||
|
**Phase 1 — Core pool extraction (this issue):**
|
||||||
|
|
||||||
|
1. Define `BrowserPoolProvider` interface in `open-sse/interfaces/browserPool.ts`
|
||||||
|
2. Extract `browserPool.ts` (~502 LOC), `browserBackedChat.ts` (~270 LOC), `grokClearance.ts` (~84 LOC) into `packages/browser-pool/`
|
||||||
|
3. Replace core files with thin stubs that try `import('../../../packages/browser-pool')` with graceful fallback
|
||||||
|
4. Keep `poolTools.ts` importing the core stub (unchanged from consumer perspective)
|
||||||
|
5. Make Playwright an optional dependency (not in root `package.json`)
|
||||||
|
6. Typecheck core passes with and without the package installed
|
||||||
|
7. All existing tests pass (with plugin installed)
|
||||||
|
|
||||||
|
**Phase 2 — Turnstile solver extraction (future):**
|
||||||
|
|
||||||
|
- Extract `claudeTurnstileSolver.ts` (~212 LOC) — has static Playwright type imports, needs type interface
|
||||||
|
- Move `claudeWebAutoRefresh.ts` (depends on turnstile solver)
|
||||||
|
|
||||||
|
**Phase 3 — Standalone Playwright instances (future):**
|
||||||
|
|
||||||
|
- Extract `inAppLoginService.ts` (~257 LOC)
|
||||||
|
- Refactor `gemini-web.ts` executor's own Playwright path (~553 LOC)
|
||||||
|
|
||||||
|
### Interface Design (Phase 1)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// open-sse/interfaces/browserPool.ts
|
||||||
|
export interface BrowserPoolProvider {
|
||||||
|
acquireBrowserContext(options?: BrowserPoolContextOptions): Promise<PooledContext>;
|
||||||
|
releaseBrowserContext(ctx: PooledContext): Promise<void>;
|
||||||
|
getBrowserPoolMetrics(): BrowserPoolMetrics;
|
||||||
|
shutdownPool(): Promise<void>;
|
||||||
|
isPoolEnabled(): boolean;
|
||||||
|
openPage(url: string, ctx?: PooledContext): Promise<{ page: any }>;
|
||||||
|
readPageResponseBody(page: any): Promise<string>;
|
||||||
|
getBrowserPoolStatus(): BrowserPoolStatus;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stub Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// open-sse/services/browserPool.ts — thin stub
|
||||||
|
let _impl: BrowserPoolProvider | null = null;
|
||||||
|
|
||||||
|
async function getImpl(): Promise<BrowserPoolProvider> {
|
||||||
|
if (!_impl) {
|
||||||
|
try {
|
||||||
|
const { createBrowserPoolProvider } = await import("../../packages/browser-pool");
|
||||||
|
_impl = createBrowserPoolProvider();
|
||||||
|
} catch {
|
||||||
|
// Graceful fallback — disabled
|
||||||
|
_impl = createNullBrowserPoolProvider();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _impl;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function acquireBrowserContext(...args) {
|
||||||
|
return (await getImpl()).acquireBrowserContext(...args);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
1. **Existing hook-based PluginManager**: Rejected. The current PluginManager operates via child-process IPC and request-pipeline hooks (`onRequest`, `onResponse`, `onError`). A browser pool is an in-process runtime service with composable lifecycle — not a request pipeline hook. Forcing it through IPC would add ~50ms+ per browser operation and break the existing synchronous pool pattern.
|
||||||
|
|
||||||
|
2. **Keep as-is, just lazy-load the import**: Minimal improvement — the dependency tree still references Playwright types, requiring it to be available. Doesn't reduce bundle size or simplify CI.
|
||||||
|
|
||||||
|
3. **Replace Playwright with a protocol-level abstraction**: Too ambitious and would change the behavior of the pool. Playwright's CDP capabilities (context isolation, cookies, screenshots) are fundamental to how the pool works.
|
||||||
|
|
||||||
|
4. **Monorepo workspace**: Too heavy for this scope. A simple extracted package avoids workspace tooling changes.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
1. `open-sse/interfaces/browserPool.ts` exists and exports `BrowserPoolProvider`, `PooledContext`, `BrowserPoolMetrics` types
|
||||||
|
2. `open-sse/services/browserPool.ts` becomes a thin stub with zero Playwright imports
|
||||||
|
3. `packages/browser-pool/` contains all extracted implementation (browserPool, browserBackedChat, grokClearance)
|
||||||
|
4. Core typecheck (`npm run typecheck:core`) passes with 0 errors **without** the browser-pool package installed
|
||||||
|
5. Core typecheck passes with the package installed
|
||||||
|
6. All existing tests pass when the browser-pool package is installed
|
||||||
|
7. `poolTools.ts` `omniroute_browser_pool_status` tool works end-to-end when the package is installed
|
||||||
|
8. Graceful degradation: when the package is absent, `getBrowserPoolStatus()` returns `{ enabled: false }` without crashing
|
||||||
|
9. Playwright is moved from root `dependencies` to optional/peer in the extracted package
|
||||||
|
10. Documentation updated in `docs/reference/ENVIRONMENT.md`
|
||||||
|
|
||||||
|
## Expected Test Plan
|
||||||
|
|
||||||
|
- Unit tests for the stub fallback path (simulate import failure, verify graceful degradation)
|
||||||
|
- Unit tests moved to the extracted package
|
||||||
|
- Verify `tests/unit/browser-pool-optional-import.test.ts` passes (still validates cloakbrowser isn't statically resolved)
|
||||||
|
- Verify `tests/unit/browserPool-proxy.test.ts` passes
|
||||||
|
- Verify `tests/unit/browserBackedChat-matcher.test.ts` passes
|
||||||
|
- E2E: `npm run typecheck:core` without the package installed → 0 errors
|
||||||
|
- E2E: `npm run test:coverage` (with package installed) → existing coverage gates pass
|
||||||
|
|
||||||
|
## Additional Context
|
||||||
|
|
||||||
|
Current dependency graph (simplified):
|
||||||
|
|
||||||
|
```
|
||||||
|
open-sse/services/browserPool.ts (502 LOC, singleton Playwright/CloakBrowser pool)
|
||||||
|
├── open-sse/services/browserBackedChat.ts (270 LOC, browser-backed chat runner)
|
||||||
|
│ ├── open-sse/executors/claude-web.ts (imports tryBackedChat)
|
||||||
|
│ └── open-sse/executors/duckduckgo-web.ts (imports tryBackedChat)
|
||||||
|
├── open-sse/services/grokClearance.ts (84 LOC, CF clearance via browser)
|
||||||
|
└── open-sse/mcp-server/tools/poolTools.ts (imports getBrowserPoolMetrics)
|
||||||
|
|
||||||
|
Standalone Playwright users (separate, future phases):
|
||||||
|
├── open-sse/services/claudeTurnstileSolver.ts (212 LOC, static Playwright type imports)
|
||||||
|
├── open-sse/services/inAppLoginService.ts (257 LOC, own browser lifecycle)
|
||||||
|
└── open-sse/executors/gemini-web.ts (553 LOC, private Playwright path)
|
||||||
|
|
||||||
|
Kill switches: OMNIROUTE_BROWSER_POOL, WEB_COOKIE_USE_BROWSER (both env vars)
|
||||||
|
```
|
||||||
|
|
||||||
|
Total extracted in Phase 1: ~856 LOC, 3 files.
|
||||||
|
Total deferred to Phase 2/3: ~1022 LOC, 4 files.
|
||||||
|
|
||||||
|
This is the first pluginization step. Future targets (separate issues): memory/compression plugin, additional provider support extraction.
|
||||||
|
|
||||||
|
## Related References
|
||||||
|
|
||||||
|
- PR #8219 (model catalog connection filter + cache TTL) — same baseline `release/v3.8.49`
|
||||||
|
- `docs/reference/ENVIRONMENT.md` — browser pool env vars documentation
|
||||||
|
- Plugin system docs at `docs/PLUGINS.md` — existing PluginManager (not used here, referenced for contrast)
|
||||||
@@ -414,6 +414,7 @@ detection above).
|
|||||||
| `OMNIROUTE_HTTP_TIMEOUT_MS` | `30000` | `bin/cli/api.mjs` | Per-attempt HTTP timeout (ms) for CLI → server requests. |
|
| `OMNIROUTE_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_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_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.
|
> 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 |
|
| Factor | Default Weight | Description |
|
||||||
| :-------------------- | :------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| :-------------------- | :------------- | :------------------------------------------------------------------------------------------------- |
|
||||||
| `health` | 0.20 | Health score from circuit breaker (CLOSED=1.0, HALF_OPEN=0.5, OPEN=0.0) |
|
| `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] |
|
| `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 |
|
| `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 |
|
| `latencyInv` | 0.12 | Inverse p95 latency normalized to pool — faster = higher score |
|
||||||
| `taskFit` | 0.08 | Task-type fitness (coding, review, planning, analysis, debugging, docs) |
|
| `taskFit` | 0.08 | Task-type fitness (coding, review, planning, analysis, debugging, docs) |
|
||||||
| `stability` | 0.05 | Variance-based stability (low latency stdDev / error rate) |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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) |
|
| `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) |
|
| `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()`).
|
**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
|
that carries them; the combo's saved `modePack`/`budgetCap`/`budgetFallback` are used
|
||||||
when the header is absent.
|
when the header is absent.
|
||||||
|
|
||||||
| Header | Accepts | Effect |
|
| 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-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` | 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. |
|
| `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
|
```bash
|
||||||
# Force the fastest profile, cap this request at $0.05, and hard-block instead of overspending
|
# 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.
|
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 |
|
| Strategy | Description |
|
||||||
| :------------------ | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| :------------------ | :--------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `priority` | First-target ordered list with explicit priority |
|
| `priority` | First-target ordered list with explicit priority |
|
||||||
| `weighted` | Weighted random by per-target weight |
|
| `weighted` | Weighted random by per-target weight |
|
||||||
| `round-robin` | Cycle through targets in order |
|
| `round-robin` | Cycle through targets in order |
|
||||||
| `context-relay` | Hand off context across targets (long conversations) |
|
| `context-relay` | Hand off context across targets (long conversations) |
|
||||||
| `fill-first` | Fill each target's quota before moving to next |
|
| `fill-first` | Fill each target's quota before moving to next |
|
||||||
| `p2c` | Power-of-2-choices random load balancing |
|
| `p2c` | Power-of-2-choices random load balancing |
|
||||||
| `random` | Uniform random selection |
|
| `random` | Uniform random selection |
|
||||||
| `least-used` | Pick target with lowest current load |
|
| `least-used` | Pick target with lowest current load |
|
||||||
| `cost-optimized` | Minimize $ per request given catalog pricing |
|
| `cost-optimized` | Minimize $ per request given catalog pricing |
|
||||||
| `reset-aware` ⭐ | Prioritize by quota reset time — short reset windows ranked higher |
|
| `reset-aware` ⭐ | Prioritize by quota reset time — short reset windows ranked higher |
|
||||||
| `reset-window` | Prefer targets whose quota window resets soonest |
|
| `reset-window` | Prefer targets whose quota window resets soonest |
|
||||||
| `headroom` | Pick the target with the most remaining quota headroom |
|
| `headroom` | Pick the target with the most remaining quota headroom |
|
||||||
| `strict-random` | Random without deduplication of repeats |
|
| `strict-random` | Random without deduplication of repeats |
|
||||||
| `auto` | Use Auto Combo scoring (9-factor) — **recommended** |
|
| `auto` | Use Auto Combo scoring (9-factor) — **recommended** |
|
||||||
| `lkgp` | Last-Known-Good Path (sticky route to last successful target) |
|
| `lkgp` | Last-Known-Good Path (sticky route to last successful target) |
|
||||||
| `context-optimized` | Pick target with best fit for current context size |
|
| `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) |
|
| `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) |
|
| `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) |
|
| `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
|
⭐ = 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`)
|
### Deterministic routing-decision matrix (`npm run test:combo:matrix`)
|
||||||
|
|
||||||
`tests/integration/combo-matrix/*.test.ts` proves the routing **decision** of all 19
|
`tests/integration/combo-matrix/*.test.ts` proves the routing **decision** of all 18
|
||||||
public strategies end-to-end through the real combo pipeline with a mocked upstream.
|
public strategies end-to-end through the real combo pipeline with a mocked upstream.
|
||||||
Coverage includes:
|
Coverage includes:
|
||||||
|
|
||||||
- All 19 `ROUTING_STRATEGY_VALUES` strategies (ordered, weighted, cost, context, fusion, …).
|
- All 18 `ROUTING_STRATEGY_VALUES` strategies (ordered, weighted, cost, context, fusion, …).
|
||||||
- `quota-share` (internal) end-to-end: DRR fairness + saturation deprioritization via the
|
- `quota-share` (internal) end-to-end: DRR fairness + saturation deprioritization via the
|
||||||
real `selectQuotaShareTarget` seam (`registerQuotaFetcher` / `setLKGP` /
|
real `selectQuotaShareTarget` seam (`registerQuotaFetcher` / `setLKGP` /
|
||||||
`__setHeadroomSaturationFetcherForTests`).
|
`__setHeadroomSaturationFetcherForTests`).
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
# agentrouter.org WAF (Web Application Firewall)
|
|
||||||
|
|
||||||
The `agentrouter` upstream gateway runs a keyword-based content filter on
|
|
||||||
`messages[].content`. The filter is partially deterministic (always blocks
|
|
||||||
certain phrases) and partially probabilistic (burst-sensitive — becomes
|
|
||||||
more aggressive after rapid requests, recovers after a cooldown).
|
|
||||||
|
|
||||||
When the WAF blocks a request it returns:
|
|
||||||
|
|
||||||
```
|
|
||||||
HTTP/1.1 400 Bad Request
|
|
||||||
{"error":{"code":"content-blocked","message":"content-blocked (request id: ...)","param":"","type":"agent_router_api_error"}}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Scope of the filter
|
|
||||||
|
|
||||||
The WAF inspects `messages[].content` only. It does **not** inspect:
|
|
||||||
|
|
||||||
- The `system` prompt
|
|
||||||
- Structured content blocks (`tool_result`, `tool_use`, `thinking`, `image`)
|
|
||||||
- Tool `description` and `input_schema` fields
|
|
||||||
- Request metadata, headers, or model id
|
|
||||||
|
|
||||||
## Always-blocked patterns (case-insensitive)
|
|
||||||
|
|
||||||
| Pattern | Notes |
|
|
||||||
|-------------------------------|----------------------------------------|
|
|
||||||
| Any `Lorem ipsum` variant | Full Latin lorem vocabulary is blocked |
|
|
||||||
| `language model` (alone) | "the language model" and "large language model" pass |
|
|
||||||
| `virtual assistant` | "AI assistant" passes |
|
|
||||||
| `I'm here to help` | "here to help" alone also blocks |
|
|
||||||
| `Claude, made by Anthropic` | Full phrase only |
|
|
||||||
|
|
||||||
## Almost-always-blocked patterns
|
|
||||||
|
|
||||||
| Pattern | Notes |
|
|
||||||
|-------------------|---------------------------------------------------------|
|
|
||||||
| `placeholder` | When it stands alone (not as a parameter name, etc.) |
|
|
||||||
| `dummy data` | Common seed phrase for fixtures |
|
|
||||||
| `foo bar baz` | Canonical placeholder phrase |
|
|
||||||
| Repeated short tokens (`AAA BBB CCC`, `test test test`) | Detector for keyword stuffing |
|
|
||||||
|
|
||||||
## Behavior under load
|
|
||||||
|
|
||||||
After ~5 rapid requests in a short window, the WAF begins blocking content
|
|
||||||
that would normally pass. The bucket relaxes after ~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,29 +13,6 @@ const TO_NUMBER_RESTRICTION = {
|
|||||||
"canonical coercion shape and the `toNumberOrNull`/`toNumberArray` variants.",
|
"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[]} */
|
/** @type {import("eslint").Linter.Config[]} */
|
||||||
const eslintConfig = [
|
const eslintConfig = [
|
||||||
...nextVitals,
|
...nextVitals,
|
||||||
@@ -62,32 +39,15 @@ const eslintConfig = [
|
|||||||
"no-eval": "error",
|
"no-eval": "error",
|
||||||
"no-implied-eval": "error",
|
"no-implied-eval": "error",
|
||||||
"no-new-func": "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": [
|
"no-restricted-imports": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
paths: [PROP_TYPES_RESTRICTION],
|
paths: [
|
||||||
},
|
{
|
||||||
],
|
name: "prop-types",
|
||||||
},
|
message: "PropTypes are deprecated. Use TypeScript types/interfaces instead.",
|
||||||
},
|
},
|
||||||
// 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,9 +1,4 @@
|
|||||||
import {
|
const DEFAULT_CODEX_CLIENT_VERSION = "0.144.1";
|
||||||
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_PLATFORM = "Windows 10.0.26200";
|
||||||
const DEFAULT_CODEX_USER_AGENT_ARCH = "x64";
|
const DEFAULT_CODEX_USER_AGENT_ARCH = "x64";
|
||||||
const CODEX_VERSION_OVERRIDE_ENV = "CODEX_CLIENT_VERSION";
|
const CODEX_VERSION_OVERRIDE_ENV = "CODEX_CLIENT_VERSION";
|
||||||
@@ -47,10 +42,6 @@ export function getCodexDefaultHeaders(): Record<string, string> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCodexCliRsHeaders(): Record<string, string> {
|
|
||||||
return buildCodexCliRsHeaders(getCodexClientVersion());
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeCodexSessionId(value: unknown): string | null {
|
export function normalizeCodexSessionId(value: unknown): string | null {
|
||||||
if (typeof value !== "string") return null;
|
if (typeof value !== "string") return null;
|
||||||
const normalized = value.trim();
|
const normalized = value.trim();
|
||||||
|
|||||||
@@ -19,9 +19,10 @@ import type { FreeModelBudget } from "./freeModelCatalog.ts";
|
|||||||
export const FREE_CATALOG_CURATED_AT = "2026-07-22";
|
export const FREE_CATALOG_CURATED_AT = "2026-07-22";
|
||||||
|
|
||||||
export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
|
export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
|
||||||
{ provider: "agentrouter", modelId: "claude-opus-4-8", displayName: "Claude Opus 4.8", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" },
|
{ provider: "agentrouter", modelId: "claude-opus-4-6", displayName: "Claude 4.6 Opus", 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: "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: "gpt-5.6-sol", displayName: "GPT-5.6 Sol", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" },
|
{ provider: "agentrouter", modelId: "glm-5.1", displayName: "GLM 5.1", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" },
|
||||||
|
{ provider: "agentrouter", modelId: "deepseek-v3.2", displayName: "DeepSeek V3.2", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" },
|
||||||
{ provider: "agy", modelId: "claude-opus-4-6-thinking", displayName: "Claude Opus 4.6 (Thinking)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" },
|
{ provider: "agy", modelId: "claude-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: "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" },
|
{ 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,7 +11,6 @@ import { KIE_IMAGE_MODELS } from "./providers/registry/kie/imageModels.ts";
|
|||||||
import { FREEPIK_IMAGE_PROVIDER } from "./providers/registry/freepik/index.ts";
|
import { FREEPIK_IMAGE_PROVIDER } from "./providers/registry/freepik/index.ts";
|
||||||
import { STABILITY_AI_IMAGE_MODELS } from "./providers/registry/stability-ai/imageModels.ts";
|
import { STABILITY_AI_IMAGE_MODELS } from "./providers/registry/stability-ai/imageModels.ts";
|
||||||
import { GEMINI_IMAGEN_PROVIDER } from "./providers/registry/gemini/imageModels.ts";
|
import { GEMINI_IMAGEN_PROVIDER } from "./providers/registry/gemini/imageModels.ts";
|
||||||
import { CHEAPERINFERENCE_IMAGE_PROVIDER } from "./providers/registry/cheaperinference/imageModels.ts";
|
|
||||||
|
|
||||||
interface ImageModelEntry {
|
interface ImageModelEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -715,13 +714,6 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderConfig> = {
|
|||||||
supportedSizes: ["1:1", "16:9", "9:16", "4:3", "3:4", "1024x1024", "1792x1024", "1024x1792"],
|
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
|
// Keep Bailian Coding Plan after existing duplicate model owners so adding
|
||||||
// explicit `bailian-coding-plan/` and `bcp/` routes does not change
|
// explicit `bailian-coding-plan/` and `bcp/` routes does not change
|
||||||
// historical bare-model routing.
|
// historical bare-model routing.
|
||||||
|
|||||||
@@ -117,7 +117,6 @@ import { uncloseaiProvider } from "./registry/uncloseai/index.ts";
|
|||||||
import { nscaleProvider } from "./registry/nscale/index.ts";
|
import { nscaleProvider } from "./registry/nscale/index.ts";
|
||||||
import { chatgpt_webProvider } from "./registry/chatgpt-web/index.ts";
|
import { chatgpt_webProvider } from "./registry/chatgpt-web/index.ts";
|
||||||
import { openrouterProvider } from "./registry/openrouter/index.ts";
|
import { openrouterProvider } from "./registry/openrouter/index.ts";
|
||||||
import { cheaperinferenceProvider } from "./registry/cheaperinference/index.ts";
|
|
||||||
import { openvectaProvider } from "./registry/openvecta/index.ts";
|
import { openvectaProvider } from "./registry/openvecta/index.ts";
|
||||||
import { orcarouterProvider } from "./registry/orcarouter/index.ts";
|
import { orcarouterProvider } from "./registry/orcarouter/index.ts";
|
||||||
import { copilot_webProvider } from "./registry/copilot-web/index.ts";
|
import { copilot_webProvider } from "./registry/copilot-web/index.ts";
|
||||||
@@ -338,7 +337,6 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
|||||||
nscale: nscaleProvider,
|
nscale: nscaleProvider,
|
||||||
"chatgpt-web": chatgpt_webProvider,
|
"chatgpt-web": chatgpt_webProvider,
|
||||||
openrouter: openrouterProvider,
|
openrouter: openrouterProvider,
|
||||||
cheaperinference: cheaperinferenceProvider,
|
|
||||||
openvecta: openvectaProvider,
|
openvecta: openvectaProvider,
|
||||||
orcarouter: orcarouterProvider,
|
orcarouter: orcarouterProvider,
|
||||||
"copilot-web": copilot_webProvider,
|
"copilot-web": copilot_webProvider,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { RegistryEntry } from "../../shared.ts";
|
import type { RegistryEntry } from "../../shared.ts";
|
||||||
import { getCodexCliRsHeaders } from "../../../codexClient.ts";
|
|
||||||
|
|
||||||
export const agentrouterProvider: RegistryEntry = {
|
export const agentrouterProvider: RegistryEntry = {
|
||||||
id: "agentrouter",
|
id: "agentrouter",
|
||||||
@@ -9,22 +8,6 @@ export const agentrouterProvider: RegistryEntry = {
|
|||||||
baseUrl: "https://agentrouter.org/v1/messages",
|
baseUrl: "https://agentrouter.org/v1/messages",
|
||||||
authType: "apikey",
|
authType: "apikey",
|
||||||
authHeader: "x-api-key",
|
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,
|
defaultContextLength: 128000,
|
||||||
// No static `headers` here: agentrouter now adopts the DYNAMIC Claude-Code
|
// No static `headers` here: agentrouter now adopts the DYNAMIC Claude-Code
|
||||||
// wire image via CC_WIRE_IMAGE_BUILTINS (#6056) — the fingerprint/headers are
|
// wire image via CC_WIRE_IMAGE_BUILTINS (#6056) — the fingerprint/headers are
|
||||||
@@ -32,9 +15,10 @@ export const agentrouterProvider: RegistryEntry = {
|
|||||||
// own baseUrl + x-api-key auth. A static fingerprint here would drift and
|
// own baseUrl + x-api-key auth. A static fingerprint here would drift and
|
||||||
// trip AgentRouter's WAF ("unauthorized client detected").
|
// trip AgentRouter's WAF ("unauthorized client detected").
|
||||||
models: [
|
models: [
|
||||||
{ id: "claude-opus-4-8", name: "Claude Opus 4.8" },
|
{ id: "claude-opus-4-6", name: "Claude 4.6 Opus" },
|
||||||
{ id: "claude-opus-5", name: "Claude Opus 5" },
|
{ id: "claude-haiku-4-5-20251001", name: "Claude 4.5 Haiku" },
|
||||||
{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol" },
|
{ id: "glm-5.1", name: "GLM 5.1" },
|
||||||
|
{ id: "deepseek-v3.2", name: "DeepSeek V3.2" },
|
||||||
],
|
],
|
||||||
passthroughModels: true,
|
passthroughModels: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
/**
|
|
||||||
* Cheaper Inference image provider registry entry.
|
|
||||||
* Extracted into its own module to keep open-sse/config/imageRegistry.ts
|
|
||||||
* under the file-size cap (god-file decomposition; semantic split) — same
|
|
||||||
* pattern as FREEPIK_IMAGE_PROVIDER / SEGMIND_IMAGE_PROVIDER.
|
|
||||||
*
|
|
||||||
* 3 image models measured from GET /v1/models?type=image on 2026-07-31.
|
|
||||||
*
|
|
||||||
* COLLISION NOTE: nano-banana-pro and nano-banana-2 are ALSO adobe-firefly model
|
|
||||||
* ids. parseImageModel() resolves a bare id by first-match over IMAGE_PROVIDERS
|
|
||||||
* iteration order, so this entry is spread into that object AFTER adobe-firefly:
|
|
||||||
* bare `nano-banana-2` keeps routing to Firefly (pre-existing behaviour) and these
|
|
||||||
* models are reachable only as `cheaperinference/<id>` / `cinf/<id>`. Do NOT add
|
|
||||||
* IMAGE_MODEL_ALIASES entries for them — that would silently re-route Firefly
|
|
||||||
* users. Guarded by tests/unit/cheaperinference-image-models.test.ts.
|
|
||||||
*
|
|
||||||
* The endpoint ignores `response_format:"url"` and always returns `b64_json`
|
|
||||||
* (measured twice). The OpenAI image path already handles b64_json; this is not a
|
|
||||||
* bug to "fix". /v1/images/edits returns 404 upstream, so no edit support.
|
|
||||||
*/
|
|
||||||
export const CHEAPERINFERENCE_IMAGE_PROVIDER = {
|
|
||||||
id: "cheaperinference",
|
|
||||||
alias: "cinf",
|
|
||||||
baseUrl: "https://api.cheaperinference.com/v1/images/generations",
|
|
||||||
authType: "apikey",
|
|
||||||
authHeader: "bearer",
|
|
||||||
format: "openai",
|
|
||||||
models: [
|
|
||||||
{ id: "grok-imagine", name: "Grok Imagine (Cheaper Inference)" },
|
|
||||||
{ id: "nano-banana-pro", name: "Nano Banana Pro (Cheaper Inference)" },
|
|
||||||
{ id: "nano-banana-2", name: "Nano Banana 2 (Cheaper Inference)" },
|
|
||||||
],
|
|
||||||
supportedSizes: ["1024x1024", "2048x2048", "4096x4096"],
|
|
||||||
};
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
import type { RegistryEntry, RegistryModel } from "../../shared.ts";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cheaper Inference (https://api.cheaperinference.com) — cost-ranked OpenAI-compatible
|
|
||||||
* gateway, OmniRoute Open Source Friend.
|
|
||||||
*
|
|
||||||
* Catalog captured from a live `GET /v1/models` on 2026-07-31 (42 entries: these 39
|
|
||||||
* `type:"text"` models plus 3 `type:"image"` models that live in imageRegistry.ts —
|
|
||||||
* sending an image model here returns HTTP 400 "Use POST /v1/images/generations").
|
|
||||||
* `supportsVision`/`supportsReasoning` mirror each entry's `capabilities` object
|
|
||||||
* verbatim; they are not inferred from the model name.
|
|
||||||
*
|
|
||||||
* The gateway also serves a native `/v1/responses` endpoint (`responsesBaseUrl`).
|
|
||||||
* It is stateless and REQUIRES `store:false` — see executors/cheaperinference.ts,
|
|
||||||
* which injects it and resolves the URL from the per-model `targetFormat` tag.
|
|
||||||
*/
|
|
||||||
export const CHEAPERINFERENCE_MODELS: RegistryModel[] = [
|
|
||||||
{ id: "aion-labs.aion-2-0", name: "Aion 2.0", supportsReasoning: true, toolCalling: true },
|
|
||||||
{
|
|
||||||
id: "claude-fable-5",
|
|
||||||
name: "Claude Fable 5",
|
|
||||||
supportsVision: true,
|
|
||||||
supportsReasoning: true,
|
|
||||||
toolCalling: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "claude-haiku-4.5",
|
|
||||||
name: "Claude Haiku 4.5",
|
|
||||||
supportsVision: true,
|
|
||||||
supportsReasoning: true,
|
|
||||||
toolCalling: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "claude-opus-4-7-fast",
|
|
||||||
name: "Claude Opus 4.7 Fast",
|
|
||||||
supportsVision: true,
|
|
||||||
supportsReasoning: true,
|
|
||||||
toolCalling: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "claude-opus-4-8-fast",
|
|
||||||
name: "Claude Opus 4.8 Fast",
|
|
||||||
supportsVision: true,
|
|
||||||
supportsReasoning: true,
|
|
||||||
toolCalling: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "claude-opus-4.5",
|
|
||||||
name: "Claude Opus 4.5",
|
|
||||||
supportsVision: true,
|
|
||||||
supportsReasoning: true,
|
|
||||||
toolCalling: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "claude-opus-4.6",
|
|
||||||
name: "Claude Opus 4.6",
|
|
||||||
supportsVision: true,
|
|
||||||
supportsReasoning: true,
|
|
||||||
toolCalling: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "claude-opus-4.7",
|
|
||||||
name: "Claude Opus 4.7",
|
|
||||||
supportsVision: true,
|
|
||||||
supportsReasoning: true,
|
|
||||||
toolCalling: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "claude-opus-4.8",
|
|
||||||
name: "Claude Opus 4.8",
|
|
||||||
supportsVision: true,
|
|
||||||
supportsReasoning: true,
|
|
||||||
toolCalling: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "claude-opus-5",
|
|
||||||
name: "Claude Opus 5",
|
|
||||||
supportsVision: true,
|
|
||||||
supportsReasoning: true,
|
|
||||||
toolCalling: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "claude-opus-5-fast",
|
|
||||||
name: "Claude Opus 5 Fast",
|
|
||||||
supportsVision: true,
|
|
||||||
supportsReasoning: true,
|
|
||||||
toolCalling: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "claude-sonnet-4.5",
|
|
||||||
name: "Claude Sonnet 4.5",
|
|
||||||
supportsVision: true,
|
|
||||||
supportsReasoning: true,
|
|
||||||
toolCalling: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "claude-sonnet-4.6",
|
|
||||||
name: "Claude Sonnet 4.6",
|
|
||||||
supportsVision: true,
|
|
||||||
supportsReasoning: true,
|
|
||||||
toolCalling: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "claude-sonnet-5",
|
|
||||||
name: "Claude Sonnet 5",
|
|
||||||
supportsVision: true,
|
|
||||||
supportsReasoning: true,
|
|
||||||
toolCalling: true,
|
|
||||||
},
|
|
||||||
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true, toolCalling: true },
|
|
||||||
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true, toolCalling: true },
|
|
||||||
{
|
|
||||||
id: "gemini-2.5-flash",
|
|
||||||
name: "Gemini 2.5 Flash",
|
|
||||||
supportsVision: true,
|
|
||||||
supportsReasoning: true,
|
|
||||||
toolCalling: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "gemini-3-5-flash",
|
|
||||||
name: "Gemini 3.5 Flash",
|
|
||||||
supportsVision: true,
|
|
||||||
supportsReasoning: true,
|
|
||||||
toolCalling: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "gemini-3-flash-preview",
|
|
||||||
name: "Gemini 3 Flash Preview",
|
|
||||||
supportsVision: true,
|
|
||||||
supportsReasoning: true,
|
|
||||||
toolCalling: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "gemini-3.1-flash-lite",
|
|
||||||
name: "Gemini 3.1 Flash Lite",
|
|
||||||
supportsVision: true,
|
|
||||||
supportsReasoning: true,
|
|
||||||
toolCalling: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "gemini-3.1-pro",
|
|
||||||
name: "Gemini 3.1 Pro",
|
|
||||||
supportsVision: true,
|
|
||||||
supportsReasoning: true,
|
|
||||||
toolCalling: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "gemini-3.1-pro-preview",
|
|
||||||
name: "Gemini 3.1 Pro Preview",
|
|
||||||
supportsReasoning: true,
|
|
||||||
toolCalling: true,
|
|
||||||
},
|
|
||||||
{ id: "glm-4.5", name: "GLM-4.5", supportsReasoning: true, toolCalling: true },
|
|
||||||
{ id: "glm-4.5-air", name: "GLM-4.5 Air", supportsReasoning: true, toolCalling: true },
|
|
||||||
{ id: "glm-4.6", name: "GLM-4.6", supportsReasoning: true, toolCalling: true },
|
|
||||||
{ id: "glm-4.7", name: "GLM-4.7", supportsReasoning: true, toolCalling: true },
|
|
||||||
{ id: "glm-5", name: "GLM-5", supportsReasoning: true, toolCalling: true },
|
|
||||||
{ id: "glm-5.1", name: "GLM-5.1", supportsReasoning: true, toolCalling: true },
|
|
||||||
{ id: "glm-5.2", name: "GLM-5.2", supportsReasoning: true, toolCalling: true },
|
|
||||||
{
|
|
||||||
id: "google/gemini-3.5-flash-lite",
|
|
||||||
name: "Gemini 3.5 Flash Lite",
|
|
||||||
supportsVision: true,
|
|
||||||
supportsReasoning: true,
|
|
||||||
toolCalling: true,
|
|
||||||
},
|
|
||||||
// The GPT-5.x family is tagged for the gateway's native /v1/responses endpoint —
|
|
||||||
// that is the surface OpenAI-family clients (Codex-style) expect for tool loops.
|
|
||||||
{
|
|
||||||
id: "gpt-5.4",
|
|
||||||
name: "GPT-5.4",
|
|
||||||
supportsVision: true,
|
|
||||||
supportsReasoning: true,
|
|
||||||
toolCalling: true,
|
|
||||||
targetFormat: "openai-responses",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "gpt-5.4-mini",
|
|
||||||
name: "GPT-5.4 Mini",
|
|
||||||
supportsVision: true,
|
|
||||||
supportsReasoning: true,
|
|
||||||
toolCalling: true,
|
|
||||||
targetFormat: "openai-responses",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "gpt-5.5",
|
|
||||||
name: "GPT-5.5",
|
|
||||||
supportsVision: true,
|
|
||||||
supportsReasoning: true,
|
|
||||||
toolCalling: true,
|
|
||||||
targetFormat: "openai-responses",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "gpt-5.6-luna",
|
|
||||||
name: "GPT-5.6 Luna",
|
|
||||||
supportsVision: true,
|
|
||||||
supportsReasoning: true,
|
|
||||||
toolCalling: true,
|
|
||||||
targetFormat: "openai-responses",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "gpt-5.6-sol",
|
|
||||||
name: "GPT-5.6 Sol",
|
|
||||||
supportsVision: true,
|
|
||||||
supportsReasoning: true,
|
|
||||||
toolCalling: true,
|
|
||||||
targetFormat: "openai-responses",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "gpt-5.6-terra",
|
|
||||||
name: "GPT-5.6 Terra",
|
|
||||||
supportsVision: true,
|
|
||||||
supportsReasoning: true,
|
|
||||||
toolCalling: true,
|
|
||||||
targetFormat: "openai-responses",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "grok-4.5",
|
|
||||||
name: "Grok 4.5",
|
|
||||||
supportsVision: true,
|
|
||||||
supportsReasoning: true,
|
|
||||||
toolCalling: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "kimi-k3",
|
|
||||||
name: "Kimi K3",
|
|
||||||
supportsVision: true,
|
|
||||||
supportsReasoning: true,
|
|
||||||
toolCalling: true,
|
|
||||||
},
|
|
||||||
{ id: "minimax-m2.7", name: "MiniMax M2.7", supportsReasoning: true, toolCalling: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const cheaperinferenceProvider: RegistryEntry = {
|
|
||||||
id: "cheaperinference",
|
|
||||||
alias: "cinf",
|
|
||||||
format: "openai",
|
|
||||||
executor: "cheaperinference",
|
|
||||||
baseUrl: "https://api.cheaperinference.com/v1/chat/completions",
|
|
||||||
// The gateway serves a native, STATELESS /v1/responses endpoint alongside
|
|
||||||
// /v1/chat/completions. Consumed by CheaperInferenceExecutor.buildUrl for the
|
|
||||||
// models tagged targetFormat: "openai-responses" above.
|
|
||||||
responsesBaseUrl: "https://api.cheaperinference.com/v1/responses",
|
|
||||||
authType: "apikey",
|
|
||||||
authHeader: "bearer",
|
|
||||||
models: CHEAPERINFERENCE_MODELS,
|
|
||||||
};
|
|
||||||
@@ -34,7 +34,6 @@ import {
|
|||||||
resolveAccountKey,
|
resolveAccountKey,
|
||||||
isFreeVariantModel,
|
isFreeVariantModel,
|
||||||
} from "../services/openrouterFreeWindow.ts";
|
} from "../services/openrouterFreeWindow.ts";
|
||||||
import { gateOutboundRequest } from "../services/wafRateLimit.ts";
|
|
||||||
import type { PoolConfig } from "../services/sessionPool/types.ts";
|
import type { PoolConfig } from "../services/sessionPool/types.ts";
|
||||||
import type { Session } from "../services/sessionPool/session.ts";
|
import type { Session } from "../services/sessionPool/session.ts";
|
||||||
import { SessionPool } from "../services/sessionPool/sessionPool.ts";
|
import { SessionPool } from "../services/sessionPool/sessionPool.ts";
|
||||||
@@ -46,7 +45,6 @@ import {
|
|||||||
} from "../services/apiKeyRotator.ts";
|
} from "../services/apiKeyRotator.ts";
|
||||||
import type { KeyHealth } from "../services/apiKeyRotator.ts";
|
import type { KeyHealth } from "../services/apiKeyRotator.ts";
|
||||||
import { getOpenAICompatibleType, isClaudeCodeCompatible } from "../services/provider.ts";
|
import { getOpenAICompatibleType, isClaudeCodeCompatible } from "../services/provider.ts";
|
||||||
import { usesCcWireImage } from "../services/ccWireImageBuiltins.ts";
|
|
||||||
import {
|
import {
|
||||||
runWithOnPersist,
|
runWithOnPersist,
|
||||||
getRefreshLeadMs,
|
getRefreshLeadMs,
|
||||||
@@ -251,10 +249,7 @@ function collectThinkingConfigs(body: unknown): Array<Record<string, unknown>> {
|
|||||||
if (!body || typeof body !== "object") return [];
|
if (!body || typeof body !== "object") return [];
|
||||||
const root = body as Record<string, unknown>;
|
const root = body as Record<string, unknown>;
|
||||||
const configs: Array<Record<string, unknown>> = [];
|
const configs: Array<Record<string, unknown>> = [];
|
||||||
const envelopes: unknown[] = [
|
const envelopes: unknown[] = [root.generationConfig, (root.request as Record<string, unknown> | undefined)?.generationConfig];
|
||||||
root.generationConfig,
|
|
||||||
(root.request as Record<string, unknown> | undefined)?.generationConfig,
|
|
||||||
];
|
|
||||||
for (const env of envelopes) {
|
for (const env of envelopes) {
|
||||||
if (!env || typeof env !== "object") continue;
|
if (!env || typeof env !== "object") continue;
|
||||||
const tc = (env as Record<string, unknown>).thinkingConfig;
|
const tc = (env as Record<string, unknown>).thinkingConfig;
|
||||||
@@ -450,12 +445,6 @@ export class BaseExecutor {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected usesClaudeCodeProtocol(credentials: ProviderCredentials | null): boolean {
|
|
||||||
if (!isClaudeCodeCompatible(this.provider)) return false;
|
|
||||||
const format = this.resolveAlternate(credentials)?.format;
|
|
||||||
return format !== "openai" && format !== "openai-responses";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the effective API key via extra-keys round-robin rotation.
|
* Resolve the effective API key via extra-keys round-robin rotation.
|
||||||
* Mutates `credentials.providerSpecificData.selectedKeyId` on rotation.
|
* Mutates `credentials.providerSpecificData.selectedKeyId` on rotation.
|
||||||
@@ -600,15 +589,6 @@ export class BaseExecutor {
|
|||||||
|
|
||||||
// Intra-URL retry config: retry same URL before falling back to next node
|
// Intra-URL retry config: retry same URL before falling back to next node
|
||||||
static readonly RETRY_CONFIG = { maxAttempts: 2, delayMs: 2000 };
|
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
|
// Timeout for receiving the initial upstream response headers. Once the response
|
||||||
// starts streaming, STREAM_IDLE_TIMEOUT_MS / Undici bodyTimeout handle stalls.
|
// starts streaming, STREAM_IDLE_TIMEOUT_MS / Undici bodyTimeout handle stalls.
|
||||||
static FETCH_START_TIMEOUT_MS = FETCH_TIMEOUT_MS;
|
static FETCH_START_TIMEOUT_MS = FETCH_TIMEOUT_MS;
|
||||||
@@ -854,16 +834,15 @@ export class BaseExecutor {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const usesClaudeCodeProtocol = this.usesClaudeCodeProtocol(requestCredentials);
|
const ccRequestDefaults = isClaudeCodeCompatible(this.provider)
|
||||||
const fingerprintProvider =
|
|
||||||
usesCcWireImage(this.provider) && !usesClaudeCodeProtocol ? "codex" : this.provider;
|
|
||||||
const ccRequestDefaults = usesClaudeCodeProtocol
|
|
||||||
? getClaudeCodeCompatibleRequestDefaults(requestCredentials?.providerSpecificData)
|
? getClaudeCodeCompatibleRequestDefaults(requestCredentials?.providerSpecificData)
|
||||||
: {};
|
: {};
|
||||||
const shouldForwardExtendedContext =
|
const shouldForwardExtendedContext =
|
||||||
extendedContext && modelSupportsContext1mBeta(model) && !usesClaudeCodeProtocol;
|
extendedContext &&
|
||||||
|
modelSupportsContext1mBeta(model) &&
|
||||||
|
!isClaudeCodeCompatible(this.provider);
|
||||||
const shouldForwardCcCompatibleContext1m =
|
const shouldForwardCcCompatibleContext1m =
|
||||||
usesClaudeCodeProtocol &&
|
isClaudeCodeCompatible(this.provider) &&
|
||||||
ccRequestDefaults.context1m === true &&
|
ccRequestDefaults.context1m === true &&
|
||||||
!modelHasNativeContext1m(model);
|
!modelHasNativeContext1m(model);
|
||||||
if (shouldForwardExtendedContext || shouldForwardCcCompatibleContext1m) {
|
if (shouldForwardExtendedContext || shouldForwardCcCompatibleContext1m) {
|
||||||
@@ -943,8 +922,8 @@ export class BaseExecutor {
|
|||||||
!activeCredentials?.apiKey;
|
!activeCredentials?.apiKey;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
((this.provider === "claude" && (isClaudeCodeClient || hasClaudeOAuthToken)) ||
|
this.provider === "claude" &&
|
||||||
usesClaudeCodeProtocol) &&
|
(isClaudeCodeClient || hasClaudeOAuthToken) &&
|
||||||
typeof transformedBody === "object" &&
|
typeof transformedBody === "object" &&
|
||||||
transformedBody !== null
|
transformedBody !== null
|
||||||
) {
|
) {
|
||||||
@@ -1221,11 +1200,6 @@ export class BaseExecutor {
|
|||||||
if (ccKeysLower.has(key.toLowerCase())) delete headers[key];
|
if (ccKeysLower.has(key.toLowerCase())) delete headers[key];
|
||||||
}
|
}
|
||||||
Object.assign(headers, ccHeaders);
|
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"];
|
delete headers["X-Stainless-Helper-Method"];
|
||||||
|
|
||||||
// OS/arch follow the host running the signed binary. Runtime version
|
// OS/arch follow the host running the signed binary. Runtime version
|
||||||
@@ -1272,7 +1246,7 @@ export class BaseExecutor {
|
|||||||
// (tool_result must be in immediately next message).
|
// (tool_result must be in immediately next message).
|
||||||
// Only apply for Claude/Claude-compatible — OpenAI allows results
|
// Only apply for Claude/Claude-compatible — OpenAI allows results
|
||||||
// spread across multiple subsequent messages.
|
// spread across multiple subsequent messages.
|
||||||
const isClaude = this.provider === "claude" || usesClaudeCodeProtocol;
|
const isClaude = this.provider === "claude" || isClaudeCodeCompatible(this.provider);
|
||||||
// For Claude, fixToolAdjacency may strip tool_use blocks whose
|
// For Claude, fixToolAdjacency may strip tool_use blocks whose
|
||||||
// tool_result isn't in the next message; re-run fixToolPairs to
|
// tool_result isn't in the next message; re-run fixToolPairs to
|
||||||
// drop any tool_result orphaned by that strip (discussion #2410).
|
// drop any tool_result orphaned by that strip (discussion #2410).
|
||||||
@@ -1291,7 +1265,7 @@ export class BaseExecutor {
|
|||||||
// at this final dispatch point — the single chokepoint every Claude
|
// at this final dispatch point — the single chokepoint every Claude
|
||||||
// routing mode (grouped/raw/combo) and the native passthrough share,
|
// routing mode (grouped/raw/combo) and the native passthrough share,
|
||||||
// before fingerprinting and CCH signing serialize the body.
|
// before fingerprinting and CCH signing serialize the body.
|
||||||
if (this.provider === "claude" || usesClaudeCodeProtocol) {
|
if (this.provider === "claude" || isClaudeCodeCompatible(this.provider)) {
|
||||||
enforceThinkingTemperature(transformedBody as Record<string, unknown>);
|
enforceThinkingTemperature(transformedBody as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1308,7 +1282,7 @@ export class BaseExecutor {
|
|||||||
// `contextEditingDisabled` (set by the 400-fallback) suppresses re-injection
|
// `contextEditingDisabled` (set by the 400-fallback) suppresses re-injection
|
||||||
// when a fresh `transformedBody` is built for a retry/fallback URL.
|
// when a fresh `transformedBody` is built for a retry/fallback URL.
|
||||||
if (
|
if (
|
||||||
(this.provider === "claude" || usesClaudeCodeProtocol) &&
|
(this.provider === "claude" || isClaudeCodeCompatible(this.provider)) &&
|
||||||
contextEditing?.enabled &&
|
contextEditing?.enabled &&
|
||||||
!contextEditingDisabled
|
!contextEditingDisabled
|
||||||
) {
|
) {
|
||||||
@@ -1324,17 +1298,17 @@ export class BaseExecutor {
|
|||||||
let bodyString = JSON.stringify(transformedBody);
|
let bodyString = JSON.stringify(transformedBody);
|
||||||
|
|
||||||
const shouldFingerprint =
|
const shouldFingerprint =
|
||||||
isCliCompatEnabled(fingerprintProvider) ||
|
isCliCompatEnabled(this.provider) ||
|
||||||
(this.provider === "claude" && (isClaudeCodeClient || hasClaudeOAuthToken));
|
(this.provider === "claude" && (isClaudeCodeClient || hasClaudeOAuthToken));
|
||||||
if (shouldFingerprint) {
|
if (shouldFingerprint) {
|
||||||
const fingerprinted = applyFingerprint(fingerprintProvider, headers, transformedBody);
|
const fingerprinted = applyFingerprint(this.provider, headers, transformedBody);
|
||||||
finalHeaders = fingerprinted.headers;
|
finalHeaders = fingerprinted.headers;
|
||||||
bodyString = fingerprinted.bodyString;
|
bodyString = fingerprinted.bodyString;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CCH signing — replaces the cch=00000 placeholder in the billing
|
// CCH signing — replaces the cch=00000 placeholder in the billing
|
||||||
// header with an xxHash64 integrity token over the serialized body.
|
// header with an xxHash64 integrity token over the serialized body.
|
||||||
if (usesClaudeCodeProtocol || this.provider === "claude") {
|
if (isClaudeCodeCompatible(this.provider) || this.provider === "claude") {
|
||||||
bodyString = await signRequestBody(bodyString);
|
bodyString = await signRequestBody(bodyString);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1399,13 +1373,6 @@ export class BaseExecutor {
|
|||||||
recordFreeWindowAttempt(openrouterFreeWindowAccountKey);
|
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);
|
let response = await fetchWithStartTimeout(url, fetchOptions);
|
||||||
|
|
||||||
if (openrouterFreeWindowAccountKey) {
|
if (openrouterFreeWindowAccountKey) {
|
||||||
@@ -1429,7 +1396,7 @@ export class BaseExecutor {
|
|||||||
contextEditingDisabled = true;
|
contextEditingDisabled = true;
|
||||||
delete (transformedBody as Record<string, unknown>).context_management;
|
delete (transformedBody as Record<string, unknown>).context_management;
|
||||||
let retryBody = JSON.stringify(transformedBody);
|
let retryBody = JSON.stringify(transformedBody);
|
||||||
if (usesClaudeCodeProtocol || this.provider === "claude") {
|
if (isClaudeCodeCompatible(this.provider) || this.provider === "claude") {
|
||||||
retryBody = await signRequestBody(retryBody);
|
retryBody = await signRequestBody(retryBody);
|
||||||
}
|
}
|
||||||
log?.debug?.(
|
log?.debug?.(
|
||||||
@@ -1468,7 +1435,7 @@ export class BaseExecutor {
|
|||||||
thinkingBudgetClampedMax = upstreamMax;
|
thinkingBudgetClampedMax = upstreamMax;
|
||||||
if (clampNestedThinkingBudget(transformedBody, upstreamMax)) {
|
if (clampNestedThinkingBudget(transformedBody, upstreamMax)) {
|
||||||
let retryBody = JSON.stringify(transformedBody);
|
let retryBody = JSON.stringify(transformedBody);
|
||||||
if (usesClaudeCodeProtocol || this.provider === "claude") {
|
if (isClaudeCodeCompatible(this.provider) || this.provider === "claude") {
|
||||||
retryBody = await signRequestBody(retryBody);
|
retryBody = await signRequestBody(retryBody);
|
||||||
}
|
}
|
||||||
log?.info?.(
|
log?.info?.(
|
||||||
@@ -1499,7 +1466,7 @@ export class BaseExecutor {
|
|||||||
strippedFields.add(offending);
|
strippedFields.add(offending);
|
||||||
delete (transformedBody as Record<string, unknown>)[offending];
|
delete (transformedBody as Record<string, unknown>)[offending];
|
||||||
let retryBody = JSON.stringify(transformedBody);
|
let retryBody = JSON.stringify(transformedBody);
|
||||||
if (usesClaudeCodeProtocol || this.provider === "claude") {
|
if (isClaudeCodeCompatible(this.provider) || this.provider === "claude") {
|
||||||
retryBody = await signRequestBody(retryBody);
|
retryBody = await signRequestBody(retryBody);
|
||||||
}
|
}
|
||||||
log?.debug?.(
|
log?.debug?.(
|
||||||
@@ -1524,7 +1491,7 @@ export class BaseExecutor {
|
|||||||
addParamToBlocklist(this.provider, autoLearned, model);
|
addParamToBlocklist(this.provider, autoLearned, model);
|
||||||
delete (transformedBody as Record<string, unknown>)[autoLearned];
|
delete (transformedBody as Record<string, unknown>)[autoLearned];
|
||||||
let retryBody = JSON.stringify(transformedBody);
|
let retryBody = JSON.stringify(transformedBody);
|
||||||
if (usesClaudeCodeProtocol || this.provider === "claude") {
|
if (isClaudeCodeCompatible(this.provider) || this.provider === "claude") {
|
||||||
retryBody = await signRequestBody(retryBody);
|
retryBody = await signRequestBody(retryBody);
|
||||||
}
|
}
|
||||||
log?.info?.(
|
log?.info?.(
|
||||||
@@ -1543,34 +1510,6 @@ export class BaseExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Intra-URL retry: agentrouter.org WAF returns 400 content-blocked
|
|
||||||
// intermittently (burst-sensitive, recovers after cooldown). Retry the
|
|
||||||
// same URL with exponential backoff before falling through to the
|
|
||||||
// 429/401/fallback chain. See docs/security/AGENTROUTER_WAF.md.
|
|
||||||
if (
|
|
||||||
!skipUpstreamRetry &&
|
|
||||||
response.status === HTTP_STATUS.BAD_REQUEST &&
|
|
||||||
(retryAttemptsByUrl[urlIndex] ?? 0) < BaseExecutor.WAF_RETRY_CONFIG.maxAttempts
|
|
||||||
) {
|
|
||||||
const wafErrText = await response
|
|
||||||
.clone()
|
|
||||||
.text()
|
|
||||||
.catch(() => "");
|
|
||||||
if (/content[_-]blocked/i.test(wafErrText)) {
|
|
||||||
retryAttemptsByUrl[urlIndex] = (retryAttemptsByUrl[urlIndex] ?? 0) + 1;
|
|
||||||
const wafAttempt = retryAttemptsByUrl[urlIndex];
|
|
||||||
const wafBackoff = BaseExecutor.WAF_RETRY_CONFIG.delayMs *
|
|
||||||
Math.pow(BaseExecutor.WAF_RETRY_CONFIG.backoffMultiplier, wafAttempt - 1);
|
|
||||||
log?.debug?.(
|
|
||||||
"WAF_RETRY",
|
|
||||||
`400 content-blocked intra-retry ${wafAttempt}/${BaseExecutor.WAF_RETRY_CONFIG.maxAttempts} on ${url} — waiting ${wafBackoff}ms`
|
|
||||||
);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, wafBackoff));
|
|
||||||
urlIndex--; // re-run this urlIndex on the next loop iteration
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Intra-URL retry: if 429 and we haven't exhausted per-URL retries, wait and retry the same URL
|
// Intra-URL retry: if 429 and we haven't exhausted per-URL retries, wait and retry the same URL
|
||||||
if (
|
if (
|
||||||
!skipUpstreamRetry &&
|
!skipUpstreamRetry &&
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
import { BaseExecutor, type ProviderCredentials } from "./base.ts";
|
|
||||||
import { PROVIDERS } from "../config/constants.ts";
|
|
||||||
import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.ts";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CheaperInferenceExecutor — api.cheaperinference.com.
|
|
||||||
*
|
|
||||||
* The gateway is OpenAI-compatible on both surfaces, so everything else comes from
|
|
||||||
* BaseExecutor. Two provider-specific facts need handling (both measured against the
|
|
||||||
* live API on 2026-07-31, not inferred from docs):
|
|
||||||
*
|
|
||||||
* 1. `/v1/responses` is STATELESS and REQUIRES `store:false`. Omitting it returns
|
|
||||||
* HTTP 400 ("This Responses-compatible endpoint is stateless. Send store=false…").
|
|
||||||
* chatCore.ts deletes `store` for every provider except "openai" — a strip shared
|
|
||||||
* by ~290 providers that must not be special-cased — so we re-add it here, after
|
|
||||||
* that strip has run. A client-supplied `store:true` is overwritten rather than
|
|
||||||
* forwarded: the endpoint cannot honour it, and forwarding would 400.
|
|
||||||
*
|
|
||||||
* 2. Chat and Responses live at DIFFERENT URLs (unlike providers that switch on a
|
|
||||||
* path suffix). The per-model `targetFormat` registry tag is the single source of
|
|
||||||
* truth for which surface a model uses — the same tag chatCore reads to translate
|
|
||||||
* the body — so resolving the URL from it keeps URL and payload in lockstep.
|
|
||||||
* Same pattern as executors/xai.ts (9router#2439).
|
|
||||||
*/
|
|
||||||
export class CheaperInferenceExecutor extends BaseExecutor {
|
|
||||||
constructor(provider = "cheaperinference") {
|
|
||||||
super(provider, PROVIDERS[provider]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True when this model is served by the native /v1/responses endpoint.
|
|
||||||
*
|
|
||||||
* PROVIDER_MODELS is keyed by provider ALIAS ("cinf"), while PROVIDERS is keyed by
|
|
||||||
* provider ID ("cheaperinference") — so `this.provider` cannot be passed straight
|
|
||||||
* through the way executors/xai.ts does (there the alias equals the id, which hides
|
|
||||||
* the distinction). Resolve the alias first or every lookup silently returns null
|
|
||||||
* and every Responses request 400s upstream.
|
|
||||||
*/
|
|
||||||
private usesResponsesEndpoint(model: string): boolean {
|
|
||||||
const alias = PROVIDER_ID_TO_ALIAS[this.provider] || this.provider;
|
|
||||||
return getModelTargetFormat(alias, model) === "openai-responses";
|
|
||||||
}
|
|
||||||
|
|
||||||
buildUrl(model: string, _stream: boolean, _urlIndex = 0): string {
|
|
||||||
if (this.usesResponsesEndpoint(model)) {
|
|
||||||
return this.config.responsesBaseUrl || this.config.baseUrl;
|
|
||||||
}
|
|
||||||
return this.config.baseUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
transformRequest(
|
|
||||||
model: string,
|
|
||||||
body: unknown,
|
|
||||||
stream: boolean,
|
|
||||||
credentials: ProviderCredentials
|
|
||||||
): unknown {
|
|
||||||
const cleanedBody = super.transformRequest(model, body, stream, credentials);
|
|
||||||
if (!cleanedBody || typeof cleanedBody !== "object" || Array.isArray(cleanedBody)) {
|
|
||||||
return cleanedBody;
|
|
||||||
}
|
|
||||||
if (!this.usesResponsesEndpoint(model)) {
|
|
||||||
// Chat Completions rejects unknown params — never add `store` on that surface.
|
|
||||||
return cleanedBody;
|
|
||||||
}
|
|
||||||
return { ...(cleanedBody as Record<string, unknown>), store: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default CheaperInferenceExecutor;
|
|
||||||
@@ -55,7 +55,6 @@ import { forwardOpencodeClientHeaders } from "../utils/opencodeHeaders.ts";
|
|||||||
import { resolveZaiUrl } from "./default/zaiFormatOverride.ts";
|
import { resolveZaiUrl } from "./default/zaiFormatOverride.ts";
|
||||||
import { acquireNvidiaConcurrencySlot } from "./default/nvidiaConcurrencyGate.ts";
|
import { acquireNvidiaConcurrencySlot } from "./default/nvidiaConcurrencyGate.ts";
|
||||||
import { resolveAlibabaProviderBaseUrl } from "@/shared/constants/alibabaProviderRegions";
|
import { resolveAlibabaProviderBaseUrl } from "@/shared/constants/alibabaProviderRegions";
|
||||||
import { usesCcWireImage } from "../services/ccWireImageBuiltins.ts";
|
|
||||||
|
|
||||||
import type { PoolConfig } from "../services/sessionPool/types.ts";
|
import type { PoolConfig } from "../services/sessionPool/types.ts";
|
||||||
|
|
||||||
@@ -292,10 +291,6 @@ export class DefaultExecutor extends BaseExecutor {
|
|||||||
case "minimax":
|
case "minimax":
|
||||||
case "minimax-cn":
|
case "minimax-cn":
|
||||||
return `${this.config.baseUrl}?beta=true`;
|
return `${this.config.baseUrl}?beta=true`;
|
||||||
case "agentrouter":
|
|
||||||
return this.usesClaudeCodeProtocol(credentials)
|
|
||||||
? `${this.config.baseUrl}?beta=true`
|
|
||||||
: this.config.baseUrl;
|
|
||||||
case "gemini":
|
case "gemini":
|
||||||
return `${this.config.baseUrl}/${model}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`;
|
return `${this.config.baseUrl}/${model}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`;
|
||||||
default: {
|
default: {
|
||||||
@@ -418,7 +413,7 @@ export class DefaultExecutor extends BaseExecutor {
|
|||||||
applyClineAuthHeaders(headers, credentials, effectiveKey, clientHeaders, false);
|
applyClineAuthHeaders(headers, credentials, effectiveKey, clientHeaders, false);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
if (this.usesClaudeCodeProtocol(credentials)) {
|
if (isClaudeCodeCompatible(this.provider)) {
|
||||||
const ccRequestDefaults = getClaudeCodeCompatibleRequestDefaults(
|
const ccRequestDefaults = getClaudeCodeCompatibleRequestDefaults(
|
||||||
credentials?.providerSpecificData
|
credentials?.providerSpecificData
|
||||||
);
|
);
|
||||||
@@ -428,10 +423,6 @@ export class DefaultExecutor extends BaseExecutor {
|
|||||||
credentials?.providerSpecificData?.ccSessionId,
|
credentials?.providerSpecificData?.ccSessionId,
|
||||||
{ redactThinking: ccRequestDefaults.redactThinking === true }
|
{ 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
|
// CC nodes are also anthropic-compatible-*, so honor operator custom
|
||||||
// headers here (the early return skips the shared block below).
|
// headers here (the early return skips the shared block below).
|
||||||
applyCustomHeaders(ccHeaders, credentials.providerSpecificData?.customHeaders);
|
applyCustomHeaders(ccHeaders, credentials.providerSpecificData?.customHeaders);
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ import { PoeWebExecutor } from "./poe-web.ts";
|
|||||||
import { VeniceWebExecutor } from "./venice-web.ts";
|
import { VeniceWebExecutor } from "./venice-web.ts";
|
||||||
import { NotionWebExecutor } from "./notion-web.ts";
|
import { NotionWebExecutor } from "./notion-web.ts";
|
||||||
import { V0VercelWebExecutor } from "./v0-vercel-web.ts";
|
import { V0VercelWebExecutor } from "./v0-vercel-web.ts";
|
||||||
import { CheaperInferenceExecutor } from "./cheaperinference.ts";
|
|
||||||
import { KimiWebExecutor } from "./kimi-web.ts";
|
import { KimiWebExecutor } from "./kimi-web.ts";
|
||||||
import { DoubaoWebExecutor } from "./doubao-web.ts";
|
import { DoubaoWebExecutor } from "./doubao-web.ts";
|
||||||
import { QwenWebExecutor } from "./qwen-web.ts";
|
import { QwenWebExecutor } from "./qwen-web.ts";
|
||||||
@@ -166,8 +165,6 @@ const executors = {
|
|||||||
"kimi-coding": new KimiExecutor(), // Alias
|
"kimi-coding": new KimiExecutor(), // Alias
|
||||||
moonshot: new MoonshotExecutor(),
|
moonshot: new MoonshotExecutor(),
|
||||||
kimi: new MoonshotExecutor("kimi"), // Hidden legacy Moonshot provider id
|
kimi: new MoonshotExecutor("kimi"), // Hidden legacy Moonshot provider id
|
||||||
cheaperinference: new CheaperInferenceExecutor(),
|
|
||||||
cinf: new CheaperInferenceExecutor("cheaperinference"), // Alias
|
|
||||||
"doubao-web": new DoubaoWebExecutor(),
|
"doubao-web": new DoubaoWebExecutor(),
|
||||||
db: new DoubaoWebExecutor(), // Alias
|
db: new DoubaoWebExecutor(), // Alias
|
||||||
"qwen-web": new QwenWebExecutor(),
|
"qwen-web": new QwenWebExecutor(),
|
||||||
@@ -285,5 +282,4 @@ export { ZenmuxFreeExecutor } from "./zenmux-free.ts";
|
|||||||
export { HyperAgentExecutor } from "./hyperagent.ts";
|
export { HyperAgentExecutor } from "./hyperagent.ts";
|
||||||
export { XaiExecutor } from "./xai.ts";
|
export { XaiExecutor } from "./xai.ts";
|
||||||
export { MoonshotExecutor } from "./moonshot.ts";
|
export { MoonshotExecutor } from "./moonshot.ts";
|
||||||
export { CheaperInferenceExecutor } from "./cheaperinference.ts";
|
|
||||||
export { PromptQlExecutor } from "./promptql.ts";
|
export { PromptQlExecutor } from "./promptql.ts";
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ import { checkHeapPressureGuard } from "../utils/heapPressure.ts";
|
|||||||
import { normalizeHeaders } from "../utils/headers.ts";
|
import { normalizeHeaders } from "../utils/headers.ts";
|
||||||
import { resolveChatCoreRequestFormat } from "./chatCore/requestFormat.ts";
|
import { resolveChatCoreRequestFormat } from "./chatCore/requestFormat.ts";
|
||||||
import { resolveChatCoreTargetFormat } from "./chatCore/targetFormat.ts";
|
import { resolveChatCoreTargetFormat } from "./chatCore/targetFormat.ts";
|
||||||
import { stripStore, usesClaudeBridge } from "./chatCore/agentRouterProtocol.ts";
|
|
||||||
import { defaultClaudeToolType } from "./chatCore/claudeToolDefaults.ts";
|
import { defaultClaudeToolType } from "./chatCore/claudeToolDefaults.ts";
|
||||||
import { injectSystemPrompt, injectCustomSystemPrompt } from "../services/systemPrompt.ts";
|
import { injectSystemPrompt, injectCustomSystemPrompt } from "../services/systemPrompt.ts";
|
||||||
import { translateRequest, needsTranslation } from "../translator/index.ts";
|
import { translateRequest, needsTranslation } from "../translator/index.ts";
|
||||||
@@ -342,6 +341,7 @@ import { OMNIROUTE_RESPONSE_HEADERS } from "@/shared/constants/headers";
|
|||||||
import { getClaudeCodeCompatibleRequestDefaults } from "@/lib/providers/requestDefaults";
|
import { getClaudeCodeCompatibleRequestDefaults } from "@/lib/providers/requestDefaults";
|
||||||
import {
|
import {
|
||||||
buildClaudeCodeCompatibleRequest,
|
buildClaudeCodeCompatibleRequest,
|
||||||
|
isClaudeCodeCompatibleProvider,
|
||||||
resolveClaudeCodeCompatibleSessionId,
|
resolveClaudeCodeCompatibleSessionId,
|
||||||
} from "../services/claudeCodeCompatible.ts";
|
} from "../services/claudeCodeCompatible.ts";
|
||||||
import { setGeminiThoughtSignatureMode } from "../services/geminiThoughtSignatureStore.ts";
|
import { setGeminiThoughtSignatureMode } from "../services/geminiThoughtSignatureStore.ts";
|
||||||
@@ -751,7 +751,6 @@ export async function handleChatCore({
|
|||||||
provider,
|
provider,
|
||||||
resolvedModel,
|
resolvedModel,
|
||||||
apiFormat,
|
apiFormat,
|
||||||
sourceFormat,
|
|
||||||
customModelTargetFormat,
|
customModelTargetFormat,
|
||||||
providerSpecificData: credentials?.providerSpecificData,
|
providerSpecificData: credentials?.providerSpecificData,
|
||||||
});
|
});
|
||||||
@@ -1921,7 +1920,7 @@ export async function handleChatCore({
|
|||||||
|
|
||||||
let translatedBody = body;
|
let translatedBody = body;
|
||||||
const isClaudePassthrough = sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE;
|
const isClaudePassthrough = sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE;
|
||||||
const isClaudeCodeCompatible = usesClaudeBridge(provider, targetFormat, credentials);
|
const isClaudeCodeCompatible = isClaudeCodeCompatibleProvider(provider);
|
||||||
const isClaudeCodeSemanticPassthrough = isClaudeCodeSemanticPassthroughRequest({
|
const isClaudeCodeSemanticPassthrough = isClaudeCodeSemanticPassthroughRequest({
|
||||||
provider,
|
provider,
|
||||||
sourceFormat,
|
sourceFormat,
|
||||||
@@ -2497,7 +2496,10 @@ export async function handleChatCore({
|
|||||||
log?.debug?.("PARAMS", `Renamed max_completion_tokens to max_tokens for ${model}`);
|
log?.debug?.("PARAMS", `Renamed max_completion_tokens to max_tokens for ${model}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
stripStore(translatedBody, provider, targetFormat);
|
// OpenAI's `store` parameter is not supported by most compatible providers and breaks them
|
||||||
|
if (provider !== "openai" && "store" in translatedBody) {
|
||||||
|
delete translatedBody.store;
|
||||||
|
}
|
||||||
|
|
||||||
// Chat clients may send stream_options.include_usage, but OpenAI Responses
|
// Chat clients may send stream_options.include_usage, but OpenAI Responses
|
||||||
// upstreams (including Azure AI Foundry /responses) reject stream_options.
|
// upstreams (including Azure AI Foundry /responses) reject stream_options.
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
/**
|
|
||||||
* Per-request AgentRouter protocol decisions kept outside the chatCore orchestration god-file.
|
|
||||||
* AgentRouter exposes Claude Messages, OpenAI Chat, and OpenAI Responses as distinct upstream
|
|
||||||
* protocols, so its dynamic target format must override the connection's default Claude wire image.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { isClaudeCodeCompatibleProvider } from "../../services/claudeCodeCompatible.ts";
|
|
||||||
import { FORMATS } from "../../translator/formats.ts";
|
|
||||||
|
|
||||||
export function usesClaudeBridge(
|
|
||||||
provider: string,
|
|
||||||
targetFormat: string,
|
|
||||||
credentials: unknown
|
|
||||||
): boolean {
|
|
||||||
const configuredTargetFormat = (
|
|
||||||
credentials as { providerSpecificData?: { targetFormat?: unknown } | null } | null | undefined
|
|
||||||
)?.providerSpecificData?.targetFormat;
|
|
||||||
const effectiveFormat = provider === "agentrouter" ? targetFormat : configuredTargetFormat;
|
|
||||||
return (
|
|
||||||
isClaudeCodeCompatibleProvider(provider) &&
|
|
||||||
effectiveFormat !== FORMATS.OPENAI &&
|
|
||||||
effectiveFormat !== FORMATS.OPENAI_RESPONSES
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stripStore(
|
|
||||||
body: Record<string, unknown>,
|
|
||||||
provider: string,
|
|
||||||
targetFormat: string
|
|
||||||
): void {
|
|
||||||
const supportsStore =
|
|
||||||
provider === "openai" ||
|
|
||||||
(provider === "agentrouter" && targetFormat === FORMATS.OPENAI_RESPONSES);
|
|
||||||
if (!supportsStore && "store" in body) delete body.store;
|
|
||||||
}
|
|
||||||
@@ -5,8 +5,8 @@
|
|||||||
* Pure builder extracted from handleChatCore: derives the per-execution credentials object from the
|
* Pure builder extracted from handleChatCore: derives the per-execution credentials object from the
|
||||||
* resolved request context. Applies the native-Codex passthrough endpoint override, forces
|
* 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
|
* apiType=responses (and the responses-upstream marker) for Azure AI Foundry / OCI when the model
|
||||||
* routes to the OpenAI Responses format, synchronizes AgentRouter's per-request alternate protocol,
|
* routes to the OpenAI Responses format, and threads the Claude Code session id when present.
|
||||||
* and threads the Claude Code session id when present. Side-effect-free.
|
* Side-effect-free; behaviour is byte-identical to the previous inline closure.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getKimiCodeStaticThinkingPolicy } from "../../config/providers/registry/kimi/coding/runtime.ts";
|
import { getKimiCodeStaticThinkingPolicy } from "../../config/providers/registry/kimi/coding/runtime.ts";
|
||||||
@@ -128,16 +128,6 @@ export function resolveExecutionCredentials(opts: {
|
|||||||
providerSpecificData.targetFormat = targetFormat;
|
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);
|
applyKimiExecutionMetadata(providerSpecificData, provider, targetFormat, modelInfo);
|
||||||
|
|
||||||
const withApiType = {
|
const withApiType = {
|
||||||
|
|||||||
@@ -4,11 +4,10 @@
|
|||||||
*
|
*
|
||||||
* Pure resolution of the provider alias + the upstream target format used to translate the request:
|
* 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
|
* apiFormat==="responses" forces OpenAI Responses; otherwise the model's registry target format, then
|
||||||
* the per-model custom override (#2905), then AgentRouter's matching inbound protocol when the
|
* the per-model custom override (#2905), then the provider default. Returns both `alias` (reused by
|
||||||
* 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`.
|
* the handler when stripping the `alias/` prefix off the upstream model id) and `targetFormat`.
|
||||||
* Side-effect-free; sits alongside the other request-setup resolvers
|
* Side-effect-free; byte-identical to the previous inline block. Sits alongside the other
|
||||||
* (resolveChatCoreRequestSetup / resolveChatCoreRequestFormat).
|
* request-setup resolvers (resolveChatCoreRequestSetup / resolveChatCoreRequestFormat).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { PROVIDER_ID_TO_ALIAS, getModelTargetFormat } from "../../config/providerModels.ts";
|
import { PROVIDER_ID_TO_ALIAS, getModelTargetFormat } from "../../config/providerModels.ts";
|
||||||
@@ -19,38 +18,16 @@ export function resolveChatCoreTargetFormat(opts: {
|
|||||||
provider: string;
|
provider: string;
|
||||||
resolvedModel: string;
|
resolvedModel: string;
|
||||||
apiFormat: string | undefined;
|
apiFormat: string | undefined;
|
||||||
sourceFormat?: string;
|
|
||||||
customModelTargetFormat: string | undefined;
|
customModelTargetFormat: string | undefined;
|
||||||
providerSpecificData: unknown;
|
providerSpecificData: unknown;
|
||||||
}) {
|
}) {
|
||||||
const {
|
const { provider, resolvedModel, apiFormat, customModelTargetFormat, providerSpecificData } = opts;
|
||||||
provider,
|
|
||||||
resolvedModel,
|
|
||||||
apiFormat,
|
|
||||||
sourceFormat,
|
|
||||||
customModelTargetFormat,
|
|
||||||
providerSpecificData,
|
|
||||||
} = opts;
|
|
||||||
const alias = PROVIDER_ID_TO_ALIAS[provider] || provider;
|
const alias = PROVIDER_ID_TO_ALIAS[provider] || provider;
|
||||||
const modelTargetFormat = getModelTargetFormat(alias, resolvedModel);
|
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 =
|
const targetFormat =
|
||||||
apiFormat === "responses"
|
apiFormat === "responses"
|
||||||
? FORMATS.OPENAI_RESPONSES
|
? FORMATS.OPENAI_RESPONSES
|
||||||
: modelTargetFormat ||
|
: modelTargetFormat || customModelTargetFormat || getTargetFormat(provider, providerSpecificData);
|
||||||
customModelTargetFormat ||
|
|
||||||
inferredAgentRouterTargetFormat ||
|
|
||||||
getTargetFormat(provider, providerSpecificData);
|
|
||||||
return { alias, targetFormat };
|
return { alias, targetFormat };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,68 +0,0 @@
|
|||||||
// open-sse/services/combo/strategyDispatch.ts
|
|
||||||
// Runtime source of truth for the known-symbols combo gate (G1).
|
|
||||||
//
|
|
||||||
// HISTÓRICO: `scripts/check/check-known-symbols.ts` (seção 2) costumava descobrir quais
|
|
||||||
// estratégias de roteamento têm branch de despacho lendo a fonte dos arquivos do combo e
|
|
||||||
// extraindo literais `strategy === "..."` por regex. Isso quebra quando o despacho vira um
|
|
||||||
// registry (R0.3): não há mais `strategy === "X"` para casar. Este módulo substitui essa
|
|
||||||
// enumeração por regex-over-source por uma enumeração EXPLÍCITA em runtime, colada ao lado
|
|
||||||
// do código de despacho real.
|
|
||||||
//
|
|
||||||
// Importamos as funções reais de ordenação/despacho (não apenas strings) para amarrar a
|
|
||||||
// enumeração ao código vivo: se a maquinaria de despacho for reestruturada ou um módulo
|
|
||||||
// quebrar, a importação falha no load do gate em vez de casar silenciosamente uma regex
|
|
||||||
// obsoleta. As chaves em HANDLED_COMBO_STRATEGIES DEVEM casar exatamente o conjunto
|
|
||||||
// canônico (ROUTING_STRATEGY_VALUES ∪ INTERNAL_ROUTING_STRATEGY_VALUES).
|
|
||||||
//
|
|
||||||
// Ao adicionar uma estratégia canônica, fie-a no despacho (aqui ou em combo.ts) e inclua-a
|
|
||||||
// nesta lista; ao remover um branch, retire a entrada — o gate acusa qualquer divergência
|
|
||||||
// nas duas direções (canonicalSemDespacho / despachoNaoCanonico).
|
|
||||||
|
|
||||||
import { applyStrategyOrdering } from "./applyStrategyOrdering.ts";
|
|
||||||
import { resolveAutoStrategyOrder } from "./resolveAutoStrategy.ts";
|
|
||||||
import { tryFusionDispatch, tryPipelineDispatch } from "./dispatchPrelude.ts";
|
|
||||||
import { resolveComboTargetPipeline } from "./targetResolution.ts";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* As funções reais que implementam o despacho/ordenação de estratégias. Referenciadas
|
|
||||||
* aqui para (a) provar ao gate que a maquinaria resolve e (b) servir de âncora viva para
|
|
||||||
* a enumeração abaixo — o registry que o R0.3 vai passar a consumir para o branching
|
|
||||||
* `strategy === ...` nasce destas mesmas funções.
|
|
||||||
*/
|
|
||||||
export const COMBO_STRATEGY_DISPATCH_LEAVES = {
|
|
||||||
applyStrategyOrdering,
|
|
||||||
resolveAutoStrategyOrder,
|
|
||||||
tryFusionDispatch,
|
|
||||||
tryPipelineDispatch,
|
|
||||||
resolveComboTargetPipeline,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Conjunto exato de estratégias de roteamento que possuem implementação de despacho real.
|
|
||||||
*
|
|
||||||
* Cobertura esperada (em `main` do gate): este set ∪ IMPLICIT_DEFAULT_STRATEGIES deve
|
|
||||||
* igualar o canônico. Atualmente todas as 20 estratégias canônicas têm branch — então
|
|
||||||
* HANDLED_COMBO_STRATEGIES já contém as 20 e IMPLICIT_DEFAULT_STRATEGIES está vazio.
|
|
||||||
*/
|
|
||||||
export const HANDLED_COMBO_STRATEGIES: readonly string[] = [
|
|
||||||
"priority",
|
|
||||||
"weighted",
|
|
||||||
"round-robin",
|
|
||||||
"context-relay",
|
|
||||||
"fill-first",
|
|
||||||
"p2c",
|
|
||||||
"random",
|
|
||||||
"least-used",
|
|
||||||
"cost-optimized",
|
|
||||||
"reset-aware",
|
|
||||||
"reset-window",
|
|
||||||
"headroom",
|
|
||||||
"strict-random",
|
|
||||||
"auto",
|
|
||||||
"lkgp",
|
|
||||||
"context-optimized",
|
|
||||||
"cache-optimized",
|
|
||||||
"fusion",
|
|
||||||
"pipeline",
|
|
||||||
"quota-share",
|
|
||||||
] as const;
|
|
||||||
@@ -1,35 +1,3 @@
|
|||||||
/**
|
|
||||||
* grokClearance.ts — gated browser-backed cf_clearance acquisition for
|
|
||||||
* grok-web (#8019).
|
|
||||||
*
|
|
||||||
* grok.com sits behind Cloudflare Enterprise, which pins `cf_clearance` to
|
|
||||||
* the client's IP+TLS+UA fingerprint. Pure cookie-replay from
|
|
||||||
* `grokTlsClient.ts` (TLS-impersonating fetch) cannot forge a fresh
|
|
||||||
* clearance from a datacenter egress that Cloudflare has already flagged —
|
|
||||||
* only a real browser solving the challenge natively can mint one bound to
|
|
||||||
* that egress's own fingerprint.
|
|
||||||
*
|
|
||||||
* This module reuses the EXISTING provider-agnostic browser pool
|
|
||||||
* (`browserPool.ts`, already live for claude-web + duckduckgo-web) rather
|
|
||||||
* than adding a new Turnstile solver — `claudeTurnstileSolver.ts` is
|
|
||||||
* claude.ai-specific and does not apply here.
|
|
||||||
*
|
|
||||||
* Opt-in only: gated behind `OMNIROUTE_BROWSER_POOL` / `WEB_COOKIE_USE_BROWSER`
|
|
||||||
* (the same env gate already used by claude-web.ts / duckduckgo-web.ts).
|
|
||||||
* With the gate off, `acquireFreshGrokClearance` is never called — the
|
|
||||||
* executor stays on the Step-1 `cloudflare_challenge` classification.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { acquireBrowserContext, type PooledContext } from "./browserPool.ts";
|
|
||||||
|
|
||||||
const GROK_WARMUP_URL = "https://grok.com/";
|
|
||||||
const GROK_COOKIE_DOMAIN = ".grok.com";
|
|
||||||
const GROK_POOL_KEY = "grok-web";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads the same opt-in gate as claude-web/duckduckgo-web
|
|
||||||
* (`WEB_COOKIE_USE_BROWSER` or `OMNIROUTE_BROWSER_POOL`). Off by default.
|
|
||||||
*/
|
|
||||||
export function shouldUseGrokBrowserBacked(): boolean {
|
export function shouldUseGrokBrowserBacked(): boolean {
|
||||||
const flag = process.env.WEB_COOKIE_USE_BROWSER;
|
const flag = process.env.WEB_COOKIE_USE_BROWSER;
|
||||||
if (flag === "1" || flag === "true" || flag === "on") return true;
|
if (flag === "1" || flag === "true" || flag === "on") return true;
|
||||||
@@ -37,48 +5,16 @@ export function shouldUseGrokBrowserBacked(): boolean {
|
|||||||
return poolFlag === "on" || poolFlag === "1" || poolFlag === "true";
|
return poolFlag === "on" || poolFlag === "1" || poolFlag === "true";
|
||||||
}
|
}
|
||||||
|
|
||||||
type AcquireGrokClearanceFn = (signal?: AbortSignal | null) => Promise<string | null>;
|
let grokClearanceAcquireOverride: ((signal?: AbortSignal) => Promise<string | null>) | null = 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(
|
export function __setGrokClearanceAcquireOverrideForTesting(
|
||||||
fn: AcquireGrokClearanceFn | null
|
fn: ((signal?: AbortSignal) => Promise<string | null>) | null,
|
||||||
): void {
|
): void {
|
||||||
acquireOverride = fn;
|
grokClearanceAcquireOverride = fn;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readCfClearanceFromContext(pooled: PooledContext): Promise<string | null> {
|
export async function acquireFreshGrokClearance(signal?: AbortSignal): Promise<string | null> {
|
||||||
const cookies = await pooled.context.cookies(GROK_WARMUP_URL);
|
if (grokClearanceAcquireOverride) return grokClearanceAcquireOverride(signal);
|
||||||
const match = cookies.find((c) => c.name === "cf_clearance");
|
const mod = await import("@omniroute/browser-pool");
|
||||||
return match?.value || null;
|
return mod.acquireFreshGrokClearance(signal);
|
||||||
}
|
|
||||||
|
|
||||||
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,44 +120,7 @@ for (const [aliasOrId, models] of Object.entries(PROVIDER_MODELS)) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const KNOWN_MODEL_IDS = new Set(MODEL_TO_PROVIDERS.keys());
|
const KNOWN_MODEL_IDS = new Set(MODEL_TO_PROVIDERS.keys());
|
||||||
// Bare Codex CLI defaults must always route to the `codex` provider (chatgpt.com
|
export const CODEX_NATIVE_UNPREFIXED_MODELS = new Set(["codex-auto-review"]);
|
||||||
// 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 {
|
interface ProviderConnectionLike {
|
||||||
provider?: unknown;
|
provider?: unknown;
|
||||||
@@ -571,19 +534,7 @@ async function resolveModelByProviderInference(modelId: string, extendedContext:
|
|||||||
getActiveSyncedProvidersForModel(modelId),
|
getActiveSyncedProvidersForModel(modelId),
|
||||||
getPreferClaudeCodeForUnprefixedClaudeModels(),
|
getPreferClaudeCodeForUnprefixedClaudeModels(),
|
||||||
]);
|
]);
|
||||||
// #FIX: synced catalogs (populated from `/v1/models` per connection) can
|
const providers = getInferredProvidersForModel(modelId, activeSyncedProviders);
|
||||||
// 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");
|
const nonOpenAIProviders = providers.filter((p) => p !== "openai");
|
||||||
|
|
||||||
// Bare model IDs from Codex CLI do not preserve OmniRoute's `cx/` prefix.
|
// Bare model IDs from Codex CLI do not preserve OmniRoute's `cx/` prefix.
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
/**
|
|
||||||
* wafRateLimit.ts — Burst guard for agentrouter.org upstream WAF.
|
|
||||||
*
|
|
||||||
* The agentrouter.org gateway runs a content-filter WAF that becomes more
|
|
||||||
* aggressive after bursts of requests from the same IP/key, returning
|
|
||||||
* `400 content-blocked` for requests that would normally pass. After ~5-10
|
|
||||||
* seconds of cooldown the filter relaxes again.
|
|
||||||
*
|
|
||||||
* To avoid tripping the WAF, we serialize outbound calls per provider and
|
|
||||||
* enforce a minimum inter-request gap. The defaults are conservative and
|
|
||||||
* meant to be a safety net — the upstream request rate from Claude Code is
|
|
||||||
* inherently low (one human-paced request at a time), so this guard should
|
|
||||||
* not affect normal traffic.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { log } from "../utils/logger.ts";
|
|
||||||
|
|
||||||
interface BurstGuardState {
|
|
||||||
lastSentAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = new Map<string, BurstGuardState>();
|
|
||||||
|
|
||||||
export interface WafRateLimitConfig {
|
|
||||||
minGapMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_CONFIG: WafRateLimitConfig = {
|
|
||||||
// 500ms is enough to prevent the burst-sensitive WAF from activating
|
|
||||||
// while staying well below human perception of latency.
|
|
||||||
minGapMs: 500,
|
|
||||||
};
|
|
||||||
|
|
||||||
let config: WafRateLimitConfig = { ...DEFAULT_CONFIG };
|
|
||||||
|
|
||||||
export function configureWafRateLimit(overrides: Partial<WafRateLimitConfig>): void {
|
|
||||||
config = { ...config, ...overrides };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getWafRateLimitConfig(): WafRateLimitConfig {
|
|
||||||
return { ...config };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait until at least `minGapMs` has passed since the last call to
|
|
||||||
* `gateOutboundRequest` for the same `bucketKey`. Safe to call from
|
|
||||||
* concurrent requests — the lock is held only for the sleep, not across
|
|
||||||
* the actual upstream fetch.
|
|
||||||
*
|
|
||||||
* @param bucketKey Stable identifier for the upstream (e.g. "agentrouter:url").
|
|
||||||
*/
|
|
||||||
export async function gateOutboundRequest(bucketKey: string): Promise<void> {
|
|
||||||
const now = Date.now();
|
|
||||||
const bucket = state.get(bucketKey);
|
|
||||||
if (!bucket) {
|
|
||||||
state.set(bucketKey, { lastSentAt: now });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const elapsed = now - bucket.lastSentAt;
|
|
||||||
const wait = config.minGapMs - elapsed;
|
|
||||||
if (wait > 0) {
|
|
||||||
log?.debug?.(
|
|
||||||
"WAF_RATE_LIMIT",
|
|
||||||
`Throttling outbound to ${bucketKey} — waiting ${wait}ms (min gap ${config.minGapMs}ms)`
|
|
||||||
);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
||||||
}
|
|
||||||
state.set(bucketKey, { lastSentAt: Date.now() });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset all rate-limit state. Primarily for tests.
|
|
||||||
*/
|
|
||||||
export function resetWafRateLimit(): void {
|
|
||||||
state.clear();
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
/**
|
|
||||||
* Functional gateway mirrors (`<gateway-alias>/<original-id>` mirror entries).
|
|
||||||
*
|
|
||||||
* /v1/models announces each model under its canonical owner provider
|
|
||||||
* (`deepseek/deepseek-v4-flash`). But the owner may have NO active credential
|
|
||||||
* while a passthrough gateway provider (e.g. agentrouter / openrouter) DOES and
|
|
||||||
* routes the same model. Discovery clients (omp, jcode, etc.) then see a model
|
|
||||||
* that fails on request, and never the route that works.
|
|
||||||
*
|
|
||||||
* This module synthesizes a mirror entry under the functional gateway alias:
|
|
||||||
*
|
|
||||||
* <gateway-alias>/<original-id> e.g. agentrouter/deepseek/deepseek-v4-flash
|
|
||||||
*
|
|
||||||
* The request path already resolves any known provider prefix
|
|
||||||
* (open-sse/services/model.ts::resolveProviderAlias), so the mirror is
|
|
||||||
* immediately routable with no request-side change. Pure synthesis over the
|
|
||||||
* already key-filtered list — no I/O.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const FUNCTIONAL_GATEWAY_MIRROR_SUFFIX = " (via ";
|
|
||||||
|
|
||||||
export interface FunctionalGatewayMirrorsDeps {
|
|
||||||
/** Ordered list of passthrough gateway provider ids to consider as mirrors. */
|
|
||||||
gatewayProviderIds: string[];
|
|
||||||
/** True when `provider` is a passthrough gateway that can route arbitrary models. */
|
|
||||||
isGateway(provider: string): boolean;
|
|
||||||
/** Map a gateway provider id to its catalog alias (e.g. "command-code" -> "cmd"). */
|
|
||||||
gatewayAlias(provider: string): string;
|
|
||||||
/** True when `gatewayProvider` has an eligible connection that covers `modelId`. */
|
|
||||||
gatewayCovers(gatewayProvider: string, modelId: string): boolean;
|
|
||||||
/** True when `gatewayProvider` has an active credential/connection. */
|
|
||||||
gatewayHasConnection(gatewayProvider: string): boolean;
|
|
||||||
/** True when the canonical owner `provider` has an eligible connection for the model. */
|
|
||||||
canonicalOwnerHasConnection(provider: string): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GatewayMirrorCatalogEntry {
|
|
||||||
id?: unknown;
|
|
||||||
owned_by?: unknown;
|
|
||||||
root?: unknown;
|
|
||||||
name?: unknown;
|
|
||||||
display_name?: unknown;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Append `<gatewayAlias>/<originalId>` mirror entries for every eligible model.
|
|
||||||
* Returns the original array reference unchanged when nothing is eligible.
|
|
||||||
*/
|
|
||||||
export function appendFunctionalGatewayMirrors<T extends GatewayMirrorCatalogEntry>(
|
|
||||||
models: T[],
|
|
||||||
deps: FunctionalGatewayMirrorsDeps
|
|
||||||
): T[] {
|
|
||||||
if (!Array.isArray(models)) return models;
|
|
||||||
|
|
||||||
const aliases: T[] = [];
|
|
||||||
for (const model of models) {
|
|
||||||
const id = model.id;
|
|
||||||
if (typeof id !== "string" || id.length === 0) continue;
|
|
||||||
|
|
||||||
const slashIndex = id.indexOf("/");
|
|
||||||
if (slashIndex <= 0) continue; // no provider prefix to re-home
|
|
||||||
const owner = id.slice(0, slashIndex);
|
|
||||||
const modelId = id.slice(slashIndex + 1);
|
|
||||||
if (!modelId || modelId === id) continue;
|
|
||||||
|
|
||||||
// Skip if the canonical owner already has a working connection for this model.
|
|
||||||
if (deps.canonicalOwnerHasConnection(owner)) continue;
|
|
||||||
|
|
||||||
// Find a passthrough gateway that actually routes this model AND has a credential.
|
|
||||||
let chosenAlias: string | null = null;
|
|
||||||
let chosenProvider: string | null = null;
|
|
||||||
for (const gatewayProvider of deps.gatewayProviderIds) {
|
|
||||||
const alias = deps.gatewayAlias(gatewayProvider);
|
|
||||||
if (!alias || alias === owner) continue;
|
|
||||||
if (!deps.isGateway(gatewayProvider)) continue;
|
|
||||||
if (!deps.gatewayHasConnection(gatewayProvider)) continue;
|
|
||||||
if (!deps.gatewayCovers(gatewayProvider, modelId)) continue;
|
|
||||||
chosenAlias = alias;
|
|
||||||
chosenProvider = gatewayProvider;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (!chosenAlias || !chosenProvider) continue;
|
|
||||||
|
|
||||||
const aliasId = `${chosenAlias}/${id}`;
|
|
||||||
// Skip if the mirror already exists in the list.
|
|
||||||
if (models.some((m) => m.id === aliasId)) continue;
|
|
||||||
// Skip if the id already starts with this gateway alias (would double-prefix).
|
|
||||||
if (id.startsWith(`${chosenAlias}/`)) continue;
|
|
||||||
|
|
||||||
const label =
|
|
||||||
typeof model.name === "string" && model.name ? model.name : modelId;
|
|
||||||
aliases.push({
|
|
||||||
...model,
|
|
||||||
id: aliasId,
|
|
||||||
root: id,
|
|
||||||
owned_by: chosenProvider,
|
|
||||||
display_name: `${label}${FUNCTIONAL_GATEWAY_MIRROR_SUFFIX}${chosenProvider})`,
|
|
||||||
} as T);
|
|
||||||
}
|
|
||||||
|
|
||||||
return aliases.length > 0 ? [...models, ...aliases] : models;
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ import { parseSSEDataPayload } from "./streamHelpers.ts";
|
|||||||
import {
|
import {
|
||||||
backfillResponsesCompletedOutput,
|
backfillResponsesCompletedOutput,
|
||||||
normalizeResponsesSseIds,
|
normalizeResponsesSseIds,
|
||||||
normalizeResponsesCompletedUsage,
|
|
||||||
pushUniqueResponsesOutputItems,
|
pushUniqueResponsesOutputItems,
|
||||||
stringifyIdValue,
|
stringifyIdValue,
|
||||||
stripResponsesLifecycleEcho,
|
stripResponsesLifecycleEcho,
|
||||||
@@ -175,20 +174,13 @@ function handleResponsesTailPayload(
|
|||||||
const outputPayload = textualToolCallBackfilled
|
const outputPayload = textualToolCallBackfilled
|
||||||
? context.toResponsesCompletedWithToolCalls(parsed)
|
? context.toResponsesCompletedWithToolCalls(parsed)
|
||||||
: parsed;
|
: parsed;
|
||||||
const usageNormalized = normalizeResponsesCompletedUsage(outputPayload);
|
|
||||||
const stripped = stripResponsesLifecycleEcho(outputPayload);
|
const stripped = stripResponsesLifecycleEcho(outputPayload);
|
||||||
const backfilled = backfillResponsesCompletedOutput(
|
const backfilled = backfillResponsesCompletedOutput(
|
||||||
outputPayload,
|
outputPayload,
|
||||||
context.passthroughResponsesOutputItems
|
context.passthroughResponsesOutputItems
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (stripped || backfilled || textualToolCallBackfilled || responsesIdsNormalized) {
|
||||||
stripped ||
|
|
||||||
backfilled ||
|
|
||||||
textualToolCallBackfilled ||
|
|
||||||
responsesIdsNormalized ||
|
|
||||||
usageNormalized
|
|
||||||
) {
|
|
||||||
output = `data: ${JSON.stringify(outputPayload)}\n\n`;
|
output = `data: ${JSON.stringify(outputPayload)}\n\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,49 +138,6 @@ export function backfillResponsesCompletedOutput(
|
|||||||
return true;
|
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([
|
const RESPONSES_LIFECYCLE_EVENT_TYPES = new Set([
|
||||||
"response.created",
|
"response.created",
|
||||||
"response.in_progress",
|
"response.in_progress",
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ import {
|
|||||||
} from "../services/sessionManager.ts";
|
} from "../services/sessionManager.ts";
|
||||||
import {
|
import {
|
||||||
backfillResponsesCompletedOutput,
|
backfillResponsesCompletedOutput,
|
||||||
normalizeResponsesCompletedUsage as normalizeUsage,
|
|
||||||
normalizeResponsesSseIds,
|
normalizeResponsesSseIds,
|
||||||
pushUniqueResponsesOutputItems,
|
pushUniqueResponsesOutputItems,
|
||||||
stringifyIdValue,
|
stringifyIdValue,
|
||||||
@@ -1582,13 +1581,11 @@ export function createSSEStream(options: StreamOptions = {}) {
|
|||||||
parsed,
|
parsed,
|
||||||
passthroughResponsesOutputItems
|
passthroughResponsesOutputItems
|
||||||
);
|
);
|
||||||
const usageNormalized = normalizeUsage(parsed);
|
|
||||||
if (
|
if (
|
||||||
stripped ||
|
stripped ||
|
||||||
backfilled ||
|
backfilled ||
|
||||||
textualToolCallBackfilled ||
|
textualToolCallBackfilled ||
|
||||||
responsesIdsNormalized ||
|
responsesIdsNormalized
|
||||||
usageNormalized
|
|
||||||
) {
|
) {
|
||||||
output = `data: ${JSON.stringify(parsed)}\n\n`;
|
output = `data: ${JSON.stringify(parsed)}\n\n`;
|
||||||
injectedUsage = true;
|
injectedUsage = true;
|
||||||
@@ -2276,6 +2273,7 @@ export function createSSEStream(options: StreamOptions = {}) {
|
|||||||
}
|
}
|
||||||
clientPayloadCollector.push(bufferedPayload);
|
clientPayloadCollector.push(bufferedPayload);
|
||||||
|
|
||||||
|
// Normalize numeric IDs for final buffered data: chunk (same as transform path)
|
||||||
if (typeof bufferedPayload === "object" && !Array.isArray(bufferedPayload)) {
|
if (typeof bufferedPayload === "object" && !Array.isArray(bufferedPayload)) {
|
||||||
const flushedParsed = bufferedPayload as JsonRecord;
|
const flushedParsed = bufferedPayload as JsonRecord;
|
||||||
const flushedType =
|
const flushedType =
|
||||||
@@ -2283,9 +2281,7 @@ export function createSSEStream(options: StreamOptions = {}) {
|
|||||||
const isResponses = flushedType.startsWith("response.");
|
const isResponses = flushedType.startsWith("response.");
|
||||||
const isClaude = isClaudeEventPayload(flushedParsed);
|
const isClaude = isClaudeEventPayload(flushedParsed);
|
||||||
if (isResponses) {
|
if (isResponses) {
|
||||||
const idsNormalized = normalizeResponsesSseIds(flushedParsed);
|
if (normalizeResponsesSseIds(flushedParsed)) {
|
||||||
const usageNormalized = normalizeUsage(flushedParsed);
|
|
||||||
if (idsNormalized || usageNormalized) {
|
|
||||||
output = `data: ${JSON.stringify(flushedParsed)}\n\n`;
|
output = `data: ${JSON.stringify(flushedParsed)}\n\n`;
|
||||||
}
|
}
|
||||||
} else if (!isClaude) {
|
} else if (!isClaude) {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ interface FetchOptions {
|
|||||||
body?: unknown;
|
body?: unknown;
|
||||||
redirect?: string;
|
redirect?: string;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
fingerprint?: { userAgent: string; secChUa: string; secChUaPlatform: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> | undefined {
|
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> | undefined {
|
||||||
|
|||||||
112
package-lock.json
generated
112
package-lock.json
generated
@@ -10,7 +10,8 @@
|
|||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"open-sse"
|
"open-sse",
|
||||||
|
"packages/browser-pool"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-bedrock-runtime": "^3.1073.0",
|
"@aws-sdk/client-bedrock-runtime": "^3.1073.0",
|
||||||
@@ -3692,9 +3693,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3711,9 +3709,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3730,9 +3725,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3749,9 +3741,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3768,9 +3757,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3787,9 +3773,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3806,9 +3789,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3825,9 +3805,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3844,9 +3821,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3869,9 +3843,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3894,9 +3865,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3919,9 +3887,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3944,9 +3909,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3969,9 +3931,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3994,9 +3953,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4019,9 +3975,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6330,6 +6283,10 @@
|
|||||||
"node": "^20.17.0 || >=22.9.0"
|
"node": "^20.17.0 || >=22.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@omniroute/browser-pool": {
|
||||||
|
"resolved": "packages/browser-pool",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@omniroute/open-sse": {
|
"node_modules/@omniroute/open-sse": {
|
||||||
"resolved": "open-sse",
|
"resolved": "open-sse",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -10739,9 +10696,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -10759,9 +10713,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -10779,9 +10730,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -10799,9 +10747,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -12894,9 +12839,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -12910,9 +12852,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -12926,9 +12865,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -12942,9 +12878,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -12958,9 +12891,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -12974,9 +12904,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -37085,6 +37012,33 @@
|
|||||||
"safe-regex": "^2.1.1",
|
"safe-regex": "^2.1.1",
|
||||||
"smol-toml": "1.7.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",
|
"name": "omniroute",
|
||||||
"version": "3.8.50",
|
"version": "3.8.50",
|
||||||
"description": "Unified AI router with 290 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.",
|
"description": "Unified AI router with 160+ providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"omniroute": "bin/omniroute.mjs",
|
"omniroute": "bin/omniroute.mjs",
|
||||||
@@ -47,7 +47,8 @@
|
|||||||
"!**/*.spec.tsx"
|
"!**/*.spec.tsx"
|
||||||
],
|
],
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"open-sse"
|
"open-sse",
|
||||||
|
"packages/browser-pool"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.22.2 <23 || >=24.0.0 <27"
|
"node": ">=22.22.2 <23 || >=24.0.0 <27"
|
||||||
@@ -110,8 +111,6 @@
|
|||||||
"test:unit:ci": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial",
|
"test:unit:ci": "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: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: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": "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: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\"",
|
"test:unit:shard:2": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=2/2 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=2/2 \"tests/unit/dashboard/**/*.test.ts\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=2/2 \"tests/unit/serial/**/*.test.ts\"",
|
||||||
@@ -305,7 +304,6 @@
|
|||||||
"smol-toml": "1.7.1",
|
"smol-toml": "1.7.1",
|
||||||
"socks": "^2.8.7",
|
"socks": "^2.8.7",
|
||||||
"sql.js": "^1.14.1",
|
"sql.js": "^1.14.1",
|
||||||
"sqlite-vec": "^0.1.9",
|
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tsx": "^4.23.0",
|
"tsx": "^4.23.0",
|
||||||
"undici": "^8.3.0",
|
"undici": "^8.3.0",
|
||||||
@@ -325,7 +323,8 @@
|
|||||||
"js-tiktoken": "^1.0.20",
|
"js-tiktoken": "^1.0.20",
|
||||||
"keytar": "^7.9.0",
|
"keytar": "^7.9.0",
|
||||||
"tls-client-node": "^0.2.0",
|
"tls-client-node": "^0.2.0",
|
||||||
"wreq-js": "^2.3.1"
|
"wreq-js": "^2.3.1",
|
||||||
|
"sqlite-vec": "^0.1.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "^4.11.3",
|
"@axe-core/playwright": "^4.11.3",
|
||||||
|
|||||||
15
packages/browser-pool/package.json
Normal file
15
packages/browser-pool/package.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "@omniroute/browser-pool",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Optional browser pool service for OmniRoute — CloakBrowser and Playwright-backed chat",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./src/index.ts",
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.61.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22"
|
||||||
|
}
|
||||||
|
}
|
||||||
37
packages/browser-pool/src/index.ts
Normal file
37
packages/browser-pool/src/index.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* @omniroute/browser-pool — Optional browser pool for Playwright-backed
|
||||||
|
* executor support (claude-web, duckduckgo-web, grok).
|
||||||
|
*
|
||||||
|
* Core stubs dynamically import this package at runtime. When the package
|
||||||
|
* is not installed, the stubs degrade gracefully (fallback or error).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Re-exports from browserPool ──────────────────────────────────────────
|
||||||
|
export {
|
||||||
|
acquireBrowserContext,
|
||||||
|
releaseBrowserContext,
|
||||||
|
getBrowserPoolMetrics,
|
||||||
|
readPageResponseBody,
|
||||||
|
openPage,
|
||||||
|
shutdownPool,
|
||||||
|
setProxyResolver,
|
||||||
|
__resetBrowserPoolMetricsForTest,
|
||||||
|
} from "./services/browserPool.ts";
|
||||||
|
|
||||||
|
export type { BrowserPoolContextOptions, BrowserPoolMetrics, PooledContext } from "./interfaces.ts";
|
||||||
|
|
||||||
|
// ── Re-exports from browserBackedChat ────────────────────────────────────
|
||||||
|
export {
|
||||||
|
browserBackedChat,
|
||||||
|
startBrowserWarmup,
|
||||||
|
getFreshCookiesWithWarmup,
|
||||||
|
} from "./services/browserBackedChat.ts";
|
||||||
|
|
||||||
|
// ── Re-exports from grokClearance ─────────────────────────────────────────
|
||||||
|
export {
|
||||||
|
getCachedCookies,
|
||||||
|
setCachedCookies,
|
||||||
|
clearCookieCache,
|
||||||
|
} from "./services/browserBackedChat.ts";
|
||||||
|
|
||||||
|
export { shouldUseGrokBrowserBacked, acquireFreshGrokClearance } from "./services/grokClearance.ts";
|
||||||
94
packages/browser-pool/src/interfaces.ts
Normal file
94
packages/browser-pool/src/interfaces.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* interfaces.ts — Shared type definitions for @omniroute/browser-pool.
|
||||||
|
*
|
||||||
|
* These types are used by both the package entry and the core stubs.
|
||||||
|
* The core stubs re-export them so existing import paths remain stable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BrowserContext, Page } from "playwright";
|
||||||
|
|
||||||
|
// ── Browser pool ───────────────────────────────────────
|
||||||
|
|
||||||
|
export interface BrowserPoolContextOptions {
|
||||||
|
cookieDomain: string;
|
||||||
|
cookieString?: string | null;
|
||||||
|
warmupUrl?: string | null;
|
||||||
|
userAgent?: string;
|
||||||
|
locale?: string;
|
||||||
|
timezone?: string;
|
||||||
|
preferCloakbrowser?: boolean;
|
||||||
|
/** Time (ms) to wait for the warmup page to be ready. */
|
||||||
|
waitFor?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PooledContext {
|
||||||
|
id: string;
|
||||||
|
context: BrowserContext;
|
||||||
|
warmupPage: Page | null;
|
||||||
|
lastUsed: number;
|
||||||
|
isStealth: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserPoolMetrics {
|
||||||
|
browserLaunches: number;
|
||||||
|
browserLaunchFailures: number;
|
||||||
|
contextsCreated: number;
|
||||||
|
contextsReused: number;
|
||||||
|
contextsEvicted: number;
|
||||||
|
contextsReleased: number;
|
||||||
|
contextCreateFailures: number;
|
||||||
|
shutdowns: number;
|
||||||
|
lastShutdownReason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Browser-backed chat ────────────────────────────────
|
||||||
|
|
||||||
|
export interface BrowserBackedChatRequest {
|
||||||
|
/** Pool key — typically a provider id like "duckduckgo-web" or
|
||||||
|
* "claude-web", optionally suffixed by user/account id. */
|
||||||
|
poolKey: string;
|
||||||
|
/** Chat URL the page should submit to (captured via waitForResponse). */
|
||||||
|
chatUrl: string;
|
||||||
|
/** Chat page URL to navigate to before typing. */
|
||||||
|
chatPageUrl: string;
|
||||||
|
/** The text the user wants to send. */
|
||||||
|
userMessage: string;
|
||||||
|
/** Cookie string (raw) to inject into the browser context. */
|
||||||
|
cookieString?: string | null;
|
||||||
|
/** Cookie domain (used together with cookieString). */
|
||||||
|
cookieDomain?: string;
|
||||||
|
/** Domain for the page's fetch to identify the chat endpoint. */
|
||||||
|
chatUrlMatchDomain: string;
|
||||||
|
/** User-Agent string for the browser context. */
|
||||||
|
userAgent?: string;
|
||||||
|
/** Locale (BCP 47). Defaults to en-US. */
|
||||||
|
locale?: string;
|
||||||
|
/** IANA timezone. Defaults to America/New_York. */
|
||||||
|
timezone?: string;
|
||||||
|
/** Selector for the chat input. */
|
||||||
|
inputSelector: string;
|
||||||
|
/** Selector for the submit button (optional — falls back to Enter). */
|
||||||
|
submitButtonSelector?: string;
|
||||||
|
/** Wait after submit for SSE/JSON to arrive. Default 15 seconds. */
|
||||||
|
postSubmitWaitMs?: number;
|
||||||
|
/** Optional AbortSignal. Cancels navigation/submit. */
|
||||||
|
signal?: AbortSignal | null;
|
||||||
|
/** Reuse the same context across requests. Default true. */
|
||||||
|
reuseContext?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserBackedChatTiming {
|
||||||
|
acquireContextMs: number;
|
||||||
|
navigateMs: number;
|
||||||
|
submitMs: number;
|
||||||
|
captureResponseMs: number;
|
||||||
|
totalMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserBackedChatResult {
|
||||||
|
status: number;
|
||||||
|
contentType: string | null;
|
||||||
|
body: Buffer;
|
||||||
|
isStealth: boolean;
|
||||||
|
timing: BrowserBackedChatTiming;
|
||||||
|
}
|
||||||
461
packages/browser-pool/src/services/browserBackedChat.ts
Normal file
461
packages/browser-pool/src/services/browserBackedChat.ts
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
/**
|
||||||
|
* browserBackedChat.ts — Full browser-backed chat interaction for @omniroute/browser-pool.
|
||||||
|
*
|
||||||
|
* Opens a page on a shared browser context, navigates to the provider's
|
||||||
|
* chat page, types the user's message, clicks Send, and returns the
|
||||||
|
* upstream SSE/JSON response body as a structured result.
|
||||||
|
*
|
||||||
|
* Providers using this path: duckduckgo-web, claude-web.
|
||||||
|
*
|
||||||
|
* The browser solves the provider's challenge natively (VQD, Cloudflare
|
||||||
|
* Turnstile, etc.) by computing real DOM measurement values. The
|
||||||
|
* Node-side challenge solver still runs as a first-line best-effort;
|
||||||
|
* this module is the fallback.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Buffer } from "node:buffer";
|
||||||
|
import {
|
||||||
|
acquireBrowserContext,
|
||||||
|
openPage,
|
||||||
|
readPageResponseBody,
|
||||||
|
releaseBrowserContext,
|
||||||
|
} from "./browserPool.ts";
|
||||||
|
import type {
|
||||||
|
PooledContext,
|
||||||
|
BrowserBackedChatRequest,
|
||||||
|
BrowserBackedChatResult,
|
||||||
|
} from "../interfaces.ts";
|
||||||
|
|
||||||
|
// Safety constants
|
||||||
|
const MAX_RESPONSE_BYTES = 10 * 1024 * 1024; // 10 MB
|
||||||
|
|
||||||
|
// Cookie cache constants
|
||||||
|
const COOKIE_CACHE_TTL_MS = 5 * 60 * 1000; // Cache fresh cookies for 5 minutes
|
||||||
|
const COOKIE_POLL_INTERVAL_MS = 500; // Poll for cookies every 500ms
|
||||||
|
const COOKIE_POLL_TIMEOUT_MS = 5000; // Max poll time for cookies
|
||||||
|
|
||||||
|
// Cookie cache — avoids repeated browser launches when cookies are still valid
|
||||||
|
interface CachedCookies {
|
||||||
|
cookieString: string;
|
||||||
|
expiresAt: number;
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
|
const cookieCache = new Map<string, CachedCookies>();
|
||||||
|
|
||||||
|
export function getCachedCookies(domain: string): string | null {
|
||||||
|
const cached = cookieCache.get(domain);
|
||||||
|
if (cached && Date.now() < cached.expiresAt) return cached.cookieString;
|
||||||
|
cookieCache.delete(domain);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCachedCookies(domain: string, cookieString: string, ttlMs?: number): void {
|
||||||
|
cookieCache.set(domain, {
|
||||||
|
cookieString,
|
||||||
|
expiresAt: Date.now() + (ttlMs ?? COOKIE_CACHE_TTL_MS),
|
||||||
|
domain,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCookieCache(): void {
|
||||||
|
cookieCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedup pending cookie refreshes per pool key
|
||||||
|
const pendingRefreshes = new Map<string, Promise<string | null>>();
|
||||||
|
|
||||||
|
/** Sanitize an error message for safe JSON transport. */
|
||||||
|
const MAX_ERROR_LEN = 512;
|
||||||
|
function sanitizeErrorMessage(message: unknown): string {
|
||||||
|
let str = typeof message === "string" ? message : String(message ?? "");
|
||||||
|
if (str.length > MAX_ERROR_LEN) str = str.slice(0, MAX_ERROR_LEN);
|
||||||
|
const nl = str.indexOf("\n");
|
||||||
|
if (nl >= 0) str = str.slice(0, nl);
|
||||||
|
return str.replace(/[^ -~]/g, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wait N milliseconds, abortable via signal. */
|
||||||
|
async function waitWithSignal(ms: number, signal?: AbortSignal | null): Promise<void> {
|
||||||
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const onAbort = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new DOMException("Aborted", "AbortError"));
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
signal?.removeEventListener("abort", onAbort);
|
||||||
|
resolve();
|
||||||
|
}, ms);
|
||||||
|
signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* waitForCookiesWithPolling — Poll for cookies every 500ms up to 5s.
|
||||||
|
* Returns as soon as challenge cookies appear, instead of always
|
||||||
|
* waiting the full timeout. Saves 1-4s when anti-bot resolves quickly.
|
||||||
|
*/
|
||||||
|
async function waitForCookiesWithPolling(
|
||||||
|
context: import("playwright").BrowserContext,
|
||||||
|
cookieDomain: string,
|
||||||
|
signal: AbortSignal | null
|
||||||
|
): Promise<string | null> {
|
||||||
|
const deadline = Date.now() + COOKIE_POLL_TIMEOUT_MS;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
const cookies = await context.cookies(cookieDomain);
|
||||||
|
const cookieString = cookies.map((c) => `${c.name}=${c.value}`).join("; ");
|
||||||
|
if (cookieString) return cookieString;
|
||||||
|
const remaining = deadline - Date.now();
|
||||||
|
if (remaining <= 0) break;
|
||||||
|
await waitWithSignal(Math.min(COOKIE_POLL_INTERVAL_MS, remaining), signal);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* doCookieRefreshOnContext — Run cookie extraction on an already-acquired
|
||||||
|
* browser context. Opens a temporary page, navigates to the chat URL,
|
||||||
|
* polls for cookies, and returns the result.
|
||||||
|
* NOTE: Does NOT pass AbortSignal to Playwright methods — signals are
|
||||||
|
* handled via waitWithSignal wrapping instead.
|
||||||
|
*/
|
||||||
|
async function doCookieRefreshOnContext(
|
||||||
|
pooled: PooledContext,
|
||||||
|
chatPageUrl: string,
|
||||||
|
cookieDomain: string,
|
||||||
|
signal: AbortSignal | null
|
||||||
|
): Promise<string | null> {
|
||||||
|
const page = await openPage(pooled);
|
||||||
|
try {
|
||||||
|
await page.goto(chatPageUrl, {
|
||||||
|
waitUntil: "domcontentloaded",
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
return await waitForCookiesWithPolling(pooled.context, cookieDomain, signal);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException && err.name === "AbortError") throw err;
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
await page.close().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match a URL against a chat URL template, allowing a single dynamic
|
||||||
|
* id segment (PLACEHOLDER) in the template.
|
||||||
|
*/
|
||||||
|
function chatUrlMatcher(u: string, matchDomain: string, chatUrl: string): boolean {
|
||||||
|
if (u === chatUrl) return true;
|
||||||
|
let parsed: URL;
|
||||||
|
let chatParsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(u);
|
||||||
|
chatParsed = new URL(chatUrl);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!parsed.host.endsWith(matchDomain)) return false;
|
||||||
|
const chatSeg = chatParsed.pathname.split("/").filter(Boolean);
|
||||||
|
const reqSeg = parsed.pathname.split("/").filter(Boolean);
|
||||||
|
if (chatSeg.length < 2 || reqSeg.length !== chatSeg.length) return false;
|
||||||
|
let allowedDynamic = 1;
|
||||||
|
for (let i = 0; i < chatSeg.length; i++) {
|
||||||
|
if (chatSeg[i] === reqSeg[i]) continue;
|
||||||
|
if (chatSeg[i] === "PLACEHOLDER" && allowedDynamic > 0) {
|
||||||
|
allowedDynamic--;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a unique pool key; when reuseContext is false, create a unique key. */
|
||||||
|
async function settlePoolKey(
|
||||||
|
requestedKey: string,
|
||||||
|
reuseContext: boolean
|
||||||
|
): Promise<{ key: string; acquired: boolean }> {
|
||||||
|
if (reuseContext) return { key: requestedKey, acquired: true };
|
||||||
|
return {
|
||||||
|
key: `${requestedKey}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
acquired: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cookie refresh helpers ──────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* doRefresh — Acquire a fresh browser context, navigate to the
|
||||||
|
* chat page, and poll for cookies. Returns the cookie string
|
||||||
|
* or null on failure.
|
||||||
|
* NOTE: Does NOT pass AbortSignal to Playwright methods — signals
|
||||||
|
* are handled via waitWithSignal wrapping instead.
|
||||||
|
*/
|
||||||
|
async function doRefresh(options: {
|
||||||
|
chatPageUrl: string;
|
||||||
|
cookieDomain: string;
|
||||||
|
poolKey: string;
|
||||||
|
signal: AbortSignal | null;
|
||||||
|
}): Promise<string | null> {
|
||||||
|
const pooled = await acquireBrowserContext(options.poolKey + "-refresh", {
|
||||||
|
cookieDomain: options.cookieDomain,
|
||||||
|
cookieString: null,
|
||||||
|
warmupUrl: options.chatPageUrl,
|
||||||
|
});
|
||||||
|
const page = await openPage(pooled);
|
||||||
|
try {
|
||||||
|
await page.goto(options.chatPageUrl, {
|
||||||
|
waitUntil: "domcontentloaded",
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
return await waitForCookiesWithPolling(pooled.context, options.cookieDomain, options.signal);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException && err.name === "AbortError") throw err;
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
await page.close().catch(() => {});
|
||||||
|
// release context — we got the cookies
|
||||||
|
setTimeout(() => {
|
||||||
|
releaseBrowserContext(options.poolKey + "-refresh").catch(() => {});
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* refreshCookiesViaBrowser — Refresh cookies using a browser context.
|
||||||
|
* Uses pendingRefreshes dedup so concurrent requests share one browser launch.
|
||||||
|
* NOTE: Override check (httpOverride) is handled in the core stub — this
|
||||||
|
* package version always attempts browser cookie refresh.
|
||||||
|
*/
|
||||||
|
async function refreshCookiesViaBrowser(
|
||||||
|
chatUrl: string,
|
||||||
|
chatPageUrl: string,
|
||||||
|
cookieDomain: string,
|
||||||
|
poolKey: string,
|
||||||
|
signal: AbortSignal | null
|
||||||
|
): Promise<string | null> {
|
||||||
|
const pending = pendingRefreshes.get(poolKey);
|
||||||
|
if (pending) return pending;
|
||||||
|
const promise = doRefresh({ chatPageUrl, cookieDomain, poolKey, signal });
|
||||||
|
pendingRefreshes.set(poolKey, promise);
|
||||||
|
try {
|
||||||
|
return await promise;
|
||||||
|
} finally {
|
||||||
|
pendingRefreshes.delete(poolKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* startBrowserWarmup — Pre-warm a browser context for the given pool key.
|
||||||
|
* This is a fire-and-forget operation: errors are caught and ignored.
|
||||||
|
* The warmup page serves as a readiness indicator — we open a page in the
|
||||||
|
* pooled context to force early navigation before the actual request.
|
||||||
|
*/
|
||||||
|
export async function startBrowserWarmup(
|
||||||
|
poolKey: string,
|
||||||
|
chatPageUrl: string,
|
||||||
|
cookieDomain: string,
|
||||||
|
signal: AbortSignal | null
|
||||||
|
): Promise<void> {
|
||||||
|
if (process.env.OMNIROUTE_BROWSER_POOL === "off") return;
|
||||||
|
const pooled = await acquireBrowserContext(poolKey, {
|
||||||
|
cookieDomain,
|
||||||
|
cookieString: null,
|
||||||
|
warmupUrl: chatPageUrl,
|
||||||
|
waitFor: 2000,
|
||||||
|
});
|
||||||
|
// Warmup: open a page in the pooled context — this can happen in parallel
|
||||||
|
openPage(pooled).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* getFreshCookiesWithWarmup — Try cached cookies first; if none, start
|
||||||
|
* a browser warmup in parallel with a cookie refresh. Returns cookie string
|
||||||
|
* or null. Caches successful results.
|
||||||
|
*/
|
||||||
|
export async function getFreshCookiesWithWarmup(
|
||||||
|
chatUrl: string,
|
||||||
|
chatPageUrl: string,
|
||||||
|
cookieDomain: string,
|
||||||
|
poolKey: string,
|
||||||
|
signal: AbortSignal | null
|
||||||
|
): Promise<string | null> {
|
||||||
|
// Try cached cookies first
|
||||||
|
const cached = getCachedCookies(cookieDomain);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
// Start warmup in parallel with refresh
|
||||||
|
const warmup = startBrowserWarmup(poolKey, chatPageUrl, cookieDomain, signal);
|
||||||
|
const fresh = await refreshCookiesViaBrowser(chatUrl, chatPageUrl, cookieDomain, poolKey, signal);
|
||||||
|
// Await warmup (errors are non-fatal)
|
||||||
|
await warmup.catch(() => {});
|
||||||
|
if (fresh) {
|
||||||
|
setCachedCookies(cookieDomain, fresh);
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main entry point ───────────────────────────────────
|
||||||
|
|
||||||
|
export async function browserBackedChat(
|
||||||
|
req: BrowserBackedChatRequest
|
||||||
|
): Promise<BrowserBackedChatResult> {
|
||||||
|
const t0 = Date.now();
|
||||||
|
const {
|
||||||
|
poolKey,
|
||||||
|
chatUrl,
|
||||||
|
chatPageUrl,
|
||||||
|
userMessage,
|
||||||
|
cookieString,
|
||||||
|
cookieDomain,
|
||||||
|
chatUrlMatchDomain,
|
||||||
|
userAgent,
|
||||||
|
locale,
|
||||||
|
timezone,
|
||||||
|
inputSelector,
|
||||||
|
submitButtonSelector,
|
||||||
|
postSubmitWaitMs = 15000,
|
||||||
|
signal,
|
||||||
|
reuseContext = true,
|
||||||
|
} = req;
|
||||||
|
|
||||||
|
const { key, acquired: reuseAcquired } = await settlePoolKey(poolKey, reuseContext);
|
||||||
|
const tAcquireStart = Date.now();
|
||||||
|
const pooled: PooledContext = await acquireBrowserContext(key, {
|
||||||
|
cookieDomain: cookieDomain || chatUrlMatchDomain,
|
||||||
|
cookieString: cookieString || null,
|
||||||
|
warmupUrl: chatPageUrl,
|
||||||
|
userAgent,
|
||||||
|
locale,
|
||||||
|
timezone,
|
||||||
|
});
|
||||||
|
const acquireContextMs = Date.now() - tAcquireStart;
|
||||||
|
|
||||||
|
const page = await openPage(pooled);
|
||||||
|
try {
|
||||||
|
const tNavStart = Date.now();
|
||||||
|
await page.goto(chatPageUrl, {
|
||||||
|
waitUntil: "domcontentloaded",
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
const navigateMs = Date.now() - tNavStart;
|
||||||
|
|
||||||
|
const inputLocator = page.locator(inputSelector).first();
|
||||||
|
await inputLocator.waitFor({ state: "visible", timeout: 10000 });
|
||||||
|
await waitWithSignal(800, signal);
|
||||||
|
|
||||||
|
const responsePromise = page.waitForResponse(
|
||||||
|
(r) =>
|
||||||
|
r.request().method() === "POST" && chatUrlMatcher(r.url(), chatUrlMatchDomain, chatUrl),
|
||||||
|
{ timeout: 30000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
let abortListener: (() => void) | undefined;
|
||||||
|
const signalPromise = signal
|
||||||
|
? new Promise<never>((_, reject) => {
|
||||||
|
if (signal.aborted) return reject(new DOMException("Aborted", "AbortError"));
|
||||||
|
abortListener = () => reject(new DOMException("Aborted", "AbortError"));
|
||||||
|
signal.addEventListener("abort", abortListener, { once: true });
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (submitButtonSelector) {
|
||||||
|
const btn = page.locator(submitButtonSelector).first();
|
||||||
|
if ((await btn.count()) > 0) {
|
||||||
|
try {
|
||||||
|
await btn.click({ timeout: 2000 });
|
||||||
|
} catch {
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
}
|
||||||
|
const tCaptureStart = Date.now();
|
||||||
|
const response = signalPromise
|
||||||
|
? await Promise.race([responsePromise, signalPromise]).catch(() => null)
|
||||||
|
: await responsePromise.catch(() => null);
|
||||||
|
if (signal && abortListener) {
|
||||||
|
signal.removeEventListener("abort", abortListener);
|
||||||
|
}
|
||||||
|
if (response) {
|
||||||
|
await waitWithSignal(Math.min(postSubmitWaitMs, 30000), signal);
|
||||||
|
} else {
|
||||||
|
await waitWithSignal(postSubmitWaitMs, signal);
|
||||||
|
}
|
||||||
|
const captureResponseMs = Date.now() - tCaptureStart;
|
||||||
|
const submitMs = captureResponseMs;
|
||||||
|
|
||||||
|
let status = 0;
|
||||||
|
let contentType: string | null = null;
|
||||||
|
let body: Buffer = Buffer.alloc(0);
|
||||||
|
if (response) {
|
||||||
|
const captured = await readPageResponseBody(response);
|
||||||
|
if (captured.body.length > MAX_RESPONSE_BYTES) {
|
||||||
|
body = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
error: {
|
||||||
|
message: "Response too large",
|
||||||
|
type: "upstream_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
status = 502;
|
||||||
|
contentType = "application/json";
|
||||||
|
} else {
|
||||||
|
body = captured.body as unknown as Buffer;
|
||||||
|
contentType = captured.headers["content-type"] || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
contentType,
|
||||||
|
body,
|
||||||
|
isStealth: pooled.isStealth,
|
||||||
|
timing: {
|
||||||
|
acquireContextMs,
|
||||||
|
navigateMs,
|
||||||
|
submitMs,
|
||||||
|
captureResponseMs,
|
||||||
|
totalMs: Date.now() - t0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
const body = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
error: {
|
||||||
|
message: sanitizeErrorMessage(`browserBackedChat failed: ${msg}`),
|
||||||
|
type: "upstream_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: 502,
|
||||||
|
contentType: "application/json",
|
||||||
|
body,
|
||||||
|
isStealth: pooled.isStealth,
|
||||||
|
timing: {
|
||||||
|
acquireContextMs,
|
||||||
|
navigateMs: 0,
|
||||||
|
submitMs: 0,
|
||||||
|
captureResponseMs: 0,
|
||||||
|
totalMs: Date.now() - t0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await page.close();
|
||||||
|
if (!reuseAcquired) {
|
||||||
|
try {
|
||||||
|
await pooled.context.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
440
packages/browser-pool/src/services/browserPool.ts
Normal file
440
packages/browser-pool/src/services/browserPool.ts
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
/**
|
||||||
|
* browserPool.ts — Shared stealth browser pool for web-cookie providers.
|
||||||
|
*
|
||||||
|
* The DuckDuckGo VQD challenge and Claude web's Cloudflare Turnstile both
|
||||||
|
* validate values that only a real browser can produce (DOM layout
|
||||||
|
* measurements like offsetWidth/Height, getBoundingClientRect,
|
||||||
|
* getComputedStyle, iframe contentWindow probes). Plain Node fetch + a
|
||||||
|
* VM-stubs solver structurally runs the JS but cannot match those values,
|
||||||
|
* so the server rejects the request.
|
||||||
|
*
|
||||||
|
* This pool keeps one Chromium instance warm and serves "browser contexts"
|
||||||
|
* (one per provider) on demand. Each context owns one or more pages; the
|
||||||
|
* caller is expected to be polite (one page per request, close on done).
|
||||||
|
*
|
||||||
|
* The pool prefers `cloakbrowser` (npm) when available — its binary-level
|
||||||
|
* fingerprint patches (--fingerprint-timezone, --fingerprint-locale, and
|
||||||
|
* dozens more) are the only thing that gets past DuckDuckGo's anti-bot
|
||||||
|
* in this environment. Falls back to plain `playwright` if cloakbrowser
|
||||||
|
* is not installed; the fallback works for Claude web (which only needs
|
||||||
|
* valid cookies) but not for DDG's VQD challenge.
|
||||||
|
*
|
||||||
|
* Opt-in: pool only launches Chromium when an executor explicitly asks
|
||||||
|
* for a context, so users who never use the browser-backed path pay zero
|
||||||
|
* startup cost. Set OMNIROUTE_BROWSER_POOL=off to fully disable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Buffer } from "node:buffer";
|
||||||
|
import type {
|
||||||
|
BrowserPoolContextOptions,
|
||||||
|
BrowserPoolMetrics,
|
||||||
|
PooledContext,
|
||||||
|
} from "../interfaces.ts";
|
||||||
|
|
||||||
|
type Browser = import("playwright").Browser;
|
||||||
|
type BrowserContext = import("playwright").BrowserContext;
|
||||||
|
type Page = import("playwright").Page;
|
||||||
|
|
||||||
|
/** Proxy resolver injected by the core stub after dynamic import. */
|
||||||
|
type ProxyResolverFn = (
|
||||||
|
providerKey: string
|
||||||
|
) => Promise<import("playwright").LaunchOptions["proxy"] | undefined>;
|
||||||
|
|
||||||
|
let injectedProxyResolver: ProxyResolverFn | null = null;
|
||||||
|
|
||||||
|
export function setProxyResolver(fn: ProxyResolverFn): void {
|
||||||
|
injectedProxyResolver = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBrowserPoolMetrics(): BrowserPoolMetrics {
|
||||||
|
return {
|
||||||
|
browserLaunches: 0,
|
||||||
|
browserLaunchFailures: 0,
|
||||||
|
contextsCreated: 0,
|
||||||
|
contextsReused: 0,
|
||||||
|
contextsEvicted: 0,
|
||||||
|
contextsReleased: 0,
|
||||||
|
contextCreateFailures: 0,
|
||||||
|
shutdowns: 0,
|
||||||
|
lastShutdownReason: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PoolState {
|
||||||
|
browser: Browser | null;
|
||||||
|
contexts: Map<string, PooledContext>;
|
||||||
|
pendingContexts: Map<string, Promise<PooledContext>>;
|
||||||
|
launching: Promise<Browser> | null;
|
||||||
|
lastActivity: number;
|
||||||
|
idleTimer: NodeJS.Timeout | null;
|
||||||
|
evictTimer: NodeJS.Timeout | null;
|
||||||
|
cloakLaunch: ((opts: unknown) => Promise<Browser>) | null;
|
||||||
|
cloakLaunchResolved: boolean;
|
||||||
|
metrics: BrowserPoolMetrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
const POOL_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
||||||
|
const CONTEXT_TTL_MS = 10 * 60 * 1000; // 10 min — evict stale contexts
|
||||||
|
const EVICT_INTERVAL_MS = 60 * 1000; // check every 60s
|
||||||
|
const DEFAULT_USER_AGENT =
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
|
||||||
|
|
||||||
|
const state: PoolState = {
|
||||||
|
browser: null,
|
||||||
|
contexts: new Map(),
|
||||||
|
pendingContexts: new Map(),
|
||||||
|
launching: null,
|
||||||
|
lastActivity: 0,
|
||||||
|
idleTimer: null,
|
||||||
|
evictTimer: null,
|
||||||
|
cloakLaunch: null,
|
||||||
|
cloakLaunchResolved: false,
|
||||||
|
metrics: createBrowserPoolMetrics(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function getCloakbrowserModuleId(): string {
|
||||||
|
// Keep this computed: cloakbrowser is an optional runtime enhancer, and a literal
|
||||||
|
// dynamic import with the package name makes Turbopack resolve it during route compilation.
|
||||||
|
return ["cloak", "browser"].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveCloakLaunch(): Promise<((opts: unknown) => Promise<Browser>) | null> {
|
||||||
|
if (state.cloakLaunchResolved) return state.cloakLaunch;
|
||||||
|
state.cloakLaunchResolved = true;
|
||||||
|
try {
|
||||||
|
const mod = (await import(getCloakbrowserModuleId())) as unknown as {
|
||||||
|
launch?: (opts: unknown) => Promise<Browser>;
|
||||||
|
};
|
||||||
|
state.cloakLaunch = mod.launch ?? null;
|
||||||
|
} catch {
|
||||||
|
state.cloakLaunch = null;
|
||||||
|
}
|
||||||
|
return state.cloakLaunch;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPoolEnabled(): boolean {
|
||||||
|
const flag = process.env.OMNIROUTE_BROWSER_POOL;
|
||||||
|
if (flag === undefined) return true;
|
||||||
|
return flag !== "off" && flag !== "0" && flag !== "false";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetIdleTimer(): void {
|
||||||
|
if (state.idleTimer) clearTimeout(state.idleTimer);
|
||||||
|
state.idleTimer = setTimeout(() => {
|
||||||
|
void shutdownPool("idle-timeout");
|
||||||
|
}, POOL_IDLE_TIMEOUT_MS);
|
||||||
|
state.idleTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function evictStaleContexts(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, pooled] of state.contexts) {
|
||||||
|
if (now - pooled.lastUsed > CONTEXT_TTL_MS) {
|
||||||
|
console.log(
|
||||||
|
"[BrowserPool] Evicted stale context:",
|
||||||
|
key,
|
||||||
|
"(idle",
|
||||||
|
((now - pooled.lastUsed) / 1000).toFixed(0) + "s)"
|
||||||
|
);
|
||||||
|
state.contexts.delete(key);
|
||||||
|
state.metrics.contextsEvicted++;
|
||||||
|
pooled.context.close().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.contexts.size === 0 && !state.launching) {
|
||||||
|
void shutdownPool("all-contexts-evicted");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEvictTimer(): void {
|
||||||
|
if (state.evictTimer) clearInterval(state.evictTimer);
|
||||||
|
state.evictTimer = setInterval(() => evictStaleContexts(), EVICT_INTERVAL_MS);
|
||||||
|
state.evictTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function launchBrowser(): Promise<Browser> {
|
||||||
|
if (state.browser) return state.browser;
|
||||||
|
if (state.launching) return state.launching;
|
||||||
|
state.launching = (async () => {
|
||||||
|
const cloakLaunch = await resolveCloakLaunch();
|
||||||
|
let browser: Browser;
|
||||||
|
if (cloakLaunch) {
|
||||||
|
browser = await cloakLaunch({
|
||||||
|
headless: true,
|
||||||
|
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback: plain Playwright. Works for Claude web (cookie-only
|
||||||
|
// auth) but DDG's VQD challenge will detect this Chromium build.
|
||||||
|
const { chromium } = await import("playwright");
|
||||||
|
browser = await chromium.launch({
|
||||||
|
headless: true,
|
||||||
|
args: [
|
||||||
|
"--no-sandbox",
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
"--disable-blink-features=AutomationControlled",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
state.browser = browser;
|
||||||
|
state.launching = null;
|
||||||
|
state.metrics.browserLaunches++;
|
||||||
|
return browser;
|
||||||
|
})();
|
||||||
|
try {
|
||||||
|
return await state.launching;
|
||||||
|
} catch (err) {
|
||||||
|
state.launching = null;
|
||||||
|
state.metrics.browserLaunchFailures++;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCookieString(
|
||||||
|
raw: string,
|
||||||
|
domain: string
|
||||||
|
): Array<{
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
domain: string;
|
||||||
|
path: string;
|
||||||
|
expires: number;
|
||||||
|
httpOnly: boolean;
|
||||||
|
secure: boolean;
|
||||||
|
sameSite: "Lax" | "Strict" | "None";
|
||||||
|
}> {
|
||||||
|
return raw
|
||||||
|
.split(";")
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((pair) => {
|
||||||
|
const eq = pair.indexOf("=");
|
||||||
|
if (eq < 0) return null;
|
||||||
|
const name = pair.slice(0, eq).trim();
|
||||||
|
const value = pair.slice(eq + 1).trim();
|
||||||
|
if (!name || !value) return null;
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
domain: domain.startsWith(".") ? domain : `.${domain}`,
|
||||||
|
path: "/",
|
||||||
|
expires: -1,
|
||||||
|
httpOnly: false,
|
||||||
|
secure: true,
|
||||||
|
sameSite: "Lax" as const,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean) as Array<{
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
domain: string;
|
||||||
|
path: string;
|
||||||
|
expires: number;
|
||||||
|
httpOnly: boolean;
|
||||||
|
secure: boolean;
|
||||||
|
sameSite: "Lax" | "Strict" | "None";
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear a key from the pending-creation map once its promise settles, counting
|
||||||
|
// failures. Kept as a leaf helper so acquireBrowserContext stays under the
|
||||||
|
// function-length ceiling (#3368 PR7 metrics).
|
||||||
|
function settlePendingContext(key: string, failed: boolean): void {
|
||||||
|
if (failed) state.metrics.contextCreateFailures++;
|
||||||
|
state.pendingContexts.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function acquireBrowserContext(
|
||||||
|
key: string,
|
||||||
|
options: BrowserPoolContextOptions
|
||||||
|
): Promise<PooledContext> {
|
||||||
|
if (!isPoolEnabled()) {
|
||||||
|
throw new Error(
|
||||||
|
"browserPool: OMNIROUTE_BROWSER_POOL=off — context requested but pool is disabled"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const existing = state.contexts.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.lastUsed = Date.now();
|
||||||
|
state.lastActivity = Date.now();
|
||||||
|
state.metrics.contextsReused++;
|
||||||
|
resetIdleTimer();
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedup concurrent creations for the same key
|
||||||
|
const pending = state.pendingContexts.get(key);
|
||||||
|
if (pending) return pending;
|
||||||
|
|
||||||
|
const createPromise = (async (): Promise<PooledContext> => {
|
||||||
|
const proxy = injectedProxyResolver ? await injectedProxyResolver(key) : undefined;
|
||||||
|
const [browser] = await Promise.all([launchBrowser()]);
|
||||||
|
const isStealth = state.cloakLaunch !== null;
|
||||||
|
const context = await browser.newContext({
|
||||||
|
userAgent: options.userAgent || DEFAULT_USER_AGENT,
|
||||||
|
locale: options.locale || "en-US",
|
||||||
|
timezoneId: options.timezone || "America/New_York",
|
||||||
|
viewport: { width: 1280, height: 800 },
|
||||||
|
...(proxy ? { proxy } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.cookieString) {
|
||||||
|
const cookies = parseCookieString(options.cookieString, options.cookieDomain);
|
||||||
|
if (cookies.length > 0) {
|
||||||
|
await context.addCookies(cookies);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let warmupPage: Page | null = null;
|
||||||
|
if (options.warmupUrl) {
|
||||||
|
try {
|
||||||
|
warmupPage = await context.newPage();
|
||||||
|
await warmupPage.goto(options.warmupUrl, {
|
||||||
|
waitUntil: "domcontentloaded",
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
// Give the warmup a moment for the upstream's status/auth/country
|
||||||
|
// JSON endpoints to fire. Without this, the first chat request would
|
||||||
|
// pay the warmup cost on the hot path.
|
||||||
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
} catch (err) {
|
||||||
|
try {
|
||||||
|
await warmupPage?.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
warmupPage = null;
|
||||||
|
void err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard: if shutdownPool() ran while we were creating this context,
|
||||||
|
// the browser we obtained is now closed. Close our temp context and
|
||||||
|
// throw so the caller knows to retry.
|
||||||
|
if (state.browser !== browser) {
|
||||||
|
await context.close().catch(() => {});
|
||||||
|
if (warmupPage) {
|
||||||
|
await warmupPage.close().catch(() => {});
|
||||||
|
}
|
||||||
|
throw new Error("Pool shut down during context creation");
|
||||||
|
}
|
||||||
|
|
||||||
|
const pooled: PooledContext = {
|
||||||
|
id: key,
|
||||||
|
context,
|
||||||
|
warmupPage,
|
||||||
|
lastUsed: Date.now(),
|
||||||
|
isStealth,
|
||||||
|
};
|
||||||
|
state.contexts.set(key, pooled);
|
||||||
|
state.metrics.contextsCreated++;
|
||||||
|
state.lastActivity = Date.now();
|
||||||
|
resetIdleTimer();
|
||||||
|
startEvictTimer();
|
||||||
|
return pooled;
|
||||||
|
})();
|
||||||
|
|
||||||
|
state.pendingContexts.set(key, createPromise);
|
||||||
|
createPromise
|
||||||
|
.then(() => settlePendingContext(key, false))
|
||||||
|
.catch(() => settlePendingContext(key, true));
|
||||||
|
|
||||||
|
return createPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openPage(pooled: PooledContext): Promise<Page> {
|
||||||
|
return pooled.context.newPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function releaseBrowserContext(key: string): Promise<void> {
|
||||||
|
const pooled = state.contexts.get(key);
|
||||||
|
if (!pooled) return;
|
||||||
|
state.contexts.delete(key);
|
||||||
|
state.metrics.contextsReleased++;
|
||||||
|
try {
|
||||||
|
await pooled.context.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
if (state.contexts.size === 0) {
|
||||||
|
await shutdownPool("last-context-closed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shutdownPool(reason: string): Promise<void> {
|
||||||
|
state.metrics.shutdowns++;
|
||||||
|
state.metrics.lastShutdownReason = reason;
|
||||||
|
if (state.idleTimer) {
|
||||||
|
clearTimeout(state.idleTimer);
|
||||||
|
state.idleTimer = null;
|
||||||
|
}
|
||||||
|
if (state.evictTimer) {
|
||||||
|
clearInterval(state.evictTimer);
|
||||||
|
state.evictTimer = null;
|
||||||
|
}
|
||||||
|
state.pendingContexts.clear();
|
||||||
|
for (const [key, pooled] of state.contexts) {
|
||||||
|
try {
|
||||||
|
await pooled.context.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
state.contexts.delete(key);
|
||||||
|
}
|
||||||
|
if (state.browser) {
|
||||||
|
try {
|
||||||
|
await state.browser.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
state.browser = null;
|
||||||
|
}
|
||||||
|
state.lastActivity = Date.now();
|
||||||
|
// Avoid unused-parameter lint: log reason via debug if anyone hooks
|
||||||
|
// process.on('exit') and prints state.
|
||||||
|
void reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBrowserPoolStatus(): {
|
||||||
|
enabled: boolean;
|
||||||
|
contexts: number;
|
||||||
|
browserRunning: boolean;
|
||||||
|
stealthAvailable: boolean;
|
||||||
|
lastActivityAgoMs: number;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
enabled: isPoolEnabled(),
|
||||||
|
contexts: state.contexts.size,
|
||||||
|
browserRunning: state.browser !== null,
|
||||||
|
stealthAvailable: state.cloakLaunch !== null,
|
||||||
|
lastActivityAgoMs: state.lastActivity === 0 ? -1 : Date.now() - state.lastActivity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #3368 PR7 — browser-pool observability. Returns live status plus cumulative
|
||||||
|
* lifecycle telemetry (launches, context create/reuse/evict/release counts,
|
||||||
|
* failures, shutdowns). Surfaced via the omniroute_browser_pool_status MCP tool.
|
||||||
|
*/
|
||||||
|
export function getBrowserPoolMetrics(): {
|
||||||
|
status: ReturnType<typeof getBrowserPoolStatus>;
|
||||||
|
metrics: BrowserPoolMetrics;
|
||||||
|
} {
|
||||||
|
return { status: getBrowserPoolStatus(), metrics: { ...state.metrics } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-only: reset cumulative metrics so assertions start from a clean slate. */
|
||||||
|
export function __resetBrowserPoolMetricsForTest(): void {
|
||||||
|
state.metrics = createBrowserPoolMetrics();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readPageResponseBody(
|
||||||
|
response: import("playwright").Response
|
||||||
|
): Promise<{ status: number; headers: Record<string, string>; body: Buffer }> {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
for (const [name, value] of Object.entries(response.headers())) {
|
||||||
|
headers[name] = value;
|
||||||
|
}
|
||||||
|
const body = await response.body();
|
||||||
|
return { status: response.status(), headers, body: Buffer.from(body) };
|
||||||
|
}
|
||||||
73
packages/browser-pool/src/services/grokClearance.ts
Normal file
73
packages/browser-pool/src/services/grokClearance.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* grokClearance.ts — gated browser-backed cf_clearance acquisition for
|
||||||
|
* grok-web (#8019).
|
||||||
|
*
|
||||||
|
* grok.com sits behind Cloudflare Enterprise, which pins `cf_clearance` to
|
||||||
|
* the client's IP+TLS+UA fingerprint. Pure cookie-replay from
|
||||||
|
* `grokTlsClient.ts` (TLS-impersonating fetch) cannot forge a fresh
|
||||||
|
* clearance from a datacenter egress that Cloudflare has already flagged —
|
||||||
|
* only a real browser solving the challenge natively can mint one bound to
|
||||||
|
* that egress's own fingerprint.
|
||||||
|
*
|
||||||
|
* This module reuses the EXISTING provider-agnostic browser pool
|
||||||
|
* (`browserPool.ts`, already live for claude-web + duckduckgo-web) rather
|
||||||
|
* than adding a new Turnstile solver — `claudeTurnstileSolver.ts` is
|
||||||
|
* claude.ai-specific and does not apply here.
|
||||||
|
*
|
||||||
|
* Opt-in only: gated behind `OMNIROUTE_BROWSER_POOL` / `WEB_COOKIE_USE_BROWSER`
|
||||||
|
* (the same env gate already used by claude-web.ts / duckduckgo-web.ts).
|
||||||
|
* With the gate off, `acquireFreshGrokClearance` is never called — the
|
||||||
|
* executor stays on the Step-1 `cloudflare_challenge` classification.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { acquireBrowserContext } from "./browserPool.ts";
|
||||||
|
import type { PooledContext } from "../interfaces.ts";
|
||||||
|
|
||||||
|
const GROK_WARMUP_URL = "https://grok.com/";
|
||||||
|
const GROK_COOKIE_DOMAIN = ".grok.com";
|
||||||
|
const GROK_POOL_KEY = "grok-web";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the same opt-in gate as claude-web/duckduckgo-web
|
||||||
|
* (`WEB_COOKIE_USE_BROWSER` or `OMNIROUTE_BROWSER_POOL`). Off by default.
|
||||||
|
*/
|
||||||
|
export function shouldUseGrokBrowserBacked(): boolean {
|
||||||
|
const flag = process.env.WEB_COOKIE_USE_BROWSER;
|
||||||
|
if (flag === "1" || flag === "true" || flag === "on") return true;
|
||||||
|
const poolFlag = process.env.OMNIROUTE_BROWSER_POOL;
|
||||||
|
return poolFlag === "on" || poolFlag === "1" || poolFlag === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readCfClearanceFromContext(pooled: PooledContext): Promise<string | null> {
|
||||||
|
const cookies = await pooled.context.cookies(GROK_WARMUP_URL);
|
||||||
|
const match = cookies.find((c) => c.name === "cf_clearance");
|
||||||
|
return match?.value || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acquireViaPool(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const pooled = await acquireBrowserContext(GROK_POOL_KEY, {
|
||||||
|
cookieDomain: GROK_COOKIE_DOMAIN,
|
||||||
|
cookieString: null,
|
||||||
|
warmupUrl: GROK_WARMUP_URL,
|
||||||
|
});
|
||||||
|
return await readCfClearanceFromContext(pooled);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acquire a fresh `.grok.com` cf_clearance via the shared browser pool.
|
||||||
|
* Never throws — resolves to `null` on any failure so callers can fall
|
||||||
|
* through to the Cloudflare-challenge error rather than crash the request.
|
||||||
|
*/
|
||||||
|
export async function acquireFreshGrokClearance(
|
||||||
|
signal?: AbortSignal | null
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
return await acquireViaPool();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
packages/browser-pool/tsconfig.json
Normal file
17
packages/browser-pool/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"noEmit": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": false,
|
||||||
|
"lib": ["esnext"],
|
||||||
|
"types": ["node"],
|
||||||
|
"allowJs": false
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
packages:
|
packages:
|
||||||
|
- "packages/*"
|
||||||
- "open-sse"
|
- "open-sse"
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
"@parcel/watcher": true
|
"@parcel/watcher": true
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
|
|
||||||
<title id="title">Cheaper Inference gateway mark</title>
|
|
||||||
<rect x="1" y="1" width="62" height="62" fill="#050807" stroke="#31f889" stroke-width="2" />
|
|
||||||
<path d="M8 21V8h13M43 56h13V43" fill="none" stroke="#c8d2cc" stroke-width="2" />
|
|
||||||
<path d="M31 20c-8 0-13 5-13 12s5 12 13 12" fill="none" stroke="#f2f5f3" stroke-linecap="square" stroke-width="7" />
|
|
||||||
<path d="m33 25 10 7-10 7" fill="none" stroke="#31f889" stroke-linecap="square" stroke-linejoin="miter" stroke-width="5" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 586 B |
@@ -9,17 +9,13 @@
|
|||||||
// não resolve para um executor válido é um símbolo morto (roteia para fallback
|
// não resolve para um executor válido é um símbolo morto (roteia para fallback
|
||||||
// silencioso em vez de falhar).
|
// silencioso em vez de falhar).
|
||||||
//
|
//
|
||||||
// (2) COMBO STRATEGIES — o despacho DEVE tratar exatamente o conjunto canônico de
|
// (2) COMBO STRATEGIES — a cadeia de despacho `strategy === "..."` em
|
||||||
// ROUTING_STRATEGY_VALUES ∪ INTERNAL_ROUTING_STRATEGY_VALUES
|
// open-sse/services/combo.ts DEVE tratar exatamente o conjunto canônico de
|
||||||
// (src/shared/constants/routingStrategies.ts), exceto as estratégias-default
|
// ROUTING_STRATEGY_VALUES (src/shared/constants/routingStrategies.ts), exceto
|
||||||
// implícitas documentadas em IMPLICIT_DEFAULT_STRATEGIES (estratégias canônicas
|
// as estratégias-default implícitas documentadas em IMPLICIT_DEFAULT_STRATEGIES
|
||||||
// sem ramo de despacho próprio; caem no ordenamento padrão). Em vez de casar
|
// (estratégias canônicas sem NENHUMA referência `strategy === "..."`; caem no
|
||||||
// literais `strategy === "..."` por regex sobre a fonte, o conjunto tratado
|
// ordenamento padrão). Adicionar um valor canônico sem fiá-lo no despacho, ou
|
||||||
// (handled) vem de uma enumeração em runtime importada de
|
// fiar uma string de estratégia que não é canônica (inventada), falha aqui.
|
||||||
// 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
|
// (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:
|
// tradutores (após bootstrap) são congelados em KNOWN_TRANSLATOR_PAIRS. Catraca:
|
||||||
@@ -487,15 +483,21 @@ async function main(): Promise<void> {
|
|||||||
...(strategiesMod.ROUTING_STRATEGY_VALUES as readonly string[]),
|
...(strategiesMod.ROUTING_STRATEGY_VALUES as readonly string[]),
|
||||||
...(strategiesMod.INTERNAL_ROUTING_STRATEGY_VALUES as readonly string[]),
|
...(strategiesMod.INTERNAL_ROUTING_STRATEGY_VALUES as readonly string[]),
|
||||||
];
|
];
|
||||||
// G1: the handled set comes from a runtime-imported dispatch registry that imports the
|
// The combo dispatch was decomposed (Block J): the `strategy === "..."` branches
|
||||||
// actual strategy-ordering functions and enumerates which strategies they implement —
|
// now live across combo.ts + its strategy-ordering leaves, so scan all of them.
|
||||||
// NOT from regex-scanning `strategy === "..."` literals in source. The old regex broke
|
const comboDispatchFiles = [
|
||||||
// when the dispatch was decomposed (Block J / #3501) and will break again when R0.3
|
"open-sse/services/combo.ts",
|
||||||
// converts it to a registry; enumerating at runtime keeps the gate correct either way.
|
"open-sse/services/combo/applyStrategyOrdering.ts",
|
||||||
// Each entry in HANDLED_COMBO_STRATEGIES must stay in sync with a real dispatch branch.
|
"open-sse/services/combo/resolveAutoStrategy.ts",
|
||||||
const strategyDispatchMod =
|
// #3501: the fusion/pipeline dispatch branches moved here with the prelude
|
||||||
await import("@omniroute/open-sse/services/combo/strategyDispatch.ts");
|
// extraction; the `strategy === "..."` checks are unchanged, just relocated.
|
||||||
const handled = new Set(strategyDispatchMod.HANDLED_COMBO_STRATEGIES as readonly string[]);
|
"open-sse/services/combo/dispatchPrelude.ts",
|
||||||
|
"open-sse/services/combo/targetResolution.ts",
|
||||||
|
];
|
||||||
|
const comboSource = comboDispatchFiles
|
||||||
|
.map((rel) => readFileSync(resolvePath(REPO_ROOT, rel), "utf8"))
|
||||||
|
.join("\n");
|
||||||
|
const handled = extractHandledStrategies(comboSource);
|
||||||
|
|
||||||
// Stale-enforcement (6A.3): IMPLICIT_DEFAULT_STRATEGIES is a suppression allowlist —
|
// Stale-enforcement (6A.3): IMPLICIT_DEFAULT_STRATEGIES is a suppression allowlist —
|
||||||
// each entry exists ONLY to suppress a `canonicalNotHandled` violation (a canonical
|
// each entry exists ONLY to suppress a `canonicalNotHandled` violation (a canonical
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# test-scoped — run only unit tests impacted by your changes.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# npm run test:scoped # tests for changes vs HEAD~1
|
|
||||||
# npm run test:scoped -- --staged # tests for staged changes only
|
|
||||||
#
|
|
||||||
# This is the local DX companion to the CI TIA gate (#8084 D1). The CI version
|
|
||||||
# builds a full import-graph impact map; for local dev we use a fast heuristic:
|
|
||||||
# - Changed test files → run those directly
|
|
||||||
# - Changed source files → run tests that share the file's directory/name prefix
|
|
||||||
# - Hub files (tsconfig, package.json, etc.) → suggest full suite
|
|
||||||
#
|
|
||||||
# For the full TIA (import-graph based), use: npm run test:scoped:full
|
|
||||||
# (requires a pre-built impact map via: node scripts/quality/build-test-impact-map.mjs)
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
||||||
|
|
||||||
# ── 1. Determine changed files ───────────────────────────────────────────────
|
|
||||||
if [[ "${1:-}" == "--staged" ]]; then
|
|
||||||
CHANGED=$(git -C "$REPO_ROOT" diff --name-only --diff-filter=ACMR --cached)
|
|
||||||
else
|
|
||||||
CHANGED=$(git -C "$REPO_ROOT" diff --name-only --diff-filter=ACMR HEAD~1...HEAD 2>/dev/null || \
|
|
||||||
git -C "$REPO_ROOT" diff --name-only --diff-filter=ACMR)
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$CHANGED" ]; then
|
|
||||||
echo "[test:scoped] No changed files — nothing to test."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 2. Classify changes ──────────────────────────────────────────────────────
|
|
||||||
HUB_RE="(setupPolyfill|tsconfig|package\\.json|package-lock\\.json|\\.env|vitest\\.config|stryker\\.conf)"
|
|
||||||
TEST_FILES=()
|
|
||||||
SRC_FILES=()
|
|
||||||
HIT_HUB=false
|
|
||||||
|
|
||||||
while IFS= read -r f; do
|
|
||||||
[ -z "$f" ] && continue
|
|
||||||
if echo "$f" | grep -qE "$HUB_RE"; then
|
|
||||||
HIT_HUB=true
|
|
||||||
elif echo "$f" | grep -qE '^tests/unit/.*\.test\.(ts|mjs)$'; then
|
|
||||||
TEST_FILES+=("$f")
|
|
||||||
elif echo "$f" | grep -qE '^(src|open-sse)/'; then
|
|
||||||
SRC_FILES+=("$f")
|
|
||||||
fi
|
|
||||||
done <<< "$CHANGED"
|
|
||||||
|
|
||||||
# ── 3. Hub file changed → full suite ─────────────────────────────────────────
|
|
||||||
if [ "$HIT_HUB" = true ]; then
|
|
||||||
echo "[test:scoped] Hub file changed — run full suite: npm run test:unit"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 4. Collect tests to run ──────────────────────────────────────────────────
|
|
||||||
RUN_TESTS=()
|
|
||||||
|
|
||||||
# Direct test file changes always run
|
|
||||||
for tf in "${TEST_FILES[@]}"; do
|
|
||||||
RUN_TESTS+=("$tf")
|
|
||||||
done
|
|
||||||
|
|
||||||
# For source files, try the impact map first; fall back to heuristic
|
|
||||||
MAP_FILE="$REPO_ROOT/config/quality/test-impact-map.json"
|
|
||||||
if [ ${#SRC_FILES[@]} -gt 0 ] && [ -f "$MAP_FILE" ]; then
|
|
||||||
# Use the TIA selection with the impact map
|
|
||||||
SEL=$(printf '%s\n' "${SRC_FILES[@]}" | node "$REPO_ROOT/scripts/quality/select-impacted-tests.mjs" 2>/dev/null || echo "__RUN_ALL__")
|
|
||||||
if echo "$SEL" | grep -q "__RUN_ALL__"; then
|
|
||||||
echo "[test:scoped] Unmapped source change — run full suite: npm run test:unit"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
while IFS= read -r t; do
|
|
||||||
[ -n "$t" ] && RUN_TESTS+=("$t")
|
|
||||||
done <<< "$SEL"
|
|
||||||
elif [ ${#SRC_FILES[@]} -gt 0 ]; then
|
|
||||||
# No impact map — heuristic: suggest building it
|
|
||||||
echo "[test:scoped] No impact map found. Build it with: node scripts/quality/build-test-impact-map.mjs"
|
|
||||||
echo "[test:scoped] Or run the full suite: npm run test:unit"
|
|
||||||
echo ""
|
|
||||||
echo "[test:scoped] Changed source files:"
|
|
||||||
printf ' %s\n' "${SRC_FILES[@]}"
|
|
||||||
if [ ${#TEST_FILES[@]} -gt 0 ]; then
|
|
||||||
echo "[test:scoped] Running changed test files only..."
|
|
||||||
else
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Deduplicate
|
|
||||||
IFS=$'\n' SORTED=($(printf '%s\n' "${RUN_TESTS[@]}" | sort -u)); unset IFS
|
|
||||||
|
|
||||||
if [ ${#SORTED[@]} -eq 0 ]; then
|
|
||||||
echo "[test:scoped] No impacted tests — source changes don't map to any unit test."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[test:scoped] Running ${#SORTED[@]} impacted test(s)..."
|
|
||||||
|
|
||||||
# ── 5. Run selected tests ────────────────────────────────────────────────────
|
|
||||||
cd "$REPO_ROOT"
|
|
||||||
exec cross-env \
|
|
||||||
DISABLE_SQLITE_AUTO_BACKUP=true \
|
|
||||||
node --max-old-space-size=8192 \
|
|
||||||
--import tsx/esm \
|
|
||||||
--import ./open-sse/utils/setupPolyfill.ts \
|
|
||||||
--import ./tests/_setup/isolateDataDir.ts \
|
|
||||||
--test --test-force-exit --test-concurrency=4 \
|
|
||||||
"${SORTED[@]}"
|
|
||||||
@@ -27,17 +27,22 @@ export default function BootstrapBanner() {
|
|||||||
<span className="text-amber-500 dark:text-amber-400 text-base shrink-0 mt-0.5">⚠️</span>
|
<span className="text-amber-500 dark:text-amber-400 text-base shrink-0 mt-0.5">⚠️</span>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-semibold text-amber-900 dark:text-amber-300">
|
<p className="font-semibold text-amber-900 dark:text-amber-300">
|
||||||
{t("zeroConfigBannerTitle")}
|
Running in zero-config mode
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 text-amber-800/80 dark:text-amber-200/80">
|
<p className="mt-0.5 text-amber-800/80 dark:text-amber-200/80">
|
||||||
{t.rich("zeroConfigBannerBody", {
|
OmniRoute auto-generated secure encryption keys on first launch. They are persisted to{" "}
|
||||||
dataDir,
|
<code className="font-mono bg-amber-200/50 dark:bg-amber-500/20 px-1 rounded text-xs">
|
||||||
code: (chunks) => (
|
{dataDir}
|
||||||
<code className="font-mono bg-amber-200/50 dark:bg-amber-500/20 px-1 rounded text-xs">
|
</code>
|
||||||
{chunks}
|
. No action is required — your data is encrypted and safe. To use custom keys, add{" "}
|
||||||
</code>
|
<code className="font-mono bg-amber-200/50 dark:bg-amber-500/20 px-1 rounded text-xs">
|
||||||
),
|
JWT_SECRET
|
||||||
})}
|
</code>{" "}
|
||||||
|
and{" "}
|
||||||
|
<code className="font-mono bg-amber-200/50 dark:bg-amber-500/20 px-1 rounded text-xs">
|
||||||
|
STORAGE_ENCRYPTION_KEY
|
||||||
|
</code>{" "}
|
||||||
|
to that file.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -144,31 +144,31 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
|||||||
const cleanLatest = latest.replace(/^v/, "");
|
const cleanLatest = latest.replace(/^v/, "");
|
||||||
if (platform === "darwin") {
|
if (platform === "darwin") {
|
||||||
return {
|
return {
|
||||||
label: t("downloadDmg"),
|
label: "Download DMG (macOS)",
|
||||||
url: `https://github.com/diegosouzapw/OmniRoute/releases/download/v${cleanLatest}/OmniRoute-${cleanLatest}.dmg`,
|
url: `https://github.com/diegosouzapw/OmniRoute/releases/download/v${cleanLatest}/OmniRoute-${cleanLatest}.dmg`,
|
||||||
desc: t("downloadDmgDescription", { version: versionInfo?.current || "" }),
|
desc: `A new version of the OmniRoute desktop app is available. Please download and install the macOS DMG installer to update (current: v${versionInfo?.current || ""}).`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (platform === "win32") {
|
if (platform === "win32") {
|
||||||
return {
|
return {
|
||||||
label: t("downloadExe"),
|
label: "Download EXE (Windows)",
|
||||||
url: `https://github.com/diegosouzapw/OmniRoute/releases/download/v${cleanLatest}/OmniRoute.Setup.${cleanLatest}.exe`,
|
url: `https://github.com/diegosouzapw/OmniRoute/releases/download/v${cleanLatest}/OmniRoute.Setup.${cleanLatest}.exe`,
|
||||||
desc: t("downloadExeDescription", { version: versionInfo?.current || "" }),
|
desc: `A new version of the OmniRoute desktop app is available. Please download and install the Windows EXE installer to update (current: v${versionInfo?.current || ""}).`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (platform === "linux") {
|
if (platform === "linux") {
|
||||||
return {
|
return {
|
||||||
label: t("downloadAppImage"),
|
label: "Download AppImage (Linux)",
|
||||||
url: `https://github.com/diegosouzapw/OmniRoute/releases/download/v${cleanLatest}/OmniRoute-${cleanLatest}.AppImage`,
|
url: `https://github.com/diegosouzapw/OmniRoute/releases/download/v${cleanLatest}/OmniRoute-${cleanLatest}.AppImage`,
|
||||||
desc: t("downloadAppImageDescription", { version: versionInfo?.current || "" }),
|
desc: `A new version of the OmniRoute desktop app is available. Please download the Linux AppImage package to update (current: v${versionInfo?.current || ""}).`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
label: t("downloadUpdate"),
|
label: "Download Update",
|
||||||
url: `https://github.com/diegosouzapw/OmniRoute/releases/tag/v${cleanLatest}`,
|
url: `https://github.com/diegosouzapw/OmniRoute/releases/tag/v${cleanLatest}`,
|
||||||
desc: t("downloadUpdateDescription", { version: versionInfo?.current || "" }),
|
desc: `A new version of the OmniRoute desktop app is available. Please download the respective app format for your system to update (current: v${versionInfo?.current || ""}).`,
|
||||||
};
|
};
|
||||||
}, [platform, t, versionInfo?.latest, versionInfo?.current]);
|
}, [platform, versionInfo?.latest, versionInfo?.current]);
|
||||||
|
|
||||||
// Electron internal auto-updater state and listeners
|
// Electron internal auto-updater state and listeners
|
||||||
const [electronUpdateStatus, setElectronUpdateStatus] = useState<{
|
const [electronUpdateStatus, setElectronUpdateStatus] = useState<{
|
||||||
@@ -539,29 +539,29 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
|||||||
{
|
{
|
||||||
step: "install",
|
step: "install",
|
||||||
status: "done",
|
status: "done",
|
||||||
message: message || t("updateQueued", { version: targetVersion }),
|
message: message || `Queued update to v${targetVersion}.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
step: "rebuild",
|
step: "rebuild",
|
||||||
status: "running",
|
status: "running",
|
||||||
message: t("updateDockerRebuilding"),
|
message: "Docker image is rebuilding in the background.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
step: "restart",
|
step: "restart",
|
||||||
status: "pending",
|
status: "pending",
|
||||||
message: t("updateWaitingRestart"),
|
message: "Waiting for OmniRoute to restart with the new version.",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
step: "install",
|
step: "install",
|
||||||
status: "running",
|
status: "running",
|
||||||
message: message || t("updateInstalling", { version: targetVersion }),
|
message: message || `Installing v${targetVersion}.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
step: "restart",
|
step: "restart",
|
||||||
status: "pending",
|
status: "pending",
|
||||||
message: t("updateWaitingRestart"),
|
message: "Waiting for OmniRoute to restart with the new version.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -593,14 +593,14 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
|||||||
next = mergeUpdateStep(next, {
|
next = mergeUpdateStep(next, {
|
||||||
step: "complete",
|
step: "complete",
|
||||||
status: "done",
|
status: "done",
|
||||||
message: t("updateRunning", { version: targetVersion }),
|
message: `OmniRoute is now running v${targetVersion}.`,
|
||||||
});
|
});
|
||||||
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
setUpdating(false);
|
setUpdating(false);
|
||||||
setUpdatePhase("done");
|
setUpdatePhase("done");
|
||||||
notify.success(t("updateCompleted", { version: targetVersion }));
|
notify.success(`OmniRoute updated to v${targetVersion}.`);
|
||||||
await fetchData();
|
await fetchData();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -611,20 +611,20 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
|||||||
next = mergeUpdateStep(next, {
|
next = mergeUpdateStep(next, {
|
||||||
step: "rebuild",
|
step: "rebuild",
|
||||||
status: "running",
|
status: "running",
|
||||||
message: t("updateDockerStillRebuilding", { version: targetVersion }),
|
message: `Docker image is still rebuilding for v${targetVersion}.`,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
next = mergeUpdateStep(next, {
|
next = mergeUpdateStep(next, {
|
||||||
step: "install",
|
step: "install",
|
||||||
status: "running",
|
status: "running",
|
||||||
message: t("updateInstallingBackground", { version: targetVersion }),
|
message: `Installing v${targetVersion} in the background.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
next = mergeUpdateStep(next, {
|
next = mergeUpdateStep(next, {
|
||||||
step: "restart",
|
step: "restart",
|
||||||
status: "pending",
|
status: "pending",
|
||||||
message: t("updateWaitingVersion", { version: targetVersion }),
|
message: `Waiting for OmniRoute to come back on v${targetVersion}.`,
|
||||||
});
|
});
|
||||||
|
|
||||||
return next;
|
return next;
|
||||||
@@ -636,20 +636,20 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
|||||||
next = mergeUpdateStep(next, {
|
next = mergeUpdateStep(next, {
|
||||||
step: "rebuild",
|
step: "rebuild",
|
||||||
status: "running",
|
status: "running",
|
||||||
message: t("updateDockerStillInProgress"),
|
message: "Docker rebuild is still in progress.",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
next = mergeUpdateStep(next, {
|
next = mergeUpdateStep(next, {
|
||||||
step: "install",
|
step: "install",
|
||||||
status: "running",
|
status: "running",
|
||||||
message: t("updateInstallingBackground", { version: targetVersion }),
|
message: `Installing v${targetVersion} in the background.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
next = mergeUpdateStep(next, {
|
next = mergeUpdateStep(next, {
|
||||||
step: "restart",
|
step: "restart",
|
||||||
status: "running",
|
status: "running",
|
||||||
message: t("updateRestarting"),
|
message: "Service restart in progress. Waiting for OmniRoute to come back online...",
|
||||||
});
|
});
|
||||||
|
|
||||||
return next;
|
return next;
|
||||||
@@ -661,14 +661,14 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
|||||||
mergeUpdateStep(prev, {
|
mergeUpdateStep(prev, {
|
||||||
step: "error",
|
step: "error",
|
||||||
status: "failed",
|
status: "failed",
|
||||||
message: t("updateTimeout", { version: targetVersion }),
|
message: `Update started, but v${targetVersion} did not become available before timeout. Refresh the page or check server logs.`,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
setUpdating(false);
|
setUpdating(false);
|
||||||
setUpdatePhase("failed");
|
setUpdatePhase("failed");
|
||||||
notify.error(t("updateTimedOut", { version: targetVersion }));
|
notify.error(`Update to v${targetVersion} timed out.`);
|
||||||
},
|
},
|
||||||
[fetchData, t]
|
[fetchData]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUpdate = async () => {
|
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 →
|
// 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
|
// "Minified React error #31" crash ("Internal Server Error" screen), e.g. on
|
||||||
// the 403 from the loopback-only /api/system/version. Extract the string.
|
// the 403 from the loopback-only /api/system/version. Extract the string.
|
||||||
notify.error(extractApiErrorMessage(data, t("updateStartFailed")));
|
notify.error(extractApiErrorMessage(data, "Failed to start update."));
|
||||||
setUpdating(false);
|
setUpdating(false);
|
||||||
setUpdatePhase("idle");
|
setUpdatePhase("idle");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
notify.success(data.message || t("updateStarted"));
|
notify.success(data.message || "Update started.");
|
||||||
await pollBackgroundUpdate({
|
await pollBackgroundUpdate({
|
||||||
channel: data.channel || "docker-compose",
|
channel: data.channel || "docker-compose",
|
||||||
message: data.message || "",
|
message: data.message || "",
|
||||||
@@ -705,7 +705,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
|||||||
|
|
||||||
// SSE stream — read progress events
|
// SSE stream — read progress events
|
||||||
if (!res.body) {
|
if (!res.body) {
|
||||||
notify.error(t("noResponseStream"));
|
notify.error("No response stream received.");
|
||||||
setUpdating(false);
|
setUpdating(false);
|
||||||
setUpdatePhase("idle");
|
setUpdatePhase("idle");
|
||||||
return;
|
return;
|
||||||
@@ -735,10 +735,10 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
|||||||
if (event.step === "complete") {
|
if (event.step === "complete") {
|
||||||
setUpdatePhase("done");
|
setUpdatePhase("done");
|
||||||
setUpdating(false);
|
setUpdating(false);
|
||||||
notify.success(event.message || t("updateComplete"));
|
notify.success(event.message || "Update complete!");
|
||||||
} else if (event.step === "error") {
|
} else if (event.step === "error") {
|
||||||
setUpdatePhase("failed");
|
setUpdatePhase("failed");
|
||||||
notify.error(event.message || t("updateFailed"));
|
notify.error(event.message || "Update failed.");
|
||||||
setUpdating(false);
|
setUpdating(false);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -753,7 +753,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
|||||||
{
|
{
|
||||||
step: "error",
|
step: "error",
|
||||||
status: "failed",
|
status: "failed",
|
||||||
message: t("updateNetworkError"),
|
message: "Network error — connection lost during update.",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
setUpdating(false);
|
setUpdating(false);
|
||||||
@@ -769,11 +769,11 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [updatePhase]);
|
}, [updatePhase]);
|
||||||
const stepLabels: Record<string, string> = {
|
const stepLabels: Record<string, string> = {
|
||||||
install: t("stepInstallPackage"),
|
install: "Install Package",
|
||||||
rebuild: t("stepRebuildNativeModules"),
|
rebuild: "Rebuild Native Modules",
|
||||||
restart: t("stepRestartService"),
|
restart: "Restart Service",
|
||||||
complete: t("stepComplete"),
|
complete: "Complete",
|
||||||
error: t("stepError"),
|
error: "Error",
|
||||||
};
|
};
|
||||||
const showUpdateOverlay = updatePhase !== "idle";
|
const showUpdateOverlay = updatePhase !== "idle";
|
||||||
|
|
||||||
@@ -801,17 +801,17 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-bold">
|
<h3 className="text-lg font-bold">
|
||||||
{updatePhase === "done"
|
{updatePhase === "done"
|
||||||
? t("updateCompleteTitle")
|
? "Update Complete!"
|
||||||
: updatePhase === "failed"
|
: updatePhase === "failed"
|
||||||
? t("updateFailedTitle")
|
? "Update Failed"
|
||||||
: t("updatingTitle")}
|
: "Updating OmniRoute..."}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-text-muted mt-0.5">
|
<p className="text-xs text-text-muted mt-0.5">
|
||||||
{updatePhase === "done"
|
{updatePhase === "done"
|
||||||
? t("reloadNotice")
|
? "The page will reload automatically in a few seconds."
|
||||||
: updatePhase === "failed"
|
: updatePhase === "failed"
|
||||||
? t("retryNotice")
|
? "Please try again or update manually via the CLI."
|
||||||
: t("restartNotice")}
|
: "Do not close this page. The system will restart automatically."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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">
|
<p className="text-sm font-semibold text-green-500 flex items-center gap-2">
|
||||||
<span className="material-symbols-outlined text-[18px]">check_circle</span>
|
<span className="material-symbols-outlined text-[18px]">check_circle</span>
|
||||||
{updateSteps.find((s) => s.step === "complete")?.message || t("updateComplete")}
|
{updateSteps.find((s) => s.step === "complete")?.message || "Update complete!"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-text-muted mt-1">{t("reloadingPageAutomatically")}</p>
|
<p className="text-xs text-text-muted mt-1">{t("reloadingPageAutomatically")}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -891,11 +891,11 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
|||||||
if (updatePhase === "done") globalThis.window.location.reload();
|
if (updatePhase === "done") globalThis.window.location.reload();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{updatePhase === "done" ? t("reloadNow") : t("closeUpdate")}
|
{updatePhase === "done" ? "Reload Now" : "Close"}
|
||||||
</Button>
|
</Button>
|
||||||
{updatePhase === "failed" && (
|
{updatePhase === "failed" && (
|
||||||
<Button size="sm" variant="secondary" fullWidth onClick={handleUpdate}>
|
<Button size="sm" variant="secondary" fullWidth onClick={handleUpdate}>
|
||||||
{t("retryUpdate")}
|
Retry
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -917,32 +917,30 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
|||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-sm">
|
<p className="font-semibold text-sm">
|
||||||
{t("updateAvailableTitle", {
|
Update Available: v{versionInfo.latest} {isElectron && "(Desktop App)"}
|
||||||
version: versionInfo.latest,
|
|
||||||
desktop: isElectron ? ` ${t("desktopAppLabel")}` : "",
|
|
||||||
})}
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs opacity-80 mt-0.5">
|
<p className="text-xs opacity-80 mt-0.5">
|
||||||
{isElectron ? (
|
{isElectron ? (
|
||||||
<>
|
<>
|
||||||
{electronUpdateStatus.status === "checking" && t("checkingForUpdates")}
|
{electronUpdateStatus.status === "checking" && "Checking for updates..."}
|
||||||
{electronUpdateStatus.status === "available" &&
|
{electronUpdateStatus.status === "available" &&
|
||||||
t("versionAvailableForDownload", { version: versionInfo.latest })}
|
`Version v${versionInfo.latest} is available for download.`}
|
||||||
{electronUpdateStatus.status === "downloading" &&
|
{electronUpdateStatus.status === "downloading" &&
|
||||||
t("downloadingUpdate", { percent: electronUpdateStatus.percent || 0 })}
|
`Downloading update... ${electronUpdateStatus.percent || 0}% complete.`}
|
||||||
{electronUpdateStatus.status === "downloaded" && t("updateDownloaded")}
|
{electronUpdateStatus.status === "downloaded" &&
|
||||||
|
"Update downloaded successfully! Click Restart & Install to apply."}
|
||||||
{electronUpdateStatus.status === "error" &&
|
{electronUpdateStatus.status === "error" &&
|
||||||
t("autoUpdateFailed", {
|
`Auto-update failed: ${electronUpdateStatus.message || "Unknown error"}.`}
|
||||||
reason: electronUpdateStatus.message || t("unknownUpdateError"),
|
|
||||||
})}
|
|
||||||
{(electronUpdateStatus.status === "idle" ||
|
{(electronUpdateStatus.status === "idle" ||
|
||||||
electronUpdateStatus.status === "not-available") &&
|
electronUpdateStatus.status === "not-available") &&
|
||||||
t("versionAvailableDesktop", { version: versionInfo.latest })}
|
`Version v${versionInfo.latest} is available for the desktop app.`}
|
||||||
</>
|
</>
|
||||||
) : versionInfo.autoUpdateSupported ? (
|
) : versionInfo.autoUpdateSupported ? (
|
||||||
t("updateAvailableDesc")
|
t("updateAvailableDesc") ||
|
||||||
|
`You are currently using v${versionInfo.current}. Update to access the latest features and bug fixes.`
|
||||||
) : (
|
) : (
|
||||||
versionInfo.autoUpdateError || t("manualUpdateRequired")
|
versionInfo.autoUpdateError ||
|
||||||
|
"Manual update required for this installation type."
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -956,7 +954,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
|||||||
onClick={() => globalThis.window.electronAPI?.downloadUpdate()}
|
onClick={() => globalThis.window.electronAPI?.downloadUpdate()}
|
||||||
className="font-semibold"
|
className="font-semibold"
|
||||||
>
|
>
|
||||||
{t("downloadUpdate")}
|
Download Update
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{electronUpdateStatus.status === "downloading" && (
|
{electronUpdateStatus.status === "downloading" && (
|
||||||
@@ -975,7 +973,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
|||||||
onClick={() => globalThis.window.electronAPI?.installUpdate()}
|
onClick={() => globalThis.window.electronAPI?.installUpdate()}
|
||||||
className="font-semibold animate-pulse"
|
className="font-semibold animate-pulse"
|
||||||
>
|
>
|
||||||
{t("restartAndInstall")}
|
Restart & Install
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{(electronUpdateStatus.status === "error" ||
|
{(electronUpdateStatus.status === "error" ||
|
||||||
@@ -991,7 +989,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
|||||||
}}
|
}}
|
||||||
className="font-semibold"
|
className="font-semibold"
|
||||||
>
|
>
|
||||||
{t("checkForUpdate")}
|
Check for Update
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1003,7 +1001,9 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
|||||||
className="ml-4 shrink-0 font-semibold"
|
className="ml-4 shrink-0 font-semibold"
|
||||||
title={versionInfo.autoUpdateError || ""}
|
title={versionInfo.autoUpdateError || ""}
|
||||||
>
|
>
|
||||||
{versionInfo.autoUpdateSupported ? t("updateNow") : t("manualUpdate")}
|
{versionInfo.autoUpdateSupported
|
||||||
|
? t("updateNow") || "Update Now"
|
||||||
|
: "Manual Update"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1015,7 +1015,9 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
|||||||
electronUpdateStatus.status === "available" ||
|
electronUpdateStatus.status === "available" ||
|
||||||
electronUpdateStatus.status === "not-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">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between border-t border-primary/20 mt-2 pt-3 gap-2">
|
||||||
<p className="text-xs opacity-75">{t("directDownloadHint")}</p>
|
<p className="text-xs opacity-75">
|
||||||
|
Or download the respective installer format directly:
|
||||||
|
</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1027,7 +1029,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
|||||||
}
|
}
|
||||||
className="font-semibold text-xs py-1"
|
className="font-semibold text-xs py-1"
|
||||||
>
|
>
|
||||||
{t("releaseNotes")}
|
Release Notes
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1065,7 +1067,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
|||||||
rel="noopener noreferrer"
|
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"
|
className="ml-4 inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-border bg-bg px-4 py-2 text-xs font-semibold text-text-main transition-colors hover:border-primary/30 hover:text-primary"
|
||||||
>
|
>
|
||||||
{versionInfo.news.linkLabel || t("readMore")}
|
{versionInfo.news.linkLabel || "Ler Mais"}
|
||||||
<span className="material-symbols-outlined text-[14px]">arrow_forward</span>
|
<span className="material-symbols-outlined text-[14px]">arrow_forward</span>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
@@ -1212,7 +1214,7 @@ function ProviderOverviewCard({
|
|||||||
item.errors > 0 ? "text-red-500" : item.connected > 0 ? "text-green-500" : "text-text-muted";
|
item.errors > 0 ? "text-red-500" : item.connected > 0 ? "text-green-500" : "text-text-muted";
|
||||||
|
|
||||||
const authTypeConfig = {
|
const authTypeConfig = {
|
||||||
"no-auth": { color: "bg-stone-500", label: t("noAuthLabel") },
|
"no-auth": { color: "bg-stone-500", label: "No Auth" },
|
||||||
free: { color: "bg-green-500", label: tc("free") },
|
free: { color: "bg-green-500", label: tc("free") },
|
||||||
oauth: { color: "bg-blue-500", label: t("oauthLabel") },
|
oauth: { color: "bg-blue-500", label: t("oauthLabel") },
|
||||||
apikey: { color: "bg-amber-500", label: t("apiKeyLabel") },
|
apikey: { color: "bg-amber-500", label: t("apiKeyLabel") },
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ export default function AutoRoutingAnalyticsTab() {
|
|||||||
if (!stats) {
|
if (!stats) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="text-center py-8 text-text-muted">{t("autoRoutingNoDataAvailable")}</div>
|
<div className="text-center py-8 text-text-muted">
|
||||||
|
No auto-routing analytics available. Make requests using the auto/ prefix to see metrics.
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -108,9 +110,7 @@ export default function AutoRoutingAnalyticsTab() {
|
|||||||
const percentage = stats.totalRequests > 0 ? (count / stats.totalRequests) * 100 : 0;
|
const percentage = stats.totalRequests > 0 ? (count / stats.totalRequests) * 100 : 0;
|
||||||
return (
|
return (
|
||||||
<div key={variant} className="flex items-center gap-3">
|
<div key={variant} className="flex items-center gap-3">
|
||||||
<div className="w-32 text-sm font-medium capitalize">
|
<div className="w-32 text-sm font-medium capitalize">{variant || "default"}</div>
|
||||||
{variant || t("defaultVariantLabel")}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 h-3 bg-border rounded-full overflow-hidden">
|
<div className="flex-1 h-3 bg-border rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-indigo-500 rounded-full transition-all"
|
className="h-full bg-indigo-500 rounded-full transition-all"
|
||||||
@@ -133,9 +133,9 @@ export default function AutoRoutingAnalyticsTab() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border">
|
<tr className="border-b border-border">
|
||||||
<th className="text-left py-2 px-3 font-medium">{t("chartProvider")}</th>
|
<th className="text-left py-2 px-3 font-medium">Provider</th>
|
||||||
<th className="text-right py-2 px-3 font-medium">{t("chartRequests")}</th>
|
<th className="text-right py-2 px-3 font-medium">Requests</th>
|
||||||
<th className="text-right py-2 px-3 font-medium">{t("chartShare")}</th>
|
<th className="text-right py-2 px-3 font-medium">Share</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -50,13 +50,11 @@ function ProviderBar({
|
|||||||
count,
|
count,
|
||||||
total,
|
total,
|
||||||
costUsd,
|
costUsd,
|
||||||
queriesLabel,
|
|
||||||
}: {
|
}: {
|
||||||
provider: string;
|
provider: string;
|
||||||
count: number;
|
count: number;
|
||||||
total: number;
|
total: number;
|
||||||
costUsd: number;
|
costUsd: number;
|
||||||
queriesLabel: string;
|
|
||||||
}) {
|
}) {
|
||||||
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
|
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
|
||||||
return (
|
return (
|
||||||
@@ -64,7 +62,7 @@ function ProviderBar({
|
|||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="font-medium text-text">{provider}</span>
|
<span className="font-medium text-text">{provider}</span>
|
||||||
<span className="text-text-muted">
|
<span className="text-text-muted">
|
||||||
{count} {queriesLabel} · ${costUsd.toFixed(4)}
|
{count} queries · ${costUsd.toFixed(4)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 rounded-full bg-bg-muted overflow-hidden">
|
<div className="h-2 rounded-full bg-bg-muted overflow-hidden">
|
||||||
@@ -101,7 +99,7 @@ export default function SearchAnalyticsTab() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-16 text-text-muted">
|
<div className="flex items-center justify-center py-16 text-text-muted">
|
||||||
<span className="material-symbols-outlined animate-spin mr-2">progress_activity</span>
|
<span className="material-symbols-outlined animate-spin mr-2">progress_activity</span>
|
||||||
{t("searchAnalyticsLoading")}
|
Loading search analytics…
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -110,11 +108,9 @@ export default function SearchAnalyticsTab() {
|
|||||||
return (
|
return (
|
||||||
<div className="card p-6 text-center text-text-muted">
|
<div className="card p-6 text-center text-text-muted">
|
||||||
<span className="material-symbols-outlined text-[32px] mb-2 block">search_off</span>
|
<span className="material-symbols-outlined text-[32px] mb-2 block">search_off</span>
|
||||||
{error || t("searchAnalyticsNoData")}
|
{error || "No search data available yet."}
|
||||||
<p className="text-xs mt-2">
|
<p className="text-xs mt-2">
|
||||||
{t.rich("searchAnalyticsNoDataDescription", {
|
Search requests will appear here after the first search via /v1/search.
|
||||||
code: (chunks) => <code className="bg-bg-muted px-1 rounded">{chunks}</code>,
|
|
||||||
})}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -130,29 +126,25 @@ export default function SearchAnalyticsTab() {
|
|||||||
icon="manage_search"
|
icon="manage_search"
|
||||||
label={t("searchAnalyticsTotalSearches")}
|
label={t("searchAnalyticsTotalSearches")}
|
||||||
value={stats.total.toLocaleString()}
|
value={stats.total.toLocaleString()}
|
||||||
sub={t("searchAnalyticsToday", { count: stats.today })}
|
sub={`${stats.today} today`}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon="cached"
|
icon="cached"
|
||||||
label={t("searchAnalyticsCacheHitRate")}
|
label={t("searchAnalyticsCacheHitRate")}
|
||||||
value={`${stats.cacheHitRate}%`}
|
value={`${stats.cacheHitRate}%`}
|
||||||
sub={t("searchAnalyticsCachedRequests", { count: stats.cached })}
|
sub={`${stats.cached} cached requests`}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon="attach_money"
|
icon="attach_money"
|
||||||
label={t("searchAnalyticsTotalCost")}
|
label={t("searchAnalyticsTotalCost")}
|
||||||
value={`$${stats.totalCostUsd.toFixed(4)}`}
|
value={`$${stats.totalCostUsd.toFixed(4)}`}
|
||||||
sub={t("searchAnalyticsApiCosts")}
|
sub="search API costs"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon="timer"
|
icon="timer"
|
||||||
label={t("searchAnalyticsAvgResponse")}
|
label={t("searchAnalyticsAvgResponse")}
|
||||||
value={`${stats.avgDurationMs}ms`}
|
value={`${stats.avgDurationMs}ms`}
|
||||||
sub={
|
sub={stats.errors > 0 ? `${stats.errors} errors` : "No errors"}
|
||||||
stats.errors > 0
|
|
||||||
? t("searchAnalyticsErrors", { count: stats.errors })
|
|
||||||
: t("searchAnalyticsNoErrors")
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -161,7 +153,7 @@ export default function SearchAnalyticsTab() {
|
|||||||
<div className="card p-5">
|
<div className="card p-5">
|
||||||
<h3 className="font-semibold text-text mb-4 flex items-center gap-2">
|
<h3 className="font-semibold text-text mb-4 flex items-center gap-2">
|
||||||
<span className="material-symbols-outlined text-primary text-[20px]">hub</span>
|
<span className="material-symbols-outlined text-primary text-[20px]">hub</span>
|
||||||
{t("searchAnalyticsProviderBreakdown")}
|
Provider Breakdown
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{providers.map(([prov, data]) => (
|
{providers.map(([prov, data]) => (
|
||||||
@@ -171,7 +163,6 @@ export default function SearchAnalyticsTab() {
|
|||||||
count={data.count}
|
count={data.count}
|
||||||
total={stats.total}
|
total={stats.total}
|
||||||
costUsd={data.costUsd}
|
costUsd={data.costUsd}
|
||||||
queriesLabel={t("searchAnalyticsQueries")}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -186,9 +177,8 @@ export default function SearchAnalyticsTab() {
|
|||||||
</span>
|
</span>
|
||||||
<p className="font-medium text-text">{t("searchAnalyticsNoSearchesYet")}</p>
|
<p className="font-medium text-text">{t("searchAnalyticsNoSearchesYet")}</p>
|
||||||
<p className="text-sm mt-1">
|
<p className="text-sm mt-1">
|
||||||
{t.rich("searchAnalyticsEmptyDescription", {
|
Use <code className="bg-bg-muted px-1 rounded">POST /v1/search</code> to start routing
|
||||||
code: (chunks) => <code className="bg-bg-muted px-1 rounded">{chunks}</code>,
|
web searches.
|
||||||
})}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -199,9 +189,8 @@ export default function SearchAnalyticsTab() {
|
|||||||
check_circle
|
check_circle
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{t.rich("searchAnalyticsFreeTier", {
|
<strong>Free tier available:</strong> Serper (2,500/mo), Brave (2,000/mo), Exa (1,000/mo),
|
||||||
strong: (chunks) => <strong>{chunks}</strong>,
|
Tavily (1,000/mo) — total 6,500+ free searches/month with automatic failover.
|
||||||
})}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -122,23 +122,23 @@ export default function McpAuditTab() {
|
|||||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
label: t("mcpMetricCalls24h"),
|
label: "Calls (24h)",
|
||||||
value: stats.totalCalls.toLocaleString(),
|
value: stats.totalCalls.toLocaleString(),
|
||||||
icon: "terminal",
|
icon: "terminal",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("mcpMetricSuccessRate"),
|
label: "Success rate",
|
||||||
value: `${Math.round(stats.successRate * 100)}%`,
|
value: `${Math.round(stats.successRate * 100)}%`,
|
||||||
icon: "check_circle",
|
icon: "check_circle",
|
||||||
highlight: stats.successRate >= 0.9,
|
highlight: stats.successRate >= 0.9,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("mcpMetricAvgDuration"),
|
label: "Avg duration",
|
||||||
value: `${Math.round(stats.avgDurationMs)}ms`,
|
value: `${Math.round(stats.avgDurationMs)}ms`,
|
||||||
icon: "timer",
|
icon: "timer",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("mcpMetricTopTool"),
|
label: "Top tool",
|
||||||
value: stats.topTools[0]?.tool ?? "—",
|
value: stats.topTools[0]?.tool ?? "—",
|
||||||
icon: "star",
|
icon: "star",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import { useEffect } from "react";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useBatchActions } from "./components/useBatchActions";
|
import { useBatchActions } from "./components/useBatchActions";
|
||||||
|
|
||||||
type BatchTranslator = ReturnType<typeof useTranslations>;
|
function relativeTime(ts: number): string {
|
||||||
|
|
||||||
function relativeTime(ts: number, t: BatchTranslator): string {
|
|
||||||
const diffMs = Date.now() - ts * 1000;
|
const diffMs = Date.now() - ts * 1000;
|
||||||
const isFuture = diffMs < 0;
|
const isFuture = diffMs < 0;
|
||||||
const absDiffMs = Math.abs(diffMs);
|
const absDiffMs = Math.abs(diffMs);
|
||||||
@@ -24,9 +22,8 @@ function relativeTime(ts: number, t: BatchTranslator): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return isFuture
|
if (isFuture) return `in ${res}`;
|
||||||
? t("batchRelativeTimeIn", { value: res })
|
return `${res} ago`;
|
||||||
: t("batchRelativeTimeAgo", { value: res });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BatchRecord {
|
interface BatchRecord {
|
||||||
@@ -96,22 +93,6 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
expired_with_failures: "expired (partial)",
|
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 {
|
function effectiveStatus(batch: BatchRecord): string {
|
||||||
const hasFailed = (batch.requestCountsFailed ?? 0) > 0;
|
const hasFailed = (batch.requestCountsFailed ?? 0) > 0;
|
||||||
if (!hasFailed) return batch.status;
|
if (!hasFailed) return batch.status;
|
||||||
@@ -125,12 +106,10 @@ function effectiveStatus(batch: BatchRecord): string {
|
|||||||
return map[batch.status] ?? batch.status;
|
return map[batch.status] ?? batch.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ batch, t }: { batch: BatchRecord; t: BatchTranslator }) {
|
function StatusBadge({ batch }: { batch: BatchRecord }) {
|
||||||
const key = effectiveStatus(batch);
|
const key = effectiveStatus(batch);
|
||||||
const cls = STATUS_STYLES[key] ?? "bg-gray-500/15 text-gray-400 border-gray-500/25";
|
const cls = STATUS_STYLES[key] ?? "bg-gray-500/15 text-gray-400 border-gray-500/25";
|
||||||
const label = STATUS_TRANSLATION_KEYS[key]
|
const label = STATUS_LABELS[key] ?? key.replace(/_/g, " ");
|
||||||
? t(STATUS_TRANSLATION_KEYS[key])
|
|
||||||
: (STATUS_LABELS[key] ?? key.replace(/_/g, " "));
|
|
||||||
return (
|
return (
|
||||||
<span className={`inline-block px-2 py-0.5 rounded-md text-xs font-medium border ${cls}`}>
|
<span className={`inline-block px-2 py-0.5 rounded-md text-xs font-medium border ${cls}`}>
|
||||||
{label}
|
{label}
|
||||||
@@ -162,24 +141,12 @@ function formatTs(ts: number | null | undefined): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BatchDetailModal({
|
export default function BatchDetailModal({ batch, files, onClose, onActionDone }: BatchDetailModalProps) {
|
||||||
batch,
|
|
||||||
files,
|
|
||||||
onClose,
|
|
||||||
onActionDone,
|
|
||||||
}: BatchDetailModalProps) {
|
|
||||||
const t = useTranslations("common");
|
const t = useTranslations("common");
|
||||||
|
|
||||||
// ── Action hook (F7) ─────────────────────────────────────────────────────────
|
// ── Action hook (F7) ─────────────────────────────────────────────────────────
|
||||||
const {
|
const { cancelling, retrying, error: actionError, cancel, retry, downloadHrefOutput, downloadHrefErrors } =
|
||||||
cancelling,
|
useBatchActions({ onRefresh: onActionDone, t });
|
||||||
retrying,
|
|
||||||
error: actionError,
|
|
||||||
cancel,
|
|
||||||
retry,
|
|
||||||
downloadHrefOutput,
|
|
||||||
downloadHrefErrors,
|
|
||||||
} = useBatchActions({ onRefresh: onActionDone, t });
|
|
||||||
|
|
||||||
// ── Status flags ──────────────────────────────────────────────────────────────
|
// ── Status flags ──────────────────────────────────────────────────────────────
|
||||||
const isTerminal = ["completed", "failed", "cancelled", "expired"].includes(batch.status);
|
const isTerminal = ["completed", "failed", "cancelled", "expired"].includes(batch.status);
|
||||||
@@ -228,11 +195,8 @@ export default function BatchDetailModal({
|
|||||||
pending_actions
|
pending_actions
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h2
|
<h2 id="batch-detail-modal-title" className="text-base font-semibold text-[var(--color-text-main)]">
|
||||||
id="batch-detail-modal-title"
|
Batch Details
|
||||||
className="text-base font-semibold text-[var(--color-text-main)]"
|
|
||||||
>
|
|
||||||
{t("batchDetailsTitle")}
|
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
<p className="text-xs text-[var(--color-text-muted)] font-mono">{batch.id}</p>
|
<p className="text-xs text-[var(--color-text-muted)] font-mono">{batch.id}</p>
|
||||||
@@ -263,18 +227,16 @@ export default function BatchDetailModal({
|
|||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
|
<span className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
|
||||||
{t("status")}
|
Status
|
||||||
</span>
|
</span>
|
||||||
<StatusBadge batch={batch} t={t} />
|
<StatusBadge batch={batch} />
|
||||||
</div>
|
</div>
|
||||||
<Field label={t("batchDetailEndpoint")} value={batch.endpoint} />
|
<Field label={t("batchDetailEndpoint")} value={batch.endpoint} />
|
||||||
{batch.model && <Field label={t("batchDetailModel")} value={batch.model} />}
|
{batch.model && <Field label={t("batchDetailModel")} value={batch.model} />}
|
||||||
<Field label={t("batchDetailWindow")} value={batch.completionWindow} />
|
<Field label={t("batchDetailWindow")} value={batch.completionWindow} />
|
||||||
<Field
|
<Field
|
||||||
label={t("batchDetailCreated")}
|
label={t("batchDetailCreated")}
|
||||||
value={
|
value={<span title={formatTs(batch.createdAt)}>{relativeTime(batch.createdAt)}</span>}
|
||||||
<span title={formatTs(batch.createdAt)}>{relativeTime(batch.createdAt, t)}</span>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -283,7 +245,7 @@ export default function BatchDetailModal({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between text-xs">
|
<div className="flex items-center justify-between text-xs">
|
||||||
<span className="text-[var(--color-text-muted)] uppercase tracking-wider font-medium">
|
<span className="text-[var(--color-text-muted)] uppercase tracking-wider font-medium">
|
||||||
{t("batchProgress")}
|
Progress
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[var(--color-text-muted)]">
|
<span className="text-[var(--color-text-muted)]">
|
||||||
{completed} / {total} ({pct}%)
|
{completed} / {total} ({pct}%)
|
||||||
@@ -300,9 +262,17 @@ export default function BatchDetailModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 text-xs text-[var(--color-text-muted)]">
|
<div className="flex gap-4 text-xs text-[var(--color-text-muted)]">
|
||||||
<span>{t("batchCompletedCount", { count: completed })}</span>
|
<span>
|
||||||
{failed > 0 && <span>{t("batchFailedCount", { count: failed })}</span>}
|
<span className="text-emerald-400 font-medium">{completed}</span> completed
|
||||||
<span>{t("batchPendingCount", { count: total - completed - failed })}</span>
|
</span>
|
||||||
|
{failed > 0 && (
|
||||||
|
<span>
|
||||||
|
<span className="text-red-400 font-medium">{failed}</span> failed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
<span className="font-medium">{total - completed - failed}</span> pending
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -310,19 +280,19 @@ export default function BatchDetailModal({
|
|||||||
{/* Timestamps */}
|
{/* Timestamps */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)] mb-3">
|
<h3 className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)] mb-3">
|
||||||
{t("batchTimeline")}
|
Timeline
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 text-sm">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 text-sm">
|
||||||
{[
|
{[
|
||||||
{ label: t("batchDetailCreated"), ts: batch.createdAt },
|
{ label: "Created", ts: batch.createdAt },
|
||||||
{ label: t("batchTimelineInProgress"), ts: batch.inProgressAt },
|
{ label: "In Progress", ts: batch.inProgressAt },
|
||||||
{ label: t("batchTimelineFinalizing"), ts: batch.finalizingAt },
|
{ label: "Finalizing", ts: batch.finalizingAt },
|
||||||
{ label: t("batchTimelineCompleted"), ts: batch.completedAt },
|
{ label: "Completed", ts: batch.completedAt },
|
||||||
{ label: t("batchTimelineFailed"), ts: batch.failedAt },
|
{ label: "Failed", ts: batch.failedAt },
|
||||||
{ label: t("batchTimelineExpires"), ts: batch.expiresAt },
|
{ label: "Expires", ts: batch.expiresAt },
|
||||||
{ label: t("batchTimelineExpired"), ts: batch.expiredAt },
|
{ label: "Expired", ts: batch.expiredAt },
|
||||||
{ label: t("batchTimelineCancelling"), ts: batch.cancellingAt },
|
{ label: "Cancelling", ts: batch.cancellingAt },
|
||||||
{ label: t("batchTimelineCancelled"), ts: batch.cancelledAt },
|
{ label: "Cancelled", ts: batch.cancelledAt },
|
||||||
]
|
]
|
||||||
.filter((t) => t.ts)
|
.filter((t) => t.ts)
|
||||||
.map(({ label, ts }) => (
|
.map(({ label, ts }) => (
|
||||||
@@ -345,21 +315,9 @@ export default function BatchDetailModal({
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{[
|
{[
|
||||||
{
|
{ role: "Input", fileId: batch.inputFileId, record: inputFile },
|
||||||
role: t("filesListUsedByRoleInput"),
|
{ role: "Output", fileId: batch.outputFileId, record: outputFile },
|
||||||
fileId: batch.inputFileId,
|
{ role: "Errors", fileId: batch.errorFileId, record: errorFile },
|
||||||
record: inputFile,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: t("filesListUsedByRoleOutput"),
|
|
||||||
fileId: batch.outputFileId,
|
|
||||||
record: outputFile,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: t("filesListUsedByRoleError"),
|
|
||||||
fileId: batch.errorFileId,
|
|
||||||
record: errorFile,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
.filter((f) => f.fileId)
|
.filter((f) => f.fileId)
|
||||||
.map(({ role, fileId, record }) => (
|
.map(({ role, fileId, record }) => (
|
||||||
@@ -392,7 +350,7 @@ export default function BatchDetailModal({
|
|||||||
className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-muted)] hover:text-[var(--color-text-main)] transition-colors"
|
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>
|
<span className="material-symbols-outlined text-[13px]">download</span>
|
||||||
{t("filesListDownload")}
|
Download
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -404,7 +362,7 @@ export default function BatchDetailModal({
|
|||||||
{batch.usage && (
|
{batch.usage && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)] mb-3">
|
<h3 className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)] mb-3">
|
||||||
{t("batchTokenUsage")}
|
Token Usage
|
||||||
</h3>
|
</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">
|
<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)}
|
{JSON.stringify(batch.usage, null, 2)}
|
||||||
@@ -416,7 +374,7 @@ export default function BatchDetailModal({
|
|||||||
{batch.errors && (
|
{batch.errors && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-[11px] uppercase tracking-wider font-medium text-red-400 mb-3">
|
<h3 className="text-[11px] uppercase tracking-wider font-medium text-red-400 mb-3">
|
||||||
{t("errors")}
|
Errors
|
||||||
</h3>
|
</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">
|
<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)}
|
{JSON.stringify(batch.errors, null, 2)}
|
||||||
@@ -428,7 +386,7 @@ export default function BatchDetailModal({
|
|||||||
{batch.metadata && Object.keys(batch.metadata).length > 0 && (
|
{batch.metadata && Object.keys(batch.metadata).length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)] mb-3">
|
<h3 className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)] mb-3">
|
||||||
{t("batchMetadata")}
|
Metadata
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{Object.entries(batch.metadata).map(([k, v]) => (
|
{Object.entries(batch.metadata).map(([k, v]) => (
|
||||||
@@ -480,7 +438,7 @@ export default function BatchDetailModal({
|
|||||||
if (
|
if (
|
||||||
window.confirm(
|
window.confirm(
|
||||||
t("batchDetailActionRetry") +
|
t("batchDetailActionRetry") +
|
||||||
` (${batch.requestCountsFailed} ${t("batchActionRetry")})?`
|
` (${batch.requestCountsFailed} ${t("batchActionRetry")})?`,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
const result = await retry({
|
const result = await retry({
|
||||||
|
|||||||
@@ -4,24 +4,22 @@ import { useEffect, useState } from "react";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/shared/components";
|
import { Button } from "@/shared/components";
|
||||||
|
|
||||||
type FileTranslator = ReturnType<typeof useTranslations>;
|
function relativeTime(ts: number): string {
|
||||||
|
|
||||||
function relativeTime(ts: number, t: FileTranslator): string {
|
|
||||||
const diffMs = Date.now() - ts * 1000;
|
const diffMs = Date.now() - ts * 1000;
|
||||||
const diffSec = Math.round(diffMs / 1000);
|
const diffSec = Math.round(diffMs / 1000);
|
||||||
if (diffSec < 60) return t("batchRelativeTimeAgo", { value: `${diffSec}s` });
|
if (diffSec < 60) return `${diffSec}s ago`;
|
||||||
const diffMin = Math.round(diffSec / 60);
|
const diffMin = Math.round(diffSec / 60);
|
||||||
if (diffMin < 60) return t("batchRelativeTimeAgo", { value: `${diffMin}m` });
|
if (diffMin < 60) return `${diffMin}m ago`;
|
||||||
const diffHr = Math.round(diffMin / 60);
|
const diffHr = Math.round(diffMin / 60);
|
||||||
if (diffHr < 24) return t("batchRelativeTimeAgo", { value: `${diffHr}h` });
|
if (diffHr < 24) return `${diffHr}h ago`;
|
||||||
const diffDays = Math.round(diffHr / 24);
|
const diffDays = Math.round(diffHr / 24);
|
||||||
return t("batchRelativeTimeAgo", { value: `${diffDays}d` });
|
return `${diffDays}d ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function relativeExpiration(ts: number | null, t: FileTranslator): string {
|
function relativeExpiration(ts: number | null): string {
|
||||||
if (!ts) return t("batchFilesNeverExpires");
|
if (!ts) return "Never";
|
||||||
const diffMs = ts * 1000 - Date.now();
|
const diffMs = ts * 1000 - Date.now();
|
||||||
if (diffMs <= 0) return t("expirationBadgeExpired");
|
if (diffMs <= 0) return "Expired";
|
||||||
const diffSec = Math.round(diffMs / 1000);
|
const diffSec = Math.round(diffMs / 1000);
|
||||||
if (diffSec < 60) return `${diffSec}s`;
|
if (diffSec < 60) return `${diffSec}s`;
|
||||||
const diffMin = Math.round(diffSec / 60);
|
const diffMin = Math.round(diffSec / 60);
|
||||||
@@ -57,22 +55,6 @@ interface BatchRecord {
|
|||||||
model?: string | null;
|
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 {
|
interface FileDetailModalProps {
|
||||||
file: FileRecord;
|
file: FileRecord;
|
||||||
contents: string | null;
|
contents: string | null;
|
||||||
@@ -151,7 +133,7 @@ export default function FileDetailModal({
|
|||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-semibold text-[var(--color-text-main)]">
|
<h2 className="text-base font-semibold text-[var(--color-text-main)]">
|
||||||
{t("batchFileContents")}
|
File Contents
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
<p className="text-xs text-[var(--color-text-muted)] font-mono">{file.id}</p>
|
<p className="text-xs text-[var(--color-text-muted)] font-mono">{file.id}</p>
|
||||||
@@ -182,7 +164,7 @@ export default function FileDetailModal({
|
|||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 p-4 rounded-xl bg-[var(--color-bg-alt)] border border-[var(--color-border)]">
|
<div className="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">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className="text-[10px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
|
<span className="text-[10px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
|
||||||
{t("batchFilesSizeColumn")}
|
Size
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-[var(--color-text-main)]">
|
<span className="text-sm text-[var(--color-text-main)]">
|
||||||
{formatBytes(file.bytes)}
|
{formatBytes(file.bytes)}
|
||||||
@@ -190,24 +172,24 @@ export default function FileDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className="text-[10px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
|
<span className="text-[10px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
|
||||||
{t("batchFilesPurpose")}
|
Purpose
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-[var(--color-text-main)]">{file.purpose}</span>
|
<span className="text-sm text-[var(--color-text-main)]">{file.purpose}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className="text-[10px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
|
<span className="text-[10px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
|
||||||
{t("batchDetailCreated")}
|
Created
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-[var(--color-text-main)]">
|
<span className="text-sm text-[var(--color-text-main)]">
|
||||||
{createdAtTs ? relativeTime(createdAtTs, t) : "—"}
|
{createdAtTs ? relativeTime(createdAtTs) : "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className="text-[10px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
|
<span className="text-[10px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
|
||||||
{t("batchFilesExpires")}
|
Expires
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-[var(--color-text-main)]">
|
<span className="text-sm text-[var(--color-text-main)]">
|
||||||
{expiresAtTs ? relativeExpiration(expiresAtTs, t) : t("batchFilesNeverExpires")}
|
{expiresAtTs ? relativeExpiration(expiresAtTs) : "Never"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -216,7 +198,7 @@ export default function FileDetailModal({
|
|||||||
{relatedBatches.length > 0 && (
|
{relatedBatches.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)] mb-2">
|
<h3 className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)] mb-2">
|
||||||
{t("batchFileUsedByCount", { count: relatedBatches.length })}
|
Used by {relatedBatches.length} batch{relatedBatches.length > 1 ? "es" : ""}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{relatedBatches.map((b) => (
|
{relatedBatches.map((b) => (
|
||||||
@@ -237,9 +219,7 @@ export default function FileDetailModal({
|
|||||||
: "bg-gray-500/15 text-gray-400 border-gray-500/25"
|
: "bg-gray-500/15 text-gray-400 border-gray-500/25"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{BATCH_STATUS_TRANSLATION_KEYS[b.status]
|
{b.status.replaceAll("_", " ")}
|
||||||
? t(BATCH_STATUS_TRANSLATION_KEYS[b.status])
|
|
||||||
: b.status.replaceAll("_", " ")}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -251,7 +231,7 @@ export default function FileDetailModal({
|
|||||||
<div className="flex-1 flex flex-col min-h-[300px]">
|
<div className="flex-1 flex flex-col min-h-[300px]">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||||
{t("batchFilePreview")}
|
Preview
|
||||||
</h3>
|
</h3>
|
||||||
{contents && (
|
{contents && (
|
||||||
<button
|
<button
|
||||||
@@ -261,7 +241,7 @@ export default function FileDetailModal({
|
|||||||
<span className="material-symbols-outlined text-[14px]">
|
<span className="material-symbols-outlined text-[14px]">
|
||||||
{copied ? "check" : "content_copy"}
|
{copied ? "check" : "content_copy"}
|
||||||
</span>
|
</span>
|
||||||
{copied ? t("copied") : t("copy")}
|
{copied ? "Copied!" : "Copy"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -279,7 +259,7 @@ export default function FileDetailModal({
|
|||||||
{isTruncated && (
|
{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">
|
<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>
|
<span className="material-symbols-outlined text-[16px]">warning</span>
|
||||||
{t("batchFilePreviewTruncated", { shown: 1000, total: lineCount })}
|
Showing first 1000 lines ({lineCount} total lines)
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -301,7 +281,7 @@ export default function FileDetailModal({
|
|||||||
className="flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-lg bg-[var(--color-accent)] text-white hover:opacity-90 transition-opacity"
|
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>
|
<span className="material-symbols-outlined text-[18px]">download</span>
|
||||||
{t("batchFileDownloadFull")}
|
Download Full File
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
export type ChaosTranslator = ((
|
|
||||||
key: string,
|
|
||||||
values?: Record<string, string | number>
|
|
||||||
) => string) & {
|
|
||||||
has?: (key: string) => boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function chaosText(
|
|
||||||
t: ChaosTranslator,
|
|
||||||
key: string,
|
|
||||||
fallback: string,
|
|
||||||
values?: Record<string, string | number>
|
|
||||||
): string {
|
|
||||||
if (typeof t.has !== "function" || !t.has(key)) return fallback;
|
|
||||||
return values ? t(key, values) : t(key);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { chaosText, type ChaosTranslator } from "../chaosI18n";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save/Reset + Test-run action buttons for the Chaos Mode config page.
|
* Save/Reset + Test-run action buttons for the Chaos Mode config page.
|
||||||
@@ -23,7 +22,7 @@ export function ChaosConfigActionsBar({
|
|||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
onTest: () => void;
|
onTest: () => void;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("chaosConfig") as ChaosTranslator;
|
const t = useTranslations("chaosConfig");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -67,7 +66,7 @@ export function ChaosConfigActionsBar({
|
|||||||
) : (
|
) : (
|
||||||
<span className="material-symbols-outlined text-[16px]">play_arrow</span>
|
<span className="material-symbols-outlined text-[16px]">play_arrow</span>
|
||||||
)}
|
)}
|
||||||
{testing ? chaosText(t, "running", "Running...") : t("testButton")}
|
{testing ? "Running..." : t("testButton")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { chaosText, type ChaosTranslator } from "../chaosI18n";
|
|
||||||
|
|
||||||
export interface ChaosModelResult {
|
export interface ChaosModelResult {
|
||||||
providerId: string;
|
providerId: string;
|
||||||
providerName: string;
|
providerName: string;
|
||||||
@@ -29,24 +26,14 @@ export interface ChaosTestResult {
|
|||||||
* complexity/size ratchet (config/quality/complexity-baseline.json).
|
* complexity/size ratchet (config/quality/complexity-baseline.json).
|
||||||
*/
|
*/
|
||||||
export function ChaosTestResultsPanel({ result }: { result: ChaosTestResult }) {
|
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 (
|
return (
|
||||||
<div className="p-3 rounded-lg border border-border bg-surface/40 space-y-3">
|
<div className="p-3 rounded-lg border border-border bg-surface/40 space-y-3">
|
||||||
<h3 className="text-sm font-bold text-text-main">{resultsTitle}</h3>
|
<h3 className="text-sm font-bold text-text-main">
|
||||||
<div className="text-xs text-text-muted">{startedLabel}</div>
|
Test Results — {result.mode} mode ({result.totalProviders} providers)
|
||||||
|
</h3>
|
||||||
|
<div className="text-xs text-text-muted">
|
||||||
|
Started: {new Date(result.startedAt).toLocaleTimeString()}
|
||||||
|
</div>
|
||||||
{result.models.map((model, idx) => (
|
{result.models.map((model, idx) => (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* /dashboard/chaos/page.tsx — Chaos Mode Configuration
|
* /dashboard/chaos/page.tsx — Chaos Mode Configuration
|
||||||
*/
|
*/
|
||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
import ChaosConfigPageClient from "./ChaosConfigPageClient";
|
import ChaosConfigPageClient from "./ChaosConfigPageClient";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export const metadata = {
|
||||||
const t = await getTranslations("chaosConfig");
|
title: "Chaos Mode — OmniRoute",
|
||||||
return { title: `${t("pageTitle")} — OmniRoute` };
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return <ChaosConfigPageClient />;
|
return <ChaosConfigPageClient />;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useCallback, useState, type Dispatch, type SetStateAction } from "react";
|
import { useCallback, useState, type Dispatch, type SetStateAction } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { chaosText, type ChaosTranslator } from "./chaosI18n";
|
|
||||||
import type { ChaosPageConfig, ChaosPageMessage } from "./chaosPageTypes";
|
import type { ChaosPageConfig, ChaosPageMessage } from "./chaosPageTypes";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,7 +13,7 @@ export function useChaosConfigPersistence(
|
|||||||
config: ChaosPageConfig,
|
config: ChaosPageConfig,
|
||||||
setConfig: Dispatch<SetStateAction<ChaosPageConfig>>
|
setConfig: Dispatch<SetStateAction<ChaosPageConfig>>
|
||||||
) {
|
) {
|
||||||
const t = useTranslations("chaosConfig") as ChaosTranslator;
|
const t = useTranslations("chaosConfig");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [message, setMessage] = useState<ChaosPageMessage>(null);
|
const [message, setMessage] = useState<ChaosPageMessage>(null);
|
||||||
|
|
||||||
@@ -50,26 +49,17 @@ export function useChaosConfigPersistence(
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setConfig(data.config);
|
setConfig(data.config);
|
||||||
setMessage({
|
setMessage({ type: "success", text: "Config reset to defaults" });
|
||||||
type: "success",
|
|
||||||
text: chaosText(t, "resetSuccess", "Config reset to defaults"),
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
const err = await res.json().catch(() => ({ error: null }));
|
const err = await res.json().catch(() => ({ error: "Reset failed" }));
|
||||||
setMessage({
|
setMessage({ type: "error", text: err.error || "Reset failed" });
|
||||||
type: "error",
|
|
||||||
text: err.error || chaosText(t, "resetFailed", "Reset failed"),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setMessage({
|
setMessage({ type: "error", text: "Failed to reset config" });
|
||||||
type: "error",
|
|
||||||
text: chaosText(t, "resetFailed", "Failed to reset config"),
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [setConfig, t]);
|
}, [setConfig]);
|
||||||
|
|
||||||
return { t, saving, message, setMessage, saveConfig, resetConfig };
|
return { t, saving, message, setMessage, saveConfig, resetConfig };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { chaosText, type ChaosTranslator } from "./chaosI18n";
|
|
||||||
import type { ChaosTestResult } from "./components/ChaosTestResultsPanel";
|
import type { ChaosTestResult } from "./components/ChaosTestResultsPanel";
|
||||||
import type { ChaosPageConfig, ChaosPageMessage } from "./chaosPageTypes";
|
import type { ChaosPageConfig, ChaosPageMessage } from "./chaosPageTypes";
|
||||||
|
|
||||||
@@ -11,11 +10,8 @@ import type { ChaosPageConfig, ChaosPageMessage } from "./chaosPageTypes";
|
|||||||
* of the page component to keep it under the complexity/size ratchet
|
* of the page component to keep it under the complexity/size ratchet
|
||||||
* (config/quality/complexity-baseline.json).
|
* (config/quality/complexity-baseline.json).
|
||||||
*/
|
*/
|
||||||
export function useChaosTestRun(
|
export function useChaosTestRun(config: ChaosPageConfig, setMessage: (message: ChaosPageMessage) => void) {
|
||||||
config: ChaosPageConfig,
|
const t = useTranslations("chaosConfig");
|
||||||
setMessage: (message: ChaosPageMessage) => void
|
|
||||||
) {
|
|
||||||
const t = useTranslations("chaosConfig") as ChaosTranslator;
|
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [testResult, setTestResult] = useState<ChaosTestResult | null>(null);
|
const [testResult, setTestResult] = useState<ChaosTestResult | null>(null);
|
||||||
|
|
||||||
@@ -38,17 +34,11 @@ export function useChaosTestRun(
|
|||||||
const data: ChaosTestResult = await res.json();
|
const data: ChaosTestResult = await res.json();
|
||||||
setTestResult(data);
|
setTestResult(data);
|
||||||
} else {
|
} else {
|
||||||
const err = await res.json().catch(() => ({ error: null }));
|
const err = await res.json().catch(() => ({ error: "Unknown error" }));
|
||||||
setMessage({
|
setMessage({ type: "error", text: err.error || "Test failed" });
|
||||||
type: "error",
|
|
||||||
text: err.error || chaosText(t, "testFailed", "Test failed"),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setMessage({
|
setMessage({ type: "error", text: err.message || "Test failed" });
|
||||||
type: "error",
|
|
||||||
text: err.message || chaosText(t, "testFailed", "Test failed"),
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
setTesting(false);
|
setTesting(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { Card, Button } from "@/shared/components";
|
import { Card, Button } from "@/shared/components";
|
||||||
|
|
||||||
interface ToolState {
|
interface ToolState {
|
||||||
@@ -25,7 +24,6 @@ interface UpdateInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CliproxyapiToolCard({ isExpanded = false, onToggle = () => {} }) {
|
export default function CliproxyapiToolCard({ isExpanded = false, onToggle = () => {} }) {
|
||||||
const t = useTranslations("cliTools");
|
|
||||||
const [toolState, setToolState] = useState<ToolState | null>(null);
|
const [toolState, setToolState] = useState<ToolState | null>(null);
|
||||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
||||||
const [loading, setLoading] = useState<string | null>(null);
|
const [loading, setLoading] = useState<string | null>(null);
|
||||||
@@ -73,7 +71,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
|||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setMessage({ type: "success", text: data.message || t("cliproxyapiActionSucceeded") });
|
setMessage({ type: "success", text: data.message || `${action} succeeded` });
|
||||||
await fetchStatus();
|
await fetchStatus();
|
||||||
if (action === "install" || action === "restart") await fetchUpdateInfo();
|
if (action === "install" || action === "restart") await fetchUpdateInfo();
|
||||||
} else {
|
} else {
|
||||||
@@ -81,14 +79,11 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
|||||||
type: "error",
|
type: "error",
|
||||||
text:
|
text:
|
||||||
(typeof data.error === "string" ? data.error : data.error?.message) ||
|
(typeof data.error === "string" ? data.error : data.error?.message) ||
|
||||||
t("cliproxyapiActionFailed"),
|
`${action} failed`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMessage({
|
setMessage({ type: "error", text: err instanceof Error ? err.message : "Request failed" });
|
||||||
type: "error",
|
|
||||||
text: err instanceof Error ? err.message : t("cliproxyapiRequestFailed"),
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(null);
|
setLoading(null);
|
||||||
}
|
}
|
||||||
@@ -98,26 +93,14 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
|||||||
if (!toolState) return null;
|
if (!toolState) return null;
|
||||||
const s = toolState.status;
|
const s = toolState.status;
|
||||||
const map: Record<string, { label: string; color: string }> = {
|
const map: Record<string, { label: string; color: string }> = {
|
||||||
running: {
|
running: { label: "Running", color: "bg-green-500/10 text-green-600 dark:text-green-400" },
|
||||||
label: t("cliproxyapiStatusRunning"),
|
stopped: { label: "Stopped", color: "bg-zinc-500/10 text-zinc-500 dark:text-zinc-400" },
|
||||||
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: {
|
not_installed: {
|
||||||
label: t("cliproxyapiStatusNotInstalled"),
|
label: "Not Installed",
|
||||||
color: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400",
|
color: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400",
|
||||||
},
|
},
|
||||||
installed: {
|
installed: { label: "Installed", color: "bg-blue-500/10 text-blue-600 dark:text-blue-400" },
|
||||||
label: t("cliproxyapiStatusInstalled"),
|
error: { label: "Error", color: "bg-red-500/10 text-red-600 dark:text-red-400" },
|
||||||
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;
|
const badge = map[s] || map.not_installed;
|
||||||
return (
|
return (
|
||||||
@@ -142,7 +125,9 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
|||||||
<h3 className="font-medium text-sm">CLIProxyAPI</h3>
|
<h3 className="font-medium text-sm">CLIProxyAPI</h3>
|
||||||
{statusBadge()}
|
{statusBadge()}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-text-muted truncate">{t("cliproxyapiDescription")}</p>
|
<p className="text-xs text-text-muted truncate">
|
||||||
|
Upstream proxy fallback (Go-based OAuth)
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
@@ -176,10 +161,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
|||||||
system_update
|
system_update
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-yellow-700 dark:text-yellow-300">
|
<span className="text-sm text-yellow-700 dark:text-yellow-300">
|
||||||
{t("cliproxyapiUpdateAvailable", {
|
Update available: v{updateInfo.current} → v{updateInfo.latest}
|
||||||
current: updateInfo.current,
|
|
||||||
latest: updateInfo.latest,
|
|
||||||
})}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -188,34 +170,32 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
|||||||
onClick={() => apiCall("install", { version: updateInfo.latest })}
|
onClick={() => apiCall("install", { version: updateInfo.latest })}
|
||||||
loading={loading === "install"}
|
loading={loading === "install"}
|
||||||
>
|
>
|
||||||
{t("cliproxyapiUpdate")}
|
Update
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div className="p-3 rounded-lg bg-bg-secondary">
|
<div className="p-3 rounded-lg bg-bg-secondary">
|
||||||
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiVersion")}</p>
|
<p className="text-xs text-text-muted mb-1">Version</p>
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-medium">
|
||||||
{toolState?.installedVersion
|
{toolState?.installedVersion ? `v${toolState.installedVersion}` : "Not installed"}
|
||||||
? `v${toolState.installedVersion}`
|
|
||||||
: t("cliproxyapiNotInstalledValue")}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 rounded-lg bg-bg-secondary">
|
<div className="p-3 rounded-lg bg-bg-secondary">
|
||||||
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiHealth")}</p>
|
<p className="text-xs text-text-muted mb-1">Health</p>
|
||||||
<p
|
<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"}`}
|
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"
|
{toolState?.healthStatus === "healthy"
|
||||||
? t("cliproxyapiHealthy")
|
? `Healthy`
|
||||||
: toolState?.healthStatus === "unhealthy"
|
: toolState?.healthStatus === "unhealthy"
|
||||||
? t("cliproxyapiUnhealthy")
|
? "Unhealthy"
|
||||||
: t("cliproxyapiUnknown")}
|
: "Unknown"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 rounded-lg bg-bg-secondary">
|
<div className="p-3 rounded-lg bg-bg-secondary">
|
||||||
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiPort")}</p>
|
<p className="text-xs text-text-muted mb-1">Port</p>
|
||||||
<p className="text-sm font-mono">{toolState?.port || 8317}</p>
|
<p className="text-sm font-mono">{toolState?.port || 8317}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -229,7 +209,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
|||||||
loading={loading === "install"}
|
loading={loading === "install"}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-[14px] mr-1">download</span>
|
<span className="material-symbols-outlined text-[14px] mr-1">download</span>
|
||||||
{t("cliproxyapiInstall")}
|
Install
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{toolState?.status === "running" ? (
|
{toolState?.status === "running" ? (
|
||||||
@@ -240,7 +220,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
|||||||
loading={loading === "stop"}
|
loading={loading === "stop"}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-[14px] mr-1">stop</span>
|
<span className="material-symbols-outlined text-[14px] mr-1">stop</span>
|
||||||
{t("cliproxyapiStop")}
|
Stop
|
||||||
</Button>
|
</Button>
|
||||||
) : toolState?.installedVersion ? (
|
) : toolState?.installedVersion ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -250,7 +230,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
|||||||
loading={loading === "start"}
|
loading={loading === "start"}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-[14px] mr-1">play_arrow</span>
|
<span className="material-symbols-outlined text-[14px] mr-1">play_arrow</span>
|
||||||
{t("cliproxyapiStart")}
|
Start
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{toolState?.status === "running" && (
|
{toolState?.status === "running" && (
|
||||||
@@ -261,7 +241,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
|||||||
loading={loading === "restart"}
|
loading={loading === "restart"}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-[14px] mr-1">restart_alt</span>
|
<span className="material-symbols-outlined text-[14px] mr-1">restart_alt</span>
|
||||||
{t("cliproxyapiRestart")}
|
Restart
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
@@ -271,7 +251,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
|
|||||||
loading={loading === "check"}
|
loading={loading === "check"}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-[14px] mr-1">sync</span>
|
<span className="material-symbols-outlined text-[14px] mr-1">sync</span>
|
||||||
{t("cliproxyapiCheckUpdates")}
|
Check Updates
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<code className="rounded bg-black/5 dark:bg-white/5 px-2 py-1 text-text-main">
|
||||||
{combo?.name}
|
{combo?.name}
|
||||||
</code>
|
</code>
|
||||||
<span>{t("intelligentComboCount", { count: allCombos.length })}</span>
|
<span>{allCombos.length} intelligent combo(s)</span>
|
||||||
<span>{t("providersInScope", { count: providerScopeCount })}</span>
|
<span>{providerScopeCount} providers in scope</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ export default function IntelligentComboPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg bg-black/5 dark:bg-white/5 px-3 py-2 text-right">
|
<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">
|
<p className="text-[10px] uppercase tracking-wide text-text-muted">
|
||||||
{getI18nOrFallback(t, "candidatePoolLabel", "Candidate Pool")}
|
Candidate Pool
|
||||||
</p>
|
</p>
|
||||||
<p className="text-lg font-semibold text-text-main">{providerScopeCount}</p>
|
<p className="text-lg font-semibold text-text-main">{providerScopeCount}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -183,12 +183,7 @@ export default function IntelligentComboPanel({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{savingModePack && (
|
{savingModePack && (
|
||||||
<span className="text-[11px] text-text-muted">
|
<span className="text-[11px] text-text-muted">Saving {savingModePack}…</span>
|
||||||
{getI18nOrFallback(t, "savingModePack", "Saving {pack}…").replace(
|
|
||||||
"{pack}",
|
|
||||||
savingModePack
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -313,7 +308,7 @@ export default function IntelligentComboPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-black/8 bg-white/60 p-3 dark:border-white/8 dark:bg-white/[0.03]">
|
<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">
|
<p className="text-[11px] uppercase tracking-wide text-text-muted">
|
||||||
{getI18nOrFallback(t, "explorationRateLabel", "Exploration Rate")}
|
Exploration Rate
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-sm font-semibold text-text-main">
|
<p className="mt-1 text-sm font-semibold text-text-main">
|
||||||
{Math.round(normalizedConfig.explorationRate * 100)}%
|
{Math.round(normalizedConfig.explorationRate * 100)}%
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
|
|
||||||
export default function CombosError({
|
export default function CombosError({
|
||||||
error: _error,
|
error: _error,
|
||||||
reset,
|
reset,
|
||||||
@@ -9,8 +7,6 @@ export default function CombosError({
|
|||||||
error: Error & { digest?: string };
|
error: Error & { digest?: string };
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("combos");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex flex-col items-center justify-center min-h-[400px]"
|
className="flex flex-col items-center justify-center min-h-[400px]"
|
||||||
@@ -18,10 +14,14 @@ export default function CombosError({
|
|||||||
aria-live="assertive"
|
aria-live="assertive"
|
||||||
>
|
>
|
||||||
<div className="text-center space-y-4">
|
<div className="text-center space-y-4">
|
||||||
<h2 className="text-xl font-semibold text-red-600 dark:text-red-400">{t("errorTitle")}</h2>
|
<h2 className="text-xl font-semibold text-red-600 dark:text-red-400">
|
||||||
<p className="text-text-muted max-w-md">{t("errorDescription")}</p>
|
Failed to load combos
|
||||||
|
</h2>
|
||||||
|
<p className="text-text-muted max-w-md">
|
||||||
|
We could not load combo data right now. Check your connection and try again.
|
||||||
|
</p>
|
||||||
{_error?.digest && (
|
{_error?.digest && (
|
||||||
<p className="text-xs text-text-muted font-mono">{t("errorId", { id: _error.digest })}</p>
|
<p className="text-xs text-text-muted font-mono">Error ID: {_error.digest}</p>
|
||||||
)}
|
)}
|
||||||
{process.env.NODE_ENV === "development" && _error?.message && (
|
{process.env.NODE_ENV === "development" && _error?.message && (
|
||||||
<p className="text-xs text-red-600 dark:text-red-400 font-mono">{_error.message}</p>
|
<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}
|
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"
|
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors focus:outline-2 focus:outline-offset-2 focus:outline-primary"
|
||||||
>
|
>
|
||||||
{t("errorRetry")}
|
Try Again
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export const metadata = {
|
||||||
const t = await getTranslations("metadata");
|
title: "Compression",
|
||||||
return {
|
description: "Configure context compression settings to reduce token usage and costs.",
|
||||||
title: t("compressionTitle"),
|
};
|
||||||
description: t("compressionDescription"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CompressionPage() {
|
export default function CompressionPage() {
|
||||||
redirect("/dashboard/context/caveman");
|
redirect("/dashboard/context/caveman");
|
||||||
|
|||||||
@@ -534,10 +534,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
|||||||
if (!cloudConfigured) {
|
if (!cloudConfigured) {
|
||||||
setCloudStatus({
|
setCloudStatus({
|
||||||
type: "warning",
|
type: "warning",
|
||||||
message: translateOrFallback(
|
message: "Cloud sync is not configured on this instance.",
|
||||||
"cloudSyncNotConfigured",
|
|
||||||
"Cloud sync is not configured on this instance."
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1825,7 +1822,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
|||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<span className="material-symbols-outlined text-sm text-primary">hub</span>
|
<span className="material-symbols-outlined text-sm text-primary">hub</span>
|
||||||
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||||
{t("categoryCore")}
|
{t("categoryCore") || "Core APIs"}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex-1 h-px bg-border/50" />
|
<div className="flex-1 h-px bg-border/50" />
|
||||||
</div>
|
</div>
|
||||||
@@ -1846,7 +1843,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
|||||||
icon="code"
|
icon="code"
|
||||||
iconColor="text-indigo-500"
|
iconColor="text-indigo-500"
|
||||||
iconBg="bg-indigo-500/10"
|
iconBg="bg-indigo-500/10"
|
||||||
title={t("responses")}
|
title={t("responses") || "Responses API"}
|
||||||
path="/v1/responses"
|
path="/v1/responses"
|
||||||
models={endpointData.chat}
|
models={endpointData.chat}
|
||||||
copy={copy}
|
copy={copy}
|
||||||
@@ -1858,7 +1855,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
|||||||
icon="text_fields"
|
icon="text_fields"
|
||||||
iconColor="text-orange-500"
|
iconColor="text-orange-500"
|
||||||
iconBg="bg-orange-500/10"
|
iconBg="bg-orange-500/10"
|
||||||
title={t("completionsLegacy")}
|
title={t("completionsLegacy") || "Completions (Legacy)"}
|
||||||
path="/v1/completions"
|
path="/v1/completions"
|
||||||
models={endpointData.chat}
|
models={endpointData.chat}
|
||||||
copy={copy}
|
copy={copy}
|
||||||
@@ -1870,7 +1867,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
|||||||
icon="psychology"
|
icon="psychology"
|
||||||
iconColor="text-violet-500"
|
iconColor="text-violet-500"
|
||||||
iconBg="bg-violet-500/10"
|
iconBg="bg-violet-500/10"
|
||||||
title={t("messagesApi")}
|
title={t("messagesApi") || "Messages"}
|
||||||
path="/v1/messages"
|
path="/v1/messages"
|
||||||
models={null}
|
models={null}
|
||||||
badge="Anthropic"
|
badge="Anthropic"
|
||||||
@@ -1886,7 +1883,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
|||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<span className="material-symbols-outlined text-sm text-purple-400">perm_media</span>
|
<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">
|
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||||
{t("categoryMedia")}
|
{t("categoryMedia") || "Media & Multi-Modal"}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex-1 h-px bg-border/50" />
|
<div className="flex-1 h-px bg-border/50" />
|
||||||
</div>
|
</div>
|
||||||
@@ -1919,7 +1916,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
|||||||
icon="edit_square"
|
icon="edit_square"
|
||||||
iconColor="text-violet-500"
|
iconColor="text-violet-500"
|
||||||
iconBg="bg-violet-500/10"
|
iconBg="bg-violet-500/10"
|
||||||
title={t("imageEdits")}
|
title={t("imageEdits") || "Image Edits"}
|
||||||
path="/v1/images/edits"
|
path="/v1/images/edits"
|
||||||
models={endpointData.images}
|
models={endpointData.images}
|
||||||
copy={copy}
|
copy={copy}
|
||||||
@@ -1955,7 +1952,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
|||||||
icon="music_note"
|
icon="music_note"
|
||||||
iconColor="text-fuchsia-500"
|
iconColor="text-fuchsia-500"
|
||||||
iconBg="bg-fuchsia-500/10"
|
iconBg="bg-fuchsia-500/10"
|
||||||
title={t("musicGeneration")}
|
title={t("musicGeneration") || "Music Generation"}
|
||||||
path="/v1/music/generations"
|
path="/v1/music/generations"
|
||||||
models={endpointData.music}
|
models={endpointData.music}
|
||||||
copy={copy}
|
copy={copy}
|
||||||
@@ -1967,7 +1964,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
|||||||
icon="videocam"
|
icon="videocam"
|
||||||
iconColor="text-red-500"
|
iconColor="text-red-500"
|
||||||
iconBg="bg-red-500/10"
|
iconBg="bg-red-500/10"
|
||||||
title={t("videoGeneration")}
|
title={t("videoGeneration") || "Video Generation"}
|
||||||
path="/v1/videos/generations"
|
path="/v1/videos/generations"
|
||||||
models={endpointData.video}
|
models={endpointData.video}
|
||||||
copy={copy}
|
copy={copy}
|
||||||
@@ -1986,7 +1983,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
|||||||
travel_explore
|
travel_explore
|
||||||
</span>
|
</span>
|
||||||
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||||
{t("categorySearch")}
|
{t("categorySearch") || "Search & Discovery"}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex-1 h-px bg-border/50" />
|
<div className="flex-1 h-px bg-border/50" />
|
||||||
</div>
|
</div>
|
||||||
@@ -1995,7 +1992,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
|||||||
icon="search"
|
icon="search"
|
||||||
iconColor="text-cyan-500"
|
iconColor="text-cyan-500"
|
||||||
iconBg="bg-cyan-500/10"
|
iconBg="bg-cyan-500/10"
|
||||||
title={t("webSearch")}
|
title={t("webSearch") || "Web Search"}
|
||||||
path="/v1/search"
|
path="/v1/search"
|
||||||
models={searchProviders.map((p) => ({ id: p.id, owned_by: p.id, type: "search" }))}
|
models={searchProviders.map((p) => ({ id: p.id, owned_by: p.id, type: "search" }))}
|
||||||
copy={copy}
|
copy={copy}
|
||||||
@@ -2011,7 +2008,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
|||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<span className="material-symbols-outlined text-sm text-amber-400">build</span>
|
<span className="material-symbols-outlined text-sm text-amber-400">build</span>
|
||||||
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||||
{t("categoryUtility")}
|
{t("categoryUtility") || "Utility & Management"}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex-1 h-px bg-border/50" />
|
<div className="flex-1 h-px bg-border/50" />
|
||||||
</div>
|
</div>
|
||||||
@@ -2044,7 +2041,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
|||||||
icon="view_list"
|
icon="view_list"
|
||||||
iconColor="text-teal-500"
|
iconColor="text-teal-500"
|
||||||
iconBg="bg-teal-500/10"
|
iconBg="bg-teal-500/10"
|
||||||
title={t("batchApi")}
|
title={t("batchApi") || "Batch API"}
|
||||||
path="/v1/batches"
|
path="/v1/batches"
|
||||||
models={null}
|
models={null}
|
||||||
badge="OpenAI"
|
badge="OpenAI"
|
||||||
@@ -2056,7 +2053,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
|||||||
icon="folder"
|
icon="folder"
|
||||||
iconColor="text-yellow-500"
|
iconColor="text-yellow-500"
|
||||||
iconBg="bg-yellow-500/10"
|
iconBg="bg-yellow-500/10"
|
||||||
title={t("filesApi")}
|
title={t("filesApi") || "Files API"}
|
||||||
path="/v1/files"
|
path="/v1/files"
|
||||||
models={null}
|
models={null}
|
||||||
copy={copy}
|
copy={copy}
|
||||||
@@ -2067,7 +2064,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
|||||||
icon="list"
|
icon="list"
|
||||||
iconColor="text-teal-500"
|
iconColor="text-teal-500"
|
||||||
iconBg="bg-teal-500/10"
|
iconBg="bg-teal-500/10"
|
||||||
title={t("listModels")}
|
title={t("listModels") || "List Models"}
|
||||||
path="/v1/models"
|
path="/v1/models"
|
||||||
models={null}
|
models={null}
|
||||||
copy={copy}
|
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("tableTask")}</th>
|
||||||
<th className="text-left py-2 pr-2">{t("tableSkill")}</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("tableState")}</th>
|
||||||
<th className="text-left py-2 pr-2">{t("tablePhase")}</th>
|
<th className="text-left py-2 pr-2">{t("tablePhase") || "FSM Status"}</th>
|
||||||
<th className="text-left py-2 pr-2">{t("tableUpdated")}</th>
|
<th className="text-left py-2 pr-2">{t("tableUpdated")}</th>
|
||||||
<th className="text-left py-2">{t("tableActions")}</th>
|
<th className="text-left py-2">{t("tableActions")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -12,18 +12,6 @@ export default function NotionSourceCard() {
|
|||||||
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||||
const [expanded, setExpanded] = useState(false);
|
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 () => {
|
const fetchConfig = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/settings/notion");
|
const res = await fetch("/api/settings/notion");
|
||||||
@@ -49,10 +37,7 @@ export default function NotionSourceCard() {
|
|||||||
|
|
||||||
const handleSaveToken = async () => {
|
const handleSaveToken = async () => {
|
||||||
if (!token.trim()) {
|
if (!token.trim()) {
|
||||||
setMessage({
|
setMessage({ type: "error", text: "Please enter a Notion integration token" });
|
||||||
type: "error",
|
|
||||||
text: translateOrFallback("notionEnterToken", "Please enter a Notion integration token"),
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -68,20 +53,11 @@ export default function NotionSourceCard() {
|
|||||||
setConnected(true);
|
setConnected(true);
|
||||||
setMessage({ type: "success", text: data.message });
|
setMessage({ type: "success", text: data.message });
|
||||||
} else {
|
} else {
|
||||||
setMessage({
|
setMessage({ type: "error", text: data.error ?? "Failed to connect" });
|
||||||
type: "error",
|
|
||||||
text: data.error ?? translateOrFallback("notionConnectFailed", "Failed to connect"),
|
|
||||||
});
|
|
||||||
setConnected(false);
|
setConnected(false);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMessage({
|
setMessage({ type: "error", text: err instanceof Error ? err.message : "Connection failed" });
|
||||||
type: "error",
|
|
||||||
text:
|
|
||||||
err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: translateOrFallback("notionConnectionFailed", "Connection failed"),
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -98,19 +74,10 @@ export default function NotionSourceCard() {
|
|||||||
setToken("");
|
setToken("");
|
||||||
setMessage({ type: "success", text: data.message });
|
setMessage({ type: "success", text: data.message });
|
||||||
} else {
|
} else {
|
||||||
setMessage({
|
setMessage({ type: "error", text: data.error ?? "Failed to disconnect" });
|
||||||
type: "error",
|
|
||||||
text: data.error ?? translateOrFallback("notionDisconnectFailed", "Failed to disconnect"),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMessage({
|
setMessage({ type: "error", text: err instanceof Error ? err.message : "Disconnect failed" });
|
||||||
type: "error",
|
|
||||||
text:
|
|
||||||
err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: translateOrFallback("notionDisconnectFailed", "Disconnect failed"),
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -130,16 +97,11 @@ export default function NotionSourceCard() {
|
|||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="font-semibold text-sm">Notion</span>
|
<span className="font-semibold text-sm">Notion</span>
|
||||||
<Badge variant={connected ? "success" : "default"}>
|
<Badge variant={connected ? "success" : "default"}>
|
||||||
{connected
|
{connected ? "Connected" : "Not connected"}
|
||||||
? translateOrFallback("notionConnected", "Connected")
|
|
||||||
: translateOrFallback("notionNotConnected", "Not connected")}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-text-muted mt-0.5">
|
<p className="text-xs text-text-muted mt-0.5">
|
||||||
{translateOrFallback(
|
Search, read, query, and write to Notion through routed AI models
|
||||||
"notionDescription",
|
|
||||||
"Search, read, query, and write to Notion through routed AI models"
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
@@ -169,10 +131,7 @@ export default function NotionSourceCard() {
|
|||||||
{!connected ? (
|
{!connected ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="text-xs text-text-muted font-medium">
|
<label className="text-xs text-text-muted font-medium">
|
||||||
{translateOrFallback(
|
Notion Internal Integration Token
|
||||||
"notionIntegrationToken",
|
|
||||||
"Notion Internal Integration Token"
|
|
||||||
)}
|
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
@@ -184,14 +143,11 @@ export default function NotionSourceCard() {
|
|||||||
className="font-mono text-sm flex-1"
|
className="font-mono text-sm flex-1"
|
||||||
/>
|
/>
|
||||||
<Button onClick={handleSaveToken} loading={busy} variant="primary" size="sm">
|
<Button onClick={handleSaveToken} loading={busy} variant="primary" size="sm">
|
||||||
{translateOrFallback("notionConnect", "Connect")}
|
Connect
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-text-muted">
|
<p className="text-[10px] text-text-muted">
|
||||||
{translateOrFallback(
|
Create an Internal Integration at{" "}
|
||||||
"notionIntegrationHelp",
|
|
||||||
"Create an Internal Integration at"
|
|
||||||
)}{" "}
|
|
||||||
<code className="text-primary font-mono bg-surface/80 px-1 rounded">
|
<code className="text-primary font-mono bg-surface/80 px-1 rounded">
|
||||||
https://www.notion.so/profile/integrations
|
https://www.notion.so/profile/integrations
|
||||||
</code>
|
</code>
|
||||||
@@ -200,10 +156,7 @@ export default function NotionSourceCard() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-text-muted flex-1">
|
<span className="text-xs text-text-muted flex-1">
|
||||||
{translateOrFallback(
|
Token configured. Notion tools are available via MCP.
|
||||||
"notionTokenConfigured",
|
|
||||||
"Token configured. Notion tools are available via MCP."
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleDisconnect}
|
onClick={handleDisconnect}
|
||||||
@@ -212,7 +165,7 @@ export default function NotionSourceCard() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="border-red-500/30! text-red-400! hover:bg-red-500/10!"
|
className="border-red-500/30! text-red-400! hover:bg-red-500/10!"
|
||||||
>
|
>
|
||||||
{translateOrFallback("notionDisconnect", "Disconnect")}
|
Disconnect
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { Card, Button, EmptyState } from "@/shared/components";
|
import { Card, Button, EmptyState, Badge } from "@/shared/components";
|
||||||
import { useNotificationStore } from "@/store/notificationStore";
|
import { useNotificationStore } from "@/store/notificationStore";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
@@ -19,6 +19,10 @@ export default function PluginsPage() {
|
|||||||
const { addNotification } = useNotificationStore();
|
const { addNotification } = useNotificationStore();
|
||||||
const t = useTranslations("plugins");
|
const t = useTranslations("plugins");
|
||||||
const [plugins, setPlugins] = useState<PluginInfo[]>([]);
|
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 [loading, setLoading] = useState(true);
|
||||||
const [scanning, setScanning] = useState(false);
|
const [scanning, setScanning] = useState(false);
|
||||||
|
|
||||||
@@ -38,7 +42,51 @@ export default function PluginsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPlugins();
|
fetchPlugins();
|
||||||
|
fetch("/api/settings")
|
||||||
|
.then((res) => res.ok ? res.json() : null)
|
||||||
|
.then((data) => {
|
||||||
|
if (data?.pluginMarketplaceUrl) setMarketplaceUrl(data.pluginMarketplaceUrl);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
}, [fetchPlugins]);
|
}, [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 () => {
|
const handleScan = async () => {
|
||||||
setScanning(true);
|
setScanning(true);
|
||||||
@@ -89,58 +137,127 @@ export default function PluginsPage() {
|
|||||||
<div className="space-y-6 p-6">
|
<div className="space-y-6 p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">{t("title")}</h1>
|
<h1 className="text-2xl font-bold">{t("title")}</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant={activeTab === "installed" ? "primary" : "secondary"} onClick={() => setActiveTab("installed")}>
|
||||||
|
{t("installedTab")}
|
||||||
|
</Button>
|
||||||
|
<Button variant={activeTab === "marketplace" ? "primary" : "secondary"} onClick={() => setActiveTab("marketplace")}>
|
||||||
|
{t("marketplaceTab")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end">
|
{activeTab === "marketplace" && (
|
||||||
<Button onClick={handleScan} disabled={scanning}>
|
<Card className="p-4 flex gap-4 items-end bg-gray-50">
|
||||||
{scanning ? t("scanning") : t("scanForPlugins")}
|
<div className="flex-1">
|
||||||
</Button>
|
<label className="block text-sm font-medium text-gray-700 mb-1">{t("marketplaceUrlLabel")}</label>
|
||||||
</div>
|
<input
|
||||||
{plugins.length === 0 ? (
|
type="text"
|
||||||
<EmptyState
|
className="w-full rounded border-gray-300 p-2"
|
||||||
title={t("noPlugins")}
|
placeholder={t("marketplaceUrlPlaceholder")}
|
||||||
description={t("noPluginsDescription")}
|
value={marketplaceUrl}
|
||||||
/>
|
onChange={(e) => setMarketplaceUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleSaveUrl} disabled={savingUrl}>
|
||||||
|
{t("saveMarketplaceUrl")}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "installed" ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<Button onClick={handleScan} disabled={scanning}>
|
||||||
|
{scanning ? t("scanning") : t("scanForPlugins")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{plugins.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t("noPlugins")}
|
||||||
|
description={t("noPluginsDescription")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{plugins.map((plugin) => (
|
||||||
|
<Card key={plugin.name} className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">{plugin.name}</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
v{plugin.version}
|
||||||
|
{plugin.author ? ` by ${plugin.author}` : ""}
|
||||||
|
{plugin.description ? ` — ${plugin.description}` : ""}
|
||||||
|
</p>
|
||||||
|
<div className="mt-1 flex gap-1">
|
||||||
|
{plugin.hooks.map((hook) => (
|
||||||
|
<span
|
||||||
|
key={hook}
|
||||||
|
className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700"
|
||||||
|
>
|
||||||
|
{hook}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant={plugin.enabled ? "secondary" : "primary"}
|
||||||
|
onClick={() => handleToggle(plugin.name, !plugin.enabled)}
|
||||||
|
>
|
||||||
|
{plugin.enabled ? t("deactivate") : t("activate")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => handleUninstall(plugin.name)}
|
||||||
|
>
|
||||||
|
{t("uninstall")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{plugins.map((plugin) => (
|
{marketplacePlugins.length === 0 ? (
|
||||||
<Card key={plugin.name} className="p-4">
|
<div className="text-gray-500 py-4">{t("marketplaceEmpty")}</div>
|
||||||
<div className="flex items-center justify-between">
|
) : (
|
||||||
<div>
|
marketplacePlugins.map((plugin) => (
|
||||||
<h3 className="font-semibold">{plugin.name}</h3>
|
<Card key={plugin.name} className="p-4">
|
||||||
<p className="text-sm text-gray-500">
|
<div className="flex items-center justify-between">
|
||||||
v{plugin.version}
|
<div>
|
||||||
{plugin.author ? ` by ${plugin.author}` : ""}
|
<h3 className="font-semibold flex items-center gap-2">
|
||||||
{plugin.description ? ` — ${plugin.description}` : ""}
|
{plugin.name}
|
||||||
</p>
|
{plugin.verified && <Badge variant="success">{t("verified")}</Badge>}
|
||||||
<div className="mt-1 flex gap-1">
|
</h3>
|
||||||
{plugin.hooks.map((hook) => (
|
<p className="text-sm text-gray-500">
|
||||||
<span
|
v{plugin.version} by {plugin.author} — {plugin.description}
|
||||||
key={hook}
|
</p>
|
||||||
className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700"
|
<div className="mt-1 flex gap-1">
|
||||||
>
|
{plugin.tags?.map((tag: string) => (
|
||||||
{hook}
|
<span key={tag} className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
|
||||||
</span>
|
{tag}
|
||||||
))}
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => {
|
||||||
|
addNotification({ type: "info", message: t("marketplaceInstallComingSoon") });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("install")}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
</Card>
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ import {
|
|||||||
getCodexEffectiveServiceTier,
|
getCodexEffectiveServiceTier,
|
||||||
type CodexGlobalServiceMode,
|
type CodexGlobalServiceMode,
|
||||||
} from "@/lib/providers/codexFastTier";
|
} 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 { getCodexPlanLabel } from "../codexPlanLabel";
|
||||||
import ProviderQuotaVisibilityToggle from "./ProviderQuotaVisibilityToggle";
|
import ProviderQuotaVisibilityToggle from "./ProviderQuotaVisibilityToggle";
|
||||||
|
|
||||||
@@ -241,7 +245,7 @@ function getStatusPresentation(
|
|||||||
if (errorType === "account_deactivated") {
|
if (errorType === "account_deactivated") {
|
||||||
return {
|
return {
|
||||||
statusVariant: "error",
|
statusVariant: "error",
|
||||||
statusLabel: providerText(t, "statusDeactivated", "Deactivated"),
|
statusLabel: t("statusDeactivated", "Deactivated"),
|
||||||
errorType,
|
errorType,
|
||||||
errorBadge,
|
errorBadge,
|
||||||
errorTextClass: "text-red-600 font-bold",
|
errorTextClass: "text-red-600 font-bold",
|
||||||
@@ -296,7 +300,7 @@ function getStatusPresentation(
|
|||||||
if (errorType === "banned") {
|
if (errorType === "banned") {
|
||||||
return {
|
return {
|
||||||
statusVariant: "error",
|
statusVariant: "error",
|
||||||
statusLabel: providerText(t, "statusBanned", "Banned (403)"),
|
statusLabel: t("statusBanned", "Banned (403)"),
|
||||||
errorType,
|
errorType,
|
||||||
errorBadge,
|
errorBadge,
|
||||||
errorTextClass: "text-red-600 font-bold",
|
errorTextClass: "text-red-600 font-bold",
|
||||||
@@ -306,7 +310,7 @@ function getStatusPresentation(
|
|||||||
if (errorType === "credits_exhausted") {
|
if (errorType === "credits_exhausted") {
|
||||||
return {
|
return {
|
||||||
statusVariant: "warning",
|
statusVariant: "warning",
|
||||||
statusLabel: providerText(t, "statusCreditsExhausted", "Out of Credits"),
|
statusLabel: t("statusCreditsExhausted", "Out of Credits"),
|
||||||
errorType,
|
errorType,
|
||||||
errorBadge,
|
errorBadge,
|
||||||
errorTextClass: "text-amber-500",
|
errorTextClass: "text-amber-500",
|
||||||
@@ -387,10 +391,22 @@ export default function ConnectionRow({
|
|||||||
t("oauthAccount")
|
t("oauthAccount")
|
||||||
)
|
)
|
||||||
: connection.name;
|
: connection.name;
|
||||||
const applyCodexAuthLabel = providerText(t, "applyCodexAuthLocal", "Apply auth");
|
const applyCodexAuthLabel =
|
||||||
const exportCodexAuthLabel = providerText(t, "exportCodexAuthFile", "Export auth");
|
typeof t.has === "function" && t.has("applyCodexAuthLocal")
|
||||||
const applyClaudeAuthLabel = providerText(t, "applyClaudeAuthLocal", "Apply auth");
|
? t("applyCodexAuthLocal")
|
||||||
const exportClaudeAuthLabel = providerText(t, "exportClaudeAuthFile", "Export auth");
|
: "Apply auth";
|
||||||
|
const exportCodexAuthLabel =
|
||||||
|
typeof t.has === "function" && t.has("exportCodexAuthFile")
|
||||||
|
? t("exportCodexAuthFile")
|
||||||
|
: "Export auth";
|
||||||
|
const applyClaudeAuthLabel =
|
||||||
|
typeof t.has === "function" && t.has("applyClaudeAuthLocal")
|
||||||
|
? t("applyClaudeAuthLocal")
|
||||||
|
: "Apply auth";
|
||||||
|
const exportClaudeAuthLabel =
|
||||||
|
typeof t.has === "function" && t.has("exportClaudeAuthFile")
|
||||||
|
? t("exportClaudeAuthFile")
|
||||||
|
: "Export auth";
|
||||||
// Use useState + useEffect for impure Date.now() to avoid calling during render
|
// Use useState + useEffect for impure Date.now() to avoid calling during render
|
||||||
const [isCooldown, setIsCooldown] = useState(false);
|
const [isCooldown, setIsCooldown] = useState(false);
|
||||||
// T12: token expiry status — lazy init avoids calling Date.now() during render;
|
// T12: token expiry status — lazy init avoids calling Date.now() during render;
|
||||||
|
|||||||
@@ -22,10 +22,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { formatResetCountdown } from "@/shared/utils/formatting";
|
import { formatResetCountdown } from "@/shared/utils/formatting";
|
||||||
import type { ConnectionRowConnection } from "./ConnectionRow";
|
import type { ConnectionRowConnection } from "./ConnectionRow";
|
||||||
import { providerText } from "../providerPageHelpers";
|
|
||||||
|
|
||||||
export interface CoolingConnectionsPanelProps {
|
export interface CoolingConnectionsPanelProps {
|
||||||
readonly connections: readonly ConnectionRowConnection[];
|
readonly connections: readonly ConnectionRowConnection[];
|
||||||
@@ -39,7 +37,6 @@ function isCoolingNow(connection: ConnectionRowConnection, now: number): boolean
|
|||||||
|
|
||||||
export default function CoolingConnectionsPanel(props: CoolingConnectionsPanelProps) {
|
export default function CoolingConnectionsPanel(props: CoolingConnectionsPanelProps) {
|
||||||
const { connections } = props;
|
const { connections } = props;
|
||||||
const t = useTranslations("providers");
|
|
||||||
// Tick once per second so the human-readable countdown updates.
|
// Tick once per second so the human-readable countdown updates.
|
||||||
const [now, setNow] = useState<number>(() => Date.now());
|
const [now, setNow] = useState<number>(() => Date.now());
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -61,17 +58,12 @@ export default function CoolingConnectionsPanel(props: CoolingConnectionsPanelPr
|
|||||||
className="inline-block h-2 w-2 animate-pulse rounded-full bg-amber-500"
|
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">
|
<h3 className="text-sm font-medium text-amber-700 dark:text-amber-300">
|
||||||
{providerText(t, "coolingConnectionsTitle", "Currently cooling ({count})", {
|
Currently cooling ({cooling.length})
|
||||||
count: cooling.length,
|
|
||||||
})}
|
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-3 text-xs text-muted-foreground">
|
<p className="mb-3 text-xs text-muted-foreground">
|
||||||
{providerText(
|
These connections returned a 429 (rate-limit) on their last request. OmniRoute will skip
|
||||||
t,
|
them until the timer expires — no manual disable required.
|
||||||
"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>
|
</p>
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{cooling.map((c) => {
|
{cooling.map((c) => {
|
||||||
@@ -80,9 +72,7 @@ export default function CoolingConnectionsPanel(props: CoolingConnectionsPanelPr
|
|||||||
c.displayName ||
|
c.displayName ||
|
||||||
c.name ||
|
c.name ||
|
||||||
c.email ||
|
c.email ||
|
||||||
(c.id
|
(c.id ? `connection ${c.id.slice(0, 8)}` : "connection");
|
||||||
? `${providerText(t, "connectionFallback", "connection")} ${c.id.slice(0, 8)}`
|
|
||||||
: providerText(t, "connectionFallback", "connection"));
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={c.id ?? label}
|
key={c.id ?? label}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
effectivePreserveForProtocol,
|
effectivePreserveForProtocol,
|
||||||
effectiveUpstreamHeadersForProtocol,
|
effectiveUpstreamHeadersForProtocol,
|
||||||
formatProviderModelsErrorResponse,
|
formatProviderModelsErrorResponse,
|
||||||
providerText,
|
|
||||||
targetFormatBadgeI18nKey,
|
targetFormatBadgeI18nKey,
|
||||||
type CompatModelRow,
|
type CompatModelRow,
|
||||||
type CompatByProtocolMap,
|
type CompatByProtocolMap,
|
||||||
@@ -286,32 +285,17 @@ export default function CustomModelsSection({
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const detail = await formatProviderModelsErrorResponse(res);
|
const detail = await formatProviderModelsErrorResponse(res);
|
||||||
throw new Error(
|
throw new Error(detail || "Failed to save model endpoint settings");
|
||||||
detail ||
|
|
||||||
providerText(
|
|
||||||
t,
|
|
||||||
"failedSaveModelEndpointSettings",
|
|
||||||
"Failed to save model endpoint settings"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await fetchCustomModels();
|
await fetchCustomModels();
|
||||||
onModelsChanged?.();
|
onModelsChanged?.();
|
||||||
notify.success(
|
notify.success("Saved model endpoint settings");
|
||||||
providerText(t, "savedModelEndpointSettings", "Saved model endpoint settings")
|
|
||||||
);
|
|
||||||
cancelEdit();
|
cancelEdit();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to save custom model:", e);
|
console.error("Failed to save custom model:", e);
|
||||||
notify.error(
|
notify.error(
|
||||||
e instanceof Error && e.message
|
e instanceof Error && e.message ? e.message : "Failed to save model endpoint settings"
|
||||||
? e.message
|
|
||||||
: providerText(
|
|
||||||
t,
|
|
||||||
"failedSaveModelEndpointSettings",
|
|
||||||
"Failed to save model endpoint settings"
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setSavingModelId(null);
|
setSavingModelId(null);
|
||||||
@@ -321,9 +305,7 @@ export default function CustomModelsSection({
|
|||||||
const saveEdit = async (modelId: string) => {
|
const saveEdit = async (modelId: string) => {
|
||||||
if (!editingModelId || editingModelId !== modelId) return;
|
if (!editingModelId || editingModelId !== modelId) return;
|
||||||
if (!editingEndpoints.length) {
|
if (!editingEndpoints.length) {
|
||||||
notify.error(
|
notify.error("Select at least one supported endpoint");
|
||||||
providerText(t, "selectSupportedEndpoint", "Select at least one supported endpoint")
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,7 +429,7 @@ export default function CustomModelsSection({
|
|||||||
: ep === "embeddings"
|
: ep === "embeddings"
|
||||||
? `📐 ${t("supportedEndpointEmbeddings")}`
|
? `📐 ${t("supportedEndpointEmbeddings")}`
|
||||||
: ep === "rerank"
|
: ep === "rerank"
|
||||||
? providerText(t, "rerankEndpoint", "Rerank")
|
? "Rerank"
|
||||||
: ep === "images"
|
: ep === "images"
|
||||||
? `🖼️ ${t("supportedEndpointImages")}`
|
? `🖼️ ${t("supportedEndpointImages")}`
|
||||||
: `🔊 ${t("supportedEndpointAudio")}`}
|
: `🔊 ${t("supportedEndpointAudio")}`}
|
||||||
@@ -679,7 +661,7 @@ export default function CustomModelsSection({
|
|||||||
: ep === "embeddings"
|
: ep === "embeddings"
|
||||||
? `📐 ${t("supportedEndpointEmbeddings")}`
|
? `📐 ${t("supportedEndpointEmbeddings")}`
|
||||||
: ep === "rerank"
|
: ep === "rerank"
|
||||||
? providerText(t, "rerankEndpoint", "Rerank")
|
? "Rerank"
|
||||||
: ep === "images"
|
: ep === "images"
|
||||||
? `🖼️ ${t("supportedEndpointImages")}`
|
? `🖼️ ${t("supportedEndpointImages")}`
|
||||||
: `🔊 ${t("supportedEndpointAudio")}`}
|
: `🔊 ${t("supportedEndpointAudio")}`}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Modal } from "@/shared/components";
|
import { Modal } from "@/shared/components";
|
||||||
import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers";
|
import type { ProviderMessageTranslator } from "../providerPageHelpers";
|
||||||
|
|
||||||
type KimiCodeAuthMethodModalProps = {
|
type KimiCodeAuthMethodModalProps = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -50,9 +50,7 @@ export default function KimiCodeAuthMethodModal({
|
|||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<span className="material-symbols-outlined mt-0.5 text-primary">key</span>
|
<span className="material-symbols-outlined mt-0.5 text-primary">key</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="mb-1 font-semibold">
|
<h3 className="mb-1 font-semibold">Kimi Code API Key</h3>
|
||||||
{providerText(t, "kimiCodeApiKeyLabel", "Kimi Code API Key")}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-text-muted">{t("apiKeySecure")}</p>
|
<p className="text-sm text-text-muted">{t("apiKeySecure")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
UPSTREAM_HEADERS_UI_MAX,
|
UPSTREAM_HEADERS_UI_MAX,
|
||||||
headerRowsToRecord,
|
headerRowsToRecord,
|
||||||
compatProtocolLabelKey,
|
compatProtocolLabelKey,
|
||||||
providerText,
|
|
||||||
type HeaderDraftRow,
|
type HeaderDraftRow,
|
||||||
} from "../providerPageHelpers";
|
} from "../providerPageHelpers";
|
||||||
|
|
||||||
@@ -354,7 +353,7 @@ export default function ModelCompatPopover({
|
|||||||
{/* Param filters — model-level block/allow (#6625) */}
|
{/* Param filters — model-level block/allow (#6625) */}
|
||||||
<div className="mt-4 space-y-2.5">
|
<div className="mt-4 space-y-2.5">
|
||||||
<label className="block text-[11px] font-semibold text-text-main">
|
<label className="block text-[11px] font-semibold text-text-main">
|
||||||
{providerText(t, "compatParamFiltersLabel", "Param Filters")}
|
{t("compatParamFiltersLabel") ?? "Param Filters"}
|
||||||
</label>
|
</label>
|
||||||
<div>
|
<div>
|
||||||
<input
|
<input
|
||||||
@@ -370,11 +369,7 @@ export default function ModelCompatPopover({
|
|||||||
className="mb-1 w-full rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-[11px] font-mono text-text-main placeholder:text-text-muted focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 dark:border-zinc-600 dark:bg-zinc-900"
|
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">
|
<p className="text-[10px] text-text-muted">
|
||||||
{providerText(
|
{t("compatBlockedParamsHint") ?? "Blocked params (stripped from requests)"}
|
||||||
t,
|
|
||||||
"compatBlockedParamsHint",
|
|
||||||
"Blocked params (stripped from requests)"
|
|
||||||
)}
|
|
||||||
{paramSaving && ` ● ${t("compatSaving")}`}
|
{paramSaving && ` ● ${t("compatSaving")}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -392,11 +387,7 @@ export default function ModelCompatPopover({
|
|||||||
className="mb-1 w-full rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-[11px] font-mono text-text-main placeholder:text-text-muted focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 dark:border-zinc-600 dark:bg-zinc-900"
|
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">
|
<p className="text-[10px] text-text-muted">
|
||||||
{providerText(
|
{t("compatAllowedParamsHint") ?? "Allowed params (re-added after deny)"}
|
||||||
t,
|
|
||||||
"compatAllowedParamsHint",
|
|
||||||
"Allowed params (re-added after deny)"
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -411,7 +411,7 @@ export default function ModelRow({
|
|||||||
: testStatus === "ok"
|
: testStatus === "ok"
|
||||||
? "OK"
|
? "OK"
|
||||||
: testStatus === "error"
|
: testStatus === "error"
|
||||||
? providerText(t, "errorShort", "Error")
|
? "Error"
|
||||||
: t("testModel")
|
: t("testModel")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useTranslations } from "next-intl";
|
|||||||
import { NoAuthAccountCard, NoAuthProviderCard } from "@/shared/components";
|
import { NoAuthAccountCard, NoAuthProviderCard } from "@/shared/components";
|
||||||
import { getProviderAlias, supportsNoAuthProviderProxy } from "@/shared/constants/providers";
|
import { getProviderAlias, supportsNoAuthProviderProxy } from "@/shared/constants/providers";
|
||||||
import { useNotificationStore } from "@/store/notificationStore";
|
import { useNotificationStore } from "@/store/notificationStore";
|
||||||
import { providerText } from "../providerPageHelpers";
|
|
||||||
|
|
||||||
const ACCOUNT_PROVIDER_NAMES: Record<string, string> = {
|
const ACCOUNT_PROVIDER_NAMES: Record<string, string> = {
|
||||||
mimocode: "MiMoCode",
|
mimocode: "MiMoCode",
|
||||||
@@ -124,10 +123,7 @@ export default function NoAuthProviderControls({
|
|||||||
const res = await fetch("/api/dahl/tokens", { method: "POST" });
|
const res = await fetch("/api/dahl/tokens", { method: "POST" });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok || !data.token) {
|
if (!res.ok || !data.token) {
|
||||||
throw new Error(
|
throw new Error(data?.error || "Failed to create Dahl token");
|
||||||
data?.error ||
|
|
||||||
providerText(t, "createDahlTokenFailed", "Failed to create Dahl token")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return data.token as string;
|
return data.token as string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ export default function PassthroughModelRow({
|
|||||||
: testStatus === "ok"
|
: testStatus === "ok"
|
||||||
? "OK"
|
? "OK"
|
||||||
: testStatus === "error"
|
: testStatus === "error"
|
||||||
? providerText(t, "errorShort", "Error")
|
? "Error"
|
||||||
: t("testModel")
|
: t("testModel")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
// a single-kind panel or the LlmChatCard for standard LLM providers.
|
// a single-kind panel or the LlmChatCard for standard LLM providers.
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { LlmChatCard } from "@/app/(dashboard)/dashboard/media-providers/components/LlmChatCard";
|
import { LlmChatCard } from "@/app/(dashboard)/dashboard/media-providers/components/LlmChatCard";
|
||||||
import { ServiceKindTabs } from "@/app/(dashboard)/dashboard/media-providers/components/ServiceKindTabs";
|
import { ServiceKindTabs } from "@/app/(dashboard)/dashboard/media-providers/components/ServiceKindTabs";
|
||||||
import { EmbeddingExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/EmbeddingExampleCard";
|
import { EmbeddingExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/EmbeddingExampleCard";
|
||||||
@@ -19,7 +18,6 @@ import { VideoExampleCard } from "@/app/(dashboard)/dashboard/media-providers/co
|
|||||||
import { MusicExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/MusicExampleCard";
|
import { MusicExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/MusicExampleCard";
|
||||||
import type { ServiceKind } from "@/shared/constants/providers";
|
import type { ServiceKind } from "@/shared/constants/providers";
|
||||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||||||
import { providerText } from "../providerPageHelpers";
|
|
||||||
|
|
||||||
export const MEDIA_SERVICE_KINDS: ServiceKind[] = [
|
export const MEDIA_SERVICE_KINDS: ServiceKind[] = [
|
||||||
"embedding",
|
"embedding",
|
||||||
@@ -58,12 +56,12 @@ export function renderKindPanel(kind: ServiceKind, providerId: string): JSX.Elem
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ProviderPlaygroundPanel({ providerId }: { providerId: string }) {
|
export default function ProviderPlaygroundPanel({ providerId }: { providerId: string }) {
|
||||||
const t = useTranslations("providers");
|
|
||||||
// Resolve serviceKinds from AI_PROVIDERS.
|
// Resolve serviceKinds from AI_PROVIDERS.
|
||||||
// For providers without explicit serviceKinds (most LLM providers), we infer
|
// For providers without explicit serviceKinds (most LLM providers), we infer
|
||||||
// "llm" as the default.
|
// "llm" as the default.
|
||||||
const providerEntry = AI_PROVIDERS[providerId as keyof typeof AI_PROVIDERS] as
|
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 ?? [];
|
const rawKinds: string[] = providerEntry?.serviceKinds ?? [];
|
||||||
|
|
||||||
@@ -95,7 +93,7 @@ export default function ProviderPlaygroundPanel({ providerId }: { providerId: st
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<h2 className="text-lg font-semibold">{providerText(t, "playgroundTitle", "Playground")}</h2>
|
<h2 className="text-lg font-semibold">Playground</h2>
|
||||||
<ServiceKindTabs
|
<ServiceKindTabs
|
||||||
kinds={playgroundableKinds}
|
kinds={playgroundableKinds}
|
||||||
activeKind={activeKind}
|
activeKind={activeKind}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useState, useEffect } from "react";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Button, Badge, Input, Modal, Select } from "@/shared/components";
|
import { Button, Badge, Input, Modal, Select } from "@/shared/components";
|
||||||
import { CC_COMPATIBLE_DEFAULT_CHAT_PATH } from "../../providerDetailConstants";
|
import { CC_COMPATIBLE_DEFAULT_CHAT_PATH } from "../../providerDetailConstants";
|
||||||
import { providerText } from "../../providerPageHelpers";
|
|
||||||
interface EditCompatibleNodeModalNode {
|
interface EditCompatibleNodeModalNode {
|
||||||
id?: string;
|
id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -46,11 +45,9 @@ export default function EditCompatibleNodeModal({
|
|||||||
const [checkKey, setCheckKey] = useState("");
|
const [checkKey, setCheckKey] = useState("");
|
||||||
const [checkModelId, setCheckModelId] = useState("");
|
const [checkModelId, setCheckModelId] = useState("");
|
||||||
const [validating, setValidating] = useState(false);
|
const [validating, setValidating] = useState(false);
|
||||||
const [validationResult, setValidationResult] = useState<null | {
|
const [validationResult, setValidationResult] = useState<
|
||||||
valid: boolean;
|
null | { valid: boolean; error?: string | null; method?: string | null }
|
||||||
error?: string | null;
|
>(null);
|
||||||
method?: string | null;
|
|
||||||
}>(null);
|
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -133,10 +130,7 @@ export default function EditCompatibleNodeModal({
|
|||||||
method: data.method ?? null,
|
method: data.method ?? null,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setValidationResult({
|
setValidationResult({ valid: false, error: "Network error" });
|
||||||
valid: false,
|
|
||||||
error: providerText(t, "networkError", "Network error"),
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
setValidating(false);
|
setValidating(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { type CommandCodeAuthFlowState } from "../providerPageHelpers";
|
||||||
import { providerText, type CommandCodeAuthFlowState } from "../providerPageHelpers";
|
|
||||||
|
|
||||||
export type UseCommandCodeAuthParams = {
|
export type UseCommandCodeAuthParams = {
|
||||||
providerId: string;
|
providerId: string;
|
||||||
@@ -16,7 +15,6 @@ export function useCommandCodeAuth({
|
|||||||
setShowAddApiKeyModal,
|
setShowAddApiKeyModal,
|
||||||
notify,
|
notify,
|
||||||
}: UseCommandCodeAuthParams) {
|
}: UseCommandCodeAuthParams) {
|
||||||
const t = useTranslations("providers");
|
|
||||||
const [commandCodeAuthState, setCommandCodeAuthState] = useState<CommandCodeAuthFlowState>({
|
const [commandCodeAuthState, setCommandCodeAuthState] = useState<CommandCodeAuthFlowState>({
|
||||||
phase: "idle",
|
phase: "idle",
|
||||||
state: "",
|
state: "",
|
||||||
@@ -64,7 +62,7 @@ export function useCommandCodeAuth({
|
|||||||
setCommandCodeAuthState((current) => ({
|
setCommandCodeAuthState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
phase: "applying",
|
phase: "applying",
|
||||||
message: providerText(t, "commandCodeApplyingKey", "Applying browser-approved key…"),
|
message: "Applying browser-approved key…",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -76,9 +74,7 @@ export function useCommandCodeAuth({
|
|||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errorMessage =
|
const errorMessage = data.error || "Failed to apply Command Code auth";
|
||||||
data.error ||
|
|
||||||
providerText(t, "commandCodeApplyFailed", "Failed to apply Command Code auth");
|
|
||||||
setCommandCodeAuthState((current) => ({
|
setCommandCodeAuthState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
phase: "error",
|
phase: "error",
|
||||||
@@ -91,30 +87,26 @@ export function useCommandCodeAuth({
|
|||||||
setCommandCodeAuthState((current) => ({
|
setCommandCodeAuthState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
phase: "applied",
|
phase: "applied",
|
||||||
message: providerText(t, "commandCodeConnected", "Command Code connected"),
|
message: "Command Code connected",
|
||||||
}));
|
}));
|
||||||
commandCodeAuthWindowRef.current?.close?.();
|
commandCodeAuthWindowRef.current?.close?.();
|
||||||
commandCodeAuthWindowRef.current = null;
|
commandCodeAuthWindowRef.current = null;
|
||||||
await fetchConnections();
|
await fetchConnections();
|
||||||
handleCloseAddApiKeyModal();
|
handleCloseAddApiKeyModal();
|
||||||
notify.success(
|
notify.success("Command Code connection added");
|
||||||
providerText(t, "commandCodeConnectionAdded", "Command Code connection added")
|
|
||||||
);
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error applying Command Code auth:", error);
|
console.error("Error applying Command Code auth:", error);
|
||||||
setCommandCodeAuthState((current) => ({
|
setCommandCodeAuthState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
phase: "error",
|
phase: "error",
|
||||||
message: providerText(t, "commandCodeApplyFailed", "Failed to apply Command Code auth"),
|
message: "Failed to apply Command Code auth",
|
||||||
}));
|
}));
|
||||||
notify.error(
|
notify.error("Failed to apply Command Code auth");
|
||||||
providerText(t, "commandCodeApplyFailed", "Failed to apply Command Code auth")
|
|
||||||
);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[fetchConnections, handleCloseAddApiKeyModal, notify, t]
|
[fetchConnections, handleCloseAddApiKeyModal, notify]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleStartCommandCodeAuth = useCallback(async () => {
|
const handleStartCommandCodeAuth = useCallback(async () => {
|
||||||
@@ -132,7 +124,7 @@ export function useCommandCodeAuth({
|
|||||||
authUrl: "",
|
authUrl: "",
|
||||||
callbackUrl: "",
|
callbackUrl: "",
|
||||||
expiresAt: null,
|
expiresAt: null,
|
||||||
message: providerText(t, "commandCodeOpeningStudio", "Opening Command Code Studio…"),
|
message: "Opening Command Code Studio…",
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -143,9 +135,7 @@ export function useCommandCodeAuth({
|
|||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
|
|
||||||
if (!res.ok || !data.state || !data.authUrl) {
|
if (!res.ok || !data.state || !data.authUrl) {
|
||||||
const errorMessage =
|
const errorMessage = data.error || "Failed to start Command Code auth";
|
||||||
data.error ||
|
|
||||||
providerText(t, "commandCodeStartFailed", "Failed to start Command Code auth");
|
|
||||||
setCommandCodeAuthState((current) => ({
|
setCommandCodeAuthState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
phase: "error",
|
phase: "error",
|
||||||
@@ -162,11 +152,7 @@ export function useCommandCodeAuth({
|
|||||||
authUrl: data.authUrl,
|
authUrl: data.authUrl,
|
||||||
callbackUrl: data.callbackUrl || "",
|
callbackUrl: data.callbackUrl || "",
|
||||||
expiresAt: data.expiresAt || null,
|
expiresAt: data.expiresAt || null,
|
||||||
message: providerText(
|
message: "Open the auth URL, approve access, then paste the returned key/JSON/URL below…",
|
||||||
t,
|
|
||||||
"commandCodeApprovalInstructions",
|
|
||||||
"Open the auth URL, approve access, then paste the returned key/JSON/URL below…"
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (popup) {
|
if (popup) {
|
||||||
@@ -183,19 +169,9 @@ export function useCommandCodeAuth({
|
|||||||
setCommandCodeAuthState((current) => ({
|
setCommandCodeAuthState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
phase: "error",
|
phase: "error",
|
||||||
message: providerText(
|
message: "Popup blocked. Please allow popups and try Command Code Connect again.",
|
||||||
t,
|
|
||||||
"commandCodePopupBlocked",
|
|
||||||
"Popup blocked. Please allow popups and try Command Code Connect again."
|
|
||||||
),
|
|
||||||
}));
|
}));
|
||||||
notify.error(
|
notify.error("Popup blocked. Please allow popups and try Command Code Connect again.");
|
||||||
providerText(
|
|
||||||
t,
|
|
||||||
"commandCodePopupBlocked",
|
|
||||||
"Popup blocked. Please allow popups and try Command Code Connect again."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
commandCodeAuthWindowRef.current = fallbackPopup;
|
commandCodeAuthWindowRef.current = fallbackPopup;
|
||||||
@@ -207,11 +183,11 @@ export function useCommandCodeAuth({
|
|||||||
setCommandCodeAuthState((current) => ({
|
setCommandCodeAuthState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
phase: "expired",
|
phase: "expired",
|
||||||
message: providerText(t, "commandCodeLinkExpired", "Command Code link expired"),
|
message: "Command Code link expired",
|
||||||
}));
|
}));
|
||||||
commandCodeAuthWindowRef.current?.close?.();
|
commandCodeAuthWindowRef.current?.close?.();
|
||||||
commandCodeAuthWindowRef.current = null;
|
commandCodeAuthWindowRef.current = null;
|
||||||
notify.error(providerText(t, "commandCodeAuthExpired", "Command Code auth expired"));
|
notify.error("Command Code auth expired");
|
||||||
clearCommandCodeAuthTimer();
|
clearCommandCodeAuthTimer();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -230,11 +206,11 @@ export function useCommandCodeAuth({
|
|||||||
setCommandCodeAuthState((current) => ({
|
setCommandCodeAuthState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
phase: "expired",
|
phase: "expired",
|
||||||
message: providerText(t, "commandCodeLinkExpired", "Command Code link expired"),
|
message: "Command Code link expired",
|
||||||
}));
|
}));
|
||||||
commandCodeAuthWindowRef.current?.close?.();
|
commandCodeAuthWindowRef.current?.close?.();
|
||||||
commandCodeAuthWindowRef.current = null;
|
commandCodeAuthWindowRef.current = null;
|
||||||
notify.error(providerText(t, "commandCodeAuthExpired", "Command Code auth expired"));
|
notify.error("Command Code auth expired");
|
||||||
clearCommandCodeAuthTimer();
|
clearCommandCodeAuthTimer();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -243,15 +219,13 @@ export function useCommandCodeAuth({
|
|||||||
setCommandCodeAuthState((current) => ({
|
setCommandCodeAuthState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
phase: "applied",
|
phase: "applied",
|
||||||
message: providerText(t, "commandCodeConnected", "Command Code connected"),
|
message: "Command Code connected",
|
||||||
}));
|
}));
|
||||||
commandCodeAuthWindowRef.current?.close?.();
|
commandCodeAuthWindowRef.current?.close?.();
|
||||||
commandCodeAuthWindowRef.current = null;
|
commandCodeAuthWindowRef.current = null;
|
||||||
await fetchConnections();
|
await fetchConnections();
|
||||||
handleCloseAddApiKeyModal();
|
handleCloseAddApiKeyModal();
|
||||||
notify.success(
|
notify.success("Command Code connection added");
|
||||||
providerText(t, "commandCodeConnectionAdded", "Command Code connection added")
|
|
||||||
);
|
|
||||||
clearCommandCodeAuthTimer();
|
clearCommandCodeAuthTimer();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -260,11 +234,7 @@ export function useCommandCodeAuth({
|
|||||||
setCommandCodeAuthState((current) => ({
|
setCommandCodeAuthState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
phase: "received",
|
phase: "received",
|
||||||
message: providerText(
|
message: "Browser approved, applying…",
|
||||||
t,
|
|
||||||
"commandCodeApplyingApproval",
|
|
||||||
"Browser approved, applying…"
|
|
||||||
),
|
|
||||||
}));
|
}));
|
||||||
clearCommandCodeAuthTimer();
|
clearCommandCodeAuthTimer();
|
||||||
await handleCommandCodeAuthApply(
|
await handleCommandCodeAuthApply(
|
||||||
@@ -288,9 +258,9 @@ export function useCommandCodeAuth({
|
|||||||
setCommandCodeAuthState((current) => ({
|
setCommandCodeAuthState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
phase: "error",
|
phase: "error",
|
||||||
message: providerText(t, "commandCodeStartFailed", "Failed to start Command Code auth"),
|
message: "Failed to start Command Code auth",
|
||||||
}));
|
}));
|
||||||
notify.error(providerText(t, "commandCodeStartFailed", "Failed to start Command Code auth"));
|
notify.error("Failed to start Command Code auth");
|
||||||
popup?.close?.();
|
popup?.close?.();
|
||||||
commandCodeAuthWindowRef.current = null;
|
commandCodeAuthWindowRef.current = null;
|
||||||
clearCommandCodeAuthTimer();
|
clearCommandCodeAuthTimer();
|
||||||
@@ -302,7 +272,6 @@ export function useCommandCodeAuth({
|
|||||||
fetchConnections,
|
fetchConnections,
|
||||||
handleCommandCodeAuthApply,
|
handleCommandCodeAuthApply,
|
||||||
notify,
|
notify,
|
||||||
t,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleOpenCommandCodeConnect = useCallback(() => {
|
const handleOpenCommandCodeConnect = useCallback(() => {
|
||||||
|
|||||||
@@ -11,8 +11,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { providerText } from "../providerPageHelpers";
|
|
||||||
|
|
||||||
export interface ConnectionDeleteConfirmTarget {
|
export interface ConnectionDeleteConfirmTarget {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -36,7 +34,6 @@ export function useConnectionDeleteConfirm(
|
|||||||
fetchConnections: () => Promise<void>,
|
fetchConnections: () => Promise<void>,
|
||||||
notify: NotifyLike
|
notify: NotifyLike
|
||||||
): ConnectionDeleteConfirmState {
|
): ConnectionDeleteConfirmState {
|
||||||
const t = useTranslations("providers");
|
|
||||||
const [connection, setConnection] = useState<ConnectionDeleteConfirmTarget | null>(null);
|
const [connection, setConnection] = useState<ConnectionDeleteConfirmTarget | null>(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
@@ -59,24 +56,24 @@ export function useConnectionDeleteConfirm(
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/providers/${connectionId}`, { method: "DELETE" });
|
const res = await fetch(`/api/providers/${connectionId}`, { method: "DELETE" });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
notify.success(providerText(t, "connectionDeleted", "Connection deleted"));
|
notify.success("Connection deleted");
|
||||||
await fetchConnections();
|
await fetchConnections();
|
||||||
} else {
|
} else {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
const message =
|
const message =
|
||||||
(typeof data?.error === "string" && data.error) ||
|
(typeof data?.error === "string" && data.error) ||
|
||||||
data?.error?.message ||
|
data?.error?.message ||
|
||||||
providerText(t, "failedDeleteConnection", "Failed to delete connection");
|
"Failed to delete connection";
|
||||||
notify.error(message);
|
notify.error(message);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting connection:", error);
|
console.error("Error deleting connection:", error);
|
||||||
notify.error(providerText(t, "failedDeleteConnection", "Failed to delete connection"));
|
notify.error("Failed to delete connection");
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
setConnection(null);
|
setConnection(null);
|
||||||
}
|
}
|
||||||
}, [connection, fetchConnections, notify, t]);
|
}, [connection, fetchConnections, notify]);
|
||||||
|
|
||||||
return { connection, deleting, request, confirm, cancel };
|
return { connection, deleting, request, confirm, cancel };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -316,13 +316,11 @@ export function useModelVisibilityHandlers({
|
|||||||
// extractApiErrorMessage coerces any object-shaped `error` (e.g. a Zod
|
// extractApiErrorMessage coerces any object-shaped `error` (e.g. a Zod
|
||||||
// format object) to a string so notify.error never hands the toast a
|
// format object) to a string so notify.error never hands the toast a
|
||||||
// non-string child (React #31 → frozen page).
|
// non-string child (React #31 → frozen page).
|
||||||
notify.error(
|
notify.error(extractApiErrorMessage(data, "Model test failed"));
|
||||||
extractApiErrorMessage(data, providerText(t, "modelTestFailed", "Model test failed"))
|
|
||||||
);
|
|
||||||
setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" }));
|
setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" }));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notify.error(providerText(t, "modelTestNetworkError", "Network error testing model"));
|
notify.error("Network error testing model");
|
||||||
setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" }));
|
setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" }));
|
||||||
} finally {
|
} finally {
|
||||||
setTestingModelId(null);
|
setTestingModelId(null);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { useNotificationStore } from "@/store/notificationStore";
|
|||||||
import { isClaudeCodeCompatibleProvider } from "@/shared/constants/providers";
|
import { isClaudeCodeCompatibleProvider } from "@/shared/constants/providers";
|
||||||
import type { ConnectionRowConnection } from "../components/ConnectionRow";
|
import type { ConnectionRowConnection } from "../components/ConnectionRow";
|
||||||
import { connectionBelongsToProviderPage } from "../../providerPageUtils";
|
import { connectionBelongsToProviderPage } from "../../providerPageUtils";
|
||||||
import { normalizeCodexLimitPolicy, providerText } from "../providerPageHelpers";
|
import { normalizeCodexLimitPolicy } from "../providerPageHelpers";
|
||||||
import { useProviderQuotaVisibility } from "./useProviderQuotaVisibility";
|
import { useProviderQuotaVisibility } from "./useProviderQuotaVisibility";
|
||||||
import { useReorderByAvailability } from "./useReorderByAvailability";
|
import { useReorderByAvailability } from "./useReorderByAvailability";
|
||||||
import {
|
import {
|
||||||
@@ -378,14 +378,7 @@ export function useProviderConnections(
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
notify.error(
|
notify.error(data.error || "Failed to update Claude extra-usage policy");
|
||||||
data.error ||
|
|
||||||
providerText(
|
|
||||||
t,
|
|
||||||
"failedUpdateClaudeExtraUsagePolicy",
|
|
||||||
"Failed to update Claude extra-usage policy"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,26 +408,12 @@ export function useProviderConnections(
|
|||||||
);
|
);
|
||||||
notify.success(
|
notify.success(
|
||||||
enabled
|
enabled
|
||||||
? providerText(
|
? "Claude extra-usage blocking enabled (extra usage will be blocked)"
|
||||||
t,
|
: "Claude extra-usage blocking disabled (extra usage is allowed)"
|
||||||
"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) {
|
} catch (error) {
|
||||||
console.error("Error toggling Claude extra-usage policy:", error);
|
console.error("Error toggling Claude extra-usage policy:", error);
|
||||||
notify.error(
|
notify.error("Failed to update Claude extra-usage policy");
|
||||||
providerText(
|
|
||||||
t,
|
|
||||||
"failedUpdateClaudeExtraUsagePolicy",
|
|
||||||
"Failed to update Claude extra-usage policy"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -468,10 +447,7 @@ export function useProviderConnections(
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
notify.error(
|
notify.error(data.error || "Failed to update Codex limit policy");
|
||||||
data.error ||
|
|
||||||
providerText(t, "failedUpdateCodexLimitPolicy", "Failed to update Codex limit policy")
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,12 +464,10 @@ export function useProviderConnections(
|
|||||||
: connection
|
: connection
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
notify.success(providerText(t, "codexLimitPolicyUpdated", "Codex limit policy updated"));
|
notify.success("Codex limit policy updated");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error toggling Codex quota policy:", error);
|
console.error("Error toggling Codex quota policy:", error);
|
||||||
notify.error(
|
notify.error("Failed to update Codex limit policy");
|
||||||
providerText(t, "failedUpdateCodexLimitPolicy", "Failed to update Codex limit policy")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -507,27 +481,18 @@ export function useProviderConnections(
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
notify.error(
|
notify.error(data.error || "Failed to update CLIProxyAPI routing");
|
||||||
data.error ||
|
|
||||||
providerText(t, "failedUpdateCliproxyRouting", "Failed to update CLIProxyAPI routing")
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setCpaProviderEnabled(enabled);
|
setCpaProviderEnabled(enabled);
|
||||||
notify.success(
|
notify.success(
|
||||||
enabled
|
enabled
|
||||||
? providerText(
|
? "Requests now route through CLIProxyAPI (deeper emulation)"
|
||||||
t,
|
: "Requests now use native OmniRoute (direct)"
|
||||||
"cliproxyRoutingEnabled",
|
|
||||||
"Requests now route through CLIProxyAPI (deeper emulation)"
|
|
||||||
)
|
|
||||||
: providerText(t, "cliproxyRoutingDisabled", "Requests now use native OmniRoute (direct)")
|
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
notify.error(
|
notify.error("Failed to update CLIProxyAPI routing");
|
||||||
providerText(t, "failedUpdateCliproxyRouting", "Failed to update CLIProxyAPI routing")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -699,10 +664,10 @@ export function useProviderConnections(
|
|||||||
if (onAfter) await onAfter();
|
if (onAfter) await onAfter();
|
||||||
} else {
|
} else {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
notify.error(data.error || providerText(t, "batchDeleteFailed", "Batch delete failed"));
|
notify.error(data.error || "Batch delete failed");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
notify.error(providerText(t, "batchDeleteNetworkError", "Network error during batch delete"));
|
notify.error("Network error during batch delete");
|
||||||
} finally {
|
} finally {
|
||||||
setBatchDeleting(false);
|
setBatchDeleting(false);
|
||||||
}
|
}
|
||||||
@@ -724,11 +689,7 @@ export function useProviderConnections(
|
|||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
throw new Error(
|
throw new Error(data.error?.message || data.error || "Batch update failed");
|
||||||
data.error?.message ||
|
|
||||||
data.error ||
|
|
||||||
providerText(t, "batchUpdateFailed", "Batch update failed")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
updated += data.updated ?? 0;
|
updated += data.updated ?? 0;
|
||||||
@@ -749,10 +710,7 @@ export function useProviderConnections(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
notify.error(
|
notify.error(error?.message || "Network error during batch update");
|
||||||
error?.message ||
|
|
||||||
providerText(t, "batchUpdateNetworkError", "Network error during batch update")
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setBatchUpdating(null);
|
setBatchUpdating(null);
|
||||||
}
|
}
|
||||||
@@ -847,13 +805,7 @@ export function useProviderConnections(
|
|||||||
const proxiesData = await proxiesRes.json();
|
const proxiesData = await proxiesRes.json();
|
||||||
const savedProxies = (proxiesData?.items || []).filter((p: any) => p.status === "active");
|
const savedProxies = (proxiesData?.items || []).filter((p: any) => p.status === "active");
|
||||||
if (savedProxies.length === 0) {
|
if (savedProxies.length === 0) {
|
||||||
notify.error(
|
notify.error("No saved proxies found. Add proxies in Settings → Proxy first.");
|
||||||
providerText(
|
|
||||||
t,
|
|
||||||
"noSavedProxies",
|
|
||||||
"No saved proxies found. Add proxies in Settings → Proxy first."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -904,16 +856,11 @@ export function useProviderConnections(
|
|||||||
await fetchConnections();
|
await fetchConnections();
|
||||||
const tagLabel = tagFilter ? `"${tagFilter}" ` : "";
|
const tagLabel = tagFilter ? `"${tagFilter}" ` : "";
|
||||||
notify.success(
|
notify.success(
|
||||||
providerText(
|
`Distributed ${assigned} proxy assignment(s) across ${tagLabel}${sorted.length} connection(s).`
|
||||||
t,
|
|
||||||
"proxiesDistributed",
|
|
||||||
"Distributed {assigned} proxy assignment(s) across {tagLabel}{total} connection(s).",
|
|
||||||
{ assigned, tagLabel, total: sorted.length }
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error distributing proxies:", err);
|
console.error("Error distributing proxies:", err);
|
||||||
notify.error(providerText(t, "failedDistributeProxies", "Failed to distribute proxies."));
|
notify.error("Failed to distribute proxies.");
|
||||||
} finally {
|
} finally {
|
||||||
setDistributingProxies(false);
|
setDistributingProxies(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useNotificationStore } from "@/store/notificationStore";
|
import { useNotificationStore } from "@/store/notificationStore";
|
||||||
import { providerText, type CompatModelRow } from "../providerPageHelpers";
|
import type { CompatModelRow } from "../providerPageHelpers";
|
||||||
|
|
||||||
// ──── types ─────────────────────────────────────────────────────────────────
|
// ──── types ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -79,13 +79,11 @@ export function useProviderModels(
|
|||||||
notify.success(t("setAliasSuccess", { alias }));
|
notify.success(t("setAliasSuccess", { alias }));
|
||||||
} else {
|
} else {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
notify.error(
|
notify.error(data?.error?.message || "Failed to set alias");
|
||||||
data?.error?.message || providerText(t, "failedSetAlias", "Failed to set alias")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error setting alias:", error);
|
console.log("Error setting alias:", error);
|
||||||
notify.error(providerText(t, "networkErrorSettingAlias", "Network error setting alias"));
|
notify.error("Network error setting alias");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[fetchAliases, t, notify]
|
[fetchAliases, t, notify]
|
||||||
@@ -102,13 +100,11 @@ export function useProviderModels(
|
|||||||
notify.success(t("deleteAliasSuccess", { alias }));
|
notify.success(t("deleteAliasSuccess", { alias }));
|
||||||
} else {
|
} else {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
notify.error(
|
notify.error(data?.error?.message || "Failed to delete alias");
|
||||||
data?.error?.message || providerText(t, "failedDeleteAlias", "Failed to delete alias")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error deleting alias:", error);
|
console.log("Error deleting alias:", error);
|
||||||
notify.error(providerText(t, "networkErrorDeletingAlias", "Network error deleting alias"));
|
notify.error("Network error deleting alias");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[fetchAliases, t, notify]
|
[fetchAliases, t, notify]
|
||||||
@@ -117,9 +113,10 @@ export function useProviderModels(
|
|||||||
const fetchProviderModelMeta = useCallback(async () => {
|
const fetchProviderModelMeta = useCallback(async () => {
|
||||||
if (isSearchProvider) return;
|
if (isSearchProvider) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/provider-models?provider=${encodeURIComponent(providerId)}`, {
|
const res = await fetch(
|
||||||
cache: "no-store",
|
`/api/provider-models?provider=${encodeURIComponent(providerId)}`,
|
||||||
});
|
{ cache: "no-store" }
|
||||||
|
);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setModelMeta({
|
setModelMeta({
|
||||||
|
|||||||
@@ -115,7 +115,9 @@ export function useProviderSettings(providerId: string): UseProviderSettingsRetu
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isCurrentRequest()) return;
|
if (!isCurrentRequest()) return;
|
||||||
setCodexSettingsLoaded(false);
|
setCodexSettingsLoaded(false);
|
||||||
setCodexSettingsLoadError(error instanceof Error ? error.message : "Failed to load settings");
|
setCodexSettingsLoadError(
|
||||||
|
error instanceof Error ? error.message : "Failed to load settings"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [providerId]);
|
}, [providerId]);
|
||||||
|
|
||||||
@@ -182,20 +184,15 @@ export function useProviderSettings(providerId: string): UseProviderSettingsRetu
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
setCodexGlobalServiceMode(previousMode);
|
setCodexGlobalServiceMode(previousMode);
|
||||||
notify.error(
|
notify.error(data.error || "Failed to update Codex service mode");
|
||||||
data.error ||
|
|
||||||
providerText(t, "failedUpdateCodexServiceMode", "Failed to update Codex service mode")
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
notify.success(providerText(t, "codexServiceModeUpdated", "Codex service mode updated"));
|
notify.success("Codex service mode updated");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setCodexGlobalServiceMode(previousMode);
|
setCodexGlobalServiceMode(previousMode);
|
||||||
console.error("Error updating Codex service mode:", error);
|
console.error("Error updating Codex service mode:", error);
|
||||||
notify.error(
|
notify.error("Failed to update Codex service mode");
|
||||||
providerText(t, "failedUpdateCodexServiceMode", "Failed to update Codex service mode")
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setSavingCodexGlobalServiceMode(false);
|
setSavingCodexGlobalServiceMode(false);
|
||||||
}
|
}
|
||||||
@@ -218,14 +215,7 @@ export function useProviderSettings(providerId: string): UseProviderSettingsRetu
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
setPreferClaudeCodeForUnprefixedClaudeModels(previous);
|
setPreferClaudeCodeForUnprefixedClaudeModels(previous);
|
||||||
notify.error(
|
notify.error(data.error || "Failed to update Claude Code routing preference");
|
||||||
data.error ||
|
|
||||||
providerText(
|
|
||||||
t,
|
|
||||||
"failedUpdateClaudeRoutingPreference",
|
|
||||||
"Failed to update Claude Code routing preference"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,26 +227,14 @@ export function useProviderSettings(providerId: string): UseProviderSettingsRetu
|
|||||||
}
|
}
|
||||||
notify.success(
|
notify.success(
|
||||||
enabled
|
enabled
|
||||||
? providerText(
|
? "Unprefixed Claude models now prefer Claude Code"
|
||||||
t,
|
: "Unprefixed Claude models no longer prefer Claude Code"
|
||||||
"claudeRoutingPreferenceEnabled",
|
|
||||||
"Unprefixed Claude models now prefer Claude Code"
|
|
||||||
)
|
|
||||||
: providerText(
|
|
||||||
t,
|
|
||||||
"claudeRoutingPreferenceDisabled",
|
|
||||||
"Unprefixed Claude models no longer prefer Claude Code"
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setPreferClaudeCodeForUnprefixedClaudeModels(previous);
|
setPreferClaudeCodeForUnprefixedClaudeModels(previous);
|
||||||
console.error("Error updating Claude Code routing preference:", error);
|
console.error("Error updating Claude Code routing preference:", error);
|
||||||
notify.error(
|
notify.error(
|
||||||
providerText(
|
providerText(t, "failedUpdateClaudeRoutingPreference", "Failed to update Claude Code routing preference")
|
||||||
t,
|
|
||||||
"failedUpdateClaudeRoutingPreference",
|
|
||||||
"Failed to update Claude Code routing preference"
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setSavingClaudeRoutingPreference(false);
|
setSavingClaudeRoutingPreference(false);
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
CLIENT_IDENTITY_PROFILE_OPTIONS,
|
CLIENT_IDENTITY_PROFILE_OPTIONS,
|
||||||
getClientIdentityProfileHeaders,
|
getClientIdentityProfileHeaders,
|
||||||
} from "@/shared/constants/clientIdentityProfiles";
|
} from "@/shared/constants/clientIdentityProfiles";
|
||||||
import { providerText } from "../[id]/providerPageHelpers";
|
|
||||||
|
|
||||||
type CompatibleMode = "openai" | "anthropic" | "cc";
|
type CompatibleMode = "openai" | "anthropic" | "cc";
|
||||||
type CompatibleProviderNode = { id: string } & Record<string, unknown>;
|
type CompatibleProviderNode = { id: string } & Record<string, unknown>;
|
||||||
@@ -101,11 +100,9 @@ export default function AddCompatibleProviderModal({
|
|||||||
const [checkKey, setCheckKey] = useState("");
|
const [checkKey, setCheckKey] = useState("");
|
||||||
const [checkModelId, setCheckModelId] = useState("");
|
const [checkModelId, setCheckModelId] = useState("");
|
||||||
const [validating, setValidating] = useState(false);
|
const [validating, setValidating] = useState(false);
|
||||||
const [validationResult, setValidationResult] = useState<null | {
|
const [validationResult, setValidationResult] = useState<
|
||||||
valid: boolean;
|
null | { valid: boolean; error?: string | null; method?: string | null }
|
||||||
error?: string | null;
|
>(null);
|
||||||
method?: string | null;
|
|
||||||
}>(null);
|
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
|
||||||
const apiTypeOptions = useMemo(
|
const apiTypeOptions = useMemo(
|
||||||
@@ -245,10 +242,7 @@ export default function AddCompatibleProviderModal({
|
|||||||
method: data.method ?? null,
|
method: data.method ?? null,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setValidationResult({
|
setValidationResult({ valid: false, error: "Network error" });
|
||||||
valid: false,
|
|
||||||
error: providerText(t, "networkError", "Network error"),
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
setValidating(false);
|
setValidating(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
} from "@/shared/constants/providers";
|
} from "@/shared/constants/providers";
|
||||||
|
|
||||||
import { CategoryDot } from "./CategoryDot";
|
import { CategoryDot } from "./CategoryDot";
|
||||||
import { isCheaperInferenceProviderId, isKimiPartnerProviderId } from "../featuredProviders";
|
import { isKimiPartnerProviderId } from "../featuredProviders";
|
||||||
|
|
||||||
interface ProviderStats {
|
interface ProviderStats {
|
||||||
total?: number;
|
total?: number;
|
||||||
@@ -30,17 +30,17 @@ interface ProviderStats {
|
|||||||
codexServiceTier?: "default" | "priority" | "flex" | null;
|
codexServiceTier?: "default" | "priority" | "flex" | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const KIND_LABEL_KEYS: Record<string, { key: string; fallback: string }> = {
|
const KIND_LABEL: Record<string, string> = {
|
||||||
llm: { key: "serviceKindChat", fallback: "Chat" },
|
llm: "Chat",
|
||||||
embedding: { key: "serviceKindEmbedding", fallback: "Embed" },
|
embedding: "Embed",
|
||||||
image: { key: "serviceKindImage", fallback: "Image" },
|
image: "Image",
|
||||||
imageToText: { key: "serviceKindImageToText", fallback: "I→T" },
|
imageToText: "I→T",
|
||||||
tts: { key: "serviceKindTts", fallback: "TTS" },
|
tts: "TTS",
|
||||||
stt: { key: "serviceKindStt", fallback: "STT" },
|
stt: "STT",
|
||||||
webSearch: { key: "serviceKindWebSearch", fallback: "Search" },
|
webSearch: "Search",
|
||||||
webFetch: { key: "serviceKindWebFetch", fallback: "Fetch" },
|
webFetch: "Fetch",
|
||||||
video: { key: "serviceKindVideo", fallback: "Video" },
|
video: "Video",
|
||||||
music: { key: "serviceKindMusic", fallback: "Music" },
|
music: "Music",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Maps a compatible-provider `apiType` to its `KIND_LABEL` key (#6936: non-chat
|
/** Maps a compatible-provider `apiType` to its `KIND_LABEL` key (#6936: non-chat
|
||||||
@@ -166,10 +166,6 @@ const ProviderCard = forwardRef<ProviderCardHandle, ProviderCardProps>(function
|
|||||||
const t = useTranslations("providers");
|
const t = useTranslations("providers");
|
||||||
const tc = useTranslations("common");
|
const tc = useTranslations("common");
|
||||||
const tp = useTranslations("miniPlayground");
|
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 [testExpanded, setTestExpanded] = useState<boolean>(false);
|
||||||
const innerRef = useRef<HTMLDivElement>(null);
|
const innerRef = useRef<HTMLDivElement>(null);
|
||||||
const linkElementRef = useRef<HTMLAnchorElement>(null);
|
const linkElementRef = useRef<HTMLAnchorElement>(null);
|
||||||
@@ -227,11 +223,9 @@ const ProviderCard = forwardRef<ProviderCardHandle, ProviderCardProps>(function
|
|||||||
const isCompatible = isOpenAICompatibleProvider(providerId);
|
const isCompatible = isOpenAICompatibleProvider(providerId);
|
||||||
const isCcCompatible = isClaudeCodeCompatibleProvider(providerId);
|
const isCcCompatible = isClaudeCodeCompatibleProvider(providerId);
|
||||||
const isAnthropicCompatible = isAnthropicCompatibleProvider(providerId) && !isCcCompatible;
|
const isAnthropicCompatible = isAnthropicCompatibleProvider(providerId) && !isCcCompatible;
|
||||||
// Open Source Friend highlights (Kimi 2026-07, Cheaper Inference 2026-07): UI-only
|
// Kimi (Moonshot AI) official-partnership highlight (2026-07): UI-only accent,
|
||||||
// accents, see featuredProviders.ts — never affect routing/fallback order.
|
// see featuredProviders.ts — never affects routing/fallback order.
|
||||||
const isKimiPartner = isKimiPartnerProviderId(provider.id || providerId);
|
const isKimiPartner = isKimiPartnerProviderId(provider.id || providerId);
|
||||||
const isCheaperInferencePartner = isCheaperInferenceProviderId(provider.id || providerId);
|
|
||||||
const isSponsorPartner = isKimiPartner || isCheaperInferencePartner;
|
|
||||||
const codexServiceTierLabel =
|
const codexServiceTierLabel =
|
||||||
stats.codexServiceTier === "flex"
|
stats.codexServiceTier === "flex"
|
||||||
? providerText(t, "codexTierFlexLabel", "Flex")
|
? providerText(t, "codexTierFlexLabel", "Flex")
|
||||||
@@ -273,24 +267,6 @@ const ProviderCard = forwardRef<ProviderCardHandle, ProviderCardProps>(function
|
|||||||
</span>
|
</span>
|
||||||
) : null;
|
) : 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> = {
|
const dotLabels: Record<string, string> = {
|
||||||
free: tc("free"),
|
free: tc("free"),
|
||||||
"no-auth": t("noAuthLabel"),
|
"no-auth": t("noAuthLabel"),
|
||||||
@@ -342,12 +318,7 @@ const ProviderCard = forwardRef<ProviderCardHandle, ProviderCardProps>(function
|
|||||||
// is a raw (non-token) brand hex, not a theme color. Keep the hex in
|
// is a raw (non-token) brand hex, not a theme color. Keep the hex in
|
||||||
// sync with KIMI_BRAND_COLOR (featuredProviders.ts).
|
// 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)]"
|
"border-2 border-[#1783FF]/70 hover:border-[#1783FF]/90 shadow-[inset_0_0_0_100px_rgba(23,131,255,0.035),0_4px_16px_-4px_rgba(23,131,255,0.45)]"
|
||||||
: isCheaperInferencePartner
|
: "hover:border-primary/40"
|
||||||
? // 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" : ""}`}
|
} ${allDisabled ? "opacity-50" : ""} ${provider.deprecated ? "opacity-60" : ""}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-2 h-full">
|
<div className="flex flex-col gap-2 h-full">
|
||||||
@@ -421,24 +392,23 @@ const ProviderCard = forwardRef<ProviderCardHandle, ProviderCardProps>(function
|
|||||||
isCompatible ||
|
isCompatible ||
|
||||||
isCcCompatible ||
|
isCcCompatible ||
|
||||||
isAnthropicCompatible ||
|
isAnthropicCompatible ||
|
||||||
isSponsorPartner) && (
|
isKimiPartner) && (
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
{kimiOfficialSupporterChip}
|
{kimiOfficialSupporterChip}
|
||||||
{cheaperInferenceSupporterChip}
|
|
||||||
{provider.serviceKinds?.map((k) => (
|
{provider.serviceKinds?.map((k) => (
|
||||||
<span
|
<span
|
||||||
key={k}
|
key={k}
|
||||||
className="text-[10px] px-1.5 py-0.5 rounded bg-bg-subtle border border-border text-text-muted leading-none"
|
className="text-[10px] px-1.5 py-0.5 rounded bg-bg-subtle border border-border text-text-muted leading-none"
|
||||||
>
|
>
|
||||||
{kindLabel(k)}
|
{KIND_LABEL[k] ?? k}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{isCompatible && (
|
{isCompatible && (
|
||||||
<Badge variant="default" size="sm">
|
<Badge variant="default" size="sm">
|
||||||
{provider.apiType === "responses"
|
{provider.apiType === "responses"
|
||||||
? t("responses")
|
? t("responses")
|
||||||
: kindLabel(COMPATIBLE_API_TYPE_KIND[provider.apiType ?? ""] ?? "") ||
|
: (KIND_LABEL[COMPATIBLE_API_TYPE_KIND[provider.apiType ?? ""] ?? ""] ??
|
||||||
t("chat")}
|
t("chat"))}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{isCcCompatible && (
|
{isCcCompatible && (
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user