mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-09 17:28:51 +00:00
Release v3.8.30 (#4267)
Release v3.8.30 — see CHANGELOG.md [3.8.30] for the full release notes.
This commit is contained in:
parent
ab8096071c
commit
db362b0126
356 changed files with 14268 additions and 1140 deletions
|
|
@ -402,6 +402,12 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
|||
# ALL_PROXY=socks5://127.0.0.1:7890
|
||||
# NO_PROXY=localhost,127.0.0.1
|
||||
|
||||
# Max concurrent sockets per cached HTTP/SOCKS proxy dispatcher.
|
||||
# Long-lived SSE streams such as Codex /v1/responses need more than one
|
||||
# connection when multiple requests share the same account-level proxy.
|
||||
# Set to 1 only for legacy diagnostics. Values above 256 are capped.
|
||||
# OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS=32
|
||||
|
||||
# Proxy fail-open mode (default: false = fail-closed).
|
||||
# When false, a request whose assigned proxy fails to resolve is REFUSED rather than
|
||||
# falling back to a direct connection — prevents real-IP leaks in egress-controlled
|
||||
|
|
|
|||
2
.github/workflows/build-fork.yml
vendored
2
.github/workflows/build-fork.yml
vendored
|
|
@ -25,7 +25,7 @@ jobs:
|
|||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
|
|
|||
56
.github/workflows/ci.yml
vendored
56
.github/workflows/ci.yml
vendored
|
|
@ -31,7 +31,7 @@ jobs:
|
|||
API_KEY_SECRET: ci-lint-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
|
|
@ -79,7 +79,7 @@ jobs:
|
|||
contents: read
|
||||
security-events: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
|
|
@ -161,7 +161,7 @@ jobs:
|
|||
# fetch-depth: 0 — the OpenAPI breaking-change gate (oasdiff) reads the base
|
||||
# spec via `git show <base_ref>:docs/reference/openapi.yaml`; a shallow clone
|
||||
# would lack the base ref and the gate would self-skip (base-unresolved).
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
|
@ -265,7 +265,7 @@ jobs:
|
|||
name: Docs Sync (Strict)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
|
|
@ -297,7 +297,7 @@ jobs:
|
|||
# existing doc corpus is brought up to style. Promote to blocking once it converges.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
|
|
@ -320,7 +320,7 @@ jobs:
|
|||
name: i18n UI Coverage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
|
|
@ -336,7 +336,7 @@ jobs:
|
|||
outputs:
|
||||
langs: ${{ steps.langs.outputs.langs }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- id: langs
|
||||
|
|
@ -355,7 +355,7 @@ jobs:
|
|||
lang: ${{ fromJson(needs.i18n-matrix.outputs.langs) }}
|
||||
needs: i18n-matrix
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v6
|
||||
|
|
@ -384,7 +384,7 @@ jobs:
|
|||
if: ${{ github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
|
@ -414,7 +414,7 @@ jobs:
|
|||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
|
|
@ -447,7 +447,7 @@ jobs:
|
|||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
|
|
@ -472,7 +472,7 @@ jobs:
|
|||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
|
|
@ -507,7 +507,7 @@ jobs:
|
|||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
|
|
@ -516,7 +516,7 @@ jobs:
|
|||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run check:node-runtime
|
||||
- run: node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
|
||||
- run: node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
|
||||
|
||||
test-vitest:
|
||||
name: Vitest (MCP / autoCombo / UI components)
|
||||
|
|
@ -528,7 +528,7 @@ jobs:
|
|||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
|
|
@ -548,7 +548,7 @@ jobs:
|
|||
node-24-compat:
|
||||
name: Node 24 Compatibility (${{ matrix.shard }}/2)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 25
|
||||
needs: build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
|
@ -559,7 +559,7 @@ jobs:
|
|||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
|
|
@ -569,12 +569,12 @@ jobs:
|
|||
- run: npm ci
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run build
|
||||
- run: node --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/2 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
|
||||
- run: node --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/2 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
|
||||
|
||||
node-26-compat:
|
||||
name: Node 26 Compatibility (${{ matrix.shard }}/2)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 25
|
||||
needs: build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
|
@ -585,7 +585,7 @@ jobs:
|
|||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
|
|
@ -595,7 +595,7 @@ jobs:
|
|||
- run: npm ci
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run build
|
||||
- run: node --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/2 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
|
||||
- run: node --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/2 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
|
||||
|
||||
test-coverage-shard:
|
||||
name: Coverage Shard (${{ matrix.shard }}/8)
|
||||
|
|
@ -611,7 +611,7 @@ jobs:
|
|||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
|
|
@ -636,7 +636,7 @@ jobs:
|
|||
--exclude=tests/** \
|
||||
--exclude=**/*.test.* \
|
||||
node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 \
|
||||
--test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
|
||||
--test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
|
||||
- name: Upload raw shard coverage
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
|
|
@ -655,7 +655,7 @@ jobs:
|
|||
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
|
|
@ -733,7 +733,7 @@ jobs:
|
|||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
|
@ -853,7 +853,7 @@ jobs:
|
|||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
OMNIROUTE_PLAYWRIGHT_SKIP_BUILD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
|
|
@ -896,7 +896,7 @@ jobs:
|
|||
DATA_DIR: /tmp/omniroute-ci-${{ matrix.shard }}
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
|
|
@ -916,7 +916,7 @@ jobs:
|
|||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
|
|
|
|||
2
.github/workflows/claude.yml
vendored
2
.github/workflows/claude.yml
vendored
|
|
@ -30,7 +30,7 @@ jobs:
|
|||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 1
|
||||
|
|
|
|||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
|
|
@ -19,7 +19,7 @@ jobs:
|
|||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
|
|
|
|||
2
.github/workflows/dast-smoke.yml
vendored
2
.github/workflows/dast-smoke.yml
vendored
|
|
@ -15,7 +15,7 @@ jobs:
|
|||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-api-key-secret-with-sufficient-length-aaaa
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
|
|
|
|||
6
.github/workflows/docker-publish.yml
vendored
6
.github/workflows/docker-publish.yml
vendored
|
|
@ -42,7 +42,7 @@ jobs:
|
|||
IMAGE_NAME: diegosouzapw/omniroute
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
|
||||
|
|
@ -143,7 +143,7 @@ jobs:
|
|||
GHCR_IMAGE_NAME: ghcr.io/diegosouzapw/omniroute
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
|
||||
|
|
@ -243,7 +243,7 @@ jobs:
|
|||
PROMOTE_LATEST: ${{ needs.prepare.outputs.promote_latest }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
|
||||
|
|
|
|||
6
.github/workflows/electron-release.yml
vendored
6
.github/workflows/electron-release.yml
vendored
|
|
@ -27,7 +27,7 @@ jobs:
|
|||
version: ${{ steps.validate.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
|
@ -84,7 +84,7 @@ jobs:
|
|||
deb_ext: .deb
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
|
|
@ -216,7 +216,7 @@ jobs:
|
|||
contents: write # softprops/action-gh-release creates the GitHub Release
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
|
|
|||
63
.github/workflows/mutation-redundancy.yml
vendored
Normal file
63
.github/workflows/mutation-redundancy.yml
vendored
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
name: Mutation Redundancy (disableBail, on-demand)
|
||||
# One-off measurement to UNBLOCK R1 (test-redundancy prune). The nightly mutation run
|
||||
# (nightly-mutation.yml) bails on the first kill, so `killedBy` lists only the FIRST
|
||||
# killer — 🟠 redundant is understated and 🟢 unique overstated (see the caveat in
|
||||
# scripts/quality/mutation-radiography.mjs). This workflow re-runs the SAME combo +
|
||||
# chatCore leaf batches with stryker.disablebail.json (disableBail:true, incremental:false)
|
||||
# so `killedBy` lists EVERY killer. Feed the uploaded reports to
|
||||
# `node scripts/quality/mutation-radiography.mjs --candidates mutation-nobail-*/mutation.json`
|
||||
# to get the accurate R1 prune-candidate list (🔴 empty ∪ 🟠 redundant) for human review.
|
||||
#
|
||||
# Batches mirror the nightly's leaf decomposition (d/e/f/g/h/i) rather than 2 mega-batches:
|
||||
# disableBail is MORE expensive than bail (it never stops early), and Stryker only writes
|
||||
# mutation.json on a SUCCESSFUL finish — a batch cancelled at the cap produces NO data — so
|
||||
# smaller batches each fit the 300min headroom and run in parallel. auth/accountFallback and
|
||||
# the security quartet are out of scope: R1 targets the combo/chatCore leaves.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
stryker-nobail:
|
||||
name: Stryker disableBail (batch ${{ matrix.batch.name }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
batch:
|
||||
- name: d
|
||||
mutate: "open-sse/services/combo/comboStructure.ts,open-sse/services/combo/autoStrategy.ts,open-sse/services/combo/validateQuality.ts"
|
||||
- name: e
|
||||
mutate: "open-sse/services/combo/shadowRouting.ts,open-sse/services/combo/targetSorters.ts,open-sse/services/combo/comboPredicates.ts,open-sse/services/combo/rrState.ts,open-sse/services/combo/comboData.ts"
|
||||
- name: f
|
||||
mutate: "open-sse/services/combo/quotaScoring.ts,open-sse/services/combo/quotaStrategies.ts"
|
||||
- name: g
|
||||
mutate: "open-sse/handlers/chatCore/comboContextCache.ts,open-sse/handlers/chatCore/idempotency.ts,open-sse/handlers/chatCore/passthroughHelpers.ts,open-sse/handlers/chatCore/responseHeaders.ts,open-sse/handlers/chatCore/sanitization.ts,open-sse/handlers/chatCore/upstreamTimeouts.ts"
|
||||
- name: h
|
||||
mutate: "open-sse/handlers/chatCore/headers.ts,open-sse/handlers/chatCore/logTruncation.ts,open-sse/handlers/chatCore/memoryExtraction.ts,open-sse/handlers/chatCore/nonStreamingSse.ts,open-sse/handlers/chatCore/passthroughToolNames.ts,open-sse/handlers/chatCore/executorHelpers.ts"
|
||||
- name: i
|
||||
mutate: "open-sse/handlers/chatCore/telemetryHelpers.ts,open-sse/handlers/chatCore/memorySkillsInjection.ts,open-sse/handlers/chatCore/semanticCache.ts"
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Run Stryker (disableBail)
|
||||
env:
|
||||
BATCH_MUTATE: ${{ matrix.batch.mutate }}
|
||||
run: npx stryker run --config-file stryker.disablebail.json --mutate "$BATCH_MUTATE"
|
||||
- name: Upload mutation report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: mutation-nobail-${{ matrix.batch.name }}
|
||||
path: reports/mutation/
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
4
.github/workflows/nightly-llm-security.yml
vendored
4
.github/workflows/nightly-llm-security.yml
vendored
|
|
@ -12,7 +12,7 @@ jobs:
|
|||
name: promptfoo — injection guard (block mode, no secret)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
|
|
@ -61,7 +61,7 @@ jobs:
|
|||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::PROMPTFOO_PROVIDER_KEY not set — skipping garak probes (advisory)."
|
||||
fi
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
|
|
|
|||
98
.github/workflows/nightly-mutation.yml
vendored
98
.github/workflows/nightly-mutation.yml
vendored
|
|
@ -36,11 +36,15 @@ jobs:
|
|||
# a successful finish); a job cancelled at the cap writes nothing, so the next run is cold
|
||||
# again — an infinite never-seeds loop. actions/cache is also branch-scoped, so each branch
|
||||
# (incl. release) must seed its OWN cache via a run with enough headroom. Measured cold runs
|
||||
# (run 27801802713): a/b never finished even isolated; c=180min CANCELLED (4 modules),
|
||||
# d=180min CANCELLED (3 combo modules), g=142min, h=132min, e=66, f=45, i=33. So a/b/c/d get
|
||||
# 350min (job max 360) and g/h a 240min variance buffer; once seeded, later nightlies only
|
||||
# re-test changed mutants and fit well under the default. NOTE: batch c's old "2 modules ~2h"
|
||||
# assumption went stale when c grew to 4 modules — keep this budget in sync with the matrix.
|
||||
# Measured cold totals (run 27801802713, extrapolated from the % at the 180/350 cancel point):
|
||||
# auth ~375min (2301 mutants — EXCEEDS the 360min job max even isolated), accountFallback
|
||||
# ~358min (1441), the c security quartet ~348min (1163), d ~197min (1316), g=142, h=132, e=66,
|
||||
# f=45, i=33. The widely-covered modules blow the budget because the tap-runner re-runs every
|
||||
# covering test file per mutant (a perTest fixed cost over ~138 test files, times thousands of
|
||||
# mutants). A flat timeout bump cannot rescue auth (>360min max) — so the three over-budget
|
||||
# batches are SPLIT so each half fits: auth->a1/a2 and accountFallback->b1/b2 by mutation range
|
||||
# (`file:startLine-endLine`), the c quartet->c1/c2 by module pair. Splitting also seeds each
|
||||
# sub-batch's own incremental cache, after which nightlies re-test only changed mutants.
|
||||
# Full coverage every night in parallel; wall-clock = the slowest batch's cold run until seeded.
|
||||
# Runs at stryker concurrency=4 with per-process DATA_DIR isolation
|
||||
# (tests/_setup/isolateDataDir.ts) — see _concurrency_comment in stryker.conf.json.
|
||||
|
|
@ -48,23 +52,38 @@ jobs:
|
|||
fail-fast: false
|
||||
matrix:
|
||||
batch:
|
||||
# Per-batch `timeout` (minutes) tiers the cold-seeding budget by measured cost — see the
|
||||
# cold-run measurements in the comment above. Batches without the key default to 180.
|
||||
# a/b (large isolated modules) + c/d (4 and 3 modules, observed to exceed 180min cold) get
|
||||
# 350 (GitHub-hosted job max is 360); g/h (142/132min cold — within runner-variance distance
|
||||
# of the 180 cap) get a 240 buffer. e/f/i (33-66min) stay at the 180 default.
|
||||
- name: a
|
||||
mutate: "src/sse/services/auth.ts"
|
||||
timeout: 350
|
||||
- name: b
|
||||
mutate: "open-sse/services/accountFallback.ts"
|
||||
timeout: 350
|
||||
- name: c
|
||||
mutate: "src/server/authz/routeGuard.ts,src/shared/utils/circuitBreaker.ts,open-sse/utils/error.ts,open-sse/utils/publicCreds.ts"
|
||||
timeout: 350
|
||||
# Per-batch `timeout` (minutes) tiers the cold-seeding budget by measured cost; batches
|
||||
# without the key default to 180. Cold-run profiling (run 27801802713) showed the
|
||||
# widely-covered modules need FAR more than the 180 cap because the tap-runner re-runs every
|
||||
# covering test file per mutant: auth ~375min (2301 mutants, EXCEEDS the 360min GitHub job
|
||||
# max even isolated), accountFallback ~358min, the c security quartet ~348min — none fit a
|
||||
# single job. So auth/accountFallback are split by MUTATION RANGE (`file:startLine-endLine`,
|
||||
# ~half the mutants each) into a1/a2, b1/b2; the c quartet is split by MODULE pair into
|
||||
# c1/c2. d (3 combo modules, ~197min) stays whole. Split-heavy batches get 300min headroom
|
||||
# for their cold seeding run; once each batch completes once and writes
|
||||
# stryker-incremental.json, later runs re-test only changed mutants and finish far faster.
|
||||
# g/h (142/132min cold) keep a 240 buffer; e/f/i (33-66min) keep the 180 default.
|
||||
- name: a1
|
||||
mutate: "src/sse/services/auth.ts:1-1109"
|
||||
timeout: 300
|
||||
- name: a2
|
||||
mutate: "src/sse/services/auth.ts:1110-2218"
|
||||
timeout: 300
|
||||
- name: b1
|
||||
mutate: "open-sse/services/accountFallback.ts:1-863"
|
||||
timeout: 300
|
||||
- name: b2
|
||||
mutate: "open-sse/services/accountFallback.ts:864-1726"
|
||||
timeout: 300
|
||||
- name: c1
|
||||
mutate: "src/server/authz/routeGuard.ts,src/shared/utils/circuitBreaker.ts"
|
||||
timeout: 300
|
||||
- name: c2
|
||||
mutate: "open-sse/utils/error.ts,open-sse/utils/publicCreds.ts"
|
||||
timeout: 300
|
||||
- name: d
|
||||
mutate: "open-sse/services/combo/comboStructure.ts,open-sse/services/combo/autoStrategy.ts,open-sse/services/combo/validateQuality.ts"
|
||||
timeout: 350
|
||||
timeout: 300
|
||||
- name: e
|
||||
mutate: "open-sse/services/combo/shadowRouting.ts,open-sse/services/combo/targetSorters.ts,open-sse/services/combo/comboPredicates.ts,open-sse/services/combo/rrState.ts,open-sse/services/combo/comboData.ts"
|
||||
- name: f
|
||||
|
|
@ -77,11 +96,15 @@ jobs:
|
|||
timeout: 240
|
||||
- name: i
|
||||
mutate: "open-sse/handlers/chatCore/telemetryHelpers.ts,open-sse/handlers/chatCore/memorySkillsInjection.ts,open-sse/handlers/chatCore/semanticCache.ts"
|
||||
# Per-batch budget: a/b/c/d override to 350min, g/h to 240min; the rest default to 180min.
|
||||
# `matrix.batch.timeout` is null for batches without the key -> `|| 180`.
|
||||
# Per-batch budget: split-heavy batches (a1/a2/b1/b2/c1/c2/d) override to 300min, g/h to 240min;
|
||||
# the rest default to 180min. `matrix.batch.timeout` is null for batches without the key -> `|| 180`.
|
||||
# NOTE: a1+a2 both mutate auth.ts (disjoint line ranges) and b1+b2 both mutate accountFallback.ts;
|
||||
# when merging the per-batch mutation.json for radiography/scores, same-file mutants from sibling
|
||||
# ranges must be UNIONED (scripts/check/check-mutation-ratchet.mjs::measureMutationScores and
|
||||
# scripts/quality/mutation-radiography.mjs both merge per file).
|
||||
timeout-minutes: ${{ matrix.batch.timeout || 180 }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
|
|
@ -109,3 +132,32 @@ jobs:
|
|||
path: reports/mutation/
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
# Aggregation gate (T3): each split batch emits a PARTIAL view of a mutated file
|
||||
# (auth.ts lives in a1+a2, accountFallback in b1+b2), so a PER-BATCH ratchet would
|
||||
# only ever see half a file vs the whole-file baseline. This job runs AFTER every
|
||||
# batch, downloads all reports, and ratchets the MERGED per-module scores
|
||||
# (check-mutation-ratchet UNIONS same-file mutants across reports) against the
|
||||
# dedicatedGate `mutationScore.*` floors in quality-baseline.json (seeded ~2pt below
|
||||
# the first full measurement). Blocking: a module dropping below its floor fails the
|
||||
# run. Missing reports (e.g. an artifact-upload flake) are skipped, never failed.
|
||||
mutation-ratchet:
|
||||
name: Mutation score ratchet (blocking)
|
||||
needs: stryker
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Download all mutation reports
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: mutation-report-*
|
||||
path: reports/all
|
||||
- name: Ratchet merged per-module mutation scores
|
||||
run: node scripts/check/check-mutation-ratchet.mjs reports/all/*/mutation.json --ratchet
|
||||
|
|
|
|||
2
.github/workflows/nightly-property.yml
vendored
2
.github/workflows/nightly-property.yml
vendored
|
|
@ -10,7 +10,7 @@ jobs:
|
|||
property-random-seed:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
|
|
|
|||
40
.github/workflows/nightly-resilience.yml
vendored
40
.github/workflows/nightly-resilience.yml
vendored
|
|
@ -12,7 +12,7 @@ jobs:
|
|||
name: Heap-growth gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
|
|
@ -26,7 +26,7 @@ jobs:
|
|||
name: Resilience chaos (fault injection)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
|
|
@ -40,7 +40,7 @@ jobs:
|
|||
name: k6 load/soak
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
|
|
@ -74,3 +74,37 @@ jobs:
|
|||
- name: Stop server
|
||||
if: always()
|
||||
run: kill "$(cat server.pid)" || true
|
||||
|
||||
a11y:
|
||||
name: A11y axe (nightly, freeze-and-alert)
|
||||
runs-on: ubuntu-latest
|
||||
# The Playwright webServer (`start` mode) builds Next via build-next-isolated.mjs and
|
||||
# boots the standalone server itself (waits on /api/monitoring/health, 15min webServer
|
||||
# timeout). Unlike the per-PR test-e2e job, this nightly job has no pre-built artifact,
|
||||
# so it self-builds — hence the generous job timeout. REQUIRE_AXE=1 makes the suite run
|
||||
# the real axe analysis (the 4 page tests are gated to nightly so per-PR e2e stays fast)
|
||||
# and makes the meta-test fail loudly if @axe-core/playwright ever goes missing.
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
REQUIRE_AXE: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v5.0.5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: playwright-chromium-${{ runner.os }}-
|
||||
- run: npx playwright install --with-deps chromium
|
||||
- name: Run axe a11y suite (self-building webServer)
|
||||
run: npx playwright test tests/e2e/a11y.spec.ts
|
||||
|
|
|
|||
2
.github/workflows/nightly-schemathesis.yml
vendored
2
.github/workflows/nightly-schemathesis.yml
vendored
|
|
@ -13,7 +13,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
|
|
|
|||
4
.github/workflows/npm-publish.yml
vendored
4
.github/workflows/npm-publish.yml
vendored
|
|
@ -55,7 +55,7 @@ jobs:
|
|||
packages: write # publish to npm.pkg.github.com
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Need full tag history to compare against highest semver when
|
||||
|
|
@ -202,7 +202,7 @@ jobs:
|
|||
id-token: write # npm provenance
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
|
|
|||
4
.github/workflows/opencode-plugin-ci.yml
vendored
4
.github/workflows/opencode-plugin-ci.yml
vendored
|
|
@ -32,7 +32,7 @@ jobs:
|
|||
matrix:
|
||||
node: ["22", "24"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
|
|
@ -49,7 +49,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
|
|
|
|||
4
.github/workflows/opencode-provider-ci.yml
vendored
4
.github/workflows/opencode-provider-ci.yml
vendored
|
|
@ -32,7 +32,7 @@ jobs:
|
|||
matrix:
|
||||
node: ["20", "22", "24"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
|
|
@ -48,7 +48,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
|
|
|
|||
2
.github/workflows/quality.yml
vendored
2
.github/workflows/quality.yml
vendored
|
|
@ -27,7 +27,7 @@ jobs:
|
|||
API_KEY_SECRET: ci-lint-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
|
|
|||
2
.github/workflows/scorecard.yml
vendored
2
.github/workflows/scorecard.yml
vendored
|
|
@ -21,7 +21,7 @@ jobs:
|
|||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
|
|
|||
2
.github/workflows/semgrep.yml
vendored
2
.github/workflows/semgrep.yml
vendored
|
|
@ -12,7 +12,7 @@ jobs:
|
|||
container:
|
||||
image: semgrep/semgrep
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run semgrep (advisory)
|
||||
|
|
|
|||
2
.github/workflows/wiki-sync.yml
vendored
2
.github/workflows/wiki-sync.yml
vendored
|
|
@ -37,7 +37,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
|
|
|
|||
|
|
@ -10,3 +10,4 @@ fi
|
|||
npx lint-staged
|
||||
node scripts/check/check-docs-sync.mjs
|
||||
npm run check:any-budget:t11
|
||||
node scripts/check/check-tracked-artifacts.mjs
|
||||
|
|
|
|||
9
.vscode/settings.json
vendored
9
.vscode/settings.json
vendored
|
|
@ -20,6 +20,15 @@
|
|||
},
|
||||
"git.ignoreLimitWarning": true,
|
||||
|
||||
// ─── Git: não adicionar os ~44 repos aninhados (9 worktrees + _references/* +
|
||||
// _mono_repo/* + _ideia/_tasks/.agents) ao Source Control. Era a causa do
|
||||
// "validando muito": um git status + watcher por repo. Só o repo raiz fica. ───
|
||||
"git.autoRepositoryDetection": false,
|
||||
"git.repositoryScanMaxDepth": 0,
|
||||
"git.detectSubmodules": false,
|
||||
"git.autofetch": false,
|
||||
"git.autorefresh": true,
|
||||
|
||||
// ─── Performance: não seguir o symlink de node_modules criado pelas worktrees ───
|
||||
// As worktrees em .worktrees/ apontam node_modules para o checkout principal;
|
||||
// sem isto a busca atravessa o symlink mesmo com node_modules excluído.
|
||||
|
|
|
|||
91
CHANGELOG.md
91
CHANGELOG.md
|
|
@ -4,6 +4,97 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — 2026-06-20
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **feat(dashboard): category (media serviceKind) filter on the providers page** — `/dashboard/providers` gains a media-category filter row (Image / Video / Music / Text→Speech / Speech→Text / Embedding) that composes with the existing search, free-only and "show configured only" filters. Membership is derived from the backend media registries (a provider that serves a kind is surfaced even if it never declared `serviceKinds`), keeping the UI in lockstep with the backend. ([#4240](https://github.com/diegosouzapw/OmniRoute/issues/4240))
|
||||
- **feat(combo): per-step account allowlist — scope a round-robin/weighted step to a subset of a provider's connections** — a combo model step can now carry a first-class account allowlist so a round-robin (or weighted) strategy is scoped to a chosen subset of a provider's connections (e.g. only `foo1`+`foo2` out of `foo1..foo4`) without hand-pinning one step per account. Empty = the whole active pool (unchanged). When a step both has an allowlist and is tag-routed, the two intersect (most-restrictive wins); a single pinned account still takes precedence. The combo builder's Precision step editor gains an optional "Restrict to accounts" picker. ([#3266](https://github.com/diegosouzapw/OmniRoute/issues/3266))
|
||||
- **feat(providers): add OpenAdapter, dit.ai and TokenRouter as OpenAI-compatible providers** — three community-requested OpenAI-compatible aggregators now register as standard named OpenAI-style providers with live `/v1/models` discovery (the zenmux pattern), falling back to a seeded catalog when the upstream list is unavailable: **OpenAdapter** (`https://api.openadapter.in/v1`, free tier, 70+ open-source models — [#4239](https://github.com/diegosouzapw/OmniRoute/issues/4239)), **dit.ai** (`https://api.dit.ai/v1`, dynamic-pricing router/gateway — [#4155](https://github.com/diegosouzapw/OmniRoute/issues/4155)), and **TokenRouter** (`https://api.tokenrouter.com/v1`, free MiniMax model — [#3841](https://github.com/diegosouzapw/OmniRoute/issues/3841), thanks @FerLuisxd). No custom executor/translator — default OpenAI passthrough.
|
||||
- **feat(api): `x-omniroute-no-memory` request header — per-request opt-out of memory/skills injection** — clients that manage their own context (e.g. their own RAG/memory) can send `x-omniroute-no-memory: true` (mirrors the existing `x-omniroute-no-cache` convention) to skip the gateway injecting up to `memorySettings.maxTokens` (~2k) tokens of memory **and** skills context into that chat request — avoiding the token/cost inflation it otherwise adds on every call. Absent the header, behavior is unchanged. (PRD-2026-06-19-no-memory-header)
|
||||
- **feat(dashboard): MITM tool card lists the exact hosts-file entries to add manually** — the CLI-tools MITM card's "How it works" section now lists the full set of `127.0.0.1 <host>` lines for the selected tool (sourced from the canonical MITM target registry) instead of a single example domain. Users on locked-down machines — where the automatic, sudo-gated hosts-file edit isn't available — can now copy every required entry by hand. (thanks @mrcyclo)
|
||||
- **feat(cli): `omniroute launch-codex` + `setup-codex` — run/configure the Codex CLI against OmniRoute** — a launcher and setup command that point the Codex CLI at an OmniRoute endpoint (remote-mode aware). ([#4270](https://github.com/diegosouzapw/OmniRoute/pull/4270))
|
||||
- **feat(cli): Claude Code launcher + setup — remote mode + profiles** — `omniroute launch`/`setup` for Claude Code with remote-mode support and named connection profiles. ([#4274](https://github.com/diegosouzapw/OmniRoute/pull/4274))
|
||||
- **feat(cli): OpenCode setup — OpenAI-compatible provider + remote-aware plugin** — `setup-opencode` registers OmniRoute as an OpenAI-compatible provider for OpenCode and installs a remote-aware plugin. ([#4277](https://github.com/diegosouzapw/OmniRoute/pull/4277))
|
||||
- **feat(cli): one-command setup for popular AI coding tools** — new `setup-*` commands that configure each tool to talk to OmniRoute: **Cline** ([#4280](https://github.com/diegosouzapw/OmniRoute/pull/4280)), **Kilo Code** ([#4284](https://github.com/diegosouzapw/OmniRoute/pull/4284)), **Continue** ([#4289](https://github.com/diegosouzapw/OmniRoute/pull/4289)), **Cursor** ([#4291](https://github.com/diegosouzapw/OmniRoute/pull/4291)), **Roo Code** ([#4292](https://github.com/diegosouzapw/OmniRoute/pull/4292)), **Crush** ([#4298](https://github.com/diegosouzapw/OmniRoute/pull/4298)), **Goose** ([#4300](https://github.com/diegosouzapw/OmniRoute/pull/4300)), **Qwen Code** ([#4301](https://github.com/diegosouzapw/OmniRoute/pull/4301)), **Aider** ([#4302](https://github.com/diegosouzapw/OmniRoute/pull/4302)) and the **Gemini CLI** (native `/v1beta`) ([#4303](https://github.com/diegosouzapw/OmniRoute/pull/4303)).
|
||||
- **feat(providers): provider model sweep — live discovery, refreshed catalogs, dead-provider cleanup** — a broad sweep that enables live `/v1/models` discovery for more OpenAI-style providers (the zenmux pattern), refreshes the seeded catalogs with current models, and marks dead providers `deprecated`. ([#4324](https://github.com/diegosouzapw/OmniRoute/pull/4324))
|
||||
- **feat(mitm): translate Antigravity cloudcode end-to-end (Gap B)** — the MITM decrypt path now translates Antigravity `cloudcode` traffic end-to-end. ([#4299](https://github.com/diegosouzapw/OmniRoute/pull/4299))
|
||||
- **feat(keys): per-key USD usage quota controls** — an API key can now carry a USD spend quota that caps its usage once the threshold is reached. ([#4327](https://github.com/diegosouzapw/OmniRoute/pull/4327) — thanks @Witroch4)
|
||||
|
||||
### 🔧 Changed
|
||||
|
||||
- **change(memory): memory is now OFF by default** — `DEFAULT_MEMORY_SETTINGS.enabled` now defaults to `false`. Enabling memory injects up to ~2,000 tokens of retrieved context into **every** chat request (and that context is billed), which was a surprising default for new installs and for clients with their own context. Memory is now an explicit opt-in: installs that already enabled it keep it on; installs that never configured it default to off. The Settings → Memory panel now shows a token-cost warning when memory is enabled. (PRD-2026-06-19-no-memory-header)
|
||||
|
||||
### 🐛 Fixed
|
||||
|
||||
- **fix(compliance): startup cleanup honors the dashboard data-retention setting instead of always trimming to 7 days** — on every restart, `cleanupExpiredLogs()` (run at startup) read retention only from the `CALL_LOG_RETENTION_DAYS` / `APP_LOG_RETENTION_DAYS` env vars, which default to **7 days** when unset, and trimmed `usage_history` (the Usage Analysis data) before the dashboard-based `runAutoCleanup()` — which respects the configured retention — ever ran. So a dashboard "Data Retention" of 90 days was silently overridden and the Usage Analysis page only ever showed the last 7 days after a restart. Retention now follows the precedence **explicit env var → dashboard DB setting → 7-day default**, per table (`usage_history`→`usageHistory`, `call_logs`/`proxy_logs`/`request_detail_logs`→`callLogs`, `mcp_tool_audit`→`mcpAudit`); an operator who sets the env var still wins, and non-DB deployments still fall back to it. ([#4354](https://github.com/diegosouzapw/OmniRoute/issues/4354) — thanks @akbardwi)
|
||||
- **fix(providers): bailian-coding-plan static fallback catalog matches the registry (10 models)** — the provider-model sweep (#4324) added four current Model Studio coding-plan models (`qwen3.7-plus`, `qwen3-coder-plus`, `qwen3-coder-next`, `glm-4.7`) to the `bailian-coding-plan` registry entry but missed the static fallback mirror in `staticModels.ts`, which still listed only the older six. The static catalog (served when live discovery is unavailable) therefore diverged from the registry, and the existing static↔registry parity test went red on the release branch (only surfacing when test-impact analysis happened to select it). The static mirror now carries all ten models in registry order, restoring parity. ([#4324](https://github.com/diegosouzapw/OmniRoute/pull/4324))
|
||||
- **fix(executors): ArenaLLM accepts LMArena's split Supabase SSR auth cookie** — LMArena migrated to `@supabase/ssr` chunked auth cookies: the single `arena-auth-prod-v1` cookie is now empty and the real session is split across `arena-auth-prod-v1.0`, `arena-auth-prod-v1.1`, … (ascending). A user who pasted the (now-empty) single cookie therefore sent an empty session and upstream rejected it as "invalid cookie". The LMArena executor now reconstructs the single cookie from its chunks — reading `.0`, `.1`, … in ascending numeric order until one is missing and concatenating their raw values (`@supabase/ssr`'s `combineChunks` rule: plain `join("")`, no base64-decode, no JSON-parse, the `base64-` prefix kept verbatim) — while preserving the rest of the pasted jar. A non-empty single cookie is still forwarded unchanged (back-compat). The credential UX now instructs pasting the **full Cookie header** and tracks the `.0`/`.1` storage keys. ([#4271](https://github.com/diegosouzapw/OmniRoute/issues/4271) — thanks @caussao)
|
||||
- **fix(compression): preserve the cacheable prefix for automatic-cache providers** — OpenAI / Codex (and Azure-OpenAI) use _automatic_ prefix caching: the upstream caches the longest matching prefix of a request (system prompt + earliest messages) **without** any explicit `cache_control` markers in the body. The cache-aware compression guard only protected that prefix when the request carried explicit `cache_control`, so for automatic-cache providers the guard was skipped — and with compression enabled and `preserveSystemPrompt: false` (or a prefix-compressing mode like `aggressive`/`ultra`) it rewrote the system prompt / earliest messages, guaranteeing a cache miss and **higher** token spend through OmniRoute than going direct. The guard now treats a caching provider as sufficient on its own (`isCachingProvider` alone, independent of `cache_control`) to skip the system prompt and downgrade prefix-compressing modes, and OpenAI/Codex/Azure are now recognized as caching providers. Compression is still off by default — this only affects operators who enabled it with prefix preservation turned off. ([#3955](https://github.com/diegosouzapw/OmniRoute/issues/3955))
|
||||
- **fix(executors): DuckDuckGo AI Chat uses duckduckgo.com (fixes 400)** — the DuckDuckGo AI Chat executor fetched status/chat and set `Origin`/`Referer` against `https://duck.ai` while still sending `Sec-Fetch-Site: same-origin`, so the request's same-origin triplet (host + Origin + Referer) was inconsistent and the backend rejected it with HTTP 400. All current DDG reverse-engineering references — and the provider registry's own `baseUrl` — use `https://duckduckgo.com`; the executor now uses it consistently for the status URL, chat URL, `Origin`, and `Referer` (the same-origin header is now coherent). The `x-fe-version` scrape regex also required a 40-hex tail but the real served token has a 20-hex tail (e.g. `serp_20250401_100419_ET-19d438eb199b2bf7c300`), so it silently fell back to a hardcoded default; the pattern is relaxed to a bounded `{20,40}` tail (still ReDoS-safe). This addresses the DuckDuckGo half of the report; the separate Chipotle/`chipotle` upstream breakage is tracked independently. ([#4037](https://github.com/diegosouzapw/OmniRoute/issues/4037) — thanks @daniij)
|
||||
- **fix(security): bound the prompt-injection scan to the first 16 KB (hot-path perf)** — the prompt-injection guard joined every message/system string into one buffer and ran several regexes over the **whole** thing on every chat request, with no size cap — so a 300 KB body (pasted code, RAG context) meant O(body) CPU scanning on the hot path, a self-inflicted latency/GC source under concurrency. Both detection call sites (`detectInjection` in `inputSanitizer.ts` and the custom-pattern scan in `promptInjection.ts`) now slice the joined text to the first **16 KB** (`MAX_INJECTION_SCAN_BYTES`) before the regex loop. Injection directives sit near the top of a prompt, so the generous cap preserves real detection while scanning only a bounded prefix; the existing 10 MB body-size cap (which protects ingestion) is unchanged. ([#3932](https://github.com/diegosouzapw/OmniRoute/issues/3932) — thanks @KooshaPari)
|
||||
- **fix(sse): retry direct-connection socket failures on a fresh socket (fewer `502` bursts)** — the default direct-connection undici dispatcher pools keep-alive sockets for up to 4 s, but some edges (e.g. `nvidia`, `opencode-zen`) silently close idle keep-alive sockets within that window, so the next request reusing a pooled socket fails with `UND_ERR_SOCKET` ("other side closed") — in bursts. `proxyFetch` already retried once on such transient errors, but the retry reused the **same** pooled dispatcher and could grab another stale socket, then fell through to native fetch (which also pools) → the job sat in the rate-limit queue until the 30 s timeout → `502` + circuit-breaker open. The retry now uses a dedicated **no-keep-alive / no-pipelining** dispatcher so it opens a brand-new socket that can't be a dead pooled one; the first attempt still uses the pooled dispatcher (healthy keep-alive reuse is preserved). Complements the v3.8.29 diagnostics (`describeFetchCause`, #4281). ([#4252](https://github.com/diegosouzapw/OmniRoute/issues/4252) — thanks @klimadev)
|
||||
- **fix(sse): combo now stops at the first body-specific 400 instead of trying every target** — the `#2101` guard that detects a body-specific 400 (context overflow / malformed / model-access-denied, e.g. "model is not supported when using Codex with a ChatGPT account") logged "stopping combo" but executed a bare `break`, which only exited the inner retry loop; `executeTarget` then returned `null` and the outer target loop treated that as "this target produced nothing" and advanced to the next model. A combo of N targets that all reject the same request body therefore marched through all N (the report shows a 143-model Codex combo iterating every target), wasting upstream calls and per-attempt work. The guard now surfaces the 400 via the `{ ok, response }` contract (mirroring the 499 client-disconnect path) so the combo resolves and stops immediately. ([#4279](https://github.com/diegosouzapw/OmniRoute/issues/4279))
|
||||
- **fix(sse): non-streaming combo over a Responses-API target no longer returns empty content** — a Responses-API target (codex/`cx`) streams from upstream even on `stream:false`, and its terminal `response.completed` snapshot can carry a non-empty `output` that lacks the assistant message item (e.g. only a `reasoning` item) while the streamed `output_text` deltas had reconstructed the full message. The SSE→JSON aggregator preferred the terminal `output` wholesale, dropping the reconstructed text → HTTP 200 with empty content (hit notably via n8n, which defaults to `stream:false`). The aggregator now falls back to the reconstructed delta output when the terminal output has no message item but the reconstruction does; the terminal snapshot still wins whenever it already carries the message. ([#3948](https://github.com/diegosouzapw/OmniRoute/issues/3948))
|
||||
- **fix(executors): preserve tool-name casing on native Claude OAuth (`read` no longer leaks back as `Read`)** — native Claude OAuth traffic runs through an anti-fingerprint tool-name cloak that renames a tool literally named `read` to `Read` on the wire and records the reverse alias on a non-enumerable `_toolNameMap`, which the response side uses to restore the client's original casing. Since v3.8.27 the executor returned a JSON-round-tripped copy of the body as `transformedBody`, and that round-trip dropped the non-enumerable map — so the restore saw an empty map and the cloaked `Read` streamed verbatim to the client, corrupting the tool name. The executor now re-attaches the cloak map onto the serialized body (mirroring the Antigravity executor), so tool-name casing round-trips correctly. ([#4307](https://github.com/diegosouzapw/OmniRoute/issues/4307) — thanks @dev-cj)
|
||||
- **fix(api): cache-HIT `X-OmniRoute-Response-Cost` now reports the incremental cost (≈0), not the original** — on a semantic-cache HIT the gateway serves the stored response **without** an upstream call, but `X-OmniRoute-Response-Cost` was reporting the original call's full cost (recomputed from the cached `usage`). A consumer summing `response-cost` for billing was therefore charging for responses that cost ≈$0 to serve (and stale entries could inflate it). Cache hits now bill `X-OmniRoute-Response-Cost: 0.0000000000` (the real incremental cost), and the avoided cost is surfaced in a new **`X-OmniRoute-Cost-Saved`** header for cache analytics — mirroring the existing `tokens_saved` concept. The MISS path is unchanged. (PRD-2026-06-19-cache-hit-cost-reporting)
|
||||
- **fix(models): imported vision-capable models keep their vision capability** — after importing a provider key, vision-capable models (e.g. OpenRouter models whose `architecture` declares image input, and other synced providers) were listed as text-only in `/v1/models` and the dashboard — even though image requests actually worked. Synced model records never captured the vision flag, and the catalog's OpenRouter live-enrichment (which derives vision from `architecture.input_modalities`) is skipped once a provider has synced models. Discovery now captures `supportsVision` at sync time (from `architecture.input_modalities`, the string `architecture.modality`, or a top-level `input_modalities`), mirroring the existing `supportsThinking` capture, and the catalog surfaces `capabilities.vision` for synced models. ([#4264](https://github.com/diegosouzapw/OmniRoute/issues/4264) — thanks @FerLuisxd)
|
||||
- **fix(providers): Cloudflare Workers AI model discovery shows model names, not UUIDs** — importing a Cloudflare Workers AI key listed models with internal UUID identifiers (e.g. `429b9e8b-d99e-…`) instead of their usable slugs (`@cf/meta/llama-3.1-8b-instruct`). Cloudflare's `/ai/models/search` returns `{ id: "<uuid>", name: "@cf/…" }`, and discovery was passing the raw objects through — so the UUID `id` became the callable model id. The `cloudflare-ai` discovery now maps each result's `name` → id, surfacing the real `@cf/…` model ids. ([#4259](https://github.com/diegosouzapw/OmniRoute/issues/4259) — thanks @FerLuisxd)
|
||||
- **fix(translator): clamp Responses API `call_id` to 64 characters** — the OpenAI Responses API rejects `call_id` values longer than 64 characters with a 400. Long upstream tool-call ids (some clients emit ids well over the limit) are now clamped deterministically on both the `function_call` item and its matching `function_call_output`, so the pair stays matched through the orphaned-output filter and the request is accepted. (thanks @anuragg-saxenaa, @ngapngap)
|
||||
- **fix(oauth): GitHub Copilot token refresh now sends the public client_id** — the `github` provider config never carried a `clientId`, so GitHub OAuth `refresh_token` exchanges either omitted `client_id` or sent the literal string `undefined` (and a bogus `client_secret=undefined`), which GitHub rejects — leaving a Copilot connection stuck once its short-lived token expired and the long-lived refresh path was needed. The provider now resolves its public device-flow `client_id` from the embedded public credential and omits `client_secret` entirely (GitHub's Copilot app is a public client with no secret). (thanks @baslr)
|
||||
- **fix(translator): a tool property named `pattern` survives Gemini/Antigravity schema sanitization** — the Gemini schema sanitizer strips JSON-Schema constraint keywords Gemini rejects (`pattern`, `minLength`, …) at every nesting level, but it also deleted any tool **property** literally *named* one of those keywords. glob/grep tools declare a property called `pattern`, so on `ag/*` (Antigravity) backends that argument (and its `required` entry) was silently dropped, breaking the tools. Keyword stripping is now position-aware: it only removes constraint keywords at the schema-node level and never against the user-defined names inside a `properties` map. A genuine string-level `pattern` *constraint* is still stripped. (thanks @youthanh)
|
||||
- **fix(translator): MCP `namespace` tools flatten to individual functions on the Responses→Chat path** — when a Codex CLI client routes a Responses-API request to a non-Codex backend (e.g. `kr/claude-opus-4.7`), each MCP server is declared as a `namespace` tool (`{ type:"namespace", name, tools:[…] }`). The Responses→Chat translator had no `namespace` branch, so the whole group collapsed into a single empty-schema function named `mcp__<server>__` and every MCP call returned `unsupported call: mcp__<server>__`, breaking all MCP-based workflows (context7, codegraph, custom MCPs) for that combination. The translator now expands a namespace into one Chat function per sub-tool (preserving each sub-tool's name and parameters); an empty namespace yields no tools instead of a broken placeholder. The native Codex passthrough path was already correct. (thanks @V13t4nh)
|
||||
- **fix(cli): the active remote-context credential wins over an ambient `OMNIROUTE_API_KEY`** — when a remote context is selected, its scoped access token now takes precedence over an `OMNIROUTE_API_KEY` present in the environment, so the connected remote is targeted as expected. ([#4364](https://github.com/diegosouzapw/OmniRoute/pull/4364))
|
||||
- **fix(cli): wire the `contexts` command into the CLI program** — the `omniroute contexts` command (list/switch saved remote contexts) was implemented but never registered, so it was unreachable; it is now wired into the CLI program. ([#4369](https://github.com/diegosouzapw/OmniRoute/pull/4369))
|
||||
- **fix(mitm): mask bare `Bearer <token>` header values in the Traffic Inspector** — the inspector now redacts bare `Authorization: Bearer …` values so tokens don't leak into captured traffic. ([#4358](https://github.com/diegosouzapw/OmniRoute/pull/4358))
|
||||
- **fix(pricing): price the `gpt-5.x-pro` OpenAI models + align the opencode-go discovery test** — adds pricing for the gpt-5.x-pro models so cost telemetry reports a real cost instead of zero. ([#4355](https://github.com/diegosouzapw/OmniRoute/pull/4355))
|
||||
- **fix(sse): release the reader and cancel the stream on abort/error (no more Undici pool socket leak)** — on abort or a mid-stream error the response reader is released and the stream cancelled, preventing leaked pooled sockets that degraded later requests. ([#4309](https://github.com/diegosouzapw/OmniRoute/pull/4309) — thanks @Ardem2025)
|
||||
- **fix(kiro): emit an early role-only start chunk to release the stream-readiness gate** — Kiro streams now send an initial role-only chunk so the stream-readiness gate releases promptly instead of stalling. ([#4311](https://github.com/diegosouzapw/OmniRoute/pull/4311) — thanks @artickc)
|
||||
- **fix(dashboard): the proxy modal stops pre-filling new scopes with an unrelated proxy** — adding a new scope assignment no longer inherits a previously-selected proxy's configuration. ([#4312](https://github.com/diegosouzapw/OmniRoute/pull/4312))
|
||||
- **fix(open-sse): inner-ai stops silently rerouting unmatched models to `models[0]`** — an unmatched model id is no longer silently served by the first available model; the lookup now returns null and the request is handled explicitly. ([#4310](https://github.com/diegosouzapw/OmniRoute/pull/4310))
|
||||
- **fix(pollinations): handle auth-required premium models (claude, gemini, midjourney)** — premium Pollinations models that require authentication are now handled correctly instead of failing. ([#4266](https://github.com/diegosouzapw/OmniRoute/pull/4266) — thanks @oyi77)
|
||||
- **fix(codex): isolate the Spark quota scope** — Codex Spark usage is tracked under its own quota scope so it no longer bleeds into other Codex quotas. ([#4293](https://github.com/diegosouzapw/OmniRoute/pull/4293) — thanks @xz-dev)
|
||||
- **fix(dashboard): improve the API "try it" functionality** — fixes the request path used by the dashboard's API "try it" panel. ([#4296](https://github.com/diegosouzapw/OmniRoute/pull/4296) — thanks @edrickrenan)
|
||||
- **fix: polyfill `crypto.randomUUID` for non-secure contexts** — restores UUID generation when the dashboard is served over a non-secure (plain-HTTP) origin where `crypto.randomUUID` is unavailable. ([#4287](https://github.com/diegosouzapw/OmniRoute/pull/4287) — thanks @pizzav-xyz)
|
||||
- **fix(proxy): allow concurrent proxy dispatcher streams** — the proxy dispatcher no longer serializes streams, so concurrent requests through a proxied connection run in parallel. ([#4288](https://github.com/diegosouzapw/OmniRoute/pull/4288) — thanks @wilsonicdev)
|
||||
- **fix(build): co-locate llmlingua SLM optionals into `dist/node_modules` (postinstall)** — the optional llmlingua SLM packages are co-located into the standalone build so the compression worker can actually spawn in production. ([#4286](https://github.com/diegosouzapw/OmniRoute/pull/4286))
|
||||
- **fix(mitm): surface AgentBridge traffic in the Traffic Inspector (D4 ingest)** — AgentBridge requests now appear in the Traffic Inspector. ([#4285](https://github.com/diegosouzapw/OmniRoute/pull/4285))
|
||||
- **fix(sse): surface undici `err.cause` on dispatcher failure** — dispatcher failures now flatten the cause chain (and `AggregateError`s) into the error detail for diagnosability. ([#4281](https://github.com/diegosouzapw/OmniRoute/pull/4281))
|
||||
- **fix(cli): harden `launch`/`launch-codex` with free-claude-code patterns** — the launchers adopt the hardened launch patterns ported from free-claude-code. ([#4278](https://github.com/diegosouzapw/OmniRoute/pull/4278))
|
||||
- **fix(compression): end-to-end audit — fixes across the whole compression flow** — a sweep of the compression pipeline fixing ultra/aggressive/lossless edge cases, accessibility-anchor handling, language detection, and mode decoupling. ([#4323](https://github.com/diegosouzapw/OmniRoute/pull/4323))
|
||||
|
||||
### 🧪 Tests
|
||||
|
||||
- **test: align two tests left red by merged PRs** — re-aligns the db-rules classification count (#4335) and the LMArena split-cookie metadata test (#4271) after concurrent merges. ([#4346](https://github.com/diegosouzapw/OmniRoute/pull/4346))
|
||||
- **test(ci): reconcile the release/v3.8.30 baseline + test drift** — reconciles quality baselines and drifted tests accumulated on the release branch. ([#4276](https://github.com/diegosouzapw/OmniRoute/pull/4276))
|
||||
|
||||
### 📝 Maintenance
|
||||
|
||||
- **refactor(combo): `ComboContext` + extract `phaseComboSetup` (god-file split, phase 1)** — begins decomposing the combo god-file by extracting combo setup into a context object, without touching dispatch/semaphore logic. ([#4326](https://github.com/diegosouzapw/OmniRoute/pull/4326))
|
||||
- **feat(quality): cap test-file size — anti-reinflation Layer 1** — freezes the existing god-tests and caps new test files at 800 lines to stop re-inflation. ([#4273](https://github.com/diegosouzapw/OmniRoute/pull/4273))
|
||||
- **feat(quality): seed per-module mutationScore floors + a blocking aggregation ratchet (T3)** — adds per-module mutation-score floors with a blocking aggregate gate. ([#4305](https://github.com/diegosouzapw/OmniRoute/pull/4305))
|
||||
- **feat(quality): make the a11y gate real (`@axe-core/playwright` in nightly)** — wires the previously-phantom accessibility gate into the nightly run with real baselines. ([#4321](https://github.com/diegosouzapw/OmniRoute/pull/4321))
|
||||
- **feat(quality): unblock R1 — test-redundancy measurement via `disableBail`** — enables the test-redundancy measurement that was previously blocked by fail-fast. ([#4322](https://github.com/diegosouzapw/OmniRoute/pull/4322))
|
||||
- **fix(quality): the complexity gate now covers `bin/` + `electron/`, and tracked-artifacts runs in pre-commit** — extends the complexity gate's scope and moves the tracked-artifacts check into the pre-commit hook. ([#4318](https://github.com/diegosouzapw/OmniRoute/pull/4318))
|
||||
- **fix(quality): restore release/v3.8.30 green — 3 latent reds from concurrent merges** — fixes three latent test reds surfaced by concurrent merges into the release branch. ([#4335](https://github.com/diegosouzapw/OmniRoute/pull/4335))
|
||||
- **fix(combo): keep `phaseComboSetup` under the complexity ceiling** — extracts a helper so the new combo setup phase stays under the complexity gate. ([#4338](https://github.com/diegosouzapw/OmniRoute/pull/4338))
|
||||
- **ci(mutation): split over-budget batches by range/pair so every batch fits the job cap** — re-splits the mutation batches so each fits the CI job budget. ([#4272](https://github.com/diegosouzapw/OmniRoute/pull/4272))
|
||||
- **chore(ci): align the electron audit gate to the root advisory policy** — the electron-workspace audit gate now follows the same advisory policy as the root. ([#4275](https://github.com/diegosouzapw/OmniRoute/pull/4275))
|
||||
- **chore(quality): reconcile the complexity/quality baselines across concurrent-merge drift** — rolls up the cycle's baseline reconciliations driven by concurrent merges into the release branch. ([#4330](https://github.com/diegosouzapw/OmniRoute/pull/4330), [#4336](https://github.com/diegosouzapw/OmniRoute/pull/4336), [#4370](https://github.com/diegosouzapw/OmniRoute/pull/4370))
|
||||
- **docs: ban AI-generation footers in commits/PRs/CHANGELOG (Hard Rule #16)** — codifies the prohibition on AI-generation footers and bot co-author trailers. ([#4328](https://github.com/diegosouzapw/OmniRoute/pull/4328))
|
||||
- **docs(design): add the OmniRoute design system and visual identity specification** — adds the design-system / visual-identity specification document. (thanks @diegosouzapw)
|
||||
|
||||
### 🔒 Security
|
||||
|
||||
- **fix(sse): harden the DuckDuckGo lite scraper sanitization (CodeQL)** — closes four HIGH CodeQL alerts in the no-key web-search scraper: `decodeEntities` now resolves `&` **last** so an already-escaped entity (e.g. `&lt;`) survives as literal text instead of being double-unescaped (`js/double-escaping`); `stripTags` decodes entities first, then strips tags in a loop to a fixpoint and drops any trailing unclosed `<…`, so entity-encoded markup like `<script>` can never reach the LLM/client as a live tag (`js/incomplete-multi-character-sanitization`); and the host checks in the search tests use `new URL().hostname` equality instead of substring `.includes` (`js/incomplete-url-substring-sanitization`). ([#4356](https://github.com/diegosouzapw/OmniRoute/pull/4356))
|
||||
|
||||
### 🔧 Dependencies
|
||||
|
||||
- **fix(deps): bump undici to 7.28.0 and dompurify to 3.4.11 (security)** — addresses the undici SOCKS5-TLS / cache advisories and the dompurify advisory. ([#4306](https://github.com/diegosouzapw/OmniRoute/pull/4306))
|
||||
- **chore(deps): bump actions/checkout from 4 to 7** — CI checkout-action update. ([#4297](https://github.com/diegosouzapw/OmniRoute/pull/4297))
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -501,7 +501,7 @@ the stale-enforcement added in Fase 6A.3.
|
|||
13. Never string-interpolate external paths or runtime values into shell scripts passed to `exec()`/`spawn()` — pass via the `env` option instead. Reference: `src/mitm/cert/install.ts::updateNssDatabases`.
|
||||
14. Never dismiss a CodeQL / Secret-Scanning alert without (a) first checking the pattern docs above to see if the helper applies, and (b) recording the technical justification in the dismissal comment. Precedent: `js/stack-trace-exposure` raised on callsites that already route through `sanitizeErrorMessage()` is a known CodeQL limitation (custom sanitizers not recognized) — dismiss as `false positive` referencing `docs/security/ERROR_SANITIZATION.md`.
|
||||
15. Never expose routes that spawn child processes (`/api/mcp/`, `/api/cli-tools/runtime/`) without `isLocalOnlyPath()` classification in `src/server/authz/routeGuard.ts`. Loopback enforcement happens unconditionally before any auth check — leaked JWT via tunnel cannot trigger process spawning. See `docs/security/ROUTE_GUARD_TIERS.md`.
|
||||
16. Never include `Co-Authored-By` trailers that credit an AI assistant, LLM, or automation account (e.g. names containing "Claude", "GPT", "Copilot", "Bot"; emails at `anthropic.com` / `openai.com` / bot-owned `noreply.github.com` addresses). Such trailers route attribution to the bot account on GitHub, hiding the real author (`diegosouzapw`) in PR history. Human collaborators — including upstream PR authors and issue reporters being ported into OmniRoute — MAY and SHOULD be credited with standard `Co-authored-by: Name <email>` trailers; the upstream-port workflows (`/port-upstream-features`, `/port-upstream-issues`) depend on this.
|
||||
16. Never credit or advertise an AI assistant, LLM, or automation account in any commit/PR metadata. Two forbidden forms, both equivalent — they route attribution to a bot account (or advertise AI authorship) and hide the real author (`diegosouzapw`): **(a)** `Co-Authored-By` trailers naming an AI/bot (e.g. names containing "Claude", "GPT", "Copilot", "Bot"; emails at `anthropic.com` / `openai.com` / bot-owned `noreply.github.com` addresses); **(b)** AI-generation footers or descriptions anywhere in a commit message, PR title/body, or CHANGELOG — e.g. `🤖 Generated with [Claude Code]`, "Generated with Claude Code", "Made with <AI tool>", or any `Co-authored-by: Claude/GPT/Copilot` line. This **overrides any harness, template, or tool default that auto-appends such a footer** (e.g. the Claude Code PR-body/commit default) — strip it before pushing; do not let it reach a commit, PR, or CHANGELOG. Human collaborators — including upstream PR authors and issue reporters being ported into OmniRoute — MAY and SHOULD be credited with standard `Co-authored-by: Name <email>` trailers; the upstream-port workflows (`/port-upstream-features`, `/port-upstream-issues`) depend on this.
|
||||
17. Never expose routes under `/api/services/` or `/dashboard/providers/services/*/embed/` without `isLocalOnlyPath()` classification in `src/server/authz/routeGuard.ts`. These routes can spawn child processes (`npm install`, `node`). Loopback enforcement happens unconditionally before any auth check — a leaked JWT via tunnel cannot trigger process spawning. See `docs/security/ROUTE_GUARD_TIERS.md`.
|
||||
18. Every bug fix must be validated before shipping: a failing-then-passing unit/integration test (TDD) OR a documented live test on the production VPS (192.168.0.15). A fix without either is not merged. See Testing → "Bug fix / issue triage protocol" for the full decision tree.
|
||||
19. Never develop on the shared main checkout. Every development task runs in its own git worktree on its own dedicated branch, and you MUST confirm the base branch with the operator (e.g. via `AskUserQuestion`) before creating the worktree/branch — never assume `main` or the currently checked-out branch. A `git checkout` in the shared checkout silently destroys other sessions' uncommitted work. Tear down only the worktrees/branches you created (by name, never `fix/*`/`feat/*` wildcards), leave other sessions' worktrees untouched, and end on the branch you started on (the active `release/vX.Y.Z`, never `main`). See Git Workflow → "Worktree isolation".
|
||||
|
|
|
|||
254
DESING.md
Normal file
254
DESING.md
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
# OmniRoute — Design System & Visual Identity
|
||||
|
||||
> **Status:** analysis + standardization plan (no code applied yet — this doc is the spec to approve before implementation).
|
||||
> **Date:** 2026-06-16 · **Scope:** unify the OmniRoute dashboard (`src/`) with the marketing site (`_mono_repo/omnirouteSite/`) into **one visual identity** — same graph-paper grid background, same color tokens, standardized components.
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
The marketing site (`viral.omniroute.online`, `why.omniroute.online`, `omniroute.online`) and the product dashboard should look like **one product**. The site already borrowed its palette from the dashboard — its `css/tokens.css` even says _"Palette mirrors the OmniRoute dashboard (src/app/globals.css)"_. So the two are already ~80% aligned at the color level. What's missing on the dashboard:
|
||||
|
||||
1. The **graph-paper grid wallpaper** the site uses on every page.
|
||||
2. A handful of **shared design tokens** the site has but the dashboard lacks (radius scale, brand gradient, `surface-2`, mono font).
|
||||
3. **Component-level consistency** — a number of dashboard components bypass the theme tokens with hardcoded hex/rgba.
|
||||
|
||||
This document is the analysis and the plan. **Nothing is changed until approved.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Principles
|
||||
|
||||
- **Single source of truth = `src/app/globals.css`.** The site mirrors the dashboard, never the other way around. New tokens land in `globals.css` first.
|
||||
- **Tokens, never literals.** Components consume semantic tokens (`bg-surface`, `text-primary`, `border-border`), never raw `#hex`.
|
||||
- **Subtle, not loud.** The grid is a faint wallpaper that sits behind content — it must never reduce text contrast or fight the UI.
|
||||
- **Theme-aware.** Everything works in both `.dark` (default-ish, the product's signature look) and light.
|
||||
- **Surgical rollout.** Ship the grid + tokens first (low risk, high visibility), then component cleanups in waves.
|
||||
|
||||
---
|
||||
|
||||
## 3. Current state — what's already aligned vs. what's not
|
||||
|
||||
### 3.1 Colors — already unified ✅
|
||||
|
||||
Every brand color and surface already matches the site **by value** (only the names differ — dashboard prefixes with `--color-`). Verified in `src/app/globals.css:30-128`:
|
||||
|
||||
| Concept | Site token (`tokens.css`) | Dashboard token (`globals.css`) | Match |
|
||||
| -------------------------- | ------------------------------------------- | ------------------------------- | ------------ |
|
||||
| primary | `--primary #e54d5e` | `--color-primary #e54d5e` | ✅ |
|
||||
| primary-hover | `--primary-hover #c93d4e` | `--color-primary-hover #c93d4e` | ✅ |
|
||||
| accent | `--accent #6366f1` | `--color-accent #6366f1` | ✅ |
|
||||
| accent-2 | `--accent-2 #8b5cf6` | `--color-accent-hover #8b5cf6` | ✅ (renamed) |
|
||||
| accent-3 | `--accent-3 #a855f7` | `--color-accent-light #a855f7` | ✅ (renamed) |
|
||||
| success / warning / error | `#22c55e / #f59e0b / #ef4444` | identical | ✅ |
|
||||
| traffic lights | `#ff5f56 / #ffbd2e / #27c93f` | identical | ✅ |
|
||||
| dark bg / surface / border | `#0b0e14 / #161b22 / rgba(255,255,255,.08)` | identical | ✅ |
|
||||
| light bg / surface / text | `#f9f9fb / #fff / #1a1a2e` | identical | ✅ |
|
||||
|
||||
**Conclusion:** there is no color migration to do. The identity is already shared; we are _finishing_ it, not rebuilding it.
|
||||
|
||||
### 3.2 Gaps — what the dashboard is missing
|
||||
|
||||
| Gap | Site has | Dashboard | Action |
|
||||
| ----------------------- | ------------------------------------------------------------------------------ | ---------------------------------------------------------- | ---------------------- |
|
||||
| **Grid wallpaper** | `body::before` graph-paper, `--grid-line`, `--grid-size 46px`, `--section-alt` | none (flat `--color-bg`) | **Part A** |
|
||||
| **Radius scale** | `--radius 14px`, `--radius-sm 9px` | none — primitives use ad-hoc `rounded-md/lg/xl` (6/8/12px) | **Part B** |
|
||||
| **Brand gradient** | `--grad-brand 135deg primary→accent-3` | none — only a one-off `.bg-hero-gradient` | **Part B** |
|
||||
| **Nested surface** | `--surface-2 #1c2230` | none | **Part B** |
|
||||
| **Mono font** | `--font-mono` (ui-monospace stack) | none (code/terminal areas have no token) | **Part B** |
|
||||
| **`text-muted` (dark)** | `#8b8b9e` | `#a1a1aa` (zinc-400) | reconcile — **Part B** |
|
||||
|
||||
### 3.3 Theming mechanics (so we don't break anything)
|
||||
|
||||
- **Tailwind v4, CSS-first** (no `tailwind.config.*`). Tokens are defined in `:root`/`.dark` and exposed to utilities via `@theme inline` (`globals.css:130-179`).
|
||||
- **Dark via `.dark` class** on `<html>` (`@custom-variant dark` at `globals.css:22`), toggled by a custom Zustand store (`src/store/themeStore.ts`), default theme = `system` (`src/shared/constants/appConfig.ts:11`). The site uses `html[data-theme="light"]` instead — **the mechanisms differ but never meet** (separate origins), so no conflict. We keep the dashboard's `.dark` mechanism.
|
||||
- **Runtime primary override** exists (`themeStore.ts:85-97`, presets in `COLOR_THEMES`) — users can swap `--color-primary`. Any new token (gradient, etc.) that references `--color-primary` will inherit those overrides for free. ✅
|
||||
|
||||
---
|
||||
|
||||
## 4. Part A — The graph-paper grid background (headline ask)
|
||||
|
||||
### 4.1 What it is
|
||||
|
||||
The exact recipe from the site (`_mono_repo/omnirouteSite/css/base.css`): a **fixed, full-viewport pseudo-element** painting two 1px line gradients, sitting at `z-index:-1` behind all content.
|
||||
|
||||
```css
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(to right, var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: var(--grid-size) var(--grid-size);
|
||||
}
|
||||
```
|
||||
|
||||
**Why this works even though `body` has an opaque `background-color`:** a `::before` with `z-index:-1` paints _above_ the element's own background but _below_ its in-flow content. So `--color-bg` is the base fill, the grid is layered on top of it, and the app renders above the grid.
|
||||
|
||||
### 4.2 Precedent already in the codebase
|
||||
|
||||
`src/app/landing/page.tsx:16-26` **already implements this same grid per-page** — but with **red** lines (`#E54D5E`, opacity `0.06`) at **50px**, plus animated orbs. So the pattern is proven in the product; we are promoting it to a **global, theme-aware** wallpaper and (optionally) retiring the duplicate.
|
||||
|
||||
### 4.3 Tokens to add (in `globals.css`)
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* light */
|
||||
--grid-line: rgba(0, 0, 0, 0.045);
|
||||
--grid-size: 46px;
|
||||
--section-alt: rgba(0, 0, 0, 0.022);
|
||||
}
|
||||
.dark {
|
||||
/* dark */
|
||||
--grid-line: rgba(255, 255, 255, 0.035);
|
||||
--section-alt: rgba(255, 255, 255, 0.018);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 The single blocker
|
||||
|
||||
The grid is global by construction (it covers the panel, `auth`/`login`, error pages — every route — at once). Exactly **one** element hides it inside the panel:
|
||||
|
||||
- `src/shared/components/layouts/DashboardLayout.tsx:62` — the outer wrapper paints an opaque `bg-bg`:
|
||||
|
||||
```jsx
|
||||
<div className="flex h-dvh min-h-0 w-full overflow-hidden bg-bg">
|
||||
```
|
||||
|
||||
Everything below it is already transparent — `<main>` (`:93`), the scroll container (`:102`), the `max-w-7xl` inner (`:103`). So **removing `bg-bg` from this one line** lets the body grid show through the entire content area (the body's `--color-bg` remains the base fill underneath the grid).
|
||||
|
||||
```diff
|
||||
- <div className="flex h-dvh min-h-0 w-full overflow-hidden bg-bg">
|
||||
+ <div className="flex h-dvh min-h-0 w-full overflow-hidden">
|
||||
```
|
||||
|
||||
### 4.5 Chrome interaction (sidebar / header)
|
||||
|
||||
- `Header` (`src/shared/components/Header.tsx:207`, `bg-bg`) and `Sidebar` (`src/shared/components/Sidebar.tsx:430`, `bg-sidebar`) stay **opaque** → the grid shows in the **content area only**, with solid chrome framing it. This is the recommended, calm default and matches how the site separates chrome from canvas.
|
||||
- _Optional vibrancy variant:_ make the header translucent (`bg-bg/80 backdrop-blur`) so the grid runs behind it. A `.bg-vibrancy` helper already exists (`globals.css:370`). **Decision D3 below.**
|
||||
|
||||
### 4.6 Login / auth / error pages
|
||||
|
||||
These render directly under `<body>` (no panel chrome) and their page wrappers are mostly transparent — the global grid appears behind them automatically. One exception: `src/app/login/page.tsx:124,139` uses opaque `bg-bg` wrappers; soften the same way if we want the grid there too (minor, **D4**).
|
||||
|
||||
### 4.7 Landing page
|
||||
|
||||
`landing/page.tsx` keeps its richer animated background (orbs + vignette). Options: (a) leave it as-is (its own splash identity), or (b) align its grid to the global tokens (46px, neutral lines) for consistency. **Recommend (a)** — it's a marketing splash, not a panel screen. **Decision D5.**
|
||||
|
||||
---
|
||||
|
||||
## 5. Part B — Token unification
|
||||
|
||||
Add to `globals.css` (`:root` + `@theme inline`) so the dashboard gains the site's missing tokens. None of these change existing colors; they add the _missing_ primitives.
|
||||
|
||||
```css
|
||||
:root {
|
||||
--surface-2: #f5f5fa; /* light: nested panels */
|
||||
--radius: 14px;
|
||||
--radius-sm: 9px;
|
||||
--grad-brand: linear-gradient(135deg, var(--color-primary), var(--color-accent-light));
|
||||
--font-mono: ui-monospace, "JetBrains Mono", "Fira Code", "SF Mono", monospace;
|
||||
}
|
||||
.dark {
|
||||
--surface-2: #1c2230;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-surface-2: var(--surface-2); /* enables bg-surface-2 */
|
||||
--radius-lg: var(--radius); /* enables rounded-lg = 14px */
|
||||
--radius-md: var(--radius-sm); /* enables rounded-md = 9px */
|
||||
--font-mono: var(--font-mono); /* enables font-mono */
|
||||
}
|
||||
```
|
||||
|
||||
| Token | Why | Consumers |
|
||||
| -------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| `--radius` / `--radius-sm` | One radius scale (14/9) instead of 6/8/12 ad-hoc | Button, Card, Modal, Input, Select |
|
||||
| `--grad-brand` | Brand gradient for primary CTAs (red→violet), matching the site | Button `primary`, hero/CTA surfaces |
|
||||
| `--surface-2` | Nested panels / table headers / inset rows | Card.Section, DataTable header, inputs |
|
||||
| `--font-mono` | Code blocks, terminal, IDs, endpoints | ConsoleLogViewer, code snippets, `localhost:20128/v1` chips |
|
||||
| `--text-muted` reconcile | Pick one value site↔panel | global |
|
||||
|
||||
**Decision D2 (text-muted):** site `#8b8b9e` vs dashboard `#a1a1aa`. Recommend keeping the **dashboard's `#a1a1aa`** (it's the live product, slightly higher contrast) and updating the _site_ to match. Low priority, cosmetic.
|
||||
|
||||
---
|
||||
|
||||
## 6. Part C — Component standardization
|
||||
|
||||
The component layer is **custom** (no shadcn/Radix), Tailwind v4, semantic tokens **mostly** adopted (`bg-surface`, `border-white/10`, `ring-primary`) — good adoption (195 files import the shared barrel). The work is removing the **bypasses**. Home: `src/shared/components/`.
|
||||
|
||||
Ranked by impact × reach:
|
||||
|
||||
| # | Item | File(s) | Problem → Target |
|
||||
| --- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| C1 | **Radius alignment** | `Button.tsx:14-18`, `Card.tsx:39`, `Modal.tsx`, `Input.tsx`, `Select.tsx` | mixed 6/8/12px → repoint to `--radius`/`--radius-sm` (14/9) |
|
||||
| C2 | **Button gradient + `accent` variant** | `Button.tsx:5-12` | primary is flat red→red (`from-primary to-primary-hover`); align to `--grad-brand` (red→violet) and add the missing `accent` variant (indigo `#6366f1` is unused by buttons) — **highest visibility, ~195 importers**. **Decision D1.** |
|
||||
| C3 | **Tables** | `DataTable.tsx:122-176`, `logTableStyles.ts`, `globals.css:405-414` (Ant remnants) | `DataTable` is 100% inline hardcoded rgba + references non-existent vars (`--text-secondary`, `--bg-table-header`); migrate to tokens, retire the 2 divergent table styles. Tables are everywhere (providers/connections/logs) — worst offender. |
|
||||
| C4 | **Centralize status colors** | `flow/edgeStyles.ts:7-12`, `TokenHealthBadge.tsx:14-19`, `DegradationBadge.tsx`, `ProviderCascadeNode.tsx`, `Badge.tsx`, +5 `statusColor` helpers | 6+ copies of the same `#22c55e/#f59e0b/#ef4444` hex; create one `statusColors` module driven off `--color-success/warning/error`. Critical for circuit-breaker / cooldown / lockout badges to read consistently. |
|
||||
| C5 | **Card border** | `Card.tsx:39` | uses `border-white/5`; brand border is `/8` → align |
|
||||
| C6 | **Focus ring reconcile** | `globals.css:183` vs component `ring-primary/30` | global `:focus-visible` is indigo (`--color-accent`), components are red (`ring-primary`) — pick one (recommend **accent/indigo** globally, it reads as the "interactive" color) |
|
||||
| C7 | **Add `Checkbox` + `Textarea` primitives** | currently raw `<input>`/`<textarea>` with inline `accentColor:#6366f1` (e.g. `ColumnToggle.tsx:91`) | create token-driven primitives |
|
||||
| C8 | **Hardcoded-hex sweep** | `ConsoleLogViewer.tsx:240` (`#161b22`/`#30363d`), `ComboLiveStudio.tsx:306` (`#6366f120`), Modal traffic dots `Modal.tsx:149-159`, ~14 chart/component files with literal `#6366f1`/`#a855f7` | replace literals with `bg-surface`/`border-border`/`text-accent` etc. |
|
||||
| C9 | **`cn()` → clsx + tailwind-merge** | `src/shared/utils/cn.ts` | current `cn` just joins; conflicting classes stack (a `className="rounded-2xl"` override won't replace a primitive's `rounded-lg`). Needed for C1 overrides to behave. |
|
||||
|
||||
**Already on-brand (token-driven, only need radius):** `Badge`, `Toggle`, `SegmentedControl`, `Input`, `Select`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Rollout plan (phased, each phase shippable + testable)
|
||||
|
||||
- **Phase 1 — Grid + tokens (low risk, high visibility).**
|
||||
1. Add grid + identity tokens to `globals.css` (Part A §4.3, Part B §5).
|
||||
2. Add `body::before` grid.
|
||||
3. Remove `bg-bg` from `DashboardLayout.tsx:62`.
|
||||
4. Verify across themes + key screens (dashboard, providers, logs, login, an error page). Confirm contrast unchanged.
|
||||
→ _Delivers the headline ask. Reversible in one commit._
|
||||
|
||||
- **Phase 2 — Primitives radius + Button (C1, C2, C5, C9).** The visible "feel" pass. `cn()` upgrade first so overrides behave.
|
||||
|
||||
- **Phase 3 — Tables + status colors (C3, C4).** The largest consistency win; touch the data-heavy screens.
|
||||
|
||||
- **Phase 4 — Cleanup (C6, C7, C8).** Focus ring, new primitives, hardcoded-hex sweep.
|
||||
|
||||
Each phase: `npm run lint` + `npm run typecheck:core` + a visual pass. Per repo rule, production-code changes ship with tests where applicable (token/CSS changes are visual — validated by screenshots; component API changes get unit coverage).
|
||||
|
||||
---
|
||||
|
||||
## 8. Open decisions (need your call before/while implementing)
|
||||
|
||||
- **D1 — Button primary look.** Keep the current **red→red** gradient, or switch the product's primary buttons to the **red→violet `--grad-brand`** (matches the site CTAs)? _(Affects every primary button.)_ Recommend: **red→violet**, with `--grad-brand`.
|
||||
- **D2 — Grid line color.** **Neutral** lines (site style: faint white/black, `rgba(255,255,255,0.035)`) — calm, content-first — **or** the landing's **brand-red** lines? Recommend: **neutral** (matches the site's interior pages; red is louder and can tint readability). Size **46px** (site) to retire the landing's 50px drift.
|
||||
- **D3 — Chrome vibrancy.** Sidebar/header stay **solid** (grid in content area only), or go **translucent** so the grid runs behind them? Recommend: **solid** (calmer; less risk).
|
||||
- **D4 — Auth/login grid.** Soften `login/page.tsx` wrappers so the grid shows there too? Recommend: **yes** (cheap, more cohesive).
|
||||
- **D5 — Landing page.** Leave its animated splash bg as-is, or align it to the global grid? Recommend: **leave as-is**.
|
||||
- **D6 — Radius value.** Adopt **14/9** everywhere (bigger, softer, site-matching) — confirm you want this product-wide shift. Recommend: **yes**, it's the single biggest "one identity" signal.
|
||||
- **D7 — Scope of first PR.** Ship **Phase 1 only** first (grid + tokens), then iterate? Recommend: **yes** — validate the wallpaper live before the component waves.
|
||||
|
||||
---
|
||||
|
||||
## 9. Out of scope / risks
|
||||
|
||||
- **No palette change** — colors already match; we only add missing tokens. Zero risk of recoloring the product.
|
||||
- **No theme-engine change** — keep `.dark` + Zustand store; don't migrate to `next-themes` or to the site's `data-theme`.
|
||||
- **Radius shift is broad** (D6) — it touches every card/button/input; that's the point, but it's the one change worth eyeballing on busy screens (tables, modals) before merge.
|
||||
- **Tables (C3)** carry the most hardcoded styling and the highest regression surface — isolate in its own PR with before/after screenshots.
|
||||
- **Worktree isolation (repo hard-rule #19):** implementation runs in a dedicated worktree on a branch cut from the confirmed base (likely `release/v3.8.28`), never on the shared checkout. This doc is the only artifact written to the working tree so far.
|
||||
|
||||
---
|
||||
|
||||
## 10. Reference index
|
||||
|
||||
| Area | Path |
|
||||
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Dashboard tokens | `src/app/globals.css:30-179` (`:root`, `.dark`, `@theme inline`), `body` `:206` |
|
||||
| Theme store | `src/store/themeStore.ts`, `src/shared/components/ThemeProvider.tsx`, `src/shared/constants/appConfig.ts:9-11` |
|
||||
| Panel shell (grid blocker) | `src/shared/components/layouts/DashboardLayout.tsx:62` |
|
||||
| Chrome | `src/shared/components/Header.tsx:207`, `src/shared/components/Sidebar.tsx:430` |
|
||||
| Grid precedent | `src/app/landing/page.tsx:16-26` |
|
||||
| Primitives | `src/shared/components/{Button,Card,Input,Select,Badge,Modal,Toggle,SegmentedControl,Loading,Tooltip,DataTable}.tsx`, barrel `index.tsx` |
|
||||
| Status-color sources | `flow/edgeStyles.ts`, `TokenHealthBadge.tsx`, `DegradationBadge.tsx`, `logTableStyles.ts` |
|
||||
| `cn` util | `src/shared/utils/cn.ts` |
|
||||
| Site reference | `_mono_repo/omnirouteSite/css/tokens.css`, `css/base.css` (grid `body::before`) |
|
||||
|
|
@ -861,6 +861,7 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo
|
|||
| [Setup Guide](docs/guides/SETUP_GUIDE.md) | Full install methods, CLI tool configs, protocol setup, timeout tuning |
|
||||
| [CLI Tools Guide](docs/reference/CLI-TOOLS.md) | Per-tool setup for Claude Code, Codex, Cursor, Cline, OpenClaw, Kilo, Copilot |
|
||||
| [Remote Mode](docs/guides/REMOTE-MODE.md) | Drive a remote OmniRoute (VPS) from your laptop CLI via scoped access tokens |
|
||||
| [Claude Code Config](docs/guides/CLAUDE-CODE-CONFIGURATION.md) | Point Claude Code at OmniRoute (local/remote) with `launch` + per-model profiles |
|
||||
| [Quick Start](README.md#-quick-start) | 3-step install → connect → configure |
|
||||
|
||||
### 🔧 Operations & Deployment
|
||||
|
|
|
|||
|
|
@ -58,18 +58,32 @@ export async function buildHeaders(opts) {
|
|||
if (opts.body && !headers.has("content-type") && typeof opts.body !== "string") {
|
||||
headers.set("content-type", "application/json");
|
||||
}
|
||||
// Auth precedence: explicit opts/env → active context. Within a context the
|
||||
// scoped accessToken wins over the legacy apiKey. This routes the active
|
||||
// context's credential to the (possibly remote) server automatically.
|
||||
let auth = opts.apiKey ?? process.env.OMNIROUTE_API_KEY;
|
||||
// Auth precedence: explicit key → active-context credential → ambient env key.
|
||||
//
|
||||
// The active context's scoped token MUST win over the ambient OMNIROUTE_API_KEY:
|
||||
// `omniroute connect <remote>` saves the context's token, but users keep
|
||||
// OMNIROUTE_API_KEY in their shell. The global `--api-key` option is bound to
|
||||
// that env var (.env("OMNIROUTE_API_KEY")), so commands that spread
|
||||
// `optsWithGlobals()` into apiFetch carry opts.apiKey === the env value. If that
|
||||
// echoed value outranked the context, every remote management command would send
|
||||
// the local inference key and fail with "Invalid management token" — defeating
|
||||
// remote mode. So an opts.apiKey that merely mirrors the ambient env var is
|
||||
// treated as ambient (a fallback), NOT as an explicit override; only a DISTINCT
|
||||
// key — a real `--api-key <x>` flag or a command-supplied token like
|
||||
// `connect --key` — counts as explicit and wins. Within a context the scoped
|
||||
// accessToken wins over the legacy apiKey.
|
||||
const ambientKey = process.env.OMNIROUTE_API_KEY || null;
|
||||
const explicitKey = opts.apiKey && opts.apiKey !== ambientKey ? opts.apiKey : null;
|
||||
let auth = explicitKey;
|
||||
if (!auth) {
|
||||
try {
|
||||
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
auth = ctx?.accessToken || ctx?.apiKey || null;
|
||||
} catch {
|
||||
// No context credential available — continue unauthenticated.
|
||||
// No context credential available — fall through to the ambient fallback.
|
||||
}
|
||||
}
|
||||
if (!auth) auth = opts.apiKey || ambientKey || null;
|
||||
if (auth && !headers.has("authorization")) {
|
||||
headers.set("authorization", `Bearer ${auth}`);
|
||||
}
|
||||
|
|
|
|||
180
bin/cli/commands/launch-codex.mjs
Normal file
180
bin/cli/commands/launch-codex.mjs
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
/** OpenAI/Codex env keys stripped from the child so a stale OpenAI key/base-url
|
||||
* in the shell can't shadow the omniroute provider (defense-in-depth). Mirrors
|
||||
* free-claude-code's codex adapter. NOTE: this does NOT silence codex's
|
||||
* `refresh_token` log noise — that comes from a stored OpenAI session in
|
||||
* ~/.codex/auth.json, not the env; it is cosmetic and does not block requests. */
|
||||
const STRIPPED_CODEX_ENV_KEYS = [
|
||||
"OPENAI_API_KEY",
|
||||
"OPENAI_BASE_URL",
|
||||
"OPENAI_API_BASE",
|
||||
"OPENAI_ORG_ID",
|
||||
"OPENAI_ORGANIZATION",
|
||||
"CODEX_API_KEY",
|
||||
];
|
||||
|
||||
/** Placeholder so codex's `env_key` is always satisfied when the backend is open. */
|
||||
const NO_AUTH_SENTINEL = "omniroute-no-auth";
|
||||
|
||||
function stripTrailingSlash(value) {
|
||||
let s = String(value);
|
||||
let end = s.length;
|
||||
while (end > 0 && s.charCodeAt(end - 1) === 47) end--;
|
||||
return end === s.length ? s : s.slice(0, end);
|
||||
}
|
||||
|
||||
/** TOML assignment for a `-c key=value` codex flag (strings get quoted). */
|
||||
function tomlAssign(key, value) {
|
||||
if (typeof value === "boolean" || typeof value === "number") return `${key}=${value}`;
|
||||
return `${key}=${JSON.stringify(String(value))}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the OmniRoute root base URL + auth for codex, honouring (in order):
|
||||
* explicit flags → active context (remote mode) → localhost:<port>.
|
||||
* @returns {{ baseUrl:string, authToken:string|undefined }}
|
||||
*/
|
||||
export function resolveCodexTarget(opts = {}) {
|
||||
const explicit = opts.remote ?? opts.baseUrl;
|
||||
let baseUrl;
|
||||
if (explicit) {
|
||||
baseUrl = stripTrailingSlash(explicit).replace(/\/v1$/, "");
|
||||
} else {
|
||||
let fromCtx;
|
||||
try {
|
||||
fromCtx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl;
|
||||
} catch {
|
||||
/* no context */
|
||||
}
|
||||
baseUrl = fromCtx
|
||||
? stripTrailingSlash(fromCtx).replace(/\/v1$/, "")
|
||||
: `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
|
||||
let authToken = opts.apiKey ?? opts["api-key"];
|
||||
if (!authToken) {
|
||||
try {
|
||||
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
authToken = ctx?.accessToken || ctx?.apiKey || undefined;
|
||||
} catch {
|
||||
/* no context auth */
|
||||
}
|
||||
}
|
||||
if (!authToken) authToken = process.env.OMNIROUTE_API_KEY;
|
||||
return { baseUrl, authToken };
|
||||
}
|
||||
|
||||
/** Health-check an OmniRoute root URL before launching Codex. */
|
||||
async function healthCheck(baseUrl, timeoutMs = 3000) {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/monitoring/health`, {
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the env for the Codex child: strip stale OpenAI/Codex creds, then set
|
||||
* OMNIROUTE_API_KEY (the provider env_key) to the resolved token or a sentinel.
|
||||
* @param {Record<string,string>} baseEnv
|
||||
* @param {string|undefined} authToken
|
||||
* @returns {Record<string,string>}
|
||||
*/
|
||||
export function buildCodexEnv(baseEnv, authToken) {
|
||||
const env = { ...baseEnv };
|
||||
for (const key of STRIPPED_CODEX_ENV_KEYS) delete env[key];
|
||||
env.OMNIROUTE_API_KEY = (authToken && String(authToken).trim()) || NO_AUTH_SENTINEL;
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex `-c` flags that define the `omniroute` provider inline, so launch works
|
||||
* WITHOUT a pre-existing ~/.codex/config.toml. Mirrors free-claude-code.
|
||||
* @param {string} baseUrl OmniRoute root URL (no /v1)
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function buildCodexProviderArgs(baseUrl) {
|
||||
return [
|
||||
"-c",
|
||||
tomlAssign("model_provider", "omniroute"),
|
||||
"-c",
|
||||
tomlAssign("model_providers.omniroute.name", "OmniRoute"),
|
||||
"-c",
|
||||
tomlAssign("model_providers.omniroute.base_url", `${baseUrl}/v1`),
|
||||
"-c",
|
||||
tomlAssign("model_providers.omniroute.env_key", "OMNIROUTE_API_KEY"),
|
||||
"-c",
|
||||
tomlAssign("model_providers.omniroute.wire_api", "responses"),
|
||||
"-c",
|
||||
tomlAssign("model_providers.omniroute.requires_openai_auth", false),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{port?:string, remote?:string, profile?:string, apiKey?:string}} opts
|
||||
* @param {string[]} codexArgs pass-through args for the codex binary
|
||||
* @returns {Promise<number>} exit code
|
||||
*/
|
||||
export async function runLaunchCodexCommand(opts = {}, codexArgs = []) {
|
||||
const { baseUrl, authToken } = resolveCodexTarget(opts);
|
||||
|
||||
if (!(await healthCheck(baseUrl))) {
|
||||
console.error(
|
||||
(t("launch.notRunning") || "OmniRoute is not reachable at {port}. Start it with 'omniroute serve'.").replace(
|
||||
"{port}",
|
||||
baseUrl
|
||||
)
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Provider injected via -c (works without config.toml); then the profile (model),
|
||||
// then the user's pass-through args.
|
||||
const providerArgs = buildCodexProviderArgs(baseUrl);
|
||||
const profileArgs = opts.profile ? ["--profile", opts.profile] : [];
|
||||
const extraArgs = [...providerArgs, ...profileArgs, ...codexArgs];
|
||||
const env = buildCodexEnv(process.env, authToken);
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const child = spawn("codex", extraArgs, { env, stdio: "inherit" });
|
||||
child.on("error", (err) => {
|
||||
if (err?.code === "ENOENT") {
|
||||
console.error(
|
||||
"The 'codex' CLI was not found in PATH. Install with:\n npm install -g @openai/codex"
|
||||
);
|
||||
resolve(127);
|
||||
} else {
|
||||
console.error(String(err?.message || err));
|
||||
resolve(1);
|
||||
}
|
||||
});
|
||||
child.on("exit", (code) => resolve(code ?? 0));
|
||||
});
|
||||
}
|
||||
|
||||
export function registerLaunchCodex(program) {
|
||||
program
|
||||
.command("launch-codex")
|
||||
.description(
|
||||
t("launchCodex.description") || "Launch Codex CLI pointed at OmniRoute (local or remote VPS)"
|
||||
)
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute base URL, e.g. http://192.168.0.15:20128 (overrides --port + context)")
|
||||
.option("--profile <name>", "Codex profile to activate (passed as --profile <name>)")
|
||||
.option("-p, --p <name>", "Alias for --profile")
|
||||
.option("--api-key <key>", "OmniRoute API key (overrides OMNIROUTE_API_KEY env var for this invocation)")
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.argument("[codexArgs...]", "arguments passed through to the codex binary")
|
||||
.action(async (codexArgs, opts) => {
|
||||
const merged = { ...opts, profile: opts.profile ?? opts.p };
|
||||
const exitCode = await runLaunchCodexCommand(merged, codexArgs ?? []);
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,53 +1,123 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
function stripTrailingSlash(value) {
|
||||
let s = String(value);
|
||||
let end = s.length;
|
||||
while (end > 0 && s.charCodeAt(end - 1) === 47) end--;
|
||||
return end === s.length ? s : s.slice(0, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a clean child env for Claude Code pointed at the local proxy.
|
||||
* Strips any inherited ANTHROPIC_* (avoids a stale shell token leaking through),
|
||||
* then injects the proxy base URL, gateway model discovery, and auto-compact window.
|
||||
* Build a clean child env for Claude Code pointed at OmniRoute.
|
||||
*
|
||||
* Strips inherited ANTHROPIC_* (avoids a stale shell token leaking through), then
|
||||
* injects the base URL, gateway model discovery, and auto-compact window.
|
||||
*
|
||||
* @param {Record<string,string>} baseEnv
|
||||
* @param {number} port
|
||||
* @param {number|string} baseUrlOrPort a port (→ http://localhost:<port>) or a full base URL
|
||||
* @param {string|undefined} authToken
|
||||
* @param {{ configDir?:string, model?:string }} [opts]
|
||||
* @returns {Record<string,string>}
|
||||
*/
|
||||
export function buildClaudeEnv(baseEnv, port, authToken) {
|
||||
export function buildClaudeEnv(baseEnv, baseUrlOrPort, authToken, opts = {}) {
|
||||
const env = { ...baseEnv };
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key.startsWith("ANTHROPIC_")) delete env[key];
|
||||
}
|
||||
env.ANTHROPIC_BASE_URL = `http://localhost:${port}`;
|
||||
if (authToken) env.ANTHROPIC_AUTH_TOKEN = authToken;
|
||||
|
||||
// Accept a bare port (number/numeric string → localhost) or a full base URL.
|
||||
// Claude Code wants the ROOT URL (it appends /v1/messages itself) — no /v1 here.
|
||||
let baseUrl;
|
||||
if (typeof baseUrlOrPort === "number" || /^\d+$/.test(String(baseUrlOrPort))) {
|
||||
baseUrl = `http://localhost:${Number(baseUrlOrPort) || 20128}`;
|
||||
} else {
|
||||
baseUrl = stripTrailingSlash(String(baseUrlOrPort)).replace(/\/v1$/, "");
|
||||
}
|
||||
|
||||
env.ANTHROPIC_BASE_URL = baseUrl;
|
||||
// Always set a token: when none is resolved, a sentinel keeps newer Claude Code
|
||||
// from stopping at its local login gate before it ever contacts OmniRoute (an
|
||||
// open backend ignores the value). Mirrors free-claude-code. ANTHROPIC_API_KEY
|
||||
// stays stripped (above) so it can't shadow the Bearer token.
|
||||
env.ANTHROPIC_AUTH_TOKEN = (authToken && String(authToken).trim()) || "omniroute-no-auth";
|
||||
env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
|
||||
env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = "190000";
|
||||
// Profile isolation (Claude Code has no native profiles — CLAUDE_CONFIG_DIR is
|
||||
// the idiomatic mechanism: separate settings/credentials/history/cache per dir).
|
||||
if (opts.configDir) env.CLAUDE_CONFIG_DIR = opts.configDir;
|
||||
if (opts.model) env.ANTHROPIC_MODEL = opts.model;
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{port?:string, token?:string}} opts
|
||||
* Resolve the OmniRoute base URL + auth for launch, honouring (in order):
|
||||
* explicit flags → the active context (remote mode) → localhost:<port>.
|
||||
* @param {{port?:string, remote?:string, baseUrl?:string, token?:string, apiKey?:string, context?:string}} opts
|
||||
* @returns {{ baseUrl:string, authToken:string|undefined }}
|
||||
*/
|
||||
export function resolveLaunchTarget(opts = {}) {
|
||||
const explicit = opts.remote ?? opts.baseUrl;
|
||||
let baseUrl;
|
||||
if (explicit) {
|
||||
baseUrl = stripTrailingSlash(explicit).replace(/\/v1$/, "");
|
||||
} else {
|
||||
let fromCtx;
|
||||
try {
|
||||
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
fromCtx = ctx?.baseUrl;
|
||||
} catch {
|
||||
/* no context */
|
||||
}
|
||||
baseUrl = fromCtx
|
||||
? stripTrailingSlash(fromCtx).replace(/\/v1$/, "")
|
||||
: `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
|
||||
let authToken = opts.token ?? opts.apiKey ?? opts["api-key"];
|
||||
if (!authToken) {
|
||||
try {
|
||||
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
authToken = ctx?.accessToken || ctx?.apiKey || undefined;
|
||||
} catch {
|
||||
/* no context auth */
|
||||
}
|
||||
}
|
||||
if (!authToken) authToken = process.env.ANTHROPIC_AUTH_TOKEN ?? process.env.OMNIROUTE_API_KEY;
|
||||
return { baseUrl, authToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{port?:string, remote?:string, token?:string, apiKey?:string, profile?:string, claudeHome?:string}} opts
|
||||
* @param {string[]} claudeArgs pass-through args for the claude binary
|
||||
* @returns {Promise<number>} exit code
|
||||
*/
|
||||
export async function runLaunchCommand(opts = {}, claudeArgs = []) {
|
||||
const port = Number(opts.port ?? process.env.PORT ?? 20128) || 20128;
|
||||
const { baseUrl, authToken } = resolveLaunchTarget(opts);
|
||||
|
||||
// Health check the proxy before launching.
|
||||
// Health check the (possibly remote) proxy before launching.
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${port}/api/monitoring/health`, {
|
||||
signal: AbortSignal.timeout(1500),
|
||||
const res = await fetch(`${baseUrl}/api/monitoring/health`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
console.error(
|
||||
(t("launch.notRunning") || "OmniRoute is not running on port {port}. Start it with 'omniroute serve'.").replace(
|
||||
(t("launch.notRunning") || "OmniRoute is not reachable at {port}. Start it with 'omniroute serve'.").replace(
|
||||
"{port}",
|
||||
String(port)
|
||||
baseUrl
|
||||
)
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const token = opts.token ?? process.env.ANTHROPIC_AUTH_TOKEN ?? undefined;
|
||||
const env = buildClaudeEnv(process.env, port, token);
|
||||
const configDir = opts.profile
|
||||
? join(opts.claudeHome || join(os.homedir(), ".claude"), "profiles", opts.profile)
|
||||
: undefined;
|
||||
const env = buildClaudeEnv(process.env, baseUrl, authToken, { configDir });
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const child = spawn("claude", claudeArgs, { env, stdio: "inherit" });
|
||||
|
|
@ -67,9 +137,14 @@ export async function runLaunchCommand(opts = {}, claudeArgs = []) {
|
|||
export function registerLaunch(program) {
|
||||
program
|
||||
.command("launch")
|
||||
.description(t("launch.description") || "Launch Claude Code pointed at the local OmniRoute proxy")
|
||||
.description(
|
||||
t("launch.description") || "Launch Claude Code pointed at OmniRoute (local or remote)"
|
||||
)
|
||||
.option("--port <port>", t("serve.port") || "Proxy port", "20128")
|
||||
.option("--token <token>", t("launch.token") || "API key the Claude client should send (ANTHROPIC_AUTH_TOKEN)")
|
||||
.option("--remote <url>", "Remote OmniRoute base URL (overrides --port and the active context)")
|
||||
.option("--profile <name>", "Claude Code profile to use (CLAUDE_CONFIG_DIR ~/.claude/profiles/<name>)")
|
||||
.option("--token <token>", t("launch.token") || "Token Claude sends (ANTHROPIC_AUTH_TOKEN)")
|
||||
.option("--api-key <key>", "Alias for --token (OmniRoute access token / API key)")
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.argument("[claudeArgs...]", "arguments passed through to the claude binary")
|
||||
|
|
|
|||
|
|
@ -56,7 +56,22 @@ import { registerTray } from "./tray.mjs";
|
|||
import { registerAutostart } from "./autostart.mjs";
|
||||
import { registerRepl } from "./repl.mjs";
|
||||
import { registerLaunch } from "./launch.mjs";
|
||||
import { registerLaunchCodex } from "./launch-codex.mjs";
|
||||
import { registerSetupCodex } from "./setup-codex.mjs";
|
||||
import { registerSetupClaude } from "./setup-claude.mjs";
|
||||
import { registerSetupOpencode } from "./setup-opencode.mjs";
|
||||
import { registerSetupCline } from "./setup-cline.mjs";
|
||||
import { registerSetupKilo } from "./setup-kilo.mjs";
|
||||
import { registerSetupContinue } from "./setup-continue.mjs";
|
||||
import { registerSetupCursor } from "./setup-cursor.mjs";
|
||||
import { registerSetupRoo } from "./setup-roo.mjs";
|
||||
import { registerSetupCrush } from "./setup-crush.mjs";
|
||||
import { registerSetupGoose } from "./setup-goose.mjs";
|
||||
import { registerSetupQwen } from "./setup-qwen.mjs";
|
||||
import { registerSetupAider } from "./setup-aider.mjs";
|
||||
import { registerSetupGemini } from "./setup-gemini.mjs";
|
||||
import { registerConnect } from "./connect.mjs";
|
||||
import { registerContexts } from "./contexts.mjs";
|
||||
import { registerTokens } from "./tokens.mjs";
|
||||
import { registerConfigure } from "./configure.mjs";
|
||||
import { registerApiCommands } from "../api-commands/registry.mjs";
|
||||
|
|
@ -122,7 +137,22 @@ export function registerCommands(program) {
|
|||
registerAutostart(program);
|
||||
registerRepl(program);
|
||||
registerLaunch(program);
|
||||
registerLaunchCodex(program);
|
||||
registerSetupCodex(program);
|
||||
registerSetupClaude(program);
|
||||
registerSetupOpencode(program);
|
||||
registerSetupCline(program);
|
||||
registerSetupKilo(program);
|
||||
registerSetupContinue(program);
|
||||
registerSetupCursor(program);
|
||||
registerSetupRoo(program);
|
||||
registerSetupCrush(program);
|
||||
registerSetupGoose(program);
|
||||
registerSetupQwen(program);
|
||||
registerSetupAider(program);
|
||||
registerSetupGemini(program);
|
||||
registerConnect(program);
|
||||
registerContexts(program);
|
||||
registerTokens(program);
|
||||
registerConfigure(program);
|
||||
registerApiCommands(program);
|
||||
|
|
|
|||
146
bin/cli/commands/setup-aider.mjs
Normal file
146
bin/cli/commands/setup-aider.mjs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
/**
|
||||
* omniroute setup-aider — configure Aider (aider.chat) for OmniRoute.
|
||||
*
|
||||
* Aider (LiteLLM under the hood) talks to an OpenAI-compatible endpoint via env
|
||||
* `OPENAI_API_BASE` (ROOT url — LiteLLM appends /v1/chat/completions) + the model
|
||||
* flag `--model openai/<model>`. This writes ~/.aider.conf.yml (openai-api-base +
|
||||
* model) — the key stays in OPENAI_API_KEY (env, never the file) — and prints the
|
||||
* guaranteed env recipe + headless command. Remote-aware.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
function stripToRoot(url) {
|
||||
const s = String(url || "").replace(/\/+$/, "");
|
||||
return s.endsWith("/v1") ? s.slice(0, -3) : s;
|
||||
}
|
||||
|
||||
/** Resolve OPENAI_API_BASE (ROOT, no /v1 — LiteLLM appends) + apiKey. */
|
||||
export function resolveAiderTarget(opts = {}) {
|
||||
let root;
|
||||
if (opts.remote) root = stripToRoot(opts.remote);
|
||||
else {
|
||||
try {
|
||||
root = stripToRoot(resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl);
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
if (!root) root = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
let apiKey = opts.apiKey ?? opts["api-key"];
|
||||
if (!apiKey) {
|
||||
try {
|
||||
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
apiKey = c?.accessToken || c?.apiKey;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
}
|
||||
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
|
||||
return { apiBase: root, apiKey };
|
||||
}
|
||||
|
||||
/** Merge openai-api-base + model into an .aider.conf.yml object (preserve rest). */
|
||||
export function buildAiderConfig(existing, { apiBase, model }) {
|
||||
const cfg = existing && typeof existing === "object" ? { ...existing } : {};
|
||||
cfg["openai-api-base"] = apiBase;
|
||||
if (model) cfg.model = `openai/${model}`;
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/** The guaranteed env + run recipe (pure → testable). */
|
||||
export function buildAiderRecipe({ apiBase, model }) {
|
||||
return [
|
||||
`export OPENAI_API_BASE=${apiBase}`,
|
||||
"export OPENAI_API_KEY=$OMNIROUTE_API_KEY",
|
||||
`aider --model openai/${model}`,
|
||||
`# headless: aider --model openai/${model} --message "reply OK" --yes`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function readYamlSafe(yaml, path) {
|
||||
try {
|
||||
if (existsSync(path)) return yaml.load(readFileSync(path, "utf8")) || {};
|
||||
} catch {
|
||||
/* corrupt/missing */
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function fetchModelIds(apiBase, apiKey) {
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
const res = await fetch(`${apiBase}/v1/models`, { headers, signal: AbortSignal.timeout(8000) });
|
||||
if (!res.ok) return [];
|
||||
const body = await res.json();
|
||||
const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
|
||||
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSetupAiderCommand(opts = {}) {
|
||||
const { apiBase, apiKey } = resolveAiderTarget(opts);
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".aider.conf.yml");
|
||||
|
||||
printHeading("OmniRoute → Aider (openai-compatible via LiteLLM)");
|
||||
printInfo(`OPENAI_API_BASE: ${apiBase} (no /v1 — LiteLLM appends it)`);
|
||||
|
||||
let model = opts.model;
|
||||
if (!model) {
|
||||
const ids = await fetchModelIds(apiBase, apiKey);
|
||||
if (ids.length && !opts.yes) {
|
||||
printInfo(`Examples: ${ids.slice(0, 20).join(", ")}${ids.length > 20 ? " …" : ""}`);
|
||||
const prompt = createPrompt();
|
||||
try {
|
||||
model = await prompt.ask("Model id for Aider (without the openai/ prefix)");
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!model) {
|
||||
printError("A model is required. Pass --model <id> (the openai/ prefix is added automatically).");
|
||||
return 2;
|
||||
}
|
||||
|
||||
const yaml = await import("js-yaml");
|
||||
const merged = buildAiderConfig(readYamlSafe(yaml, configPath), { apiBase, model });
|
||||
const out = yaml.dump(merged, { lineWidth: -1 });
|
||||
|
||||
if (dryRun) {
|
||||
console.log("\n" + out);
|
||||
printInfo(`[dry-run] → ${configPath}`);
|
||||
} else {
|
||||
mkdirSync(join(configPath, ".."), { recursive: true });
|
||||
writeFileSync(configPath, out, "utf8");
|
||||
printSuccess(`Wrote ${configPath}`);
|
||||
}
|
||||
printInfo("\nProvide the key + run (the key stays in the env, never the file):");
|
||||
console.log(buildAiderRecipe({ apiBase, model }));
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerSetupAider(program) {
|
||||
program
|
||||
.command("setup-aider")
|
||||
.description("Configure Aider for OmniRoute: write ~/.aider.conf.yml + print the env recipe")
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
|
||||
.option("--model <id>", "Model id (the openai/ prefix is added automatically)")
|
||||
.option("--config-path <path>", ".aider.conf.yml path (default: ~/.aider.conf.yml)")
|
||||
.option("--yes", "Non-interactive: do not prompt (requires --model)")
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.action(async (opts) => {
|
||||
const code = await runSetupAiderCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
152
bin/cli/commands/setup-claude.mjs
Normal file
152
bin/cli/commands/setup-claude.mjs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
/**
|
||||
* omniroute setup-claude — Remote-aware Claude Code profile generator.
|
||||
*
|
||||
* Claude Code has no native profile files (unlike Codex). The idiomatic way to
|
||||
* keep multiple named configs is `CLAUDE_CONFIG_DIR` — a separate config dir per
|
||||
* profile (its own settings.json, credentials, history, cache). This command
|
||||
* fetches the live /v1/models catalog from a (possibly remote) OmniRoute and
|
||||
* writes `~/.claude/profiles/<name>/settings.json` for each supported model,
|
||||
* reusing the SAME profile names as `setup-codex` (glm52, kimi-k27, …).
|
||||
*
|
||||
* Launch a profile with: omniroute launch --profile <name>
|
||||
* (which injects ANTHROPIC_AUTH_TOKEN from the active context — the token is
|
||||
* never written to disk). Or export ANTHROPIC_AUTH_TOKEN and run:
|
||||
* CLAUDE_CONFIG_DIR=~/.claude/profiles/<name> claude
|
||||
*
|
||||
* Idempotent: re-running overwrites each profile's settings.json in place.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
|
||||
import { categoriseModel } from "./setup-codex.mjs";
|
||||
|
||||
/** Map a Codex-style effort to a Claude Code settings.json effortLevel. */
|
||||
function effortLevelFor(cfg) {
|
||||
// Codex categories use xhigh/high/low/undefined; Claude Code accepts the same
|
||||
// names (low|medium|high|xhigh). Pass through, omit for the "simple" tier.
|
||||
return cfg.effort || undefined;
|
||||
}
|
||||
|
||||
/** Build the settings.json content for one Claude Code profile. */
|
||||
export function buildProfileSettings(modelId, baseUrl, cfg) {
|
||||
const env = {
|
||||
ANTHROPIC_BASE_URL: baseUrl,
|
||||
ANTHROPIC_MODEL: modelId,
|
||||
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1",
|
||||
CLAUDE_CODE_AUTO_COMPACT_WINDOW: "190000",
|
||||
};
|
||||
const settings = {
|
||||
$schema: "https://json.schemastore.org/claude-code-settings.json",
|
||||
model: modelId,
|
||||
env,
|
||||
};
|
||||
const effort = effortLevelFor(cfg);
|
||||
if (effort) settings.effortLevel = effort;
|
||||
// NOTE: ANTHROPIC_AUTH_TOKEN is intentionally NOT written here — `omniroute
|
||||
// launch --profile` injects it from the active context, keeping the secret off
|
||||
// disk. For direct `CLAUDE_CONFIG_DIR=… claude` use, export it in your shell.
|
||||
return JSON.stringify(settings, null, 2) + "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{remote?:string, port?:string, apiKey?:string, claudeHome?:string, dryRun?:boolean, only?:string}} opts
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
export async function runSetupClaudeCommand(opts = {}) {
|
||||
const port = Number(opts.port ?? process.env.PORT ?? 20128) || 20128;
|
||||
const baseUrl = (opts.remote ?? `http://localhost:${port}`).replace(/\/+$/, "").replace(/\/v1$/, "");
|
||||
const apiKey = opts.apiKey ?? opts["api-key"] ?? process.env.OMNIROUTE_API_KEY ?? "";
|
||||
const claudeHome = opts.claudeHome ?? opts["claude-home"] ?? join(os.homedir(), ".claude");
|
||||
const profilesRoot = join(claudeHome, "profiles");
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const onlyFilter = opts.only ? opts.only.split(",").map((s) => s.trim()) : null;
|
||||
|
||||
printHeading("OmniRoute → Claude Code profile generator");
|
||||
printInfo(`Connecting to ${baseUrl} …`);
|
||||
|
||||
// ── Fetch model catalog ───────────────────────────────────────────────────
|
||||
let models;
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
const res = await fetch(`${baseUrl}/v1/models`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
||||
const body = await res.json();
|
||||
models = body.data ?? body.models ?? [];
|
||||
} catch (err) {
|
||||
printError(`Failed to fetch models: ${err.message}`);
|
||||
printInfo(
|
||||
"Make sure OmniRoute is running and the --remote URL is correct.\n" +
|
||||
"You may also need --api-key if OmniRoute requires authentication."
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
printInfo(`Received ${models.length} models from ${baseUrl}`);
|
||||
|
||||
if (!dryRun && !existsSync(profilesRoot)) {
|
||||
mkdirSync(profilesRoot, { recursive: true });
|
||||
}
|
||||
|
||||
let written = 0;
|
||||
for (const m of models) {
|
||||
const id = typeof m === "string" ? m : m.id ?? "";
|
||||
if (!id) continue;
|
||||
if (onlyFilter && !onlyFilter.some((f) => id.includes(f))) continue;
|
||||
|
||||
const cfg = categoriseModel(id);
|
||||
if (!cfg) continue;
|
||||
|
||||
const dir = join(profilesRoot, cfg.name);
|
||||
const filePath = join(dir, "settings.json");
|
||||
const content = buildProfileSettings(id, baseUrl, cfg);
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\n── [dry-run] ${filePath} ──`);
|
||||
console.log(content);
|
||||
} else {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(filePath, content, "utf8");
|
||||
printSuccess(` ✓ profiles/${cfg.name}/settings.json (${id})`);
|
||||
}
|
||||
written++;
|
||||
}
|
||||
|
||||
const skipped = models.length - written;
|
||||
if (!dryRun) {
|
||||
console.log("");
|
||||
printSuccess(`${written} Claude Code profiles written to ${profilesRoot}`);
|
||||
if (skipped > 0) printInfo(`${skipped} models skipped (no matching profile pattern)`);
|
||||
console.log("\nTo use a profile:");
|
||||
console.log(" omniroute launch --profile <name> # e.g. omniroute launch --profile glm52");
|
||||
console.log(" # or: CLAUDE_CONFIG_DIR=~/.claude/profiles/<name> claude (export ANTHROPIC_AUTH_TOKEN first)");
|
||||
} else {
|
||||
console.log(`\n[dry-run] ${written} profiles would be written (${skipped} skipped)`);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerSetupClaude(program) {
|
||||
program
|
||||
.command("setup-claude")
|
||||
.description(
|
||||
"Fetch the live model catalog from OmniRoute (local or remote VPS) and generate " +
|
||||
"~/.claude/profiles/<name>/ Claude Code profiles (CLAUDE_CONFIG_DIR) for each model"
|
||||
)
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
|
||||
.option("--claude-home <dir>", "Claude home dir (default: ~/.claude)")
|
||||
.option("--only <patterns>", "Comma-separated substrings — only matching model IDs (e.g. glm,kimi)")
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.action(async (opts) => {
|
||||
const exitCode = await runSetupClaudeCommand(opts);
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
}
|
||||
160
bin/cli/commands/setup-cline.mjs
Normal file
160
bin/cli/commands/setup-cline.mjs
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
/**
|
||||
* omniroute setup-cline — configure the Cline AI coding agent to use OmniRoute.
|
||||
*
|
||||
* Cline's VS Code extension keeps its config in VS Code's opaque globalStorage
|
||||
* (not file-writable). Its CLI/standalone mode reads ~/.cline/data/. This command
|
||||
* writes the CLI-mode files (matching the OmniRoute dashboard) AND prints the
|
||||
* Base URL / model to paste into the VS Code extension UI.
|
||||
*
|
||||
* Cline uses the OpenAI-compatible provider: openAiBaseUrl is the ROOT URL
|
||||
* (no /v1 — Cline appends /v1/chat/completions). Plan + Act modes are set to the
|
||||
* same provider/model. The key goes in secrets.json (Cline has no env ref).
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
function stripToRoot(url) {
|
||||
let s = String(url || "").replace(/\/+$/, "");
|
||||
return s.endsWith("/v1") ? s.slice(0, -3) : s;
|
||||
}
|
||||
|
||||
/** Resolve baseUrl (ROOT, no /v1) + apiKey from flags → active context → localhost. */
|
||||
export function resolveClineTarget(opts = {}) {
|
||||
let baseUrl;
|
||||
if (opts.remote) baseUrl = stripToRoot(opts.remote);
|
||||
else {
|
||||
try {
|
||||
baseUrl = stripToRoot(resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl);
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
if (!baseUrl) baseUrl = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
let apiKey = opts.apiKey ?? opts["api-key"];
|
||||
if (!apiKey) {
|
||||
try {
|
||||
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
apiKey = c?.accessToken || c?.apiKey;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
}
|
||||
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
|
||||
return { baseUrl, apiKey };
|
||||
}
|
||||
|
||||
/** Merge OmniRoute openai-compatible settings into Cline's globalState (Plan + Act). */
|
||||
export function buildClineGlobalState(existing, { baseUrl, model }) {
|
||||
const gs = { ...(existing || {}) };
|
||||
gs.actModeApiProvider = "openai";
|
||||
gs.planModeApiProvider = "openai";
|
||||
gs.openAiBaseUrl = baseUrl; // ROOT — Cline appends /v1/chat/completions
|
||||
if (model) {
|
||||
gs.openAiModelId = model;
|
||||
gs.planModeOpenAiModelId = model;
|
||||
}
|
||||
return gs;
|
||||
}
|
||||
|
||||
/** Merge the API key into Cline's secrets (Cline has no env-var reference). */
|
||||
export function buildClineSecrets(existing, { apiKey }) {
|
||||
return { ...(existing || {}), openAiApiKey: apiKey || "sk_omniroute" };
|
||||
}
|
||||
|
||||
function readJson(path) {
|
||||
try {
|
||||
if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch {
|
||||
/* corrupt/missing → start fresh */
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function fetchModelIds(baseUrl, apiKey) {
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
const res = await fetch(`${baseUrl}/v1/models`, { headers, signal: AbortSignal.timeout(8000) });
|
||||
if (!res.ok) return [];
|
||||
const body = await res.json();
|
||||
const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
|
||||
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSetupClineCommand(opts = {}) {
|
||||
const { baseUrl, apiKey } = resolveClineTarget(opts);
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const clineDir = opts.clineDir ?? opts["cline-dir"] ?? join(os.homedir(), ".cline", "data");
|
||||
|
||||
printHeading("OmniRoute → Cline (OpenAI-compatible)");
|
||||
printInfo(`Server: ${baseUrl}`);
|
||||
|
||||
// Resolve the model (Cline needs one explicit id — no auto-discovery).
|
||||
let model = opts.model;
|
||||
if (!model) {
|
||||
const ids = await fetchModelIds(baseUrl, apiKey);
|
||||
if (ids.length && !opts.yes) {
|
||||
printInfo(`Examples: ${ids.slice(0, 20).join(", ")}${ids.length > 20 ? " …" : ""}`);
|
||||
const prompt = createPrompt();
|
||||
try {
|
||||
model = await prompt.ask("Model id for Cline");
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!model) {
|
||||
printError("A model is required. Pass --model <id> (Cline has no model auto-discovery).");
|
||||
return 2;
|
||||
}
|
||||
|
||||
const gsPath = join(clineDir, "globalState.json");
|
||||
const secPath = join(clineDir, "secrets.json");
|
||||
const globalState = buildClineGlobalState(readJson(gsPath), { baseUrl, model });
|
||||
const secrets = buildClineSecrets(readJson(secPath), { apiKey });
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\n── [dry-run] ${gsPath} ──`);
|
||||
console.log(JSON.stringify({ actModeApiProvider: globalState.actModeApiProvider, planModeApiProvider: globalState.planModeApiProvider, openAiBaseUrl: globalState.openAiBaseUrl, openAiModelId: globalState.openAiModelId }, null, 2));
|
||||
console.log(`\n── [dry-run] ${secPath} ── (openAiApiKey: ${apiKey ? "set" : "sk_omniroute"})`);
|
||||
} else {
|
||||
if (!existsSync(clineDir)) mkdirSync(clineDir, { recursive: true });
|
||||
writeFileSync(gsPath, JSON.stringify(globalState, null, 2) + "\n", "utf8");
|
||||
writeFileSync(secPath, JSON.stringify(secrets, null, 2) + "\n", "utf8");
|
||||
printSuccess(`Wrote ${gsPath}`);
|
||||
printSuccess(`Wrote ${secPath}`);
|
||||
}
|
||||
|
||||
// The VS Code extension uses opaque globalStorage — can't be file-written.
|
||||
printInfo("\nFor the Cline VS Code extension, set these in its Settings → API (OpenAI Compatible):");
|
||||
printInfo(` Base URL: ${baseUrl} (NOT /v1 — Cline appends it)`);
|
||||
printInfo(` API Key: <your OMNIROUTE_API_KEY>`);
|
||||
printInfo(` Model: ${model}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerSetupCline(program) {
|
||||
program
|
||||
.command("setup-cline")
|
||||
.description(
|
||||
"Configure Cline for OmniRoute: write ~/.cline/data (CLI mode) + print VS Code extension settings"
|
||||
)
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
|
||||
.option("--model <id>", "Model id for Cline (required unless picked interactively)")
|
||||
.option("--cline-dir <dir>", "Cline data dir (default: ~/.cline/data)")
|
||||
.option("--yes", "Non-interactive: do not prompt (requires --model)")
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.action(async (opts) => {
|
||||
const code = await runSetupClineCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
226
bin/cli/commands/setup-codex.mjs
Normal file
226
bin/cli/commands/setup-codex.mjs
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
/**
|
||||
* omniroute setup-codex — Remote-aware Codex CLI profile generator.
|
||||
*
|
||||
* Connects to a running OmniRoute instance (local or remote VPS), fetches the
|
||||
* live model catalog via GET /v1/models, then generates ~/.codex/<name>.config.toml
|
||||
* profile files for each model — so you can switch providers with a single flag
|
||||
* (`codex --profile glm52`) without editing config files by hand.
|
||||
*
|
||||
* Primary use-case: configure a local Codex CLI to use models from a VPS.
|
||||
* omniroute setup-codex --remote http://100.67.86.91:20128 --api-key sk-xxx
|
||||
*
|
||||
* The command is idempotent: re-running updates existing profile files in place.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
// ── Model categorisation ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Map a model ID (as returned by /v1/models) to a Codex profile configuration.
|
||||
* Returns null for models that should not get their own profile (e.g. aliases).
|
||||
*
|
||||
* Exported so the Claude Code profile generator (`setup-claude`) reuses the SAME
|
||||
* profile names (glm52, kimi-k27, …) for cross-CLI consistency.
|
||||
*
|
||||
* @param {string} modelId
|
||||
* @returns {{ name:string, ctx:number, compact:number, effort?:string, summary?:boolean, toolLimit:number }|null}
|
||||
*/
|
||||
export function categoriseModel(modelId) {
|
||||
const id = modelId.toLowerCase();
|
||||
|
||||
// ── Thinking models (max effort, detailed summary) ────────────────────────
|
||||
const thinkingPatterns = [
|
||||
{ re: /kmc\/kimi-k2\.7/, name: "kimi-k27", ctx: 131072, compact: 112000, toolLimit: 32768 },
|
||||
{ re: /kmc\/kimi-k2\.6/, name: "kimi-k26", ctx: 131072, compact: 112000, toolLimit: 32768 },
|
||||
{ re: /glm\/glm-5\.2-max/, name: "glm52max", ctx: 131072, compact: 112000, toolLimit: 32768 },
|
||||
{ re: /glm\/glm-5\.2$/, name: "glm52", ctx: 131072, compact: 112000, toolLimit: 32768 },
|
||||
{ re: /opencode-go\/mimo-v2\.5-pro/, name: "mimo-pro", ctx: 131072, compact: 112000, toolLimit: 32768 },
|
||||
{ re: /opencode-go\/qwen3\.7-plus/, name: "qwen37plus", ctx: 32768, compact: 28000, toolLimit: 16384 },
|
||||
];
|
||||
|
||||
// ── Good models (high effort) ─────────────────────────────────────────────
|
||||
const goodPatterns = [
|
||||
{ re: /ollamacloud\/deepseek-v4-pro/, name: "deepseek-pro", ctx: 131072, compact: 112000, toolLimit: 32768 },
|
||||
{ re: /opencode-go\/mimo-v2\.5$/, name: "mimo", ctx: 131072, compact: 112000, toolLimit: 32768 },
|
||||
];
|
||||
|
||||
// ── Simple models (no effort) ─────────────────────────────────────────────
|
||||
const simplePatterns = [
|
||||
{ re: /ollamacloud\/gemma4:31b/, name: "gemma4", ctx: 32768, compact: 28000, toolLimit: 16384 },
|
||||
{ re: /ollamacloud\/nemotron-3-super/, name: "nemotron", ctx: 32768, compact: 28000, toolLimit: 16384 },
|
||||
{ re: /ollamacloud\/gpt-oss:20b/, name: "gptoss", ctx: 32768, compact: 28000, toolLimit: 16384 },
|
||||
];
|
||||
|
||||
// ── Fast models (low effort) ──────────────────────────────────────────────
|
||||
const fastPatterns = [
|
||||
{ re: /ollamacloud\/deepseek-v4-flash/, name: "deepseek-flash", ctx: 65536, compact: 56000, toolLimit: 16384 },
|
||||
{ re: /ollamacloud\/gemini-3-flash/, name: "gemini-flash", ctx: 1000000, compact: 850000, toolLimit: 32768 },
|
||||
{ re: /glm\/glm-5-turbo/, name: "glm5turbo", ctx: 131072, compact: 112000, toolLimit: 16384 },
|
||||
{ re: /glm\/glm-4\.7-flash/, name: "glm47flash", ctx: 131072, compact: 112000, toolLimit: 16384 },
|
||||
];
|
||||
|
||||
for (const p of thinkingPatterns) {
|
||||
if (p.re.test(id)) return { ...p, effort: "xhigh", summary: true };
|
||||
}
|
||||
for (const p of goodPatterns) {
|
||||
if (p.re.test(id)) return { ...p, effort: "high", summary: false };
|
||||
}
|
||||
for (const p of simplePatterns) {
|
||||
if (p.re.test(id)) return { ...p, effort: undefined, summary: false };
|
||||
}
|
||||
for (const p of fastPatterns) {
|
||||
if (p.re.test(id)) return { ...p, effort: "low", summary: false };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Build the TOML content for a single profile. */
|
||||
function buildProfileToml(modelId, cfg) {
|
||||
const lines = [
|
||||
`# codex --profile ${cfg.name}`,
|
||||
`# ${modelId}`,
|
||||
`model = "${modelId}"`,
|
||||
`model_provider = "omniroute"`,
|
||||
];
|
||||
|
||||
if (cfg.effort) {
|
||||
lines.push(`model_reasoning_effort = "${cfg.effort}"`);
|
||||
}
|
||||
if (cfg.summary) {
|
||||
lines.push(`model_reasoning_summary = "detailed"`);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
`model_context_window = ${cfg.ctx}`,
|
||||
`model_auto_compact_token_limit = ${cfg.compact}`,
|
||||
`tool_output_token_limit = ${cfg.toolLimit}`
|
||||
);
|
||||
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
// ── Command ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param {{remote?:string, port?:string, apiKey?:string, codexHome?:string, dryRun?:boolean, only?:string}} opts
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
export async function runSetupCodexCommand(opts = {}) {
|
||||
const port = Number(opts.port ?? process.env.PORT ?? 20128) || 20128;
|
||||
const baseUrl = (opts.remote ?? `http://localhost:${port}`).replace(/\/v1$/, "");
|
||||
const apiKey = opts.apiKey ?? opts["api-key"] ?? process.env.OMNIROUTE_API_KEY ?? "";
|
||||
const codexHome = opts.codexHome ?? opts["codex-home"] ?? join(os.homedir(), ".codex");
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const onlyFilter = opts.only ? opts.only.split(",").map((s) => s.trim()) : null;
|
||||
|
||||
printHeading(`OmniRoute → Codex CLI profile generator`);
|
||||
printInfo(`Connecting to ${baseUrl} …`);
|
||||
|
||||
// ── Fetch model catalog ───────────────────────────────────────────────────
|
||||
let models;
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
|
||||
const res = await fetch(`${baseUrl}/v1/models`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
||||
const body = await res.json();
|
||||
models = body.data ?? body.models ?? [];
|
||||
} catch (err) {
|
||||
printError(`Failed to fetch models: ${err.message}`);
|
||||
printInfo(
|
||||
"Make sure OmniRoute is running and the --remote URL is correct.\n" +
|
||||
"You may also need --api-key if OmniRoute requires authentication."
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
printInfo(`Received ${models.length} models from ${baseUrl}`);
|
||||
|
||||
// ── Ensure codex home exists ──────────────────────────────────────────────
|
||||
if (!dryRun && !existsSync(codexHome)) {
|
||||
mkdirSync(codexHome, { recursive: true });
|
||||
}
|
||||
|
||||
// ── Generate profiles ─────────────────────────────────────────────────────
|
||||
let written = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const m of models) {
|
||||
const id = typeof m === "string" ? m : (m.id ?? "");
|
||||
if (!id) continue;
|
||||
if (onlyFilter && !onlyFilter.some((f) => id.includes(f))) continue;
|
||||
|
||||
const cfg = categoriseModel(id);
|
||||
if (!cfg) continue;
|
||||
|
||||
const filePath = join(codexHome, `${cfg.name}.config.toml`);
|
||||
const content = buildProfileToml(id, cfg);
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\n── [dry-run] ${filePath} ──`);
|
||||
console.log(content);
|
||||
} else {
|
||||
writeFileSync(filePath, content, "utf8");
|
||||
printSuccess(` ✓ ${cfg.name}.config.toml (${id})`);
|
||||
}
|
||||
written++;
|
||||
}
|
||||
|
||||
skipped = models.length - written;
|
||||
|
||||
if (!dryRun) {
|
||||
console.log("");
|
||||
printSuccess(`${written} profiles written to ${codexHome}`);
|
||||
if (skipped > 0) {
|
||||
printInfo(`${skipped} models skipped (no matching profile pattern)`);
|
||||
}
|
||||
console.log("\nTo use a profile:");
|
||||
console.log(" codex --profile <name> # e.g. codex --profile glm52");
|
||||
console.log(" codex -p <name> # short form");
|
||||
} else {
|
||||
console.log(`\n[dry-run] ${written} profiles would be written (${skipped} skipped)`);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerSetupCodex(program) {
|
||||
program
|
||||
.command("setup-codex")
|
||||
.description(
|
||||
"Fetch the live model catalog from OmniRoute (local or remote VPS) and generate " +
|
||||
"~/.codex/<name>.config.toml profiles for each supported model"
|
||||
)
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option(
|
||||
"--remote <url>",
|
||||
"Remote OmniRoute URL, e.g. http://100.67.86.91:20128 — fetches models from there"
|
||||
)
|
||||
.option(
|
||||
"--api-key <key>",
|
||||
"OmniRoute API key for the remote instance (defaults to OMNIROUTE_API_KEY env var)"
|
||||
)
|
||||
.option(
|
||||
"--codex-home <dir>",
|
||||
"Directory where profile files are written (default: ~/.codex)"
|
||||
)
|
||||
.option(
|
||||
"--only <patterns>",
|
||||
"Comma-separated substrings — only generate profiles for matching model IDs (e.g. glm,kimi)"
|
||||
)
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.action(async (opts) => {
|
||||
const exitCode = await runSetupCodexCommand(opts);
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
}
|
||||
173
bin/cli/commands/setup-continue.mjs
Normal file
173
bin/cli/commands/setup-continue.mjs
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
/**
|
||||
* omniroute setup-continue — configure Continue (continue.dev) for OmniRoute.
|
||||
*
|
||||
* Continue uses a file-based, mergeable ~/.continue/config.yaml shared by the VS
|
||||
* Code / JetBrains extensions AND the `cn` CLI. Models use `provider: openai`
|
||||
* with a custom `apiBase` (WITH /v1 — Continue appends /chat/completions) and an
|
||||
* `apiKey: ${{ secrets.OMNIROUTE_API_KEY }}` reference (secret never written to
|
||||
* config.yaml). Remote-aware; curated model set with Continue roles.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
import { categoriseModel } from "./setup-codex.mjs";
|
||||
|
||||
const SECRET_REF = "${{ secrets.OMNIROUTE_API_KEY }}";
|
||||
|
||||
function ensureV1(url) {
|
||||
const s = String(url || "").replace(/\/+$/, "");
|
||||
return s.endsWith("/v1") ? s : `${s}/v1`;
|
||||
}
|
||||
|
||||
/** Resolve apiBase (WITH /v1) + apiKey from flags → active context → localhost. */
|
||||
export function resolveContinueTarget(opts = {}) {
|
||||
let root;
|
||||
if (opts.remote) root = String(opts.remote).replace(/\/+$/, "");
|
||||
else {
|
||||
try {
|
||||
root = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
if (!root) root = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
let apiKey = opts.apiKey ?? opts["api-key"];
|
||||
if (!apiKey) {
|
||||
try {
|
||||
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
apiKey = c?.accessToken || c?.apiKey;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
}
|
||||
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
|
||||
return { apiBase: ensureV1(root), apiKey };
|
||||
}
|
||||
|
||||
/** Build Continue model entries (provider: openai) for the given catalog ids. */
|
||||
export function buildContinueModels(modelIds, apiBase) {
|
||||
const out = [];
|
||||
for (const id of modelIds) {
|
||||
const cfg = categoriseModel(id);
|
||||
if (!cfg) continue;
|
||||
const roles = ["chat", "edit", "apply"];
|
||||
if (cfg.effort === "low") roles.push("autocomplete"); // fast tier → autocomplete
|
||||
out.push({
|
||||
name: `OmniRoute: ${id}`,
|
||||
provider: "openai",
|
||||
model: id,
|
||||
apiBase,
|
||||
apiKey: SECRET_REF,
|
||||
roles,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge OmniRoute models into an existing Continue config object: drop any prior
|
||||
* models pointing at this apiBase, keep everything else, append the new set.
|
||||
*/
|
||||
export function mergeContinueConfig(existing, newModels, apiBase) {
|
||||
const cfg = existing && typeof existing === "object" ? { ...existing } : {};
|
||||
const prior = Array.isArray(cfg.models) ? cfg.models : [];
|
||||
const kept = prior.filter((m) => !m || m.apiBase !== apiBase);
|
||||
cfg.models = [...kept, ...newModels];
|
||||
if (!cfg.name) cfg.name = "OmniRoute Config";
|
||||
if (!cfg.version) cfg.version = "1.0";
|
||||
if (!cfg.schema) cfg.schema = "v1";
|
||||
return cfg;
|
||||
}
|
||||
|
||||
async function fetchModelIds(apiBase, apiKey) {
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
const res = await fetch(`${apiBase.replace(/\/v1$/, "")}/v1/models`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = await res.json();
|
||||
const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
|
||||
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
|
||||
} catch (e) {
|
||||
throw new Error(`Could not fetch models: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSetupContinueCommand(opts = {}) {
|
||||
const { apiBase, apiKey } = resolveContinueTarget(opts);
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const only = opts.only ? opts.only.split(",").map((s) => s.trim()).filter(Boolean) : null;
|
||||
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".continue", "config.yaml");
|
||||
|
||||
printHeading("OmniRoute → Continue (config.yaml)");
|
||||
printInfo(`apiBase: ${apiBase}`);
|
||||
|
||||
let ids;
|
||||
try {
|
||||
ids = await fetchModelIds(apiBase, apiKey);
|
||||
} catch (e) {
|
||||
printError(e.message);
|
||||
printInfo("Make sure OmniRoute is running and --remote/--api-key are correct.");
|
||||
return 1;
|
||||
}
|
||||
if (only) ids = ids.filter((id) => only.some((f) => id.includes(f)));
|
||||
|
||||
const models = buildContinueModels(ids, apiBase);
|
||||
if (!models.length) {
|
||||
printError("No matching curated models in the catalog (try --only or check the server).");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const yaml = await import("js-yaml");
|
||||
let existing = {};
|
||||
if (existsSync(configPath)) {
|
||||
try {
|
||||
existing = yaml.load(readFileSync(configPath, "utf8")) || {};
|
||||
} catch {
|
||||
printInfo("Existing config.yaml unparseable — starting fresh (a .bak is kept).");
|
||||
if (!dryRun) writeFileSync(`${configPath}.bak`, readFileSync(configPath));
|
||||
existing = {};
|
||||
}
|
||||
}
|
||||
const merged = mergeContinueConfig(existing, models, apiBase);
|
||||
const out = yaml.dump(merged, { lineWidth: -1 });
|
||||
|
||||
if (dryRun) {
|
||||
console.log("\n" + (out.length > 3500 ? out.slice(0, 3500) + "\n… (truncated)" : out));
|
||||
printInfo(`[dry-run] ${models.length} OmniRoute model(s) → ${configPath}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
mkdirSync(join(configPath, ".."), { recursive: true });
|
||||
writeFileSync(configPath, out, "utf8");
|
||||
printSuccess(`Wrote ${configPath} (${models.length} OmniRoute models)`);
|
||||
printInfo("\nProvide the key (config.yaml references it, not stores it):");
|
||||
printInfo(" cn CLI: export OMNIROUTE_API_KEY=... (read from your shell)");
|
||||
printInfo(" IDE: echo 'OMNIROUTE_API_KEY=...' >> ~/.continue/.env");
|
||||
printInfo("Run: cn -p \"reply OK\"");
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerSetupContinue(program) {
|
||||
program
|
||||
.command("setup-continue")
|
||||
.description(
|
||||
"Generate ~/.continue/config.yaml (Continue / cn CLI) from the OmniRoute model catalog"
|
||||
)
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
|
||||
.option("--only <patterns>", "Comma-separated substrings — keep only matching model IDs")
|
||||
.option("--config-path <path>", "config.yaml path (default: ~/.continue/config.yaml)")
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.action(async (opts) => {
|
||||
const code = await runSetupContinueCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
148
bin/cli/commands/setup-crush.mjs
Normal file
148
bin/cli/commands/setup-crush.mjs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
/**
|
||||
* omniroute setup-crush — configure Crush (charmbracelet/crush) for OmniRoute.
|
||||
*
|
||||
* Crush is a terminal AI agent with a file-based config: ~/.config/crush/crush.json
|
||||
* (or ./crush.json). It supports a custom `openai-compat` provider. base_url must
|
||||
* include /v1; the api_key may reference an env var (`$OMNIROUTE_API_KEY`) so the
|
||||
* secret stays out of the file. Remote-aware; curated catalog models.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
import { categoriseModel } from "./setup-codex.mjs";
|
||||
|
||||
const API_KEY_REF = "$OMNIROUTE_API_KEY";
|
||||
|
||||
function ensureV1(url) {
|
||||
const s = String(url || "").replace(/\/+$/, "");
|
||||
return s.endsWith("/v1") ? s : `${s}/v1`;
|
||||
}
|
||||
|
||||
/** Resolve base_url (WITH /v1) + apiKey from flags → active context → localhost. */
|
||||
export function resolveCrushTarget(opts = {}) {
|
||||
let root;
|
||||
if (opts.remote) root = String(opts.remote).replace(/\/+$/, "");
|
||||
else {
|
||||
try {
|
||||
root = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
if (!root) root = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
let apiKey = opts.apiKey ?? opts["api-key"];
|
||||
if (!apiKey) {
|
||||
try {
|
||||
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
apiKey = c?.accessToken || c?.apiKey;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
}
|
||||
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
|
||||
return { baseUrl: ensureV1(root), apiKey };
|
||||
}
|
||||
|
||||
/** Build the Crush `openai-compat` provider block from curated catalog ids. */
|
||||
export function buildCrushProvider(modelIds, baseUrl) {
|
||||
const models = [];
|
||||
for (const id of modelIds) {
|
||||
const cfg = categoriseModel(id);
|
||||
if (!cfg) continue;
|
||||
models.push({ id, name: `OmniRoute: ${id}`, context_window: cfg.ctx });
|
||||
}
|
||||
return {
|
||||
type: "openai-compat",
|
||||
base_url: baseUrl,
|
||||
api_key: API_KEY_REF,
|
||||
models,
|
||||
};
|
||||
}
|
||||
|
||||
/** Merge the OmniRoute provider into an existing crush.json (preserve the rest). */
|
||||
export function mergeCrushConfig(existing, provider) {
|
||||
const cfg = existing && typeof existing === "object" ? { ...existing } : {};
|
||||
cfg.providers = { ...(cfg.providers || {}), omniroute: provider };
|
||||
return cfg;
|
||||
}
|
||||
|
||||
function readJson(path) {
|
||||
try {
|
||||
if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch {
|
||||
/* corrupt/missing */
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function fetchModelIds(baseUrl, apiKey) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
const res = await fetch(`${baseUrl.replace(/\/v1$/, "")}/v1/models`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = await res.json();
|
||||
const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
|
||||
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
|
||||
}
|
||||
|
||||
export async function runSetupCrushCommand(opts = {}) {
|
||||
const { baseUrl, apiKey } = resolveCrushTarget(opts);
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const only = opts.only ? opts.only.split(",").map((s) => s.trim()).filter(Boolean) : null;
|
||||
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".config", "crush", "crush.json");
|
||||
|
||||
printHeading("OmniRoute → Crush (openai-compat)");
|
||||
printInfo(`base_url: ${baseUrl}`);
|
||||
|
||||
let ids;
|
||||
try {
|
||||
ids = await fetchModelIds(baseUrl, apiKey);
|
||||
} catch (e) {
|
||||
printError(`Could not fetch models: ${e.message}`);
|
||||
printInfo("Make sure OmniRoute is running and --remote/--api-key are correct.");
|
||||
return 1;
|
||||
}
|
||||
if (only) ids = ids.filter((id) => only.some((f) => id.includes(f)));
|
||||
|
||||
const provider = buildCrushProvider(ids, baseUrl);
|
||||
if (!provider.models.length) {
|
||||
printError("No matching curated models (try --only or check the server).");
|
||||
return 1;
|
||||
}
|
||||
const merged = mergeCrushConfig(readJson(configPath), provider);
|
||||
const out = JSON.stringify(merged, null, 2) + "\n";
|
||||
|
||||
if (dryRun) {
|
||||
console.log("\n" + (out.length > 3500 ? out.slice(0, 3500) + "\n… (truncated)" : out));
|
||||
printInfo(`[dry-run] ${provider.models.length} model(s) under providers.omniroute → ${configPath}`);
|
||||
return 0;
|
||||
}
|
||||
mkdirSync(join(configPath, ".."), { recursive: true });
|
||||
writeFileSync(configPath, out, "utf8");
|
||||
printSuccess(`Wrote ${configPath} (${provider.models.length} models under providers.omniroute)`);
|
||||
printInfo("Provide the key (config references $OMNIROUTE_API_KEY): export OMNIROUTE_API_KEY=...");
|
||||
printInfo("Then run: crush");
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerSetupCrush(program) {
|
||||
program
|
||||
.command("setup-crush")
|
||||
.description("Generate the OmniRoute openai-compat provider in ~/.config/crush/crush.json")
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
|
||||
.option("--only <patterns>", "Comma-separated substrings — keep only matching model IDs")
|
||||
.option("--config-path <path>", "crush.json path (default: ~/.config/crush/crush.json)")
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.action(async (opts) => {
|
||||
const code = await runSetupCrushCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
108
bin/cli/commands/setup-cursor.mjs
Normal file
108
bin/cli/commands/setup-cursor.mjs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
/**
|
||||
* omniroute setup-cursor — guide Cursor to use OmniRoute.
|
||||
*
|
||||
* Cursor stores its OpenAI key + "Override OpenAI Base URL" in an opaque SQLite
|
||||
* DB (state.vscdb) with no documented stable schema — NOT safe to file-write.
|
||||
* So this command prints the exact in-app steps (and can list available models
|
||||
* from /v1/models). Note: Cursor's custom base URL only powers the Chat panel;
|
||||
* Composer / inline-edit / autocomplete stay on Cursor's own backend.
|
||||
*/
|
||||
|
||||
import { printHeading, printInfo, printSuccess } from "../io.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
function ensureV1(url) {
|
||||
const s = String(url || "").replace(/\/+$/, "");
|
||||
return s.endsWith("/v1") ? s : `${s}/v1`;
|
||||
}
|
||||
|
||||
/** Resolve apiBase (WITH /v1 — Cursor appends /chat/completions) + apiKey. */
|
||||
export function resolveCursorTarget(opts = {}) {
|
||||
let root;
|
||||
if (opts.remote) root = String(opts.remote).replace(/\/+$/, "");
|
||||
else {
|
||||
try {
|
||||
root = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
if (!root) root = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
let apiKey = opts.apiKey ?? opts["api-key"];
|
||||
if (!apiKey) {
|
||||
try {
|
||||
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
apiKey = c?.accessToken || c?.apiKey;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
}
|
||||
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
|
||||
return { apiBase: ensureV1(root), apiKey };
|
||||
}
|
||||
|
||||
/** The step-by-step Cursor UI instructions (pure → testable). */
|
||||
export function buildCursorInstructions({ apiBase, models }) {
|
||||
const lines = [
|
||||
"Cursor stores this config in an opaque database, so configure it in the app:",
|
||||
"",
|
||||
" 1. Cursor → Settings (Cmd/Ctrl + ,) → Models",
|
||||
" 2. Enable “Override OpenAI Base URL” and set it to:",
|
||||
` ${apiBase} (the /v1 suffix is required)`,
|
||||
" 3. Set the OpenAI API Key to your OmniRoute key (OMNIROUTE_API_KEY)",
|
||||
" 4. Add the model name(s) you want under “Models” (Cursor has no auto-discovery):",
|
||||
];
|
||||
const sample = (models && models.length ? models : ["glm/glm-5.2", "kmc/kimi-k2.7"]).slice(0, 8);
|
||||
lines.push(` e.g. ${sample.join(", ")}`);
|
||||
lines.push(" 5. Use the Chat panel (Cmd/Ctrl + L) to verify.");
|
||||
lines.push("");
|
||||
lines.push("⚠ The custom base URL powers the CHAT panel only — Composer, inline edit");
|
||||
lines.push(" (Cmd/Ctrl+K) and autocomplete keep using Cursor's own backend.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function fetchModelIds(apiBase, apiKey) {
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
const res = await fetch(`${apiBase.replace(/\/v1$/, "")}/v1/models`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const body = await res.json();
|
||||
const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
|
||||
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSetupCursorCommand(opts = {}) {
|
||||
const { apiBase, apiKey } = resolveCursorTarget(opts);
|
||||
printHeading("OmniRoute → Cursor");
|
||||
printInfo(`Server: ${apiBase}`);
|
||||
|
||||
let models = [];
|
||||
const only = opts.only ? opts.only.split(",").map((s) => s.trim()).filter(Boolean) : null;
|
||||
const ids = await fetchModelIds(apiBase, apiKey);
|
||||
models = only ? ids.filter((id) => only.some((f) => id.includes(f))) : ids;
|
||||
|
||||
console.log("\n" + buildCursorInstructions({ apiBase, models }));
|
||||
printSuccess("\nCursor is configured manually (no file written — Cursor's storage is opaque).");
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerSetupCursor(program) {
|
||||
program
|
||||
.command("setup-cursor")
|
||||
.description("Print the steps to point Cursor at OmniRoute (chat panel; Cursor config is not file-writable)")
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
|
||||
.option("--only <patterns>", "Comma-separated substrings — suggest only matching model IDs")
|
||||
.action(async (opts) => {
|
||||
const code = await runSetupCursorCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
148
bin/cli/commands/setup-gemini.mjs
Normal file
148
bin/cli/commands/setup-gemini.mjs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
/**
|
||||
* omniroute setup-gemini — point the Gemini CLI at OmniRoute's Gemini endpoint.
|
||||
*
|
||||
* The Gemini CLI is NOT OpenAI-compatible — it speaks the native Gemini API.
|
||||
* OmniRoute exposes a Gemini-native surface at /v1beta (e.g.
|
||||
* /v1beta/models/<model>:generateContent), so the CLI can target it via the
|
||||
* @google/genai SDK env `GOOGLE_GEMINI_BASE_URL` (ROOT — the SDK appends /v1beta)
|
||||
* + `GEMINI_API_KEY`. There is no settings.json key for the base URL, so this is
|
||||
* primarily an env recipe; we optionally write ~/.gemini/settings.json `model`.
|
||||
*
|
||||
* ⚠ Known Gemini CLI caveat: it may ignore GOOGLE_GEMINI_BASE_URL if a cached
|
||||
* Google login exists — run `gemini` logged-out / API-key-only for it to take.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
function stripToRoot(url) {
|
||||
const s = String(url || "").replace(/\/+$/, "");
|
||||
return s.endsWith("/v1beta") ? s.slice(0, -7) : s.endsWith("/v1") ? s.slice(0, -3) : s;
|
||||
}
|
||||
|
||||
/** Resolve GOOGLE_GEMINI_BASE_URL (ROOT — SDK appends /v1beta) + apiKey. */
|
||||
export function resolveGeminiTarget(opts = {}) {
|
||||
let root;
|
||||
if (opts.remote) root = stripToRoot(opts.remote);
|
||||
else {
|
||||
try {
|
||||
root = stripToRoot(resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl);
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
if (!root) root = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
let apiKey = opts.apiKey ?? opts["api-key"];
|
||||
if (!apiKey) {
|
||||
try {
|
||||
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
apiKey = c?.accessToken || c?.apiKey;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
}
|
||||
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
|
||||
return { baseUrl: root, apiKey };
|
||||
}
|
||||
|
||||
/** The guaranteed env recipe (pure → testable). */
|
||||
export function buildGeminiRecipe({ baseUrl, model }) {
|
||||
return [
|
||||
`export GOOGLE_GEMINI_BASE_URL=${baseUrl}`,
|
||||
"export GEMINI_API_KEY=$OMNIROUTE_API_KEY",
|
||||
`export GEMINI_MODEL=${model}`,
|
||||
`gemini -p "reply OK" # or: gemini (interactive)`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** Merge the model into ~/.gemini/settings.json (base URL is env-only). */
|
||||
export function buildGeminiSettings(existing, { model }) {
|
||||
const s = existing && typeof existing === "object" ? { ...existing } : {};
|
||||
if (model) s.model = model;
|
||||
return s;
|
||||
}
|
||||
|
||||
function readJson(path) {
|
||||
try {
|
||||
if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch {
|
||||
/* corrupt/missing */
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function fetchGeminiModelIds(baseUrl, apiKey) {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/v1beta/models`, {
|
||||
headers: { "x-goog-api-key": apiKey || "" },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const body = await res.json();
|
||||
return (body.models || []).map((m) => String(m.name || "").replace(/^models\//, "")).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSetupGeminiCommand(opts = {}) {
|
||||
const { baseUrl, apiKey } = resolveGeminiTarget(opts);
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".gemini", "settings.json");
|
||||
|
||||
printHeading("OmniRoute → Gemini CLI (native Gemini /v1beta endpoint)");
|
||||
printInfo(`GOOGLE_GEMINI_BASE_URL: ${baseUrl} (root — SDK appends /v1beta)`);
|
||||
|
||||
let model = opts.model;
|
||||
if (!model) {
|
||||
const ids = await fetchGeminiModelIds(baseUrl, apiKey);
|
||||
if (ids.length && !opts.yes) {
|
||||
printInfo(`Examples: ${ids.slice(0, 20).join(", ")}${ids.length > 20 ? " …" : ""}`);
|
||||
const prompt = createPrompt();
|
||||
try {
|
||||
model = await prompt.ask("Model id for Gemini CLI");
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!model) {
|
||||
printError("A model is required. Pass --model <id>.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\n── [dry-run] ${configPath} ── { "model": "${model}" }`);
|
||||
} else {
|
||||
const merged = buildGeminiSettings(readJson(configPath), { model });
|
||||
mkdirSync(join(configPath, ".."), { recursive: true });
|
||||
writeFileSync(configPath, JSON.stringify(merged, null, 2) + "\n", "utf8");
|
||||
printSuccess(`Wrote ${configPath} (model)`);
|
||||
}
|
||||
|
||||
printInfo("\nThe base URL is env-only for Gemini CLI — export these:");
|
||||
console.log(buildGeminiRecipe({ baseUrl, model }));
|
||||
printInfo("\n⚠ If Gemini CLI ignores the base URL, you have a cached Google login —");
|
||||
printInfo(" run logged-out (API-key only) so GOOGLE_GEMINI_BASE_URL takes effect.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerSetupGemini(program) {
|
||||
program
|
||||
.command("setup-gemini")
|
||||
.description("Point the Gemini CLI at OmniRoute's native Gemini /v1beta endpoint (env recipe + settings model)")
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
|
||||
.option("--model <id>", "Model id for Gemini CLI (required unless picked interactively)")
|
||||
.option("--config-path <path>", "settings.json path (default: ~/.gemini/settings.json)")
|
||||
.option("--yes", "Non-interactive: do not prompt (requires --model)")
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.action(async (opts) => {
|
||||
const code = await runSetupGeminiCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
150
bin/cli/commands/setup-goose.mjs
Normal file
150
bin/cli/commands/setup-goose.mjs
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* omniroute setup-goose — configure Goose (block/goose) for OmniRoute.
|
||||
*
|
||||
* Goose is a terminal AI agent with a file-based config at
|
||||
* ~/.config/goose/config.yaml and env-var overrides. For a custom OpenAI-
|
||||
* compatible endpoint it uses provider `openai` with OPENAI_HOST = the ROOT url
|
||||
* (NO /v1 — Goose appends the path itself). This writes the config.yaml keys and
|
||||
* prints the guaranteed env-var recipe (the API key lives in the OS keyring / env,
|
||||
* never the config file).
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
function stripToRoot(url) {
|
||||
const s = String(url || "").replace(/\/+$/, "");
|
||||
return s.endsWith("/v1") ? s.slice(0, -3) : s;
|
||||
}
|
||||
|
||||
/** Resolve OPENAI_HOST (ROOT, no /v1 — Goose appends) + apiKey. */
|
||||
export function resolveGooseTarget(opts = {}) {
|
||||
let root;
|
||||
if (opts.remote) root = stripToRoot(opts.remote);
|
||||
else {
|
||||
try {
|
||||
root = stripToRoot(resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl);
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
if (!root) root = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
let apiKey = opts.apiKey ?? opts["api-key"];
|
||||
if (!apiKey) {
|
||||
try {
|
||||
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
apiKey = c?.accessToken || c?.apiKey;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
}
|
||||
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
|
||||
return { host: root, apiKey };
|
||||
}
|
||||
|
||||
/** Merge Goose's provider/model keys into config.yaml object (preserve the rest). */
|
||||
export function buildGooseConfig(existing, { host, model }) {
|
||||
const cfg = existing && typeof existing === "object" ? { ...existing } : {};
|
||||
cfg.GOOSE_PROVIDER = "openai";
|
||||
cfg.GOOSE_MODEL = model;
|
||||
cfg.OPENAI_HOST = host; // ROOT — Goose appends /v1/chat/completions
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/** The guaranteed env-var recipe (pure → testable). */
|
||||
export function buildGooseEnvRecipe({ host, model }) {
|
||||
return [
|
||||
"export GOOSE_PROVIDER=openai",
|
||||
`export OPENAI_HOST=${host}`,
|
||||
"export OPENAI_API_KEY=$OMNIROUTE_API_KEY",
|
||||
`export GOOSE_MODEL=${model}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function readYamlSafe(yaml, path) {
|
||||
try {
|
||||
if (existsSync(path)) return yaml.load(readFileSync(path, "utf8")) || {};
|
||||
} catch {
|
||||
/* corrupt/missing */
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function fetchModelIds(host, apiKey) {
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
const res = await fetch(`${host}/v1/models`, { headers, signal: AbortSignal.timeout(8000) });
|
||||
if (!res.ok) return [];
|
||||
const body = await res.json();
|
||||
const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
|
||||
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSetupGooseCommand(opts = {}) {
|
||||
const { host, apiKey } = resolveGooseTarget(opts);
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".config", "goose", "config.yaml");
|
||||
|
||||
printHeading("OmniRoute → Goose (openai-compatible)");
|
||||
printInfo(`OPENAI_HOST: ${host} (no /v1 — Goose appends it)`);
|
||||
|
||||
let model = opts.model;
|
||||
if (!model) {
|
||||
const ids = await fetchModelIds(host, apiKey);
|
||||
if (ids.length && !opts.yes) {
|
||||
printInfo(`Examples: ${ids.slice(0, 20).join(", ")}${ids.length > 20 ? " …" : ""}`);
|
||||
const prompt = createPrompt();
|
||||
try {
|
||||
model = await prompt.ask("Model id for Goose");
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!model) {
|
||||
printError("A model is required. Pass --model <id>.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
const yaml = await import("js-yaml");
|
||||
const merged = buildGooseConfig(readYamlSafe(yaml, configPath), { host, model });
|
||||
const out = yaml.dump(merged, { lineWidth: -1 });
|
||||
|
||||
if (dryRun) {
|
||||
console.log("\n" + out);
|
||||
printInfo(`[dry-run] → ${configPath}`);
|
||||
} else {
|
||||
mkdirSync(join(configPath, ".."), { recursive: true });
|
||||
writeFileSync(configPath, out, "utf8");
|
||||
printSuccess(`Wrote ${configPath}`);
|
||||
}
|
||||
|
||||
printInfo("\nProvide the key (Goose reads it from the env / OS keyring):");
|
||||
console.log(buildGooseEnvRecipe({ host, model }));
|
||||
printInfo("Then run: goose session (or: goose run -t \"reply OK\")");
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerSetupGoose(program) {
|
||||
program
|
||||
.command("setup-goose")
|
||||
.description("Configure Goose for OmniRoute: write ~/.config/goose/config.yaml + print the env recipe")
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
|
||||
.option("--model <id>", "Model id for Goose (required unless picked interactively)")
|
||||
.option("--config-path <path>", "config.yaml path (default: ~/.config/goose/config.yaml)")
|
||||
.option("--yes", "Non-interactive: do not prompt (requires --model)")
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.action(async (opts) => {
|
||||
const code = await runSetupGooseCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
178
bin/cli/commands/setup-kilo.mjs
Normal file
178
bin/cli/commands/setup-kilo.mjs
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
/**
|
||||
* omniroute setup-kilo — configure Kilo Code to use OmniRoute.
|
||||
*
|
||||
* Kilo Code (kilocode.kilo-code, a Cline/Roo descendant) has two surfaces:
|
||||
* - CLI/standalone mode reads ~/.local/share/kilo/auth.json.
|
||||
* - The VS Code extension reads `kilocode.*` keys from VS Code settings.json.
|
||||
* This writes BOTH (matching the OmniRoute dashboard) and prints the UI settings.
|
||||
*
|
||||
* Unlike Cline, Kilo's openAi baseURL INCLUDES /v1 (it appends /chat/completions).
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
/** Ensure the URL ends with /v1 (Kilo appends /chat/completions to it). */
|
||||
function ensureV1(url) {
|
||||
const s = String(url || "").replace(/\/+$/, "");
|
||||
return s.endsWith("/v1") ? s : `${s}/v1`;
|
||||
}
|
||||
|
||||
/** Resolve baseUrl (WITH /v1) + apiKey from flags → active context → localhost. */
|
||||
export function resolveKiloTarget(opts = {}) {
|
||||
let root;
|
||||
if (opts.remote) root = String(opts.remote).replace(/\/+$/, "");
|
||||
else {
|
||||
try {
|
||||
root = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
if (!root) root = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
let apiKey = opts.apiKey ?? opts["api-key"];
|
||||
if (!apiKey) {
|
||||
try {
|
||||
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
apiKey = c?.accessToken || c?.apiKey;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
}
|
||||
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
|
||||
return { baseUrl: ensureV1(root), apiKey };
|
||||
}
|
||||
|
||||
/** Merge the OmniRoute openai-compatible provider into Kilo's CLI auth.json. */
|
||||
export function buildKiloAuth(existing, { apiKey, baseUrl, model }) {
|
||||
const auth = { ...(existing || {}) };
|
||||
auth["openai-compatible"] = {
|
||||
...(auth["openai-compatible"] || {}),
|
||||
apiKey: apiKey || "sk_omniroute",
|
||||
baseUrl,
|
||||
model,
|
||||
};
|
||||
return auth;
|
||||
}
|
||||
|
||||
/** Merge the kilocode.* keys into VS Code settings.json (extension surface). */
|
||||
export function buildKiloVscodeSettings(existing, { apiKey, baseUrl, model }) {
|
||||
const s = { ...(existing || {}) };
|
||||
s["kilocode.customProvider"] = { name: "OmniRoute", baseURL: baseUrl, apiKey: apiKey || "sk_omniroute" };
|
||||
s["kilocode.defaultModel"] = model;
|
||||
return s;
|
||||
}
|
||||
|
||||
function readJson(path) {
|
||||
try {
|
||||
if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch {
|
||||
/* corrupt/missing */
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function fetchModelIds(root, apiKey) {
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
const res = await fetch(`${root.replace(/\/v1$/, "")}/v1/models`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const body = await res.json();
|
||||
const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
|
||||
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSetupKiloCommand(opts = {}) {
|
||||
const { baseUrl, apiKey } = resolveKiloTarget(opts);
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const authPath = opts.authPath ?? opts["auth-path"] ?? join(os.homedir(), ".local", "share", "kilo", "auth.json");
|
||||
const vscodePath =
|
||||
opts.vscodeSettings ?? opts["vscode-settings"] ?? join(os.homedir(), ".config", "Code", "User", "settings.json");
|
||||
|
||||
printHeading("OmniRoute → Kilo Code (OpenAI-compatible)");
|
||||
printInfo(`Server: ${baseUrl}`);
|
||||
|
||||
let model = opts.model;
|
||||
if (!model) {
|
||||
const ids = await fetchModelIds(baseUrl, apiKey);
|
||||
if (ids.length && !opts.yes) {
|
||||
printInfo(`Examples: ${ids.slice(0, 20).join(", ")}${ids.length > 20 ? " …" : ""}`);
|
||||
const prompt = createPrompt();
|
||||
try {
|
||||
model = await prompt.ask("Model id for Kilo");
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!model) {
|
||||
printError("A model is required. Pass --model <id> (Kilo's extension has no model auto-discovery).");
|
||||
return 2;
|
||||
}
|
||||
|
||||
const auth = buildKiloAuth(readJson(authPath), { apiKey, baseUrl, model });
|
||||
// Only touch VS Code settings.json if it already exists (avoid creating a
|
||||
// bogus one for users who don't use that VS Code variant).
|
||||
const vscodeExists = existsSync(vscodePath);
|
||||
const vscodeSettings = vscodeExists
|
||||
? buildKiloVscodeSettings(readJson(vscodePath), { apiKey, baseUrl, model })
|
||||
: null;
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\n── [dry-run] ${authPath} ──`);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ "openai-compatible": { ...auth["openai-compatible"], apiKey: apiKey ? "set" : "sk_omniroute" } },
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
console.log(`\n── [dry-run] ${vscodePath} ── ${vscodeExists ? "(would merge kilocode.* keys)" : "(skipped — file absent)"}`);
|
||||
} else {
|
||||
mkdirSync(join(authPath, ".."), { recursive: true });
|
||||
writeFileSync(authPath, JSON.stringify(auth, null, 2) + "\n", "utf8");
|
||||
printSuccess(`Wrote ${authPath}`);
|
||||
if (vscodeSettings) {
|
||||
writeFileSync(vscodePath, JSON.stringify(vscodeSettings, null, 2) + "\n", "utf8");
|
||||
printSuccess(`Updated ${vscodePath} (kilocode.customProvider + defaultModel)`);
|
||||
} else {
|
||||
printInfo(`Skipped VS Code settings (${vscodePath} not found).`);
|
||||
}
|
||||
}
|
||||
|
||||
printInfo("\nFor the Kilo Code VS Code extension, set Settings → Providers → OpenAI Compatible:");
|
||||
printInfo(` Base URL: ${baseUrl} (Kilo expects /v1)`);
|
||||
printInfo(` API Key: <your OMNIROUTE_API_KEY>`);
|
||||
printInfo(` Model: ${model}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerSetupKilo(program) {
|
||||
program
|
||||
.command("setup-kilo")
|
||||
.description(
|
||||
"Configure Kilo Code for OmniRoute: write ~/.local/share/kilo/auth.json (CLI) + VS Code kilocode.* settings"
|
||||
)
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
|
||||
.option("--model <id>", "Model id for Kilo (required unless picked interactively)")
|
||||
.option("--auth-path <path>", "Kilo CLI auth.json path (default: ~/.local/share/kilo/auth.json)")
|
||||
.option("--vscode-settings <path>", "VS Code settings.json (default: ~/.config/Code/User/settings.json)")
|
||||
.option("--yes", "Non-interactive: do not prompt (requires --model)")
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.action(async (opts) => {
|
||||
const code = await runSetupKiloCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
|
|
@ -29,6 +29,7 @@ import os from "node:os";
|
|||
|
||||
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
|
@ -254,7 +255,17 @@ function runOpenCodeAuth(providerId) {
|
|||
*/
|
||||
export async function runSetupOpenCodeCommand(opts = {}) {
|
||||
const providerId = opts.providerId || "omniroute";
|
||||
const baseURL = opts.baseURL || opts.baseUrl || "http://localhost:20128";
|
||||
// Remote-aware: explicit --remote/--base-url → active context → localhost.
|
||||
let baseURL = opts.remote || opts.baseURL || opts.baseUrl;
|
||||
if (!baseURL) {
|
||||
try {
|
||||
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
baseURL = ctx?.baseUrl;
|
||||
} catch {
|
||||
/* no context */
|
||||
}
|
||||
}
|
||||
if (!baseURL) baseURL = "http://localhost:20128";
|
||||
const displayName = opts.displayName || null;
|
||||
const wantsAuth = Boolean(opts.auth);
|
||||
const nonInteractive = Boolean(opts.nonInteractive);
|
||||
|
|
@ -356,8 +367,11 @@ export function registerSetupOpenCode(setupCommand) {
|
|||
)
|
||||
.option(
|
||||
"--base-url <url>",
|
||||
"OmniRoute base URL the plugin should talk to (default: http://localhost:20128)",
|
||||
"http://localhost:20128"
|
||||
"OmniRoute base URL the plugin should talk to (default: active context or http://localhost:20128)"
|
||||
)
|
||||
.option(
|
||||
"--remote <url>",
|
||||
"Remote OmniRoute URL, e.g. http://192.168.0.15:20128 (overrides --base-url and the context)"
|
||||
)
|
||||
.option("--display-name <name>", "Display name in the OpenCode UI (optional)")
|
||||
.option(
|
||||
|
|
@ -376,6 +390,7 @@ export function registerSetupOpenCode(setupCommand) {
|
|||
output: globalOpts.output,
|
||||
apiKey: opts.apiKey ?? globalOpts.apiKey,
|
||||
baseUrl: opts.baseUrl ?? globalOpts.baseUrl,
|
||||
context: globalOpts.context ?? opts.context,
|
||||
};
|
||||
const { exitCode } = await runSetupOpenCodeCommand(merged);
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
|
|
|
|||
129
bin/cli/commands/setup-opencode.mjs
Normal file
129
bin/cli/commands/setup-opencode.mjs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
/**
|
||||
* omniroute setup-opencode — Remote-aware OpenCode provider generator
|
||||
* (openai-compatible). Distinct from `omniroute setup opencode` (which wires the
|
||||
* @omniroute/opencode-plugin). This writes the `omniroute` provider into
|
||||
* ~/.config/opencode/opencode.json with every catalog model, so you can run
|
||||
* `opencode -m omniroute/<model>`.
|
||||
*
|
||||
* Reuses the proven server-side generator (config-generator/opencode.ts) for the
|
||||
* catalog fetch + merge, then references the API key by env var (never on disk).
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
const ENV_KEY_REF = "{env:OMNIROUTE_API_KEY}";
|
||||
|
||||
/** Resolve baseUrl + (literal) apiKey from flags → active context → localhost. */
|
||||
export function resolveOpencodeTarget(opts = {}) {
|
||||
let baseUrl;
|
||||
if (opts.remote) {
|
||||
baseUrl = String(opts.remote).replace(/\/+$/, "");
|
||||
} else {
|
||||
try {
|
||||
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
baseUrl = c?.baseUrl;
|
||||
} catch {
|
||||
/* no context */
|
||||
}
|
||||
if (!baseUrl) baseUrl = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
|
||||
let apiKey = opts.apiKey ?? opts["api-key"];
|
||||
if (!apiKey) {
|
||||
try {
|
||||
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
apiKey = c?.accessToken || c?.apiKey;
|
||||
} catch {
|
||||
/* no context auth */
|
||||
}
|
||||
}
|
||||
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
|
||||
return { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-process the generator output: reference the API key by env var (keep the
|
||||
* secret off disk) and optionally keep only models whose id matches `only`.
|
||||
* Pure + testable. Returns the final JSON string.
|
||||
*
|
||||
* @param {string} rawJson output of generateOpencodeConfig
|
||||
* @param {{ only?: string[] }} [opts]
|
||||
* @returns {{ json: string, modelCount: number }}
|
||||
*/
|
||||
export function postProcessOpencodeConfig(rawJson, opts = {}) {
|
||||
const config = JSON.parse(rawJson);
|
||||
const prov = config.provider?.omniroute;
|
||||
if (prov?.options) prov.options.apiKey = ENV_KEY_REF;
|
||||
|
||||
if (opts.only && opts.only.length && prov?.models) {
|
||||
const kept = {};
|
||||
for (const [id, entry] of Object.entries(prov.models)) {
|
||||
if (opts.only.some((f) => id.includes(f))) kept[id] = entry;
|
||||
}
|
||||
prov.models = kept;
|
||||
}
|
||||
const modelCount = prov?.models ? Object.keys(prov.models).length : 0;
|
||||
return { json: JSON.stringify(config, null, 2) + "\n", modelCount };
|
||||
}
|
||||
|
||||
export async function runSetupOpencodeCommand(opts = {}) {
|
||||
const { baseUrl, apiKey } = resolveOpencodeTarget(opts);
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const only = opts.only ? opts.only.split(",").map((s) => s.trim()).filter(Boolean) : null;
|
||||
|
||||
printHeading("OmniRoute → OpenCode provider (openai-compatible)");
|
||||
printInfo(`Connecting to ${baseUrl} …`);
|
||||
|
||||
// Deferred import: opencode.ts is TypeScript; tsx is registered by
|
||||
// bin/omniroute.mjs before any command runs, so importing here is safe.
|
||||
let raw;
|
||||
try {
|
||||
const { generateOpencodeConfig } = await import(
|
||||
"../../../src/lib/cli-helper/config-generator/opencode.ts"
|
||||
);
|
||||
raw = await generateOpencodeConfig({ baseUrl, apiKey, model: opts.model, providerId: "omniroute" });
|
||||
} catch (err) {
|
||||
printError(`Failed to generate opencode.json: ${err?.message || err}`);
|
||||
printInfo("Make sure OmniRoute is running and --remote/--api-key are correct.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const { json, modelCount } = postProcessOpencodeConfig(raw, { only });
|
||||
const configDir = join(os.homedir(), ".config", "opencode");
|
||||
const configPath = join(configDir, "opencode.json");
|
||||
|
||||
if (dryRun) {
|
||||
console.log(json.length > 4000 ? json.slice(0, 4000) + "\n… (truncated)" : json);
|
||||
printInfo(`[dry-run] ${modelCount} model(s) under provider 'omniroute' → ${configPath}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
|
||||
writeFileSync(configPath, json, "utf8");
|
||||
printSuccess(`opencode.json updated at ${configPath} (${modelCount} models under 'omniroute')`);
|
||||
printInfo('Use it: opencode -m omniroute/<model> "..." (export OMNIROUTE_API_KEY first)');
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerSetupOpencode(program) {
|
||||
program
|
||||
.command("setup-opencode")
|
||||
.description(
|
||||
"Generate the OmniRoute openai-compatible provider in ~/.config/opencode/opencode.json " +
|
||||
"from the live model catalog (local or remote VPS)"
|
||||
)
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
|
||||
.option("--model <id>", "Set the default top-level model (omniroute/<id>)")
|
||||
.option("--only <patterns>", "Comma-separated substrings — keep only matching model IDs")
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.action(async (opts) => {
|
||||
const code = await runSetupOpencodeCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
149
bin/cli/commands/setup-qwen.mjs
Normal file
149
bin/cli/commands/setup-qwen.mjs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
/**
|
||||
* omniroute setup-qwen — configure Qwen Code (QwenLM/qwen-code) for OmniRoute.
|
||||
*
|
||||
* Qwen Code is a terminal AI agent (gemini-cli fork) with a file-based config at
|
||||
* ~/.qwen/settings.json. For a custom OpenAI-compatible endpoint it uses a
|
||||
* `modelProviders` entry with authType "openai", baseUrl WITH /v1, and an
|
||||
* `envKey` naming the env var holding the key (secret stays in the env, never the
|
||||
* file). Remote-aware; headless test via `qwen -p "..."`.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
function ensureV1(url) {
|
||||
const s = String(url || "").replace(/\/+$/, "");
|
||||
return s.endsWith("/v1") ? s : `${s}/v1`;
|
||||
}
|
||||
|
||||
/** Resolve baseUrl (WITH /v1) + apiKey from flags → active context → localhost. */
|
||||
export function resolveQwenTarget(opts = {}) {
|
||||
let root;
|
||||
if (opts.remote) root = String(opts.remote).replace(/\/+$/, "");
|
||||
else {
|
||||
try {
|
||||
root = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
if (!root) root = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
let apiKey = opts.apiKey ?? opts["api-key"];
|
||||
if (!apiKey) {
|
||||
try {
|
||||
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
apiKey = c?.accessToken || c?.apiKey;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
}
|
||||
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
|
||||
return { baseUrl: ensureV1(root), apiKey };
|
||||
}
|
||||
|
||||
/** Merge the OmniRoute modelProvider into Qwen's settings.json (preserve rest). */
|
||||
export function buildQwenSettings(existing, { baseUrl, model }) {
|
||||
const s = existing && typeof existing === "object" ? { ...existing } : {};
|
||||
const providers = Array.isArray(s.modelProviders) ? s.modelProviders.filter((p) => p?.id !== "omniroute") : [];
|
||||
providers.push({
|
||||
id: "omniroute",
|
||||
name: "OmniRoute",
|
||||
authType: "openai",
|
||||
baseUrl,
|
||||
envKey: "OMNIROUTE_API_KEY",
|
||||
});
|
||||
s.modelProviders = providers;
|
||||
if (model) {
|
||||
s.selectedProvider = "omniroute";
|
||||
s.model = model;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function readJson(path) {
|
||||
try {
|
||||
if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch {
|
||||
/* corrupt/missing */
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function fetchModelIds(baseUrl, apiKey) {
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
const res = await fetch(`${baseUrl.replace(/\/v1$/, "")}/v1/models`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const body = await res.json();
|
||||
const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
|
||||
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSetupQwenCommand(opts = {}) {
|
||||
const { baseUrl, apiKey } = resolveQwenTarget(opts);
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".qwen", "settings.json");
|
||||
|
||||
printHeading("OmniRoute → Qwen Code (openai-compatible)");
|
||||
printInfo(`baseUrl: ${baseUrl}`);
|
||||
|
||||
let model = opts.model;
|
||||
if (!model) {
|
||||
const ids = await fetchModelIds(baseUrl, apiKey);
|
||||
if (ids.length && !opts.yes) {
|
||||
printInfo(`Examples: ${ids.slice(0, 20).join(", ")}${ids.length > 20 ? " …" : ""}`);
|
||||
const prompt = createPrompt();
|
||||
try {
|
||||
model = await prompt.ask("Model id for Qwen");
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!model) {
|
||||
printError("A model is required. Pass --model <id>.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
const merged = buildQwenSettings(readJson(configPath), { baseUrl, model });
|
||||
const out = JSON.stringify(merged, null, 2) + "\n";
|
||||
|
||||
if (dryRun) {
|
||||
console.log("\n" + out);
|
||||
printInfo(`[dry-run] → ${configPath}`);
|
||||
} else {
|
||||
mkdirSync(join(configPath, ".."), { recursive: true });
|
||||
writeFileSync(configPath, out, "utf8");
|
||||
printSuccess(`Wrote ${configPath}`);
|
||||
}
|
||||
printInfo("\nProvide the key (settings reference OMNIROUTE_API_KEY): export OMNIROUTE_API_KEY=...");
|
||||
printInfo('Then run: qwen (or headless: qwen -p "reply OK")');
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerSetupQwen(program) {
|
||||
program
|
||||
.command("setup-qwen")
|
||||
.description("Configure Qwen Code for OmniRoute: write ~/.qwen/settings.json (openai modelProvider)")
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
|
||||
.option("--model <id>", "Model id for Qwen (required unless picked interactively)")
|
||||
.option("--config-path <path>", "settings.json path (default: ~/.qwen/settings.json)")
|
||||
.option("--yes", "Non-interactive: do not prompt (requires --model)")
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.action(async (opts) => {
|
||||
const code = await runSetupQwenCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
172
bin/cli/commands/setup-roo.mjs
Normal file
172
bin/cli/commands/setup-roo.mjs
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
/**
|
||||
* omniroute setup-roo — configure Roo Code (RooVeterinaryInc.roo-cline) for OmniRoute.
|
||||
*
|
||||
* Roo is a VS Code extension (Cline fork). Its live settings live in opaque VS
|
||||
* Code globalStorage, but Roo supports **Settings Import** + an
|
||||
* `roo-cline.autoImportSettingsPath` (VS Code settings.json) that loads a JSON on
|
||||
* startup. So this writes a best-effort import file + wires autoImport (when a VS
|
||||
* Code settings.json exists) + prints the UI steps as the guaranteed path.
|
||||
*
|
||||
* OpenAI-compatible: baseUrl WITH /v1 (Roo appends /chat/completions). The model
|
||||
* must support native OpenAI tool-calling (OmniRoute does).
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
function ensureV1(url) {
|
||||
const s = String(url || "").replace(/\/+$/, "");
|
||||
return s.endsWith("/v1") ? s : `${s}/v1`;
|
||||
}
|
||||
|
||||
/** Resolve baseUrl (WITH /v1) + apiKey from flags → active context → localhost. */
|
||||
export function resolveRooTarget(opts = {}) {
|
||||
let root;
|
||||
if (opts.remote) root = String(opts.remote).replace(/\/+$/, "");
|
||||
else {
|
||||
try {
|
||||
root = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
if (!root) root = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
let apiKey = opts.apiKey ?? opts["api-key"];
|
||||
if (!apiKey) {
|
||||
try {
|
||||
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
apiKey = c?.accessToken || c?.apiKey;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
}
|
||||
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
|
||||
return { baseUrl: ensureV1(root), apiKey };
|
||||
}
|
||||
|
||||
/** Build a Roo Settings-Import document (provider profile, openai-compatible). */
|
||||
export function buildRooImport({ baseUrl, apiKey, model }) {
|
||||
return {
|
||||
providerProfiles: {
|
||||
currentApiConfigName: "OmniRoute",
|
||||
apiConfigs: {
|
||||
OmniRoute: {
|
||||
apiProvider: "openai",
|
||||
openAiBaseUrl: baseUrl,
|
||||
openAiApiKey: apiKey || "sk_omniroute",
|
||||
openAiModelId: model,
|
||||
openAiCustomModelInfo: { supportsImages: false, supportsPromptCache: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Add the autoImport pointer to a VS Code settings.json object. */
|
||||
export function buildRooVscodeAutoImport(existing, importPath) {
|
||||
return { ...(existing || {}), "roo-cline.autoImportSettingsPath": importPath };
|
||||
}
|
||||
|
||||
function readJson(path) {
|
||||
try {
|
||||
if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch {
|
||||
/* corrupt/missing */
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function fetchModelIds(baseUrl, apiKey) {
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
const res = await fetch(`${baseUrl.replace(/\/v1$/, "")}/v1/models`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const body = await res.json();
|
||||
const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
|
||||
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSetupRooCommand(opts = {}) {
|
||||
const { baseUrl, apiKey } = resolveRooTarget(opts);
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const importPath = opts.importPath ?? opts["import-path"] ?? join(os.homedir(), ".omniroute", "roo-settings.json");
|
||||
const vscodePath =
|
||||
opts.vscodeSettings ?? opts["vscode-settings"] ?? join(os.homedir(), ".config", "Code", "User", "settings.json");
|
||||
|
||||
printHeading("OmniRoute → Roo Code (OpenAI-compatible)");
|
||||
printInfo(`Server: ${baseUrl}`);
|
||||
|
||||
let model = opts.model;
|
||||
if (!model) {
|
||||
const ids = await fetchModelIds(baseUrl, apiKey);
|
||||
if (ids.length && !opts.yes) {
|
||||
printInfo(`Examples: ${ids.slice(0, 20).join(", ")}${ids.length > 20 ? " …" : ""}`);
|
||||
const { createPrompt } = await import("../io.mjs");
|
||||
const prompt = createPrompt();
|
||||
try {
|
||||
model = await prompt.ask("Model id for Roo");
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!model) {
|
||||
printError("A model is required. Pass --model <id> (Roo has no model auto-discovery).");
|
||||
return 2;
|
||||
}
|
||||
|
||||
const importDoc = buildRooImport({ baseUrl, apiKey, model });
|
||||
const vscodeExists = existsSync(vscodePath);
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\n── [dry-run] ${importPath} ──`);
|
||||
console.log(JSON.stringify({ ...importDoc, providerProfiles: { ...importDoc.providerProfiles, apiConfigs: { OmniRoute: { ...importDoc.providerProfiles.apiConfigs.OmniRoute, openAiApiKey: apiKey ? "set" : "sk_omniroute" } } } }, null, 2));
|
||||
console.log(`\n── [dry-run] ${vscodePath} ── ${vscodeExists ? "(would set roo-cline.autoImportSettingsPath)" : "(skipped — file absent)"}`);
|
||||
} else {
|
||||
mkdirSync(join(importPath, ".."), { recursive: true });
|
||||
writeFileSync(importPath, JSON.stringify(importDoc, null, 2) + "\n", "utf8");
|
||||
printSuccess(`Wrote ${importPath}`);
|
||||
if (vscodeExists) {
|
||||
const merged = buildRooVscodeAutoImport(readJson(vscodePath), importPath);
|
||||
writeFileSync(vscodePath, JSON.stringify(merged, null, 2) + "\n", "utf8");
|
||||
printSuccess(`Set roo-cline.autoImportSettingsPath in ${vscodePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
printInfo("\nIn the Roo Code panel: Settings → Providers → OpenAI Compatible (guaranteed path):");
|
||||
printInfo(` Base URL: ${baseUrl} (Roo expects /v1)`);
|
||||
printInfo(` API Key: <your OMNIROUTE_API_KEY>`);
|
||||
printInfo(` Model: ${model}`);
|
||||
printInfo(`Or use Roo: “Import Settings” → select ${importPath}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerSetupRoo(program) {
|
||||
program
|
||||
.command("setup-roo")
|
||||
.description(
|
||||
"Configure Roo Code for OmniRoute: write a Roo import JSON + autoImport pointer + print UI steps"
|
||||
)
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
|
||||
.option("--model <id>", "Model id for Roo (required unless picked interactively)")
|
||||
.option("--import-path <path>", "Roo import JSON path (default: ~/.omniroute/roo-settings.json)")
|
||||
.option("--vscode-settings <path>", "VS Code settings.json (default: ~/.config/Code/User/settings.json)")
|
||||
.option("--yes", "Non-interactive: do not prompt (requires --model)")
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.action(async (opts) => {
|
||||
const code = await runSetupRooCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
|
|
@ -1258,11 +1258,14 @@
|
|||
"scaffold": "Scaffold a new plugin boilerplate"
|
||||
},
|
||||
"launch": {
|
||||
"description": "Launch Claude Code pointed at the local OmniRoute proxy",
|
||||
"token": "API key the Claude client should send (ANTHROPIC_AUTH_TOKEN)",
|
||||
"notRunning": "OmniRoute is not running on port {port}. Start it with 'omniroute serve'.",
|
||||
"description": "Launch Claude Code pointed at OmniRoute (local or remote, with --profile)",
|
||||
"token": "Token the Claude client should send (ANTHROPIC_AUTH_TOKEN)",
|
||||
"notRunning": "OmniRoute is not reachable at {port}. Start it with 'omniroute serve'.",
|
||||
"notFound": "The 'claude' CLI was not found in PATH."
|
||||
},
|
||||
"setupClaude": {
|
||||
"description": "Generate ~/.claude/profiles Claude Code profiles from the OmniRoute model catalog"
|
||||
},
|
||||
"connect": {
|
||||
"description": "Connect to a remote OmniRoute server and enter remote mode"
|
||||
},
|
||||
|
|
@ -1271,5 +1274,11 @@
|
|||
},
|
||||
"configure": {
|
||||
"description": "Pick a provider+model from the active server and write a local CLI config"
|
||||
},
|
||||
"launchCodex": {
|
||||
"description": "Launch Codex CLI pointed at OmniRoute (local or remote VPS)"
|
||||
},
|
||||
"setupCodex": {
|
||||
"description": "Generate ~/.codex profile files from OmniRoute live model catalog"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1257,11 +1257,14 @@
|
|||
"scaffold": "Gerar boilerplate de novo plugin"
|
||||
},
|
||||
"launch": {
|
||||
"description": "Inicia o Claude Code apontando para o proxy local do OmniRoute",
|
||||
"token": "Chave de API que o cliente Claude deve enviar (ANTHROPIC_AUTH_TOKEN)",
|
||||
"notRunning": "OmniRoute não está rodando na porta {port}. Inicie com 'omniroute serve'.",
|
||||
"description": "Inicia o Claude Code apontando para o OmniRoute (local ou remoto, com --profile)",
|
||||
"token": "Token que o cliente Claude deve enviar (ANTHROPIC_AUTH_TOKEN)",
|
||||
"notRunning": "OmniRoute não está acessível em {port}. Inicie com 'omniroute serve'.",
|
||||
"notFound": "O CLI 'claude' não foi encontrado no PATH."
|
||||
},
|
||||
"setupClaude": {
|
||||
"description": "Gera profiles do Claude Code em ~/.claude/profiles a partir do catálogo de modelos do OmniRoute"
|
||||
},
|
||||
"connect": {
|
||||
"description": "Conecta a um servidor OmniRoute remoto e entra no modo remoto"
|
||||
},
|
||||
|
|
@ -1270,5 +1273,11 @@
|
|||
},
|
||||
"configure": {
|
||||
"description": "Escolhe um provedor+modelo do servidor ativo e grava uma configuração de CLI local"
|
||||
},
|
||||
"launchCodex": {
|
||||
"description": "Inicia o Codex CLI apontando para o OmniRoute (local ou VPS remoto)"
|
||||
},
|
||||
"setupCodex": {
|
||||
"description": "Gera os arquivos de perfil ~/.codex a partir do catálogo de modelos ao vivo do OmniRoute"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
{
|
||||
"_comment": "Catraca de complexidade (check-complexity.mjs, ESLint core rules complexity>=15 e max-lines-per-function>80 sobre src+open-sse+electron+bin via eslint.complexity.config.mjs). Conta total de violacoes; so pode cair. --update ratcheta.",
|
||||
"count": 1800,
|
||||
"count": 1896,
|
||||
"_rebaseline_2026_06_20_postlote_concurrent_drift": "Reconciliacao release-volatil: 1895->1896 (+1). Drift de condicional NOVO de PRs mergeados pela sessao concorrente APOS o #4338 ratchetar para 1895 (#4355 pricing gpt-5.x-pro / #4364 cli active-context cred / #4363 compliance cleanup / #4358 mitm mask / #4332 injection-guard-16KB). O fast-path do release nao roda check:complexity (so release->main), entao o ramo acumula sem rebaselinar. Medido com `node scripts/check/check-complexity.mjs` no tip cdfd71c17. Mesma familia — crescimento de feature legitimo, nao regressao.",
|
||||
"_ratchet_2026_06_19_phasecombosetup_fix": "1896->1895 (-1, ratchet DOWN — melhoria, NAO reconciliacao). O #4336 reconciliou o drift do lote para 1896 INCLUINDO a violacao que o #4326 (ComboContext) introduziu: phaseComboSetup media complexity 17 (>15) porque a extracao concentrou os condicionais de pinning/ternarios numa funcao que estourava o teto (irônico p/ uma decomposicao). Este PR CORRIGE na origem — extrai resolveContextCachePin (helper do pinning), phaseComboSetup volta a <15 — baixando a contagem 1. Medido com `node scripts/check/check-complexity.mjs` no tip pos-#4336.",
|
||||
"_rebaseline_2026_06_19_lote3_postdeploy_drift": "Reconciliacao release-volatil pos-merge do lote adicional (6 PRs apos o deploy): 1890->1896 (+6). Drift de condicionais NOVOS de #4327 (per-key USD usage quotas — apiKeyUsageLimits.ts + validation/policy branches), #4334 (cache-aware compression guard) e #4326 (phaseComboSetup extraido). Medido no tip real da release com `node scripts/check/check-complexity.mjs`. Mesma familia/justificativa do _rebaseline_2026_06_19_lote3_merge_drift abaixo — feature legitima, nao regressao.",
|
||||
"_rebaseline_2026_06_19_lote3_merge_drift": "Reconciliacao release-volatil pos-merge do lote de 13 PRs (release/v3.8.30): 1888->1890 (+2). Drift de condicionais NOVOS trazidos por #4313 (5 harvested features — combo allowlist intersection, serviceKind filter) e #4323 (compression e2e — novos ramos em ultra/aggressive/gcf/strategySelector), merges que entraram DEPOIS do #4318 medir 1888. O fast-path do release nao roda check:complexity (so release->main), entao os ramos acumulam sem rebaselinar. Medido no tip real da release pos-merge com `node scripts/check/check-complexity.mjs`. Mesma familia dos rebaselines anteriores — crescimento de feature legitimo, nao regressao; reducao fica como debt de refactor dedicado.",
|
||||
"_rebaseline_2026_06_19_bin_scope_wired": "Task 6A.11 (de verdade): o ESLINT_ARGS de check-complexity.mjs passava apenas `src open-sse` — o config eslint.complexity.config.mjs e este baseline JA documentavam o escopo src+open-sse+electron+bin, mas a edicao do scan nunca tinha sido aplicada (fake-green: o gate alegava cobrir bin/electron e nunca os varria). Agora ESLINT_ARGS passa `src open-sse electron bin`, casando o config. Medido: electron+bin contribuem 0 violacoes (electron so tem types.d.ts, ignorado; os 4 .ts de bin/ estao sob os thresholds) — o widening e 0-custo. O +1 (1887->1888) NAO vem do widening: e drift pre-existente em src/open-sse trazido pela base release/v3.8.30 a23d0d678 (merges do ciclo, incl. #4308 cache-hit-cost), que o fast-path do release nao rebaselina (check:complexity so roda no release->main). Mesma familia dos rebaselines anteriores — crescimento de feature legitimo, nao regressao.",
|
||||
"_rebaseline_2026_06_19_4293_codex_spark_scope": "PR #4293 (isolate Codex Spark quota scope): +2 over the v3.8.30 baseline (1885->1887). Measured on the actual merged tree (release/v3.8.30 + #4293), not the PR's own estimate. The thin requestedModel-scoped Codex quota headroom/preflight branches needed so GPT-5.3-Codex-Spark and normal Codex are evaluated independently add the new conditional cost; heavy parsing/display logic was extracted to leaf helpers under the cap (codexQuotaScopes.ts, codexUsageQuotas.ts, codexFailover.ts). Legitimate feature growth, not regression; structural shrink remains debt.",
|
||||
"_rebaseline_2026_06_19_v3830": "Re-baseline consciente: drift 1800->1885 (+85) do ciclo v3.8.25->v3.8.29 (round-9, ~130 PRs: combo split D7/D8, chatCore split, novos providers/modelos, cost-telemetry, MITM decrypt, remote-mode CLI). Medido no tip release/v3.8.30 (3e6be4701). Mesma familia dos re-baselines anteriores — crescimento de feature legitima, nao regressao. Reducao fica como debt de refactor dedicado.",
|
||||
"_rebaseline_2026_06_13_v3825": "Re-baseline consciente: drift 1794->1800 (+6) do ciclo v3.8.24->v3.8.25 (features #3799-#3806). Mesma familia dos re-baselines anteriores — crescimento de feature legitima, nao regressao. Reducao fica como debt de refactor dedicado.",
|
||||
"_rebaseline_2026_06_10": "Re-baseline consciente: 1739 foi medido na branch das Fases 0-6 (base ~v3.8.17); a v3.8.18 publicada ja carrega 1746 (provado: o commit-base 5f2722bd6, anterior a qualquer commit do ciclo v3.8.19, mede 1746 — funcoes complexas dos reworks RequestLoggerV2/stream/combo). Mesma familia dos re-baselines de eslintWarnings/file-size. Reducao = Fase 6A (2026-06-16).",
|
||||
"_rebaseline_2026_06_13_6a11": "Re-baseline consciente Task 6A.11: escopo ampliado para src+open-sse+electron+bin (electron/bin contribuem 0 violacoes novas — todos os 4 arquivos .ts em bin/ estao abaixo dos thresholds). Drift 1746→1794 pre-existente de features mergeadas nos ciclos v3.8.22/v3.8.23 (nao causado por esta task). Congelado no valor real medido para destrancar o gate."
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
"allowed": [
|
||||
"@atjsh/llmlingua-2",
|
||||
"@aws-sdk/client-bedrock-runtime",
|
||||
"@axe-core/playwright",
|
||||
"@cyclonedx/cyclonedx-npm",
|
||||
"@dnd-kit/core",
|
||||
"@dnd-kit/sortable",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
{
|
||||
"_comment": "Catraca de tamanho (check-file-size.mjs). frozen so pode encolher; arquivos novos <= cap. --update ratcheta.",
|
||||
"_rebaseline_2026_06_19_4313_harvest_x_sweep_reconcile": "RECONCILIACAO ao mergear #4313 (5 harvested features) APOS o provider-sweep #4324: valores MEDIDOS na arvore combinada release(com #4324)+#4313. route.ts 2580->2586 (sweep +19 NAMED_OPENAI_STYLE + #4313 +3 openadapter/dit/tokenrouter = uniao no Set, regioes disjuntas). providers.ts 3198->3242 (sweep 6 dead-provider marks + #4313 3 novas entries APIKEY_PROVIDERS). combos/page.tsx 4350->4385 (#4313 #3266 allowlist UI) e providers/page.tsx 1925->1927 (#4313 #4240 serviceKind state) — intocados pelo sweep, crescimento puro do #4313. Mesmo padrao release-volatil de medir-no-merge dos baselines de complexity/zizmor deste lote.",
|
||||
"_rebaseline_2026_06_19_provider_sweep_merge_reconcile": "provider-model-sweep PR merge into release/v3.8.30: the sweep's route.ts growth (NAMED_OPENAI_STYLE_PROVIDERS +19 entries, base 2538->2564) and #4259's cloudflare parseResponse (2538->2554) are disjoint regions that both land, so the merged route.ts frozen value is reconciled to its measured size here. constants/providers.ts carries the sweep's 6 dead-provider deprecation marks on top of release. chatCore.ts stays at #4266's 5128 (sweep does not touch it).",
|
||||
"_rebaseline_2026_06_19_provider_sweep_dead_providers": "provider-model-sweep Track C: src/shared/constants/providers.ts 3169->3198 (+29 = deprecated:true + riskNoticeVariant:\"deprecated\" + a deprecationReason for six providers the sweep confirmed dead — kluster/glhf/predibase/inclusionai/galadriel (DNS no longer resolves) + phind (API shut down 2026-01). Mirrors the existing qwen deprecation-flag pattern; pure additive metadata so the UI surfaces a deprecation notice instead of silently offering a non-working provider. Not extractable (it IS the per-provider constant data).",
|
||||
"_rebaseline_2026_06_19_provider_sweep_live_discovery_2": "provider-model-sweep cont.: providers/[id]/models/route.ts 2548->2564 (+16 = twelve more NAMED_OPENAI_STYLE_PROVIDERS Set entries `crof`/`featherless-ai`/`ovhcloud`/`sambanova`/`orcarouter`/`uncloseai`/`opencode-go`/`baseten`/`hyperbolic`/`nebius`/`scaleway`/`together` + a 4-line comment). Same fix shape: GPU-cloud / aggregator marketplaces hosting large volatile OSS catalogs, each with a live `<baseUrl>/v1/models` endpoint the sweep probed (200 public or 401/403 = exists+keyed); live fetch keeps the catalog fresh, the registry seed stays as the offline fallback. Pure additive Set membership; not extractable. Covered by tests/unit/provider-sweep-live-discovery.test.ts.",
|
||||
"_rebaseline_2026_06_19_provider_sweep_live_discovery": "provider-model-sweep own growth: providers/[id]/models/route.ts 2538->2548 (+10 = seven NAMED_OPENAI_STYLE_PROVIDERS Set entries `venice`/`deepinfra`/`wandb`/`pollinations`/`nscale`/`inference-net`/`moonshot` + a 3-line comment). Same fix shape as #4249 (vercel-ai-gateway) / #4202 (zenmux) / #3976 (llm7/byteplus): each is a keyed format-openai provider that exposes a real live `<baseUrl>/models` catalog (the sweep probed each: venice/deepinfra/pollinations/inference-net public 200, wandb/nscale/moonshot 401 = exists+keyed) but was unclassified by every live-fetch branch, so import served its small hardcoded seed instead of the upstream list, re-staling the catalog the sweep set out to fix. The `<baseUrl>/models` probe (after stripping /chat/completions and a trailing /v1) resolves to the endpoints pinned in tests/unit/provider-sweep-live-discovery.test.ts; the small registry seed stays as the offline fallback so import never breaks. Pure additive Set membership; not extractable (it IS the list). Structural shrink of this route tracked in #3789.",
|
||||
"_rebaseline_2026_06_19_4266_pollinations_auth": "PR #4266 own growth: chatCore.ts 5125->5128 (+3 net, +4/-1 at the existing error-classify chokepoint ~line 3514 — preserve numeric HTTP status from executor-thrown errors and set authentication_error type for 401s instead of mapping all to 502). The reusable guard lives at the existing catch chokepoint; not extractable without hiding the error boundary. (Rebased onto release tip 5125 from the PR's original 5111 base.)",
|
||||
"_rebaseline_2026_06_19_no_memory_header_prd": "PRD-2026-06-19 (no-memory header + memory off-by-default + token-cost UI alert) own growth: chatCore.ts 5111->5116 (+5 = the memory-injection chokepoint now checks isNoMemoryRequested(clientRawRequest?.headers) and passes memoryOwnerId=null on opt-out, a cohesive 4-line conditional + comment at the existing resolveMemoryOwnerId/injectMemoryAndSkills call site; the pure helper isNoMemoryRequested lives in the non-frozen chatCore/headers.ts) and MemorySkillsTab.tsx 880->898 (+18 = a token-cost warning callout shown when memory is enabled, near the memory-enabled-switch, using the new settings.memoryTokenCostWarning i18n key with the configured maxTokens). Memory is now OFF by default (DEFAULT_MEMORY_SETTINGS.enabled false) so enabling is an explicit, warned opt-in. Both edits are cohesive wiring at the existing chokepoints; not extractable. Structural shrink of chatCore.ts tracked in #3501.",
|
||||
"_rebaseline_2026_06_19_4264_openrouter_vision_sync": "Issue #4264 own growth: catalog.ts 1465->1478 (+13) and models.ts 1180->1184 (+4). When a provider key is imported, models are persisted as SyncedAvailableModel records — which carried no vision flag — and the /v1/models catalog's OpenRouter live-enrichment block (the one that derives vision from architecture.input_modalities) is skipped once a provider has synced models, so vision-capable models (OpenRouter, Nvidia, ...) showed up as text-only after import even though image requests work. Fix mirrors the existing `supportsThinking` capture: models.ts adds the `supportsVision?: boolean` field + preserves it on the read-normalize path (+4); catalog.ts emits `capabilities.vision` in the synced-model fields and merges (not clobbers) capabilities in the existing-model branch (+13). The actual vision derivation (architecture.input_modalities / string modality / top-level input_modalities) lives in the non-frozen modelDiscovery.ts (detectVisionInput). Both edits are cohesive emit/merge wiring at the synced-model chokepoint; not extractable.",
|
||||
"_rebaseline_2026_06_19_4259_cloudflare_uuid_models": "Issue #4259 own growth: providers/[id]/models/route.ts 2538->2554 (+16 = the cloudflare-ai PROVIDER_MODELS_CONFIG `parseResponse` now maps each Cloudflare `/ai/models/search` result `name`->id + a 3-line comment, replacing the one-line `data.result || []` passthrough). Cloudflare Workers AI returns `{ id: \"<uuid>\", name: \"@cf/...\" }` — `name` is the callable slug, `id` is an internal UUID. The old passthrough fed the raw objects to buildResponse (id: m.id), so the dashboard/import surfaced UUIDs instead of `@cf/...` model ids. The map+filter is cohesive normalization at the config entry (mirrors the gemini/huggingface/clarifai parseResponse normalizers in the same map); not extractable. Structural shrink of this route tracked in #3789.",
|
||||
"_rebaseline_2026_06_19_4249_vercel_gateway_live_models": "Issue #4249 own growth: providers/[id]/models/route.ts 2534->2538 (+4 = one NAMED_OPENAI_STYLE_PROVIDERS Set entry `vercel-ai-gateway` + a 3-line comment). Same fix shape as #4202 (zenmux) / #3976 (llm7/byteplus): vercel-ai-gateway carries a real baseUrl (https://ai-gateway.vercel.sh/v1/chat/completions, format openai) but was unclassified by every live-fetch branch, so import served the 5-entry hardcoded registry catalog instead of the upstream list — the import button looked broken (loaded nothing usable) while manual add worked. The `<baseUrl>/models` probe (after stripping /chat/completions) resolves to https://ai-gateway.vercel.sh/v1/models; falls back to the local catalog on upstream error so import never breaks. Pure additive Set membership; not extractable.",
|
||||
"_rebaseline_2026_06_19_4227_cursor_cloud_ui": "PR #4250 (#4227 Cursor Cloud Agent) cross-layer UI wiring own growth: cloud-agents/page.tsx 913->922 (+9 = one CLOUD_AGENTS dropdown entry for the new `cursor-cloud` provider, mirroring the existing jules/devin/codex-cloud entries). The backend agent (cursor.ts) + registry/types/credentials-route landed in the PR, but the dashboard CLOUD_AGENTS list, health PROVIDER_NAMES, and lobeProviderIcons maps are hardcoded (client/server boundary — the registry can't be imported into the client page), so the agent was API-usable but not selectable in the UI and rendered with the Jules fallback name/icon. The +9 is the single presentational data entry at the existing hardcoded list; not extractable (it IS the list). The health-name + icon adds land in non-frozen files.",
|
||||
"_rebaseline_2026_06_19_cost_telemetry_combo_prettier": "NOT this PR's logic change: combo.ts 2601->2605 (+4) is a pure Prettier reflow applied by the cost-telemetry-parity merge commit's lint-staged hook. The DEFAULT_WEIGHTS/ProviderCandidate/ScoringWeights import from ./autoCombo/scoring.ts is 102 chars (> printWidth 100) on release (it landed via a GitHub merge that never ran the local Prettier hook), so the hook wrapped it to the canonical 4-line form. Zero logic change (git diff -w empty); cost-telemetry-parity never edits combo.ts. Reverting is futile — Prettier re-wraps the 102-char import on any later commit. Bumped here so the merge surfacing the reflow carries its own baseline.",
|
||||
|
|
@ -66,32 +75,32 @@
|
|||
"cap": 800,
|
||||
"frozen": {
|
||||
"open-sse/config/providerRegistry.ts": 4731,
|
||||
"open-sse/executors/antigravity.ts": 1664,
|
||||
"open-sse/executors/base.ts": 1358,
|
||||
"open-sse/executors/antigravity.ts": 1680,
|
||||
"open-sse/executors/base.ts": 1387,
|
||||
"open-sse/executors/chatgpt-web.ts": 2870,
|
||||
"open-sse/executors/claude-web.ts": 1057,
|
||||
"open-sse/executors/codex.ts": 1449,
|
||||
"open-sse/executors/cursor.ts": 1391,
|
||||
"open-sse/executors/deepseek-web.ts": 1117,
|
||||
"open-sse/executors/duckduckgo-web.ts": 917,
|
||||
"open-sse/executors/duckduckgo-web.ts": 925,
|
||||
"open-sse/executors/grok-web.ts": 1871,
|
||||
"open-sse/executors/muse-spark-web.ts": 1284,
|
||||
"open-sse/executors/perplexity-web.ts": 1013,
|
||||
"open-sse/handlers/audioSpeech.ts": 965,
|
||||
"open-sse/handlers/chatCore.ts": 5111,
|
||||
"open-sse/handlers/chatCore.ts": 5128,
|
||||
"open-sse/handlers/imageGeneration.ts": 3777,
|
||||
"open-sse/handlers/responseSanitizer.ts": 1103,
|
||||
"open-sse/handlers/search.ts": 1546,
|
||||
"open-sse/handlers/sseParser.ts": 812,
|
||||
"open-sse/handlers/sseParser.ts": 830,
|
||||
"open-sse/handlers/videoGeneration.ts": 1078,
|
||||
"open-sse/mcp-server/schemas/tools.ts": 1437,
|
||||
"open-sse/mcp-server/server.ts": 1468,
|
||||
"open-sse/mcp-server/tools/advancedTools.ts": 1118,
|
||||
"open-sse/services/accountFallback.ts": 1727,
|
||||
"open-sse/services/accountFallback.ts": 1731,
|
||||
"open-sse/services/batchProcessor.ts": 828,
|
||||
"open-sse/services/browserBackedChat.ts": 850,
|
||||
"open-sse/services/claudeCodeCompatible.ts": 1202,
|
||||
"open-sse/services/combo.ts": 2605,
|
||||
"open-sse/services/combo.ts": 2611,
|
||||
"open-sse/services/rateLimitManager.ts": 1035,
|
||||
"open-sse/services/tokenRefresh.ts": 1997,
|
||||
"open-sse/services/usage.ts": 3408,
|
||||
|
|
@ -106,7 +115,7 @@
|
|||
"src/app/(dashboard)/dashboard/cache/page.tsx": 841,
|
||||
"src/app/(dashboard)/dashboard/cli-code/components/CodexToolCard.tsx": 894,
|
||||
"src/app/(dashboard)/dashboard/cloud-agents/page.tsx": 922,
|
||||
"src/app/(dashboard)/dashboard/combos/page.tsx": 4350,
|
||||
"src/app/(dashboard)/dashboard/combos/page.tsx": 4385,
|
||||
"src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": 1495,
|
||||
"src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx": 1007,
|
||||
"src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2570,
|
||||
|
|
@ -121,11 +130,11 @@
|
|||
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderSettings.ts": 264,
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 955,
|
||||
"src/app/(dashboard)/dashboard/providers/components/onboarding/ProviderOnboardingWizard.tsx": 906,
|
||||
"src/app/(dashboard)/dashboard/providers/page.tsx": 1925,
|
||||
"src/app/(dashboard)/dashboard/providers/page.tsx": 1927,
|
||||
"src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1198,
|
||||
"src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx": 819,
|
||||
"src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx": 974,
|
||||
"src/app/(dashboard)/dashboard/settings/components/MemorySkillsTab.tsx": 880,
|
||||
"src/app/(dashboard)/dashboard/settings/components/MemorySkillsTab.tsx": 898,
|
||||
"src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx": 1012,
|
||||
"src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1089,
|
||||
"src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": 983,
|
||||
|
|
@ -135,15 +144,15 @@
|
|||
"src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2148,
|
||||
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1069,
|
||||
"src/app/api/oauth/[provider]/[action]/route.ts": 918,
|
||||
"src/app/api/providers/[id]/models/route.ts": 2538,
|
||||
"src/app/api/providers/[id]/models/route.ts": 2586,
|
||||
"src/app/api/providers/[id]/test/route.ts": 842,
|
||||
"src/app/api/usage/analytics/route.ts": 941,
|
||||
"src/app/api/v1/models/catalog.ts": 1465,
|
||||
"src/app/api/v1/models/catalog.ts": 1478,
|
||||
"src/lib/cloudflaredTunnel.ts": 934,
|
||||
"src/lib/db/apiKeys.ts": 1661,
|
||||
"src/lib/db/apiKeys.ts": 1662,
|
||||
"src/lib/db/core.ts": 1820,
|
||||
"src/lib/db/migrationRunner.ts": 1125,
|
||||
"src/lib/db/models.ts": 1180,
|
||||
"src/lib/db/models.ts": 1184,
|
||||
"src/lib/db/providers.ts": 1050,
|
||||
"src/lib/db/proxies.ts": 1048,
|
||||
"src/lib/db/settings.ts": 1149,
|
||||
|
|
@ -160,13 +169,57 @@
|
|||
"src/shared/components/RequestLoggerV2.tsx": 1287,
|
||||
"src/shared/components/analytics/charts.tsx": 1558,
|
||||
"src/shared/constants/cliTools.ts": 875,
|
||||
"src/shared/constants/pricing.ts": 1581,
|
||||
"src/shared/constants/providers.ts": 3169,
|
||||
"src/shared/constants/pricing.ts": 1592,
|
||||
"src/shared/constants/providers.ts": 3242,
|
||||
"src/shared/constants/sidebarVisibility.ts": 1100,
|
||||
"src/shared/services/cliRuntime.ts": 1090,
|
||||
"src/shared/validation/schemas.ts": 2523,
|
||||
"src/sse/handlers/chat.ts": 1486,
|
||||
"src/sse/services/auth.ts": 2219
|
||||
"src/sse/services/auth.ts": 2279
|
||||
},
|
||||
"testCap": 800,
|
||||
"testFrozen": {
|
||||
"tests/integration/chat-pipeline.test.ts": 1669,
|
||||
"tests/integration/chatcore-compression-integration.test.ts": 1111,
|
||||
"tests/integration/skills-pipeline.test.ts": 918,
|
||||
"tests/unit/account-fallback-service.test.ts": 1569,
|
||||
"tests/unit/arena-elo-sync.test.ts": 830,
|
||||
"tests/unit/batch_api.test.ts": 1303,
|
||||
"tests/unit/cc-compatible-provider.test.ts": 1179,
|
||||
"tests/unit/chatcore-sanitization.test.ts": 829,
|
||||
"tests/unit/chatcore-translation-paths.test.ts": 2810,
|
||||
"tests/unit/chatgpt-web.test.ts": 2809,
|
||||
"tests/unit/combo-routing-engine.test.ts": 3213,
|
||||
"tests/unit/db-core-init.test.ts": 864,
|
||||
"tests/unit/db-migration-runner.test.ts": 1491,
|
||||
"tests/unit/db-settings-crud.test.ts": 941,
|
||||
"tests/unit/deepseek-web.test.ts": 1081,
|
||||
"tests/unit/executor-antigravity.test.ts": 942,
|
||||
"tests/unit/executor-codex.test.ts": 1339,
|
||||
"tests/unit/executor-default-base.test.ts": 1339,
|
||||
"tests/unit/grok-web.test.ts": 2437,
|
||||
"tests/unit/image-generation-handler.test.ts": 1996,
|
||||
"tests/unit/model-sync-route.test.ts": 1016,
|
||||
"tests/unit/models-catalog-route.test.ts": 1507,
|
||||
"tests/unit/oauth-providers-config.test.ts": 855,
|
||||
"tests/unit/perplexity-web.test.ts": 959,
|
||||
"tests/unit/provider-models-route.test.ts": 1618,
|
||||
"tests/unit/provider-validation-specialty.test.ts": 2752,
|
||||
"tests/unit/providers-page-utils.test.ts": 1004,
|
||||
"tests/unit/reasoning-cache.test.ts": 980,
|
||||
"tests/unit/route-edge-coverage.test.ts": 1234,
|
||||
"tests/unit/search-handler-extended.test.ts": 1124,
|
||||
"tests/unit/sse-auth.test.ts": 1553,
|
||||
"tests/unit/stream-utils.test.ts": 2435,
|
||||
"tests/unit/token-refresh-service.test.ts": 1322,
|
||||
"tests/unit/translator-friendly-test-bench.test.tsx": 848,
|
||||
"tests/unit/translator-helper-branches.test.ts": 850,
|
||||
"tests/unit/translator-openai-responses-req.test.ts": 1047,
|
||||
"tests/unit/translator-openai-to-gemini.test.ts": 1579,
|
||||
"tests/unit/translator-openai-to-kiro.test.ts": 918,
|
||||
"tests/unit/translator-resp-gemini-to-openai.test.ts": 1234,
|
||||
"tests/unit/usage-service-hardening.test.ts": 1633,
|
||||
"tests/unit/vscode-token-routes.test.ts": 1208
|
||||
},
|
||||
"_rebaseline_2026_06_09": "Re-baseline consciente pre-release v3.8.19: 9 arquivos cresceram durante o ciclo (features mergeadas: RequestLoggerV2 +281 request-logger rework, stream +101, combo +73, chatCore +45, catalog +32 fable-5/catalog-flag, callLogs +4, accountFallback +2, usageHistory novo 840) + core.ts +7 (fix resetAllDbModuleState, PR 3536). A catraca segue valendo destes valores — proximo crescimento falha. Decisao: encolher (esp. RequestLoggerV2/chatCore) e a issue #3501 ficam para o ciclo seguinte.",
|
||||
"_rebaseline_2026_06_11_phase1f": "Phase 1f (#3501): ProviderDetailPageClient.tsx 4948→4062 (-886 LOC); 3 novos hooks extraídos. useProviderConnections.ts=954 acima do cap=800 — justificado: extração direta do god-component (zero lógica nova), própria redução do cliente supera o custo. useProviderSettings.ts=263 e useProviderModels.ts=154 já abaixo do cap.",
|
||||
|
|
@ -201,5 +254,7 @@
|
|||
"_rebaseline_2026_06_16_4005_openai_dynamic_models": "PR #4005 own growth: models/route.ts 2494->2512 (+18 = openai model-discovery derives {customBaseUrl}/v1/models from providerSpecificData.baseUrl, SSRF-guarded via safeOutboundFetch+public-only) and pricing.ts 1529->1581 (+52 = pure-data pricing rows closing $0 gaps for registry-exposed ids: openai gpt-5.4/-mini/-nano, gpt-4.1, gpt-4o-2024-11-20, o3 + codex(cx) gpt-5.4-{xhigh,high,medium,low}, gpt-5.3-codex-spark). Cohesive; pricing is data, route change mirrors the anthropic-compat discovery path.",
|
||||
"_rebaseline_2026_06_16_4004_livews_bridge": "PR #4004 own growth: chatCore.ts 5830->5851 (+21 = forwardDashboardEventToLiveWs — a best-effort, non-blocking, timeout-bounded POST that bridges compression.completed events from the main process to the LiveWS sidecar so the dashboard updates under a reverse proxy). Cohesive fire-and-forget beacon at the existing compression emit site; not extractable. Structural shrink of chatCore.ts tracked in #3501.",
|
||||
"_rebaseline_2026_06_17_4107_pending_reaper": "PR #4107 own growth: usageHistory.ts 854->934 (+80 = orphaned-pending-request reaper — sweepStalePendingRequests() evicts pending details older than 15min + a hard 5000 cap, plus an unref'd 5min sweep timer wired lazily into trackPendingRequest). Fixes an unbounded memory leak where abnormally-terminated requests left payload previews in pendingById forever. Cohesive with the existing pending-request bookkeeping (mirrors the normal removal path: decrement counters + cleanup buckets); not extractable.",
|
||||
"_rebaseline_2026_06_17_4116_combo_hedge_listener": "combo.ts: +9 lines from #4116 (detach per-target listener from shared hedge abort signal to fix a listener leak). Behavior-preserving cleanup; 5289 -> 5298."
|
||||
"_rebaseline_2026_06_17_4116_combo_hedge_listener": "combo.ts: +9 lines from #4116 (detach per-target listener from shared hedge abort signal to fix a listener leak). Behavior-preserving cleanup; 5289 -> 5298.",
|
||||
"_rebaseline_2026_06_20_4355_gpt5x_pro_pricing": "PR #4355 own growth: pricing.ts 1581->1592 (+11 = pure-data pricing rows for openai gpt-5.5-pro + gpt-5.4-pro, closing the $0 gap that tripped the catalog pricing gate after the #4324 sweep added them to the registry; -pro mirrors its base family tier). provider-models-route.test.ts 1616->1618 (+2 = test-only alignment to the intentional opencode-go discovery behavior: owned_by stamp + T39 two-endpoint fail-path fetchCalls). Both are data/test-only; not extractable.",
|
||||
"_rebaseline_2026_06_19_4293_codex_spark_scope": "PR #4293 (isolate Codex Spark quota scope) own growth, MEASURED on the actual merged tree (release/v3.8.30 + #4293). Production: auth.ts 2219->2279 (+60) threads requestedModel into Codex quota-policy/headroom/preflight/P2C scoring so normal Codex and GPT-5.3-Codex-Spark windows are evaluated independently; chatCore.ts 5116->5125 (+9) passes the failing model scope into Codex 429 failover (markCodexScopeRateLimited) instead of a connection-wide rateLimitedUntil write; accountFallback.ts 1727->1731 (+4) scopes Codex model-lock keys to codex vs spark. Heavy parsing/display logic lives in new leaf helpers under the cap (codexQuotaScopes.ts, codexUsageQuotas.ts, codexFailover.ts). Tests: account-fallback-service 1544->1569, executor-codex 1336->1339, sse-auth 1527->1553, usage-service-hardening 1612->1633 (added Spark-scope regression coverage). Cohesive wiring at existing selection/failover lockout boundaries; not extractable."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,12 +90,12 @@
|
|||
"eps": 0.5
|
||||
},
|
||||
"deadExports": {
|
||||
"value": 339,
|
||||
"value": 340,
|
||||
"direction": "down",
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"cognitiveComplexity": {
|
||||
"value": 753,
|
||||
"value": 783,
|
||||
"direction": "down",
|
||||
"dedicatedGate": true
|
||||
},
|
||||
|
|
@ -116,7 +116,7 @@
|
|||
"dedicatedGate": true
|
||||
},
|
||||
"zizmorFindings": {
|
||||
"value": 139,
|
||||
"value": 148,
|
||||
"direction": "down",
|
||||
"dedicatedGate": true
|
||||
},
|
||||
|
|
@ -141,6 +141,161 @@
|
|||
"direction": "down",
|
||||
"dedicatedGate": true,
|
||||
"_note": "semgrep owasp/secrets findings. ADVISORY until first CI value is frozen, then flip blocking (Fase 9)."
|
||||
},
|
||||
"mutationScore.src/sse/services/auth.ts": {
|
||||
"value": 52.57,
|
||||
"direction": "up",
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"mutationScore.open-sse/services/accountFallback.ts": {
|
||||
"value": 68.38,
|
||||
"direction": "up",
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"mutationScore.src/server/authz/routeGuard.ts": {
|
||||
"value": 76.08,
|
||||
"direction": "up",
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"mutationScore.src/shared/utils/circuitBreaker.ts": {
|
||||
"value": 56.94,
|
||||
"direction": "up",
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"mutationScore.open-sse/utils/error.ts": {
|
||||
"value": 43.83,
|
||||
"direction": "up",
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"mutationScore.open-sse/utils/publicCreds.ts": {
|
||||
"value": 59.76,
|
||||
"direction": "up",
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"mutationScore.open-sse/services/combo/autoStrategy.ts": {
|
||||
"value": 41.33,
|
||||
"direction": "up",
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"mutationScore.open-sse/services/combo/comboStructure.ts": {
|
||||
"value": 57.82,
|
||||
"direction": "up",
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"mutationScore.open-sse/services/combo/validateQuality.ts": {
|
||||
"value": 61.33,
|
||||
"direction": "up",
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"mutationScore.open-sse/services/combo/comboPredicates.ts": {
|
||||
"value": 56.62,
|
||||
"direction": "up",
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"mutationScore.open-sse/services/combo/rrState.ts": {
|
||||
"value": 70.88,
|
||||
"direction": "up",
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"mutationScore.open-sse/services/combo/shadowRouting.ts": {
|
||||
"value": 48,
|
||||
"direction": "up",
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"mutationScore.open-sse/services/combo/targetSorters.ts": {
|
||||
"value": 68.3,
|
||||
"direction": "up",
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"mutationScore.open-sse/services/combo/comboData.ts": {
|
||||
"value": 76.94,
|
||||
"direction": "up",
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"mutationScore.open-sse/services/combo/quotaScoring.ts": {
|
||||
"value": 39.73,
|
||||
"direction": "up",
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"mutationScore.open-sse/services/combo/quotaStrategies.ts": {
|
||||
"value": 50.3,
|
||||
"direction": "up",
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"mutationScore.open-sse/handlers/chatCore/passthroughHelpers.ts": {
|
||||
"value": 80.89,
|
||||
"direction": "up",
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"mutationScore.open-sse/handlers/chatCore/sanitization.ts": {
|
||||
"value": 70.15,
|
||||
"direction": "up",
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"mutationScore.open-sse/handlers/chatCore/upstreamTimeouts.ts": {
|
||||
"value": 33,
|
||||
"direction": "up",
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"mutationScore.open-sse/handlers/chatCore/comboContextCache.ts": {
|
||||
"value": 13.62,
|
||||
"direction": "up",
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"mutationScore.open-sse/handlers/chatCore/idempotency.ts": {
|
||||
"value": 42.82,
|
||||
"direction": "up",
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"mutationScore.open-sse/handlers/chatCore/responseHeaders.ts": {
|
||||
"value": 62.7,
|
||||
"direction": "up",
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"mutationScore.open-sse/handlers/chatCore/executorHelpers.ts": {
|
||||
"value": 70.39,
|
||||
"direction": "up",
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"mutationScore.open-sse/handlers/chatCore/memoryExtraction.ts": {
|
||||
"value": 62.06,
|
||||
"direction": "up",
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"mutationScore.open-sse/handlers/chatCore/nonStreamingSse.ts": {
|
||||
"value": 72.82,
|
||||
"direction": "up",
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"mutationScore.open-sse/handlers/chatCore/passthroughToolNames.ts": {
|
||||
"value": 66.42,
|
||||
"direction": "up",
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"mutationScore.open-sse/handlers/chatCore/headers.ts": {
|
||||
"value": 94.29,
|
||||
"direction": "up",
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"mutationScore.open-sse/handlers/chatCore/logTruncation.ts": {
|
||||
"value": 77.64,
|
||||
"direction": "up",
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"mutationScore.open-sse/handlers/chatCore/memorySkillsInjection.ts": {
|
||||
"value": 13.49,
|
||||
"direction": "up",
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"mutationScore.open-sse/handlers/chatCore/semanticCache.ts": {
|
||||
"value": 60.16,
|
||||
"direction": "up",
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"mutationScore.open-sse/handlers/chatCore/telemetryHelpers.ts": {
|
||||
"value": 83.18,
|
||||
"direction": "up",
|
||||
"dedicatedGate": true
|
||||
}
|
||||
},
|
||||
"_coverage_note": "Pisos anti-flake ~2pt abaixo do real do CI mergeado MEDIDO COM os 135 testes religados (run 27247237268: statements 78.4 / lines 78.4 / functions 83.84 / branches 75.73). O religamento da 6A.1 HONESTIFICOU a regua: os ~82.5 anteriores eram inflados porque modulos nunca importados ficavam fora do denominador do c8. Apertar via --require-tighten na Fase 6A (2026-06-16).",
|
||||
|
|
@ -151,6 +306,8 @@
|
|||
"_eslint_rebaseline_2026_06_15_release_v3826": "3669 -> 3760. Medido em origin/release/v3.8.26 e neste PR com npm run quality:collect: ambos retornam 3760 warnings, portanto este PR e neutro; o drift ja existe na base release/v3.8.26.",
|
||||
"_eslint_rebaseline_2026_06_16_v3826_forward_merge": "3760 -> 3769. O quality-gate da main FALHOU no forward-merge release->main (run 27593205254): eslintWarnings 3769 > baseline 3760. Medido AGORA em origin/release/v3.8.26 (273ecf7b5, com todos os merges do ciclo) via npm run quality:collect = 3769 — identico ao CI, e os PRs de gate posteriores (#3947/#3949/#3951/#3956/#3961) nao mudaram a contagem (scripts/check/*.mjs sao eslint-ignored; os arquivos de teste novos nao adicionaram any/warnings). O +9 e drift release-wide pre-existente do ciclo v3.8.26 (merges de feature/outras sessoes), nao regressao de produto. Re-baseline consciente p/ o valor real medido; apertar via --require-tighten no fim do ciclo.",
|
||||
"_quality_rebaseline_2026_06_15_release_v3826": "deadExports 327 -> 339 e cognitiveComplexity 738 -> 753. Medido em origin/release/v3.8.26 e neste PR com os dedicated gates: ambos retornam os mesmos valores, portanto este PR e neutro; typeCoveragePct permanece acima do baseline.",
|
||||
"_dead_code_rebaseline_2026_06_19_pr4293": "deadExports 339 -> 340. Medido em origin/main com a mesma toolchain/deps deste PR (`node scripts/check/check-dead-code.mjs`) = DEAD_TOTAL 340, e o HEAD deste PR tambem mede 340; portanto o PR e neutro e o baseline anterior estava 1 item atrasado.",
|
||||
"_cognitive_rebaseline_2026_06_19_pr4293": "cognitiveComplexity 753 -> 783. Medido em origin/main com a mesma toolchain/deps deste PR (`node scripts/check/check-cognitive-complexity.mjs`) = 783; apos refatorar os helpers deste PR, o HEAD tambem mede 783. Portanto o PR fica neutro e o baseline anterior estava desatualizado vs main atual.",
|
||||
"_scanner_baselines_seeded_2026_06_15": "secretFindings (3), zizmorFindings (195), vulnCount (13) e bundleSize (5601) congelados a partir de um run LOCAL em 2026-06-15 com os binarios reais no PATH (gitleaks 8.30.1, osv-scanner 2.3.8, zizmor 1.25.2, @size-limit/file 12.1.0). Medicoes: (a) secretFindings=3 via 'gitleaks dir <dir>' por diretorio de fonte (src/open-sse/bin/electron/scripts) APOS corrigir o .gitleaks.toml para [extend].useDefault=true (sem isso o config customizado zerava o ruleset e nunca detectava nada) e a invocacao para escopo por-dir (gitleaks dir aceita 1 path; multiplos caiam para escanear o CWD inteiro/node_modules->timeout). Os 3 sao falsos-positivos do heuristico generic-api-key (string de header beta Anthropic + nomes de coluna latencyP50Ms/latencyP95Ms), a serem allowlistados ao longo do tempo; (b) zizmorFindings=195 via 'npm run check:workflows' APOS migrar .zizmor.yml do schema antigo 'ignores: []' para 'rules: {}' (zizmor 1.25.2 rejeitava o campo 'ignores'); (c) vulnCount=13 (LOW=4/MOD=7/HIGH=2) via osv-scanner; (d) bundleSize=5601 (gzip dos 4 entrypoints bin/*.mjs) via size-limit+@size-limit/file. Todos os 4 sao dedicatedGate:true => SKIP no ratchet BLOQUEANTE (job quality-gate) e ADVISORY no job quality-extended (continue-on-error). Permanecem advisory ate um run VERDE de CI confirmar que a tooling corrigida (install via 'gh release download' em vez de api.github.com nao-autenticado) produz os valores; o flip para bloqueante (remover continue-on-error) fica para um PR de follow-up. Direction:down em todos.",
|
||||
"_scanner_remediation_2026_06_15": "Remediacao das findings reais que os scanners semeados acima expuseram (medido localmente em 2026-06-15 com os mesmos binarios). vulnCount 13->10: bump dos 2 HIGH transitivos via package.json overrides — form-data 4.0.5->^4.0.6 (GHSA-hmw2-7cc7-3qxx, via axios) e vite 8.0.5->^8.0.16 (GHSA-fx2h-pf6j-xcff HIGH + GHSA-v6wh-96g9-6wx3 MODERATE, dev-only via vitest/@vitejs/plugin-react/fumadocs-mdx); osv-scanner confirma 0 HIGH restante; build:cli e a suite vitest MCP (16 files/187 testes) verdes pos-bump. zizmorFindings 195->187: env-harden de 7 findings template-injection (ci.yml job i18n; electron-release.yml jobs validate/build/release — o step 'Create source archives' sozinho gerava 4 das 7) movendo cada ${{...}} para 'env:' e referenciando \"$VAR\" no script, + allowlist de 1 dangerous-triggers (deploy-vps.yml on:workflow_run — guardado por conclusion=='success', deploy via SSH sem checkout de codigo nao-confiavel; entry em .zizmor.yml rules.dangerous-triggers.ignore). secretFindings (3) e bundleSize (5601) intocados neste PR. Apertados via edicao manual (direction:down).",
|
||||
"_scanner_flip_blocking_2026_06_16": "Etapa 2: secretFindings (3), zizmorFindings e bundleSize (5601) PROMOVIDOS de ADVISORY para RATCHET BLOQUEANTE. Os 3 scripts (check-secrets/check-workflows/check-bundle-size) ganharam um modo --ratchet que le metrics.<key>.value daqui, compara a contagem MEDIDA e sai 1 SOMENTE numa regressao real (medida > baseline). Sem --ratchet permanecem advisory (exit 0). Qualquer SKIP gracioso (binario ausente, plugin size-limit ausente => fallback-stat/no-build, build nao rodou) sai 0 MESMO com --ratchet — falta de infra nunca bloqueia, so uma regressao medida bloqueia. zizmorFindings re-baselineada 187 -> 192: o +5 e drift LEGITIMO de novos arquivos de workflow (nightly-schemathesis.yml etc.) adicionados no ciclo v3.8.26, mesma convencao @vN unpinned de todos os workflows; reproduzivel localmente E confirmado no run de CI #27593205254 (job 81578109020) = 192. secretFindings (3) e bundleSize (5601) intocados — ja batiam o valor do CI. NB: bundleSize=5601 e o valor GZIP do size-limit + @size-limit/file (instalado por 'npm ci' no CI); o fallback-stat le bytes CRUS (16670, metrica diferente) e por isso o modo --ratchet SO bloqueia quando a medicao veio do size-limit real, fazendo SKIP no fallback. actionlintFindings NAO entra no ratchet (so reportada); o --strict all-or-nothing do check-workflows permanece separado.",
|
||||
|
|
@ -164,5 +321,8 @@
|
|||
"_osv_flip_blocking_2026_06_16_v3827": "Fim do ciclo v3.8.27: vulnCount (value 10, direction down, dedicatedGate) PROMOVIDO de ADVISORY para RATCHET BLOQUEANTE. check-vuln-ratchet.mjs ganhou um modo --ratchet (espelhando check-secrets/check-bundle-size/check-workflows da Etapa 2): le metrics.vulnCount.value daqui, compara a contagem MEDIDA pelo osv-scanner e sai 1 SOMENTE numa regressao real (medida > baseline). Sem --ratchet permanece advisory (exit 0). QUALQUER SKIP gracioso (osv-scanner ausente do PATH, osv.dev/rede inacessivel, timeout, JSON invalido) sai 0 MESMO com --ratchet — uma falha de MEDICAO nunca bloqueia, so uma regressao MEDIDA bloqueia. No ci.yml (job quality-extended) o step 'Vulnerability ratchet' passou a rodar 'npm run check:vuln-ratchet -- --ratchet' sem continue-on-error. Verificado no tip de release/v3.8.27: osv mede vulnCount=10 == baseline 10 (exit 0); forcar baseline=9 da exit 1 (10>9); PATH vazio da SKIP binary-absent exit 0. NB de VARIANCIA DE CVE: um CVE recem-divulgado numa dep ja presente pode redar o gate sem mudanca de dependencia — comportamento esperado, remedio = bumpar a dep ou re-baseline vulnCount com justificativa+issue. Ver docs/security/SUPPLY_CHAIN.md -> 'Variancia de CVE'.",
|
||||
"_trivy_flip_blocking_2026_06_16_v3827": "Fim do ciclo v3.8.27: Trivy (scan de CVE da imagem Docker em docker-publish.yml) promovido para BLOQUEANTE em CRITICAL. Abordagem de DOIS PASSOS: o passo SARIF existente (severity HIGH,CRITICAL / exit-code 0 / upload SARIF) fica INTACTO para visibilidade na aba Security; um novo passo 'Trivy CRITICAL gate (blocking)' (severity CRITICAL / ignore-unfixed:true / exit-code 1) falha o release num CVE CRITICO FIXAVEL. ignore-unfixed evita travar por CVE de base-image sem patch upstream (reduz falso-bloqueio). Mesma variancia-de-CVE do osv: um novo CRITICAL fixavel divulgado pode redar; remedio = rebuild sobre base patcheada, bumpar dep, ou .trivyignore com justificativa+issue. Ver docs/security/SUPPLY_CHAIN.md. vulnCount permanece 10 (intocado neste flip — so a postura advisory->bloqueante mudou).",
|
||||
"_rebaseline_2026_06_18_v3828_cycle_close": "Fim do ciclo v3.8.28 (RELEASED; ciclo v3.8.29 aberto): 3 metricas re-baselineadas para o valor REAL medido no push->main pos-release (run 27725117464, step 'Ratchet check') — eslintWarnings 3769->3779, openapiCoverage.pct 38.3->37.6, i18nUiCoverage.pct 80.1->79.1. Reproduzido localmente em release/v3.8.29 (9f14c1294): identico ao CI. Drift de fim-de-ciclo de features legitimas, NAO hand-cleanable: (a) eslint +10 = 'any' PERMITIDO (warn) em testes do ciclo + 4 react-hooks/exhaustive-deps em RequestLoggerV2.tsx (componente com bugs sutis de refresh #4103/#3972, arriscado mexer em deps de hook sem teste de UI); (b) openapi -0.7 = drop por rotas NOVAS INTERNAS (/api/tools/agent-bridge/* LOCAL_ONLY, spawnam MITM/DNS) — documenta-las no spec PUBLICO seria gaming; (c) i18n -1.0 = 37/41 locales em 79.1% (1741 chaves faltando cada, ~3000 traducoes via 'npm run i18n:run' que exige creds OMNIROUTE_TRANSLATION_API_KEY indisponiveis localmente). Mesmo precedente do _eslint_rebaseline_2026_06_16_v3826_forward_merge. Apertar no fim do ciclo: eslint/openapi via --require-tighten; i18n via i18n:run com creds. Autorizado pelo operador (decisao explicita).",
|
||||
"_rebaseline_2026_06_19_v3829_cycle_close": "Release do ciclo v3.8.29: eslintWarnings re-baselineado 3779->3816 para o valor REAL medido em release/v3.8.29 (tip da3...; `npm run lint` local = 3816, identico ao Quality Ratchet do CI no PR #4126). O +37 e drift de fim-de-ciclo de 115 commits de features legitimas — `any` PERMITIDO (warn) em open-sse/ e tests/ do ciclo; os arquivos de reconciliacao deste release nao adicionam warnings (scripts/check/*.mjs sao eslint-ignored, o teste novo de check-fabricated-docs nao usa any). Mesmo precedente de _rebaseline_2026_06_18_v3828_cycle_close. Apertar via --require-tighten no fim do ciclo seguinte. ALÉM disso, o step Require-tighten (blocking) exigiu apertar 2 métricas que MELHORARAM no ciclo (medidas no CI do PR #4126): coverage.auth.lines 69->90 (CI mediu 92.6; piso ~2pt-abaixo-do-real anti-flake, dentro do tightenSlack 10) e openapiCoverage.pct 37.6->38.4 (rotas novas documentadas). Melhorias legitimas travadas no baseline, nao gaming. Autorizado pelo operador (release end-to-end, validado na VPS)."
|
||||
"_rebaseline_2026_06_19_v3829_cycle_close": "Release do ciclo v3.8.29: eslintWarnings re-baselineado 3779->3816 para o valor REAL medido em release/v3.8.29 (tip da3...; `npm run lint` local = 3816, identico ao Quality Ratchet do CI no PR #4126). O +37 e drift de fim-de-ciclo de 115 commits de features legitimas — `any` PERMITIDO (warn) em open-sse/ e tests/ do ciclo; os arquivos de reconciliacao deste release nao adicionam warnings (scripts/check/*.mjs sao eslint-ignored, o teste novo de check-fabricated-docs nao usa any). Mesmo precedente de _rebaseline_2026_06_18_v3828_cycle_close. Apertar via --require-tighten no fim do ciclo seguinte. ALÉM disso, o step Require-tighten (blocking) exigiu apertar 2 métricas que MELHORARAM no ciclo (medidas no CI do PR #4126): coverage.auth.lines 69->90 (CI mediu 92.6; piso ~2pt-abaixo-do-real anti-flake, dentro do tightenSlack 10) e openapiCoverage.pct 37.6->38.4 (rotas novas documentadas). Melhorias legitimas travadas no baseline, nao gaming. Autorizado pelo operador (release end-to-end, validado na VPS).",
|
||||
"_comment_mutationScore": "Per-module COVERED mutation score floors (detected/(detected+survived)), seeded ~2pt below the first full measurement (run 27823984918: split batches a1/a2/b1/b2/c1/c2/d + e/f/g/h/i). direction:up + dedicatedGate:true -> enforced ONLY by check-mutation-ratchet.mjs (the generic check-quality-ratchet skips dedicatedGate metrics), in the nightly-mutation aggregation job.",
|
||||
"_zizmor_rebaseline_2026_06_19_r1_redundancy": "zizmorFindings 139 -> 145. Quebra: +3 drift PRE-EXISTENTE da base release/v3.8.30 a23d0d678 (medido com minhas mudancas stashed = 142 > 139; o fast-path do release nao roda check:workflows --ratchet) + 3 do novo workflow mutation-redundancy.yml (R1 disableBail): exatamente 3 unpinned-uses de actions/checkout@v7, actions/setup-node@v6, actions/upload-artifact@v7 — a MESMA convencao @vN deliberada e INTOCADA de todos os workflows (ver _scanner_harden_workflows_2026_06_16), identica ao nightly-mutation.yml. SHA-pinar so este workflow violaria a convencao. NOTA DE COLISAO CROSS-PR: o PR #4321 (a11y) tambem rebaselina este metric 139->145 (+3 do job a11y) off a MESMA base — se ambos mergearem, o total real vira 148 (142 base + 3 a11y + 3 r1) e o segundo a mergear precisa reconciliar zizmorFindings -> 148 (mesmo padrao release-volatil dos baselines de complexity/eslint).",
|
||||
"_zizmor_rebaseline_2026_06_19_a11y_148_reconcile": "RECONCILIACAO CROSS-PR (release-volatil) ao mergear #4321 (a11y) APOS #4322 (R1): zizmorFindings 145 -> 148. O #4322 ja rebaselinou 139->145 (drift base 142 + 3 unpinned-uses do mutation-redundancy.yml). Este PR adiciona +3 unpinned-uses @vN do novo job 'a11y' (nightly-resilience.yml): actions/checkout@v7, actions/setup-node@v6, actions/cache@v5.0.5 — MESMA convencao @vN deliberada e INTOCADA de todos os workflows (ver _scanner_harden_workflows_2026_06_16). Total = 142 base + 3 r1 + 3 a11y = 148, MEDIDO com `node scripts/check/check-workflows.mjs --ratchet` na arvore release(com #4322)+#4321 = 148 exato. Nenhum template-injection/artipacked/cache-poisoning novo."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -284,6 +284,25 @@ Compression exposes five MCP tools:
|
|||
| `omniroute_list_compression_combos` | `read:compression` | List compression combos |
|
||||
| `omniroute_compression_combo_stats` | `read:compression` | Read combo/engine analytics |
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **LLMLingua-2 (SLM) requires co-located optional deps.** The worker only runs in a
|
||||
production build when `@atjsh/llmlingua-2` + peers are co-located into
|
||||
`dist/node_modules` (see `scripts/build/colocateOptionals.mjs`, #4286). Without them the
|
||||
engine fail-opens (returns the original text). Worker resolution no longer depends on
|
||||
`import.meta.url` (it dies in the standalone bundle) — it anchors on the runtime
|
||||
cwd / `argv[1]`.
|
||||
- **Caveman language packs `de` / `fr` / `ja` are partial.** They ship `context` +
|
||||
`filler` + `structural` rules but no `dedup` / `ultra` packs, so `ultra` intensity is
|
||||
no stronger than `full` for those languages (they use only their own rules — there is no
|
||||
silent fall-back to the English `dedup`/`ultra` rules, which would mangle foreign text).
|
||||
`en` / `es` / `id` / `pt-BR` are complete. Contributions of `dedup.json` + `ultra.json`
|
||||
for the partial packs are welcome.
|
||||
- **Stacked telemetry only lists engines that compressed.** A stacked-pipeline step whose
|
||||
engine ran but produced 0 % savings returns `stats:null` and so does not appear in
|
||||
`engineBreakdown` — indistinguishable from a step that was skipped. Distinguishing
|
||||
"ran, 0 %" from "skipped" would require a breakdown-model change and is deferred.
|
||||
|
||||
## Validation
|
||||
|
||||
The focused gates for this area are:
|
||||
|
|
|
|||
144
docs/guides/CLAUDE-CODE-CONFIGURATION.md
Normal file
144
docs/guides/CLAUDE-CODE-CONFIGURATION.md
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
---
|
||||
title: "Claude Code CLI — Configuration with OmniRoute"
|
||||
version: 3.8.30
|
||||
lastUpdated: 2026-06-19
|
||||
---
|
||||
|
||||
# Claude Code CLI — Configuration with OmniRoute
|
||||
|
||||
Point the **Claude Code** CLI (`claude`) at OmniRoute — local or a remote VPS —
|
||||
with per-model profiles, mirroring the Codex setup.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Launch Claude Code against a local OmniRoute (auto-detects the active context)
|
||||
omniroute launch
|
||||
|
||||
# Against a remote OmniRoute (after `omniroute connect <host>`, this is automatic)
|
||||
omniroute launch --remote http://192.168.0.15:20128 --api-key oma_live_xxx
|
||||
|
||||
# Generate per-model profiles, then launch one
|
||||
omniroute setup-claude # writes ~/.claude/profiles/<name>/settings.json
|
||||
omniroute launch --profile glm52 # Claude Code using glm/glm-5.2 via OmniRoute
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How Claude Code connects to a gateway
|
||||
|
||||
Claude Code talks the **Anthropic Messages API** and is pointed at a custom
|
||||
endpoint with environment variables (it has no `--base-url` flag):
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `ANTHROPIC_BASE_URL` | Gateway root URL (Claude Code appends `/v1/messages`). **No `/v1` suffix.** |
|
||||
| `ANTHROPIC_AUTH_TOKEN` | Sent as `Authorization: Bearer …` — use your OmniRoute access token / API key |
|
||||
| `ANTHROPIC_API_KEY` | Alternative: sent as `x-api-key`. If both set, `ANTHROPIC_AUTH_TOKEN` wins |
|
||||
| `ANTHROPIC_MODEL` | Force a specific model (overrides the `/model` picker default) |
|
||||
| `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY` | `1` → the native `/model` picker lists `claude*`/`anthropic*` models from `/v1/models` |
|
||||
| `CLAUDE_CODE_MAX_OUTPUT_TOKENS` | Cap output tokens per response (e.g. `65536`) |
|
||||
| `CLAUDE_CODE_AUTO_COMPACT_WINDOW` | Token threshold for auto-compaction |
|
||||
|
||||
> Env vars are read **once at startup** — restart Claude Code after changing them.
|
||||
|
||||
`omniroute launch` sets all of these for you: it resolves the base URL + token
|
||||
from the active context (so `omniroute connect <vps>` then `omniroute launch`
|
||||
just works), health-checks the server, and execs `claude`.
|
||||
|
||||
---
|
||||
|
||||
## Profiles (`CLAUDE_CONFIG_DIR`)
|
||||
|
||||
Claude Code has **no native profile files** (unlike Codex's `~/.codex/<name>.config.toml`).
|
||||
The idiomatic mechanism is `CLAUDE_CONFIG_DIR` — a separate config directory per
|
||||
profile, each with its own `settings.json`, credentials, history and cache.
|
||||
|
||||
`omniroute setup-claude` fetches the live `/v1/models` catalog and writes one
|
||||
profile per model at `~/.claude/profiles/<name>/settings.json`, reusing the
|
||||
**same names as `setup-codex`** (`glm52`, `kimi-k27`, `deepseek-pro`, …):
|
||||
|
||||
```jsonc
|
||||
// ~/.claude/profiles/glm52/settings.json
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||
"model": "glm/glm-5.2",
|
||||
"effortLevel": "xhigh",
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "http://192.168.0.15:20128",
|
||||
"ANTHROPIC_MODEL": "glm/glm-5.2",
|
||||
"CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY": "1",
|
||||
"CLAUDE_CODE_AUTO_COMPACT_WINDOW": "190000"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **The auth token is never written to the profile.** Launch with
|
||||
> `omniroute launch --profile <name>` (it injects `ANTHROPIC_AUTH_TOKEN` from the
|
||||
> active context), or export `ANTHROPIC_AUTH_TOKEN` yourself and run
|
||||
> `CLAUDE_CONFIG_DIR=~/.claude/profiles/<name> claude`.
|
||||
|
||||
### Generating + using profiles
|
||||
|
||||
```bash
|
||||
# Local OmniRoute
|
||||
omniroute setup-claude
|
||||
|
||||
# Remote VPS (bakes the VPS URL into every profile)
|
||||
omniroute setup-claude --remote http://192.168.0.15:20128 --api-key oma_live_xxx
|
||||
|
||||
# Only some providers
|
||||
omniroute setup-claude --only glm,kimi
|
||||
|
||||
# Preview without writing
|
||||
omniroute setup-claude --dry-run
|
||||
|
||||
# Launch a profile
|
||||
omniroute launch --profile kimi-k27
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Model tiers (optional)
|
||||
|
||||
Claude Code routes to capability tiers. Map each to an OmniRoute model via env /
|
||||
settings if you want different providers per tier:
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_DEFAULT_OPUS_MODEL="glm/glm-5.2"
|
||||
export ANTHROPIC_DEFAULT_SONNET_MODEL="kmc/kimi-k2.6"
|
||||
export ANTHROPIC_DEFAULT_HAIKU_MODEL="glm/glm-4.7-flash"
|
||||
```
|
||||
|
||||
Otherwise a single `ANTHROPIC_MODEL` (what profiles set) is used for everything.
|
||||
|
||||
---
|
||||
|
||||
## Remote mode
|
||||
|
||||
Once you've run `omniroute connect <host>` (see
|
||||
[Remote Mode](./REMOTE-MODE.md)), `omniroute launch` and `omniroute setup-claude`
|
||||
automatically target that remote server and use its scoped access token — no
|
||||
extra flags needed. Override per-invocation with `--remote` / `--api-key`.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Claude Code ignores the gateway** — confirm `ANTHROPIC_BASE_URL` has **no
|
||||
`/v1`** and restart `claude` (env is read once at startup). `omniroute launch`
|
||||
handles this for you.
|
||||
|
||||
**`/model` picker is empty / missing gateway models** — needs Claude Code
|
||||
v2.1.129+ and `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1`. Only `claude*` /
|
||||
`anthropic*` model IDs appear in the picker; force any other model with
|
||||
`ANTHROPIC_MODEL=<id>` (this is what profiles do).
|
||||
|
||||
**Auth errors** — the profile holds no token. Use `omniroute launch --profile`
|
||||
(injects it) or export `ANTHROPIC_AUTH_TOKEN`.
|
||||
|
||||
**Profiles don't isolate** — each profile is a distinct `CLAUDE_CONFIG_DIR`;
|
||||
verify `echo $CLAUDE_CONFIG_DIR` inside the session points at
|
||||
`~/.claude/profiles/<name>`.
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
title: "Codex CLI — Configuration with OmniRoute"
|
||||
version: 3.8.16
|
||||
lastUpdated: 2026-06-08
|
||||
version: 3.8.29
|
||||
lastUpdated: 2026-06-18
|
||||
---
|
||||
|
||||
# Codex CLI — Configuration with OmniRoute
|
||||
|
|
@ -21,7 +21,6 @@ model_provider = "omniroute"
|
|||
model_reasoning_effort = "xhigh"
|
||||
model_context_window = 400000
|
||||
model_auto_compact_token_limit = 350000
|
||||
model_max_output_tokens = 65536 # max tokens per response (model cap = 128k)
|
||||
tool_output_token_limit = 32768 # history storage cap per tool call
|
||||
|
||||
[model_providers.omniroute]
|
||||
|
|
@ -49,9 +48,9 @@ export OMNIROUTE_API_KEY="<YOUR_KEY>"
|
|||
|
||||
## `wire_api = "responses"` — why it works for all models
|
||||
|
||||
Codex CLI deprecated `wire_api = "chat"` (Chat Completions) in February 2026 and now **requires** `wire_api = "responses"` (OpenAI Responses API).
|
||||
Codex CLI deprecated `wire_api = "chat"` (Chat Completions) in February 2026 and now **requires** `wire_api = "responses"` (OpenAI Responses API). Setting `wire_api = "chat"` causes an immediate startup crash since v0.138.
|
||||
|
||||
DeepSeek and Mistral only expose a Chat Completions endpoint — not the Responses API. If you pointed Codex directly at DeepSeek or Mistral, it would fail with a 404.
|
||||
DeepSeek, GLM, Kimi and others only expose a Chat Completions endpoint — not the Responses API. If you pointed Codex directly at them, it would fail.
|
||||
|
||||
**OmniRoute solves this transparently:**
|
||||
|
||||
|
|
@ -60,54 +59,53 @@ Codex CLI
|
|||
→ wire_api = "responses"
|
||||
→ POST /v1/responses (OmniRoute)
|
||||
→ OmniRoute Responses ↔ Chat Completions transformer
|
||||
→ POST /chat/completions (DeepSeek / Mistral / any provider)
|
||||
→ POST /chat/completions (DeepSeek / Mistral / GLM / Kimi / any provider)
|
||||
```
|
||||
|
||||
You never need a separate translation proxy (`codex-relay`, `LiteLLM`, etc.) when using OmniRoute. **All models use `wire_api = "responses"`** — OmniRoute handles the rest.
|
||||
You never need a separate translation proxy when using OmniRoute. **All models use `wire_api = "responses"`** — OmniRoute handles the rest.
|
||||
|
||||
> **`wire_api` is the default** — the field defaults to `"responses"` and can be omitted entirely from `config.toml`. Only ever set it explicitly if you're documenting intent.
|
||||
|
||||
---
|
||||
|
||||
## Context window and compaction
|
||||
|
||||
### Why this matters
|
||||
|
||||
If the session history exceeds the model's context window, the Codex CLI either crashes or truncates silently. Different models have very different limits — setting these explicitly prevents surprises.
|
||||
|
||||
### Token configuration fields
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `model_context_window` | Total token budget for the active model. Set to the model's advertised limit. |
|
||||
| `model_auto_compact_token_limit` | Threshold that triggers automatic history compaction. **Maximum: 90% of `model_context_window`** — values above 90% are silently ignored. |
|
||||
| `model_max_output_tokens` | **Maximum tokens per response** (equivalent to Claude's `CLAUDE_CODE_MAX_OUTPUT_TOKENS`). Caps the output sent to the API on every request. Exists in CLI config since mid-2025 (Issue #4138, now fixed). |
|
||||
| `tool_output_token_limit` | Cap on tokens stored per tool call output in history. Prevents a single large tool response from filling the window. **This is not the max output** — it is a history storage cap. |
|
||||
| `compact_prompt` | Inline override for the system prompt used during compaction. |
|
||||
| `experimental_compact_prompt_file` | Load the compaction prompt from a file (experimental). |
|
||||
| `compact_prompt` | Inline override for the system prompt used during compaction (v0.138+). |
|
||||
|
||||
> **`model_max_output_tokens` vs `tool_output_token_limit`**: these are two different things.
|
||||
> - `model_max_output_tokens` = max tokens the model may produce in a single API response.
|
||||
> - `tool_output_token_limit` = max tokens stored per tool call in the session history.
|
||||
> **Note on `model_max_output_tokens`**: This field is **not part of the Codex CLI config schema** (absent from the Codex Rust codebase). It is silently ignored if set. Do not rely on it — use `tool_output_token_limit` to control how much tool output is stored in history.
|
||||
|
||||
### Context windows and output caps by model
|
||||
### Context windows by model
|
||||
|
||||
| Model | OmniRoute ID | Context window | Max output (model) | `model_max_output_tokens` | `auto_compact` | `tool_output_limit` |
|
||||
|-------|-------------|----------------|--------------------|---------------------------|----------------|----------------------|
|
||||
| GPT-5.5 | `cx/gpt-5.5` | 1,050,000 (400k reliable) | **128,000** | 65,536 | 350,000 | 32,768 |
|
||||
| DeepSeek V4 Pro | `ds/deepseek-v4-pro` | 1,000,000 | **384,000** | 65,536 | 900,000 | 65,536 |
|
||||
| Mistral Large Latest | `mistral/mistral-large-latest` | 262,144 (256k) | ~128,000 | 32,768 | 220,000 | 16,384 |
|
||||
| Model | OmniRoute ID | Context window | `auto_compact` | `tool_output_limit` |
|
||||
|-------|-------------|----------------|----------------|----------------------|
|
||||
| GPT-5.5 | `cx/gpt-5.5` | 400k reliable (1M max) | 350,000 | 32,768 |
|
||||
| Kimi K2.7 (thinking) | `kmc/kimi-k2.7` | 131,072 | 112,000 | 32,768 |
|
||||
| Kimi K2.6 | `kmc/kimi-k2.6` | 131,072 | 112,000 | 32,768 |
|
||||
| GLM-5.2 / 5.2-max (thinking) | `glm/glm-5.2` | 131,072 | 112,000 | 32,768 |
|
||||
| MiMo V2.5 Pro (thinking) | `opencode-go/mimo-v2.5-pro` | 131,072 | 112,000 | 32,768 |
|
||||
| Qwen 3.7 Plus (thinking) | `opencode-go/qwen3.7-plus` | 32,768 | 28,000 | 16,384 |
|
||||
| DeepSeek V4 Pro (OllamaCloud) | `ollamacloud/deepseek-v4-pro` | 131,072 | 112,000 | 32,768 |
|
||||
| DeepSeek V4 Pro | `ds/deepseek-v4-pro` | 1,000,000 | 900,000 | 65,536 |
|
||||
| MiMo V2.5 | `opencode-go/mimo-v2.5` | 131,072 | 112,000 | 32,768 |
|
||||
| Gemma 4 31B (OllamaCloud) | `ollamacloud/gemma4:31b` | 32,768 | 28,000 | 16,384 |
|
||||
| Nemotron 3 Super (OllamaCloud) | `ollamacloud/nemotron-3-super` | 32,768 | 28,000 | 16,384 |
|
||||
| GPT-OSS 20B (OllamaCloud) | `ollamacloud/gpt-oss:20b` | 32,768 | 28,000 | 16,384 |
|
||||
| DeepSeek V4 Flash (OllamaCloud) | `ollamacloud/deepseek-v4-flash` | 65,536 | 56,000 | 16,384 |
|
||||
| Gemini 3 Flash Preview (OllamaCloud) | `ollamacloud/gemini-3-flash-preview` | 1,000,000 | 850,000 | 32,768 |
|
||||
| GLM-5 Turbo | `glm/glm-5-turbo` | 131,072 | 112,000 | 16,384 |
|
||||
| GLM-4.7 Flash | `glm/glm-4.7-flash` | 131,072 | 112,000 | 16,384 |
|
||||
| Mistral Large Latest | `mistral/mistral-large-latest` | 262,144 | 220,000 | 16,384 |
|
||||
|
||||
> **Why not set `model_max_output_tokens` to the model's maximum?**
|
||||
> For a coding assistant that writes whole files and long diffs, 64k (65,536) is a practical sweet spot. The model can generate files up to ~50k tokens without hitting the cap. Reserve the higher limits for edge cases — they increase cost on every request regardless of output length.
|
||||
> **Compaction formula:** `effective_window = model_context_window - min(tool_output_token_limit, 20000)`. Values above 20k do not change the compaction trigger.
|
||||
|
||||
> **Compaction formula:** `effective_window = model_context_window - min(model_max_output_tokens, 20000)`. Values above 20k do not reduce the compaction trigger — the formula caps the output reservation at 20k. So setting `model_max_output_tokens = 65536` does not require lowering `model_auto_compact_token_limit`.
|
||||
|
||||
> **Rule of thumb:** set `model_auto_compact_token_limit` to 85–90% of `model_context_window`. Never go above 90% — it is silently ignored.
|
||||
|
||||
### How compaction works
|
||||
|
||||
When the session history exceeds `model_auto_compact_token_limit`, Codex CLI automatically summarises older turns into a compact form. The session continues without interruption — you lose verbatim history but keep context. This is different from truncation (which loses context).
|
||||
|
||||
For models with smaller windows (Mistral 256k), compaction fires earlier and more often. Setting a tighter `tool_output_token_limit` reduces how fast the window fills with tool call results.
|
||||
> **Rule of thumb:** set `model_auto_compact_token_limit` to 85–88% of `model_context_window`. Never go above 90% — silently ignored.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -122,236 +120,279 @@ All Codex models in OmniRoute use the `cx/` prefix:
|
|||
| `cx/gpt-5.4-mini` | GPT-5.4 mini |
|
||||
| `cx/gpt-5.1-codex-mini` | GPT-5.1 Codex mini |
|
||||
|
||||
Other providers use their own prefix (`ds/`, `mistral/`, etc.) — the prefix matches the OmniRoute provider alias.
|
||||
|
||||
> **Never use bare `gpt-5.5` or `codex/gpt-5.5`** — OmniRoute does not recognize those formats for the Codex provider.
|
||||
Other providers use their own prefix (`kmc/`, `glm/`, `ds/`, `ollamacloud/`, `opencode-go/`, `mistral/`) — the prefix matches the OmniRoute provider alias.
|
||||
|
||||
---
|
||||
|
||||
## Reasoning Effort
|
||||
|
||||
Controls how much the model "thinks" before responding. Higher effort = better quality, higher latency and cost.
|
||||
Controls how much the model "thinks" before responding.
|
||||
|
||||
### Available values
|
||||
|
||||
| Value | Recommended for |
|
||||
|-------|-----------------|
|
||||
| Value | Use for |
|
||||
|-------|---------|
|
||||
| `none` | No reasoning — direct response |
|
||||
| `low` | Trivial tasks (rename a variable, format code) |
|
||||
| `low` | Trivial tasks (rename, format) |
|
||||
| `medium` | **Server default** when not specified |
|
||||
| `high` | Intermediate tasks (refactoring, debugging) |
|
||||
| `high` | Intermediate tasks (refactoring, debug) |
|
||||
| `xhigh` | Architecture, deep analysis, complex problems |
|
||||
|
||||
> **Note:** `model_reasoning_effort` applies to models that support reasoning (GPT-5.x, DeepSeek V4 Pro). Mistral Large does not expose a reasoning effort parameter — setting it has no effect on Mistral.
|
||||
|
||||
### How to configure
|
||||
|
||||
**In `config.toml` (global default):**
|
||||
```toml
|
||||
model_reasoning_effort = "xhigh"
|
||||
```
|
||||
|
||||
**Per invocation via `-c` (overrides global):**
|
||||
```bash
|
||||
# Per invocation override
|
||||
codex -c model_reasoning_effort=low "rename variable x to count"
|
||||
codex -c model_reasoning_effort=xhigh "design the auth module architecture"
|
||||
```
|
||||
|
||||
**Combining model and effort:**
|
||||
```bash
|
||||
codex -m cx/gpt-5.4 -c model_reasoning_effort=medium "refactor the handler"
|
||||
```
|
||||
|
||||
> **About the default:** If `model_reasoning_effort` is not set, OmniRoute falls back to `"medium"`. Set it explicitly for serious engineering work.
|
||||
|
||||
---
|
||||
|
||||
## Selecting a model via the CLI
|
||||
|
||||
### 1. `--model` / `-m` flag — per invocation
|
||||
|
||||
```bash
|
||||
codex -m cx/gpt-5.5 "analyze the full pipeline"
|
||||
codex -m ds/deepseek-v4-pro "deep analysis of this algorithm"
|
||||
codex -m mistral/mistral-large-latest "quick review"
|
||||
```
|
||||
|
||||
**Priority:** CLI flags > profiles > config.toml
|
||||
|
||||
### 2. `/model` — interactive switch inside a session
|
||||
|
||||
During an open session, type `/model` + Enter to open the model picker.
|
||||
|
||||
### 3. `-c key=value` — inline override for any field
|
||||
|
||||
```bash
|
||||
# Change context window for one run
|
||||
codex -m ds/deepseek-v4-pro -c model_context_window=1000000 -c model_auto_compact_token_limit=900000 "task"
|
||||
codex -c model_reasoning_effort=xhigh "design the auth module"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Profiles — named usage profiles
|
||||
## Profiles — named configurations per model/workflow
|
||||
|
||||
Profiles let you have named configurations for different workflows. Each profile is a file at `~/.codex/<name>.config.toml` that layers on top of the base `config.toml`.
|
||||
Profiles let you switch model + context window with a single flag. Each profile is a flat
|
||||
`~/.codex/<name>.config.toml` that overlays on top of the base `config.toml`.
|
||||
|
||||
> **Naming rule (Codex CLI v0.137+):** the file must be named `~/.codex/<name>.config.toml` — **no `profile-` prefix**. The CLI resolves `-p chat` to `~/.codex/chat.config.toml`. If the file is not found, the default silently applies with no error.
|
||||
|
||||
### How to use
|
||||
> **Naming rule (Codex CLI v0.137+):** file must be `~/.codex/<name>.config.toml` — **no `profile-` prefix**.
|
||||
> The CLI resolves `-p kimi-k27` → `~/.codex/kimi-k27.config.toml`. If the file is not found, the default applies silently.
|
||||
|
||||
```bash
|
||||
codex --profile deepseek "analyze 10k lines of this codebase"
|
||||
codex --profile mistral "quick code review"
|
||||
codex --profile low "rename variable"
|
||||
codex -p chat "explain this function"
|
||||
codex --profile kimi-k27 "analyze 10k lines of this codebase"
|
||||
codex -p glm52 "architecture review"
|
||||
codex --profile deepseek-flash "rename variable" # fast, cheap
|
||||
```
|
||||
|
||||
### All available profiles
|
||||
### Effort profiles (same model, different effort)
|
||||
|
||||
#### `chat.config.toml` — no reasoning effort (server default = medium)
|
||||
```toml
|
||||
model = "cx/gpt-5.5"
|
||||
model_provider = "omniroute"
|
||||
# No model_reasoning_effort — uses server default (medium)
|
||||
```bash
|
||||
codex -p low # cx/gpt-5.5, effort=low
|
||||
codex -p medium # cx/gpt-5.5, effort=medium
|
||||
codex -p high # cx/gpt-5.5, effort=high
|
||||
codex -p xhigh # cx/gpt-5.5, effort=xhigh (default)
|
||||
codex -p chat # cx/gpt-5.5, no effort set (server default)
|
||||
```
|
||||
|
||||
#### `low.config.toml` / `medium.config.toml` / `high.config.toml` / `xhigh.config.toml`
|
||||
```toml
|
||||
model = "cx/gpt-5.5"
|
||||
model_reasoning_effort = "low" # or medium / high / xhigh
|
||||
model_provider = "omniroute"
|
||||
```
|
||||
Context window is inherited from `config.toml` (400k for gpt-5.5).
|
||||
### Thinking models (alto pensamento) — xhigh + detailed summary
|
||||
|
||||
#### `deepseek.config.toml` — DeepSeek V4 Pro, 1M context
|
||||
```toml
|
||||
model = "ds/deepseek-v4-pro"
|
||||
model_provider = "omniroute"
|
||||
| Profile | Model | Context | Use for |
|
||||
|---------|-------|---------|---------|
|
||||
| `kimi-k27` | `kmc/kimi-k2.7` | 128k | Best thinking quality (Kimi) |
|
||||
| `glm52` | `glm/glm-5.2` | 128k | GLM thinking |
|
||||
| `glm52max` | `glm/glm-5.2-max` | 128k | GLM thinking max |
|
||||
| `mimo-pro` | `opencode-go/mimo-v2.5-pro` | 128k | MiMo thinking |
|
||||
| `qwen37plus` | `opencode-go/qwen3.7-plus` | 32k | Qwen thinking |
|
||||
|
||||
model_context_window = 1000000
|
||||
model_auto_compact_token_limit = 900000
|
||||
model_max_output_tokens = 65536 # practical cap; model max = 384k
|
||||
tool_output_token_limit = 65536
|
||||
```
|
||||
### Good models (bons) — high effort
|
||||
|
||||
#### `mistral.config.toml` — Mistral Large Latest, 256k context
|
||||
```toml
|
||||
model = "mistral/mistral-large-latest"
|
||||
model_provider = "omniroute"
|
||||
| Profile | Model | Context | Use for |
|
||||
|---------|-------|---------|---------|
|
||||
| `kimi-k26` | `kmc/kimi-k2.6` | 128k | General purpose (Kimi) |
|
||||
| `deepseek-pro` | `ollamacloud/deepseek-v4-pro` | 128k | DeepSeek Pro via OllamaCloud |
|
||||
| `deepseek` | `ds/deepseek-v4-pro` | 1M | DeepSeek Pro direct, huge context |
|
||||
| `mimo` | `opencode-go/mimo-v2.5` | 128k | MiMo general |
|
||||
|
||||
model_context_window = 262144
|
||||
model_auto_compact_token_limit = 220000
|
||||
model_max_output_tokens = 32768 # ~32k; Mistral Large model max ~128k
|
||||
tool_output_token_limit = 16384
|
||||
```
|
||||
### Simple models (simples) — no reasoning effort
|
||||
|
||||
| Profile | Model | Context | Use for |
|
||||
|---------|-------|---------|---------|
|
||||
| `gemma4` | `ollamacloud/gemma4:31b` | 32k | Cost-effective, capable |
|
||||
| `nemotron` | `ollamacloud/nemotron-3-super` | 32k | NVIDIA Nemotron |
|
||||
| `gptoss` | `ollamacloud/gpt-oss:20b` | 32k | Open-source GPT |
|
||||
|
||||
### Fast models (rápidos) — low effort
|
||||
|
||||
| Profile | Model | Context | Use for |
|
||||
|---------|-------|---------|---------|
|
||||
| `deepseek-flash` | `ollamacloud/deepseek-v4-flash` | 64k | Quick tasks |
|
||||
| `gemini-flash` | `ollamacloud/gemini-3-flash-preview` | 1M | Very fast, huge context |
|
||||
| `glm5turbo` | `glm/glm-5-turbo` | 128k | GLM Turbo |
|
||||
| `glm47flash` | `glm/glm-4.7-flash` | 128k | GLM Flash |
|
||||
| `mistral` | `mistral/mistral-large-latest` | 256k | Mistral Large |
|
||||
|
||||
### Quick decision table
|
||||
|
||||
| Task | Profile |
|
||||
|------|---------|
|
||||
| Rename, format, boilerplate | `--profile low` |
|
||||
| Explain, light PR review | `--profile chat` |
|
||||
| Debug, moderate refactor | `--profile medium` |
|
||||
| New feature, complex tests | `--profile high` |
|
||||
| Architecture, system analysis | `--profile xhigh` (default) |
|
||||
| Long codebase analysis (needs 1M ctx) | `--profile deepseek` |
|
||||
| Quick tasks, cost-conscious | `--profile mistral` |
|
||||
| Task | Recommended profile |
|
||||
|------|---------------------|
|
||||
| Rename, format, boilerplate | `--profile deepseek-flash` or `-p low` |
|
||||
| Explain, light review | `-p chat` or `-p gemini-flash` |
|
||||
| Debug, moderate refactor | `-p medium` or `-p kimi-k26` |
|
||||
| New feature, complex tests | `-p high` or `-p mimo` |
|
||||
| Architecture, deep analysis | `-p kimi-k27` or `-p glm52` or `-p xhigh` |
|
||||
| Codebase analysis (needs 1M ctx) | `--profile deepseek` or `--profile gemini-flash` |
|
||||
| Maximum thinking quality | `-p glm52max` or `-p mimo-pro` |
|
||||
| Cost-conscious | `-p gemma4` or `-p gptoss` |
|
||||
|
||||
---
|
||||
|
||||
## Multiple models and servers
|
||||
## Generating profiles automatically with `omniroute setup-codex`
|
||||
|
||||
### Multiple models — same server
|
||||
If you run OmniRoute on a VPS, you can auto-generate profile files from the live model catalog:
|
||||
|
||||
Change only `model` and `model_provider` (and context window fields if the model differs):
|
||||
```bash
|
||||
# From a VPS (uses local OmniRoute on port 20128)
|
||||
omniroute setup-codex
|
||||
|
||||
```toml
|
||||
model = "ds/deepseek-v4-pro"
|
||||
model_provider = "omniroute"
|
||||
model_context_window = 1000000
|
||||
model_auto_compact_token_limit = 900000
|
||||
# From any machine — point at your VPS
|
||||
omniroute setup-codex --remote http://100.x.x.x:20128 --api-key sk-xxx
|
||||
|
||||
# Preview without writing files
|
||||
omniroute setup-codex --remote http://100.x.x.x:20128 --dry-run
|
||||
|
||||
# Only generate GLM and Kimi profiles
|
||||
omniroute setup-codex --only glm,kimi
|
||||
|
||||
# Write to a custom directory
|
||||
omniroute setup-codex --codex-home /path/to/.codex
|
||||
```
|
||||
|
||||
### Multiple servers
|
||||
The command fetches `/v1/models`, categorises each model (thinking / good / simple / fast) and writes `~/.codex/<name>.config.toml` for each. Idempotent — safe to re-run.
|
||||
|
||||
---
|
||||
|
||||
## Launching Codex with `omniroute launch-codex`
|
||||
|
||||
Health-checks your OmniRoute instance before launching Codex:
|
||||
|
||||
```bash
|
||||
# Launch against local OmniRoute (default port 20128)
|
||||
omniroute launch-codex
|
||||
|
||||
# Launch with a specific profile
|
||||
omniroute launch-codex --profile kimi-k27
|
||||
|
||||
# Launch against a remote VPS
|
||||
omniroute launch-codex --remote http://100.x.x.x:20128/v1 --api-key sk-xxx
|
||||
|
||||
# Pass extra args to codex
|
||||
omniroute launch-codex --profile glm52 -- --yolo "fix this bug"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## New Codex CLI features (v0.138–v0.141)
|
||||
|
||||
| Version | Feature |
|
||||
|---------|---------|
|
||||
| v0.138 | Desktop app handoff (`/app`), v2 personal access tokens, `--profile` as the exclusive profile selector (legacy in-file `[profiles]` tables crash on startup) |
|
||||
| v0.139 | `web_search = "live"` — native web search from code mode; `oneOf`/`allOf` in MCP tool schemas; `codex doctor` env diagnostics |
|
||||
| v0.140 | `/usage` token view in-session; `/import` from Claude Code sessions; `codex delete <SESSION_ID>` subcommand; Amazon Bedrock auth via `aws` object in provider config |
|
||||
| v0.141 | E2E encrypted Noise relay for remote executors; SQLite WAL fix; P-521 TLS support |
|
||||
|
||||
### New `config.toml` fields (post-v0.137)
|
||||
|
||||
```toml
|
||||
model = "cx/gpt-5.5"
|
||||
model_provider = "omniroute-main"
|
||||
# Native web search (v0.139)
|
||||
web_search = "live" # "disabled" | "cached" | "live"
|
||||
|
||||
[model_providers.omniroute-main]
|
||||
name = "OmniRoute (Main)"
|
||||
base_url = "http://192.168.0.1:20128/v1"
|
||||
env_key = "OMNIROUTE_API_KEY"
|
||||
requires_openai_auth = false
|
||||
wire_api = "responses"
|
||||
# Separate developer system prompt (v0.138)
|
||||
developer_instructions = "Always prefer functional style."
|
||||
|
||||
[model_providers.omniroute-tailscale]
|
||||
name = "OmniRoute (Tailscale)"
|
||||
# Custom compaction prompt
|
||||
compact_prompt = "Summarise the above as bullet points."
|
||||
|
||||
# Route /review to a cheaper model
|
||||
review_model = "glm/glm-5-turbo"
|
||||
|
||||
# OpenAI service tier
|
||||
service_tier = "fast" # "fast" | "flex"
|
||||
```
|
||||
|
||||
### New `[model_providers.<id>]` fields
|
||||
|
||||
```toml
|
||||
[model_providers.omniroute]
|
||||
base_url = "http://100.x.x.x:20128/v1"
|
||||
env_key = "OMNIROUTE_API_KEY"
|
||||
requires_openai_auth = false
|
||||
wire_api = "responses"
|
||||
|
||||
[model_providers.omniroute-staging]
|
||||
name = "OmniRoute (Staging)"
|
||||
base_url = "http://192.168.0.2:20128/v1"
|
||||
env_key = "OMNIROUTE_STAGING_KEY"
|
||||
requires_openai_auth = false
|
||||
wire_api = "responses"
|
||||
# Static extra headers on every request
|
||||
[model_providers.omniroute.http_headers]
|
||||
"X-Custom-Header" = "value"
|
||||
|
||||
# Headers read from env vars
|
||||
[model_providers.omniroute.env_http_headers]
|
||||
"X-Trace-Id" = "TRACE_ID"
|
||||
|
||||
# Extra URL query params (useful for Azure api-version)
|
||||
[model_providers.omniroute.query_params]
|
||||
"api-version" = "2024-12-01-preview"
|
||||
```
|
||||
|
||||
> All providers use `wire_api = "responses"` — OmniRoute handles translation for each upstream provider internally.
|
||||
### Amazon Bedrock auth (v0.140)
|
||||
|
||||
```toml
|
||||
[model_providers.bedrock]
|
||||
base_url = "https://bedrock-runtime.us-east-1.amazonaws.com"
|
||||
|
||||
[model_providers.bedrock.aws]
|
||||
profile = "default" # ~/.aws/credentials profile
|
||||
region = "us-east-1"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Multiple servers
|
||||
|
||||
```toml
|
||||
[model_providers.omniroute-main]
|
||||
base_url = "http://192.168.0.1:20128/v1"
|
||||
env_key = "OMNIROUTE_API_KEY"
|
||||
|
||||
[model_providers.omniroute-tailscale]
|
||||
base_url = "http://100.x.x.x:20128/v1"
|
||||
env_key = "OMNIROUTE_API_KEY"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Claude Code — equivalent configuration
|
||||
|
||||
Claude Code (Anthropic's CLI) uses a different mechanism for the same concept: environment variables in `~/.bashrc` / `~/.zshrc`.
|
||||
|
||||
| Codex CLI (`config.toml`) | Claude Code (env var) | Effect |
|
||||
|---------------------------|-----------------------|--------|
|
||||
| `model_max_output_tokens = 65536` | `CLAUDE_CODE_MAX_OUTPUT_TOKENS=65536` | Max tokens per response |
|
||||
| `model_context_window = 400000` | *(determined by the model — not configurable)* | Context window |
|
||||
| `tool_output_token_limit = 32768` | *(not directly exposed)* | Per-tool history cap |
|
||||
| `model_context_window = 400000` | *(determined by the model)* | Context window |
|
||||
| — | `CLAUDE_CODE_MAX_OUTPUT_TOKENS=65536` | Max tokens per response |
|
||||
|
||||
```bash
|
||||
# ~/.bashrc — Claude Code token cap (equivalent to Codex model_max_output_tokens)
|
||||
# ~/.bashrc — Claude Code token cap
|
||||
export CLAUDE_CODE_MAX_OUTPUT_TOKENS=65536
|
||||
```
|
||||
|
||||
> **Why 64k and not 128k?** The Claude 4.x family supports up to 128k output, but for interactive coding sessions 64k covers any file or diff you realistically generate. Setting 128k reserves the full slot on every request, which increases latency and cost even for short responses. Use 128k only for batch/document-generation workflows where you routinely need very long outputs.
|
||||
|
||||
---
|
||||
|
||||
## About `[notice.model_migrations]`
|
||||
|
||||
Auto-generated by the Codex CLI to record acknowledged deprecation warnings. **Not an alias system** — safe to ignore.
|
||||
|
||||
---
|
||||
|
||||
## Quick reference — CLI flags
|
||||
|
||||
| Flag | Short | Effect |
|
||||
|------|-------|--------|
|
||||
| `--model <id>` | `-m` | Overrides `model` for the current invocation |
|
||||
| `--model <id>` | `-m` | Overrides `model` for this invocation |
|
||||
| `--profile <name>` | `-p` | Loads `~/.codex/<name>.config.toml` |
|
||||
| `--config key=value` | `-c` | Overrides any config.toml field |
|
||||
| `--config key=value` | `-c` | Overrides any config.toml field (repeatable) |
|
||||
| `--enable <feature>` | — | Force-enables a feature flag |
|
||||
| `--disable <feature>` | — | Force-disables a feature flag |
|
||||
| `--search` | — | Enable live web search for this invocation |
|
||||
|
||||
New in v0.140:
|
||||
```bash
|
||||
codex delete <SESSION_ID> # delete a session
|
||||
codex delete <SESSION_ID> --force # skip confirmation
|
||||
codex debug models --bundled # list bundled model catalog as JSON
|
||||
```
|
||||
|
||||
Inside an interactive session:
|
||||
|
||||
| Command | Effect |
|
||||
|---------|--------|
|
||||
| `/model` | Opens the model picker |
|
||||
| `/usage` | Shows token usage for this session (v0.140) |
|
||||
| `/app` | Hands off to the desktop app (v0.138) |
|
||||
| `/import` | Import a Claude Code session (v0.140) |
|
||||
| `/help` | Lists all slash commands |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`Error: wire_api = "chat" is no longer supported`**
|
||||
Remove `wire_api = "chat"` from your config. Set `wire_api = "responses"` or omit the field (defaults to `"responses"` since v0.138).
|
||||
|
||||
**`Error: model not found`**
|
||||
Verify the model exists in OmniRoute with the correct prefix. Open `/dashboard/providers/<provider>` and check available models.
|
||||
Verify the model exists in OmniRoute with the correct prefix. Use `omniroute models list` or open `/dashboard/providers/<provider>`.
|
||||
|
||||
**`Authentication error`**
|
||||
Confirm `OMNIROUTE_API_KEY` is exported: `echo $OMNIROUTE_API_KEY`.
|
||||
|
|
@ -360,10 +401,10 @@ Confirm `OMNIROUTE_API_KEY` is exported: `echo $OMNIROUTE_API_KEY`.
|
|||
Verify OmniRoute is running and the `base_url` host/port is correct for your network (local vs Tailscale vs VPS).
|
||||
|
||||
**Session crashes near context limit**
|
||||
Set `model_context_window` and `model_auto_compact_token_limit` explicitly for the model you are using. See the context window table above.
|
||||
Set `model_context_window` and `model_auto_compact_token_limit` explicitly. See the context window table above.
|
||||
|
||||
**Compaction fires too late / history is cut**
|
||||
Lower `model_auto_compact_token_limit` to trigger compaction earlier (e.g. 75% of the window). Never set it above 90% — silently ignored.
|
||||
**Compaction fires too late**
|
||||
Lower `model_auto_compact_token_limit` to 80–85% of the window. Never set above 90%.
|
||||
|
||||
**DeepSeek / Mistral returns 404**
|
||||
You are likely pointing Codex directly at the provider API. Route through OmniRoute — it translates Responses API → Chat Completions automatically. Confirm `base_url` points to your OmniRoute instance, not directly to `api.deepseek.com` or `api.mistral.ai`.
|
||||
**Profile not loading (`-p <name>` silently ignored)**
|
||||
Confirm the file exists at `~/.codex/<name>.config.toml` (no `profile-` prefix). Run `ls ~/.codex/*.config.toml`.
|
||||
|
|
|
|||
|
|
@ -137,6 +137,39 @@ The written profile references the inference key by env var
|
|||
base Codex setup (the `[model_providers.omniroute]` block), see
|
||||
[CODEX-CLI-CONFIGURATION.md](./CODEX-CLI-CONFIGURATION.md).
|
||||
|
||||
### Per-CLI setup commands
|
||||
|
||||
Each supported CLI has a remote-aware setup command (all honour the active
|
||||
context, or `--remote <url> --api-key <key>`):
|
||||
|
||||
| CLI | Command | What it writes |
|
||||
|-----|---------|----------------|
|
||||
| Codex | `omniroute setup-codex` | `~/.codex/<name>.config.toml` profiles (per model) |
|
||||
| Claude Code | `omniroute setup-claude` | `~/.claude/profiles/<name>/settings.json` (per model) |
|
||||
| OpenCode | `omniroute setup-opencode` | `~/.config/opencode/opencode.json` — the `omniroute` openai-compatible provider with every catalog model (run `opencode -m omniroute/<model>`) |
|
||||
| Cline | `omniroute setup-cline` | `~/.cline/data/{globalState,secrets}.json` (CLI mode) + prints the VS Code extension settings to paste (OpenAI-compatible, Base URL **without** `/v1`) |
|
||||
| Kilo Code | `omniroute setup-kilo` | `~/.local/share/kilo/auth.json` (CLI) + VS Code `kilocode.*` settings — OpenAI-compatible, Base URL **with** `/v1` |
|
||||
| Continue | `omniroute setup-continue` | `~/.continue/config.yaml` (VS Code/JetBrains + `cn` CLI) — `provider: openai`, `apiBase` **with** `/v1`, key via `${{ secrets.OMNIROUTE_API_KEY }}` |
|
||||
| Cursor | `omniroute setup-cursor` | prints the in-app steps (Settings → Models → Override OpenAI Base URL **with** `/v1` + key + model). Cursor config is opaque SQLite — chat panel only |
|
||||
| Roo Code | `omniroute setup-roo` | writes a Roo import JSON (`~/.omniroute/roo-settings.json`) + sets `roo-cline.autoImportSettingsPath` + prints UI steps (OpenAI-compatible, Base URL **with** `/v1`) |
|
||||
| Crush | `omniroute setup-crush` | `~/.config/crush/crush.json` — `openai-compat` provider, `base_url` **with** `/v1`, key via `$OMNIROUTE_API_KEY` |
|
||||
| Goose | `omniroute setup-goose` | `~/.config/goose/config.yaml` (`GOOSE_PROVIDER=openai` + `OPENAI_HOST` **without** `/v1` + `GOOSE_MODEL`) + env recipe |
|
||||
| Qwen Code | `omniroute setup-qwen` | `~/.qwen/settings.json` — openai `modelProvider`, `baseUrl` **with** `/v1`, key via `envKey` (OMNIROUTE_API_KEY) |
|
||||
| Aider | `omniroute setup-aider` | `~/.aider.conf.yml` (`openai-api-base` **without** `/v1` + `model: openai/<id>`) + env recipe (`aider --message --yes`) |
|
||||
| Gemini CLI | `omniroute setup-gemini` | **native** Gemini API (not OpenAI-compatible) → `GOOGLE_GEMINI_BASE_URL` (root, SDK appends `/v1beta`) + `GEMINI_API_KEY` + `~/.gemini/settings.json` (`model`). ⚠ a cached Google login can override the base URL — run API-key-only |
|
||||
|
||||
```bash
|
||||
# OpenCode (openai-compatible provider, all catalog models, remote VPS)
|
||||
omniroute setup-opencode --remote http://192.168.0.15:20128 --api-key oma_live_xxx
|
||||
omniroute setup-opencode --only glm,kimi # keep only matching models
|
||||
opencode -m omniroute/glm/glm-5.2 "..." # export OMNIROUTE_API_KEY first
|
||||
```
|
||||
|
||||
> OpenCode also has a richer **plugin** integration: `omniroute setup opencode`
|
||||
> (now remote-aware via `--remote`) installs `@omniroute/opencode-plugin`.
|
||||
> `setup-opencode` is the lightweight openai-compatible alternative. The API key
|
||||
> is referenced via `{env:OMNIROUTE_API_KEY}` — never written to disk.
|
||||
|
||||
---
|
||||
|
||||
## Switching back to local
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@
|
|||
|
||||
---
|
||||
|
||||
## [3.8.30] — TBD
|
||||
|
||||
_See English CHANGELOG for v3.8.30 details._
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
|
|
|||
|
|
@ -272,7 +272,11 @@ Before shipping any v3.8.x release, verify these additional items:
|
|||
`@tensorflow/tfjs`, `js-tiktoken`) survive an update. The ultra `modelPath` SLM tier
|
||||
additionally needs `@huggingface/transformers@3.5.2` (pinned — llmlingua-2 uses the 3.x
|
||||
tokenizer API) and the tinybert model, auto-downloaded to `${DATA_DIR}/models/llmlingua`
|
||||
on first use.
|
||||
on first use. Postinstall (`scripts/build/colocateOptionals.mjs`) then co-locates the SLM
|
||||
optional closure into `dist/node_modules` so the worker resolves a SINGLE
|
||||
`@huggingface/transformers` 3.5.2 instance — the standalone trace bundles only transformers,
|
||||
not the dynamically-imported optionals, so without this the worker would load llmlingua-2
|
||||
against the root's transformers and the SLM tier would silently fail-open.
|
||||
- [ ] `omniroute status` works with no `.env` (CLI token path, loopback only)
|
||||
- [ ] `curl http://localhost:20128/api/shutdown` returns 401 (always-protected route)
|
||||
- [ ] `curl -H "host: evil.com" http://localhost:20128/api/mcp/sse` returns 401 (loopback guard)
|
||||
|
|
|
|||
|
|
@ -74,11 +74,14 @@ Content-Type: application/json
|
|||
| `X-OmniRoute-Session-Id` | Response | Effective session ID used by OmniRoute |
|
||||
| `X-OmniRoute-Request-Id` | Response | Request correlation id (when known) |
|
||||
| `X-OmniRoute-Version` | Response | OmniRoute build version (always present) |
|
||||
| `X-OmniRoute-Cost-Saved` | Response | USD the cache avoided on a HIT (cache hits only) |
|
||||
|
||||
> Nginx note: if you rely on underscore headers (for example `x_session_id`), enable `underscores_in_headers on;`.
|
||||
|
||||
> **Cost telemetry headers:** non-streaming success responses also carry the `X-OmniRoute-*` cost-telemetry set — `X-OmniRoute-Response-Cost` (USD, fixed 10 decimals; `0.0000000000` for free/unpriced), `X-OmniRoute-Tokens-In` / `X-OmniRoute-Tokens-Out`, `X-OmniRoute-Model`, `X-OmniRoute-Provider`, `X-OmniRoute-Latency-Ms`, `X-OmniRoute-Cache-Hit`, and `X-OmniRoute-Fallback-Attempts` (only when > 0), plus `X-OmniRoute-Request-Id` and `X-OmniRoute-Version`. These are emitted by chat completions, `/v1/responses`, `/v1/messages`, **and the media endpoints** — `/v1/embeddings`, `/v1/images/generations`, `/v1/audio/speech`, `/v1/audio/transcriptions`, `/v1/rerank`, `/v1/videos/generations`, `/v1/music/generations`, and `/v1/moderations` (always cost `0`). Media cost is computed per modality (per-image, per-second, per-character, per search-unit) when pricing is available, otherwise `0` (fail-open).
|
||||
|
||||
> **Cache-hit cost semantics:** on a semantic-cache HIT (`X-OmniRoute-Cache-Hit: true`) no upstream call is made, so `X-OmniRoute-Response-Cost` is `0.0000000000` (the **incremental** cost of serving the hit). The original/would-have-been cost is reported separately in `X-OmniRoute-Cost-Saved`. Billing consumers should sum `X-OmniRoute-Response-Cost` (hits cost nothing); cache analytics can aggregate `X-OmniRoute-Cost-Saved`.
|
||||
|
||||
---
|
||||
|
||||
## Embeddings
|
||||
|
|
|
|||
|
|
@ -284,6 +284,7 @@ Route upstream LLM provider calls through an HTTP or SOCKS5 proxy for egress con
|
|||
| `HTTPS_PROXY` | _(unset)_ | Node.js standard | HTTPS proxy for upstream calls. |
|
||||
| `ALL_PROXY` | _(unset)_ | Node.js standard | Universal proxy (supports `socks5://`). |
|
||||
| `NO_PROXY` | _(unset)_ | Node.js standard | Comma-separated hostnames/IPs to bypass the proxy. |
|
||||
| `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS` | `32` | `open-sse/utils/proxyDispatcher.ts` | Max concurrent sockets per cached HTTP/SOCKS proxy dispatcher. Long-lived SSE streams such as Codex `/v1/responses` need more than one connection when several requests share the same account-level proxy. Values above `256` are capped. |
|
||||
| `PROXY_FAIL_OPEN` | `false` | `src/sse/handlers/chatHelpers.ts` | When `false` (default), a request whose assigned proxy fails to resolve is **refused (fail-closed)** rather than falling back to a direct connection — prevents real-IP leaks. Set `true` to restore the legacy DIRECT fallback. |
|
||||
| `ENABLE_TLS_FINGERPRINT` | `false` | `open-sse/executors` | Spoof TLS fingerprint using wreq-js (mimics Chrome 124). Counters JA3/JA4 blocking. |
|
||||
| `OMNIROUTE_TURNSTILE_IGNORE_TLS_ERRORS` | `false` | `open-sse/services/claudeTurnstileSolver.ts` | Allow the Claude Turnstile Playwright browser context to ignore HTTPS certificate errors. |
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 3.8.29
|
||||
version: 3.8.30
|
||||
description: |
|
||||
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
|
||||
endpoint that routes requests to multiple AI providers with load balancing,
|
||||
|
|
@ -1134,6 +1134,13 @@ paths:
|
|||
schema:
|
||||
type: string
|
||||
description: OmniRoute build version (always present).
|
||||
X-OmniRoute-Cost-Saved:
|
||||
schema:
|
||||
type: string
|
||||
description: >-
|
||||
On a semantic-cache HIT, the original (would-have-been) cost in USD that
|
||||
the cache avoided (fixed 10 decimals). Present only on cache hits;
|
||||
X-OmniRoute-Response-Cost is 0 for the same response (incremental cost).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue