mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-09 00:32:13 +03:00
Compare commits
26 Commits
feat/plugi
...
fix/i18n-h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2925fde9f | ||
|
|
9a64e0e17d | ||
|
|
e2eba05e5c | ||
|
|
9e628e73fa | ||
|
|
9061d7c70d | ||
|
|
7163081f5e | ||
|
|
a72e1656eb | ||
|
|
84b1e5e12f | ||
|
|
92e8960f77 | ||
|
|
743ccc895a | ||
|
|
35405be602 | ||
|
|
cfb44dab91 | ||
|
|
d53f9bd813 | ||
|
|
e16865e394 | ||
|
|
8846f08c73 | ||
|
|
b532894a3e | ||
|
|
7b2e4b4837 | ||
|
|
fc35dc248f | ||
|
|
ec150a0069 | ||
|
|
564c204efe | ||
|
|
b38f3a4c02 | ||
|
|
0ef50886ef | ||
|
|
8fac6bcd48 | ||
|
|
45c91e22c2 | ||
|
|
1b5f7dd7e6 | ||
|
|
371c10ea5f |
197
.cbmignore
Normal file
197
.cbmignore
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
# codebase-memory-mcp ignore list
|
||||||
|
#
|
||||||
|
# Padrão gitignore-style. Linhas começando com `#` são comentários.
|
||||||
|
# Barra final (`/`) = só diretório. Sem barra = casa arquivo OU diretório.
|
||||||
|
#
|
||||||
|
# O CBM também lê `.gitignore` automaticamente — esta lista deixa explícito o que
|
||||||
|
# os hooks do CBM vão pular. Se uma regra entrar em conflito entre os dois arquivos,
|
||||||
|
# vale a união. Editar este arquivo é mais barato do que confiar na herança implícita.
|
||||||
|
#
|
||||||
|
# Última reconciliação: 2026-07-31, status `ready` (513k nodes / 689k edges),
|
||||||
|
# `auto_index_limit=50000`, total indexável medido ≈11.546 arquivos (folga 4,3×).
|
||||||
|
#
|
||||||
|
# Fontes cruzadas:
|
||||||
|
# - `codebase-memory-mcp cli index_status --project home-diegosouzapw-dev-proxys-OmniRoute`
|
||||||
|
# → `not_indexed.dirs` (27) + `not_indexed.files` (336), todos `BY DESIGN`.
|
||||||
|
# - `.gitignore` deste repo (5.691 B) — fonte canônica secundária.
|
||||||
|
#
|
||||||
|
# Como auditar mudanças: depois de editar este arquivo, rodar `index_repository`
|
||||||
|
# (ou esperar `auto_watch` re-indexar) e re-checar `cli index_status` → comparar
|
||||||
|
# contagens em `not_indexed.dirs_count` e `not_indexed.files_count`.
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 1. Diretorios de runtime / pacote — nao sao codigo-fonte
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
node_modules/
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Builds e artefatos reproduziveis (Layer 1 Next.js / Electron)
|
||||||
|
.build/
|
||||||
|
dist/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# Electron especifico
|
||||||
|
electron/dist-electron/
|
||||||
|
electron/node_modules/
|
||||||
|
icon.iconset/
|
||||||
|
|
||||||
|
# Workspaces internos que tem proprio node_modules
|
||||||
|
@omniroute/opencode-plugin/dist/
|
||||||
|
@omniroute/opencode-plugin/node_modules/
|
||||||
|
@omniroute/opencode-provider/dist/
|
||||||
|
@omniroute/opencode-provider/node_modules/
|
||||||
|
|
||||||
|
# Recursos nativos compilados (C/JNI/wasm)
|
||||||
|
src/mitm/tproxy/native/build/
|
||||||
|
|
||||||
|
# Artefatos locais do Stryker / Playwright / coverage
|
||||||
|
.stryker-tmp/
|
||||||
|
reports/mutation/
|
||||||
|
stryker-output-*.json
|
||||||
|
.playwright-mcp/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
blob-report/
|
||||||
|
|
||||||
|
# Analise / linters / caches
|
||||||
|
.analysis/
|
||||||
|
.sisyphus/
|
||||||
|
.plans/
|
||||||
|
.gitnexus
|
||||||
|
.worktrees
|
||||||
|
.codegraph/
|
||||||
|
|
||||||
|
# Quality artifacts (gerados por npm run lint --cache etc)
|
||||||
|
.eslintcache
|
||||||
|
.eslintcache-complexity
|
||||||
|
|
||||||
|
# Claude Code local state
|
||||||
|
.claude/scheduled_tasks.lock
|
||||||
|
.claude/scheduled_tasks/
|
||||||
|
.claude/sessions/
|
||||||
|
.claude/state.json
|
||||||
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
# Serena / Antigravity / outras tools locais
|
||||||
|
.serena/
|
||||||
|
.antigravitycli/
|
||||||
|
.gemini/
|
||||||
|
.config/
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 2. Diretorios com prefixo `_` — locais / privados (regra global do .gitignore)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
_*/
|
||||||
|
_artifacts/
|
||||||
|
_cache/
|
||||||
|
_mono_repo/
|
||||||
|
_references/
|
||||||
|
_tasks/
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 3. Diretorios de tooling IA (state local, nao codigo)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
.agents/
|
||||||
|
.claude/
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.junie/
|
||||||
|
.omc/
|
||||||
|
.data/
|
||||||
|
.data-dev/
|
||||||
|
.local-data/
|
||||||
|
.logs/
|
||||||
|
.artifacts/
|
||||||
|
.source/
|
||||||
|
.superpowers/
|
||||||
|
.claude-flow/
|
||||||
|
.omnivscodeagent/
|
||||||
|
omnirouteCloud/
|
||||||
|
omnirouteSite/
|
||||||
|
.omniroute/
|
||||||
|
.stent/
|
||||||
|
|
||||||
|
# Subpaths especificos do Claude Code que nao estao em .claude/ (criados sob repo)
|
||||||
|
.claude/worktrees/
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 4. Diretorios de dados / runtime locais (storage, env, secrets, scratch)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
data/
|
||||||
|
src/lib/env/
|
||||||
|
src/app/api/agent-skills/coverage/
|
||||||
|
src/app/api/cloud/
|
||||||
|
src/app/api/sync/cloud/
|
||||||
|
src/app/api/system/env/
|
||||||
|
tests/golden-set/data/
|
||||||
|
|
||||||
|
# Logs e saida de teste
|
||||||
|
logs/*
|
||||||
|
test_output.log
|
||||||
|
home-diegosouzapw-dev-automacoes-*.txt
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 5. Diretorios do monorepo por subprojeto (nao fazem parte do app principal)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
security-analysis/
|
||||||
|
vscode-extension/
|
||||||
|
obsidian-plugin/node_modules/
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 6. Diretorios de documentacao interna / workflow
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
docs/superpowers/
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 7. Arquivos especificos (nao diretorios inteiros)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Segredos e env — NUNCA indexar
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.homolog.example
|
||||||
|
|
||||||
|
# TypeScript build info e next env declaration
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
typescript
|
||||||
|
|
||||||
|
# SQLite transient files (WAL/SHM/journal)
|
||||||
|
*.sqlite-shm
|
||||||
|
*.sqlite-wal
|
||||||
|
*.sqlite-journal
|
||||||
|
|
||||||
|
# Mapas e source maps
|
||||||
|
*.map
|
||||||
|
|
||||||
|
# Bun / npm lockfiles ruidosos
|
||||||
|
bun.lock
|
||||||
|
|
||||||
|
# `cheaper-inference-gateway.svg` e arquivos de midia na raiz/asset ja cobertos
|
||||||
|
# pelos `ignored-suffix` do indexador (svg/png/jpg/ico/etc >50kB ou >500linhas);
|
||||||
|
# manter a regra explicita aqui ajuda a auditar:
|
||||||
|
cheaper-inference-gateway.svg
|
||||||
|
cheaper-inference-gateway-*.svg
|
||||||
|
|
||||||
|
# Husky internals
|
||||||
|
.husky/_/
|
||||||
|
|
||||||
|
# CI / quality metric artifacts
|
||||||
|
config/quality/quality-metrics.json
|
||||||
|
config/quality/test-impact-map.json
|
||||||
|
audit-report.json
|
||||||
|
.gh-discussions.json
|
||||||
|
|
||||||
|
# i18n audit (gerado por npm run scripts)
|
||||||
|
scripts/i18n/_audit.json
|
||||||
|
scripts/i18n/_pending-keys.json
|
||||||
|
|
||||||
|
# Cli binario local (scratch)
|
||||||
|
bin/omniroute.mjs
|
||||||
|
|
||||||
|
# Deploy / docker backups
|
||||||
|
deploy.sh
|
||||||
|
docker-compose.yml.bak
|
||||||
|
docker-compose.minimal.yml
|
||||||
@@ -1414,10 +1414,6 @@ APP_LOG_TO_FILE=true
|
|||||||
# Default: ~/.omniroute/plugins/ Override in dev/CI to point at a local plugin tree.
|
# 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,11 +9,14 @@
|
|||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
Run only the focused loop for what you changed — the full unit suite, Vitest, the
|
Choose the change type and focused loop from the
|
||||||
60% coverage gate, and the production build all run in CI on this PR (#8329):
|
[Contribution Golden Path](../docs/dev/CONTRIBUTION_GOLDEN_PATH.md). The full unit suite,
|
||||||
|
Vitest, the 60% coverage gate, and the production build all run in CI on this PR (#8329):
|
||||||
|
|
||||||
- [ ] Focused tests for the change: `node --import tsx/esm --test tests/unit/<file>.test.ts`
|
- [ ] Change type: provider / routing / UI / i18n / CLI / DB / build-deploy / other
|
||||||
|
- [ ] Focused tests and category gates from the golden path
|
||||||
- [ ] `npm run lint`
|
- [ ] `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
|
||||||
|
|
||||||
@@ -29,4 +32,4 @@ Run only the focused loop for what you changed — the full unit suite, Vitest,
|
|||||||
|
|
||||||
## 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,6 +179,81 @@ 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
|
||||||
@@ -364,6 +439,12 @@ jobs:
|
|||||||
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
|
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:
|
||||||
@@ -385,6 +466,29 @@ 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,7 +171,6 @@ 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/
|
||||||
@@ -201,7 +200,10 @@ 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/
|
||||||
@@ -250,3 +252,4 @@ 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/
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
// @ts-nocheck
|
|
||||||
import { dynamic } from 'fumadocs-mdx/runtime/dynamic';
|
|
||||||
import * as Config from '../source.config';
|
|
||||||
|
|
||||||
const create = await dynamic<typeof Config, import("fumadocs-mdx/runtime/types").InternalTypeConfig & {
|
|
||||||
DocData: {
|
|
||||||
}
|
|
||||||
}>(Config, {"configPath":"source.config.ts","environment":"next","outDir":".source"}, {"doc":{"passthroughs":["extractedReferences"]}});
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
// source.config.ts
|
|
||||||
import { defineDocs, defineConfig } from "fumadocs-mdx/config";
|
|
||||||
var docs = defineDocs({
|
|
||||||
dir: "docs",
|
|
||||||
docs: {
|
|
||||||
files: [
|
|
||||||
"./architecture/**/*.md",
|
|
||||||
"./guides/**/*.md",
|
|
||||||
"./reference/**/*.md",
|
|
||||||
"./frameworks/**/*.md",
|
|
||||||
"./routing/**/*.md",
|
|
||||||
"./security/**/*.md",
|
|
||||||
"./compression/**/*.md",
|
|
||||||
"./ops/**/*.md"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
var source_config_default = defineConfig();
|
|
||||||
export {
|
|
||||||
source_config_default as default,
|
|
||||||
docs
|
|
||||||
};
|
|
||||||
18
CLAUDE.md
18
CLAUDE.md
@@ -45,7 +45,7 @@ For full test matrix, see `CONTRIBUTING.md` → "Running Tests". For deep archit
|
|||||||
| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) |
|
| 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 (95 files, 110 migrations) |
|
| Database | `src/lib/db/` | SQLite domain modules (130 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`): 18 strategies (priority, weighted, fill-first, round-robin, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline). Each target calls `handleSingleModel()` which wraps `handleChatCore()` with per-target error handling and circuit breaker checks. The `fusion` strategy is the exception: it fans out to a panel of models in parallel, then a judge model synthesizes one final answer (`open-sse/services/fusion.ts`). See `docs/routing/AUTO-COMBO.md` for the 12-factor Auto-Combo scoring + the full strategy table and `docs/architecture/RESILIENCE_GUIDE.md` for the 3 resilience layers.
|
**Combo routing** (`open-sse/services/combo.ts`): 19 public strategies (priority, weighted, fill-first, round-robin, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline). Each target calls `handleSingleModel()` which wraps `handleChatCore()` with per-target error handling and circuit breaker checks. The `fusion` strategy is the exception: it fans out to a panel of models in parallel, then a judge model synthesizes one final answer (`open-sse/services/fusion.ts`). See `docs/routing/AUTO-COMBO.md` for the 13-factor Auto-Combo scoring + the full strategy table and `docs/architecture/RESILIENCE_GUIDE.md` for the 3 resilience layers.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -332,7 +332,7 @@ For any non-trivial change, read the matching deep-dive first:
|
|||||||
| Repo navigation | `docs/architecture/REPOSITORY_MAP.md` |
|
| 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 (12-factor scoring, 18 strategies) | `docs/routing/AUTO-COMBO.md` |
|
| Auto-Combo (13-factor scoring, 19 strategies) | `docs/routing/AUTO-COMBO.md` |
|
||||||
| Resilience (3 mechanisms) | `docs/architecture/RESILIENCE_GUIDE.md` |
|
| 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,10 +461,18 @@ 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##*/}"
|
||||||
# symlink node_modules from the main checkout to skip a per-worktree npm install:
|
# Reuse the main checkout's node_modules to skip a per-worktree npm install.
|
||||||
ln -s "$(git -C <main_checkout> rev-parse --show-toplevel)/node_modules" node_modules
|
# HARD LINKS (`cp -al`), never a symlink: ~5s for the whole tree and near-zero extra
|
||||||
|
# disk (the inodes are shared), and unlike a symlink it does not break the dev server.
|
||||||
|
cp -al "$(git -C <main_checkout> rev-parse --show-toplevel)/node_modules" node_modules
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Never `ln -s` node_modules.** Turbopack rejects a symlink that resolves outside the
|
||||||
|
project root, so `npm run dev` dies with a FATAL panic (`Symlink [project]/node_modules
|
||||||
|
is invalid, it points out of the filesystem root`) while typecheck, lint and the test
|
||||||
|
runners all keep passing — the error names "filesystem root", not the worktree, so it
|
||||||
|
reads like a Next/build bug and costs real time to trace (incident 2026-07-31, #9043).
|
||||||
|
|
||||||
In Claude Code prefer the native `EnterWorktree` tool (it already creates worktrees under
|
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,6 +2,11 @@
|
|||||||
|
|
||||||
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
|
||||||
@@ -198,10 +203,11 @@ Coverage notes:
|
|||||||
|
|
||||||
### Pull Request Requirements
|
### Pull Request Requirements
|
||||||
|
|
||||||
Before opening a PR, run the focused loop for what you changed. The full unit suite
|
Before opening a PR, use the
|
||||||
(4 CI shards), Vitest, the **60%+** coverage gate, and the production build are CI's
|
[Contribution Golden Path](docs/dev/CONTRIBUTION_GOLDEN_PATH.md) to run the focused loop for
|
||||||
responsibility — running them locally adds no signal the PR checks will not already
|
what you changed. The full unit suite (4 CI shards), Vitest, the **60%+** coverage gate, and
|
||||||
give you, and on smaller machines it can saturate the host (#8084):
|
the production build are CI's responsibility — running them locally adds no signal the PR
|
||||||
|
checks will not already give you, and on smaller machines it can saturate the host (#8084):
|
||||||
|
|
||||||
- Run the test files that cover your change: `node --import tsx/esm --test tests/unit/<file>.test.ts`
|
- Run 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`
|
||||||
@@ -271,7 +277,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 database layer (21 modules + 16 migrations)
|
│ ├── db/ # SQLite domain modules + 130 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
|
||||||
@@ -281,7 +287,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 (177), MCP scopes, 14 routing strategies
|
│ ├── constants/ # Provider definitions (290), MCP scopes, 19 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
|
||||||
@@ -289,7 +295,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 (25 tools, 3 transports, 10 scopes)
|
├── mcp-server/ # MCP server (104 tools, 3 transports, 31 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,12 +241,53 @@ curl http://localhost:20128/v1/chat/completions \
|
|||||||
<b>What Kimi's support powers:</b> Kimi's API credits power OmniRoute's AI-validated release pipeline — the <i>merge validation powered by Kimi K3</i> stage that reviews every pull request before it ships — plus day-to-day feature development. First-class Kimi support ships on both rails: the direct <a href="https://platform.kimi.ai?aff=omniroute">Kimi API</a> (<code>kimi-k3</code>) and the <a href="https://www.kimi.com/code?aff=omniroute">Kimi Code coding plan</a> (OAuth and API key). OmniRoute is also the first Brazilian open-source project in Kimi's support program. <a href="https://platform.kimi.ai?aff=omniroute"><b>Get a Kimi API key →</b></a>
|
<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
changelog.d/fixes/9235-french-ui-catalog.md
Normal file
1
changelog.d/fixes/9235-french-ui-catalog.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **fix(i18n):** completed the French UI catalog by adding all missing keys and replacing every placeholder translation ([#9235](https://github.com/diegosouzapw/OmniRoute/pull/9235)) — thanks @alex-jordan547
|
||||||
1
changelog.d/fixes/9245-hardcoded-web-ui-i18n.md
Normal file
1
changelog.d/fixes/9245-hardcoded-web-ui-i18n.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **fix(i18n):** localized hardcoded web UI copy across public pages, dashboard views, and shared components, with complete French and Vietnamese coverage ([#9245](https://github.com/diegosouzapw/OmniRoute/pull/9245)) — thanks @alex-jordan547
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -177,7 +177,7 @@
|
|||||||
"tests/unit/account-fallback-service.test.ts": 1563,
|
"tests/unit/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": 2769,
|
"tests/unit/chatcore-translation-paths.test.ts": 2776,
|
||||||
"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": 1562,
|
"open-sse/executors/base.ts": 1578,
|
||||||
"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": 2887,
|
"open-sse/utils/stream.ts": 2889,
|
||||||
"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,5 +413,6 @@
|
|||||||
"_rebaseline_2026_07_28_8860_tokenrefresh_projectid": "PR #8860 (fix/antigravity-projectid-centralized) own test growth: tests/unit/token-refresh-service.test.ts 1311->1378 (+67 = 4 cases covering projectId discovery on the tokenRefresh.ts path — the Dashboard/health-check refresh route, which #8842 did not reach since that fixed the executor path). Covered by the same file.",
|
"_rebaseline_2026_07_28_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."
|
||||||
}
|
}
|
||||||
|
|||||||
229
docs/dev/CONTRIBUTION_GOLDEN_PATH.md
Normal file
229
docs/dev/CONTRIBUTION_GOLDEN_PATH.md
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
---
|
||||||
|
title: "Contribution Golden Path"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Contribution Golden Path
|
||||||
|
|
||||||
|
Use this guide to choose the smallest reliable development loop for a pull request. It does not
|
||||||
|
replace the area-specific architecture and security documents linked below; it connects each common
|
||||||
|
change type to its contracts, focused checks, and CI coverage.
|
||||||
|
|
||||||
|
## The path every change follows
|
||||||
|
|
||||||
|
1. **Choose the base before editing.** Find the highest active `release/v*` branch and branch from
|
||||||
|
its tip. Target that branch, not `main`. If a release freeze is active, do not target the frozen
|
||||||
|
branch; use the next active cycle described in
|
||||||
|
[Branching & Release Model](../ops/BRANCHING_MODEL.md).
|
||||||
|
2. **Name the contracts.** Identify every catalog, schema, generated artifact, public API, or user
|
||||||
|
interface that the change affects. The table below gives the minimum starting set.
|
||||||
|
3. **Write or update focused tests.** Production changes in `src/`, `open-sse/`, `electron/`, or
|
||||||
|
`bin/` require an automated test in the same PR. Run the smallest test files that prove the
|
||||||
|
behavior, then the listed focused gates.
|
||||||
|
4. **Let CI run the broad matrix.** The complete unit shards, Vitest, coverage ratchet, and
|
||||||
|
production build run on the PR. Run a broad suite locally only when a focused failure points to
|
||||||
|
wider impact or when the change spans several subsystems.
|
||||||
|
5. **Reconcile before review.** Fetch the active base, inspect its new commits and your diff against
|
||||||
|
it, then rebase or merge the base according to the contributor workflow. Resolve generated-file
|
||||||
|
and catalog conflicts from their source, regenerate them, rerun the focused loop, and confirm the
|
||||||
|
PR still targets the active release branch.
|
||||||
|
6. **Record evidence.** In the PR template, list the commands run, every test file added or changed,
|
||||||
|
migrations or feature flags, and any CI-only validation still pending.
|
||||||
|
|
||||||
|
## Golden paths by change type
|
||||||
|
|
||||||
|
Commands below are minimum focused checks, not permission to skip a test that directly covers the
|
||||||
|
behavior you changed.
|
||||||
|
|
||||||
|
### Provider
|
||||||
|
|
||||||
|
**Contracts**
|
||||||
|
|
||||||
|
- Provider definition in `src/shared/constants/providers/` and its composition in
|
||||||
|
`src/shared/constants/providers.ts`.
|
||||||
|
- Models and capabilities in `open-sse/config/providerRegistry.ts` or its extracted registry files.
|
||||||
|
- Executor/translator selection, OAuth or API-key configuration, dashboard assets, and generated
|
||||||
|
provider reference when applicable.
|
||||||
|
- Public credentials must use `resolvePublicCred()`; error responses must use the shared sanitized
|
||||||
|
error helpers. See [Public Credentials](../security/PUBLIC_CREDS.md) and
|
||||||
|
[Error Sanitization](../security/ERROR_SANITIZATION.md).
|
||||||
|
|
||||||
|
**Focused loop**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run check:provider-consistency
|
||||||
|
npm run check:provider-assets
|
||||||
|
node --import tsx/esm --test tests/unit/provider-translate-path-golden.test.ts
|
||||||
|
node --import tsx/esm --test tests/unit/<provider-or-executor>.test.ts
|
||||||
|
npm run gen:provider-reference # when the catalog changes; commit the generated diff
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Also test every affected request family: chat, Responses, images, embeddings, audio, or video.
|
||||||
|
Review generated catalog and golden diffs as contract changes; do not accept them blindly.
|
||||||
|
|
||||||
|
### Routing
|
||||||
|
|
||||||
|
**Contracts**
|
||||||
|
|
||||||
|
- Public strategy values and UI metadata in `src/shared/constants/routingStrategies.ts`.
|
||||||
|
- Dispatch and ordering under `open-sse/services/combo.ts` and `open-sse/services/combo/`.
|
||||||
|
- Combo schemas, persistence, resilience state, model capabilities, and API/UI controls.
|
||||||
|
- [Auto-Combo Engine](../routing/AUTO-COMBO.md) and resilience documentation when behavior changes.
|
||||||
|
|
||||||
|
**Focused loop**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --import tsx/esm --test tests/unit/combo-<behavior>.test.ts
|
||||||
|
npm run test:combo:matrix # strategy or dispatch changes
|
||||||
|
npm run check:known-symbols # strategy registration changes
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Use deterministic mocked-upstream tests locally. Live combo smokes require credentials and are
|
||||||
|
manual, not CI substitutes.
|
||||||
|
|
||||||
|
### UI / UX
|
||||||
|
|
||||||
|
**Contracts**
|
||||||
|
|
||||||
|
- Next.js route/page and shared component boundaries under `src/app/` and
|
||||||
|
`src/shared/components/`.
|
||||||
|
- API response shapes, loading/empty/error states, keyboard and screen-reader behavior,
|
||||||
|
responsive layout, theming, and locale expansion.
|
||||||
|
- English UI source strings in `src/i18n/messages/en.json`; do not hard-code new user-facing copy.
|
||||||
|
|
||||||
|
**Focused loop**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --import tsx --test tests/unit/dashboard/<feature>.test.ts
|
||||||
|
npx vitest run --config vitest.config.ts tests/unit/ui/<component>.test.tsx
|
||||||
|
npm run check:dashboard-typecheck
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the app for interaction or visual changes and check both narrow and wide viewports. CI runs the
|
||||||
|
production build and broader suites; visual behavior still needs a focused component, Playwright,
|
||||||
|
or documented manual check appropriate to the change.
|
||||||
|
|
||||||
|
### i18n
|
||||||
|
|
||||||
|
**Contracts**
|
||||||
|
|
||||||
|
- `src/i18n/messages/en.json` is the UI source; `config/i18n.json` is the locale source.
|
||||||
|
- CLI catalogs live separately under `bin/cli/locales/`.
|
||||||
|
- Preserve ICU placeholders and tags exactly. Do not translate product/provider/model names,
|
||||||
|
protocol and header names, commands, code/JSON identifiers, URLs, environment variables, or
|
||||||
|
protected terms such as `OmniRoute`, `OAuth`, `MCP`, and `A2A`. The current source list is
|
||||||
|
`scripts/i18n/glossary/protected-terms.json`.
|
||||||
|
|
||||||
|
**Focused loop**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run i18n:sync-ui:dry
|
||||||
|
npm run i18n:check-ui-coverage
|
||||||
|
npm run i18n:check-value-drift
|
||||||
|
npm run i18n:check-glossary
|
||||||
|
npm run check:cli-i18n # when CLI strings/catalogs change
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
This is guidance for the existing system, not an invitation to expand its tooling or key model.
|
||||||
|
Keep i18n patches surgical while the replacement system is being designed. Do not run translation
|
||||||
|
commands that call external services unless the task explicitly requires generated translations and
|
||||||
|
you have reviewed the resulting diff.
|
||||||
|
|
||||||
|
### CLI
|
||||||
|
|
||||||
|
**Contracts**
|
||||||
|
|
||||||
|
- Public commands and flags in `bin/cli/`, generated API commands, exit codes, stdout/stderr and
|
||||||
|
JSON output shapes, config/environment behavior, and packaged files.
|
||||||
|
- CLI user-facing strings must use the CLI i18n layer and keep `en`/`pt-BR` catalogs aligned.
|
||||||
|
- Preserve Node as the supported runtime and the published binary contract.
|
||||||
|
|
||||||
|
**Focused loop**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --import tsx/esm --test tests/unit/cli/<command>.test.ts
|
||||||
|
npm run check:cli-i18n
|
||||||
|
npm run build:cli # generated/bundled CLI changes
|
||||||
|
npm run check:pack-policy # package-surface changes
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the exact command in a temporary data directory when behavior depends on parsing, files, or exit
|
||||||
|
status. CI performs the broader package artifact and ecosystem checks.
|
||||||
|
|
||||||
|
### Database
|
||||||
|
|
||||||
|
**Contracts**
|
||||||
|
|
||||||
|
- Domain modules under `src/lib/db/`; `src/lib/localDb.ts` remains a re-export layer only.
|
||||||
|
- Numbered, idempotent SQL migrations under `src/lib/db/migrations/`, transaction safety, upgrade
|
||||||
|
behavior, indexes, and every caller affected by the schema.
|
||||||
|
- Routes and handlers never issue raw SQL directly.
|
||||||
|
|
||||||
|
**Focused loop**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run check:migration-numbering
|
||||||
|
npm run check:db-rules
|
||||||
|
node --import tsx/esm --test tests/unit/db/<domain>.test.ts
|
||||||
|
node --import tsx/esm --test tests/unit/db/migration-<number>.test.ts
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Test both a fresh database and upgrade from the prior schema when adding a migration. Database tests
|
||||||
|
must close handles and call `resetDbInstance()` during cleanup. Run `npm run test:bun:db` only when
|
||||||
|
the best-effort Bun adapter path changes; Node remains authoritative.
|
||||||
|
|
||||||
|
### Build / deploy
|
||||||
|
|
||||||
|
**Contracts**
|
||||||
|
|
||||||
|
- Root and workspace manifests/lockfile, `scripts/build/`, Next.js standalone assembly, `dist/`
|
||||||
|
package contents, Electron platform metadata, CI workflows, and deployment sentinels.
|
||||||
|
- Supported Node ranges and the allow-listed Bun use in `CLAUDE.md` must remain intact.
|
||||||
|
- Build artifacts stay untracked; dependency, license, workflow, and package policies apply.
|
||||||
|
|
||||||
|
**Focused loop**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --import tsx/esm --test tests/unit/build/<behavior>.test.ts
|
||||||
|
npm run check:build-scope
|
||||||
|
npm run check:lockfile # dependency or lockfile changes
|
||||||
|
npm run check:pack-policy # published package surface changes
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `npm run build` locally only when the change affects compilation, standalone assembly, assets,
|
||||||
|
or runtime bundling. Use `npm run build:release` only for release/deploy validation. CI's build is
|
||||||
|
the final cross-platform signal; platform-specific Electron changes need the matching focused build
|
||||||
|
or smoke evidence.
|
||||||
|
|
||||||
|
## Local loop versus CI
|
||||||
|
|
||||||
|
| Run locally for each patch | CI supplies the broad signal |
|
||||||
|
| ----------------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
||||||
|
| Direct behavior tests and category gates above | Sharded full unit suite and serial tests |
|
||||||
|
| `npm run lint` | Vitest suites and coverage/quality ratchets |
|
||||||
|
| Typecheck or build only when the affected contract calls for it | Production build, security, docs, dependency, and PR-policy gates |
|
||||||
|
| Manual interaction/live checks only when automation cannot prove the behavior | Cross-job integration and platform checks configured by workflow |
|
||||||
|
|
||||||
|
A green focused loop is evidence about the changed contract, not proof that unrelated CI checks
|
||||||
|
will pass. Conversely, do not make every local edit wait for the full repository matrix.
|
||||||
|
|
||||||
|
## Reconciliation checklist
|
||||||
|
|
||||||
|
Before requesting review:
|
||||||
|
|
||||||
|
- Confirm the PR base is still the highest active `release/v*` branch.
|
||||||
|
- Fetch that base and review commits that landed since you branched.
|
||||||
|
- Review `git diff <active-base>...HEAD` for accidental or generated churn.
|
||||||
|
- Resolve catalog and generated-document conflicts by updating the source and regenerating output.
|
||||||
|
- Rerun every focused test/gate listed in the PR description after reconciliation.
|
||||||
|
- Never weaken assertions or drop required tests merely to match a moved base.
|
||||||
|
|
||||||
|
For release-freeze and retargeting rules, use
|
||||||
|
[Branching & Release Model](../ops/BRANCHING_MODEL.md). For the complete CI inventory, use
|
||||||
|
[Quality Gates Reference](../architecture/QUALITY_GATES.md).
|
||||||
@@ -461,10 +461,18 @@ own dedicated branch, and you MUST confirm the base branch with the operator bef
|
|||||||
git fetch origin "$BASE_BRANCH"
|
git 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##*/}"
|
||||||
# symlink node_modules from the main checkout to skip a per-worktree npm install:
|
# Reuse the main checkout's node_modules to skip a per-worktree npm install.
|
||||||
ln -s "$(git -C <main_checkout> rev-parse --show-toplevel)/node_modules" node_modules
|
# HARD LINKS (`cp -al`), never a symlink: ~5s for the whole tree and near-zero extra
|
||||||
|
# disk (the inodes are shared), and unlike a symlink it does not break the dev server.
|
||||||
|
cp -al "$(git -C <main_checkout> rev-parse --show-toplevel)/node_modules" node_modules
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Never `ln -s` node_modules.** Turbopack rejects a symlink that resolves outside the
|
||||||
|
project root, so `npm run dev` dies with a FATAL panic (`Symlink [project]/node_modules
|
||||||
|
is invalid, it points out of the filesystem root`) while typecheck, lint and the test
|
||||||
|
runners all keep passing — the error names "filesystem root", not the worktree, so it
|
||||||
|
reads like a Next/build bug and costs real time to trace (incident 2026-07-31, #9043).
|
||||||
|
|
||||||
In Claude Code prefer the native `EnterWorktree` tool (it already creates worktrees under
|
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,10 +415,18 @@ 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。
|
||||||
ln -s "$(git -C <main_checkout> rev-parse --show-toplevel)/node_modules" node_modules
|
# 必须用硬链接(`cp -al`),绝不能用符号链接:整棵树约 5 秒,几乎不占额外磁盘
|
||||||
|
# (inode 是共享的),而且与符号链接不同,它不会破坏开发服务器。
|
||||||
|
cp -al "$(git -C <main_checkout> rev-parse --show-toplevel)/node_modules" node_modules
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**绝不要对 node_modules 使用 `ln -s`。** Turbopack 会拒绝解析到项目根目录之外的符号链接,
|
||||||
|
因此 `npm run dev` 会以 FATAL panic 崩溃(`Symlink [project]/node_modules is invalid, it
|
||||||
|
points out of the filesystem root`),而 typecheck、lint 和测试运行器却都照常通过 —— 错误信息
|
||||||
|
提到的是 "filesystem root" 而不是 worktree,看起来像 Next/构建的 bug,排查会浪费大量时间
|
||||||
|
(事故 2026-07-31,#9043)。
|
||||||
|
|
||||||
在 Claude Code 中优先使用原生的 `EnterWorktree` 工具(它已经在 `.claude/worktrees/` 下创建 worktree):先用上述命令创建 worktree,然后用其 `path` 调用 `EnterWorktree`。
|
在 Claude Code 中优先使用原生的 `EnterWorktree` 工具(它已经在 `.claude/worktrees/` 下创建 worktree):先用上述命令创建 worktree,然后用其 `path` 调用 `EnterWorktree`。
|
||||||
|
|
||||||
3. **工作、提交、推送、发起 PR — 全部在 worktree 内部完成。** 绝不在另一个会话可能共享的 worktree 内 `git checkout` 不同分支。
|
3. **工作、提交、推送、发起 PR — 全部在 worktree 内部完成。** 绝不在另一个会话可能共享的 worktree 内 `git checkout` 不同分支。
|
||||||
|
|||||||
@@ -414,7 +414,6 @@ detection above).
|
|||||||
| `OMNIROUTE_HTTP_TIMEOUT_MS` | `30000` | `bin/cli/api.mjs` | Per-attempt HTTP timeout (ms) for CLI → server requests. |
|
| `OMNIROUTE_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 18
|
`tests/integration/combo-matrix/*.test.ts` proves the routing **decision** of all 19
|
||||||
public strategies end-to-end through the real combo pipeline with a mocked upstream.
|
public strategies end-to-end through the real combo pipeline with a mocked upstream.
|
||||||
Coverage includes:
|
Coverage includes:
|
||||||
|
|
||||||
- All 18 `ROUTING_STRATEGY_VALUES` strategies (ordered, weighted, cost, context, fusion, …).
|
- All 19 `ROUTING_STRATEGY_VALUES` strategies (ordered, weighted, cost, context, fusion, …).
|
||||||
- `quota-share` (internal) end-to-end: DRR fairness + saturation deprioritization via the
|
- `quota-share` (internal) end-to-end: DRR fairness + saturation deprioritization via the
|
||||||
real `selectQuotaShareTarget` seam (`registerQuotaFetcher` / `setLKGP` /
|
real `selectQuotaShareTarget` seam (`registerQuotaFetcher` / `setLKGP` /
|
||||||
`__setHeadroomSaturationFetcherForTests`).
|
`__setHeadroomSaturationFetcherForTests`).
|
||||||
|
|||||||
91
docs/security/AGENTROUTER_WAF.md
Normal file
91
docs/security/AGENTROUTER_WAF.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# agentrouter.org WAF (Web Application Firewall)
|
||||||
|
|
||||||
|
The `agentrouter` upstream gateway runs a keyword-based content filter on
|
||||||
|
`messages[].content`. The filter is partially deterministic (always blocks
|
||||||
|
certain phrases) and partially probabilistic (burst-sensitive — becomes
|
||||||
|
more aggressive after rapid requests, recovers after a cooldown).
|
||||||
|
|
||||||
|
When the WAF blocks a request it returns:
|
||||||
|
|
||||||
|
```
|
||||||
|
HTTP/1.1 400 Bad Request
|
||||||
|
{"error":{"code":"content-blocked","message":"content-blocked (request id: ...)","param":"","type":"agent_router_api_error"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scope of the filter
|
||||||
|
|
||||||
|
The WAF inspects `messages[].content` only. It does **not** inspect:
|
||||||
|
|
||||||
|
- The `system` prompt
|
||||||
|
- Structured content blocks (`tool_result`, `tool_use`, `thinking`, `image`)
|
||||||
|
- Tool `description` and `input_schema` fields
|
||||||
|
- Request metadata, headers, or model id
|
||||||
|
|
||||||
|
## Always-blocked patterns (case-insensitive)
|
||||||
|
|
||||||
|
| Pattern | Notes |
|
||||||
|
|-------------------------------|----------------------------------------|
|
||||||
|
| Any `Lorem ipsum` variant | Full Latin lorem vocabulary is blocked |
|
||||||
|
| `language model` (alone) | "the language model" and "large language model" pass |
|
||||||
|
| `virtual assistant` | "AI assistant" passes |
|
||||||
|
| `I'm here to help` | "here to help" alone also blocks |
|
||||||
|
| `Claude, made by Anthropic` | Full phrase only |
|
||||||
|
|
||||||
|
## Almost-always-blocked patterns
|
||||||
|
|
||||||
|
| Pattern | Notes |
|
||||||
|
|-------------------|---------------------------------------------------------|
|
||||||
|
| `placeholder` | When it stands alone (not as a parameter name, etc.) |
|
||||||
|
| `dummy data` | Common seed phrase for fixtures |
|
||||||
|
| `foo bar baz` | Canonical placeholder phrase |
|
||||||
|
| Repeated short tokens (`AAA BBB CCC`, `test test test`) | Detector for keyword stuffing |
|
||||||
|
|
||||||
|
## Behavior under load
|
||||||
|
|
||||||
|
After ~5 rapid requests in a short window, the WAF begins blocking content
|
||||||
|
that would normally pass. The bucket relaxes after ~5–10 seconds of idle
|
||||||
|
time. This is the same IP-and-key-bound rate limiter that causes
|
||||||
|
intermittent `400 content-blocked` errors when Claude Code or Codex CLI
|
||||||
|
makes multiple tool-use / message-send calls in quick succession.
|
||||||
|
|
||||||
|
## Mitigations already applied in OmniRoute
|
||||||
|
|
||||||
|
1. **`open-sse/services/wafRateLimit.ts`** — burst guard that enforces a
|
||||||
|
500 ms minimum gap between outbound requests to any `agentrouter:*`
|
||||||
|
URL. The gap is well below human perception of latency and prevents
|
||||||
|
the WAF from activating on normal traffic.
|
||||||
|
|
||||||
|
2. **`BaseExecutor.WAF_RETRY_CONFIG`** — when an upstream returns
|
||||||
|
`400 content-blocked`, the executor retries the same URL with
|
||||||
|
exponential backoff (1.5 s, 3.0 s, max 2 attempts). After the backoff
|
||||||
|
the WAF usually relaxes and the retry succeeds.
|
||||||
|
|
||||||
|
3. **`tests/unit/compression/harness.test.ts`** — the test fixture
|
||||||
|
`longInput` was changed from `"lorem ipsum dolor sit amet ".repeat(40)`
|
||||||
|
to `"example content for testing purposes ".repeat(40)` so that when
|
||||||
|
Claude Code reads this file via the `Read` tool, the file contents
|
||||||
|
do not flow back through a `tool_result` block and trip the WAF.
|
||||||
|
|
||||||
|
## Guidance for prompts and tool output
|
||||||
|
|
||||||
|
If a Claude Code or Codex CLI session repeatedly hits
|
||||||
|
`400 content-blocked`, check the most recent user message and the most
|
||||||
|
recent tool result for any of the patterns above and rephrase. Common
|
||||||
|
workarounds:
|
||||||
|
|
||||||
|
- Replace `Lorem ipsum …` with `example text …` or the actual content
|
||||||
|
the test or fixture is trying to model.
|
||||||
|
- Replace `placeholder` (when standing alone) with `example value`,
|
||||||
|
`sample value`, or the real value.
|
||||||
|
- Replace `language model` with `large language model` or `the model`.
|
||||||
|
- Replace `dummy data` with `sample data` or realistic seed values.
|
||||||
|
- Replace `I'm here to help` / `here to help` with a more specific
|
||||||
|
opener (e.g. "I'll review the file you mentioned").
|
||||||
|
|
||||||
|
## Reporting the false positives upstream
|
||||||
|
|
||||||
|
The current filter is overly aggressive — it blocks "Lorem ipsum" in
|
||||||
|
`tool_result` blocks even though the operator clearly did not intend to
|
||||||
|
inject a prompt. Operators who want this fixed at the source should
|
||||||
|
contact `agentrouter.org` to report the false positives. The blocklist
|
||||||
|
above is the empirical result of probing the upstream as of 2026-08-03.
|
||||||
@@ -13,6 +13,29 @@ const TO_NUMBER_RESTRICTION = {
|
|||||||
"canonical coercion shape and the `toNumberOrNull`/`toNumberArray` variants.",
|
"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,
|
||||||
@@ -39,15 +62,32 @@ 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: [
|
paths: [PROP_TYPES_RESTRICTION],
|
||||||
{
|
},
|
||||||
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,4 +1,9 @@
|
|||||||
const DEFAULT_CODEX_CLIENT_VERSION = "0.144.1";
|
import {
|
||||||
|
DEFAULT_CODEX_CLIENT_VERSION,
|
||||||
|
getCodexCliRsHeaders as buildCodexCliRsHeaders,
|
||||||
|
} from "@/shared/constants/codexClient";
|
||||||
|
|
||||||
|
export { DEFAULT_CODEX_CLIENT_VERSION } from "@/shared/constants/codexClient";
|
||||||
const DEFAULT_CODEX_USER_AGENT_PLATFORM = "Windows 10.0.26200";
|
const DEFAULT_CODEX_USER_AGENT_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";
|
||||||
@@ -42,6 +47,10 @@ export function getCodexDefaultHeaders(): Record<string, string> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCodexCliRsHeaders(): Record<string, string> {
|
||||||
|
return buildCodexCliRsHeaders(getCodexClientVersion());
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeCodexSessionId(value: unknown): string | null {
|
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,10 +19,9 @@ 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-6", displayName: "Claude 4.6 Opus", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" },
|
{ provider: "agentrouter", modelId: "claude-opus-4-8", displayName: "Claude Opus 4.8", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" },
|
||||||
{ provider: "agentrouter", modelId: "claude-haiku-4-5-20251001", displayName: "Claude 4.5 Haiku", 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: "glm-5.1", displayName: "GLM 5.1", 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: "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,6 +11,7 @@ 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;
|
||||||
@@ -714,6 +715,13 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderConfig> = {
|
|||||||
supportedSizes: ["1:1", "16:9", "9:16", "4:3", "3:4", "1024x1024", "1792x1024", "1024x1792"],
|
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,6 +117,7 @@ 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";
|
||||||
@@ -337,6 +338,7 @@ 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,4 +1,5 @@
|
|||||||
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",
|
||||||
@@ -8,6 +9,22 @@ 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
|
||||||
@@ -15,10 +32,9 @@ 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-6", name: "Claude 4.6 Opus" },
|
{ id: "claude-opus-4-8", name: "Claude Opus 4.8" },
|
||||||
{ id: "claude-haiku-4-5-20251001", name: "Claude 4.5 Haiku" },
|
{ id: "claude-opus-5", name: "Claude Opus 5" },
|
||||||
{ id: "glm-5.1", name: "GLM 5.1" },
|
{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol" },
|
||||||
{ id: "deepseek-v3.2", name: "DeepSeek V3.2" },
|
|
||||||
],
|
],
|
||||||
passthroughModels: true,
|
passthroughModels: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Cheaper Inference image provider registry entry.
|
||||||
|
* Extracted into its own module to keep open-sse/config/imageRegistry.ts
|
||||||
|
* under the file-size cap (god-file decomposition; semantic split) — same
|
||||||
|
* pattern as FREEPIK_IMAGE_PROVIDER / SEGMIND_IMAGE_PROVIDER.
|
||||||
|
*
|
||||||
|
* 3 image models measured from GET /v1/models?type=image on 2026-07-31.
|
||||||
|
*
|
||||||
|
* COLLISION NOTE: nano-banana-pro and nano-banana-2 are ALSO adobe-firefly model
|
||||||
|
* ids. parseImageModel() resolves a bare id by first-match over IMAGE_PROVIDERS
|
||||||
|
* iteration order, so this entry is spread into that object AFTER adobe-firefly:
|
||||||
|
* bare `nano-banana-2` keeps routing to Firefly (pre-existing behaviour) and these
|
||||||
|
* models are reachable only as `cheaperinference/<id>` / `cinf/<id>`. Do NOT add
|
||||||
|
* IMAGE_MODEL_ALIASES entries for them — that would silently re-route Firefly
|
||||||
|
* users. Guarded by tests/unit/cheaperinference-image-models.test.ts.
|
||||||
|
*
|
||||||
|
* The endpoint ignores `response_format:"url"` and always returns `b64_json`
|
||||||
|
* (measured twice). The OpenAI image path already handles b64_json; this is not a
|
||||||
|
* bug to "fix". /v1/images/edits returns 404 upstream, so no edit support.
|
||||||
|
*/
|
||||||
|
export const CHEAPERINFERENCE_IMAGE_PROVIDER = {
|
||||||
|
id: "cheaperinference",
|
||||||
|
alias: "cinf",
|
||||||
|
baseUrl: "https://api.cheaperinference.com/v1/images/generations",
|
||||||
|
authType: "apikey",
|
||||||
|
authHeader: "bearer",
|
||||||
|
format: "openai",
|
||||||
|
models: [
|
||||||
|
{ id: "grok-imagine", name: "Grok Imagine (Cheaper Inference)" },
|
||||||
|
{ id: "nano-banana-pro", name: "Nano Banana Pro (Cheaper Inference)" },
|
||||||
|
{ id: "nano-banana-2", name: "Nano Banana 2 (Cheaper Inference)" },
|
||||||
|
],
|
||||||
|
supportedSizes: ["1024x1024", "2048x2048", "4096x4096"],
|
||||||
|
};
|
||||||
247
open-sse/config/providers/registry/cheaperinference/index.ts
Normal file
247
open-sse/config/providers/registry/cheaperinference/index.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import type { RegistryEntry, RegistryModel } from "../../shared.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cheaper Inference (https://api.cheaperinference.com) — cost-ranked OpenAI-compatible
|
||||||
|
* gateway, OmniRoute Open Source Friend.
|
||||||
|
*
|
||||||
|
* Catalog captured from a live `GET /v1/models` on 2026-07-31 (42 entries: these 39
|
||||||
|
* `type:"text"` models plus 3 `type:"image"` models that live in imageRegistry.ts —
|
||||||
|
* sending an image model here returns HTTP 400 "Use POST /v1/images/generations").
|
||||||
|
* `supportsVision`/`supportsReasoning` mirror each entry's `capabilities` object
|
||||||
|
* verbatim; they are not inferred from the model name.
|
||||||
|
*
|
||||||
|
* The gateway also serves a native `/v1/responses` endpoint (`responsesBaseUrl`).
|
||||||
|
* It is stateless and REQUIRES `store:false` — see executors/cheaperinference.ts,
|
||||||
|
* which injects it and resolves the URL from the per-model `targetFormat` tag.
|
||||||
|
*/
|
||||||
|
export const CHEAPERINFERENCE_MODELS: RegistryModel[] = [
|
||||||
|
{ id: "aion-labs.aion-2-0", name: "Aion 2.0", supportsReasoning: true, toolCalling: true },
|
||||||
|
{
|
||||||
|
id: "claude-fable-5",
|
||||||
|
name: "Claude Fable 5",
|
||||||
|
supportsVision: true,
|
||||||
|
supportsReasoning: true,
|
||||||
|
toolCalling: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-haiku-4.5",
|
||||||
|
name: "Claude Haiku 4.5",
|
||||||
|
supportsVision: true,
|
||||||
|
supportsReasoning: true,
|
||||||
|
toolCalling: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-opus-4-7-fast",
|
||||||
|
name: "Claude Opus 4.7 Fast",
|
||||||
|
supportsVision: true,
|
||||||
|
supportsReasoning: true,
|
||||||
|
toolCalling: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-opus-4-8-fast",
|
||||||
|
name: "Claude Opus 4.8 Fast",
|
||||||
|
supportsVision: true,
|
||||||
|
supportsReasoning: true,
|
||||||
|
toolCalling: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-opus-4.5",
|
||||||
|
name: "Claude Opus 4.5",
|
||||||
|
supportsVision: true,
|
||||||
|
supportsReasoning: true,
|
||||||
|
toolCalling: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-opus-4.6",
|
||||||
|
name: "Claude Opus 4.6",
|
||||||
|
supportsVision: true,
|
||||||
|
supportsReasoning: true,
|
||||||
|
toolCalling: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-opus-4.7",
|
||||||
|
name: "Claude Opus 4.7",
|
||||||
|
supportsVision: true,
|
||||||
|
supportsReasoning: true,
|
||||||
|
toolCalling: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-opus-4.8",
|
||||||
|
name: "Claude Opus 4.8",
|
||||||
|
supportsVision: true,
|
||||||
|
supportsReasoning: true,
|
||||||
|
toolCalling: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-opus-5",
|
||||||
|
name: "Claude Opus 5",
|
||||||
|
supportsVision: true,
|
||||||
|
supportsReasoning: true,
|
||||||
|
toolCalling: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-opus-5-fast",
|
||||||
|
name: "Claude Opus 5 Fast",
|
||||||
|
supportsVision: true,
|
||||||
|
supportsReasoning: true,
|
||||||
|
toolCalling: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-sonnet-4.5",
|
||||||
|
name: "Claude Sonnet 4.5",
|
||||||
|
supportsVision: true,
|
||||||
|
supportsReasoning: true,
|
||||||
|
toolCalling: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-sonnet-4.6",
|
||||||
|
name: "Claude Sonnet 4.6",
|
||||||
|
supportsVision: true,
|
||||||
|
supportsReasoning: true,
|
||||||
|
toolCalling: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-sonnet-5",
|
||||||
|
name: "Claude Sonnet 5",
|
||||||
|
supportsVision: true,
|
||||||
|
supportsReasoning: true,
|
||||||
|
toolCalling: true,
|
||||||
|
},
|
||||||
|
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true, toolCalling: true },
|
||||||
|
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true, toolCalling: true },
|
||||||
|
{
|
||||||
|
id: "gemini-2.5-flash",
|
||||||
|
name: "Gemini 2.5 Flash",
|
||||||
|
supportsVision: true,
|
||||||
|
supportsReasoning: true,
|
||||||
|
toolCalling: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gemini-3-5-flash",
|
||||||
|
name: "Gemini 3.5 Flash",
|
||||||
|
supportsVision: true,
|
||||||
|
supportsReasoning: true,
|
||||||
|
toolCalling: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gemini-3-flash-preview",
|
||||||
|
name: "Gemini 3 Flash Preview",
|
||||||
|
supportsVision: true,
|
||||||
|
supportsReasoning: true,
|
||||||
|
toolCalling: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gemini-3.1-flash-lite",
|
||||||
|
name: "Gemini 3.1 Flash Lite",
|
||||||
|
supportsVision: true,
|
||||||
|
supportsReasoning: true,
|
||||||
|
toolCalling: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gemini-3.1-pro",
|
||||||
|
name: "Gemini 3.1 Pro",
|
||||||
|
supportsVision: true,
|
||||||
|
supportsReasoning: true,
|
||||||
|
toolCalling: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gemini-3.1-pro-preview",
|
||||||
|
name: "Gemini 3.1 Pro Preview",
|
||||||
|
supportsReasoning: true,
|
||||||
|
toolCalling: true,
|
||||||
|
},
|
||||||
|
{ id: "glm-4.5", name: "GLM-4.5", supportsReasoning: true, toolCalling: true },
|
||||||
|
{ id: "glm-4.5-air", name: "GLM-4.5 Air", supportsReasoning: true, toolCalling: true },
|
||||||
|
{ id: "glm-4.6", name: "GLM-4.6", supportsReasoning: true, toolCalling: true },
|
||||||
|
{ id: "glm-4.7", name: "GLM-4.7", supportsReasoning: true, toolCalling: true },
|
||||||
|
{ id: "glm-5", name: "GLM-5", supportsReasoning: true, toolCalling: true },
|
||||||
|
{ id: "glm-5.1", name: "GLM-5.1", supportsReasoning: true, toolCalling: true },
|
||||||
|
{ id: "glm-5.2", name: "GLM-5.2", supportsReasoning: true, toolCalling: true },
|
||||||
|
{
|
||||||
|
id: "google/gemini-3.5-flash-lite",
|
||||||
|
name: "Gemini 3.5 Flash Lite",
|
||||||
|
supportsVision: true,
|
||||||
|
supportsReasoning: true,
|
||||||
|
toolCalling: true,
|
||||||
|
},
|
||||||
|
// The GPT-5.x family is tagged for the gateway's native /v1/responses endpoint —
|
||||||
|
// that is the surface OpenAI-family clients (Codex-style) expect for tool loops.
|
||||||
|
{
|
||||||
|
id: "gpt-5.4",
|
||||||
|
name: "GPT-5.4",
|
||||||
|
supportsVision: true,
|
||||||
|
supportsReasoning: true,
|
||||||
|
toolCalling: true,
|
||||||
|
targetFormat: "openai-responses",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gpt-5.4-mini",
|
||||||
|
name: "GPT-5.4 Mini",
|
||||||
|
supportsVision: true,
|
||||||
|
supportsReasoning: true,
|
||||||
|
toolCalling: true,
|
||||||
|
targetFormat: "openai-responses",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gpt-5.5",
|
||||||
|
name: "GPT-5.5",
|
||||||
|
supportsVision: true,
|
||||||
|
supportsReasoning: true,
|
||||||
|
toolCalling: true,
|
||||||
|
targetFormat: "openai-responses",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gpt-5.6-luna",
|
||||||
|
name: "GPT-5.6 Luna",
|
||||||
|
supportsVision: true,
|
||||||
|
supportsReasoning: true,
|
||||||
|
toolCalling: true,
|
||||||
|
targetFormat: "openai-responses",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gpt-5.6-sol",
|
||||||
|
name: "GPT-5.6 Sol",
|
||||||
|
supportsVision: true,
|
||||||
|
supportsReasoning: true,
|
||||||
|
toolCalling: true,
|
||||||
|
targetFormat: "openai-responses",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gpt-5.6-terra",
|
||||||
|
name: "GPT-5.6 Terra",
|
||||||
|
supportsVision: true,
|
||||||
|
supportsReasoning: true,
|
||||||
|
toolCalling: true,
|
||||||
|
targetFormat: "openai-responses",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "grok-4.5",
|
||||||
|
name: "Grok 4.5",
|
||||||
|
supportsVision: true,
|
||||||
|
supportsReasoning: true,
|
||||||
|
toolCalling: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "kimi-k3",
|
||||||
|
name: "Kimi K3",
|
||||||
|
supportsVision: true,
|
||||||
|
supportsReasoning: true,
|
||||||
|
toolCalling: true,
|
||||||
|
},
|
||||||
|
{ id: "minimax-m2.7", name: "MiniMax M2.7", supportsReasoning: true, toolCalling: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const cheaperinferenceProvider: RegistryEntry = {
|
||||||
|
id: "cheaperinference",
|
||||||
|
alias: "cinf",
|
||||||
|
format: "openai",
|
||||||
|
executor: "cheaperinference",
|
||||||
|
baseUrl: "https://api.cheaperinference.com/v1/chat/completions",
|
||||||
|
// The gateway serves a native, STATELESS /v1/responses endpoint alongside
|
||||||
|
// /v1/chat/completions. Consumed by CheaperInferenceExecutor.buildUrl for the
|
||||||
|
// models tagged targetFormat: "openai-responses" above.
|
||||||
|
responsesBaseUrl: "https://api.cheaperinference.com/v1/responses",
|
||||||
|
authType: "apikey",
|
||||||
|
authHeader: "bearer",
|
||||||
|
models: CHEAPERINFERENCE_MODELS,
|
||||||
|
};
|
||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
resolveAccountKey,
|
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";
|
||||||
@@ -45,6 +46,7 @@ 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,
|
||||||
@@ -249,7 +251,10 @@ 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[] = [root.generationConfig, (root.request as Record<string, unknown> | undefined)?.generationConfig];
|
const envelopes: unknown[] = [
|
||||||
|
root.generationConfig,
|
||||||
|
(root.request as Record<string, unknown> | undefined)?.generationConfig,
|
||||||
|
];
|
||||||
for (const env of envelopes) {
|
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;
|
||||||
@@ -445,6 +450,12 @@ export class BaseExecutor {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected usesClaudeCodeProtocol(credentials: ProviderCredentials | null): boolean {
|
||||||
|
if (!isClaudeCodeCompatible(this.provider)) return false;
|
||||||
|
const format = this.resolveAlternate(credentials)?.format;
|
||||||
|
return format !== "openai" && format !== "openai-responses";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the effective API key via extra-keys round-robin rotation.
|
* Resolve the effective API key via extra-keys round-robin rotation.
|
||||||
* Mutates `credentials.providerSpecificData.selectedKeyId` on rotation.
|
* Mutates `credentials.providerSpecificData.selectedKeyId` on rotation.
|
||||||
@@ -589,6 +600,15 @@ export class BaseExecutor {
|
|||||||
|
|
||||||
// Intra-URL retry config: retry same URL before falling back to next node
|
// 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;
|
||||||
@@ -834,15 +854,16 @@ export class BaseExecutor {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ccRequestDefaults = isClaudeCodeCompatible(this.provider)
|
const usesClaudeCodeProtocol = this.usesClaudeCodeProtocol(requestCredentials);
|
||||||
|
const fingerprintProvider =
|
||||||
|
usesCcWireImage(this.provider) && !usesClaudeCodeProtocol ? "codex" : this.provider;
|
||||||
|
const ccRequestDefaults = usesClaudeCodeProtocol
|
||||||
? getClaudeCodeCompatibleRequestDefaults(requestCredentials?.providerSpecificData)
|
? getClaudeCodeCompatibleRequestDefaults(requestCredentials?.providerSpecificData)
|
||||||
: {};
|
: {};
|
||||||
const shouldForwardExtendedContext =
|
const shouldForwardExtendedContext =
|
||||||
extendedContext &&
|
extendedContext && modelSupportsContext1mBeta(model) && !usesClaudeCodeProtocol;
|
||||||
modelSupportsContext1mBeta(model) &&
|
|
||||||
!isClaudeCodeCompatible(this.provider);
|
|
||||||
const shouldForwardCcCompatibleContext1m =
|
const shouldForwardCcCompatibleContext1m =
|
||||||
isClaudeCodeCompatible(this.provider) &&
|
usesClaudeCodeProtocol &&
|
||||||
ccRequestDefaults.context1m === true &&
|
ccRequestDefaults.context1m === true &&
|
||||||
!modelHasNativeContext1m(model);
|
!modelHasNativeContext1m(model);
|
||||||
if (shouldForwardExtendedContext || shouldForwardCcCompatibleContext1m) {
|
if (shouldForwardExtendedContext || shouldForwardCcCompatibleContext1m) {
|
||||||
@@ -922,8 +943,8 @@ export class BaseExecutor {
|
|||||||
!activeCredentials?.apiKey;
|
!activeCredentials?.apiKey;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.provider === "claude" &&
|
((this.provider === "claude" && (isClaudeCodeClient || hasClaudeOAuthToken)) ||
|
||||||
(isClaudeCodeClient || hasClaudeOAuthToken) &&
|
usesClaudeCodeProtocol) &&
|
||||||
typeof transformedBody === "object" &&
|
typeof transformedBody === "object" &&
|
||||||
transformedBody !== null
|
transformedBody !== null
|
||||||
) {
|
) {
|
||||||
@@ -1200,6 +1221,11 @@ 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
|
||||||
@@ -1246,7 +1272,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" || isClaudeCodeCompatible(this.provider);
|
const isClaude = this.provider === "claude" || usesClaudeCodeProtocol;
|
||||||
// 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).
|
||||||
@@ -1265,7 +1291,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" || isClaudeCodeCompatible(this.provider)) {
|
if (this.provider === "claude" || usesClaudeCodeProtocol) {
|
||||||
enforceThinkingTemperature(transformedBody as Record<string, unknown>);
|
enforceThinkingTemperature(transformedBody as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1282,7 +1308,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" || isClaudeCodeCompatible(this.provider)) &&
|
(this.provider === "claude" || usesClaudeCodeProtocol) &&
|
||||||
contextEditing?.enabled &&
|
contextEditing?.enabled &&
|
||||||
!contextEditingDisabled
|
!contextEditingDisabled
|
||||||
) {
|
) {
|
||||||
@@ -1298,17 +1324,17 @@ export class BaseExecutor {
|
|||||||
let bodyString = JSON.stringify(transformedBody);
|
let bodyString = JSON.stringify(transformedBody);
|
||||||
|
|
||||||
const shouldFingerprint =
|
const shouldFingerprint =
|
||||||
isCliCompatEnabled(this.provider) ||
|
isCliCompatEnabled(fingerprintProvider) ||
|
||||||
(this.provider === "claude" && (isClaudeCodeClient || hasClaudeOAuthToken));
|
(this.provider === "claude" && (isClaudeCodeClient || hasClaudeOAuthToken));
|
||||||
if (shouldFingerprint) {
|
if (shouldFingerprint) {
|
||||||
const fingerprinted = applyFingerprint(this.provider, headers, transformedBody);
|
const fingerprinted = applyFingerprint(fingerprintProvider, 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 (isClaudeCodeCompatible(this.provider) || this.provider === "claude") {
|
if (usesClaudeCodeProtocol || this.provider === "claude") {
|
||||||
bodyString = await signRequestBody(bodyString);
|
bodyString = await signRequestBody(bodyString);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1373,6 +1399,13 @@ 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) {
|
||||||
@@ -1396,7 +1429,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 (isClaudeCodeCompatible(this.provider) || this.provider === "claude") {
|
if (usesClaudeCodeProtocol || this.provider === "claude") {
|
||||||
retryBody = await signRequestBody(retryBody);
|
retryBody = await signRequestBody(retryBody);
|
||||||
}
|
}
|
||||||
log?.debug?.(
|
log?.debug?.(
|
||||||
@@ -1435,7 +1468,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 (isClaudeCodeCompatible(this.provider) || this.provider === "claude") {
|
if (usesClaudeCodeProtocol || this.provider === "claude") {
|
||||||
retryBody = await signRequestBody(retryBody);
|
retryBody = await signRequestBody(retryBody);
|
||||||
}
|
}
|
||||||
log?.info?.(
|
log?.info?.(
|
||||||
@@ -1466,7 +1499,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 (isClaudeCodeCompatible(this.provider) || this.provider === "claude") {
|
if (usesClaudeCodeProtocol || this.provider === "claude") {
|
||||||
retryBody = await signRequestBody(retryBody);
|
retryBody = await signRequestBody(retryBody);
|
||||||
}
|
}
|
||||||
log?.debug?.(
|
log?.debug?.(
|
||||||
@@ -1491,7 +1524,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 (isClaudeCodeCompatible(this.provider) || this.provider === "claude") {
|
if (usesClaudeCodeProtocol || this.provider === "claude") {
|
||||||
retryBody = await signRequestBody(retryBody);
|
retryBody = await signRequestBody(retryBody);
|
||||||
}
|
}
|
||||||
log?.info?.(
|
log?.info?.(
|
||||||
@@ -1510,6 +1543,34 @@ export class BaseExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Intra-URL retry: agentrouter.org WAF returns 400 content-blocked
|
||||||
|
// intermittently (burst-sensitive, recovers after cooldown). Retry the
|
||||||
|
// same URL with exponential backoff before falling through to the
|
||||||
|
// 429/401/fallback chain. See docs/security/AGENTROUTER_WAF.md.
|
||||||
|
if (
|
||||||
|
!skipUpstreamRetry &&
|
||||||
|
response.status === HTTP_STATUS.BAD_REQUEST &&
|
||||||
|
(retryAttemptsByUrl[urlIndex] ?? 0) < BaseExecutor.WAF_RETRY_CONFIG.maxAttempts
|
||||||
|
) {
|
||||||
|
const wafErrText = await response
|
||||||
|
.clone()
|
||||||
|
.text()
|
||||||
|
.catch(() => "");
|
||||||
|
if (/content[_-]blocked/i.test(wafErrText)) {
|
||||||
|
retryAttemptsByUrl[urlIndex] = (retryAttemptsByUrl[urlIndex] ?? 0) + 1;
|
||||||
|
const wafAttempt = retryAttemptsByUrl[urlIndex];
|
||||||
|
const wafBackoff = BaseExecutor.WAF_RETRY_CONFIG.delayMs *
|
||||||
|
Math.pow(BaseExecutor.WAF_RETRY_CONFIG.backoffMultiplier, wafAttempt - 1);
|
||||||
|
log?.debug?.(
|
||||||
|
"WAF_RETRY",
|
||||||
|
`400 content-blocked intra-retry ${wafAttempt}/${BaseExecutor.WAF_RETRY_CONFIG.maxAttempts} on ${url} — waiting ${wafBackoff}ms`
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, wafBackoff));
|
||||||
|
urlIndex--; // re-run this urlIndex on the next loop iteration
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Intra-URL retry: if 429 and we haven't exhausted per-URL retries, wait and retry the same URL
|
// Intra-URL retry: if 429 and we haven't exhausted per-URL retries, wait and retry the same URL
|
||||||
if (
|
if (
|
||||||
!skipUpstreamRetry &&
|
!skipUpstreamRetry &&
|
||||||
|
|||||||
69
open-sse/executors/cheaperinference.ts
Normal file
69
open-sse/executors/cheaperinference.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { BaseExecutor, type ProviderCredentials } from "./base.ts";
|
||||||
|
import { PROVIDERS } from "../config/constants.ts";
|
||||||
|
import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CheaperInferenceExecutor — api.cheaperinference.com.
|
||||||
|
*
|
||||||
|
* The gateway is OpenAI-compatible on both surfaces, so everything else comes from
|
||||||
|
* BaseExecutor. Two provider-specific facts need handling (both measured against the
|
||||||
|
* live API on 2026-07-31, not inferred from docs):
|
||||||
|
*
|
||||||
|
* 1. `/v1/responses` is STATELESS and REQUIRES `store:false`. Omitting it returns
|
||||||
|
* HTTP 400 ("This Responses-compatible endpoint is stateless. Send store=false…").
|
||||||
|
* chatCore.ts deletes `store` for every provider except "openai" — a strip shared
|
||||||
|
* by ~290 providers that must not be special-cased — so we re-add it here, after
|
||||||
|
* that strip has run. A client-supplied `store:true` is overwritten rather than
|
||||||
|
* forwarded: the endpoint cannot honour it, and forwarding would 400.
|
||||||
|
*
|
||||||
|
* 2. Chat and Responses live at DIFFERENT URLs (unlike providers that switch on a
|
||||||
|
* path suffix). The per-model `targetFormat` registry tag is the single source of
|
||||||
|
* truth for which surface a model uses — the same tag chatCore reads to translate
|
||||||
|
* the body — so resolving the URL from it keeps URL and payload in lockstep.
|
||||||
|
* Same pattern as executors/xai.ts (9router#2439).
|
||||||
|
*/
|
||||||
|
export class CheaperInferenceExecutor extends BaseExecutor {
|
||||||
|
constructor(provider = "cheaperinference") {
|
||||||
|
super(provider, PROVIDERS[provider]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when this model is served by the native /v1/responses endpoint.
|
||||||
|
*
|
||||||
|
* PROVIDER_MODELS is keyed by provider ALIAS ("cinf"), while PROVIDERS is keyed by
|
||||||
|
* provider ID ("cheaperinference") — so `this.provider` cannot be passed straight
|
||||||
|
* through the way executors/xai.ts does (there the alias equals the id, which hides
|
||||||
|
* the distinction). Resolve the alias first or every lookup silently returns null
|
||||||
|
* and every Responses request 400s upstream.
|
||||||
|
*/
|
||||||
|
private usesResponsesEndpoint(model: string): boolean {
|
||||||
|
const alias = PROVIDER_ID_TO_ALIAS[this.provider] || this.provider;
|
||||||
|
return getModelTargetFormat(alias, model) === "openai-responses";
|
||||||
|
}
|
||||||
|
|
||||||
|
buildUrl(model: string, _stream: boolean, _urlIndex = 0): string {
|
||||||
|
if (this.usesResponsesEndpoint(model)) {
|
||||||
|
return this.config.responsesBaseUrl || this.config.baseUrl;
|
||||||
|
}
|
||||||
|
return this.config.baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
transformRequest(
|
||||||
|
model: string,
|
||||||
|
body: unknown,
|
||||||
|
stream: boolean,
|
||||||
|
credentials: ProviderCredentials
|
||||||
|
): unknown {
|
||||||
|
const cleanedBody = super.transformRequest(model, body, stream, credentials);
|
||||||
|
if (!cleanedBody || typeof cleanedBody !== "object" || Array.isArray(cleanedBody)) {
|
||||||
|
return cleanedBody;
|
||||||
|
}
|
||||||
|
if (!this.usesResponsesEndpoint(model)) {
|
||||||
|
// Chat Completions rejects unknown params — never add `store` on that surface.
|
||||||
|
return cleanedBody;
|
||||||
|
}
|
||||||
|
return { ...(cleanedBody as Record<string, unknown>), store: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CheaperInferenceExecutor;
|
||||||
@@ -55,6 +55,7 @@ import { forwardOpencodeClientHeaders } from "../utils/opencodeHeaders.ts";
|
|||||||
import { resolveZaiUrl } from "./default/zaiFormatOverride.ts";
|
import { 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";
|
||||||
|
|
||||||
@@ -291,6 +292,10 @@ 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: {
|
||||||
@@ -413,7 +418,7 @@ export class DefaultExecutor extends BaseExecutor {
|
|||||||
applyClineAuthHeaders(headers, credentials, effectiveKey, clientHeaders, false);
|
applyClineAuthHeaders(headers, credentials, effectiveKey, clientHeaders, false);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
if (isClaudeCodeCompatible(this.provider)) {
|
if (this.usesClaudeCodeProtocol(credentials)) {
|
||||||
const ccRequestDefaults = getClaudeCodeCompatibleRequestDefaults(
|
const ccRequestDefaults = getClaudeCodeCompatibleRequestDefaults(
|
||||||
credentials?.providerSpecificData
|
credentials?.providerSpecificData
|
||||||
);
|
);
|
||||||
@@ -423,6 +428,10 @@ 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,6 +50,7 @@ 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";
|
||||||
@@ -165,6 +166,8 @@ 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(),
|
||||||
@@ -282,4 +285,5 @@ 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,6 +71,7 @@ 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";
|
||||||
@@ -341,7 +342,6 @@ 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,6 +751,7 @@ export async function handleChatCore({
|
|||||||
provider,
|
provider,
|
||||||
resolvedModel,
|
resolvedModel,
|
||||||
apiFormat,
|
apiFormat,
|
||||||
|
sourceFormat,
|
||||||
customModelTargetFormat,
|
customModelTargetFormat,
|
||||||
providerSpecificData: credentials?.providerSpecificData,
|
providerSpecificData: credentials?.providerSpecificData,
|
||||||
});
|
});
|
||||||
@@ -1920,7 +1921,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 = isClaudeCodeCompatibleProvider(provider);
|
const isClaudeCodeCompatible = usesClaudeBridge(provider, targetFormat, credentials);
|
||||||
const isClaudeCodeSemanticPassthrough = isClaudeCodeSemanticPassthroughRequest({
|
const isClaudeCodeSemanticPassthrough = isClaudeCodeSemanticPassthroughRequest({
|
||||||
provider,
|
provider,
|
||||||
sourceFormat,
|
sourceFormat,
|
||||||
@@ -2496,10 +2497,7 @@ 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}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAI's `store` parameter is not supported by most compatible providers and breaks them
|
stripStore(translatedBody, provider, targetFormat);
|
||||||
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.
|
||||||
|
|||||||
35
open-sse/handlers/chatCore/agentRouterProtocol.ts
Normal file
35
open-sse/handlers/chatCore/agentRouterProtocol.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Per-request AgentRouter protocol decisions kept outside the chatCore orchestration god-file.
|
||||||
|
* AgentRouter exposes Claude Messages, OpenAI Chat, and OpenAI Responses as distinct upstream
|
||||||
|
* protocols, so its dynamic target format must override the connection's default Claude wire image.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isClaudeCodeCompatibleProvider } from "../../services/claudeCodeCompatible.ts";
|
||||||
|
import { FORMATS } from "../../translator/formats.ts";
|
||||||
|
|
||||||
|
export function usesClaudeBridge(
|
||||||
|
provider: string,
|
||||||
|
targetFormat: string,
|
||||||
|
credentials: unknown
|
||||||
|
): boolean {
|
||||||
|
const configuredTargetFormat = (
|
||||||
|
credentials as { providerSpecificData?: { targetFormat?: unknown } | null } | null | undefined
|
||||||
|
)?.providerSpecificData?.targetFormat;
|
||||||
|
const effectiveFormat = provider === "agentrouter" ? targetFormat : configuredTargetFormat;
|
||||||
|
return (
|
||||||
|
isClaudeCodeCompatibleProvider(provider) &&
|
||||||
|
effectiveFormat !== FORMATS.OPENAI &&
|
||||||
|
effectiveFormat !== FORMATS.OPENAI_RESPONSES
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripStore(
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
provider: string,
|
||||||
|
targetFormat: string
|
||||||
|
): void {
|
||||||
|
const supportsStore =
|
||||||
|
provider === "openai" ||
|
||||||
|
(provider === "agentrouter" && targetFormat === FORMATS.OPENAI_RESPONSES);
|
||||||
|
if (!supportsStore && "store" in body) delete body.store;
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@
|
|||||||
* Pure builder extracted from handleChatCore: derives the per-execution credentials object from the
|
* 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, and threads the Claude Code session id when present.
|
* routes to the OpenAI Responses format, synchronizes AgentRouter's per-request alternate protocol,
|
||||||
* Side-effect-free; behaviour is byte-identical to the previous inline closure.
|
* and threads the Claude Code session id when present. Side-effect-free.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getKimiCodeStaticThinkingPolicy } from "../../config/providers/registry/kimi/coding/runtime.ts";
|
import { getKimiCodeStaticThinkingPolicy } from "../../config/providers/registry/kimi/coding/runtime.ts";
|
||||||
@@ -128,6 +128,16 @@ 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,10 +4,11 @@
|
|||||||
*
|
*
|
||||||
* 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 the provider default. Returns both `alias` (reused by
|
* the per-model custom override (#2905), then AgentRouter's matching inbound protocol when the
|
||||||
|
* connection has no explicit override, then the provider default. Returns both `alias` (reused by
|
||||||
* the handler when stripping the `alias/` prefix off the upstream model id) and `targetFormat`.
|
* the handler when stripping the `alias/` prefix off the upstream model id) and `targetFormat`.
|
||||||
* Side-effect-free; byte-identical to the previous inline block. Sits alongside the other
|
* Side-effect-free; sits alongside the other request-setup resolvers
|
||||||
* request-setup resolvers (resolveChatCoreRequestSetup / resolveChatCoreRequestFormat).
|
* (resolveChatCoreRequestSetup / resolveChatCoreRequestFormat).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { PROVIDER_ID_TO_ALIAS, getModelTargetFormat } from "../../config/providerModels.ts";
|
import { PROVIDER_ID_TO_ALIAS, getModelTargetFormat } from "../../config/providerModels.ts";
|
||||||
@@ -18,16 +19,38 @@ 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 { provider, resolvedModel, apiFormat, customModelTargetFormat, providerSpecificData } = opts;
|
const {
|
||||||
|
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 || customModelTargetFormat || getTargetFormat(provider, providerSpecificData);
|
: modelTargetFormat ||
|
||||||
|
customModelTargetFormat ||
|
||||||
|
inferredAgentRouterTargetFormat ||
|
||||||
|
getTargetFormat(provider, providerSpecificData);
|
||||||
return { alias, targetFormat };
|
return { alias, targetFormat };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
68
open-sse/services/combo/strategyDispatch.ts
Normal file
68
open-sse/services/combo/strategyDispatch.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// open-sse/services/combo/strategyDispatch.ts
|
||||||
|
// Runtime source of truth for the known-symbols combo gate (G1).
|
||||||
|
//
|
||||||
|
// HISTÓRICO: `scripts/check/check-known-symbols.ts` (seção 2) costumava descobrir quais
|
||||||
|
// estratégias de roteamento têm branch de despacho lendo a fonte dos arquivos do combo e
|
||||||
|
// extraindo literais `strategy === "..."` por regex. Isso quebra quando o despacho vira um
|
||||||
|
// registry (R0.3): não há mais `strategy === "X"` para casar. Este módulo substitui essa
|
||||||
|
// enumeração por regex-over-source por uma enumeração EXPLÍCITA em runtime, colada ao lado
|
||||||
|
// do código de despacho real.
|
||||||
|
//
|
||||||
|
// Importamos as funções reais de ordenação/despacho (não apenas strings) para amarrar a
|
||||||
|
// enumeração ao código vivo: se a maquinaria de despacho for reestruturada ou um módulo
|
||||||
|
// quebrar, a importação falha no load do gate em vez de casar silenciosamente uma regex
|
||||||
|
// obsoleta. As chaves em HANDLED_COMBO_STRATEGIES DEVEM casar exatamente o conjunto
|
||||||
|
// canônico (ROUTING_STRATEGY_VALUES ∪ INTERNAL_ROUTING_STRATEGY_VALUES).
|
||||||
|
//
|
||||||
|
// Ao adicionar uma estratégia canônica, fie-a no despacho (aqui ou em combo.ts) e inclua-a
|
||||||
|
// nesta lista; ao remover um branch, retire a entrada — o gate acusa qualquer divergência
|
||||||
|
// nas duas direções (canonicalSemDespacho / despachoNaoCanonico).
|
||||||
|
|
||||||
|
import { applyStrategyOrdering } from "./applyStrategyOrdering.ts";
|
||||||
|
import { resolveAutoStrategyOrder } from "./resolveAutoStrategy.ts";
|
||||||
|
import { tryFusionDispatch, tryPipelineDispatch } from "./dispatchPrelude.ts";
|
||||||
|
import { resolveComboTargetPipeline } from "./targetResolution.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As funções reais que implementam o despacho/ordenação de estratégias. Referenciadas
|
||||||
|
* aqui para (a) provar ao gate que a maquinaria resolve e (b) servir de âncora viva para
|
||||||
|
* a enumeração abaixo — o registry que o R0.3 vai passar a consumir para o branching
|
||||||
|
* `strategy === ...` nasce destas mesmas funções.
|
||||||
|
*/
|
||||||
|
export const COMBO_STRATEGY_DISPATCH_LEAVES = {
|
||||||
|
applyStrategyOrdering,
|
||||||
|
resolveAutoStrategyOrder,
|
||||||
|
tryFusionDispatch,
|
||||||
|
tryPipelineDispatch,
|
||||||
|
resolveComboTargetPipeline,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conjunto exato de estratégias de roteamento que possuem implementação de despacho real.
|
||||||
|
*
|
||||||
|
* Cobertura esperada (em `main` do gate): este set ∪ IMPLICIT_DEFAULT_STRATEGIES deve
|
||||||
|
* igualar o canônico. Atualmente todas as 20 estratégias canônicas têm branch — então
|
||||||
|
* HANDLED_COMBO_STRATEGIES já contém as 20 e IMPLICIT_DEFAULT_STRATEGIES está vazio.
|
||||||
|
*/
|
||||||
|
export const HANDLED_COMBO_STRATEGIES: readonly string[] = [
|
||||||
|
"priority",
|
||||||
|
"weighted",
|
||||||
|
"round-robin",
|
||||||
|
"context-relay",
|
||||||
|
"fill-first",
|
||||||
|
"p2c",
|
||||||
|
"random",
|
||||||
|
"least-used",
|
||||||
|
"cost-optimized",
|
||||||
|
"reset-aware",
|
||||||
|
"reset-window",
|
||||||
|
"headroom",
|
||||||
|
"strict-random",
|
||||||
|
"auto",
|
||||||
|
"lkgp",
|
||||||
|
"context-optimized",
|
||||||
|
"cache-optimized",
|
||||||
|
"fusion",
|
||||||
|
"pipeline",
|
||||||
|
"quota-share",
|
||||||
|
] as const;
|
||||||
@@ -120,7 +120,44 @@ 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());
|
||||||
export const CODEX_NATIVE_UNPREFIXED_MODELS = new Set(["codex-auto-review"]);
|
// Bare Codex CLI defaults must always route to the `codex` provider (chatgpt.com
|
||||||
|
// OAuth) even when other providers that also catalog the model id (e.g.
|
||||||
|
// `agentrouter`, `openai`) are active. The Codex cookie quota on the user's
|
||||||
|
// account is the source of truth for capacity, and bare-id requests from
|
||||||
|
// `codex` (CLI)/`Codex` (web) would otherwise silently fan out to whichever
|
||||||
|
// provider won the inference race — leaving the user wondering why the
|
||||||
|
// canonical ChatGPT subscription stopped working. Override per-request by
|
||||||
|
// prefixing the model id (e.g. `agentrouter/gpt-5.6-sol`,
|
||||||
|
// `openai/gpt-5.6-sol`) — the prefix path always wins.
|
||||||
|
export const CODEX_NATIVE_UNPREFIXED_MODELS = new Set([
|
||||||
|
"codex-auto-review",
|
||||||
|
"gpt-5.6-sol",
|
||||||
|
"gpt-5.6-sol-ultra",
|
||||||
|
"gpt-5.6-sol-max",
|
||||||
|
"gpt-5.6-sol-xhigh",
|
||||||
|
"gpt-5.6-sol-high",
|
||||||
|
"gpt-5.6-sol-medium",
|
||||||
|
"gpt-5.6-sol-low",
|
||||||
|
"gpt-5.6-terra",
|
||||||
|
"gpt-5.6-terra-ultra",
|
||||||
|
"gpt-5.6-terra-max",
|
||||||
|
"gpt-5.6-terra-xhigh",
|
||||||
|
"gpt-5.6-terra-high",
|
||||||
|
"gpt-5.6-terra-medium",
|
||||||
|
"gpt-5.6-terra-low",
|
||||||
|
"gpt-5.6-luna",
|
||||||
|
"gpt-5.6-luna-max",
|
||||||
|
"gpt-5.6-luna-xhigh",
|
||||||
|
"gpt-5.6-luna-high",
|
||||||
|
"gpt-5.6-luna-medium",
|
||||||
|
"gpt-5.6-luna-low",
|
||||||
|
"gpt-5.5",
|
||||||
|
"gpt-5.5-xhigh",
|
||||||
|
"gpt-5.5-high",
|
||||||
|
"gpt-5.5-medium",
|
||||||
|
"gpt-5.5-low",
|
||||||
|
"gpt-5.3-codex-spark",
|
||||||
|
]);
|
||||||
|
|
||||||
interface ProviderConnectionLike {
|
interface ProviderConnectionLike {
|
||||||
provider?: unknown;
|
provider?: unknown;
|
||||||
@@ -534,7 +571,19 @@ async function resolveModelByProviderInference(modelId: string, extendedContext:
|
|||||||
getActiveSyncedProvidersForModel(modelId),
|
getActiveSyncedProvidersForModel(modelId),
|
||||||
getPreferClaudeCodeForUnprefixedClaudeModels(),
|
getPreferClaudeCodeForUnprefixedClaudeModels(),
|
||||||
]);
|
]);
|
||||||
const providers = getInferredProvidersForModel(modelId, activeSyncedProviders);
|
// #FIX: synced catalogs (populated from `/v1/models` per connection) can
|
||||||
|
// claim ownership of models the provider does not actually serve (e.g. a
|
||||||
|
// `kiro` upstream briefly advertising `claude-opus-5` before it was
|
||||||
|
// vendored into the registry). Without this filter the bare-routing path
|
||||||
|
// would forward traffic to providers that 404 on the upstream call.
|
||||||
|
// Auto-discovery still wins when no static registry entry exists for the
|
||||||
|
// model id — only entries that conflict with the static catalog are dropped.
|
||||||
|
const staticCatalogProviders = MODEL_TO_PROVIDERS.get(modelId) || [];
|
||||||
|
const validatedSyncedProviders =
|
||||||
|
staticCatalogProviders.length > 0
|
||||||
|
? activeSyncedProviders.filter((p) => staticCatalogProviders.includes(p))
|
||||||
|
: activeSyncedProviders;
|
||||||
|
const providers = getInferredProvidersForModel(modelId, validatedSyncedProviders);
|
||||||
const nonOpenAIProviders = providers.filter((p) => p !== "openai");
|
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.
|
||||||
|
|||||||
76
open-sse/services/wafRateLimit.ts
Normal file
76
open-sse/services/wafRateLimit.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* wafRateLimit.ts — Burst guard for agentrouter.org upstream WAF.
|
||||||
|
*
|
||||||
|
* The agentrouter.org gateway runs a content-filter WAF that becomes more
|
||||||
|
* aggressive after bursts of requests from the same IP/key, returning
|
||||||
|
* `400 content-blocked` for requests that would normally pass. After ~5-10
|
||||||
|
* seconds of cooldown the filter relaxes again.
|
||||||
|
*
|
||||||
|
* To avoid tripping the WAF, we serialize outbound calls per provider and
|
||||||
|
* enforce a minimum inter-request gap. The defaults are conservative and
|
||||||
|
* meant to be a safety net — the upstream request rate from Claude Code is
|
||||||
|
* inherently low (one human-paced request at a time), so this guard should
|
||||||
|
* not affect normal traffic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { log } from "../utils/logger.ts";
|
||||||
|
|
||||||
|
interface BurstGuardState {
|
||||||
|
lastSentAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = new Map<string, BurstGuardState>();
|
||||||
|
|
||||||
|
export interface WafRateLimitConfig {
|
||||||
|
minGapMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: WafRateLimitConfig = {
|
||||||
|
// 500ms is enough to prevent the burst-sensitive WAF from activating
|
||||||
|
// while staying well below human perception of latency.
|
||||||
|
minGapMs: 500,
|
||||||
|
};
|
||||||
|
|
||||||
|
let config: WafRateLimitConfig = { ...DEFAULT_CONFIG };
|
||||||
|
|
||||||
|
export function configureWafRateLimit(overrides: Partial<WafRateLimitConfig>): void {
|
||||||
|
config = { ...config, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWafRateLimitConfig(): WafRateLimitConfig {
|
||||||
|
return { ...config };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait until at least `minGapMs` has passed since the last call to
|
||||||
|
* `gateOutboundRequest` for the same `bucketKey`. Safe to call from
|
||||||
|
* concurrent requests — the lock is held only for the sleep, not across
|
||||||
|
* the actual upstream fetch.
|
||||||
|
*
|
||||||
|
* @param bucketKey Stable identifier for the upstream (e.g. "agentrouter:url").
|
||||||
|
*/
|
||||||
|
export async function gateOutboundRequest(bucketKey: string): Promise<void> {
|
||||||
|
const now = Date.now();
|
||||||
|
const bucket = state.get(bucketKey);
|
||||||
|
if (!bucket) {
|
||||||
|
state.set(bucketKey, { lastSentAt: now });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const elapsed = now - bucket.lastSentAt;
|
||||||
|
const wait = config.minGapMs - elapsed;
|
||||||
|
if (wait > 0) {
|
||||||
|
log?.debug?.(
|
||||||
|
"WAF_RATE_LIMIT",
|
||||||
|
`Throttling outbound to ${bucketKey} — waiting ${wait}ms (min gap ${config.minGapMs}ms)`
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, wait));
|
||||||
|
}
|
||||||
|
state.set(bucketKey, { lastSentAt: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset all rate-limit state. Primarily for tests.
|
||||||
|
*/
|
||||||
|
export function resetWafRateLimit(): void {
|
||||||
|
state.clear();
|
||||||
|
}
|
||||||
103
open-sse/utils/functionalGatewayMirrors.ts
Normal file
103
open-sse/utils/functionalGatewayMirrors.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Functional gateway mirrors (`<gateway-alias>/<original-id>` mirror entries).
|
||||||
|
*
|
||||||
|
* /v1/models announces each model under its canonical owner provider
|
||||||
|
* (`deepseek/deepseek-v4-flash`). But the owner may have NO active credential
|
||||||
|
* while a passthrough gateway provider (e.g. agentrouter / openrouter) DOES and
|
||||||
|
* routes the same model. Discovery clients (omp, jcode, etc.) then see a model
|
||||||
|
* that fails on request, and never the route that works.
|
||||||
|
*
|
||||||
|
* This module synthesizes a mirror entry under the functional gateway alias:
|
||||||
|
*
|
||||||
|
* <gateway-alias>/<original-id> e.g. agentrouter/deepseek/deepseek-v4-flash
|
||||||
|
*
|
||||||
|
* The request path already resolves any known provider prefix
|
||||||
|
* (open-sse/services/model.ts::resolveProviderAlias), so the mirror is
|
||||||
|
* immediately routable with no request-side change. Pure synthesis over the
|
||||||
|
* already key-filtered list — no I/O.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const FUNCTIONAL_GATEWAY_MIRROR_SUFFIX = " (via ";
|
||||||
|
|
||||||
|
export interface FunctionalGatewayMirrorsDeps {
|
||||||
|
/** Ordered list of passthrough gateway provider ids to consider as mirrors. */
|
||||||
|
gatewayProviderIds: string[];
|
||||||
|
/** True when `provider` is a passthrough gateway that can route arbitrary models. */
|
||||||
|
isGateway(provider: string): boolean;
|
||||||
|
/** Map a gateway provider id to its catalog alias (e.g. "command-code" -> "cmd"). */
|
||||||
|
gatewayAlias(provider: string): string;
|
||||||
|
/** True when `gatewayProvider` has an eligible connection that covers `modelId`. */
|
||||||
|
gatewayCovers(gatewayProvider: string, modelId: string): boolean;
|
||||||
|
/** True when `gatewayProvider` has an active credential/connection. */
|
||||||
|
gatewayHasConnection(gatewayProvider: string): boolean;
|
||||||
|
/** True when the canonical owner `provider` has an eligible connection for the model. */
|
||||||
|
canonicalOwnerHasConnection(provider: string): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GatewayMirrorCatalogEntry {
|
||||||
|
id?: unknown;
|
||||||
|
owned_by?: unknown;
|
||||||
|
root?: unknown;
|
||||||
|
name?: unknown;
|
||||||
|
display_name?: unknown;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append `<gatewayAlias>/<originalId>` mirror entries for every eligible model.
|
||||||
|
* Returns the original array reference unchanged when nothing is eligible.
|
||||||
|
*/
|
||||||
|
export function appendFunctionalGatewayMirrors<T extends GatewayMirrorCatalogEntry>(
|
||||||
|
models: T[],
|
||||||
|
deps: FunctionalGatewayMirrorsDeps
|
||||||
|
): T[] {
|
||||||
|
if (!Array.isArray(models)) return models;
|
||||||
|
|
||||||
|
const aliases: T[] = [];
|
||||||
|
for (const model of models) {
|
||||||
|
const id = model.id;
|
||||||
|
if (typeof id !== "string" || id.length === 0) continue;
|
||||||
|
|
||||||
|
const slashIndex = id.indexOf("/");
|
||||||
|
if (slashIndex <= 0) continue; // no provider prefix to re-home
|
||||||
|
const owner = id.slice(0, slashIndex);
|
||||||
|
const modelId = id.slice(slashIndex + 1);
|
||||||
|
if (!modelId || modelId === id) continue;
|
||||||
|
|
||||||
|
// Skip if the canonical owner already has a working connection for this model.
|
||||||
|
if (deps.canonicalOwnerHasConnection(owner)) continue;
|
||||||
|
|
||||||
|
// Find a passthrough gateway that actually routes this model AND has a credential.
|
||||||
|
let chosenAlias: string | null = null;
|
||||||
|
let chosenProvider: string | null = null;
|
||||||
|
for (const gatewayProvider of deps.gatewayProviderIds) {
|
||||||
|
const alias = deps.gatewayAlias(gatewayProvider);
|
||||||
|
if (!alias || alias === owner) continue;
|
||||||
|
if (!deps.isGateway(gatewayProvider)) continue;
|
||||||
|
if (!deps.gatewayHasConnection(gatewayProvider)) continue;
|
||||||
|
if (!deps.gatewayCovers(gatewayProvider, modelId)) continue;
|
||||||
|
chosenAlias = alias;
|
||||||
|
chosenProvider = gatewayProvider;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!chosenAlias || !chosenProvider) continue;
|
||||||
|
|
||||||
|
const aliasId = `${chosenAlias}/${id}`;
|
||||||
|
// Skip if the mirror already exists in the list.
|
||||||
|
if (models.some((m) => m.id === aliasId)) continue;
|
||||||
|
// Skip if the id already starts with this gateway alias (would double-prefix).
|
||||||
|
if (id.startsWith(`${chosenAlias}/`)) continue;
|
||||||
|
|
||||||
|
const label =
|
||||||
|
typeof model.name === "string" && model.name ? model.name : modelId;
|
||||||
|
aliases.push({
|
||||||
|
...model,
|
||||||
|
id: aliasId,
|
||||||
|
root: id,
|
||||||
|
owned_by: chosenProvider,
|
||||||
|
display_name: `${label}${FUNCTIONAL_GATEWAY_MIRROR_SUFFIX}${chosenProvider})`,
|
||||||
|
} as T);
|
||||||
|
}
|
||||||
|
|
||||||
|
return aliases.length > 0 ? [...models, ...aliases] : models;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { parseSSEDataPayload } from "./streamHelpers.ts";
|
|||||||
import {
|
import {
|
||||||
backfillResponsesCompletedOutput,
|
backfillResponsesCompletedOutput,
|
||||||
normalizeResponsesSseIds,
|
normalizeResponsesSseIds,
|
||||||
|
normalizeResponsesCompletedUsage,
|
||||||
pushUniqueResponsesOutputItems,
|
pushUniqueResponsesOutputItems,
|
||||||
stringifyIdValue,
|
stringifyIdValue,
|
||||||
stripResponsesLifecycleEcho,
|
stripResponsesLifecycleEcho,
|
||||||
@@ -174,13 +175,20 @@ 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 (stripped || backfilled || textualToolCallBackfilled || responsesIdsNormalized) {
|
if (
|
||||||
|
stripped ||
|
||||||
|
backfilled ||
|
||||||
|
textualToolCallBackfilled ||
|
||||||
|
responsesIdsNormalized ||
|
||||||
|
usageNormalized
|
||||||
|
) {
|
||||||
output = `data: ${JSON.stringify(outputPayload)}\n\n`;
|
output = `data: ${JSON.stringify(outputPayload)}\n\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,49 @@ 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,6 +49,7 @@ import {
|
|||||||
} from "../services/sessionManager.ts";
|
} from "../services/sessionManager.ts";
|
||||||
import {
|
import {
|
||||||
backfillResponsesCompletedOutput,
|
backfillResponsesCompletedOutput,
|
||||||
|
normalizeResponsesCompletedUsage as normalizeUsage,
|
||||||
normalizeResponsesSseIds,
|
normalizeResponsesSseIds,
|
||||||
pushUniqueResponsesOutputItems,
|
pushUniqueResponsesOutputItems,
|
||||||
stringifyIdValue,
|
stringifyIdValue,
|
||||||
@@ -1581,11 +1582,13 @@ 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;
|
||||||
@@ -2273,7 +2276,6 @@ 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 =
|
||||||
@@ -2281,7 +2283,9 @@ 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) {
|
||||||
if (normalizeResponsesSseIds(flushedParsed)) {
|
const idsNormalized = 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) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "omniroute",
|
"name": "omniroute",
|
||||||
"version": "3.8.50",
|
"version": "3.8.50",
|
||||||
"description": "Unified AI router with 160+ providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.",
|
"description": "Unified AI router with 290 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"omniroute": "bin/omniroute.mjs",
|
"omniroute": "bin/omniroute.mjs",
|
||||||
@@ -110,6 +110,8 @@
|
|||||||
"test:unit:ci": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial",
|
"test:unit:ci": "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\"",
|
||||||
|
|||||||
7
public/providers/cheaperinference.svg
Normal file
7
public/providers/cheaperinference.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
|
||||||
|
<title id="title">Cheaper Inference gateway mark</title>
|
||||||
|
<rect x="1" y="1" width="62" height="62" fill="#050807" stroke="#31f889" stroke-width="2" />
|
||||||
|
<path d="M8 21V8h13M43 56h13V43" fill="none" stroke="#c8d2cc" stroke-width="2" />
|
||||||
|
<path d="M31 20c-8 0-13 5-13 12s5 12 13 12" fill="none" stroke="#f2f5f3" stroke-linecap="square" stroke-width="7" />
|
||||||
|
<path d="m33 25 10 7-10 7" fill="none" stroke="#31f889" stroke-linecap="square" stroke-linejoin="miter" stroke-width="5" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 586 B |
@@ -9,13 +9,17 @@
|
|||||||
// não resolve para um executor válido é um símbolo morto (roteia para fallback
|
// 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 — a cadeia de despacho `strategy === "..."` em
|
// (2) COMBO STRATEGIES — o despacho DEVE tratar exatamente o conjunto canônico de
|
||||||
// open-sse/services/combo.ts DEVE tratar exatamente o conjunto canônico de
|
// ROUTING_STRATEGY_VALUES ∪ INTERNAL_ROUTING_STRATEGY_VALUES
|
||||||
// ROUTING_STRATEGY_VALUES (src/shared/constants/routingStrategies.ts), exceto
|
// (src/shared/constants/routingStrategies.ts), exceto as estratégias-default
|
||||||
// as estratégias-default implícitas documentadas em IMPLICIT_DEFAULT_STRATEGIES
|
// implícitas documentadas em IMPLICIT_DEFAULT_STRATEGIES (estratégias canônicas
|
||||||
// (estratégias canônicas sem NENHUMA referência `strategy === "..."`; caem no
|
// sem ramo de despacho próprio; caem no ordenamento padrão). Em vez de casar
|
||||||
// ordenamento padrão). Adicionar um valor canônico sem fiá-lo no despacho, ou
|
// literais `strategy === "..."` por regex sobre a fonte, o conjunto tratado
|
||||||
// fiar uma string de estratégia que não é canônica (inventada), falha aqui.
|
// (handled) vem de uma enumeração em runtime importada de
|
||||||
|
// open-sse/services/combo/strategyDispatch.ts — o módulo que importa as funções
|
||||||
|
// reais de ordenação/despacho e lista quais estratégias elas implementam. Adicionar
|
||||||
|
// um valor canônico sem fiá-lo no despacho/e na enumeração, ou fiar uma string de
|
||||||
|
// estratégia que não é canônica (inventada), falha aqui.
|
||||||
//
|
//
|
||||||
// (3) TRANSLATOR PAIRS — os pares from:to registrados em runtime no registry de
|
// (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:
|
||||||
@@ -483,21 +487,15 @@ 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[]),
|
||||||
];
|
];
|
||||||
// The combo dispatch was decomposed (Block J): the `strategy === "..."` branches
|
// G1: the handled set comes from a runtime-imported dispatch registry that imports the
|
||||||
// now live across combo.ts + its strategy-ordering leaves, so scan all of them.
|
// actual strategy-ordering functions and enumerates which strategies they implement —
|
||||||
const comboDispatchFiles = [
|
// NOT from regex-scanning `strategy === "..."` literals in source. The old regex broke
|
||||||
"open-sse/services/combo.ts",
|
// when the dispatch was decomposed (Block J / #3501) and will break again when R0.3
|
||||||
"open-sse/services/combo/applyStrategyOrdering.ts",
|
// converts it to a registry; enumerating at runtime keeps the gate correct either way.
|
||||||
"open-sse/services/combo/resolveAutoStrategy.ts",
|
// Each entry in HANDLED_COMBO_STRATEGIES must stay in sync with a real dispatch branch.
|
||||||
// #3501: the fusion/pipeline dispatch branches moved here with the prelude
|
const strategyDispatchMod =
|
||||||
// extraction; the `strategy === "..."` checks are unchanged, just relocated.
|
await import("@omniroute/open-sse/services/combo/strategyDispatch.ts");
|
||||||
"open-sse/services/combo/dispatchPrelude.ts",
|
const handled = new Set(strategyDispatchMod.HANDLED_COMBO_STRATEGIES as readonly string[]);
|
||||||
"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
|
||||||
|
|||||||
111
scripts/quality/test-scoped.sh
Executable file
111
scripts/quality/test-scoped.sh
Executable file
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# test-scoped — run only unit tests impacted by your changes.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# npm run test:scoped # tests for changes vs HEAD~1
|
||||||
|
# npm run test:scoped -- --staged # tests for staged changes only
|
||||||
|
#
|
||||||
|
# This is the local DX companion to the CI TIA gate (#8084 D1). The CI version
|
||||||
|
# builds a full import-graph impact map; for local dev we use a fast heuristic:
|
||||||
|
# - Changed test files → run those directly
|
||||||
|
# - Changed source files → run tests that share the file's directory/name prefix
|
||||||
|
# - Hub files (tsconfig, package.json, etc.) → suggest full suite
|
||||||
|
#
|
||||||
|
# For the full TIA (import-graph based), use: npm run test:scoped:full
|
||||||
|
# (requires a pre-built impact map via: node scripts/quality/build-test-impact-map.mjs)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
|
||||||
|
# ── 1. Determine changed files ───────────────────────────────────────────────
|
||||||
|
if [[ "${1:-}" == "--staged" ]]; then
|
||||||
|
CHANGED=$(git -C "$REPO_ROOT" diff --name-only --diff-filter=ACMR --cached)
|
||||||
|
else
|
||||||
|
CHANGED=$(git -C "$REPO_ROOT" diff --name-only --diff-filter=ACMR HEAD~1...HEAD 2>/dev/null || \
|
||||||
|
git -C "$REPO_ROOT" diff --name-only --diff-filter=ACMR)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$CHANGED" ]; then
|
||||||
|
echo "[test:scoped] No changed files — nothing to test."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 2. Classify changes ──────────────────────────────────────────────────────
|
||||||
|
HUB_RE="(setupPolyfill|tsconfig|package\\.json|package-lock\\.json|\\.env|vitest\\.config|stryker\\.conf)"
|
||||||
|
TEST_FILES=()
|
||||||
|
SRC_FILES=()
|
||||||
|
HIT_HUB=false
|
||||||
|
|
||||||
|
while IFS= read -r f; do
|
||||||
|
[ -z "$f" ] && continue
|
||||||
|
if echo "$f" | grep -qE "$HUB_RE"; then
|
||||||
|
HIT_HUB=true
|
||||||
|
elif echo "$f" | grep -qE '^tests/unit/.*\.test\.(ts|mjs)$'; then
|
||||||
|
TEST_FILES+=("$f")
|
||||||
|
elif echo "$f" | grep -qE '^(src|open-sse)/'; then
|
||||||
|
SRC_FILES+=("$f")
|
||||||
|
fi
|
||||||
|
done <<< "$CHANGED"
|
||||||
|
|
||||||
|
# ── 3. Hub file changed → full suite ─────────────────────────────────────────
|
||||||
|
if [ "$HIT_HUB" = true ]; then
|
||||||
|
echo "[test:scoped] Hub file changed — run full suite: npm run test:unit"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 4. Collect tests to run ──────────────────────────────────────────────────
|
||||||
|
RUN_TESTS=()
|
||||||
|
|
||||||
|
# Direct test file changes always run
|
||||||
|
for tf in "${TEST_FILES[@]}"; do
|
||||||
|
RUN_TESTS+=("$tf")
|
||||||
|
done
|
||||||
|
|
||||||
|
# For source files, try the impact map first; fall back to heuristic
|
||||||
|
MAP_FILE="$REPO_ROOT/config/quality/test-impact-map.json"
|
||||||
|
if [ ${#SRC_FILES[@]} -gt 0 ] && [ -f "$MAP_FILE" ]; then
|
||||||
|
# Use the TIA selection with the impact map
|
||||||
|
SEL=$(printf '%s\n' "${SRC_FILES[@]}" | node "$REPO_ROOT/scripts/quality/select-impacted-tests.mjs" 2>/dev/null || echo "__RUN_ALL__")
|
||||||
|
if echo "$SEL" | grep -q "__RUN_ALL__"; then
|
||||||
|
echo "[test:scoped] Unmapped source change — run full suite: npm run test:unit"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
while IFS= read -r t; do
|
||||||
|
[ -n "$t" ] && RUN_TESTS+=("$t")
|
||||||
|
done <<< "$SEL"
|
||||||
|
elif [ ${#SRC_FILES[@]} -gt 0 ]; then
|
||||||
|
# No impact map — heuristic: suggest building it
|
||||||
|
echo "[test:scoped] No impact map found. Build it with: node scripts/quality/build-test-impact-map.mjs"
|
||||||
|
echo "[test:scoped] Or run the full suite: npm run test:unit"
|
||||||
|
echo ""
|
||||||
|
echo "[test:scoped] Changed source files:"
|
||||||
|
printf ' %s\n' "${SRC_FILES[@]}"
|
||||||
|
if [ ${#TEST_FILES[@]} -gt 0 ]; then
|
||||||
|
echo "[test:scoped] Running changed test files only..."
|
||||||
|
else
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Deduplicate
|
||||||
|
IFS=$'\n' SORTED=($(printf '%s\n' "${RUN_TESTS[@]}" | sort -u)); unset IFS
|
||||||
|
|
||||||
|
if [ ${#SORTED[@]} -eq 0 ]; then
|
||||||
|
echo "[test:scoped] No impacted tests — source changes don't map to any unit test."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[test:scoped] Running ${#SORTED[@]} impacted test(s)..."
|
||||||
|
|
||||||
|
# ── 5. Run selected tests ────────────────────────────────────────────────────
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
exec cross-env \
|
||||||
|
DISABLE_SQLITE_AUTO_BACKUP=true \
|
||||||
|
node --max-old-space-size=8192 \
|
||||||
|
--import tsx/esm \
|
||||||
|
--import ./open-sse/utils/setupPolyfill.ts \
|
||||||
|
--import ./tests/_setup/isolateDataDir.ts \
|
||||||
|
--test --test-force-exit --test-concurrency=4 \
|
||||||
|
"${SORTED[@]}"
|
||||||
@@ -27,22 +27,17 @@ export default function BootstrapBanner() {
|
|||||||
<span className="text-amber-500 dark:text-amber-400 text-base shrink-0 mt-0.5">⚠️</span>
|
<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">
|
||||||
Running in zero-config mode
|
{t("zeroConfigBannerTitle")}
|
||||||
</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">
|
||||||
OmniRoute auto-generated secure encryption keys on first launch. They are persisted to{" "}
|
{t.rich("zeroConfigBannerBody", {
|
||||||
<code className="font-mono bg-amber-200/50 dark:bg-amber-500/20 px-1 rounded text-xs">
|
dataDir,
|
||||||
{dataDir}
|
code: (chunks) => (
|
||||||
</code>
|
<code className="font-mono bg-amber-200/50 dark:bg-amber-500/20 px-1 rounded text-xs">
|
||||||
. No action is required — your data is encrypted and safe. To use custom keys, add{" "}
|
{chunks}
|
||||||
<code className="font-mono bg-amber-200/50 dark:bg-amber-500/20 px-1 rounded text-xs">
|
</code>
|
||||||
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: "Download DMG (macOS)",
|
label: t("downloadDmg"),
|
||||||
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: `A new version of the OmniRoute desktop app is available. Please download and install the macOS DMG installer to update (current: v${versionInfo?.current || ""}).`,
|
desc: t("downloadDmgDescription", { version: versionInfo?.current || "" }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (platform === "win32") {
|
if (platform === "win32") {
|
||||||
return {
|
return {
|
||||||
label: "Download EXE (Windows)",
|
label: t("downloadExe"),
|
||||||
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: `A new version of the OmniRoute desktop app is available. Please download and install the Windows EXE installer to update (current: v${versionInfo?.current || ""}).`,
|
desc: t("downloadExeDescription", { version: versionInfo?.current || "" }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (platform === "linux") {
|
if (platform === "linux") {
|
||||||
return {
|
return {
|
||||||
label: "Download AppImage (Linux)",
|
label: t("downloadAppImage"),
|
||||||
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: `A new version of the OmniRoute desktop app is available. Please download the Linux AppImage package to update (current: v${versionInfo?.current || ""}).`,
|
desc: t("downloadAppImageDescription", { version: versionInfo?.current || "" }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
label: "Download Update",
|
label: t("downloadUpdate"),
|
||||||
url: `https://github.com/diegosouzapw/OmniRoute/releases/tag/v${cleanLatest}`,
|
url: `https://github.com/diegosouzapw/OmniRoute/releases/tag/v${cleanLatest}`,
|
||||||
desc: `A new version of the OmniRoute desktop app is available. Please download the respective app format for your system to update (current: v${versionInfo?.current || ""}).`,
|
desc: t("downloadUpdateDescription", { version: versionInfo?.current || "" }),
|
||||||
};
|
};
|
||||||
}, [platform, versionInfo?.latest, versionInfo?.current]);
|
}, [platform, t, versionInfo?.latest, versionInfo?.current]);
|
||||||
|
|
||||||
// Electron internal auto-updater state and listeners
|
// 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 || `Queued update to v${targetVersion}.`,
|
message: message || t("updateQueued", { version: targetVersion }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
step: "rebuild",
|
step: "rebuild",
|
||||||
status: "running",
|
status: "running",
|
||||||
message: "Docker image is rebuilding in the background.",
|
message: t("updateDockerRebuilding"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
step: "restart",
|
step: "restart",
|
||||||
status: "pending",
|
status: "pending",
|
||||||
message: "Waiting for OmniRoute to restart with the new version.",
|
message: t("updateWaitingRestart"),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
step: "install",
|
step: "install",
|
||||||
status: "running",
|
status: "running",
|
||||||
message: message || `Installing v${targetVersion}.`,
|
message: message || t("updateInstalling", { version: targetVersion }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
step: "restart",
|
step: "restart",
|
||||||
status: "pending",
|
status: "pending",
|
||||||
message: "Waiting for OmniRoute to restart with the new version.",
|
message: t("updateWaitingRestart"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -593,14 +593,14 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
|||||||
next = mergeUpdateStep(next, {
|
next = mergeUpdateStep(next, {
|
||||||
step: "complete",
|
step: "complete",
|
||||||
status: "done",
|
status: "done",
|
||||||
message: `OmniRoute is now running v${targetVersion}.`,
|
message: t("updateRunning", { version: targetVersion }),
|
||||||
});
|
});
|
||||||
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
setUpdating(false);
|
setUpdating(false);
|
||||||
setUpdatePhase("done");
|
setUpdatePhase("done");
|
||||||
notify.success(`OmniRoute updated to v${targetVersion}.`);
|
notify.success(t("updateCompleted", { version: 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: `Docker image is still rebuilding for v${targetVersion}.`,
|
message: t("updateDockerStillRebuilding", { version: targetVersion }),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
next = mergeUpdateStep(next, {
|
next = mergeUpdateStep(next, {
|
||||||
step: "install",
|
step: "install",
|
||||||
status: "running",
|
status: "running",
|
||||||
message: `Installing v${targetVersion} in the background.`,
|
message: t("updateInstallingBackground", { version: targetVersion }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
next = mergeUpdateStep(next, {
|
next = mergeUpdateStep(next, {
|
||||||
step: "restart",
|
step: "restart",
|
||||||
status: "pending",
|
status: "pending",
|
||||||
message: `Waiting for OmniRoute to come back on v${targetVersion}.`,
|
message: t("updateWaitingVersion", { version: 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: "Docker rebuild is still in progress.",
|
message: t("updateDockerStillInProgress"),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
next = mergeUpdateStep(next, {
|
next = mergeUpdateStep(next, {
|
||||||
step: "install",
|
step: "install",
|
||||||
status: "running",
|
status: "running",
|
||||||
message: `Installing v${targetVersion} in the background.`,
|
message: t("updateInstallingBackground", { version: targetVersion }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
next = mergeUpdateStep(next, {
|
next = mergeUpdateStep(next, {
|
||||||
step: "restart",
|
step: "restart",
|
||||||
status: "running",
|
status: "running",
|
||||||
message: "Service restart in progress. Waiting for OmniRoute to come back online...",
|
message: t("updateRestarting"),
|
||||||
});
|
});
|
||||||
|
|
||||||
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: `Update started, but v${targetVersion} did not become available before timeout. Refresh the page or check server logs.`,
|
message: t("updateTimeout", { version: targetVersion }),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
setUpdating(false);
|
setUpdating(false);
|
||||||
setUpdatePhase("failed");
|
setUpdatePhase("failed");
|
||||||
notify.error(`Update to v${targetVersion} timed out.`);
|
notify.error(t("updateTimedOut", { version: targetVersion }));
|
||||||
},
|
},
|
||||||
[fetchData]
|
[fetchData, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
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, "Failed to start update."));
|
notify.error(extractApiErrorMessage(data, t("updateStartFailed")));
|
||||||
setUpdating(false);
|
setUpdating(false);
|
||||||
setUpdatePhase("idle");
|
setUpdatePhase("idle");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
notify.success(data.message || "Update started.");
|
notify.success(data.message || t("updateStarted"));
|
||||||
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("No response stream received.");
|
notify.error(t("noResponseStream"));
|
||||||
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 || "Update complete!");
|
notify.success(event.message || t("updateComplete"));
|
||||||
} else if (event.step === "error") {
|
} else if (event.step === "error") {
|
||||||
setUpdatePhase("failed");
|
setUpdatePhase("failed");
|
||||||
notify.error(event.message || "Update failed.");
|
notify.error(event.message || t("updateFailed"));
|
||||||
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: "Network error — connection lost during update.",
|
message: t("updateNetworkError"),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
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: "Install Package",
|
install: t("stepInstallPackage"),
|
||||||
rebuild: "Rebuild Native Modules",
|
rebuild: t("stepRebuildNativeModules"),
|
||||||
restart: "Restart Service",
|
restart: t("stepRestartService"),
|
||||||
complete: "Complete",
|
complete: t("stepComplete"),
|
||||||
error: "Error",
|
error: t("stepError"),
|
||||||
};
|
};
|
||||||
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"
|
||||||
? "Update Complete!"
|
? t("updateCompleteTitle")
|
||||||
: updatePhase === "failed"
|
: updatePhase === "failed"
|
||||||
? "Update Failed"
|
? t("updateFailedTitle")
|
||||||
: "Updating OmniRoute..."}
|
: t("updatingTitle")}
|
||||||
</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"
|
||||||
? "The page will reload automatically in a few seconds."
|
? t("reloadNotice")
|
||||||
: updatePhase === "failed"
|
: updatePhase === "failed"
|
||||||
? "Please try again or update manually via the CLI."
|
? t("retryNotice")
|
||||||
: "Do not close this page. The system will restart automatically."}
|
: t("restartNotice")}
|
||||||
</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 || "Update complete!"}
|
{updateSteps.find((s) => s.step === "complete")?.message || t("updateComplete")}
|
||||||
</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" ? "Reload Now" : "Close"}
|
{updatePhase === "done" ? t("reloadNow") : t("closeUpdate")}
|
||||||
</Button>
|
</Button>
|
||||||
{updatePhase === "failed" && (
|
{updatePhase === "failed" && (
|
||||||
<Button size="sm" variant="secondary" fullWidth onClick={handleUpdate}>
|
<Button size="sm" variant="secondary" fullWidth onClick={handleUpdate}>
|
||||||
Retry
|
{t("retryUpdate")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -917,30 +917,32 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
|||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-sm">
|
<p className="font-semibold text-sm">
|
||||||
Update Available: v{versionInfo.latest} {isElectron && "(Desktop App)"}
|
{t("updateAvailableTitle", {
|
||||||
|
version: versionInfo.latest,
|
||||||
|
desktop: isElectron ? ` ${t("desktopAppLabel")}` : "",
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs opacity-80 mt-0.5">
|
<p className="text-xs opacity-80 mt-0.5">
|
||||||
{isElectron ? (
|
{isElectron ? (
|
||||||
<>
|
<>
|
||||||
{electronUpdateStatus.status === "checking" && "Checking for updates..."}
|
{electronUpdateStatus.status === "checking" && t("checkingForUpdates")}
|
||||||
{electronUpdateStatus.status === "available" &&
|
{electronUpdateStatus.status === "available" &&
|
||||||
`Version v${versionInfo.latest} is available for download.`}
|
t("versionAvailableForDownload", { version: versionInfo.latest })}
|
||||||
{electronUpdateStatus.status === "downloading" &&
|
{electronUpdateStatus.status === "downloading" &&
|
||||||
`Downloading update... ${electronUpdateStatus.percent || 0}% complete.`}
|
t("downloadingUpdate", { percent: electronUpdateStatus.percent || 0 })}
|
||||||
{electronUpdateStatus.status === "downloaded" &&
|
{electronUpdateStatus.status === "downloaded" && t("updateDownloaded")}
|
||||||
"Update downloaded successfully! Click Restart & Install to apply."}
|
|
||||||
{electronUpdateStatus.status === "error" &&
|
{electronUpdateStatus.status === "error" &&
|
||||||
`Auto-update failed: ${electronUpdateStatus.message || "Unknown error"}.`}
|
t("autoUpdateFailed", {
|
||||||
|
reason: electronUpdateStatus.message || t("unknownUpdateError"),
|
||||||
|
})}
|
||||||
{(electronUpdateStatus.status === "idle" ||
|
{(electronUpdateStatus.status === "idle" ||
|
||||||
electronUpdateStatus.status === "not-available") &&
|
electronUpdateStatus.status === "not-available") &&
|
||||||
`Version v${versionInfo.latest} is available for the desktop app.`}
|
t("versionAvailableDesktop", { version: versionInfo.latest })}
|
||||||
</>
|
</>
|
||||||
) : 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 ||
|
versionInfo.autoUpdateError || t("manualUpdateRequired")
|
||||||
"Manual update required for this installation type."
|
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -954,7 +956,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
|||||||
onClick={() => globalThis.window.electronAPI?.downloadUpdate()}
|
onClick={() => globalThis.window.electronAPI?.downloadUpdate()}
|
||||||
className="font-semibold"
|
className="font-semibold"
|
||||||
>
|
>
|
||||||
Download Update
|
{t("downloadUpdate")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{electronUpdateStatus.status === "downloading" && (
|
{electronUpdateStatus.status === "downloading" && (
|
||||||
@@ -973,7 +975,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"
|
||||||
>
|
>
|
||||||
Restart & Install
|
{t("restartAndInstall")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{(electronUpdateStatus.status === "error" ||
|
{(electronUpdateStatus.status === "error" ||
|
||||||
@@ -989,7 +991,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
|||||||
}}
|
}}
|
||||||
className="font-semibold"
|
className="font-semibold"
|
||||||
>
|
>
|
||||||
Check for Update
|
{t("checkForUpdate")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1001,9 +1003,7 @@ 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
|
{versionInfo.autoUpdateSupported ? t("updateNow") : t("manualUpdate")}
|
||||||
? t("updateNow") || "Update Now"
|
|
||||||
: "Manual Update"}
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1015,9 +1015,7 @@ 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">
|
<p className="text-xs opacity-75">{t("directDownloadHint")}</p>
|
||||||
Or download the respective installer format directly:
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1029,7 +1027,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
|
|||||||
}
|
}
|
||||||
className="font-semibold text-xs py-1"
|
className="font-semibold text-xs py-1"
|
||||||
>
|
>
|
||||||
Release Notes
|
{t("releaseNotes")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1067,7 +1065,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 || "Ler Mais"}
|
{versionInfo.news.linkLabel || t("readMore")}
|
||||||
<span className="material-symbols-outlined text-[14px]">arrow_forward</span>
|
<span className="material-symbols-outlined text-[14px]">arrow_forward</span>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
@@ -1214,7 +1212,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: "No Auth" },
|
"no-auth": { color: "bg-stone-500", label: t("noAuthLabel") },
|
||||||
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,9 +42,7 @@ export default function AutoRoutingAnalyticsTab() {
|
|||||||
if (!stats) {
|
if (!stats) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="text-center py-8 text-text-muted">
|
<div className="text-center py-8 text-text-muted">{t("autoRoutingNoDataAvailable")}</div>
|
||||||
No auto-routing analytics available. Make requests using the auto/ prefix to see metrics.
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -110,7 +108,9 @@ 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">{variant || "default"}</div>
|
<div className="w-32 text-sm font-medium capitalize">
|
||||||
|
{variant || t("defaultVariantLabel")}
|
||||||
|
</div>
|
||||||
<div className="flex-1 h-3 bg-border rounded-full overflow-hidden">
|
<div className="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">Provider</th>
|
<th className="text-left py-2 px-3 font-medium">{t("chartProvider")}</th>
|
||||||
<th className="text-right py-2 px-3 font-medium">Requests</th>
|
<th className="text-right py-2 px-3 font-medium">{t("chartRequests")}</th>
|
||||||
<th className="text-right py-2 px-3 font-medium">Share</th>
|
<th className="text-right py-2 px-3 font-medium">{t("chartShare")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -50,11 +50,13 @@ 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 (
|
||||||
@@ -62,7 +64,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} queries · ${costUsd.toFixed(4)}
|
{count} {queriesLabel} · ${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">
|
||||||
@@ -99,7 +101,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>
|
||||||
Loading search analytics…
|
{t("searchAnalyticsLoading")}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -108,9 +110,11 @@ 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 || "No search data available yet."}
|
{error || t("searchAnalyticsNoData")}
|
||||||
<p className="text-xs mt-2">
|
<p className="text-xs mt-2">
|
||||||
Search requests will appear here after the first search via /v1/search.
|
{t.rich("searchAnalyticsNoDataDescription", {
|
||||||
|
code: (chunks) => <code className="bg-bg-muted px-1 rounded">{chunks}</code>,
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -126,25 +130,29 @@ 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={`${stats.today} today`}
|
sub={t("searchAnalyticsToday", { count: stats.today })}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon="cached"
|
icon="cached"
|
||||||
label={t("searchAnalyticsCacheHitRate")}
|
label={t("searchAnalyticsCacheHitRate")}
|
||||||
value={`${stats.cacheHitRate}%`}
|
value={`${stats.cacheHitRate}%`}
|
||||||
sub={`${stats.cached} cached requests`}
|
sub={t("searchAnalyticsCachedRequests", { count: stats.cached })}
|
||||||
/>
|
/>
|
||||||
<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="search API costs"
|
sub={t("searchAnalyticsApiCosts")}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon="timer"
|
icon="timer"
|
||||||
label={t("searchAnalyticsAvgResponse")}
|
label={t("searchAnalyticsAvgResponse")}
|
||||||
value={`${stats.avgDurationMs}ms`}
|
value={`${stats.avgDurationMs}ms`}
|
||||||
sub={stats.errors > 0 ? `${stats.errors} errors` : "No errors"}
|
sub={
|
||||||
|
stats.errors > 0
|
||||||
|
? t("searchAnalyticsErrors", { count: stats.errors })
|
||||||
|
: t("searchAnalyticsNoErrors")
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -153,7 +161,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>
|
||||||
Provider Breakdown
|
{t("searchAnalyticsProviderBreakdown")}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{providers.map(([prov, data]) => (
|
{providers.map(([prov, data]) => (
|
||||||
@@ -163,6 +171,7 @@ 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>
|
||||||
@@ -177,8 +186,9 @@ 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">
|
||||||
Use <code className="bg-bg-muted px-1 rounded">POST /v1/search</code> to start routing
|
{t.rich("searchAnalyticsEmptyDescription", {
|
||||||
web searches.
|
code: (chunks) => <code className="bg-bg-muted px-1 rounded">{chunks}</code>,
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -189,8 +199,9 @@ export default function SearchAnalyticsTab() {
|
|||||||
check_circle
|
check_circle
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<strong>Free tier available:</strong> Serper (2,500/mo), Brave (2,000/mo), Exa (1,000/mo),
|
{t.rich("searchAnalyticsFreeTier", {
|
||||||
Tavily (1,000/mo) — total 6,500+ free searches/month with automatic failover.
|
strong: (chunks) => <strong>{chunks}</strong>,
|
||||||
|
})}
|
||||||
</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: "Calls (24h)",
|
label: t("mcpMetricCalls24h"),
|
||||||
value: stats.totalCalls.toLocaleString(),
|
value: stats.totalCalls.toLocaleString(),
|
||||||
icon: "terminal",
|
icon: "terminal",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Success rate",
|
label: t("mcpMetricSuccessRate"),
|
||||||
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: "Avg duration",
|
label: t("mcpMetricAvgDuration"),
|
||||||
value: `${Math.round(stats.avgDurationMs)}ms`,
|
value: `${Math.round(stats.avgDurationMs)}ms`,
|
||||||
icon: "timer",
|
icon: "timer",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Top tool",
|
label: t("mcpMetricTopTool"),
|
||||||
value: stats.topTools[0]?.tool ?? "—",
|
value: stats.topTools[0]?.tool ?? "—",
|
||||||
icon: "star",
|
icon: "star",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { useEffect } from "react";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useBatchActions } from "./components/useBatchActions";
|
import { useBatchActions } from "./components/useBatchActions";
|
||||||
|
|
||||||
function relativeTime(ts: number): string {
|
type BatchTranslator = ReturnType<typeof useTranslations>;
|
||||||
|
|
||||||
|
function relativeTime(ts: number, t: BatchTranslator): string {
|
||||||
const diffMs = Date.now() - ts * 1000;
|
const diffMs = Date.now() - ts * 1000;
|
||||||
const isFuture = diffMs < 0;
|
const isFuture = diffMs < 0;
|
||||||
const absDiffMs = Math.abs(diffMs);
|
const absDiffMs = Math.abs(diffMs);
|
||||||
@@ -22,8 +24,9 @@ function relativeTime(ts: number): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isFuture) return `in ${res}`;
|
return isFuture
|
||||||
return `${res} ago`;
|
? t("batchRelativeTimeIn", { value: res })
|
||||||
|
: t("batchRelativeTimeAgo", { value: res });
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BatchRecord {
|
interface BatchRecord {
|
||||||
@@ -93,6 +96,22 @@ 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;
|
||||||
@@ -106,10 +125,12 @@ function effectiveStatus(batch: BatchRecord): string {
|
|||||||
return map[batch.status] ?? batch.status;
|
return map[batch.status] ?? batch.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ batch }: { batch: BatchRecord }) {
|
function StatusBadge({ batch, t }: { batch: BatchRecord; t: BatchTranslator }) {
|
||||||
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_LABELS[key] ?? key.replace(/_/g, " ");
|
const label = STATUS_TRANSLATION_KEYS[key]
|
||||||
|
? 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}
|
||||||
@@ -141,12 +162,24 @@ function formatTs(ts: number | null | undefined): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BatchDetailModal({ batch, files, onClose, onActionDone }: BatchDetailModalProps) {
|
export default function BatchDetailModal({
|
||||||
|
batch,
|
||||||
|
files,
|
||||||
|
onClose,
|
||||||
|
onActionDone,
|
||||||
|
}: BatchDetailModalProps) {
|
||||||
const t = useTranslations("common");
|
const t = useTranslations("common");
|
||||||
|
|
||||||
// ── Action hook (F7) ─────────────────────────────────────────────────────────
|
// ── Action hook (F7) ─────────────────────────────────────────────────────────
|
||||||
const { cancelling, retrying, error: actionError, cancel, retry, downloadHrefOutput, downloadHrefErrors } =
|
const {
|
||||||
useBatchActions({ onRefresh: onActionDone, t });
|
cancelling,
|
||||||
|
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);
|
||||||
@@ -195,8 +228,11 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
|||||||
pending_actions
|
pending_actions
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h2 id="batch-detail-modal-title" className="text-base font-semibold text-[var(--color-text-main)]">
|
<h2
|
||||||
Batch Details
|
id="batch-detail-modal-title"
|
||||||
|
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>
|
||||||
@@ -227,16 +263,18 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
|||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
<div className="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)]">
|
||||||
Status
|
{t("status")}
|
||||||
</span>
|
</span>
|
||||||
<StatusBadge batch={batch} />
|
<StatusBadge batch={batch} t={t} />
|
||||||
</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={<span title={formatTs(batch.createdAt)}>{relativeTime(batch.createdAt)}</span>}
|
value={
|
||||||
|
<span title={formatTs(batch.createdAt)}>{relativeTime(batch.createdAt, t)}</span>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -245,7 +283,7 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
|||||||
<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">
|
||||||
Progress
|
{t("batchProgress")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[var(--color-text-muted)]">
|
<span className="text-[var(--color-text-muted)]">
|
||||||
{completed} / {total} ({pct}%)
|
{completed} / {total} ({pct}%)
|
||||||
@@ -262,17 +300,9 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
|||||||
/>
|
/>
|
||||||
</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>
|
<span>{t("batchCompletedCount", { count: completed })}</span>
|
||||||
<span className="text-emerald-400 font-medium">{completed}</span> completed
|
{failed > 0 && <span>{t("batchFailedCount", { count: failed })}</span>}
|
||||||
</span>
|
<span>{t("batchPendingCount", { count: total - completed - failed })}</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>
|
||||||
)}
|
)}
|
||||||
@@ -280,19 +310,19 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
|||||||
{/* 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">
|
||||||
Timeline
|
{t("batchTimeline")}
|
||||||
</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: "Created", ts: batch.createdAt },
|
{ label: t("batchDetailCreated"), ts: batch.createdAt },
|
||||||
{ label: "In Progress", ts: batch.inProgressAt },
|
{ label: t("batchTimelineInProgress"), ts: batch.inProgressAt },
|
||||||
{ label: "Finalizing", ts: batch.finalizingAt },
|
{ label: t("batchTimelineFinalizing"), ts: batch.finalizingAt },
|
||||||
{ label: "Completed", ts: batch.completedAt },
|
{ label: t("batchTimelineCompleted"), ts: batch.completedAt },
|
||||||
{ label: "Failed", ts: batch.failedAt },
|
{ label: t("batchTimelineFailed"), ts: batch.failedAt },
|
||||||
{ label: "Expires", ts: batch.expiresAt },
|
{ label: t("batchTimelineExpires"), ts: batch.expiresAt },
|
||||||
{ label: "Expired", ts: batch.expiredAt },
|
{ label: t("batchTimelineExpired"), ts: batch.expiredAt },
|
||||||
{ label: "Cancelling", ts: batch.cancellingAt },
|
{ label: t("batchTimelineCancelling"), ts: batch.cancellingAt },
|
||||||
{ label: "Cancelled", ts: batch.cancelledAt },
|
{ label: t("batchTimelineCancelled"), ts: batch.cancelledAt },
|
||||||
]
|
]
|
||||||
.filter((t) => t.ts)
|
.filter((t) => t.ts)
|
||||||
.map(({ label, ts }) => (
|
.map(({ label, ts }) => (
|
||||||
@@ -315,9 +345,21 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{[
|
{[
|
||||||
{ role: "Input", fileId: batch.inputFileId, record: inputFile },
|
{
|
||||||
{ role: "Output", fileId: batch.outputFileId, record: outputFile },
|
role: t("filesListUsedByRoleInput"),
|
||||||
{ role: "Errors", fileId: batch.errorFileId, record: errorFile },
|
fileId: batch.inputFileId,
|
||||||
|
record: inputFile,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: t("filesListUsedByRoleOutput"),
|
||||||
|
fileId: batch.outputFileId,
|
||||||
|
record: outputFile,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: t("filesListUsedByRoleError"),
|
||||||
|
fileId: batch.errorFileId,
|
||||||
|
record: errorFile,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
.filter((f) => f.fileId)
|
.filter((f) => f.fileId)
|
||||||
.map(({ role, fileId, record }) => (
|
.map(({ role, fileId, record }) => (
|
||||||
@@ -350,7 +392,7 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
|||||||
className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-muted)] hover:text-[var(--color-text-main)] transition-colors"
|
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>
|
||||||
Download
|
{t("filesListDownload")}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -362,7 +404,7 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
|||||||
{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">
|
||||||
Token Usage
|
{t("batchTokenUsage")}
|
||||||
</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)}
|
||||||
@@ -374,7 +416,7 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
|||||||
{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">
|
||||||
Errors
|
{t("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)}
|
||||||
@@ -386,7 +428,7 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
|||||||
{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">
|
||||||
Metadata
|
{t("batchMetadata")}
|
||||||
</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]) => (
|
||||||
@@ -438,7 +480,7 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
|
|||||||
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,22 +4,24 @@ 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";
|
||||||
|
|
||||||
function relativeTime(ts: number): string {
|
type FileTranslator = ReturnType<typeof useTranslations>;
|
||||||
|
|
||||||
|
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 `${diffSec}s ago`;
|
if (diffSec < 60) return t("batchRelativeTimeAgo", { value: `${diffSec}s` });
|
||||||
const diffMin = Math.round(diffSec / 60);
|
const diffMin = Math.round(diffSec / 60);
|
||||||
if (diffMin < 60) return `${diffMin}m ago`;
|
if (diffMin < 60) return t("batchRelativeTimeAgo", { value: `${diffMin}m` });
|
||||||
const diffHr = Math.round(diffMin / 60);
|
const diffHr = Math.round(diffMin / 60);
|
||||||
if (diffHr < 24) return `${diffHr}h ago`;
|
if (diffHr < 24) return t("batchRelativeTimeAgo", { value: `${diffHr}h` });
|
||||||
const diffDays = Math.round(diffHr / 24);
|
const diffDays = Math.round(diffHr / 24);
|
||||||
return `${diffDays}d ago`;
|
return t("batchRelativeTimeAgo", { value: `${diffDays}d` });
|
||||||
}
|
}
|
||||||
|
|
||||||
function relativeExpiration(ts: number | null): string {
|
function relativeExpiration(ts: number | null, t: FileTranslator): string {
|
||||||
if (!ts) return "Never";
|
if (!ts) return t("batchFilesNeverExpires");
|
||||||
const diffMs = ts * 1000 - Date.now();
|
const diffMs = ts * 1000 - Date.now();
|
||||||
if (diffMs <= 0) return "Expired";
|
if (diffMs <= 0) return t("expirationBadgeExpired");
|
||||||
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);
|
||||||
@@ -55,6 +57,22 @@ 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;
|
||||||
@@ -133,7 +151,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)]">
|
||||||
File Contents
|
{t("batchFileContents")}
|
||||||
</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>
|
||||||
@@ -164,7 +182,7 @@ export default function FileDetailModal({
|
|||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 p-4 rounded-xl bg-[var(--color-bg-alt)] border border-[var(--color-border)]">
|
<div className="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)]">
|
||||||
Size
|
{t("batchFilesSizeColumn")}
|
||||||
</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)}
|
||||||
@@ -172,24 +190,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)]">
|
||||||
Purpose
|
{t("batchFilesPurpose")}
|
||||||
</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)]">
|
||||||
Created
|
{t("batchDetailCreated")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-[var(--color-text-main)]">
|
<span className="text-sm text-[var(--color-text-main)]">
|
||||||
{createdAtTs ? relativeTime(createdAtTs) : "—"}
|
{createdAtTs ? relativeTime(createdAtTs, t) : "—"}
|
||||||
</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)]">
|
||||||
Expires
|
{t("batchFilesExpires")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-[var(--color-text-main)]">
|
<span className="text-sm text-[var(--color-text-main)]">
|
||||||
{expiresAtTs ? relativeExpiration(expiresAtTs) : "Never"}
|
{expiresAtTs ? relativeExpiration(expiresAtTs, t) : t("batchFilesNeverExpires")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -198,7 +216,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">
|
||||||
Used by {relatedBatches.length} batch{relatedBatches.length > 1 ? "es" : ""}
|
{t("batchFileUsedByCount", { count: relatedBatches.length })}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{relatedBatches.map((b) => (
|
{relatedBatches.map((b) => (
|
||||||
@@ -219,7 +237,9 @@ 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"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{b.status.replaceAll("_", " ")}
|
{BATCH_STATUS_TRANSLATION_KEYS[b.status]
|
||||||
|
? t(BATCH_STATUS_TRANSLATION_KEYS[b.status])
|
||||||
|
: b.status.replaceAll("_", " ")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -231,7 +251,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)]">
|
||||||
Preview
|
{t("batchFilePreview")}
|
||||||
</h3>
|
</h3>
|
||||||
{contents && (
|
{contents && (
|
||||||
<button
|
<button
|
||||||
@@ -241,7 +261,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 ? "Copied!" : "Copy"}
|
{copied ? t("copied") : t("copy")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -259,7 +279,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>
|
||||||
Showing first 1000 lines ({lineCount} total lines)
|
{t("batchFilePreviewTruncated", { shown: 1000, total: lineCount })}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -281,7 +301,7 @@ export default function FileDetailModal({
|
|||||||
className="flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-lg bg-[var(--color-accent)] text-white hover:opacity-90 transition-opacity"
|
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>
|
||||||
Download Full File
|
{t("batchFileDownloadFull")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
16
src/app/(dashboard)/dashboard/chaos/chaosI18n.ts
Normal file
16
src/app/(dashboard)/dashboard/chaos/chaosI18n.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export type ChaosTranslator = ((
|
||||||
|
key: string,
|
||||||
|
values?: Record<string, string | number>
|
||||||
|
) => string) & {
|
||||||
|
has?: (key: string) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function chaosText(
|
||||||
|
t: ChaosTranslator,
|
||||||
|
key: string,
|
||||||
|
fallback: string,
|
||||||
|
values?: Record<string, string | number>
|
||||||
|
): string {
|
||||||
|
if (typeof t.has !== "function" || !t.has(key)) return fallback;
|
||||||
|
return values ? t(key, values) : t(key);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"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.
|
||||||
@@ -22,7 +23,7 @@ export function ChaosConfigActionsBar({
|
|||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
onTest: () => void;
|
onTest: () => void;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("chaosConfig");
|
const t = useTranslations("chaosConfig") as ChaosTranslator;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -66,7 +67,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 ? "Running..." : t("testButton")}
|
{testing ? chaosText(t, "running", "Running...") : t("testButton")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
"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;
|
||||||
@@ -26,14 +29,24 @@ 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">
|
<h3 className="text-sm font-bold text-text-main">{resultsTitle}</h3>
|
||||||
Test Results — {result.mode} mode ({result.totalProviders} providers)
|
<div className="text-xs text-text-muted">{startedLabel}</div>
|
||||||
</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,11 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* /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 const metadata = {
|
export async function generateMetadata() {
|
||||||
title: "Chaos Mode — OmniRoute",
|
const t = await getTranslations("chaosConfig");
|
||||||
};
|
return { title: `${t("pageTitle")} — OmniRoute` };
|
||||||
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return <ChaosConfigPageClient />;
|
return <ChaosConfigPageClient />;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,7 +14,7 @@ export function useChaosConfigPersistence(
|
|||||||
config: ChaosPageConfig,
|
config: ChaosPageConfig,
|
||||||
setConfig: Dispatch<SetStateAction<ChaosPageConfig>>
|
setConfig: Dispatch<SetStateAction<ChaosPageConfig>>
|
||||||
) {
|
) {
|
||||||
const t = useTranslations("chaosConfig");
|
const t = useTranslations("chaosConfig") as ChaosTranslator;
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [message, setMessage] = useState<ChaosPageMessage>(null);
|
const [message, setMessage] = useState<ChaosPageMessage>(null);
|
||||||
|
|
||||||
@@ -49,17 +50,26 @@ 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({ type: "success", text: "Config reset to defaults" });
|
setMessage({
|
||||||
|
type: "success",
|
||||||
|
text: chaosText(t, "resetSuccess", "Config reset to defaults"),
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
const err = await res.json().catch(() => ({ error: "Reset failed" }));
|
const err = await res.json().catch(() => ({ error: null }));
|
||||||
setMessage({ type: "error", text: err.error || "Reset failed" });
|
setMessage({
|
||||||
|
type: "error",
|
||||||
|
text: err.error || chaosText(t, "resetFailed", "Reset failed"),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setMessage({ type: "error", text: "Failed to reset config" });
|
setMessage({
|
||||||
|
type: "error",
|
||||||
|
text: chaosText(t, "resetFailed", "Failed to reset config"),
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [setConfig]);
|
}, [setConfig, t]);
|
||||||
|
|
||||||
return { t, saving, message, setMessage, saveConfig, resetConfig };
|
return { t, saving, message, setMessage, saveConfig, resetConfig };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
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";
|
||||||
|
|
||||||
@@ -10,8 +11,11 @@ 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(config: ChaosPageConfig, setMessage: (message: ChaosPageMessage) => void) {
|
export function useChaosTestRun(
|
||||||
const t = useTranslations("chaosConfig");
|
config: ChaosPageConfig,
|
||||||
|
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);
|
||||||
|
|
||||||
@@ -34,11 +38,17 @@ export function useChaosTestRun(config: ChaosPageConfig, setMessage: (message: C
|
|||||||
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: "Unknown error" }));
|
const err = await res.json().catch(() => ({ error: null }));
|
||||||
setMessage({ type: "error", text: err.error || "Test failed" });
|
setMessage({
|
||||||
|
type: "error",
|
||||||
|
text: err.error || chaosText(t, "testFailed", "Test failed"),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setMessage({ type: "error", text: err.message || "Test failed" });
|
setMessage({
|
||||||
|
type: "error",
|
||||||
|
text: err.message || chaosText(t, "testFailed", "Test failed"),
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setTesting(false);
|
setTesting(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"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 {
|
||||||
@@ -24,6 +25,7 @@ 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);
|
||||||
@@ -71,7 +73,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 || `${action} succeeded` });
|
setMessage({ type: "success", text: data.message || t("cliproxyapiActionSucceeded") });
|
||||||
await fetchStatus();
|
await fetchStatus();
|
||||||
if (action === "install" || action === "restart") await fetchUpdateInfo();
|
if (action === "install" || action === "restart") await fetchUpdateInfo();
|
||||||
} else {
|
} else {
|
||||||
@@ -79,11 +81,14 @@ 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) ||
|
||||||
`${action} failed`,
|
t("cliproxyapiActionFailed"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMessage({ type: "error", text: err instanceof Error ? err.message : "Request failed" });
|
setMessage({
|
||||||
|
type: "error",
|
||||||
|
text: err instanceof Error ? err.message : t("cliproxyapiRequestFailed"),
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(null);
|
setLoading(null);
|
||||||
}
|
}
|
||||||
@@ -93,14 +98,26 @@ 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: { label: "Running", color: "bg-green-500/10 text-green-600 dark:text-green-400" },
|
running: {
|
||||||
stopped: { label: "Stopped", color: "bg-zinc-500/10 text-zinc-500 dark:text-zinc-400" },
|
label: t("cliproxyapiStatusRunning"),
|
||||||
|
color: "bg-green-500/10 text-green-600 dark:text-green-400",
|
||||||
|
},
|
||||||
|
stopped: {
|
||||||
|
label: t("cliproxyapiStatusStopped"),
|
||||||
|
color: "bg-zinc-500/10 text-zinc-500 dark:text-zinc-400",
|
||||||
|
},
|
||||||
not_installed: {
|
not_installed: {
|
||||||
label: "Not Installed",
|
label: t("cliproxyapiStatusNotInstalled"),
|
||||||
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: { label: "Installed", color: "bg-blue-500/10 text-blue-600 dark:text-blue-400" },
|
installed: {
|
||||||
error: { label: "Error", color: "bg-red-500/10 text-red-600 dark:text-red-400" },
|
label: t("cliproxyapiStatusInstalled"),
|
||||||
|
color: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
label: t("cliproxyapiStatusError"),
|
||||||
|
color: "bg-red-500/10 text-red-600 dark:text-red-400",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const badge = map[s] || map.not_installed;
|
const badge = map[s] || map.not_installed;
|
||||||
return (
|
return (
|
||||||
@@ -125,9 +142,7 @@ 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">
|
<p className="text-xs text-text-muted truncate">{t("cliproxyapiDescription")}</p>
|
||||||
Upstream proxy fallback (Go-based OAuth)
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
@@ -161,7 +176,10 @@ 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">
|
||||||
Update available: v{updateInfo.current} → v{updateInfo.latest}
|
{t("cliproxyapiUpdateAvailable", {
|
||||||
|
current: updateInfo.current,
|
||||||
|
latest: updateInfo.latest,
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -170,32 +188,34 @@ 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"}
|
||||||
>
|
>
|
||||||
Update
|
{t("cliproxyapiUpdate")}
|
||||||
</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">Version</p>
|
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiVersion")}</p>
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-medium">
|
||||||
{toolState?.installedVersion ? `v${toolState.installedVersion}` : "Not installed"}
|
{toolState?.installedVersion
|
||||||
|
? `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">Health</p>
|
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiHealth")}</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"
|
||||||
? `Healthy`
|
? t("cliproxyapiHealthy")
|
||||||
: toolState?.healthStatus === "unhealthy"
|
: toolState?.healthStatus === "unhealthy"
|
||||||
? "Unhealthy"
|
? t("cliproxyapiUnhealthy")
|
||||||
: "Unknown"}
|
: t("cliproxyapiUnknown")}
|
||||||
</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">Port</p>
|
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiPort")}</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>
|
||||||
@@ -209,7 +229,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>
|
||||||
Install
|
{t("cliproxyapiInstall")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{toolState?.status === "running" ? (
|
{toolState?.status === "running" ? (
|
||||||
@@ -220,7 +240,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>
|
||||||
Stop
|
{t("cliproxyapiStop")}
|
||||||
</Button>
|
</Button>
|
||||||
) : toolState?.installedVersion ? (
|
) : toolState?.installedVersion ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -230,7 +250,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>
|
||||||
Start
|
{t("cliproxyapiStart")}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{toolState?.status === "running" && (
|
{toolState?.status === "running" && (
|
||||||
@@ -241,7 +261,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>
|
||||||
Restart
|
{t("cliproxyapiRestart")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
@@ -251,7 +271,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>
|
||||||
Check Updates
|
{t("cliproxyapiCheckUpdates")}
|
||||||
</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>{allCombos.length} intelligent combo(s)</span>
|
<span>{t("intelligentComboCount", { count: allCombos.length })}</span>
|
||||||
<span>{providerScopeCount} providers in scope</span>
|
<span>{t("providersInScope", { count: providerScopeCount })}</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">
|
||||||
Candidate Pool
|
{getI18nOrFallback(t, "candidatePoolLabel", "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,7 +183,12 @@ export default function IntelligentComboPanel({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{savingModePack && (
|
{savingModePack && (
|
||||||
<span className="text-[11px] text-text-muted">Saving {savingModePack}…</span>
|
<span className="text-[11px] text-text-muted">
|
||||||
|
{getI18nOrFallback(t, "savingModePack", "Saving {pack}…").replace(
|
||||||
|
"{pack}",
|
||||||
|
savingModePack
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -308,7 +313,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">
|
||||||
Exploration Rate
|
{getI18nOrFallback(t, "explorationRateLabel", "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,5 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
export default function CombosError({
|
export default function CombosError({
|
||||||
error: _error,
|
error: _error,
|
||||||
reset,
|
reset,
|
||||||
@@ -7,6 +9,8 @@ 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]"
|
||||||
@@ -14,14 +18,10 @@ 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">
|
<h2 className="text-xl font-semibold text-red-600 dark:text-red-400">{t("errorTitle")}</h2>
|
||||||
Failed to load combos
|
<p className="text-text-muted max-w-md">{t("errorDescription")}</p>
|
||||||
</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">Error ID: {_error.digest}</p>
|
<p className="text-xs text-text-muted font-mono">{t("errorId", { id: _error.digest })}</p>
|
||||||
)}
|
)}
|
||||||
{process.env.NODE_ENV === "development" && _error?.message && (
|
{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"
|
||||||
>
|
>
|
||||||
Try Again
|
{t("errorRetry")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
export const metadata = {
|
export async function generateMetadata() {
|
||||||
title: "Compression",
|
const t = await getTranslations("metadata");
|
||||||
description: "Configure context compression settings to reduce token usage and costs.",
|
return {
|
||||||
};
|
title: t("compressionTitle"),
|
||||||
|
description: t("compressionDescription"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function CompressionPage() {
|
export default function CompressionPage() {
|
||||||
redirect("/dashboard/context/caveman");
|
redirect("/dashboard/context/caveman");
|
||||||
|
|||||||
@@ -534,7 +534,10 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
|||||||
if (!cloudConfigured) {
|
if (!cloudConfigured) {
|
||||||
setCloudStatus({
|
setCloudStatus({
|
||||||
type: "warning",
|
type: "warning",
|
||||||
message: "Cloud sync is not configured on this instance.",
|
message: translateOrFallback(
|
||||||
|
"cloudSyncNotConfigured",
|
||||||
|
"Cloud sync is not configured on this instance."
|
||||||
|
),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1822,7 +1825,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") || "Core APIs"}
|
{t("categoryCore")}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex-1 h-px bg-border/50" />
|
<div className="flex-1 h-px bg-border/50" />
|
||||||
</div>
|
</div>
|
||||||
@@ -1843,7 +1846,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") || "Responses API"}
|
title={t("responses")}
|
||||||
path="/v1/responses"
|
path="/v1/responses"
|
||||||
models={endpointData.chat}
|
models={endpointData.chat}
|
||||||
copy={copy}
|
copy={copy}
|
||||||
@@ -1855,7 +1858,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") || "Completions (Legacy)"}
|
title={t("completionsLegacy")}
|
||||||
path="/v1/completions"
|
path="/v1/completions"
|
||||||
models={endpointData.chat}
|
models={endpointData.chat}
|
||||||
copy={copy}
|
copy={copy}
|
||||||
@@ -1867,7 +1870,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") || "Messages"}
|
title={t("messagesApi")}
|
||||||
path="/v1/messages"
|
path="/v1/messages"
|
||||||
models={null}
|
models={null}
|
||||||
badge="Anthropic"
|
badge="Anthropic"
|
||||||
@@ -1883,7 +1886,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") || "Media & Multi-Modal"}
|
{t("categoryMedia")}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex-1 h-px bg-border/50" />
|
<div className="flex-1 h-px bg-border/50" />
|
||||||
</div>
|
</div>
|
||||||
@@ -1916,7 +1919,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") || "Image Edits"}
|
title={t("imageEdits")}
|
||||||
path="/v1/images/edits"
|
path="/v1/images/edits"
|
||||||
models={endpointData.images}
|
models={endpointData.images}
|
||||||
copy={copy}
|
copy={copy}
|
||||||
@@ -1952,7 +1955,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") || "Music Generation"}
|
title={t("musicGeneration")}
|
||||||
path="/v1/music/generations"
|
path="/v1/music/generations"
|
||||||
models={endpointData.music}
|
models={endpointData.music}
|
||||||
copy={copy}
|
copy={copy}
|
||||||
@@ -1964,7 +1967,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") || "Video Generation"}
|
title={t("videoGeneration")}
|
||||||
path="/v1/videos/generations"
|
path="/v1/videos/generations"
|
||||||
models={endpointData.video}
|
models={endpointData.video}
|
||||||
copy={copy}
|
copy={copy}
|
||||||
@@ -1983,7 +1986,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") || "Search & Discovery"}
|
{t("categorySearch")}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex-1 h-px bg-border/50" />
|
<div className="flex-1 h-px bg-border/50" />
|
||||||
</div>
|
</div>
|
||||||
@@ -1992,7 +1995,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") || "Web Search"}
|
title={t("webSearch")}
|
||||||
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}
|
||||||
@@ -2008,7 +2011,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") || "Utility & Management"}
|
{t("categoryUtility")}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex-1 h-px bg-border/50" />
|
<div className="flex-1 h-px bg-border/50" />
|
||||||
</div>
|
</div>
|
||||||
@@ -2041,7 +2044,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") || "Batch API"}
|
title={t("batchApi")}
|
||||||
path="/v1/batches"
|
path="/v1/batches"
|
||||||
models={null}
|
models={null}
|
||||||
badge="OpenAI"
|
badge="OpenAI"
|
||||||
@@ -2053,7 +2056,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") || "Files API"}
|
title={t("filesApi")}
|
||||||
path="/v1/files"
|
path="/v1/files"
|
||||||
models={null}
|
models={null}
|
||||||
copy={copy}
|
copy={copy}
|
||||||
@@ -2064,7 +2067,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") || "List Models"}
|
title={t("listModels")}
|
||||||
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") || "FSM Status"}</th>
|
<th className="text-left py-2 pr-2">{t("tablePhase")}</th>
|
||||||
<th className="text-left py-2 pr-2">{t("tableUpdated")}</th>
|
<th className="text-left py-2 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,6 +12,18 @@ 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");
|
||||||
@@ -37,7 +49,10 @@ export default function NotionSourceCard() {
|
|||||||
|
|
||||||
const handleSaveToken = async () => {
|
const handleSaveToken = async () => {
|
||||||
if (!token.trim()) {
|
if (!token.trim()) {
|
||||||
setMessage({ type: "error", text: "Please enter a Notion integration token" });
|
setMessage({
|
||||||
|
type: "error",
|
||||||
|
text: translateOrFallback("notionEnterToken", "Please enter a Notion integration token"),
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -53,11 +68,20 @@ export default function NotionSourceCard() {
|
|||||||
setConnected(true);
|
setConnected(true);
|
||||||
setMessage({ type: "success", text: data.message });
|
setMessage({ type: "success", text: data.message });
|
||||||
} else {
|
} else {
|
||||||
setMessage({ type: "error", text: data.error ?? "Failed to connect" });
|
setMessage({
|
||||||
|
type: "error",
|
||||||
|
text: data.error ?? translateOrFallback("notionConnectFailed", "Failed to connect"),
|
||||||
|
});
|
||||||
setConnected(false);
|
setConnected(false);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMessage({ type: "error", text: err instanceof Error ? err.message : "Connection failed" });
|
setMessage({
|
||||||
|
type: "error",
|
||||||
|
text:
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: translateOrFallback("notionConnectionFailed", "Connection failed"),
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -74,10 +98,19 @@ export default function NotionSourceCard() {
|
|||||||
setToken("");
|
setToken("");
|
||||||
setMessage({ type: "success", text: data.message });
|
setMessage({ type: "success", text: data.message });
|
||||||
} else {
|
} else {
|
||||||
setMessage({ type: "error", text: data.error ?? "Failed to disconnect" });
|
setMessage({
|
||||||
|
type: "error",
|
||||||
|
text: data.error ?? translateOrFallback("notionDisconnectFailed", "Failed to disconnect"),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMessage({ type: "error", text: err instanceof Error ? err.message : "Disconnect failed" });
|
setMessage({
|
||||||
|
type: "error",
|
||||||
|
text:
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: translateOrFallback("notionDisconnectFailed", "Disconnect failed"),
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -97,11 +130,16 @@ 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" : "Not connected"}
|
{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">
|
||||||
Search, read, query, and write to Notion through routed AI models
|
{translateOrFallback(
|
||||||
|
"notionDescription",
|
||||||
|
"Search, read, query, and write to Notion through routed AI models"
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
@@ -131,7 +169,10 @@ 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">
|
||||||
Notion Internal Integration Token
|
{translateOrFallback(
|
||||||
|
"notionIntegrationToken",
|
||||||
|
"Notion Internal Integration Token"
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
@@ -143,11 +184,14 @@ 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">
|
||||||
Connect
|
{translateOrFallback("notionConnect", "Connect")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-text-muted">
|
<p className="text-[10px] text-text-muted">
|
||||||
Create an Internal Integration at{" "}
|
{translateOrFallback(
|
||||||
|
"notionIntegrationHelp",
|
||||||
|
"Create an Internal Integration at"
|
||||||
|
)}{" "}
|
||||||
<code className="text-primary font-mono bg-surface/80 px-1 rounded">
|
<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>
|
||||||
@@ -156,7 +200,10 @@ 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">
|
||||||
Token configured. Notion tools are available via MCP.
|
{translateOrFallback(
|
||||||
|
"notionTokenConfigured",
|
||||||
|
"Token configured. Notion tools are available via MCP."
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleDisconnect}
|
onClick={handleDisconnect}
|
||||||
@@ -165,7 +212,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!"
|
||||||
>
|
>
|
||||||
Disconnect
|
{translateOrFallback("notionDisconnect", "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, Badge } from "@/shared/components";
|
import { Card, Button, EmptyState } from "@/shared/components";
|
||||||
import { useNotificationStore } from "@/store/notificationStore";
|
import { useNotificationStore } from "@/store/notificationStore";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
@@ -19,10 +19,6 @@ 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);
|
||||||
|
|
||||||
@@ -42,51 +38,7 @@ 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);
|
||||||
@@ -137,127 +89,58 @@ 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>
|
||||||
|
|
||||||
{activeTab === "marketplace" && (
|
<div className="flex items-center justify-end">
|
||||||
<Card className="p-4 flex gap-4 items-end bg-gray-50">
|
<Button onClick={handleScan} disabled={scanning}>
|
||||||
<div className="flex-1">
|
{scanning ? t("scanning") : t("scanForPlugins")}
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t("marketplaceUrlLabel")}</label>
|
</Button>
|
||||||
<input
|
</div>
|
||||||
type="text"
|
{plugins.length === 0 ? (
|
||||||
className="w-full rounded border-gray-300 p-2"
|
<EmptyState
|
||||||
placeholder={t("marketplaceUrlPlaceholder")}
|
title={t("noPlugins")}
|
||||||
value={marketplaceUrl}
|
description={t("noPluginsDescription")}
|
||||||
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">
|
||||||
{marketplacePlugins.length === 0 ? (
|
{plugins.map((plugin) => (
|
||||||
<div className="text-gray-500 py-4">{t("marketplaceEmpty")}</div>
|
<Card key={plugin.name} className="p-4">
|
||||||
) : (
|
<div className="flex items-center justify-between">
|
||||||
marketplacePlugins.map((plugin) => (
|
<div>
|
||||||
<Card key={plugin.name} className="p-4">
|
<h3 className="font-semibold">{plugin.name}</h3>
|
||||||
<div className="flex items-center justify-between">
|
<p className="text-sm text-gray-500">
|
||||||
<div>
|
v{plugin.version}
|
||||||
<h3 className="font-semibold flex items-center gap-2">
|
{plugin.author ? ` by ${plugin.author}` : ""}
|
||||||
{plugin.name}
|
{plugin.description ? ` — ${plugin.description}` : ""}
|
||||||
{plugin.verified && <Badge variant="success">{t("verified")}</Badge>}
|
</p>
|
||||||
</h3>
|
<div className="mt-1 flex gap-1">
|
||||||
<p className="text-sm text-gray-500">
|
{plugin.hooks.map((hook) => (
|
||||||
v{plugin.version} by {plugin.author} — {plugin.description}
|
<span
|
||||||
</p>
|
key={hook}
|
||||||
<div className="mt-1 flex gap-1">
|
className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700"
|
||||||
{plugin.tags?.map((tag: string) => (
|
>
|
||||||
<span key={tag} className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
|
{hook}
|
||||||
{tag}
|
</span>
|
||||||
</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>
|
||||||
</Card>
|
<div className="flex gap-2">
|
||||||
))
|
<Button
|
||||||
)}
|
variant={plugin.enabled ? "secondary" : "primary"}
|
||||||
|
onClick={() => handleToggle(plugin.name, !plugin.enabled)}
|
||||||
|
>
|
||||||
|
{plugin.enabled ? t("deactivate") : t("activate")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => handleUninstall(plugin.name)}
|
||||||
|
>
|
||||||
|
{t("uninstall")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,11 +15,7 @@ import {
|
|||||||
getCodexEffectiveServiceTier,
|
getCodexEffectiveServiceTier,
|
||||||
type CodexGlobalServiceMode,
|
type CodexGlobalServiceMode,
|
||||||
} from "@/lib/providers/codexFastTier";
|
} from "@/lib/providers/codexFastTier";
|
||||||
import {
|
import { normalizeCodexLimitPolicy, providerText, ERROR_TYPE_LABELS } from "../providerPageHelpers";
|
||||||
normalizeCodexLimitPolicy,
|
|
||||||
providerText,
|
|
||||||
ERROR_TYPE_LABELS,
|
|
||||||
} from "../providerPageHelpers";
|
|
||||||
import { getCodexPlanLabel } from "../codexPlanLabel";
|
import { getCodexPlanLabel } from "../codexPlanLabel";
|
||||||
import ProviderQuotaVisibilityToggle from "./ProviderQuotaVisibilityToggle";
|
import ProviderQuotaVisibilityToggle from "./ProviderQuotaVisibilityToggle";
|
||||||
|
|
||||||
@@ -245,7 +241,7 @@ function getStatusPresentation(
|
|||||||
if (errorType === "account_deactivated") {
|
if (errorType === "account_deactivated") {
|
||||||
return {
|
return {
|
||||||
statusVariant: "error",
|
statusVariant: "error",
|
||||||
statusLabel: t("statusDeactivated", "Deactivated"),
|
statusLabel: providerText(t, "statusDeactivated", "Deactivated"),
|
||||||
errorType,
|
errorType,
|
||||||
errorBadge,
|
errorBadge,
|
||||||
errorTextClass: "text-red-600 font-bold",
|
errorTextClass: "text-red-600 font-bold",
|
||||||
@@ -300,7 +296,7 @@ function getStatusPresentation(
|
|||||||
if (errorType === "banned") {
|
if (errorType === "banned") {
|
||||||
return {
|
return {
|
||||||
statusVariant: "error",
|
statusVariant: "error",
|
||||||
statusLabel: t("statusBanned", "Banned (403)"),
|
statusLabel: providerText(t, "statusBanned", "Banned (403)"),
|
||||||
errorType,
|
errorType,
|
||||||
errorBadge,
|
errorBadge,
|
||||||
errorTextClass: "text-red-600 font-bold",
|
errorTextClass: "text-red-600 font-bold",
|
||||||
@@ -310,7 +306,7 @@ function getStatusPresentation(
|
|||||||
if (errorType === "credits_exhausted") {
|
if (errorType === "credits_exhausted") {
|
||||||
return {
|
return {
|
||||||
statusVariant: "warning",
|
statusVariant: "warning",
|
||||||
statusLabel: t("statusCreditsExhausted", "Out of Credits"),
|
statusLabel: providerText(t, "statusCreditsExhausted", "Out of Credits"),
|
||||||
errorType,
|
errorType,
|
||||||
errorBadge,
|
errorBadge,
|
||||||
errorTextClass: "text-amber-500",
|
errorTextClass: "text-amber-500",
|
||||||
@@ -391,22 +387,10 @@ export default function ConnectionRow({
|
|||||||
t("oauthAccount")
|
t("oauthAccount")
|
||||||
)
|
)
|
||||||
: connection.name;
|
: connection.name;
|
||||||
const applyCodexAuthLabel =
|
const applyCodexAuthLabel = providerText(t, "applyCodexAuthLocal", "Apply auth");
|
||||||
typeof t.has === "function" && t.has("applyCodexAuthLocal")
|
const exportCodexAuthLabel = providerText(t, "exportCodexAuthFile", "Export auth");
|
||||||
? t("applyCodexAuthLocal")
|
const applyClaudeAuthLabel = providerText(t, "applyClaudeAuthLocal", "Apply auth");
|
||||||
: "Apply auth";
|
const exportClaudeAuthLabel = providerText(t, "exportClaudeAuthFile", "Export 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,8 +22,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
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[];
|
||||||
@@ -37,6 +39,7 @@ 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(() => {
|
||||||
@@ -58,12 +61,17 @@ 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">
|
||||||
Currently cooling ({cooling.length})
|
{providerText(t, "coolingConnectionsTitle", "Currently cooling ({count})", {
|
||||||
|
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">
|
||||||
These connections returned a 429 (rate-limit) on their last request. OmniRoute will skip
|
{providerText(
|
||||||
them until the timer expires — no manual disable required.
|
t,
|
||||||
|
"coolingConnectionsDescription",
|
||||||
|
"These connections returned a 429 (rate-limit) on their last request. OmniRoute will skip them until the timer expires — no manual disable required."
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{cooling.map((c) => {
|
{cooling.map((c) => {
|
||||||
@@ -72,7 +80,9 @@ export default function CoolingConnectionsPanel(props: CoolingConnectionsPanelPr
|
|||||||
c.displayName ||
|
c.displayName ||
|
||||||
c.name ||
|
c.name ||
|
||||||
c.email ||
|
c.email ||
|
||||||
(c.id ? `connection ${c.id.slice(0, 8)}` : "connection");
|
(c.id
|
||||||
|
? `${providerText(t, "connectionFallback", "connection")} ${c.id.slice(0, 8)}`
|
||||||
|
: providerText(t, "connectionFallback", "connection"));
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={c.id ?? label}
|
key={c.id ?? label}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
effectivePreserveForProtocol,
|
effectivePreserveForProtocol,
|
||||||
effectiveUpstreamHeadersForProtocol,
|
effectiveUpstreamHeadersForProtocol,
|
||||||
formatProviderModelsErrorResponse,
|
formatProviderModelsErrorResponse,
|
||||||
|
providerText,
|
||||||
targetFormatBadgeI18nKey,
|
targetFormatBadgeI18nKey,
|
||||||
type CompatModelRow,
|
type CompatModelRow,
|
||||||
type CompatByProtocolMap,
|
type CompatByProtocolMap,
|
||||||
@@ -285,17 +286,32 @@ export default function CustomModelsSection({
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const detail = await formatProviderModelsErrorResponse(res);
|
const detail = await formatProviderModelsErrorResponse(res);
|
||||||
throw new Error(detail || "Failed to save model endpoint settings");
|
throw new Error(
|
||||||
|
detail ||
|
||||||
|
providerText(
|
||||||
|
t,
|
||||||
|
"failedSaveModelEndpointSettings",
|
||||||
|
"Failed to save model endpoint settings"
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await fetchCustomModels();
|
await fetchCustomModels();
|
||||||
onModelsChanged?.();
|
onModelsChanged?.();
|
||||||
notify.success("Saved model endpoint settings");
|
notify.success(
|
||||||
|
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.message : "Failed to save model endpoint settings"
|
e instanceof Error && e.message
|
||||||
|
? e.message
|
||||||
|
: providerText(
|
||||||
|
t,
|
||||||
|
"failedSaveModelEndpointSettings",
|
||||||
|
"Failed to save model endpoint settings"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setSavingModelId(null);
|
setSavingModelId(null);
|
||||||
@@ -305,7 +321,9 @@ 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("Select at least one supported endpoint");
|
notify.error(
|
||||||
|
providerText(t, "selectSupportedEndpoint", "Select at least one supported endpoint")
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,7 +447,7 @@ export default function CustomModelsSection({
|
|||||||
: ep === "embeddings"
|
: ep === "embeddings"
|
||||||
? `📐 ${t("supportedEndpointEmbeddings")}`
|
? `📐 ${t("supportedEndpointEmbeddings")}`
|
||||||
: ep === "rerank"
|
: ep === "rerank"
|
||||||
? "Rerank"
|
? providerText(t, "rerankEndpoint", "Rerank")
|
||||||
: ep === "images"
|
: ep === "images"
|
||||||
? `🖼️ ${t("supportedEndpointImages")}`
|
? `🖼️ ${t("supportedEndpointImages")}`
|
||||||
: `🔊 ${t("supportedEndpointAudio")}`}
|
: `🔊 ${t("supportedEndpointAudio")}`}
|
||||||
@@ -661,7 +679,7 @@ export default function CustomModelsSection({
|
|||||||
: ep === "embeddings"
|
: ep === "embeddings"
|
||||||
? `📐 ${t("supportedEndpointEmbeddings")}`
|
? `📐 ${t("supportedEndpointEmbeddings")}`
|
||||||
: ep === "rerank"
|
: ep === "rerank"
|
||||||
? "Rerank"
|
? providerText(t, "rerankEndpoint", "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 type { ProviderMessageTranslator } from "../providerPageHelpers";
|
import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers";
|
||||||
|
|
||||||
type KimiCodeAuthMethodModalProps = {
|
type KimiCodeAuthMethodModalProps = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -50,7 +50,9 @@ 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">Kimi Code API Key</h3>
|
<h3 className="mb-1 font-semibold">
|
||||||
|
{providerText(t, "kimiCodeApiKeyLabel", "Kimi Code API Key")}
|
||||||
|
</h3>
|
||||||
<p className="text-sm text-text-muted">{t("apiKeySecure")}</p>
|
<p className="text-sm text-text-muted">{t("apiKeySecure")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
UPSTREAM_HEADERS_UI_MAX,
|
UPSTREAM_HEADERS_UI_MAX,
|
||||||
headerRowsToRecord,
|
headerRowsToRecord,
|
||||||
compatProtocolLabelKey,
|
compatProtocolLabelKey,
|
||||||
|
providerText,
|
||||||
type HeaderDraftRow,
|
type HeaderDraftRow,
|
||||||
} from "../providerPageHelpers";
|
} from "../providerPageHelpers";
|
||||||
|
|
||||||
@@ -353,7 +354,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">
|
||||||
{t("compatParamFiltersLabel") ?? "Param Filters"}
|
{providerText(t, "compatParamFiltersLabel", "Param Filters")}
|
||||||
</label>
|
</label>
|
||||||
<div>
|
<div>
|
||||||
<input
|
<input
|
||||||
@@ -369,7 +370,11 @@ export default function ModelCompatPopover({
|
|||||||
className="mb-1 w-full rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-[11px] font-mono text-text-main placeholder:text-text-muted focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 dark:border-zinc-600 dark:bg-zinc-900"
|
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">
|
||||||
{t("compatBlockedParamsHint") ?? "Blocked params (stripped from requests)"}
|
{providerText(
|
||||||
|
t,
|
||||||
|
"compatBlockedParamsHint",
|
||||||
|
"Blocked params (stripped from requests)"
|
||||||
|
)}
|
||||||
{paramSaving && ` ● ${t("compatSaving")}`}
|
{paramSaving && ` ● ${t("compatSaving")}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -387,7 +392,11 @@ export default function ModelCompatPopover({
|
|||||||
className="mb-1 w-full rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-[11px] font-mono text-text-main placeholder:text-text-muted focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 dark:border-zinc-600 dark:bg-zinc-900"
|
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">
|
||||||
{t("compatAllowedParamsHint") ?? "Allowed params (re-added after deny)"}
|
{providerText(
|
||||||
|
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"
|
||||||
? "Error"
|
? providerText(t, "errorShort", "Error")
|
||||||
: t("testModel")
|
: t("testModel")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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",
|
||||||
@@ -123,7 +124,10 @@ 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(data?.error || "Failed to create Dahl token");
|
throw new Error(
|
||||||
|
data?.error ||
|
||||||
|
providerText(t, "createDahlTokenFailed", "Failed to create Dahl token")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return data.token as string;
|
return data.token as string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ export default function PassthroughModelRow({
|
|||||||
: testStatus === "ok"
|
: testStatus === "ok"
|
||||||
? "OK"
|
? "OK"
|
||||||
: testStatus === "error"
|
: testStatus === "error"
|
||||||
? "Error"
|
? providerText(t, "errorShort", "Error")
|
||||||
: t("testModel")
|
: t("testModel")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// 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";
|
||||||
@@ -18,6 +19,7 @@ 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",
|
||||||
@@ -56,12 +58,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[] })
|
(Record<string, unknown> & { serviceKinds?: string[] }) | undefined;
|
||||||
| undefined;
|
|
||||||
|
|
||||||
const rawKinds: string[] = providerEntry?.serviceKinds ?? [];
|
const rawKinds: string[] = providerEntry?.serviceKinds ?? [];
|
||||||
|
|
||||||
@@ -93,7 +95,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">Playground</h2>
|
<h2 className="text-lg font-semibold">{providerText(t, "playgroundTitle", "Playground")}</h2>
|
||||||
<ServiceKindTabs
|
<ServiceKindTabs
|
||||||
kinds={playgroundableKinds}
|
kinds={playgroundableKinds}
|
||||||
activeKind={activeKind}
|
activeKind={activeKind}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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;
|
||||||
@@ -45,9 +46,11 @@ 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<
|
const [validationResult, setValidationResult] = useState<null | {
|
||||||
null | { valid: boolean; error?: string | null; method?: string | null }
|
valid: boolean;
|
||||||
>(null);
|
error?: string | null;
|
||||||
|
method?: string | null;
|
||||||
|
}>(null);
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -130,7 +133,10 @@ export default function EditCompatibleNodeModal({
|
|||||||
method: data.method ?? null,
|
method: data.method ?? null,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setValidationResult({ valid: false, error: "Network error" });
|
setValidationResult({
|
||||||
|
valid: false,
|
||||||
|
error: providerText(t, "networkError", "Network error"),
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setValidating(false);
|
setValidating(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { type CommandCodeAuthFlowState } from "../providerPageHelpers";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { providerText, type CommandCodeAuthFlowState } from "../providerPageHelpers";
|
||||||
|
|
||||||
export type UseCommandCodeAuthParams = {
|
export type UseCommandCodeAuthParams = {
|
||||||
providerId: string;
|
providerId: string;
|
||||||
@@ -15,6 +16,7 @@ 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: "",
|
||||||
@@ -62,7 +64,7 @@ export function useCommandCodeAuth({
|
|||||||
setCommandCodeAuthState((current) => ({
|
setCommandCodeAuthState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
phase: "applying",
|
phase: "applying",
|
||||||
message: "Applying browser-approved key…",
|
message: providerText(t, "commandCodeApplyingKey", "Applying browser-approved key…"),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -74,7 +76,9 @@ export function useCommandCodeAuth({
|
|||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errorMessage = data.error || "Failed to apply Command Code auth";
|
const errorMessage =
|
||||||
|
data.error ||
|
||||||
|
providerText(t, "commandCodeApplyFailed", "Failed to apply Command Code auth");
|
||||||
setCommandCodeAuthState((current) => ({
|
setCommandCodeAuthState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
phase: "error",
|
phase: "error",
|
||||||
@@ -87,26 +91,30 @@ export function useCommandCodeAuth({
|
|||||||
setCommandCodeAuthState((current) => ({
|
setCommandCodeAuthState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
phase: "applied",
|
phase: "applied",
|
||||||
message: "Command Code connected",
|
message: providerText(t, "commandCodeConnected", "Command Code connected"),
|
||||||
}));
|
}));
|
||||||
commandCodeAuthWindowRef.current?.close?.();
|
commandCodeAuthWindowRef.current?.close?.();
|
||||||
commandCodeAuthWindowRef.current = null;
|
commandCodeAuthWindowRef.current = null;
|
||||||
await fetchConnections();
|
await fetchConnections();
|
||||||
handleCloseAddApiKeyModal();
|
handleCloseAddApiKeyModal();
|
||||||
notify.success("Command Code connection added");
|
notify.success(
|
||||||
|
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: "Failed to apply Command Code auth",
|
message: providerText(t, "commandCodeApplyFailed", "Failed to apply Command Code auth"),
|
||||||
}));
|
}));
|
||||||
notify.error("Failed to apply Command Code auth");
|
notify.error(
|
||||||
|
providerText(t, "commandCodeApplyFailed", "Failed to apply Command Code auth")
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[fetchConnections, handleCloseAddApiKeyModal, notify]
|
[fetchConnections, handleCloseAddApiKeyModal, notify, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleStartCommandCodeAuth = useCallback(async () => {
|
const handleStartCommandCodeAuth = useCallback(async () => {
|
||||||
@@ -124,7 +132,7 @@ export function useCommandCodeAuth({
|
|||||||
authUrl: "",
|
authUrl: "",
|
||||||
callbackUrl: "",
|
callbackUrl: "",
|
||||||
expiresAt: null,
|
expiresAt: null,
|
||||||
message: "Opening Command Code Studio…",
|
message: providerText(t, "commandCodeOpeningStudio", "Opening Command Code Studio…"),
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -135,7 +143,9 @@ 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 = data.error || "Failed to start Command Code auth";
|
const errorMessage =
|
||||||
|
data.error ||
|
||||||
|
providerText(t, "commandCodeStartFailed", "Failed to start Command Code auth");
|
||||||
setCommandCodeAuthState((current) => ({
|
setCommandCodeAuthState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
phase: "error",
|
phase: "error",
|
||||||
@@ -152,7 +162,11 @@ export function useCommandCodeAuth({
|
|||||||
authUrl: data.authUrl,
|
authUrl: data.authUrl,
|
||||||
callbackUrl: data.callbackUrl || "",
|
callbackUrl: data.callbackUrl || "",
|
||||||
expiresAt: data.expiresAt || null,
|
expiresAt: data.expiresAt || null,
|
||||||
message: "Open the auth URL, approve access, then paste the returned key/JSON/URL below…",
|
message: providerText(
|
||||||
|
t,
|
||||||
|
"commandCodeApprovalInstructions",
|
||||||
|
"Open the auth URL, approve access, then paste the returned key/JSON/URL below…"
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (popup) {
|
if (popup) {
|
||||||
@@ -169,9 +183,19 @@ export function useCommandCodeAuth({
|
|||||||
setCommandCodeAuthState((current) => ({
|
setCommandCodeAuthState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
phase: "error",
|
phase: "error",
|
||||||
message: "Popup blocked. Please allow popups and try Command Code Connect again.",
|
message: providerText(
|
||||||
|
t,
|
||||||
|
"commandCodePopupBlocked",
|
||||||
|
"Popup blocked. Please allow popups and try Command Code Connect again."
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
notify.error("Popup blocked. Please allow popups and try Command Code Connect again.");
|
notify.error(
|
||||||
|
providerText(
|
||||||
|
t,
|
||||||
|
"commandCodePopupBlocked",
|
||||||
|
"Popup blocked. Please allow popups and try Command Code Connect again."
|
||||||
|
)
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
commandCodeAuthWindowRef.current = fallbackPopup;
|
commandCodeAuthWindowRef.current = fallbackPopup;
|
||||||
@@ -183,11 +207,11 @@ export function useCommandCodeAuth({
|
|||||||
setCommandCodeAuthState((current) => ({
|
setCommandCodeAuthState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
phase: "expired",
|
phase: "expired",
|
||||||
message: "Command Code link expired",
|
message: providerText(t, "commandCodeLinkExpired", "Command Code link expired"),
|
||||||
}));
|
}));
|
||||||
commandCodeAuthWindowRef.current?.close?.();
|
commandCodeAuthWindowRef.current?.close?.();
|
||||||
commandCodeAuthWindowRef.current = null;
|
commandCodeAuthWindowRef.current = null;
|
||||||
notify.error("Command Code auth expired");
|
notify.error(providerText(t, "commandCodeAuthExpired", "Command Code auth expired"));
|
||||||
clearCommandCodeAuthTimer();
|
clearCommandCodeAuthTimer();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -206,11 +230,11 @@ export function useCommandCodeAuth({
|
|||||||
setCommandCodeAuthState((current) => ({
|
setCommandCodeAuthState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
phase: "expired",
|
phase: "expired",
|
||||||
message: "Command Code link expired",
|
message: providerText(t, "commandCodeLinkExpired", "Command Code link expired"),
|
||||||
}));
|
}));
|
||||||
commandCodeAuthWindowRef.current?.close?.();
|
commandCodeAuthWindowRef.current?.close?.();
|
||||||
commandCodeAuthWindowRef.current = null;
|
commandCodeAuthWindowRef.current = null;
|
||||||
notify.error("Command Code auth expired");
|
notify.error(providerText(t, "commandCodeAuthExpired", "Command Code auth expired"));
|
||||||
clearCommandCodeAuthTimer();
|
clearCommandCodeAuthTimer();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -219,13 +243,15 @@ export function useCommandCodeAuth({
|
|||||||
setCommandCodeAuthState((current) => ({
|
setCommandCodeAuthState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
phase: "applied",
|
phase: "applied",
|
||||||
message: "Command Code connected",
|
message: providerText(t, "commandCodeConnected", "Command Code connected"),
|
||||||
}));
|
}));
|
||||||
commandCodeAuthWindowRef.current?.close?.();
|
commandCodeAuthWindowRef.current?.close?.();
|
||||||
commandCodeAuthWindowRef.current = null;
|
commandCodeAuthWindowRef.current = null;
|
||||||
await fetchConnections();
|
await fetchConnections();
|
||||||
handleCloseAddApiKeyModal();
|
handleCloseAddApiKeyModal();
|
||||||
notify.success("Command Code connection added");
|
notify.success(
|
||||||
|
providerText(t, "commandCodeConnectionAdded", "Command Code connection added")
|
||||||
|
);
|
||||||
clearCommandCodeAuthTimer();
|
clearCommandCodeAuthTimer();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -234,7 +260,11 @@ export function useCommandCodeAuth({
|
|||||||
setCommandCodeAuthState((current) => ({
|
setCommandCodeAuthState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
phase: "received",
|
phase: "received",
|
||||||
message: "Browser approved, applying…",
|
message: providerText(
|
||||||
|
t,
|
||||||
|
"commandCodeApplyingApproval",
|
||||||
|
"Browser approved, applying…"
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
clearCommandCodeAuthTimer();
|
clearCommandCodeAuthTimer();
|
||||||
await handleCommandCodeAuthApply(
|
await handleCommandCodeAuthApply(
|
||||||
@@ -258,9 +288,9 @@ export function useCommandCodeAuth({
|
|||||||
setCommandCodeAuthState((current) => ({
|
setCommandCodeAuthState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
phase: "error",
|
phase: "error",
|
||||||
message: "Failed to start Command Code auth",
|
message: providerText(t, "commandCodeStartFailed", "Failed to start Command Code auth"),
|
||||||
}));
|
}));
|
||||||
notify.error("Failed to start Command Code auth");
|
notify.error(providerText(t, "commandCodeStartFailed", "Failed to start Command Code auth"));
|
||||||
popup?.close?.();
|
popup?.close?.();
|
||||||
commandCodeAuthWindowRef.current = null;
|
commandCodeAuthWindowRef.current = null;
|
||||||
clearCommandCodeAuthTimer();
|
clearCommandCodeAuthTimer();
|
||||||
@@ -272,6 +302,7 @@ export function useCommandCodeAuth({
|
|||||||
fetchConnections,
|
fetchConnections,
|
||||||
handleCommandCodeAuthApply,
|
handleCommandCodeAuthApply,
|
||||||
notify,
|
notify,
|
||||||
|
t,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleOpenCommandCodeConnect = useCallback(() => {
|
const handleOpenCommandCodeConnect = useCallback(() => {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
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;
|
||||||
@@ -34,6 +36,7 @@ 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);
|
||||||
|
|
||||||
@@ -56,24 +59,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("Connection deleted");
|
notify.success(providerText(t, "connectionDeleted", "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 ||
|
||||||
"Failed to delete connection";
|
providerText(t, "failedDeleteConnection", "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("Failed to delete connection");
|
notify.error(providerText(t, "failedDeleteConnection", "Failed to delete connection"));
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
setConnection(null);
|
setConnection(null);
|
||||||
}
|
}
|
||||||
}, [connection, fetchConnections, notify]);
|
}, [connection, fetchConnections, notify, t]);
|
||||||
|
|
||||||
return { connection, deleting, request, confirm, cancel };
|
return { connection, deleting, request, confirm, cancel };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -316,11 +316,13 @@ 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(extractApiErrorMessage(data, "Model test failed"));
|
notify.error(
|
||||||
|
extractApiErrorMessage(data, providerText(t, "modelTestFailed", "Model test failed"))
|
||||||
|
);
|
||||||
setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" }));
|
setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" }));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notify.error("Network error testing model");
|
notify.error(providerText(t, "modelTestNetworkError", "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 } from "../providerPageHelpers";
|
import { normalizeCodexLimitPolicy, providerText } from "../providerPageHelpers";
|
||||||
import { useProviderQuotaVisibility } from "./useProviderQuotaVisibility";
|
import { useProviderQuotaVisibility } from "./useProviderQuotaVisibility";
|
||||||
import { useReorderByAvailability } from "./useReorderByAvailability";
|
import { useReorderByAvailability } from "./useReorderByAvailability";
|
||||||
import {
|
import {
|
||||||
@@ -378,7 +378,14 @@ export function useProviderConnections(
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
notify.error(data.error || "Failed to update Claude extra-usage policy");
|
notify.error(
|
||||||
|
data.error ||
|
||||||
|
providerText(
|
||||||
|
t,
|
||||||
|
"failedUpdateClaudeExtraUsagePolicy",
|
||||||
|
"Failed to update Claude extra-usage policy"
|
||||||
|
)
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,12 +415,26 @@ export function useProviderConnections(
|
|||||||
);
|
);
|
||||||
notify.success(
|
notify.success(
|
||||||
enabled
|
enabled
|
||||||
? "Claude extra-usage blocking enabled (extra usage will be blocked)"
|
? providerText(
|
||||||
: "Claude extra-usage blocking disabled (extra usage is allowed)"
|
t,
|
||||||
|
"claudeExtraUsageBlockingEnabled",
|
||||||
|
"Claude extra-usage blocking enabled (extra usage will be blocked)"
|
||||||
|
)
|
||||||
|
: providerText(
|
||||||
|
t,
|
||||||
|
"claudeExtraUsageBlockingDisabled",
|
||||||
|
"Claude extra-usage blocking disabled (extra usage is allowed)"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error toggling Claude extra-usage policy:", error);
|
console.error("Error toggling Claude extra-usage policy:", error);
|
||||||
notify.error("Failed to update Claude extra-usage policy");
|
notify.error(
|
||||||
|
providerText(
|
||||||
|
t,
|
||||||
|
"failedUpdateClaudeExtraUsagePolicy",
|
||||||
|
"Failed to update Claude extra-usage policy"
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -447,7 +468,10 @@ export function useProviderConnections(
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
notify.error(data.error || "Failed to update Codex limit policy");
|
notify.error(
|
||||||
|
data.error ||
|
||||||
|
providerText(t, "failedUpdateCodexLimitPolicy", "Failed to update Codex limit policy")
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,10 +488,12 @@ export function useProviderConnections(
|
|||||||
: connection
|
: connection
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
notify.success("Codex limit policy updated");
|
notify.success(providerText(t, "codexLimitPolicyUpdated", "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("Failed to update Codex limit policy");
|
notify.error(
|
||||||
|
providerText(t, "failedUpdateCodexLimitPolicy", "Failed to update Codex limit policy")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -481,18 +507,27 @@ export function useProviderConnections(
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
notify.error(data.error || "Failed to update CLIProxyAPI routing");
|
notify.error(
|
||||||
|
data.error ||
|
||||||
|
providerText(t, "failedUpdateCliproxyRouting", "Failed to update CLIProxyAPI routing")
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setCpaProviderEnabled(enabled);
|
setCpaProviderEnabled(enabled);
|
||||||
notify.success(
|
notify.success(
|
||||||
enabled
|
enabled
|
||||||
? "Requests now route through CLIProxyAPI (deeper emulation)"
|
? providerText(
|
||||||
: "Requests now use native OmniRoute (direct)"
|
t,
|
||||||
|
"cliproxyRoutingEnabled",
|
||||||
|
"Requests now route through CLIProxyAPI (deeper emulation)"
|
||||||
|
)
|
||||||
|
: providerText(t, "cliproxyRoutingDisabled", "Requests now use native OmniRoute (direct)")
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
notify.error("Failed to update CLIProxyAPI routing");
|
notify.error(
|
||||||
|
providerText(t, "failedUpdateCliproxyRouting", "Failed to update CLIProxyAPI routing")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -664,10 +699,10 @@ export function useProviderConnections(
|
|||||||
if (onAfter) await onAfter();
|
if (onAfter) await onAfter();
|
||||||
} else {
|
} else {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
notify.error(data.error || "Batch delete failed");
|
notify.error(data.error || providerText(t, "batchDeleteFailed", "Batch delete failed"));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
notify.error("Network error during batch delete");
|
notify.error(providerText(t, "batchDeleteNetworkError", "Network error during batch delete"));
|
||||||
} finally {
|
} finally {
|
||||||
setBatchDeleting(false);
|
setBatchDeleting(false);
|
||||||
}
|
}
|
||||||
@@ -689,7 +724,11 @@ export function useProviderConnections(
|
|||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
throw new Error(data.error?.message || data.error || "Batch update failed");
|
throw new Error(
|
||||||
|
data.error?.message ||
|
||||||
|
data.error ||
|
||||||
|
providerText(t, "batchUpdateFailed", "Batch update failed")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
updated += data.updated ?? 0;
|
updated += data.updated ?? 0;
|
||||||
@@ -710,7 +749,10 @@ export function useProviderConnections(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
notify.error(error?.message || "Network error during batch update");
|
notify.error(
|
||||||
|
error?.message ||
|
||||||
|
providerText(t, "batchUpdateNetworkError", "Network error during batch update")
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setBatchUpdating(null);
|
setBatchUpdating(null);
|
||||||
}
|
}
|
||||||
@@ -805,7 +847,13 @@ 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("No saved proxies found. Add proxies in Settings → Proxy first.");
|
notify.error(
|
||||||
|
providerText(
|
||||||
|
t,
|
||||||
|
"noSavedProxies",
|
||||||
|
"No saved proxies found. Add proxies in Settings → Proxy first."
|
||||||
|
)
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -856,11 +904,16 @@ export function useProviderConnections(
|
|||||||
await fetchConnections();
|
await fetchConnections();
|
||||||
const tagLabel = tagFilter ? `"${tagFilter}" ` : "";
|
const tagLabel = tagFilter ? `"${tagFilter}" ` : "";
|
||||||
notify.success(
|
notify.success(
|
||||||
`Distributed ${assigned} proxy assignment(s) across ${tagLabel}${sorted.length} connection(s).`
|
providerText(
|
||||||
|
t,
|
||||||
|
"proxiesDistributed",
|
||||||
|
"Distributed {assigned} proxy assignment(s) across {tagLabel}{total} connection(s).",
|
||||||
|
{ assigned, tagLabel, total: sorted.length }
|
||||||
|
)
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error distributing proxies:", err);
|
console.error("Error distributing proxies:", err);
|
||||||
notify.error("Failed to distribute proxies.");
|
notify.error(providerText(t, "failedDistributeProxies", "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 type { CompatModelRow } from "../providerPageHelpers";
|
import { providerText, type CompatModelRow } from "../providerPageHelpers";
|
||||||
|
|
||||||
// ──── types ─────────────────────────────────────────────────────────────────
|
// ──── types ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -79,11 +79,13 @@ 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(data?.error?.message || "Failed to set alias");
|
notify.error(
|
||||||
|
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("Network error setting alias");
|
notify.error(providerText(t, "networkErrorSettingAlias", "Network error setting alias"));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[fetchAliases, t, notify]
|
[fetchAliases, t, notify]
|
||||||
@@ -100,11 +102,13 @@ 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(data?.error?.message || "Failed to delete alias");
|
notify.error(
|
||||||
|
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("Network error deleting alias");
|
notify.error(providerText(t, "networkErrorDeletingAlias", "Network error deleting alias"));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[fetchAliases, t, notify]
|
[fetchAliases, t, notify]
|
||||||
@@ -113,10 +117,9 @@ export function useProviderModels(
|
|||||||
const fetchProviderModelMeta = useCallback(async () => {
|
const fetchProviderModelMeta = useCallback(async () => {
|
||||||
if (isSearchProvider) return;
|
if (isSearchProvider) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(`/api/provider-models?provider=${encodeURIComponent(providerId)}`, {
|
||||||
`/api/provider-models?provider=${encodeURIComponent(providerId)}`,
|
cache: "no-store",
|
||||||
{ cache: "no-store" }
|
});
|
||||||
);
|
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setModelMeta({
|
setModelMeta({
|
||||||
|
|||||||
@@ -115,9 +115,7 @@ export function useProviderSettings(providerId: string): UseProviderSettingsRetu
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isCurrentRequest()) return;
|
if (!isCurrentRequest()) return;
|
||||||
setCodexSettingsLoaded(false);
|
setCodexSettingsLoaded(false);
|
||||||
setCodexSettingsLoadError(
|
setCodexSettingsLoadError(error instanceof Error ? error.message : "Failed to load settings");
|
||||||
error instanceof Error ? error.message : "Failed to load settings"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}, [providerId]);
|
}, [providerId]);
|
||||||
|
|
||||||
@@ -184,15 +182,20 @@ 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(data.error || "Failed to update Codex service mode");
|
notify.error(
|
||||||
|
data.error ||
|
||||||
|
providerText(t, "failedUpdateCodexServiceMode", "Failed to update Codex service mode")
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
notify.success("Codex service mode updated");
|
notify.success(providerText(t, "codexServiceModeUpdated", "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("Failed to update Codex service mode");
|
notify.error(
|
||||||
|
providerText(t, "failedUpdateCodexServiceMode", "Failed to update Codex service mode")
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setSavingCodexGlobalServiceMode(false);
|
setSavingCodexGlobalServiceMode(false);
|
||||||
}
|
}
|
||||||
@@ -215,7 +218,14 @@ 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(data.error || "Failed to update Claude Code routing preference");
|
notify.error(
|
||||||
|
data.error ||
|
||||||
|
providerText(
|
||||||
|
t,
|
||||||
|
"failedUpdateClaudeRoutingPreference",
|
||||||
|
"Failed to update Claude Code routing preference"
|
||||||
|
)
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,14 +237,26 @@ export function useProviderSettings(providerId: string): UseProviderSettingsRetu
|
|||||||
}
|
}
|
||||||
notify.success(
|
notify.success(
|
||||||
enabled
|
enabled
|
||||||
? "Unprefixed Claude models now prefer Claude Code"
|
? providerText(
|
||||||
: "Unprefixed Claude models no longer prefer Claude Code"
|
t,
|
||||||
|
"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(t, "failedUpdateClaudeRoutingPreference", "Failed to update Claude Code routing preference")
|
providerText(
|
||||||
|
t,
|
||||||
|
"failedUpdateClaudeRoutingPreference",
|
||||||
|
"Failed to update Claude Code routing preference"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setSavingClaudeRoutingPreference(false);
|
setSavingClaudeRoutingPreference(false);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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>;
|
||||||
@@ -100,9 +101,11 @@ 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<
|
const [validationResult, setValidationResult] = useState<null | {
|
||||||
null | { valid: boolean; error?: string | null; method?: string | null }
|
valid: boolean;
|
||||||
>(null);
|
error?: string | null;
|
||||||
|
method?: string | null;
|
||||||
|
}>(null);
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
|
||||||
const apiTypeOptions = useMemo(
|
const apiTypeOptions = useMemo(
|
||||||
@@ -242,7 +245,10 @@ export default function AddCompatibleProviderModal({
|
|||||||
method: data.method ?? null,
|
method: data.method ?? null,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setValidationResult({ valid: false, error: "Network error" });
|
setValidationResult({
|
||||||
|
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 { isKimiPartnerProviderId } from "../featuredProviders";
|
import { isCheaperInferenceProviderId, 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: Record<string, string> = {
|
const KIND_LABEL_KEYS: Record<string, { key: string; fallback: string }> = {
|
||||||
llm: "Chat",
|
llm: { key: "serviceKindChat", fallback: "Chat" },
|
||||||
embedding: "Embed",
|
embedding: { key: "serviceKindEmbedding", fallback: "Embed" },
|
||||||
image: "Image",
|
image: { key: "serviceKindImage", fallback: "Image" },
|
||||||
imageToText: "I→T",
|
imageToText: { key: "serviceKindImageToText", fallback: "I→T" },
|
||||||
tts: "TTS",
|
tts: { key: "serviceKindTts", fallback: "TTS" },
|
||||||
stt: "STT",
|
stt: { key: "serviceKindStt", fallback: "STT" },
|
||||||
webSearch: "Search",
|
webSearch: { key: "serviceKindWebSearch", fallback: "Search" },
|
||||||
webFetch: "Fetch",
|
webFetch: { key: "serviceKindWebFetch", fallback: "Fetch" },
|
||||||
video: "Video",
|
video: { key: "serviceKindVideo", fallback: "Video" },
|
||||||
music: "Music",
|
music: { key: "serviceKindMusic", fallback: "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,6 +166,10 @@ 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);
|
||||||
@@ -223,9 +227,11 @@ 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;
|
||||||
// Kimi (Moonshot AI) official-partnership highlight (2026-07): UI-only accent,
|
// Open Source Friend highlights (Kimi 2026-07, Cheaper Inference 2026-07): UI-only
|
||||||
// see featuredProviders.ts — never affects routing/fallback order.
|
// accents, see featuredProviders.ts — never affect 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")
|
||||||
@@ -267,6 +273,24 @@ 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"),
|
||||||
@@ -318,7 +342,12 @@ const ProviderCard = forwardRef<ProviderCardHandle, ProviderCardProps>(function
|
|||||||
// is a raw (non-token) brand hex, not a theme color. Keep the hex in
|
// 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)]"
|
||||||
: "hover:border-primary/40"
|
: isCheaperInferencePartner
|
||||||
|
? // Cheaper Inference Open Source Friend accent — same construction in
|
||||||
|
// its brand green (#31f889 = rgb(49,248,137)). Keep in sync with
|
||||||
|
// CHEAPERINFERENCE_BRAND_COLOR (featuredProviders.ts).
|
||||||
|
"border-2 border-[#31f889]/70 hover:border-[#31f889]/90 shadow-[inset_0_0_0_100px_rgba(49,248,137,0.035),0_4px_16px_-4px_rgba(49,248,137,0.45)]"
|
||||||
|
: "hover:border-primary/40"
|
||||||
} ${allDisabled ? "opacity-50" : ""} ${provider.deprecated ? "opacity-60" : ""}`}
|
} ${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">
|
||||||
@@ -392,23 +421,24 @@ const ProviderCard = forwardRef<ProviderCardHandle, ProviderCardProps>(function
|
|||||||
isCompatible ||
|
isCompatible ||
|
||||||
isCcCompatible ||
|
isCcCompatible ||
|
||||||
isAnthropicCompatible ||
|
isAnthropicCompatible ||
|
||||||
isKimiPartner) && (
|
isSponsorPartner) && (
|
||||||
<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"
|
||||||
>
|
>
|
||||||
{KIND_LABEL[k] ?? k}
|
{kindLabel(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")
|
||||||
: (KIND_LABEL[COMPATIBLE_API_TYPE_KIND[provider.apiType ?? ""] ?? ""] ??
|
: kindLabel(COMPATIBLE_API_TYPE_KIND[provider.apiType ?? ""] ?? "") ||
|
||||||
t("chat"))}
|
t("chat")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{isCcCompatible && (
|
{isCcCompatible && (
|
||||||
|
|||||||
@@ -110,7 +110,12 @@ export default function ProviderSummaryCard({
|
|||||||
const categories = [
|
const categories = [
|
||||||
{ key: null, color: null, label: t("providerSummaryAll"), stat: summaryStats.all },
|
{ key: null, color: null, label: t("providerSummaryAll"), stat: summaryStats.all },
|
||||||
{ key: "oauth", color: "bg-blue-500", label: t("oauthLabel"), stat: summaryStats.oauth },
|
{ key: "oauth", color: "bg-blue-500", label: t("oauthLabel"), stat: summaryStats.oauth },
|
||||||
{ key: "ide", color: "bg-cyan-500", label: "IDE", stat: summaryStats.ide },
|
{
|
||||||
|
key: "ide",
|
||||||
|
color: "bg-cyan-500",
|
||||||
|
label: providerText(t, "categoryIde", "IDE"),
|
||||||
|
stat: summaryStats.ide,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "free",
|
key: "free",
|
||||||
color: "bg-green-500",
|
color: "bg-green-500",
|
||||||
@@ -132,8 +137,18 @@ export default function ProviderSummaryCard({
|
|||||||
label: t("compatibleLabel"),
|
label: t("compatibleLabel"),
|
||||||
stat: summaryStats.compatible,
|
stat: summaryStats.compatible,
|
||||||
},
|
},
|
||||||
{ key: "webcookie", color: "bg-purple-500", label: "Web Cookie", stat: summaryStats.webcookie },
|
{
|
||||||
{ key: "search", color: "bg-teal-500", label: "Search", stat: summaryStats.search },
|
key: "webcookie",
|
||||||
|
color: "bg-purple-500",
|
||||||
|
label: providerText(t, "categoryWebCookie", "Web Cookie"),
|
||||||
|
stat: summaryStats.webcookie,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "search",
|
||||||
|
color: "bg-teal-500",
|
||||||
|
label: providerText(t, "categorySearch", "Search"),
|
||||||
|
stat: summaryStats.search,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "webfetch",
|
key: "webfetch",
|
||||||
color: "bg-orange-500",
|
color: "bg-orange-500",
|
||||||
@@ -141,12 +156,22 @@ export default function ProviderSummaryCard({
|
|||||||
stat: summaryStats.webfetch,
|
stat: summaryStats.webfetch,
|
||||||
title: t("webFetchTooltip"),
|
title: t("webFetchTooltip"),
|
||||||
},
|
},
|
||||||
{ key: "audio", color: "bg-rose-500", label: "Audio", stat: summaryStats.audio },
|
{
|
||||||
{ key: "local", color: "bg-emerald-500", label: "Local", stat: summaryStats.local },
|
key: "audio",
|
||||||
|
color: "bg-rose-500",
|
||||||
|
label: providerText(t, "categoryAudio", "Audio"),
|
||||||
|
stat: summaryStats.audio,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "local",
|
||||||
|
color: "bg-emerald-500",
|
||||||
|
label: providerText(t, "categoryLocal", "Local"),
|
||||||
|
stat: summaryStats.local,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "cloudagent",
|
key: "cloudagent",
|
||||||
color: "bg-violet-500",
|
color: "bg-violet-500",
|
||||||
label: "Cloud Agent",
|
label: providerText(t, "categoryCloudAgent", "Cloud Agent"),
|
||||||
stat: summaryStats.cloudagent,
|
stat: summaryStats.cloudagent,
|
||||||
},
|
},
|
||||||
].filter((category) => category.key !== "no-auth" || category.stat.total > 0);
|
].filter((category) => category.key !== "no-auth" || category.stat.total > 0);
|
||||||
@@ -178,8 +203,8 @@ export default function ProviderSummaryCard({
|
|||||||
<Input
|
<Input
|
||||||
value={modelSearchQuery}
|
value={modelSearchQuery}
|
||||||
onChange={(e) => setModelSearchQuery(e.target.value)}
|
onChange={(e) => setModelSearchQuery(e.target.value)}
|
||||||
placeholder={t("searchByModel") || "Search by model…"}
|
placeholder={providerText(t, "searchByModel", "Search by model…")}
|
||||||
aria-label={t("searchByModel") || "Search by model"}
|
aria-label={providerText(t, "searchByModelAria", "Search by model")}
|
||||||
icon="psychology"
|
icon="psychology"
|
||||||
inputClassName={modelSearchQuery ? "pr-9" : ""}
|
inputClassName={modelSearchQuery ? "pr-9" : ""}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,27 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
type ProviderMessageTranslator = ((key: string, values?: Record<string, unknown>) => string) & {
|
||||||
|
has?: (key: string) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function providerText(
|
||||||
|
t: ProviderMessageTranslator,
|
||||||
|
key: string,
|
||||||
|
fallback: string,
|
||||||
|
values?: Record<string, unknown>
|
||||||
|
): string {
|
||||||
|
if (typeof t.has === "function" && t.has(key)) return t(key, values);
|
||||||
|
if (values) {
|
||||||
|
return Object.entries(values).reduce(
|
||||||
|
(acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)),
|
||||||
|
fallback
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProvidersError({
|
export default function ProvidersError({
|
||||||
error: _error,
|
error: _error,
|
||||||
reset,
|
reset,
|
||||||
@@ -7,6 +29,8 @@ export default function ProvidersError({
|
|||||||
error: Error & { digest?: string };
|
error: Error & { digest?: string };
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations("providers");
|
||||||
|
|
||||||
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]"
|
||||||
@@ -15,13 +39,19 @@ export default function ProvidersError({
|
|||||||
>
|
>
|
||||||
<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">
|
<h2 className="text-xl font-semibold text-red-600 dark:text-red-400">
|
||||||
Failed to load providers
|
{providerText(t, "pageLoadErrorTitle", "Failed to load providers")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-text-muted max-w-md">
|
<p className="text-text-muted max-w-md">
|
||||||
We could not load provider data right now. Check your connection and try again.
|
{providerText(
|
||||||
|
t,
|
||||||
|
"pageLoadErrorDescription",
|
||||||
|
"We could not load provider data right now. Check your connection and try again."
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
{_error?.digest && (
|
{_error?.digest && (
|
||||||
<p className="text-xs text-text-muted font-mono">Error ID: {_error.digest}</p>
|
<p className="text-xs text-text-muted font-mono">
|
||||||
|
{providerText(t, "pageLoadErrorId", "Error ID: {id}", { 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 +60,7 @@ export default function ProvidersError({
|
|||||||
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"
|
||||||
>
|
>
|
||||||
Try Again
|
{providerText(t, "pageLoadErrorRetry", "Try Again")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
* "Featured" provider pinning + brand accent — presentation-only concern for the
|
* "Featured" provider pinning + brand accent — presentation-only concern for the
|
||||||
* providers dashboard grid.
|
* providers dashboard grid.
|
||||||
*
|
*
|
||||||
* Currently powers the Kimi (Moonshot AI) official-partnership highlight
|
* Powers the Open Source Friend highlights: Kimi (Moonshot AI, rank 1) and
|
||||||
* (2026-07): Kimi-family providers are pinned first within whichever
|
* Cheaper Inference (rank 2). Ranked providers are pinned — in rank order —
|
||||||
* category/group they render in on `/dashboard/providers`, and their
|
* within whichever category/group they render in on `/dashboard/providers`, and
|
||||||
* `ProviderCard` renders a Kimi-blue accent + "Official Supporter" badge.
|
* their `ProviderCard` renders a brand accent + supporter badge.
|
||||||
*
|
*
|
||||||
* Scope guard: this is a UI ordering/branding concern ONLY. It must never be
|
* Scope guard: this is a UI ordering/branding concern ONLY. It must never be
|
||||||
* imported by routing/fallback code (`open-sse/config/providerRegistry.ts`,
|
* imported by routing/fallback code (`open-sse/config/providerRegistry.ts`,
|
||||||
@@ -25,6 +25,9 @@
|
|||||||
*/
|
*/
|
||||||
export const KIMI_BRAND_COLOR = "#1783FF";
|
export const KIMI_BRAND_COLOR = "#1783FF";
|
||||||
|
|
||||||
|
/** Cheaper Inference brand green (the accent stroke in its logomark). */
|
||||||
|
export const CHEAPERINFERENCE_BRAND_COLOR = "#31f889";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every Kimi/Moonshot dashboard-catalog provider id (`src/shared/constants/providers/`),
|
* Every Kimi/Moonshot dashboard-catalog provider id (`src/shared/constants/providers/`),
|
||||||
* including the two `hiddenFromDashboard` aliases that never render their own
|
* including the two `hiddenFromDashboard` aliases that never render their own
|
||||||
@@ -46,19 +49,62 @@ const KIMI_PROVIDER_IDS: readonly string[] = [
|
|||||||
"moonshot",
|
"moonshot",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Cheaper Inference (api.cheaperinference.com) — apikey category, single id. */
|
||||||
|
const CHEAPERINFERENCE_PROVIDER_IDS: readonly string[] = ["cheaperinference"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicit sponsor ordering for the dashboard provider grids.
|
||||||
|
*
|
||||||
|
* A plain Set is NOT enough: `sortProviderEntriesFeaturedFirst` pins featured
|
||||||
|
* entries while preserving alphabetical order among them, and "Cheaper Inference"
|
||||||
|
* sorts before "Kimi". The operator requires Kimi 1st and Cheaper Inference 2nd
|
||||||
|
* (2026-07-31), so rank is stated here rather than derived from the display name.
|
||||||
|
* Lower number = higher on the page; equal ranks fall back to alphabetical.
|
||||||
|
*
|
||||||
|
* Scope guard (see file header): presentation only — never import this from
|
||||||
|
* routing/fallback code.
|
||||||
|
*/
|
||||||
|
const FEATURED_PROVIDER_RANKS: ReadonlyMap<string, number> = new Map([
|
||||||
|
...KIMI_PROVIDER_IDS.map((id) => [id, 1] as const),
|
||||||
|
...CHEAPERINFERENCE_PROVIDER_IDS.map((id) => [id, 2] as const),
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Brand accent per sponsor family, keyed by any of that family's provider ids. */
|
||||||
|
export const SPONSOR_BRAND_COLORS: Readonly<Record<string, string>> = Object.freeze({
|
||||||
|
...Object.fromEntries(KIMI_PROVIDER_IDS.map((id) => [id, KIMI_BRAND_COLOR])),
|
||||||
|
...Object.fromEntries(
|
||||||
|
CHEAPERINFERENCE_PROVIDER_IDS.map((id) => [id, CHEAPERINFERENCE_BRAND_COLOR])
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Sponsor rank (1 = top), or null when the provider is not featured. */
|
||||||
|
export function getFeaturedProviderRank(providerId: string | null | undefined): number | null {
|
||||||
|
if (typeof providerId !== "string") return null;
|
||||||
|
return FEATURED_PROVIDER_RANKS.get(providerId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Providers pinned first within their dashboard category/group by
|
* Providers pinned first within their dashboard category/group by
|
||||||
* `sortProviderEntriesFeaturedFirst`. A plain Set (not hardcoded inline at the
|
* `sortProviderEntriesFeaturedFirst`. Retained for existing importers: the flat
|
||||||
* sort call site) so a future partnership can extend it without touching the
|
* set of featured ids, derived from the rank map so the two cannot drift.
|
||||||
* sort implementation.
|
|
||||||
*/
|
*/
|
||||||
export const FEATURED_PROVIDER_IDS: ReadonlySet<string> = new Set(KIMI_PROVIDER_IDS);
|
export const FEATURED_PROVIDER_IDS: ReadonlySet<string> = new Set(FEATURED_PROVIDER_RANKS.keys());
|
||||||
|
|
||||||
export function isFeaturedProviderId(providerId: string | null | undefined): boolean {
|
export function isFeaturedProviderId(providerId: string | null | undefined): boolean {
|
||||||
return typeof providerId === "string" && FEATURED_PROVIDER_IDS.has(providerId);
|
return getFeaturedProviderRank(providerId) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True for providers that should render the Kimi official-supporter card accent. */
|
/** True for providers that should render the Kimi official-supporter card accent. */
|
||||||
export function isKimiPartnerProviderId(providerId: string | null | undefined): boolean {
|
export function isKimiPartnerProviderId(providerId: string | null | undefined): boolean {
|
||||||
return typeof providerId === "string" && KIMI_PROVIDER_IDS.includes(providerId);
|
return typeof providerId === "string" && KIMI_PROVIDER_IDS.includes(providerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True for providers that should render the Cheaper Inference card accent. */
|
||||||
|
export function isCheaperInferenceProviderId(providerId: string | null | undefined): boolean {
|
||||||
|
return typeof providerId === "string" && CHEAPERINFERENCE_PROVIDER_IDS.includes(providerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True for any Open Source Friend — drives the shared supporter chip. */
|
||||||
|
export function isSponsorProviderId(providerId: string | null | undefined): boolean {
|
||||||
|
return isFeaturedProviderId(providerId);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
export interface ProviderModel {
|
export interface ProviderModel {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -29,6 +30,7 @@ interface UseProviderModelsResult {
|
|||||||
* `providerId` changes).
|
* `providerId` changes).
|
||||||
*/
|
*/
|
||||||
export function useProviderModels(providerId: string): UseProviderModelsResult {
|
export function useProviderModels(providerId: string): UseProviderModelsResult {
|
||||||
|
const t = useTranslations("providers");
|
||||||
const [models, setModels] = useState<ProviderModel[]>([]);
|
const [models, setModels] = useState<ProviderModel[]>([]);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -49,7 +51,7 @@ export function useProviderModels(providerId: string): UseProviderModelsResult {
|
|||||||
const body = (await res.json().catch(() => null)) as {
|
const body = (await res.json().catch(() => null)) as {
|
||||||
error?: { message?: string };
|
error?: { message?: string };
|
||||||
} | null;
|
} | null;
|
||||||
const msg = body?.error?.message ?? `HTTP ${res.status}`;
|
const msg = body?.error?.message ?? `${t("providerTestFailed")} (HTTP ${res.status})`;
|
||||||
if (!cancelled) setError(msg);
|
if (!cancelled) setError(msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -113,7 +115,7 @@ export function useProviderModels(providerId: string): UseProviderModelsResult {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [providerId]);
|
}, [providerId, t]);
|
||||||
|
|
||||||
return { models, loading, error };
|
return { models, loading, error };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,22 +134,28 @@ type ProviderBatchTestResults = {
|
|||||||
error?: string | { message?: string };
|
error?: string | { message?: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
function getConnectionErrorTag(connection) {
|
function getConnectionErrorTag(connection, t: ProviderMessageTranslator) {
|
||||||
if (!connection) return null;
|
if (!connection) return null;
|
||||||
|
|
||||||
const explicitType = connection.lastErrorType;
|
const explicitType = connection.lastErrorType;
|
||||||
if (explicitType === "runtime_error") return "Runtime";
|
if (explicitType === "runtime_error") return providerText(t, "errorTypeRuntime", "Runtime");
|
||||||
if (
|
if (
|
||||||
explicitType === "upstream_auth_error" ||
|
explicitType === "upstream_auth_error" ||
|
||||||
explicitType === "auth_missing" ||
|
explicitType === "auth_missing" ||
|
||||||
explicitType === "token_refresh_failed" ||
|
explicitType === "token_refresh_failed" ||
|
||||||
explicitType === "token_expired"
|
explicitType === "token_expired"
|
||||||
) {
|
) {
|
||||||
return "Auth";
|
return providerText(t, "errorTypeUpstreamAuth", "Auth");
|
||||||
|
}
|
||||||
|
if (explicitType === "upstream_rate_limited") {
|
||||||
|
return providerText(t, "errorTypeRateLimited", "Rate limited");
|
||||||
|
}
|
||||||
|
if (explicitType === "upstream_unavailable") {
|
||||||
|
return providerText(t, "errorTypeUpstreamUnavailable", "Server error");
|
||||||
|
}
|
||||||
|
if (explicitType === "network_error") {
|
||||||
|
return providerText(t, "errorTypeNetworkError", "Network");
|
||||||
}
|
}
|
||||||
if (explicitType === "upstream_rate_limited") return "Rate limited";
|
|
||||||
if (explicitType === "upstream_unavailable") return "Server error";
|
|
||||||
if (explicitType === "network_error") return "Network";
|
|
||||||
|
|
||||||
const numericCode = Number(connection.errorCode);
|
const numericCode = Number(connection.errorCode);
|
||||||
if (Number.isFinite(numericCode) && numericCode >= 400) {
|
if (Number.isFinite(numericCode) && numericCode >= 400) {
|
||||||
@@ -157,19 +163,21 @@ function getConnectionErrorTag(connection) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fromMessage = getErrorCode(connection.lastError);
|
const fromMessage = getErrorCode(connection.lastError);
|
||||||
if (fromMessage === "401" || fromMessage === "403") return "Auth";
|
if (fromMessage === "401" || fromMessage === "403") {
|
||||||
|
return providerText(t, "errorTypeUpstreamAuth", "Auth");
|
||||||
|
}
|
||||||
if (fromMessage && fromMessage !== "ERR") return fromMessage;
|
if (fromMessage && fromMessage !== "ERR") return fromMessage;
|
||||||
|
|
||||||
const msg = (connection.lastError || "").toLowerCase();
|
const msg = (connection.lastError || "").toLowerCase();
|
||||||
if (msg.includes("runtime") || msg.includes("not runnable") || msg.includes("not installed"))
|
if (msg.includes("runtime") || msg.includes("not runnable") || msg.includes("not installed"))
|
||||||
return "Runtime";
|
return providerText(t, "errorTypeRuntime", "Runtime");
|
||||||
if (
|
if (
|
||||||
msg.includes("invalid api key") ||
|
msg.includes("invalid api key") ||
|
||||||
msg.includes("token invalid") ||
|
msg.includes("token invalid") ||
|
||||||
msg.includes("revoked") ||
|
msg.includes("revoked") ||
|
||||||
msg.includes("unauthorized")
|
msg.includes("unauthorized")
|
||||||
)
|
)
|
||||||
return "Auth";
|
return providerText(t, "errorTypeUpstreamAuth", "Auth");
|
||||||
|
|
||||||
return "ERR";
|
return "ERR";
|
||||||
}
|
}
|
||||||
@@ -352,7 +360,7 @@ export default function ProvidersPage() {
|
|||||||
(a: any, b: any) =>
|
(a: any, b: any) =>
|
||||||
(new Date(b.lastErrorAt || 0) as any) - (new Date(a.lastErrorAt || 0) as any)
|
(new Date(b.lastErrorAt || 0) as any) - (new Date(a.lastErrorAt || 0) as any)
|
||||||
)[0];
|
)[0];
|
||||||
const errorCode = latestError ? getConnectionErrorTag(latestError) : null;
|
const errorCode = latestError ? getConnectionErrorTag(latestError, t) : null;
|
||||||
const errorTime = latestError?.lastErrorAt ? getRelativeTime(latestError.lastErrorAt) : null;
|
const errorTime = latestError?.lastErrorAt ? getRelativeTime(latestError.lastErrorAt) : null;
|
||||||
|
|
||||||
// Check expirations
|
// Check expirations
|
||||||
@@ -822,11 +830,14 @@ export default function ProvidersPage() {
|
|||||||
<span className="material-symbols-outlined text-[32px] text-primary">dns</span>
|
<span className="material-symbols-outlined text-[32px] text-primary">dns</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-semibold text-text-main">
|
<h2 className="text-xl font-semibold text-text-main">
|
||||||
{t("addFirstProvider") || "Add your first provider"}
|
{providerText(t, "addFirstProvider", "Add your first provider")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-text-muted mt-2 max-w-md">
|
<p className="text-sm text-text-muted mt-2 max-w-md">
|
||||||
{t("addFirstProviderDesc") ||
|
{providerText(
|
||||||
"Connect an AI provider to start routing requests through OmniRoute. You can use free providers, API keys, or OAuth accounts."}
|
t,
|
||||||
|
"addFirstProviderDesc",
|
||||||
|
"Connect an AI provider to start routing requests through OmniRoute. You can use free providers, API keys, or OAuth accounts."
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
|
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
|
||||||
<Button icon="add" onClick={() => router.push("/dashboard/providers/new")}>
|
<Button icon="add" onClick={() => router.push("/dashboard/providers/new")}>
|
||||||
@@ -839,7 +850,7 @@ export default function ProvidersPage() {
|
|||||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-lg border border-border text-text-muted hover:text-text-main hover:bg-bg-subtle transition-colors"
|
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-lg border border-border text-text-muted hover:text-text-main hover:bg-bg-subtle transition-colors"
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-[16px]">help</span>
|
<span className="material-symbols-outlined text-[16px]">help</span>
|
||||||
{t("learnMore") || "Learn more"}
|
{providerText(t, "learnMore", "Learn more")}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1094,10 +1105,10 @@ export default function ProvidersPage() {
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h2 className="text-xl font-semibold flex items-center gap-2 flex-1 min-w-0">
|
<h2 className="text-xl font-semibold flex items-center gap-2 flex-1 min-w-0">
|
||||||
{t("ideProviders") || "IDE Providers"}{" "}
|
{providerText(t, "ideProviders", "IDE Providers")}{" "}
|
||||||
<span
|
<span
|
||||||
className="size-2.5 rounded-full bg-cyan-500"
|
className="size-2.5 rounded-full bg-cyan-500"
|
||||||
title={t("ideProviders") || "IDE Providers"}
|
title={providerText(t, "ideProviders", "IDE Providers")}
|
||||||
/>
|
/>
|
||||||
<ProviderCountBadge {...countConfigured(ideProviderEntriesAll)} />
|
<ProviderCountBadge {...countConfigured(ideProviderEntriesAll)} />
|
||||||
</h2>
|
</h2>
|
||||||
@@ -1121,12 +1132,15 @@ export default function ProvidersPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-text-muted -mt-2">
|
<p className="text-sm text-text-muted -mt-2">
|
||||||
{t("ideProvidersDesc") ||
|
{providerText(
|
||||||
"Editors with built-in AI subscription. Use the provider page to import credentials directly from the IDE's keychain."}
|
t,
|
||||||
|
"ideProvidersDesc",
|
||||||
|
"Editors with built-in AI subscription. Use the provider page to import credentials directly from the IDE's keychain."
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
{ideProviderEntries.length === 0 ? (
|
{ideProviderEntries.length === 0 ? (
|
||||||
<div className="rounded-lg border border-dashed border-border bg-bg-subtle p-6 text-center text-sm text-text-muted">
|
<div className="rounded-lg border border-dashed border-border bg-bg-subtle p-6 text-center text-sm text-text-muted">
|
||||||
{t("noIdeProviders") || "No IDE providers match the current filters."}
|
{providerText(t, "noIdeProviders", "No IDE providers match the current filters.")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { providerHasServiceKind } from "@/lib/providers/serviceKindIndex";
|
|||||||
import { compareTr, matchesAnyToken, matchesSearch } from "@/shared/utils/turkishText";
|
import { compareTr, matchesAnyToken, matchesSearch } from "@/shared/utils/turkishText";
|
||||||
import { fetchWithTimeout } from "@/shared/utils/fetchTimeout";
|
import { fetchWithTimeout } from "@/shared/utils/fetchTimeout";
|
||||||
import type { ProviderDisplayMode } from "./providerPageStorage";
|
import type { ProviderDisplayMode } from "./providerPageStorage";
|
||||||
import { isFeaturedProviderId } from "./featuredProviders";
|
import { getFeaturedProviderRank } from "./featuredProviders";
|
||||||
|
|
||||||
export interface ProviderStatsSnapshot {
|
export interface ProviderStatsSnapshot {
|
||||||
total?: number;
|
total?: number;
|
||||||
@@ -165,22 +165,31 @@ export function sortProviderEntriesByName<TProvider>(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Sort provider entries alphabetically (via `sortProviderEntriesByName`), then
|
* Sort provider entries alphabetically (via `sortProviderEntriesByName`), then
|
||||||
* stable-pin any `FEATURED_PROVIDER_IDS` member first — featured entries keep
|
* stable-pin sponsors first in explicit rank order (see `featuredProviders.ts`):
|
||||||
* their alphabetical order among themselves, followed by the rest in
|
* rank 1 block, then rank 2, then everything unranked — each block keeping the
|
||||||
* alphabetical order. Presentation-only (see `featuredProviders.ts`): this must
|
* alphabetical order established above. Presentation-only: this must never
|
||||||
* never influence routing/fallback order, only how the dashboard's provider
|
* influence routing/fallback order, only how the dashboard's provider category
|
||||||
* category grids are sorted.
|
* grids are sorted.
|
||||||
*/
|
*/
|
||||||
export function sortProviderEntriesFeaturedFirst<TProvider>(
|
export function sortProviderEntriesFeaturedFirst<TProvider>(
|
||||||
entries: ProviderEntry<TProvider>[]
|
entries: ProviderEntry<TProvider>[]
|
||||||
): ProviderEntry<TProvider>[] {
|
): ProviderEntry<TProvider>[] {
|
||||||
const sorted = sortProviderEntriesByName(entries);
|
const sorted = sortProviderEntriesByName(entries);
|
||||||
const featured: ProviderEntry<TProvider>[] = [];
|
// A plain "featured first" pin would order Cheaper Inference above Kimi (the
|
||||||
|
// alphabet), which is exactly what the explicit ranks prevent.
|
||||||
|
const ranked: ProviderEntry<TProvider>[] = [];
|
||||||
const rest: ProviderEntry<TProvider>[] = [];
|
const rest: ProviderEntry<TProvider>[] = [];
|
||||||
for (const entry of sorted) {
|
for (const entry of sorted) {
|
||||||
(isFeaturedProviderId(entry.providerId) ? featured : rest).push(entry);
|
(getFeaturedProviderRank(entry.providerId) === null ? rest : ranked).push(entry);
|
||||||
}
|
}
|
||||||
return [...featured, ...rest];
|
// Array.prototype.sort is stable in ES2019+, so equal-rank entries keep the
|
||||||
|
// alphabetical order established above.
|
||||||
|
ranked.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(getFeaturedProviderRank(a.providerId) as number) -
|
||||||
|
(getFeaturedProviderRank(b.providerId) as number)
|
||||||
|
);
|
||||||
|
return [...ranked, ...rest];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildProviderEntries<TProvider = Record<string, unknown>>(
|
export function buildProviderEntries<TProvider = Record<string, unknown>>(
|
||||||
|
|||||||
@@ -6,12 +6,34 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { Card, Toggle } from "@/shared/components";
|
import { Card, Toggle } from "@/shared/components";
|
||||||
import { useServiceStatus } from "../hooks/useServiceStatus";
|
import { useServiceStatus } from "../hooks/useServiceStatus";
|
||||||
|
|
||||||
|
type ServiceMessageTranslator = ((key: string, values?: Record<string, unknown>) => string) & {
|
||||||
|
has?: (key: string) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function serviceText(
|
||||||
|
t: ServiceMessageTranslator,
|
||||||
|
key: string,
|
||||||
|
fallback: string,
|
||||||
|
values?: Record<string, unknown>
|
||||||
|
): string {
|
||||||
|
if (typeof t.has === "function" && t.has(key)) return t(key, values);
|
||||||
|
if (values) {
|
||||||
|
return Object.entries(values).reduce(
|
||||||
|
(acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)),
|
||||||
|
fallback
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
const NAME = "cliproxy";
|
const NAME = "cliproxy";
|
||||||
|
|
||||||
export function CliproxyProviderExposureCard() {
|
export function CliproxyProviderExposureCard() {
|
||||||
|
const t = useTranslations("embeddedServices");
|
||||||
const { data, mutate } = useServiceStatus(NAME);
|
const { data, mutate } = useServiceStatus(NAME);
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||||
@@ -28,14 +50,30 @@ export function CliproxyProviderExposureCard() {
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
const errorMsg =
|
const errorMsg =
|
||||||
body?.error?.message ?? body?.message ?? `Failed to update (HTTP ${res.status})`;
|
body?.error?.message ??
|
||||||
|
body?.message ??
|
||||||
|
serviceText(
|
||||||
|
t,
|
||||||
|
"cliproxyProviderExposureUpdateFailed",
|
||||||
|
"Failed to update (HTTP {status})",
|
||||||
|
{
|
||||||
|
status: res.status,
|
||||||
|
}
|
||||||
|
);
|
||||||
setMsg({ ok: false, text: errorMsg });
|
setMsg({ ok: false, text: errorMsg });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
mutate();
|
mutate();
|
||||||
} catch {
|
} catch {
|
||||||
setMsg({ ok: false, text: "Network error — could not update provider exposure setting" });
|
setMsg({
|
||||||
|
ok: false,
|
||||||
|
text: serviceText(
|
||||||
|
t,
|
||||||
|
"cliproxyProviderExposureNetworkFailed",
|
||||||
|
"Network error — could not update provider exposure setting"
|
||||||
|
),
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setPending(false);
|
setPending(false);
|
||||||
}
|
}
|
||||||
@@ -48,10 +86,15 @@ export function CliproxyProviderExposureCard() {
|
|||||||
<span className="material-symbols-outlined text-sky-500 text-xl">hub</span>
|
<span className="material-symbols-outlined text-sky-500 text-xl">hub</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium text-sm">Provider Exposure</h3>
|
<h3 className="font-medium text-sm">
|
||||||
|
{serviceText(t, "cliproxyProviderExposureTitle", "Provider Exposure")}
|
||||||
|
</h3>
|
||||||
<p className="text-xs text-text-muted">
|
<p className="text-xs text-text-muted">
|
||||||
Expose CLIProxyAPI models as a routing target under the{" "}
|
{serviceText(
|
||||||
<code className="font-mono bg-bg-subtle px-1 rounded">cliproxyapi/</code> prefix.
|
t,
|
||||||
|
"cliproxyProviderExposureDescription",
|
||||||
|
"Expose CLIProxyAPI models as a routing target under the cliproxyapi/ prefix."
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,16 +117,23 @@ export function CliproxyProviderExposureCard() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
Expose as{" "}
|
{serviceText(t, "cliproxyProviderExposureLabel", "Expose as")}{" "}
|
||||||
<code className="font-mono bg-bg-subtle px-1 rounded text-xs">cliproxyapi/...</code>
|
<code className="font-mono bg-bg-subtle px-1 rounded text-xs">cliproxyapi/...</code>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-text-muted mt-0.5">
|
<p className="text-xs text-text-muted mt-0.5">
|
||||||
When enabled, discovered models appear in provider selects across OmniRoute.
|
{serviceText(
|
||||||
|
t,
|
||||||
|
"cliproxyProviderExposureHint",
|
||||||
|
"When enabled, discovered models appear in provider selects across OmniRoute."
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle checked={data?.providerExpose ?? false} onChange={handleToggle} disabled={pending || !data} />
|
<Toggle
|
||||||
|
checked={data?.providerExpose ?? false}
|
||||||
|
onChange={handleToggle}
|
||||||
|
disabled={pending || !data}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
import RelayProxyClient from "./RelayProxyClient";
|
import RelayProxyClient from "./RelayProxyClient";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
title: "OmniRoute — Relay Proxies",
|
const t = await getTranslations("metadata");
|
||||||
description: "Serverless relay proxy endpoints for your AI infrastructure",
|
return {
|
||||||
};
|
title: t("relayTitle"),
|
||||||
|
description: t("relayDescription"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function RelayProxyPage() {
|
export default function RelayProxyPage() {
|
||||||
return <RelayProxyClient />;
|
return <RelayProxyClient />;
|
||||||
|
|||||||
@@ -320,7 +320,7 @@ export default function AuthzSection() {
|
|||||||
onClick={() => handleRemovePrefix(prefix)}
|
onClick={() => handleRemovePrefix(prefix)}
|
||||||
disabled={locked || submitting}
|
disabled={locked || submitting}
|
||||||
>
|
>
|
||||||
Remove
|
{t("authz.remove")}
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default function AutoDisableCard() {
|
|||||||
const savedData = await res.json();
|
const savedData = await res.json();
|
||||||
setData(savedData);
|
setData(savedData);
|
||||||
setEditMode(false);
|
setEditMode(false);
|
||||||
notify.success(t("savedSuccessfully") || "Saved successfully");
|
notify.success(t("savedSuccessfully"));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notify.error(err instanceof Error ? err.message : "Error saving");
|
notify.error(err instanceof Error ? err.message : "Error saving");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -93,18 +93,12 @@ export default function BackgroundDegradationTab() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-lg font-semibold">
|
<h3 className="text-lg font-semibold">{t("backgroundDegradationTitle")}</h3>
|
||||||
{t("backgroundDegradationTitle") || "Background Task Degradation"}
|
<p className="text-sm text-text-muted">{t("backgroundDegradationDesc")}</p>
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-text-muted">
|
|
||||||
{t("backgroundDegradationDesc") ||
|
|
||||||
"Auto-redirect background requests (titles, summaries) to cheaper models"}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{status === "saved" && (
|
{status === "saved" && (
|
||||||
<span className="text-xs font-medium text-emerald-500 flex items-center gap-1">
|
<span className="text-xs font-medium text-emerald-500 flex items-center gap-1">
|
||||||
<span className="material-symbols-outlined text-[14px]">check_circle</span>{" "}
|
<span className="material-symbols-outlined text-[14px]">check_circle</span> {t("saved")}
|
||||||
{t("saved") || "Saved"}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -112,9 +106,7 @@ export default function BackgroundDegradationTab() {
|
|||||||
{/* Toggle */}
|
{/* Toggle */}
|
||||||
<div className="flex items-center justify-between p-4 rounded-lg bg-surface/30 border border-border/30 mb-4">
|
<div className="flex items-center justify-between p-4 rounded-lg bg-surface/30 border border-border/30 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-medium">{t("enableDegradation")}</p>
|
||||||
{t("enableDegradation") || "Enable Background Degradation"}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-text-muted mt-0.5">
|
<p className="text-xs text-text-muted mt-0.5">
|
||||||
{t("enableDegradationHint") ||
|
{t("enableDegradationHint") ||
|
||||||
"Automatically use cheaper models for background utility tasks"}
|
"Automatically use cheaper models for background utility tasks"}
|
||||||
@@ -124,7 +116,7 @@ export default function BackgroundDegradationTab() {
|
|||||||
checked={config.enabled}
|
checked={config.enabled}
|
||||||
onChange={(enabled) => save({ enabled })}
|
onChange={(enabled) => save({ enabled })}
|
||||||
disabled={loading || saving}
|
disabled={loading || saving}
|
||||||
ariaLabel={t("enableDegradation") || "Enable Background Degradation"}
|
ariaLabel={t("enableDegradation")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -133,9 +125,7 @@ export default function BackgroundDegradationTab() {
|
|||||||
<div className="flex items-center gap-4 p-3 rounded-lg bg-sky-500/5 border border-sky-500/20 mb-4">
|
<div className="flex items-center gap-4 p-3 rounded-lg bg-sky-500/5 border border-sky-500/20 mb-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="material-symbols-outlined text-[16px] text-sky-400">analytics</span>
|
<span className="material-symbols-outlined text-[16px] text-sky-400">analytics</span>
|
||||||
<span className="text-xs text-text-muted">
|
<span className="text-xs text-text-muted">{t("tasksDetected")}:</span>
|
||||||
{t("tasksDetected") || "Tasks detected"}:
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-mono font-semibold text-sky-400">
|
<span className="text-sm font-mono font-semibold text-sky-400">
|
||||||
{config.stats.detected}
|
{config.stats.detected}
|
||||||
</span>
|
</span>
|
||||||
@@ -148,7 +138,7 @@ export default function BackgroundDegradationTab() {
|
|||||||
{/* Degradation Map */}
|
{/* Degradation Map */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<p className="text-xs font-medium text-text-muted uppercase tracking-wider mb-2">
|
<p className="text-xs font-medium text-text-muted uppercase tracking-wider mb-2">
|
||||||
{t("degradationMap") || "Model Degradation Map"}
|
{t("degradationMap")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Add new mapping */}
|
{/* Add new mapping */}
|
||||||
@@ -157,23 +147,19 @@ export default function BackgroundDegradationTab() {
|
|||||||
<ModelSelectField
|
<ModelSelectField
|
||||||
value={newFrom}
|
value={newFrom}
|
||||||
onChange={setNewFrom}
|
onChange={setNewFrom}
|
||||||
placeholder={t("premiumModel") || "Premium model"}
|
placeholder={t("premiumModel")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-text-muted text-lg">→</span>
|
<span className="text-text-muted text-lg">→</span>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<ModelSelectField
|
<ModelSelectField value={newTo} onChange={setNewTo} placeholder={t("cheapModel")} />
|
||||||
value={newTo}
|
|
||||||
onChange={setNewTo}
|
|
||||||
placeholder={t("cheapModel") || "Cheap model"}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={addMapping}
|
onClick={addMapping}
|
||||||
disabled={saving || !newFrom.trim() || !newTo.trim()}
|
disabled={saving || !newFrom.trim() || !newTo.trim()}
|
||||||
className="px-3 py-2 rounded-lg text-sm font-medium bg-sky-500/10 text-sky-500 hover:bg-sky-500/20 disabled:opacity-50 transition-all"
|
className="px-3 py-2 rounded-lg text-sm font-medium bg-sky-500/10 text-sky-500 hover:bg-sky-500/20 disabled:opacity-50 transition-all"
|
||||||
>
|
>
|
||||||
{t("add") || "Add"}
|
{t("add")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -206,15 +192,14 @@ export default function BackgroundDegradationTab() {
|
|||||||
<span className="material-symbols-outlined text-[14px] group-open:rotate-90 transition-transform">
|
<span className="material-symbols-outlined text-[14px] group-open:rotate-90 transition-transform">
|
||||||
chevron_right
|
chevron_right
|
||||||
</span>
|
</span>
|
||||||
{t("detectionPatterns") || "Detection Patterns"} (
|
{t("detectionPatterns")} ({config.detectionPatterns?.length || 0})
|
||||||
{config.detectionPatterns?.length || 0})
|
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
{/* Add new pattern */}
|
{/* Add new pattern */}
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t("newPattern") || 'e.g. "generate a title"'}
|
placeholder={t("newPattern")}
|
||||||
value={newPattern}
|
value={newPattern}
|
||||||
onChange={(e) => setNewPattern(e.target.value)}
|
onChange={(e) => setNewPattern(e.target.value)}
|
||||||
className="flex-1 px-3 py-2 rounded-lg text-sm bg-surface border border-border/50 focus:border-sky-500/50 focus:outline-none"
|
className="flex-1 px-3 py-2 rounded-lg text-sm bg-surface border border-border/50 focus:border-sky-500/50 focus:outline-none"
|
||||||
@@ -224,7 +209,7 @@ export default function BackgroundDegradationTab() {
|
|||||||
disabled={saving || !newPattern.trim()}
|
disabled={saving || !newPattern.trim()}
|
||||||
className="px-3 py-2 rounded-lg text-sm font-medium bg-sky-500/10 text-sky-500 hover:bg-sky-500/20 disabled:opacity-50 transition-all"
|
className="px-3 py-2 rounded-lg text-sm font-medium bg-sky-500/10 text-sky-500 hover:bg-sky-500/20 disabled:opacity-50 transition-all"
|
||||||
>
|
>
|
||||||
{t("add") || "Add"}
|
{t("add")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -49,17 +49,21 @@ export default function CliproxyapiSettingsTab() {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setImportResult(
|
setImportResult(
|
||||||
`Imported ${data.imported ?? 0} account(s) (scanned ${data.scanned ?? 0}, skipped ${data.skipped ?? 0}).`
|
t("cliproxyapiImportResult", {
|
||||||
|
imported: data.imported ?? 0,
|
||||||
|
scanned: data.scanned ?? 0,
|
||||||
|
skipped: data.skipped ?? 0,
|
||||||
|
})
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
setImportResult(data.error || "Import failed.");
|
setImportResult(data.error || t("cliproxyapiImportFailed"));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setImportResult("Import failed.");
|
setImportResult(t("cliproxyapiImportFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setImporting(false);
|
setImporting(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/settings")
|
fetch("/api/settings")
|
||||||
@@ -95,34 +99,37 @@ export default function CliproxyapiSettingsTab() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateSetting = useCallback(async (key: string, value: boolean | string) => {
|
const updateSetting = useCallback(
|
||||||
if (key === "cliproxyapi_url" && typeof value === "string" && value.trim() !== "") {
|
async (key: string, value: boolean | string) => {
|
||||||
if (!isValidUrl(value)) {
|
if (key === "cliproxyapi_url" && typeof value === "string" && value.trim() !== "") {
|
||||||
setMessage({ type: "error", text: "Invalid URL format. Use http:// or https://" });
|
if (!isValidUrl(value)) {
|
||||||
return;
|
setMessage({ type: "error", text: t("cliproxyapiInvalidUrl") });
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/settings", {
|
const res = await fetch("/api/settings", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ [key]: value }),
|
body: JSON.stringify({ [key]: value }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Server returned ${res.status}`);
|
throw new Error(`Server returned ${res.status}`);
|
||||||
|
}
|
||||||
|
await res.json();
|
||||||
|
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||||
|
setMessage({ type: "success", text: t("settingSaved") });
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: "error", text: t("settingSaveFailed") });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
}
|
}
|
||||||
await res.json();
|
},
|
||||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
[t]
|
||||||
setMessage({ type: "success", text: "Setting saved" });
|
);
|
||||||
} catch {
|
|
||||||
setMessage({ type: "error", text: "Failed to save setting" });
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const cpaEnabled = settings.cliproxyapi_fallback_enabled === true;
|
const cpaEnabled = settings.cliproxyapi_fallback_enabled === true;
|
||||||
const cpaUrl = settings.cliproxyapi_url || "http://127.0.0.1:8317";
|
const cpaUrl = settings.cliproxyapi_url || "http://127.0.0.1:8317";
|
||||||
@@ -148,14 +155,14 @@ export default function CliproxyapiSettingsTab() {
|
|||||||
<div className="flex items-start gap-2 px-3 py-2.5 rounded-lg bg-blue-500/10 text-blue-700 dark:text-blue-300 text-xs">
|
<div className="flex items-start gap-2 px-3 py-2.5 rounded-lg bg-blue-500/10 text-blue-700 dark:text-blue-300 text-xs">
|
||||||
<span className="material-symbols-outlined text-[14px] mt-0.5 shrink-0">info</span>
|
<span className="material-symbols-outlined text-[14px] mt-0.5 shrink-0">info</span>
|
||||||
<span>
|
<span>
|
||||||
CLIProxyAPI lifecycle management (install, start, stop) has moved to{" "}
|
{t("cliproxyapiLifecycleNoticeBefore")}{" "}
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/providers/services"
|
href="/dashboard/providers/services"
|
||||||
className="underline underline-offset-2 hover:opacity-80"
|
className="underline underline-offset-2 hover:opacity-80"
|
||||||
>
|
>
|
||||||
Providers → Services
|
{t("cliproxyapiLifecycleNoticeLink")}
|
||||||
</Link>
|
</Link>
|
||||||
. Fallback routing settings below remain here.
|
{t("cliproxyapiLifecycleNoticeAfter")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -181,9 +188,7 @@ export default function CliproxyapiSettingsTab() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium text-sm">{t("cliproxyapiFallback")}</h3>
|
<h3 className="font-medium text-sm">{t("cliproxyapiFallback")}</h3>
|
||||||
<p className="text-xs text-text-muted">
|
<p className="text-xs text-text-muted">{t("cliproxyapiFallbackDescription")}</p>
|
||||||
When enabled, failed requests are retried through CLIProxyAPI (localhost:8317)
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -212,7 +217,7 @@ export default function CliproxyapiSettingsTab() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-text-muted mb-1.5 block">
|
<label className="text-xs text-text-muted mb-1.5 block">
|
||||||
Fallback Status Codes (comma-separated)
|
{t("cliproxyapiFallbackCodes")}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={cpaCodes}
|
value={cpaCodes}
|
||||||
@@ -233,31 +238,31 @@ export default function CliproxyapiSettingsTab() {
|
|||||||
<span className="material-symbols-outlined animate-spin text-base">
|
<span className="material-symbols-outlined animate-spin text-base">
|
||||||
progress_activity
|
progress_activity
|
||||||
</span>
|
</span>
|
||||||
Loading...
|
{t("loading")}
|
||||||
</div>
|
</div>
|
||||||
) : toolStateError ? (
|
) : toolStateError ? (
|
||||||
<p className="text-sm text-text-muted">{toolStateError}</p>
|
<p className="text-sm text-text-muted">{toolStateError}</p>
|
||||||
) : toolState ? (
|
) : toolState ? (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 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">Status</p>
|
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiStatusLabel")}</p>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className={`material-symbols-outlined text-sm ${statusColor}`}>
|
<span className={`material-symbols-outlined text-sm ${statusColor}`}>
|
||||||
{statusIcon}
|
{statusIcon}
|
||||||
</span>
|
</span>
|
||||||
<p className={`text-sm font-medium capitalize ${statusColor}`}>
|
<p className={`text-sm font-medium capitalize ${statusColor}`}>
|
||||||
{toolState.status?.replace("_", " ") || "Unknown"}
|
{toolState.status?.replace("_", " ") || t("unknown")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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">Version</p>
|
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiVersionLabel")}</p>
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-medium">
|
||||||
{toolState.installedVersion ? `v${toolState.installedVersion}` : "Not installed"}
|
{toolState.installedVersion ? `v${toolState.installedVersion}` : t("notInstalled")}
|
||||||
</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">Health</p>
|
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiHealthLabel")}</p>
|
||||||
<p
|
<p
|
||||||
className={`text-sm font-medium ${
|
className={`text-sm font-medium ${
|
||||||
toolState.healthStatus === "healthy"
|
toolState.healthStatus === "healthy"
|
||||||
@@ -268,14 +273,14 @@ export default function CliproxyapiSettingsTab() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{toolState.healthStatus === "healthy"
|
{toolState.healthStatus === "healthy"
|
||||||
? "Healthy"
|
? t("healthy")
|
||||||
: toolState.healthStatus === "unhealthy"
|
: toolState.healthStatus === "unhealthy"
|
||||||
? "Unhealthy"
|
? t("unhealthy")
|
||||||
: "Unknown"}
|
: t("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">Port</p>
|
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiPortLabel")}</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>
|
||||||
|
|||||||
@@ -369,7 +369,10 @@ export default function MemorySkillsTab() {
|
|||||||
role="note"
|
role="note"
|
||||||
data-testid="memory-token-cost-warning"
|
data-testid="memory-token-cost-warning"
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-[18px] leading-none mt-0.5" aria-hidden="true">
|
<span
|
||||||
|
className="material-symbols-outlined text-[18px] leading-none mt-0.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
info
|
info
|
||||||
</span>
|
</span>
|
||||||
<p className="text-xs leading-relaxed">
|
<p className="text-xs leading-relaxed">
|
||||||
@@ -540,7 +543,7 @@ export default function MemorySkillsTab() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<div className="p-4 rounded-lg bg-surface/30 border border-border/30">
|
<div className="p-4 rounded-lg bg-surface/30 border border-border/30">
|
||||||
<label className="text-sm font-medium block mb-2">Host</label>
|
<label className="text-sm font-medium block mb-2">{t("host")}</label>
|
||||||
<input
|
<input
|
||||||
value={qdrant.host}
|
value={qdrant.host}
|
||||||
onChange={(e) => setQdrant((s) => ({ ...s, host: e.target.value }))}
|
onChange={(e) => setQdrant((s) => ({ ...s, host: e.target.value }))}
|
||||||
@@ -567,7 +570,7 @@ export default function MemorySkillsTab() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 rounded-lg bg-surface/30 border border-border/30">
|
<div className="p-4 rounded-lg bg-surface/30 border border-border/30">
|
||||||
<label className="text-sm font-medium block mb-2">Collection</label>
|
<label className="text-sm font-medium block mb-2">{t("collection")}</label>
|
||||||
<input
|
<input
|
||||||
value={qdrant.collection}
|
value={qdrant.collection}
|
||||||
onChange={(e) => setQdrant((s) => ({ ...s, collection: e.target.value }))}
|
onChange={(e) => setQdrant((s) => ({ ...s, collection: e.target.value }))}
|
||||||
@@ -781,9 +784,7 @@ export default function MemorySkillsTab() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold">{t("memorySkillsSkillsmpMarketplace")}</h3>
|
<h3 className="text-lg font-semibold">{t("memorySkillsSkillsmpMarketplace")}</h3>
|
||||||
<p className="text-sm text-text-muted">
|
<p className="text-sm text-text-muted">{t("memorySkillsSkillsmpDescription")}</p>
|
||||||
Connect to SkillsMP to discover and install skills from the marketplace.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{skillsmpStatus === "saved" && (
|
{skillsmpStatus === "saved" && (
|
||||||
<span className="ml-auto text-xs font-medium text-emerald-500 flex items-center gap-1">
|
<span className="ml-auto text-xs font-medium text-emerald-500 flex items-center gap-1">
|
||||||
@@ -813,12 +814,12 @@ export default function MemorySkillsTab() {
|
|||||||
disabled={skillsmpSaving}
|
disabled={skillsmpSaving}
|
||||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 disabled:opacity-50 transition-colors"
|
className="px-4 py-2 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
{skillsmpSaving ? "Saving..." : "Save"}
|
{skillsmpSaving ? t("saving") : t("save")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-text-muted mt-2">
|
<p className="text-xs text-text-muted mt-2">
|
||||||
Get your API key from <span className="text-violet-400">skillsmp.com</span>. Rate limit:
|
{t("skillsmpApiKeyHintBefore")} <span className="text-violet-400">skillsmp.com</span>
|
||||||
500 requests/day.
|
{t("skillsmpApiKeyHintAfter", { limit: 500 })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -833,9 +834,7 @@ export default function MemorySkillsTab() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold">{t("memorySkillsActiveSkillsProvider")}</h3>
|
<h3 className="text-lg font-semibold">{t("memorySkillsActiveSkillsProvider")}</h3>
|
||||||
<p className="text-sm text-text-muted">
|
<p className="text-sm text-text-muted">{t("memorySkillsActiveProviderDescription")}</p>
|
||||||
Choose which provider the Skills page uses for search and install.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{skillsProviderStatus === "saved" && (
|
{skillsProviderStatus === "saved" && (
|
||||||
<span className="ml-auto text-xs font-medium text-emerald-500 flex items-center gap-1">
|
<span className="ml-auto text-xs font-medium text-emerald-500 flex items-center gap-1">
|
||||||
@@ -864,10 +863,10 @@ export default function MemorySkillsTab() {
|
|||||||
<p
|
<p
|
||||||
className={`text-sm font-medium ${skillsProvider === "skillsmp" ? "text-indigo-400" : ""}`}
|
className={`text-sm font-medium ${skillsProvider === "skillsmp" ? "text-indigo-400" : ""}`}
|
||||||
>
|
>
|
||||||
SkillsMP Marketplace
|
{t("memorySkillsSkillsmpProviderTitle")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-text-muted mt-0.5 leading-relaxed">
|
<p className="text-xs text-text-muted mt-0.5 leading-relaxed">
|
||||||
Authenticated marketplace (uses your SkillsMP API key).
|
{t("memorySkillsSkillsmpProviderDescription")}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -884,10 +883,10 @@ export default function MemorySkillsTab() {
|
|||||||
<p
|
<p
|
||||||
className={`text-sm font-medium ${skillsProvider === "skillssh" ? "text-indigo-400" : ""}`}
|
className={`text-sm font-medium ${skillsProvider === "skillssh" ? "text-indigo-400" : ""}`}
|
||||||
>
|
>
|
||||||
skills.sh Directory
|
{t("memorySkillsSkillsshProviderTitle")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-text-muted mt-0.5 leading-relaxed">
|
<p className="text-xs text-text-muted mt-0.5 leading-relaxed">
|
||||||
Public directory provider (no API key required).
|
{t("memorySkillsSkillsshProviderDescription")}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user