mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-30 11:02:15 +03:00
Compare commits
30 Commits
dependabot
...
fix/radar-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c37f4f4f65 | ||
|
|
9f4c385084 | ||
|
|
1b1c466c69 | ||
|
|
537d19bb72 | ||
|
|
d2cda9651d | ||
|
|
1170b1783f | ||
|
|
83b61449a4 | ||
|
|
6ca6cd8508 | ||
|
|
02652a653b | ||
|
|
0c17c7219b | ||
|
|
0f1a72afa7 | ||
|
|
5cefe2b795 | ||
|
|
34115cbf33 | ||
|
|
d468ff4153 | ||
|
|
12051f7edd | ||
|
|
678ab01148 | ||
|
|
45e6a89a92 | ||
|
|
c8b410c5f0 | ||
|
|
ad1a8460c9 | ||
|
|
2ffee220bf | ||
|
|
648e81416b | ||
|
|
eb9f1cf8e5 | ||
|
|
f5de0d8cad | ||
|
|
5b79c5b171 | ||
|
|
c53f6015e4 | ||
|
|
c0bfdba52c | ||
|
|
59a583ddf6 | ||
|
|
f379e9215a | ||
|
|
4f21e663f0 | ||
|
|
f6708c78fb |
@@ -0,0 +1 @@
|
||||
- **docs:** add management authentication terminology guide ([#7786](https://github.com/diegosouzapw/OmniRoute/issues/7786))
|
||||
41
.claude/worktrees/feat-7786/docs/guides/MANAGEMENT-AUTH.md
Normal file
41
.claude/worktrees/feat-7786/docs/guides/MANAGEMENT-AUTH.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Management Authentication
|
||||
|
||||
OmniRoute uses four distinct credential families for management access. This guide
|
||||
distinguishes them by purpose, scope, and locality.
|
||||
|
||||
| Credential | Scope | Locality | Use Case |
|
||||
|-------------------------|--------------------|---------------|-----------------------------------|
|
||||
| Dashboard JWT session | Full management | Localhost | Web dashboard login |
|
||||
| CLI machine-id token | Full management | Per-machine | `omniroute` CLI commands |
|
||||
| Scoped `oma_` token | Configurable scope | External | Automation / CI / API access |
|
||||
| Manage-scope API key | `manage` scope | External | Management API calls |
|
||||
|
||||
## Dashboard JWT Session
|
||||
|
||||
Generated on dashboard login (`/api/auth/login`). Stored in HTTP-only cookie.
|
||||
Valid for the session duration. Cannot be used from external hosts.
|
||||
|
||||
## CLI Machine-ID Token
|
||||
|
||||
Created by `omniroute auth login` on first use. Stored in `~/.omniroute/auth.json`.
|
||||
Used by the CLI for all management operations. Tied to the machine identity.
|
||||
|
||||
## Scoped `oma_` Access Token
|
||||
|
||||
Created via dashboard or CLI with configurable scopes (e.g., `manage`, `read`).
|
||||
Format: `oma_<random-hex>`. Used for programmatic access from external systems.
|
||||
|
||||
## Manage-Scope API Key
|
||||
|
||||
Standard API key with the `manage` scope enabled. Created in dashboard API Keys page.
|
||||
Used for management API calls from external hosts.
|
||||
|
||||
## Header Examples
|
||||
|
||||
```
|
||||
Authorization: Bearer oma_abc123def456
|
||||
Authorization: Bearer <standard-api-key-with-manage-scope>
|
||||
Cookie: omniroute_session=<jwt-token>
|
||||
```
|
||||
|
||||
See `docs/reference/API_REFERENCE.md` for endpoint-specific auth requirements.
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it } from "node:test";
|
||||
import { ok } from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
describe("Management auth documentation (#7786)", () => {
|
||||
const docPath = "docs/guides/MANAGEMENT-AUTH.md";
|
||||
const content = readFileSync(docPath, "utf-8");
|
||||
|
||||
it("exists and has content", () => {
|
||||
ok(content.length > 500, "should have substantial content");
|
||||
ok(content.includes("Dashboard JWT session"));
|
||||
ok(content.includes("CLI machine-id token"));
|
||||
ok(content.includes("oma_"));
|
||||
});
|
||||
|
||||
it("documents all four credential families", () => {
|
||||
const families = ["Dashboard JWT", "CLI machine-id", "oma_", "Manage-scope"];
|
||||
for (const f of families) {
|
||||
ok(content.includes(f), `should document ${f}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("mentions relevant auth header examples", () => {
|
||||
ok(content.includes("Authorization"));
|
||||
ok(content.includes("Bearer"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
- **feat(infra):** add systemd autostart unit for Linux ([#8635](https://github.com/diegosouzapw/OmniRoute/issues/8635))
|
||||
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=OmniRoute AI Proxy
|
||||
After=network.target network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=$(which omniroute) start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
Environment=NODE_ENV=production
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=full
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it } from "node:test";
|
||||
import { ok } from "node:assert/strict";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
|
||||
describe("Systemd autostart (#8635)", () => {
|
||||
const svcPath = "contrib/systemd/omniroute.service";
|
||||
const content = readFileSync(svcPath, "utf-8");
|
||||
|
||||
it("service file exists", () => {
|
||||
ok(existsSync(svcPath));
|
||||
ok(content.length > 200);
|
||||
});
|
||||
|
||||
it("defines required systemd sections", () => {
|
||||
ok(content.includes("[Unit]"));
|
||||
ok(content.includes("[Service]"));
|
||||
ok(content.includes("[Install]"));
|
||||
});
|
||||
|
||||
it("specifies WantedBy=default.target", () => {
|
||||
ok(content.includes("WantedBy=default.target"));
|
||||
});
|
||||
});
|
||||
@@ -53,10 +53,8 @@ reports/mutation
|
||||
# Local caches and quality-gate artifacts (all gitignored). `_*` does not match
|
||||
# dot-prefixed names, so these need explicit entries.
|
||||
.artifacts
|
||||
.eslintcache*
|
||||
.fakebin-*
|
||||
MAX
|
||||
quality-ratchet/
|
||||
.eslintcache
|
||||
.eslintcache-complexity
|
||||
|
||||
# Documentation
|
||||
# Issue #2348: The Dashboard Docs viewer reads markdown from `/app/docs` at
|
||||
|
||||
678
.env.example
678
.env.example
File diff suppressed because it is too large
Load Diff
1
.eslintcache-probe
Normal file
1
.eslintcache-probe
Normal file
File diff suppressed because one or more lines are too long
4
.fakebin-9475/npm
Executable file
4
.fakebin-9475/npm
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
if [ "$1" = "view" ]; then echo "3.8.99"; exit 0; fi
|
||||
if [ "$1" = "install" ]; then echo "added 1 package"; exit 0; fi
|
||||
exit 0
|
||||
11
.gitattributes
vendored
11
.gitattributes
vendored
@@ -1,11 +0,0 @@
|
||||
# Shell scripts must always be checked out with LF line endings.
|
||||
#
|
||||
# On Windows, core.autocrlf=true converts text files to CRLF in the working
|
||||
# tree. Scripts that are kernel-exec'd (Docker ENTRYPOINT, bin/*.sh on Linux
|
||||
# hosts) then fail with `exec ...: no such file or directory` because the
|
||||
# shebang becomes "#!/bin/sh\r". eol=lf overrides autocrlf for these files.
|
||||
*.sh text eol=lf
|
||||
|
||||
# This file must stay LF too: git parses it as-is, and a trailing CR would
|
||||
# corrupt every pattern (e.g. "*.sh\r" matches nothing).
|
||||
.gitattributes text eol=lf
|
||||
14
.github/dependabot.yml
vendored
14
.github/dependabot.yml
vendored
@@ -50,13 +50,13 @@ updates:
|
||||
# bumps; majors here need their own PR and a deliberate migration review.
|
||||
- dependency-name: "ioredis"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# @huggingface/transformers is VPS-validated at ^4.2.0 (migrated intentionally in
|
||||
# #9962). It is load-bearing for the LLMLingua ONNX compression engine (open-sse/
|
||||
# services/compression/engines/llmlingua/ — @atjsh/llmlingua-2@2.0.5 peers on
|
||||
# "@huggingface/transformers": "^3.5.2 || ^4.0.0") and for local memory embeddings
|
||||
# (src/lib/memory/embedding/transformersLocal.ts). Further majors must be re-validated
|
||||
# on the VPS — so keep auto-bumps frozen (no update-types = ignore every version).
|
||||
# Migrate it intentionally, not via dependabot (#4050).
|
||||
# @huggingface/transformers is HARD-PINNED at 3.5.2 (exact, no caret) — FROZEN.
|
||||
# It is load-bearing for the LLMLingua ONNX compression engine (open-sse/services/
|
||||
# compression/engines/llmlingua/ — worker.ts pins @huggingface/transformers@3.5.2)
|
||||
# and for local memory embeddings (src/lib/memory/embedding/transformersLocal.ts),
|
||||
# and was VPS-validated at 3.5.2 (#4014). 4.x breaks both, and even 3.x minors must
|
||||
# be re-validated on the VPS — so freeze ALL auto-bumps (no update-types = ignore
|
||||
# every version). Migrate it intentionally, not via dependabot (#4050).
|
||||
- dependency-name: "@huggingface/transformers"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
|
||||
4
.github/pull_request_template.md
vendored
4
.github/pull_request_template.md
vendored
@@ -10,7 +10,7 @@
|
||||
## Validation
|
||||
|
||||
Choose the change type and focused loop from the
|
||||
[Contribution Golden Path](../docs/ops/CONTRIBUTION_GOLDEN_PATH.md). The full unit suite,
|
||||
[Contribution Golden Path](../docs/dev/CONTRIBUTION_GOLDEN_PATH.md). The full unit suite,
|
||||
Vitest, the 60% coverage gate, and the production build all run in CI on this PR (#8329):
|
||||
|
||||
- [ ] Change type: provider / routing / UI / i18n / CLI / DB / build-deploy / other
|
||||
@@ -18,7 +18,7 @@ Vitest, the 60% coverage gate, and the production build all run in CI on this PR
|
||||
- [ ] `npm run lint`
|
||||
- [ ] Reconciled with the current active release base; focused checks rerun afterward
|
||||
- [ ] Production-code changes include a new or updated automated test in this PR
|
||||
- SonarQube is temporarily opt-in while the private project has no quota; it is not a PR gate.
|
||||
- [ ] SonarQube PR analysis is green or any remaining issues are explicitly documented below
|
||||
|
||||
## Tests Added Or Updated
|
||||
|
||||
|
||||
11
.github/workflows/build.yml
vendored
11
.github/workflows/build.yml
vendored
@@ -1,16 +1,9 @@
|
||||
name: Build App
|
||||
|
||||
# Manual-only since #11946. The hosted 7 GB runner can no longer build this tree — 19 of
|
||||
# the last 30 runs died with "The runner has received a shutdown signal" (VM out of
|
||||
# memory) ~8 min into `next build`, release/v3.8.51 itself included, even with the 10 GB
|
||||
# swapfile below. Triggered on `push: branches: ["**"]` it painted every branch and every
|
||||
# PR red while producing an artefact nothing downloads. The bundle is validated where a
|
||||
# build actually fits:
|
||||
# - main: ci.yml `Build` (self-hosted omni-build pool) on every merge
|
||||
# - release/**: nightly-release-green.yml (same pool, continuous)
|
||||
# Dispatch this workflow by hand when a hosted build artefact is genuinely needed.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: ["**"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
214
.github/workflows/ci.yml
vendored
214
.github/workflows/ci.yml
vendored
@@ -93,7 +93,6 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
@@ -127,8 +126,6 @@ jobs:
|
||||
- run: npm run check:route-validation:t06
|
||||
- run: npm run check:any-budget:t11
|
||||
- run: npm run check:provider-consistency
|
||||
- run: npm run check:model-lifecycle
|
||||
- run: npm run check:provider-asset-provenance
|
||||
- run: npm run check:fetch-targets
|
||||
- run: npm run check:deps
|
||||
- run: npm run check:file-size
|
||||
@@ -504,13 +501,11 @@ jobs:
|
||||
BASE_REF: ${{ github.base_ref && format('origin/{0}', github.base_ref) || '' }}
|
||||
run: node scripts/i18n/check-ui-value-drift.mjs
|
||||
|
||||
# #8038: cheap glossary/protected-terms consistency gate —
|
||||
# #8038: cheap single-locale glossary/protected-terms consistency gate —
|
||||
# complements i18n-ui-coverage (key parity) and the ICU `i18n` job below
|
||||
# without needing app-boot/Playwright infra. Same gating as i18n-ui-coverage.
|
||||
# ko added after the #8224 ko.json mistranslation cleanup so the fixed
|
||||
# terminology cannot silently regress on the next machine-translation run.
|
||||
i18n-glossary-zhcn:
|
||||
name: i18n Glossary (zh-CN, ko)
|
||||
name: i18n Glossary (zh-CN)
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (needs.changes.outputs.i18n == 'true' || needs.changes.outputs.code == 'true')) }}
|
||||
@@ -609,24 +604,13 @@ jobs:
|
||||
# Dynamic runner: when the release captain flips the USE_VPS_RUNNER repo var to
|
||||
# 'true' (scripts/vps/release-runner-up.sh does it after the self-hosted VM is
|
||||
# online), the heavy jobs run on the dedicated 32-core VPS runners (label
|
||||
# omni-build) instead of queueing on the 20-concurrent-job hosted pool.
|
||||
# omni-release) instead of queueing on the 20-concurrent-job hosted pool.
|
||||
# Safety: fork PRs NEVER reach the self-hosted runner — the expression falls
|
||||
# back to ubuntu-latest unless the PR head repo is this repository (push /
|
||||
# dispatch events are own-origin by definition). Any failure path (VM down,
|
||||
# var unset/false) also falls back to ubuntu-latest.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-build"]') || 'ubuntu-latest' }}
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
|
||||
needs: changes
|
||||
# The .113 pool runs ONE next-build with room to spare and two at the edge: the
|
||||
# box has 31 GB and a single next-build peaks at 14–16 GB RSS. On 2026-08-28
|
||||
# 13:50Z the kernel OOM-killed main's build while a PR build ran beside it
|
||||
# (five Build jobs had been queued by a burst of PRs). Two lanes: main keeps
|
||||
# its own so a release is never queued behind PR traffic; PR builds serialize
|
||||
# among themselves. GitHub keeps one running + one pending per group and
|
||||
# CANCELS older pendings — a cancelled PR build is re-runnable; a dead main
|
||||
# build costs the publish its artefact and a 40-minute rebuild that OOMs.
|
||||
concurrency:
|
||||
group: heavy-build-${{ github.ref == 'refs/heads/main' && 'main' || 'pr' }}
|
||||
cancel-in-progress: false
|
||||
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -660,14 +644,14 @@ jobs:
|
||||
# Keep standalone/node_modules intact: package/electron jobs consume the
|
||||
# Next-traced standalone tree and must not replace it with root node_modules.
|
||||
run: |
|
||||
tar -czf "$RUNNER_TEMP/e2e-build.tar.gz" \
|
||||
tar -czf /tmp/e2e-build.tar.gz \
|
||||
--exclude='.build/next/cache' \
|
||||
.build/next
|
||||
- name: Upload Next.js build for downstream jobs
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: next-build
|
||||
path: ${{ runner.temp }}/e2e-build.tar.gz
|
||||
path: /tmp/e2e-build.tar.gz
|
||||
retention-days: 1
|
||||
|
||||
package-artifact:
|
||||
@@ -690,63 +674,16 @@ jobs:
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: next-build
|
||||
# Workspace-relative on purpose: the matrix below includes windows-latest, whose
|
||||
# default shell is pwsh, where $RUNNER_TEMP is empty (it is $env:RUNNER_TEMP) —
|
||||
# #11896's first cut broke the Electron smoke on exactly that. A relative path
|
||||
# works in bash and pwsh alike; hosted workspaces are ephemeral.
|
||||
path: next-build-artifact
|
||||
path: /tmp/
|
||||
- name: Extract Next.js build artifact
|
||||
run: |
|
||||
tar -xzf next-build-artifact/e2e-build.tar.gz
|
||||
tar -xzf /tmp/e2e-build.tar.gz
|
||||
# build:cli consumes the downloaded .build/next standalone artifact and assembles dist/;
|
||||
# it only rebuilds if the downloaded standalone artifact is missing.
|
||||
- run: npm run build:cli
|
||||
- name: Assert dist/server.js exists
|
||||
run: test -f dist/server.js || (echo "dist/server.js missing — build:cli did not assemble correctly" && exit 1)
|
||||
# `build:cli` monta dist/ mas NAO grava dist/BUILD_SHA — so `build:release` faz
|
||||
# isso, chamando write-build-sha.mjs. O guard de proveniencia do #10427, dentro
|
||||
# de check:pack-artifact, rejeita um artefato sem SHA (e rejeita mesmo com
|
||||
# OMNIROUTE_ALLOW_CANARY_BUILD=1: o que nao da para identificar nao da para
|
||||
# vouchear). Sem este passo o par build+validate deste job e estruturalmente
|
||||
# incompativel e falha 100% das vezes.
|
||||
- name: Stamp dist/BUILD_SHA for the provenance guard (#10427)
|
||||
# O SHA TEM de vir do head da PR, nao de `git rev-parse HEAD`. Este workflow
|
||||
# roda em `pull_request`, entao o checkout e o MERGE COMMIT efemero que o
|
||||
# GitHub cria — um commit que nao existe em branch nenhuma e portanto nunca e
|
||||
# ancestral da release. O guard de proveniencia (#10427) rejeita exatamente
|
||||
# isso, e com razao: um artefato carimbado com o merge commit nao pode ser
|
||||
# rastreado ate codigo que passou pelos gates.
|
||||
env:
|
||||
OMNIROUTE_BUILD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
run: |
|
||||
export OMNIROUTE_BUILD_SHA="${OMNIROUTE_BUILD_SHA:0:7}"
|
||||
node scripts/build/write-build-sha.mjs
|
||||
# O guard de proveniencia checa ancestralidade contra `origin/main` por padrao.
|
||||
# Esse e o ref certo na PUBLICACAO (npm-publish.yml roda em main), mas em
|
||||
# `pull_request` e estruturalmente impossivel: enquanto a PR esta aberta o head
|
||||
# dela NUNCA e ancestral de main — e o checkout raso nem traz `origin/main` para
|
||||
# o grafo local, entao a sonda responde `false` de qualquer jeito. Resultado: o
|
||||
# gate falhava 100% das vezes em PR. Pre-merge o unico invariante checavel e "o
|
||||
# stamp corresponde a branch sob teste", entao apontamos o ref para o head da PR.
|
||||
# Usamos `refs/pull/<N>/head` e nao `head.ref` porque aquele existe no PROPRIO
|
||||
# origin mesmo quando a PR vem de um fork; `head.ref` so existe no repo do autor.
|
||||
- name: Resolve the provenance ref for the pack gate (#10427)
|
||||
id: provenance-ref
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
git fetch --no-tags --depth=50 origin \
|
||||
"+refs/pull/$PR_NUMBER/head:refs/remotes/origin/pr-head"
|
||||
echo "ref=origin/pr-head" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
git fetch --no-tags --depth=50 origin \
|
||||
"+refs/heads/$GITHUB_REF_NAME:refs/remotes/origin/$GITHUB_REF_NAME"
|
||||
echo "ref=origin/$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- run: npm run check:pack-artifact
|
||||
env:
|
||||
OMNIROUTE_RELEASE_REF: ${{ steps.provenance-ref.outputs.ref }}
|
||||
# WS1.2 (#7065 class): pack the real tarball, install it into a clean prefix and
|
||||
# BOOT it to a healthy /api/monitoring/health — the gate that structure checks
|
||||
# cannot provide (3 releases shipped boot-crashing tarballs with green lists).
|
||||
@@ -758,12 +695,11 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
needs: build
|
||||
# WS1.5 (v3.8.49 plan): the Electron native-module path previously executed for
|
||||
# WS1.5 (v3.8.49 plan): the Electron rebuild/spawn path previously executed for
|
||||
# the FIRST time on the release tag — the v3.8.48 Windows bug (npx.cmd spawned
|
||||
# without shell, CVE-2024-27980 behavior change) could only surface at release.
|
||||
# windows-latest runs prepare:bundle (better-sqlite3 prebuild verification since
|
||||
# v13 — the node-gyp rebuild is gone) per release PR; ubuntu keeps the full
|
||||
# pack + headless smoke.
|
||||
# windows-latest runs prepare:bundle (the ABI rebuild + spawn plan) per release
|
||||
# PR; ubuntu keeps the full pack + headless smoke.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -785,14 +721,10 @@ jobs:
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: next-build
|
||||
# Workspace-relative on purpose: the matrix below includes windows-latest, whose
|
||||
# default shell is pwsh, where $RUNNER_TEMP is empty (it is $env:RUNNER_TEMP) —
|
||||
# #11896's first cut broke the Electron smoke on exactly that. A relative path
|
||||
# works in bash and pwsh alike; hosted workspaces are ephemeral.
|
||||
path: next-build-artifact
|
||||
path: /tmp/
|
||||
- name: Extract Next.js build artifact
|
||||
run: |
|
||||
tar -xzf next-build-artifact/e2e-build.tar.gz
|
||||
tar -xzf /tmp/e2e-build.tar.gz
|
||||
- name: Install Electron dependencies
|
||||
working-directory: electron
|
||||
run: npm install --no-audit --no-fund
|
||||
@@ -804,7 +736,7 @@ jobs:
|
||||
# precedent): its first-ever real run (2026-07-15, run 29457533565) died in
|
||||
# 0.7s with the error swallowed by pwsh — bash shell captures stderr and
|
||||
# continue-on-error keeps the heavy gate green while we harden it (#7336).
|
||||
- name: Prepare Electron standalone (Windows prebuild verification)
|
||||
- name: Prepare Electron standalone (Windows ABI rebuild + spawn path)
|
||||
if: runner.os == 'Windows'
|
||||
working-directory: electron
|
||||
continue-on-error: true
|
||||
@@ -857,23 +789,9 @@ jobs:
|
||||
# D3 (plano mestre): a coverage é coletada NESTE mesmo run (c8/NODE_V8_COVERAGE propaga
|
||||
# aos filhos através do npm) — elimina a matrix Coverage Shard ×8, que re-executava a
|
||||
# suíte inteira só para medir o gate. Padrão usado pelo CI do próprio nodejs/node.
|
||||
# Heap: os shards rodam sob instrumentacao de cobertura do V8, que retem muito
|
||||
# mais memoria que a suite crua. Com o teto antigo de 4096 MB os shards passaram
|
||||
# a abortar com SIGABRT (exit 134, "Ineffective mark-compacts near heap limit")
|
||||
# ao redor de 4086 MB conforme o catalogo de providers cresceu no ciclo v3.8.50 —
|
||||
# todos os testes passavam e o processo morria no fim, o que le como falha de
|
||||
# teste sem ser. O teto vive em `test:unit:ci:shard` (package.json) e agora
|
||||
# acompanha os 8192 MB ja usados pelas variantes nao-shardadas; os runners
|
||||
# GitHub-hosted tem 16 GB.
|
||||
- name: Unit tests (shard ${{ matrix.shard }}/8) with V8 coverage
|
||||
env:
|
||||
TEST_SHARD: ${{ matrix.shard }}/8
|
||||
# NODE_OPTIONS (nao so o flag em test:unit:ci:shard) porque quem estoura o
|
||||
# heap e o processo `c8` que embrulha a suite — ele agrega ~577 MB de JSON
|
||||
# de cobertura bruta. Subir o teto so no node filho deixa o pai no default
|
||||
# do V8 (~4 GB) e o OOM continua igual, em ~4083 MB. Mesmo padrao ja usado
|
||||
# pelo job de merge de cobertura mais abaixo.
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: |
|
||||
rm -rf coverage-shard coverage-shard-report
|
||||
npx c8 \
|
||||
@@ -893,12 +811,7 @@ jobs:
|
||||
|
||||
test-bun-sqlite:
|
||||
name: Bun SQLite Compatibility
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
fail-fast: false
|
||||
runs-on: ${{ matrix.os }}
|
||||
continue-on-error: ${{ matrix.os == 'windows-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
|
||||
@@ -911,15 +824,6 @@ jobs:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- name: Install Bun (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
powershell -c "iwr bun.sh/install.ps1 -useb | iex"
|
||||
echo "$env:USERPROFILE\.bun\bin" | Out-File -FilePath $env:GITHUB_PATH -Append
|
||||
- name: Install Bun (non-Windows)
|
||||
if: runner.os != 'Windows'
|
||||
run: npm install -g bun
|
||||
- run: npm run test:bun:db
|
||||
|
||||
test-vitest:
|
||||
@@ -979,11 +883,7 @@ jobs:
|
||||
# 10min was sized before #7114 added the lcov reporter (Codecov/Sonar need it);
|
||||
# merging 8 shard JSONs + text+json+lcov now takes ~10-12min — three consecutive
|
||||
# release-tip runs died at exactly 10m as job-timeout "cancelled" (2026-07-15/16).
|
||||
# 30, not 20 (2026-08-29): the informational Codecov upload below hung for the rest of
|
||||
# the budget on two consecutive main runs (33207760653, 33215115341); the job ended
|
||||
# `cancelled` and dragged the whole run's conclusion to `cancelled` although every
|
||||
# blocking job was green. The upload step now has its own ceiling; this is headroom.
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 20
|
||||
needs: test-unit
|
||||
if: ${{ !cancelled() && needs.test-unit.result == 'success' && !contains(github.event.pull_request.labels.*.name, 'hotfix') }}
|
||||
env:
|
||||
@@ -1062,10 +962,6 @@ jobs:
|
||||
# (if-no-files-found: warn) — Sonar consumes the same file.
|
||||
- name: Upload coverage to Codecov (informational)
|
||||
if: always()
|
||||
# Informational means informational: its own ceiling and continue-on-error, so a
|
||||
# stalled upload can neither eat the job's budget nor turn a green job cancelled.
|
||||
timeout-minutes: 5
|
||||
continue-on-error: true
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
files: coverage/lcov.info
|
||||
@@ -1086,10 +982,7 @@ jobs:
|
||||
name: SonarQube
|
||||
runs-on: ubuntu-latest
|
||||
needs: test-coverage
|
||||
# Temporarily opt-in: the private project currently has no Sonar quota.
|
||||
# Re-enable without another code change by setting the repository Actions
|
||||
# variable SONARQUBE_ENABLED=true after quota/project access is restored.
|
||||
if: ${{ vars.SONARQUBE_ENABLED == 'true' && !cancelled() && needs.test-coverage.result == 'success' }}
|
||||
if: ${{ !cancelled() && needs.test-coverage.result == 'success' }}
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
|
||||
@@ -1260,14 +1153,10 @@ jobs:
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: next-build
|
||||
# Workspace-relative on purpose: the matrix below includes windows-latest, whose
|
||||
# default shell is pwsh, where $RUNNER_TEMP is empty (it is $env:RUNNER_TEMP) —
|
||||
# #11896's first cut broke the Electron smoke on exactly that. A relative path
|
||||
# works in bash and pwsh alike; hosted workspaces are ephemeral.
|
||||
path: next-build-artifact
|
||||
path: /tmp/
|
||||
- name: Extract Next.js build artifact
|
||||
run: |
|
||||
tar -xzf next-build-artifact/e2e-build.tar.gz
|
||||
tar -xzf /tmp/e2e-build.tar.gz
|
||||
# WS4.1: duration-balanced shards (LPT over config/quality/e2e-timings.json).
|
||||
# Measured skew of plain --shard was 14× (24m47s vs 1m47s) — E2E was the CI
|
||||
# critical path. The balancer self-verifies completeness and exits non-zero on
|
||||
@@ -1351,63 +1240,6 @@ jobs:
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run test:security
|
||||
|
||||
# Live-server E2E. Both suites boot a real OmniRoute via their own runner and
|
||||
# drive it over HTTP; neither needs provider credentials. They were documented in
|
||||
# AGENTS.md's test matrix but wired to NO workflow, and had additionally been
|
||||
# unrunnable (vitest.config.ts excluded the very files their runners passed as a
|
||||
# positional filter) — so nothing had executed them for as long as that was true.
|
||||
test-ecosystem:
|
||||
name: Ecosystem E2E (live server)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
# needs: changes (not build) — the runner boots its own dev server.
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
|
||||
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"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run test:ecosystem
|
||||
|
||||
test-protocols-e2e:
|
||||
name: Protocol Clients E2E (live server, advisory)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
|
||||
# ADVISORY until #10049 is resolved. Restoring this suite immediately surfaced a
|
||||
# real discrepancy that had been invisible while it could not run: GET
|
||||
# /api/mcp/audit answers 403 over loopback where the suite expects 200|401. That
|
||||
# is a pre-existing contract question, not a defect introduced by wiring the job
|
||||
# up, so it must not block every PR in the meantime. Flip to blocking (drop this
|
||||
# continue-on-error) the moment #10049 lands.
|
||||
continue-on-error: true
|
||||
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"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run test:protocols:e2e
|
||||
|
||||
ci-summary:
|
||||
name: CI Dashboard
|
||||
runs-on: ubuntu-latest
|
||||
@@ -1430,8 +1262,6 @@ jobs:
|
||||
- test-e2e
|
||||
- test-integration
|
||||
- test-security
|
||||
- test-ecosystem
|
||||
- test-protocols-e2e
|
||||
steps:
|
||||
- name: Download i18n results
|
||||
continue-on-error: true
|
||||
@@ -1490,9 +1320,9 @@ jobs:
|
||||
echo "| Lint | $(status '${{ needs.lint.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Docs Sync (Strict) | $(status '${{ needs.docs-sync-strict.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| i18n UI Coverage | $(status '${{ needs.i18n-ui-coverage.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| i18n Glossary (zh-CN, ko) | $(status '${{ needs.i18n-glossary-zhcn.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| i18n Glossary (zh-CN) | $(status '${{ needs.i18n-glossary-zhcn.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| PR Test Policy | $(status '${{ needs.pr-test-policy.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| SonarQube (opt-in; disabled without SONARQUBE_ENABLED=true) | $(status '${{ needs.sonarqube.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| SonarQube | $(status '${{ needs.sonarqube.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "## 🏗️ Build" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -1512,8 +1342,6 @@ jobs:
|
||||
echo "| E2E | $(status '${{ needs.test-e2e.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Integration | $(status '${{ needs.test-integration.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Security Tests | $(status '${{ needs.test-security.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Ecosystem E2E | $(status '${{ needs.test-ecosystem.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Protocol Clients E2E (advisory, #10049) | $(status '${{ needs.test-protocols-e2e.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "## 🌍 Translations" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -22,10 +22,10 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
- uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
queries: security-extended
|
||||
- uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
- uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
21
.github/workflows/dast-smoke.yml
vendored
21
.github/workflows/dast-smoke.yml
vendored
@@ -1,15 +1,7 @@
|
||||
name: DAST smoke (PR)
|
||||
# PRs into main only since #11946. The job's "Build CLI bundle" step is a backend-only
|
||||
# `next build`; on the hosted 7 GB runner it fits main's tree (~5.5 min) but dies on
|
||||
# release/v3.8.51 (VM shutdown ~7 min in, before the server even starts), and because the
|
||||
# job is continue-on-error the result was a permanently red advisory check on every
|
||||
# release PR — noise, not signal. DAST coverage for release/** lives on the nightly rail
|
||||
# (nightly-schemathesis.yml, nightly-llm-security.yml); dispatch this workflow by hand
|
||||
# to smoke a release branch on demand.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
branches: ["main", "release/**"]
|
||||
# Runner-cost guard (#8084): the CLI-bundle build alone is 6-11min; a docs-only PR
|
||||
# cannot change DAST behavior, so skip the whole workflow for pure docs/markdown
|
||||
# changes. Any code path in the diff still runs the full smoke.
|
||||
@@ -45,7 +37,7 @@ jobs:
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm ci
|
||||
- name: Build CLI bundle
|
||||
env:
|
||||
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
|
||||
@@ -54,7 +46,6 @@ jobs:
|
||||
env:
|
||||
PORT: "20128"
|
||||
INJECTION_GUARD_MODE: block
|
||||
REQUIRE_API_KEY: "false"
|
||||
run: |
|
||||
node dist/server.js > server.log 2>&1 &
|
||||
echo $! > server.pid
|
||||
@@ -73,20 +64,16 @@ jobs:
|
||||
# those 302s as "the API accepted a schema-violating request" and the configured-off
|
||||
# 400 as "rejected a schema-compliant request". Documenting the flow in the spec is
|
||||
# still right (operators need it); fuzzing it is not what this smoke is for.
|
||||
# /api/auth/login has brute-force rate limiting: repeated failed logins return 429,
|
||||
# which Schemathesis flags as rejection of schema-compliant requests.
|
||||
schemathesis run docs/openapi.yaml --url http://localhost:20128 \
|
||||
--include-path-regex '^/v1/(chat/completions|models)$|^/api/(auth|keys)' \
|
||||
--exclude-path-regex '^/api/auth/(oidc/|login)' \
|
||||
--exclude-path-regex '^/api/auth/oidc/' \
|
||||
--max-examples 8 --workers 4 --checks all --max-response-time 30 \
|
||||
--request-timeout 20 --suppress-health-check all --no-color
|
||||
- name: Install promptfoo
|
||||
run: npm install -g promptfoo@0.122.0
|
||||
- name: promptfoo injection-guard (blocking)
|
||||
env:
|
||||
OMNIROUTE_URL: http://localhost:20128
|
||||
OMNIROUTE_API_KEY: not-needed-blocked-before-upstream
|
||||
run: promptfoo eval -c promptfooconfig.yaml --no-cache
|
||||
run: npx --yes promptfoo@latest eval -c promptfooconfig.yaml --no-cache
|
||||
- name: Stop server
|
||||
if: always()
|
||||
run: kill "$(cat server.pid)" || true
|
||||
|
||||
139
.github/workflows/docker-publish.yml
vendored
139
.github/workflows/docker-publish.yml
vendored
@@ -68,16 +68,6 @@ jobs:
|
||||
"$EVENT_NAME" "$REF_TYPE" "$REF_NAME" "$INPUT_VERSION" "$DEFAULT_BRANCH")
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Frozen release branches keep receiving coordination commits after the
|
||||
# next cycle becomes the default branch. They must not overwrite :next,
|
||||
# but that expected no-op is not a workflow failure.
|
||||
if [ "$VERSION" = "skip" ]; then
|
||||
echo "promote_latest=false" >> "$GITHUB_OUTPUT"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Skipping Docker publish from non-default release branch: $REF_NAME"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 2) Decide whether to promote :latest. Floating channels are never
|
||||
# eligible, and the helper independently fails closed for non-semver.
|
||||
PROMOTE="false"
|
||||
@@ -171,7 +161,7 @@ jobs:
|
||||
${{ env.IMAGE_NAME }}
|
||||
${{ env.GHCR_IMAGE_NAME }}
|
||||
cache-from: type=gha,scope=docker-${{ matrix.arch }}
|
||||
cache-to: type=gha,scope=docker-${{ matrix.arch }},mode=max,ignore-error=true
|
||||
cache-to: type=gha,scope=docker-${{ matrix.arch }},mode=max
|
||||
no-cache: false
|
||||
env:
|
||||
DOCKER_BUILDKIT_INLINE_CACHE: 1
|
||||
@@ -188,57 +178,7 @@ jobs:
|
||||
${{ env.IMAGE_NAME }}
|
||||
${{ env.GHCR_IMAGE_NAME }}
|
||||
cache-from: type=gha,scope=docker-web-${{ matrix.arch }}
|
||||
cache-to: type=gha,scope=docker-web-${{ matrix.arch }},mode=max,ignore-error=true
|
||||
no-cache: false
|
||||
env:
|
||||
DOCKER_BUILDKIT_INLINE_CACHE: 1
|
||||
|
||||
- name: Build and push BUN base platform image by digest
|
||||
id: build-bun-base
|
||||
# Bun is a best-effort compatibility target, not a supported runtime
|
||||
# (AGENTS.md -> Environment). Its `bun run build` has been OOM-killing on
|
||||
# both arches; letting that sink the whole publish means the SUPPORTED
|
||||
# runner-base / runner-web images never reach the registry either. The
|
||||
# image is still built and pushed whenever it succeeds — only its power to
|
||||
# block the release is removed.
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.bun
|
||||
target: runner-base
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}
|
||||
${{ env.GHCR_IMAGE_NAME }}
|
||||
cache-from: type=gha,scope=docker-bun-base-${{ matrix.arch }}
|
||||
cache-to: type=gha,scope=docker-bun-base-${{ matrix.arch }},mode=max,ignore-error=true
|
||||
no-cache: false
|
||||
env:
|
||||
DOCKER_BUILDKIT_INLINE_CACHE: 1
|
||||
|
||||
- name: Build and push BUN web platform image by digest
|
||||
id: build-bun-web
|
||||
# Bun is a best-effort compatibility target, not a supported runtime
|
||||
# (AGENTS.md -> Environment). Its `bun run build` has been OOM-killing on
|
||||
# both arches; letting that sink the whole publish means the SUPPORTED
|
||||
# runner-base / runner-web images never reach the registry either. The
|
||||
# image is still built and pushed whenever it succeeds — only its power to
|
||||
# block the release is removed.
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.bun
|
||||
target: runner-web
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}
|
||||
${{ env.GHCR_IMAGE_NAME }}
|
||||
cache-from: type=gha,scope=docker-bun-web-${{ matrix.arch }}
|
||||
cache-to: type=gha,scope=docker-bun-web-${{ matrix.arch }},mode=max,ignore-error=true
|
||||
cache-to: type=gha,scope=docker-web-${{ matrix.arch }},mode=max
|
||||
no-cache: false
|
||||
env:
|
||||
DOCKER_BUILDKIT_INLINE_CACHE: 1
|
||||
@@ -247,22 +187,11 @@ jobs:
|
||||
env:
|
||||
DIGEST_BASE: ${{ steps.build.outputs.digest }}
|
||||
DIGEST_WEB: ${{ steps.build-web.outputs.digest }}
|
||||
DIGEST_BUN_BASE: ${{ steps.build-bun-base.outputs.digest }}
|
||||
DIGEST_BUN_WEB: ${{ steps.build-bun-web.outputs.digest }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p /tmp/digests/base /tmp/digests/web /tmp/digests/bun-base /tmp/digests/bun-web
|
||||
mkdir -p /tmp/digests/base /tmp/digests/web
|
||||
touch "/tmp/digests/base/${DIGEST_BASE#sha256:}"
|
||||
touch "/tmp/digests/web/${DIGEST_WEB#sha256:}"
|
||||
# Empty when the (non-blocking) bun build produced no image. `if` blocks,
|
||||
# not `[ -n ] && touch`: under `set -e` a failing AND-list aborts the step,
|
||||
# which is precisely the case being handled here.
|
||||
if [ -n "$DIGEST_BUN_BASE" ]; then
|
||||
touch "/tmp/digests/bun-base/${DIGEST_BUN_BASE#sha256:}"
|
||||
fi
|
||||
if [ -n "$DIGEST_BUN_WEB" ]; then
|
||||
touch "/tmp/digests/bun-web/${DIGEST_BUN_WEB#sha256:}"
|
||||
fi
|
||||
|
||||
- name: Upload base digests
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -280,30 +209,6 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload bun-base digests
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: digests-bun-base-${{ matrix.arch }}
|
||||
path: /tmp/digests/bun-base/*
|
||||
# `ignore`, not `error`: the bun build is non-blocking, so an absent
|
||||
# digest is the expected outcome of a failed/skipped bun image — the
|
||||
# manifest step already treats these tags as optional. Leaving `error`
|
||||
# here just relocates the blocker from the manifest to the upload.
|
||||
if-no-files-found: ignore
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload bun-web digests
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: digests-bun-web-${{ matrix.arch }}
|
||||
path: /tmp/digests/bun-web/*
|
||||
# `ignore`, not `error`: the bun build is non-blocking, so an absent
|
||||
# digest is the expected outcome of a failed/skipped bun image — the
|
||||
# manifest step already treats these tags as optional. Leaving `error`
|
||||
# here just relocates the blocker from the manifest to the upload.
|
||||
if-no-files-found: ignore
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
name: Publish multi-arch manifests
|
||||
needs:
|
||||
@@ -358,32 +263,12 @@ jobs:
|
||||
path: /tmp/digests/web
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download bun-base digests
|
||||
# Non-blocking: the bun image is best-effort, so its artifact may not
|
||||
# exist at all. The manifest step treats these tags as optional.
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
pattern: digests-bun-base-*
|
||||
path: /tmp/digests/bun-base
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download bun-web digests
|
||||
# Non-blocking: the bun image is best-effort, so its artifact may not
|
||||
# exist at all. The manifest step treats these tags as optional.
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
pattern: digests-bun-web-*
|
||||
path: /tmp/digests/bun-web
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create Docker Hub manifest
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
create_manifest() {
|
||||
local image="$1" suffix="$2" dir="$3" optional="${4:-}"
|
||||
local image="$1" suffix="$2" dir="$3"
|
||||
local tags=(-t "${image}:${VERSION}${suffix}")
|
||||
if [ "$PROMOTE_LATEST" = "true" ]; then
|
||||
tags+=(-t "${image}:latest${suffix}")
|
||||
@@ -393,10 +278,6 @@ jobs:
|
||||
refs+=("${image}@sha256:$(basename "$digest_file")")
|
||||
done < <(find "$dir" -type f | sort)
|
||||
if [ "${#refs[@]}" -eq 0 ]; then
|
||||
if [ -n "$optional" ]; then
|
||||
echo "::warning::No image digests in $dir — skipping optional tag ${image}:${VERSION}${suffix}" >&2
|
||||
return 0
|
||||
fi
|
||||
echo "No image digests in $dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -405,15 +286,13 @@ jobs:
|
||||
|
||||
create_manifest "${IMAGE_NAME}" "" /tmp/digests/base
|
||||
create_manifest "${IMAGE_NAME}" "-web" /tmp/digests/web
|
||||
create_manifest "${IMAGE_NAME}" "-bun" /tmp/digests/bun-base optional
|
||||
create_manifest "${IMAGE_NAME}" "-web-bun" /tmp/digests/bun-web optional
|
||||
|
||||
- name: Create GHCR manifest
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
create_manifest() {
|
||||
local image="$1" suffix="$2" dir="$3" optional="${4:-}"
|
||||
local image="$1" suffix="$2" dir="$3"
|
||||
local tags=(-t "${image}:${VERSION}${suffix}")
|
||||
if [ "$PROMOTE_LATEST" = "true" ]; then
|
||||
tags+=(-t "${image}:latest${suffix}")
|
||||
@@ -423,10 +302,6 @@ jobs:
|
||||
refs+=("${image}@sha256:$(basename "$digest_file")")
|
||||
done < <(find "$dir" -type f | sort)
|
||||
if [ "${#refs[@]}" -eq 0 ]; then
|
||||
if [ -n "$optional" ]; then
|
||||
echo "::warning::No image digests in $dir — skipping optional tag ${image}:${VERSION}${suffix}" >&2
|
||||
return 0
|
||||
fi
|
||||
echo "No image digests in $dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -435,8 +310,6 @@ jobs:
|
||||
|
||||
create_manifest "${GHCR_IMAGE_NAME}" "" /tmp/digests/base
|
||||
create_manifest "${GHCR_IMAGE_NAME}" "-web" /tmp/digests/web
|
||||
create_manifest "${GHCR_IMAGE_NAME}" "-bun" /tmp/digests/bun-base optional
|
||||
create_manifest "${GHCR_IMAGE_NAME}" "-web-bun" /tmp/digests/bun-web optional
|
||||
|
||||
- name: Inspect image
|
||||
if: needs.prepare.outputs.version != 'main'
|
||||
@@ -499,7 +372,7 @@ jobs:
|
||||
- name: Upload Trivy SARIF to Security tab
|
||||
if: needs.prepare.outputs.version != 'main'
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v4.37.8
|
||||
uses: github/codeql-action/upload-sarif@v4.37.4
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: trivy-image
|
||||
|
||||
143
.github/workflows/electron-release.yml
vendored
143
.github/workflows/electron-release.yml
vendored
@@ -10,11 +10,6 @@ on:
|
||||
description: "Release version (e.g., v1.6.8)"
|
||||
required: true
|
||||
type: string
|
||||
publish_npm:
|
||||
description: "Also run the npm publish leg (turn off when re-attaching desktop assets to a release whose npm package already shipped)"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
# Least-privilege default: read-only at the top level; each job grants the writes it
|
||||
# needs (build/release upload assets, publish-npm forwards npm provenance / packages
|
||||
@@ -60,78 +55,9 @@ jobs:
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "✓ Valid version: $VERSION"
|
||||
|
||||
web-build:
|
||||
name: Build shared Next standalone
|
||||
needs: validate
|
||||
# Stage 8 (issue #10321): the four desktop legs used to each run the full
|
||||
# `npm run build` (Next standalone) — ~111 runner-minutes per release just to
|
||||
# produce the same platform-independent bundle four times. This job builds it
|
||||
# once on ubuntu; every leg then restores the byte-verified archive and
|
||||
# re-forks its native optionals (scripts/build/standaloneBundle.mjs).
|
||||
#
|
||||
# Rollback lever: set the repo variable ELECTRON_SHARED_STANDALONE=disabled.
|
||||
# This job then skips, every leg falls back to building its own web bundle
|
||||
# (the legacy step below), and the pipeline behaves exactly like pre-Stage 8 —
|
||||
# no revert needed.
|
||||
if: ${{ !cancelled() && needs.validate.result == 'success' && vars.ELECTRON_SHARED_STANDALONE != 'disabled' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
# workflow_dispatch: build the tag being (re)built, not the dispatching branch. On a
|
||||
# tag push this resolves to the same commit.
|
||||
ref: ${{ needs.validate.outputs.version }}
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
NPM_CONFIG_LEGACY_PEER_DEPS: true
|
||||
|
||||
- name: Build Next.js standalone
|
||||
# webpack, not Turbopack, for the same hosted-runner RAM reason as the
|
||||
# linux leg (see the long comment on the fallback step in `build`).
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
NODE_OPTIONS: "--max_old_space_size=6144"
|
||||
OMNIROUTE_USE_TURBOPACK: "0"
|
||||
run: npm run build
|
||||
|
||||
- name: Pack standalone bundle
|
||||
# Deterministic tar.gz + byte-level manifest; the manifest embeds the
|
||||
# archive's own sha256 so artifact-transfer corruption is caught before
|
||||
# extraction, and every entry is re-verified after extraction.
|
||||
run: node scripts/build/standaloneBundle.mjs pack --out web-bundle.tar.gz
|
||||
|
||||
- name: Upload shared web bundle
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: web-standalone-bundle
|
||||
# compression-level 0: the payload is already a deterministic tar.gz;
|
||||
# re-zipping would only burn runner CPU without shrinking it further.
|
||||
compression-level: 0
|
||||
# Legs consume this within minutes; no reason to retain it like the
|
||||
# installer artifacts (default 90d).
|
||||
retention-days: 3
|
||||
path: |
|
||||
web-bundle.tar.gz
|
||||
web-bundle.tar.gz.manifest.json
|
||||
|
||||
build:
|
||||
name: Build Electron (${{ matrix.platform }})
|
||||
needs: [validate, web-build]
|
||||
# `web-build` is skipped when ELECTRON_SHARED_STANDALONE=disabled (rollback
|
||||
# mode); legs then run the legacy per-leg web build below. If it ran and
|
||||
# failed, fail closed: legs cannot package without the bundle, and silently
|
||||
# falling back to four per-leg builds would hide exactly the regression the
|
||||
# shared job exists to surface.
|
||||
if: ${{ !cancelled() && needs.validate.result == 'success' && (needs.web-build.result == 'success' || needs.web-build.result == 'skipped') }}
|
||||
needs: validate
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: write # electron-builder may publish artifacts with GH_TOKEN
|
||||
@@ -143,41 +69,38 @@ jobs:
|
||||
runner: windows-latest
|
||||
target: win
|
||||
ext: .exe
|
||||
os: win32
|
||||
arch: x64
|
||||
- platform: macos-intel
|
||||
runner: macos-15-intel
|
||||
target: mac-x64
|
||||
ext: .dmg
|
||||
os: darwin
|
||||
arch: x64
|
||||
- platform: macos-arm64
|
||||
runner: macos-latest
|
||||
target: mac-arm64
|
||||
ext: -arm64.dmg
|
||||
os: darwin
|
||||
arch: arm64
|
||||
- platform: linux
|
||||
runner: ubuntu-latest
|
||||
target: linux
|
||||
ext: .AppImage
|
||||
deb_ext: .deb
|
||||
os: linux
|
||||
arch: x64,arm64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
# workflow_dispatch: build the tag being (re)built, not the dispatching branch. On a
|
||||
# tag push this resolves to the same commit.
|
||||
ref: ${{ needs.validate.outputs.version }}
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
|
||||
- name: Cache node_modules
|
||||
uses: actions/cache@v6.1.0
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
@@ -193,11 +116,7 @@ jobs:
|
||||
mkdir -p "$RUNNER_TEMP/home"
|
||||
echo "USERPROFILE=$RUNNER_TEMP/home" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build Next.js standalone (legacy per-leg fallback)
|
||||
# Stage 8: only runs in rollback mode (ELECTRON_SHARED_STANDALONE=disabled)
|
||||
# or when the shared web-build job was skipped. Otherwise the leg restores
|
||||
# the shared bundle from the `web-build` job below.
|
||||
if: needs.web-build.result == 'skipped'
|
||||
- name: Build Next.js standalone
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
NODE_OPTIONS: "--max_old_space_size=6144"
|
||||
@@ -215,30 +134,6 @@ jobs:
|
||||
OMNIROUTE_USE_TURBOPACK: ${{ matrix.platform == 'linux' && '0' || '1' }}
|
||||
run: npm run build
|
||||
|
||||
- name: Download shared web bundle
|
||||
# Stage 8: inverse of the fallback step above — runs exactly when the
|
||||
# shared `web-build` job produced the bundle.
|
||||
if: needs.web-build.result == 'success'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: web-standalone-bundle
|
||||
|
||||
- name: Restore + hydrate shared web bundle
|
||||
if: needs.web-build.result == 'success'
|
||||
shell: bash
|
||||
# restore: verify the archive's sha256 against the manifest, extract, then
|
||||
# re-verify every entry (existence + size + content hash + symlink
|
||||
# targets, and no unlisted files) byte-for-byte.
|
||||
# hydrate: the bundle was built on ubuntu, so install-machine-forked native
|
||||
# optionals (@img/sharp-*, @img/sharp-libvips-*, @ngrok/ngrok-*,
|
||||
# fsevents) carry linux forks. Replace them with the forks this
|
||||
# leg's own `npm ci` resolved, then assert every bundled native
|
||||
# (koffi triplets, better-sqlite3 prebuilds, wreq-js, onnxruntime)
|
||||
# can service this leg's platform/arch before packaging starts.
|
||||
run: |
|
||||
node scripts/build/standaloneBundle.mjs restore --archive web-bundle.tar.gz
|
||||
node scripts/build/standaloneBundle.mjs hydrate --platform ${{ matrix.os }} --arch ${{ matrix.arch }}
|
||||
|
||||
- name: Sync version in electron/package.json
|
||||
shell: bash
|
||||
env:
|
||||
@@ -263,7 +158,7 @@ jobs:
|
||||
|
||||
- name: Install Electron dependencies
|
||||
working-directory: electron
|
||||
run: npm ci --no-audit --no-fund
|
||||
run: npm install --no-audit --no-fund
|
||||
|
||||
- name: Build Electron for ${{ matrix.platform }}
|
||||
working-directory: electron
|
||||
@@ -290,14 +185,9 @@ jobs:
|
||||
|
||||
- name: Smoke packaged Electron app (Linux)
|
||||
if: matrix.platform == 'linux'
|
||||
# #7592: also cold-restart against the same DATA_DIR and assert a
|
||||
# native SQLite driver (not the sql.js WASM fallback) is selected on
|
||||
# the second launch — blocking here since Linux has no Windows-style
|
||||
# sandbox caveats that would make it flaky.
|
||||
env:
|
||||
ELECTRON_SMOKE_TIMEOUT_MS: 60000
|
||||
ELECTRON_SMOKE_STREAM_LOGS: "1"
|
||||
ELECTRON_SMOKE_COLD_RESTART: "1"
|
||||
run: xvfb-run -a npm run electron:smoke:packaged
|
||||
|
||||
- name: Collect installers
|
||||
@@ -358,8 +248,6 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
# Source archives + SBOM come from the tag being released, not the dispatching branch.
|
||||
ref: ${{ needs.validate.outputs.version }}
|
||||
|
||||
# `merge-multiple` is deliberately OFF. It resolves same-name collisions by ARRIVAL
|
||||
# ORDER, and the two macOS jobs each emit their own `latest-mac.yml` listing only their
|
||||
@@ -475,20 +363,11 @@ jobs:
|
||||
publish-npm:
|
||||
name: Publish to npm
|
||||
needs: [validate, release]
|
||||
# A re-dispatch that only re-attaches desktop assets must not publish the npm package again.
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_npm }}
|
||||
permissions:
|
||||
# Must be `write`, not `read`: this job calls the reusable npm-publish.yml whose
|
||||
# `publish` job needs `contents: write` (gh release upload — attach the SBOM, #3874).
|
||||
# A reusable workflow's job cannot request more permission than the caller grants,
|
||||
# so a `read` here makes GitHub reject the run at startup (startup_failure).
|
||||
#
|
||||
# `actions: read` for the same reason: the called `publish` job downloads the next-build
|
||||
# artefact and requests it. v3.8.50 (run 33005490476) died at startup with "The nested
|
||||
# job 'publish' is requesting 'actions: read', but is only allowed 'actions: none'" — and
|
||||
# because `release` lives in this same workflow, the tag shipped with ZERO assets. Keep
|
||||
# this block a superset of every job's permissions in npm-publish.yml.
|
||||
actions: read
|
||||
contents: write
|
||||
id-token: write # npm provenance (forwarded to the reusable workflow)
|
||||
packages: write # publish to npm.pkg.github.com
|
||||
|
||||
10
.github/workflows/nightly-llm-security.yml
vendored
10
.github/workflows/nightly-llm-security.yml
vendored
@@ -10,10 +10,7 @@ permissions:
|
||||
jobs:
|
||||
promptfoo-guard:
|
||||
name: promptfoo — injection guard (block mode, no secret)
|
||||
# #11965: this job runs a backend-only `next build`; the hosted 7 GB runner cannot build
|
||||
# release/v3.8.51 (VM shutdown ~7 min in), so it targets the box's light pool (`omni-light`:
|
||||
# two listeners, jobs ≤ ~6 GB). Falls back to hosted when USE_VPS_RUNNER is off.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-light"]')) || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
@@ -49,10 +46,7 @@ jobs:
|
||||
|
||||
garak:
|
||||
name: garak probes (skip without provider secret)
|
||||
# #11965: this job runs a backend-only `next build`; the hosted 7 GB runner cannot build
|
||||
# release/v3.8.51 (VM shutdown ~7 min in), so it targets the box's light pool (`omni-light`:
|
||||
# two listeners, jobs ≤ ~6 GB). Falls back to hosted when USE_VPS_RUNNER is off.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-light"]')) || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
# NOTE: the `secrets` context is NOT available in a job-level `if:` — referencing
|
||||
# it there makes GitHub reject the file on push (startup_failure on every push).
|
||||
# Map the secret into a job-level env and gate each step on a presence check, so
|
||||
|
||||
4
.github/workflows/nightly-release-green.yml
vendored
4
.github/workflows/nightly-release-green.yml
vendored
@@ -68,7 +68,7 @@ jobs:
|
||||
# this runs on the dedicated VPS runner — clean env (no operator OMNIROUTE_API_KEY,
|
||||
# no local noauth CLIs => zero machine-specific false positives) and no contention.
|
||||
# Nightly cron normally finds the var false (VM off) and falls back to hosted.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-build"]')) || 'ubuntu-latest' }}
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-release"]')) || 'ubuntu-latest' }}
|
||||
env:
|
||||
JWT_SECRET: ci-nightly-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-nightly-api-key-secret-long
|
||||
@@ -217,7 +217,7 @@ jobs:
|
||||
# On a push, only run for a push to main — a push to release/* is handled by
|
||||
# release-green above. Schedule/dispatch always run (they also sweep main).
|
||||
if: ${{ github.event_name != 'push' || github.ref_name == 'main' }}
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-build"]')) || 'ubuntu-latest' }}
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-release"]')) || 'ubuntu-latest' }}
|
||||
env:
|
||||
JWT_SECRET: ci-nightly-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-nightly-api-key-secret-long
|
||||
|
||||
5
.github/workflows/nightly-resilience.yml
vendored
5
.github/workflows/nightly-resilience.yml
vendored
@@ -78,10 +78,7 @@ jobs:
|
||||
|
||||
a11y:
|
||||
name: A11y axe (nightly, freeze-and-alert)
|
||||
# #11965: this job runs a backend-only `next build`; the hosted 7 GB runner cannot build
|
||||
# release/v3.8.51 (VM shutdown ~7 min in), so it targets the box's light pool (`omni-light`:
|
||||
# two listeners, jobs ≤ ~6 GB). Falls back to hosted when USE_VPS_RUNNER is off.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-light"]')) || 'ubuntu-latest' }}
|
||||
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,
|
||||
|
||||
5
.github/workflows/nightly-schemathesis.yml
vendored
5
.github/workflows/nightly-schemathesis.yml
vendored
@@ -10,10 +10,7 @@ permissions:
|
||||
jobs:
|
||||
schemathesis:
|
||||
name: Schemathesis — OpenAPI contract fuzz (advisory)
|
||||
# #11965: this job runs a backend-only `next build`; the hosted 7 GB runner cannot build
|
||||
# release/v3.8.51 (VM shutdown ~7 min in), so it targets the box's light pool (`omni-light`:
|
||||
# two listeners, jobs ≤ ~6 GB). Falls back to hosted when USE_VPS_RUNNER is off.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-light"]')) || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
198
.github/workflows/npm-publish.yml
vendored
198
.github/workflows/npm-publish.yml
vendored
@@ -23,12 +23,11 @@ on:
|
||||
- next
|
||||
- historic
|
||||
publish_mode:
|
||||
description: "auto = publish through npm Trusted Publishing (OIDC, no token, no 2FA prompt — the default); staged = npm stage publish (owner approves with 2FA); direct = legacy token publish (emergency fallback only)"
|
||||
description: "staged = npm stage publish (owner approves with 2FA after the staged boot-verify); direct = legacy immediate publish (emergency fallback only)"
|
||||
required: false
|
||||
default: "auto"
|
||||
default: "staged"
|
||||
type: choice
|
||||
options:
|
||||
- auto
|
||||
- staged
|
||||
- direct
|
||||
workflow_call:
|
||||
@@ -63,15 +62,11 @@ jobs:
|
||||
# mid-"Creating an optimized production build" while v3.8.48 had still fit in 16min.
|
||||
# This job never runs on `pull_request`, so the fork-safety clause is always true here;
|
||||
# it is kept verbatim so the expression stays greppable against ci.yml.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-build"]') || 'ubuntu-latest' }}
|
||||
outputs:
|
||||
version: ${{ steps.resolve.outputs.version }}
|
||||
tag: ${{ steps.resolve.outputs.tag }}
|
||||
skip: ${{ steps.resolve.outputs.skip }}
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
actions: read # find + download the CI run's next-build artifact for this SHA
|
||||
contents: write # gh release upload (attach SBOM to the GitHub Release)
|
||||
id-token: write # npm provenance (GitHub Packages step)
|
||||
id-token: write # npm provenance
|
||||
packages: write # publish to npm.pkg.github.com
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -205,11 +200,8 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
RUN=""
|
||||
# $RUNNER_TEMP, never /tmp: on the .113 pool /tmp is a 12 GB tmpfs (RAM). Parking
|
||||
# this 1.3 GB artefact there took 27–32 min of the 76-min publish job — the
|
||||
# same bytes upload from disk in 2 min. RUNNER_TEMP is per-runner and on disk.
|
||||
for candidate in $CANDIDATES; do
|
||||
if gh run download "$candidate" --repo "$REPO" --name next-build --dir "$RUNNER_TEMP/next-build" 2>/dev/null; then
|
||||
if gh run download "$candidate" --repo "$REPO" --name next-build --dir /tmp/next-build 2>/dev/null; then
|
||||
RUN="$candidate"
|
||||
break
|
||||
fi
|
||||
@@ -219,8 +211,8 @@ jobs:
|
||||
echo "::notice::none of the candidate runs still carries next-build (1-day retention) — falling back to a full build"
|
||||
exit 0
|
||||
fi
|
||||
tar -xzf "$RUNNER_TEMP/next-build/e2e-build.tar.gz" -C .
|
||||
rm -rf "$RUNNER_TEMP/next-build"
|
||||
tar -xzf /tmp/next-build/e2e-build.tar.gz -C .
|
||||
rm -rf /tmp/next-build
|
||||
if [ -f .build/next/standalone/server.js ]; then
|
||||
echo "✅ standalone tree restored from CI run $RUN — build:cli will skip next build"
|
||||
else
|
||||
@@ -234,28 +226,6 @@ jobs:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
run: npm run build:cli
|
||||
|
||||
# `build:cli` assembles dist/ but does NOT write dist/BUILD_SHA — only
|
||||
# `build:release` does, by calling write-build-sha.mjs. The #10427 provenance
|
||||
# guard inside check:pack-artifact rejects an artifact with no SHA (and rejects
|
||||
# it even under OMNIROUTE_ALLOW_CANARY_BUILD=1: what cannot be identified cannot
|
||||
# be vouched for). Without this step the build+validate pair in this job is
|
||||
# structurally incompatible and fails 100% of the time — the same gap that was
|
||||
# fixed in ci.yml's Package Artifact job.
|
||||
- name: Stamp dist/BUILD_SHA for the provenance guard (#10427)
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
env:
|
||||
OMNIROUTE_BUILD_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
export OMNIROUTE_BUILD_SHA="${OMNIROUTE_BUILD_SHA:0:7}"
|
||||
node scripts/build/write-build-sha.mjs
|
||||
|
||||
# The guard checks ancestry against origin/main by default, which is correct
|
||||
# here (a release tag is cut from main), but the ref has to exist locally for
|
||||
# `git merge-base` to resolve it.
|
||||
- name: Fetch main for the provenance probe
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
run: git fetch --no-tags --depth=50 origin +refs/heads/main:refs/remotes/origin/main
|
||||
|
||||
- name: Validate npm package artifact
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
run: npm run check:pack-artifact
|
||||
@@ -295,12 +265,7 @@ jobs:
|
||||
# a staged package that is never approved simply expires, with no `npm deprecate` needed.
|
||||
- name: Prove clean-install AND upgrade-over-previous both boot
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
# 60, not 30. This gate was added in #8953 and the 2026-08-27 v3.8.50 publish
|
||||
# was the FIRST run to ever reach it — every earlier attempt died upstream, so
|
||||
# its budget had never been measured against a real run. It then blew the limit
|
||||
# on its debut: `npm pack` alone took 24m37s, leaving 5 minutes for two installs
|
||||
# and two boots. 30 was a guess; 60 is sized to the one measurement we have.
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 30
|
||||
run: npm run check:install-upgrade
|
||||
|
||||
# WS1.3 (D2, v3.8.49 plan): STAGED publishing by default — `npm stage publish`
|
||||
@@ -322,133 +287,17 @@ jobs:
|
||||
fi
|
||||
npm --version
|
||||
|
||||
# The registry upload itself moved to the `stage-npm` job below: npm REFUSES
|
||||
# `--provenance` from a self-hosted runner (422 "Unsupported GitHub Actions
|
||||
# runner environment"), and the heavy verification above cannot move to a
|
||||
# hosted one (16 GB is not enough for build:cli's next-build fallback — see
|
||||
# this job's runs-on comment). So this job proves the bytes and hands them
|
||||
# over; a tiny hosted job does the upload.
|
||||
- name: Pack the verified tarball for the upload job
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.resolve.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# --ignore-scripts: prepublishOnly would re-run build:cli-api && build:cli,
|
||||
# rebuilding bytes this job has already built, validated and boot-smoked.
|
||||
npm pack --ignore-scripts
|
||||
TARBALL="omniroute-${VERSION}.tgz"
|
||||
test -f "$TARBALL" || { echo "expected $TARBALL to exist after npm pack" >&2; ls -la ./*.tgz || true; exit 1; }
|
||||
echo "packed $TARBALL ($(du -h "$TARBALL" | cut -f1))"
|
||||
|
||||
- name: Hand the tarball to the hosted publish job
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: npm-tarball
|
||||
path: omniroute-${{ steps.resolve.outputs.version }}.tgz
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Publish to GitHub Packages
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
- name: Publish to npm (staged — owner approves with 2FA)
|
||||
if: steps.resolve.outputs.skip != 'true' && (github.event_name != 'workflow_dispatch' || inputs.publish_mode != 'direct')
|
||||
env:
|
||||
VERSION: ${{ steps.resolve.outputs.version }}
|
||||
TAG: ${{ steps.resolve.outputs.tag }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Configuring for GitHub Packages..."
|
||||
echo "//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}" > .npmrc
|
||||
npm pkg set name="@diegosouzapw/omniroute"
|
||||
npm publish --registry=https://npm.pkg.github.com --tag "$TAG" \
|
||||
|| echo "⚠️ omniroute@${VERSION} might already be published on GitHub Packages."
|
||||
echo "✅ Action finished for GitHub Packages"
|
||||
|
||||
# npm REFUSES `--provenance` from a self-hosted runner:
|
||||
# 422 Unprocessable Entity - Error verifying sigstore provenance bundle:
|
||||
# Unsupported GitHub Actions runner environment: "self-hosted".
|
||||
# Only "github-hosted" runners are supported when publishing with provenance.
|
||||
# v3.8.49 published fine because it predates USE_VPS_RUNNER being turned on
|
||||
# (2026-08-02); v3.8.50 was the first release after it, so this had been latent
|
||||
# for four weeks. Dropping --provenance was not an option: 3.8.49 carries a
|
||||
# SLSA attestation and 3.8.50 must not regress that.
|
||||
# The `publish` job cannot simply move to a hosted runner either — 16 GB is not
|
||||
# enough for build:cli's next-build fallback. So it keeps proving the bytes and
|
||||
# this job, which needs no memory at all, performs the upload.
|
||||
stage-npm:
|
||||
needs: publish
|
||||
if: needs.publish.outputs.skip != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # npm provenance — the whole reason this job is separate
|
||||
steps:
|
||||
- name: Download the tarball the publish job proved
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: npm-tarball
|
||||
path: .
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }}
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Ensure npm supports staged publishing
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CUR=$(npm --version)
|
||||
if ! node -e "const [a,b]='$(npm --version)'.split('.').map(Number); process.exit(a>11||(a===11&&b>=15)?0:1)"; then
|
||||
# Pinned exact version (supply-chain: never float @latest in a publish
|
||||
# job); bump deliberately when a newer npm is required.
|
||||
echo "npm $CUR < 11.15 — installing pinned npm 11.15.0 for staged publishing"
|
||||
npm install -g --ignore-scripts npm@11.15.0
|
||||
fi
|
||||
npm --version
|
||||
|
||||
# Trusted Publishing (OIDC): npm mints a short-lived credential for THIS run from
|
||||
# GitHub's id-token — no NPM_TOKEN secret, no 2FA prompt, provenance included, and
|
||||
# it is the bypass npm sanctions now that tokens which skip 2FA are being retired
|
||||
# (gh.io/npm-gat-bypass2fa-deprecation). Requires the package's Trusted Publisher to
|
||||
# be configured on npmjs.com (owner: diegosouzapw/OmniRoute, workflow
|
||||
# npm-publish.yml) and a github-hosted runner — which is why this job exists.
|
||||
# Without that configuration `npm publish` fails with ENEEDAUTH: re-dispatch with
|
||||
# publish_mode=staged or direct. Automatic publishing was the flow up to v3.8.48;
|
||||
# v3.8.49 moved to staged (WS1.3) to keep a leaked token from publishing alone —
|
||||
# OIDC gives the same guarantee without the manual approve.
|
||||
- name: Publish to npm (Trusted Publishing / OIDC — automatic)
|
||||
if: github.event_name != 'workflow_dispatch' || inputs.publish_mode == 'auto'
|
||||
env:
|
||||
VERSION: ${{ needs.publish.outputs.version }}
|
||||
TAG: ${{ needs.publish.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARBALL="omniroute-${VERSION}.tgz"
|
||||
test -f "$TARBALL" || { echo "tarball $TARBALL did not arrive from the publish job" >&2; ls -la; exit 1; }
|
||||
# Deliberately NO NODE_AUTH_TOKEN in this step: npm >= 11.5 detects the GitHub
|
||||
# OIDC token itself. Always pass --tag explicitly (defense in depth: an older
|
||||
# VERSION can never claim `@latest`).
|
||||
npm publish "$TARBALL" --provenance --access public --tag "$TAG" --ignore-scripts
|
||||
echo "✅ Published omniroute@$VERSION (dist-tag=$TAG) via Trusted Publishing"
|
||||
|
||||
- name: Publish to npm (staged — owner approves with 2FA)
|
||||
# Only on an explicit request now: Trusted Publishing below is the default.
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.publish_mode == 'staged'
|
||||
env:
|
||||
VERSION: ${{ needs.publish.outputs.version }}
|
||||
TAG: ${{ needs.publish.outputs.tag }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARBALL="omniroute-${VERSION}.tgz"
|
||||
test -f "$TARBALL" || { echo "tarball $TARBALL did not arrive from the publish job" >&2; ls -la; exit 1; }
|
||||
# Always pass --tag explicitly. Defense in depth: even if VERSION is
|
||||
# accidentally an older release, the historic tag will NOT claim `@latest`.
|
||||
# --ignore-scripts: publishing a built tarball must never re-run
|
||||
# prepublishOnly (build:cli-api && build:cli) on this small runner.
|
||||
npm stage publish "$TARBALL" --provenance --access public --tag "$TAG" --ignore-scripts
|
||||
npm stage publish --provenance --access public --tag "$TAG"
|
||||
{
|
||||
echo "## 📦 omniroute@$VERSION STAGED (not yet installable)"
|
||||
echo ""
|
||||
@@ -464,18 +313,31 @@ jobs:
|
||||
echo "✅ Staged omniroute@$VERSION (dist-tag=$TAG) — awaiting owner 'npm stage approve'"
|
||||
|
||||
- name: Publish to npm (DIRECT — emergency fallback)
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.publish_mode == 'direct'
|
||||
if: steps.resolve.outputs.skip != 'true' && github.event_name == 'workflow_dispatch' && inputs.publish_mode == 'direct'
|
||||
env:
|
||||
VERSION: ${{ needs.publish.outputs.version }}
|
||||
TAG: ${{ needs.publish.outputs.tag }}
|
||||
VERSION: ${{ steps.resolve.outputs.version }}
|
||||
TAG: ${{ steps.resolve.outputs.tag }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARBALL="omniroute-${VERSION}.tgz"
|
||||
test -f "$TARBALL" || { echo "tarball $TARBALL did not arrive from the publish job" >&2; ls -la; exit 1; }
|
||||
npm publish "$TARBALL" --provenance --access public --tag "$TAG" --ignore-scripts
|
||||
npm publish --provenance --access public --tag "$TAG"
|
||||
echo "✅ Published omniroute@$VERSION (dist-tag=$TAG) [DIRECT mode]"
|
||||
|
||||
- name: Publish to GitHub Packages
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.resolve.outputs.version }}
|
||||
TAG: ${{ steps.resolve.outputs.tag }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Configuring for GitHub Packages..."
|
||||
echo "//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}" > .npmrc
|
||||
npm pkg set name="@diegosouzapw/omniroute"
|
||||
npm publish --registry=https://npm.pkg.github.com --tag "$TAG" \
|
||||
|| echo "⚠️ omniroute@${VERSION} might already be published on GitHub Packages."
|
||||
echo "✅ Action finished for GitHub Packages"
|
||||
|
||||
publish-opencode-plugin:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|
||||
4
.github/workflows/opencode-plugin-ci.yml
vendored
4
.github/workflows/opencode-plugin-ci.yml
vendored
@@ -2,11 +2,11 @@ name: opencode-plugin CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, "release/**"]
|
||||
branches: [main, release/v3.8.2]
|
||||
paths:
|
||||
- "@omniroute/opencode-plugin/**"
|
||||
pull_request:
|
||||
branches: [main, "release/**"]
|
||||
branches: [main, release/v3.8.2]
|
||||
paths:
|
||||
- "@omniroute/opencode-plugin/**"
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
253
.github/workflows/quality.yml
vendored
253
.github/workflows/quality.yml
vendored
@@ -60,52 +60,13 @@ jobs:
|
||||
build:
|
||||
name: Build (advisory)
|
||||
needs: changes
|
||||
# FORK PRs ONLY. build.yml's `Fast Production Build` triggers on `push: branches: ["**"]`
|
||||
# (#11946, 2026-08-29: build.yml is now workflow_dispatch-only — the hosted runner cannot
|
||||
# build this tree in any profile, 8/8 recent fork PRs included — so own-origin PRs rely on
|
||||
# ci.yml `Build` after merge to main and on nightly-release-green for release/**.)
|
||||
# and runs `build:release` — a superset of this job — so for an own-origin branch this job
|
||||
# was building the same tree twice. A fork contributor pushes to THEIR repo, so that push
|
||||
# never fires here, and this is the only pre-merge build signal they get. Measured
|
||||
# 2026-08-14: 72 of the last 100 PRs into release/** came from forks, so the fork case is
|
||||
# the majority of the traffic, not the exception — this job earns its place, it just should
|
||||
# not duplicate build.yml for the own-origin 28%.
|
||||
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true' && github.event.pull_request.head.repo.full_name != github.repository) }}
|
||||
# PINNED to hosted — this was the last job in THIS workflow still on the USE_VPS_RUNNER
|
||||
# switch (ci.yml's Build, nightly-release-green and npm-publish keep it, so the variable
|
||||
# stays meaningful), and with USE_VPS_RUNNER=true it produced NO signal at all here.
|
||||
# Measured 2026-08-14 over the last 25
|
||||
# quality.yml runs: not one Build (advisory) reached a conclusion. Every sample was either
|
||||
# queued on the self-hosted pool (2 runners, `omniroute-113-6/7`, both permanently busy — one
|
||||
# job sat queued 2h+ and was still unclaimed) or, when it did land, killed mid-build by this
|
||||
# workflow's own `cancel-in-progress` concurrency. 6/6 sampled "failures" are exit 143 /
|
||||
# "The runner has received a shutdown signal" at ~3.5 min into `npm run build` — zero OOM,
|
||||
# zero build errors. So the job burned a scarce runner that the gates actually need while
|
||||
# reporting a permanent red on every PR.
|
||||
#
|
||||
# Gap 19 left USE_VPS_RUNNER governing build-like jobs on the premise that "the build needs
|
||||
# the .113's RAM". That premise no longer holds: `Fast Production Build` (build.yml) runs
|
||||
# `build:release` — a SUPERSET of this job's `npm run build`, plus the CLI bundle — on plain
|
||||
# ubuntu-latest and passed 24/25 of its last runs in ~15 min. What it has and this job did
|
||||
# not is memory PROVISIONING: a 10 GB swapfile plus a 12 GB V8 heap. That matters because
|
||||
# --max-old-space-size only bounds V8's JS heap, never Turbopack's native (Rust) allocation
|
||||
# (#6409) — swap is what absorbs the native peak. Both are mirrored below.
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
|
||||
# Dynamic runner — same fork-safe rule as ci.yml / fast-gates.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
|
||||
# #7307: advisory for the first week of release-PR runs; remove
|
||||
# continue-on-error after the production-build signal is stable.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
# Mirrors build.yml: Turbopack's native peak is not bounded by --max-old-space-size, so
|
||||
# the hosted runner needs swap headroom before the build starts.
|
||||
- name: Expand virtual memory (10 GB swap)
|
||||
run: |
|
||||
sudo swapoff -a || true
|
||||
sudo rm -f /mnt/swapfile /swapfile
|
||||
sudo fallocate -l 10G /mnt/swapfile || sudo dd if=/dev/zero of=/mnt/swapfile bs=1M count=10240
|
||||
sudo chmod 600 /mnt/swapfile
|
||||
sudo mkswap /mnt/swapfile
|
||||
sudo swapon /mnt/swapfile
|
||||
free -h
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
@@ -118,10 +79,6 @@ jobs:
|
||||
- run: npm run build
|
||||
env:
|
||||
OMNIROUTE_USE_TURBOPACK: "1"
|
||||
# Same heap build.yml proves sufficient. build-next-isolated.mjs defaults to 8192 and
|
||||
# honours OMNIROUTE_BUILD_MEMORY_MB; NODE_OPTIONS is set for parity with build.yml.
|
||||
NODE_OPTIONS: "--max-old-space-size=12288"
|
||||
OMNIROUTE_BUILD_MEMORY_MB: "12288"
|
||||
# No artifact upload here: the PR-to-release quality workflow has no
|
||||
# downstream package/e2e jobs that consume the Next.js build output.
|
||||
|
||||
@@ -140,7 +97,7 @@ jobs:
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm ci
|
||||
# One walk of src/app/api for openapi-routes + docs-symbols (both still fail independently).
|
||||
- run: npm run check:api-docs-refs
|
||||
- name: Docs accuracy (fabricated-docs + i18n mirrors, strict)
|
||||
@@ -184,7 +141,7 @@ jobs:
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm ci
|
||||
- name: Restore ESLint file cache
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
@@ -192,11 +149,65 @@ jobs:
|
||||
.eslintcache
|
||||
.eslintcache-complexity
|
||||
key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }}
|
||||
# No restore-keys fallback on purpose (#11600, P-II.1 of the v3.8.50 postmortem): a
|
||||
# cache built under a different suppressions file / lint config / lockfile reports
|
||||
# stale per-file verdicts, which is exactly how 215 pre-existing errors stayed
|
||||
# invisible for a whole cycle. Exact key or a cold full lint (~13 min) — never a
|
||||
# partial cache from another configuration.
|
||||
restore-keys: |
|
||||
eslint-${{ runner.os }}-
|
||||
- run: npm run check:provider-consistency
|
||||
- run: npm run check:fetch-targets
|
||||
# docs-all / openapi-routes / docs-symbols live in docs-gates (path-filtered).
|
||||
- run: npm run check:deps
|
||||
# #8522: --base-ref mode for PR events — compare against max(frozen, base) so
|
||||
# inherited drift (base already over frozen cap) doesn't red an innocent PR.
|
||||
# workflow_dispatch (no PR base) falls back to absolute comparison.
|
||||
- name: File-size ratchet (base-relative on PR)
|
||||
env:
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
if [ -n "$PR_BASE_SHA" ]; then
|
||||
npm run check:file-size -- --base-ref "$PR_BASE_SHA"
|
||||
else
|
||||
npm run check:file-size
|
||||
fi
|
||||
- run: npm run check:error-helper
|
||||
- run: npm run check:migration-numbering
|
||||
- run: npm run check:public-creds
|
||||
- run: npm run check:db-rules
|
||||
- run: npm run check:known-symbols
|
||||
- run: npm run check:route-guard-membership
|
||||
- run: npm run check:test-discovery
|
||||
- run: npm run check:test-runner-api
|
||||
# Guards tap.testFiles drift: a covering unit test absent from stryker.conf.json
|
||||
# tap.testFiles makes its module's mutants survive on a cold nightly-mutation run,
|
||||
# false-failing the blocking mutationScore ratchet. See check-mutation-test-coverage.mjs.
|
||||
- run: npm run check:mutation-test-coverage
|
||||
- run: npm run check:any-budget:t11
|
||||
# Build-scope guard: fails if worktrees/cruft leak into the tsconfig include
|
||||
# scope (would OOM `next build`). Instant. See incident 2026-06-25 / #5031.
|
||||
- run: npm run check:build-scope
|
||||
# Pack-policy (unexpected-files allowlist) WITHOUT a build — catches a stray file
|
||||
# leaking into the npm tarball (v3.8.36: 6 ops bin/*.sh) per-PR instead of only on
|
||||
# the release PR's heavy Package Artifact job.
|
||||
- run: npm run check:pack-policy
|
||||
# Complexity + cognitive-complexity: ONE ESLint walk (both baselines still
|
||||
# enforced separately by ruleId). Avoids two cold tree walks on fast-path.
|
||||
- run: npm run check:complexity-ratchets
|
||||
# ── G0 (trilho .50): gates do trilho A que faltavam no trilho B ──────────────
|
||||
# The god-file refactor happens in PRs→release/**; without these, the release
|
||||
# rail never sees a new import cycle, dead code, duplication or a security
|
||||
# regression until the release PR to main. Deliberately NOT brought here:
|
||||
# bundle-size (self-skips without a build — this rail's build job is advisory
|
||||
# and uploads nothing, so it would be dead configuration) and the coverage
|
||||
# run (fast-unit already runs the full suite; the coverage ratchet stays on
|
||||
# the main rail via --allow-missing in lint-guard).
|
||||
- run: npm run check:cycles
|
||||
- run: npm run check:lockfile
|
||||
- name: Duplication ratchet
|
||||
run: npm run check:duplication
|
||||
- name: Dead-code ratchet (knip)
|
||||
run: npm run check:dead-code
|
||||
- name: Type coverage ratchet
|
||||
run: npm run check:type-coverage
|
||||
- name: Compression budget ratchet
|
||||
run: npm run check:compression-budget
|
||||
# Security scanners — same hardened install as ci.yml quality-extended
|
||||
# (gh release download = authenticated, 5000 req/hr; curl to api.github.com
|
||||
# is rate-limited to 60/hr and silently no-ops when throttled). The blocking
|
||||
@@ -240,106 +251,42 @@ jobs:
|
||||
"$HOME/.local/bin/osv-scanner" --version || true
|
||||
"$HOME/.local/bin/oasdiff" --version || true
|
||||
zizmor --version || true
|
||||
- name: Forgotten sibling tests (advisory)
|
||||
- name: Secret scan (gitleaks, ratchet, blocking)
|
||||
run: npm run check:secrets -- --ratchet
|
||||
- name: Vulnerability ratchet (osv-scanner, ratchet, blocking)
|
||||
run: npm run check:vuln-ratchet -- --ratchet
|
||||
- name: Workflow lint (actionlint+zizmor, ratchet, blocking)
|
||||
run: npm run check:workflows -- --ratchet
|
||||
# BASE_REF is read by the script from the env (never interpolated into a
|
||||
# shell body) — workflow-injection-safe. actions/checkout fetches remote
|
||||
# refs, not a local branch named github.base_ref, so prefix origin/ or this
|
||||
# gate self-skips every PR with reason=base-unresolved.
|
||||
- name: OpenAPI breaking-change (oasdiff, ratchet, blocking)
|
||||
env:
|
||||
GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
node scripts/quality/build-test-impact-map.mjs
|
||||
node scripts/check/check-forgotten-sibling-tests.mjs \
|
||||
--summary-file forgotten-sibling-tests.md \
|
||||
--json-file forgotten-sibling-tests.json
|
||||
cat forgotten-sibling-tests.md >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Upload forgotten sibling report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: forgotten-sibling-tests
|
||||
path: |
|
||||
forgotten-sibling-tests.md
|
||||
forgotten-sibling-tests.json
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
# Quality gates (all, non-fail-fast) — #8542: replaces 17 bare check:* steps,
|
||||
# 6 G0 gates, 4 ratchet gates, and 3 typecheck steps with a single aggregation
|
||||
# step. Each gate runs in a loop with ::group::; failures are collected and
|
||||
# reported at the end. set -uo pipefail (NOT set -e) so one failing gate does
|
||||
# not abort the job and mask every later gate. Release-added gates are folded
|
||||
# in: open-sse typecheck (#8781) and file-size base-relative mode (#8522).
|
||||
- name: Quality gates (all, non-fail-fast)
|
||||
env:
|
||||
# #8522: base-relative file-size mode on PR events — inherited drift (base
|
||||
# already over frozen cap) must not red an innocent PR. Unset on
|
||||
# workflow_dispatch (no PR base) → absolute comparison.
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
BASE_REF: ${{ github.base_ref && format('origin/{0}', github.base_ref) || '' }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
gates=(
|
||||
provider-consistency provider-asset-provenance fetch-targets deps file-size error-helper
|
||||
migration-numbering public-creds db-rules known-symbols
|
||||
route-guard-membership test-discovery test-runner-api
|
||||
mutation-test-coverage any-budget:t11 build-scope pack-policy
|
||||
complexity-ratchets model-lifecycle
|
||||
cycles lockfile duplication dead-code type-coverage compression-budget
|
||||
# #8781: open-sse workspace typecheck gate — the workspace imports @/ which
|
||||
# escapes to src/ via undeclared path aliases. See check-open-sse-typecheck.mjs.
|
||||
open-sse-typecheck
|
||||
)
|
||||
ratchet_gates=(
|
||||
secrets vuln-ratchet workflows openapi-breaking
|
||||
)
|
||||
failed=()
|
||||
for g in "${gates[@]}"; do
|
||||
echo "::group::check:$g"
|
||||
# #8522: file-size is base-relative on PR events (compare against
|
||||
# max(frozen, base)) so inherited drift doesn't red an innocent PR;
|
||||
# workflow_dispatch (no PR base) falls back to absolute comparison.
|
||||
if [ "$g" = "file-size" ] && [ -n "${PR_BASE_SHA:-}" ]; then
|
||||
npm run "check:$g" -- --base-ref "$PR_BASE_SHA" || failed+=("$g")
|
||||
else
|
||||
npm run "check:$g" || failed+=("$g")
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
for g in "${ratchet_gates[@]}"; do
|
||||
echo "::group::check:$g (ratchet)"
|
||||
npm run "check:$g" -- --ratchet || failed+=("$g")
|
||||
echo "::endgroup::"
|
||||
done
|
||||
echo "::group::typecheck:core"
|
||||
npm run typecheck:core || failed+=("typecheck:core")
|
||||
echo "::endgroup::"
|
||||
echo "::group::check:dashboard-typecheck"
|
||||
npm run check:dashboard-typecheck || failed+=("check:dashboard-typecheck")
|
||||
echo "::endgroup::"
|
||||
# #10134: TS7 zero-new-diagnostics ratchet — folded into this non-fail-fast
|
||||
# loop (never a separate blocking step) so an earlier red gate cannot abort
|
||||
# the job and mask it (#8542 mechanism). PR-only: the base-relative
|
||||
# comparison needs the PR base SHA (empty on workflow_dispatch).
|
||||
if [ -n "${PR_BASE_SHA:-}" ]; then
|
||||
echo "::group::check:ts7-diagnostics-ratchet"
|
||||
npm run check:ts7-diagnostics-ratchet -- --base-ref "$PR_BASE_SHA" || failed+=("ts7-diagnostics-ratchet")
|
||||
echo "::endgroup::"
|
||||
fi
|
||||
if (( ${#failed[@]} )); then
|
||||
printf '::error::%d gate(s) failed: %s\n' "${#failed[@]}" "${failed[*]}"
|
||||
exit 1
|
||||
fi
|
||||
run: npm run check:openapi-breaking -- --ratchet
|
||||
- name: Typecheck (core)
|
||||
run: npm run typecheck:core
|
||||
# #7033: dashboard-scoped typecheck gate — src/app/(dashboard) TSX is not
|
||||
# covered by typecheck:core's curated allowlist. See check-dashboard-typecheck.mjs.
|
||||
- name: Typecheck (dashboard)
|
||||
run: npm run check:dashboard-typecheck
|
||||
# #8781: open-sse workspace typecheck gate — the workspace imports @/ which
|
||||
# escapes to src/ via undeclared path aliases. See check-open-sse-typecheck.mjs.
|
||||
- name: Typecheck (open-sse)
|
||||
run: npm run check:open-sse-typecheck
|
||||
# WS4.2 (v3.8.49 plan): TypeScript 7 native-compiler SHADOW — advisory only.
|
||||
# TS7 went GA 2026-07-08 with 8-12x type-check speedups; its Compiler API only
|
||||
# arrives in 7.1, so typescript-eslint / type-coverage / Stryker stay on 6.x
|
||||
# (the hybrid is the officially documented pattern). Isolated npx on purpose:
|
||||
# installing an alias package could collide node_modules/.bin/tsc with 6.x.
|
||||
# The full result stays advisory while #8484 has a backlog. The blocking
|
||||
# base-relative ratchet (folded into the non-fail-fast gates step above)
|
||||
# rejects only diagnostics added by the PR, so existing release debt does
|
||||
# not block unrelated work.
|
||||
# Promote to the blocking gate after ~1 week of parity with the step above.
|
||||
- name: Typecheck (core) — TS7 native shadow (advisory)
|
||||
continue-on-error: true
|
||||
run: |
|
||||
RC=0
|
||||
START=$(date +%s)
|
||||
npm exec --yes --package=typescript@7.0.2 -- tsc --pretty false -p tsconfig.typecheck-core.json || RC=$?
|
||||
npx -y -p typescript@7 tsc --pretty false -p tsconfig.typecheck-core.json || RC=$?
|
||||
echo "[ts7-shadow] exit=$RC elapsed=$(( $(date +%s) - START ))s — the 6.x step above stays authoritative"
|
||||
exit $RC
|
||||
# TIA: build the impact map at runtime (gitignored, ~21MB) and run only the
|
||||
@@ -356,8 +303,7 @@ jobs:
|
||||
GITHUB_BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
git fetch --no-tags origin "$GITHUB_BASE_REF" || true
|
||||
# The advisory sibling-test step generates the same map earlier in this job.
|
||||
[ -f config/quality/test-impact-map.json ] || node scripts/quality/build-test-impact-map.mjs
|
||||
node scripts/quality/build-test-impact-map.mjs
|
||||
SEL="$(node scripts/quality/select-impacted-tests.mjs)"
|
||||
# Shadow evidence (#8084): persist every selection so TIA false negatives can
|
||||
# be measured against fast-unit's full-suite verdict across releases BEFORE
|
||||
@@ -436,7 +382,7 @@ jobs:
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm ci
|
||||
# WS5.2/5.3: JUnit feeds Trunk Flaky Tests — the fast-path runs on EVERY PR,
|
||||
# which is where flaky-detection volume actually comes from (ci.yml's heavy
|
||||
# jobs only run on the release PR). Advisory upload, own-origin only.
|
||||
@@ -466,12 +412,6 @@ jobs:
|
||||
# cache restore saturating the link), while the tests themselves tied, 2m54 vs 2m31. So
|
||||
# self-hosted is strictly worse here and there is nothing to configure.
|
||||
runs-on: ubuntu-latest
|
||||
# A shard finishes in ~10 min. Without a ceiling a hung test process holds the PR for
|
||||
# GitHub's 6 h default: on 2026-08-28 shard 1/4 sat 64 min without a line of output
|
||||
# (twice, same spot — a timing race, gone on the third run) while the other three
|
||||
# shards were long green. 30 min = 3x the normal wall-clock; a shard that needs more
|
||||
# is a hang, not a slow run, and a fast red with a re-run beats a silent 6 h hold.
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -488,7 +428,7 @@ jobs:
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm ci
|
||||
# QW-d: fonte única — o mesmo npm script do CI pesado/local. Fecha dois drifts do
|
||||
# comando inline antigo: os dirs `memory` e `usage` estavam FORA do glob (testes
|
||||
# silenciosamente não rodavam no fast path) e o setupPolyfill não era importado.
|
||||
@@ -528,7 +468,7 @@ jobs:
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm ci
|
||||
- name: Restore ESLint file cache
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
@@ -536,11 +476,8 @@ jobs:
|
||||
.eslintcache
|
||||
.eslintcache-complexity
|
||||
key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }}
|
||||
# No restore-keys fallback on purpose (#11600, P-II.1 of the v3.8.50 postmortem): a
|
||||
# cache built under a different suppressions file / lint config / lockfile reports
|
||||
# stale per-file verdicts, which is exactly how 215 pre-existing errors stayed
|
||||
# invisible for a whole cycle. Exact key or a cold full lint (~13 min) — never a
|
||||
# partial cache from another configuration.
|
||||
restore-keys: |
|
||||
eslint-${{ runner.os }}-
|
||||
- name: ESLint (baseline congelado — warning novo = vermelho)
|
||||
# lint:json writes the report; --max-warnings 0 keeps no-new-warnings policy.
|
||||
run: npm run lint:json -- --max-warnings 0
|
||||
@@ -598,7 +535,7 @@ jobs:
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm ci
|
||||
- name: CHANGELOG integrity (nenhum bullet da base pode sumir no merge-result)
|
||||
run: npm run check:changelog-integrity
|
||||
- name: Agent-skills generator sync (SKILL.md gerado ≡ catálogo)
|
||||
|
||||
64
.github/workflows/radar-export.yml
vendored
64
.github/workflows/radar-export.yml
vendored
@@ -1,64 +0,0 @@
|
||||
# Publica o export estável do catálogo consumido pelo OmniRoute Radar numa URL
|
||||
# fixa (asset de release `radar-export-latest`), para o servidor privado do Radar
|
||||
# (1 GB RAM, nunca clona/builda o OmniRoute) baixá-lo via `RADAR_EXPORT_URL` em
|
||||
# vez de depender do snapshot gravado no deploy. Fonte: scripts/release/radar-export.mjs.
|
||||
#
|
||||
# A URL estável resultante (definir em RADAR_EXPORT_URL no .env do radar-server):
|
||||
# https://github.com/diegosouzapw/OmniRoute/releases/download/radar-export-latest/export-omniroute.json
|
||||
name: Radar Export
|
||||
|
||||
on:
|
||||
workflow_dispatch: # o operador pode publicar sob demanda (de qualquer ref)
|
||||
push:
|
||||
branches: [main] # produção: só o catálogo do main clobra o asset estável
|
||||
paths:
|
||||
- open-sse/config/freeModelCatalog.data.ts
|
||||
- open-sse/config/freeModelCatalog.ts
|
||||
- open-sse/config/providerRegistry.ts
|
||||
- open-sse/config/providers/**
|
||||
- scripts/release/radar-export.mjs
|
||||
- .github/workflows/radar-export.yml
|
||||
schedule:
|
||||
- cron: "17 6 * * 1" # semanal (segunda 06:17 UTC): mantém geradoEm/proveniência frescos
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: radar-export-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CI_NODE_VERSION: "24"
|
||||
|
||||
jobs:
|
||||
publish-export:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # gh release upload — clobra o asset estável do export
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false # publish usa GH_TOKEN via gh release, não a credencial do checkout
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Generate catalog export with provenance
|
||||
run: node --import tsx/esm scripts/release/radar-export.mjs "$RUNNER_TEMP/export-omniroute.json"
|
||||
- name: Publish to the stable release asset
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="radar-export-latest"
|
||||
# Cria o release estável na primeira vez; nas seguintes só re-anexa o asset.
|
||||
if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
gh release create "$TAG" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--title "Radar catalog export (rolling)" \
|
||||
--notes "Export estável do catálogo OmniRoute para o Radar. Atualizado automaticamente; NÃO é um release de versão do produto." \
|
||||
--latest=false
|
||||
fi
|
||||
gh release upload "$TAG" "$RUNNER_TEMP/export-omniroute.json" --repo "$GITHUB_REPOSITORY" --clobber
|
||||
83
.gitignore
vendored
83
.gitignore
vendored
@@ -1,8 +1,6 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# project-specific directories
|
||||
/output/
|
||||
.slim/deepwork/
|
||||
.omnivscodeagent/
|
||||
omnirouteCloud/
|
||||
omnirouteSite/
|
||||
@@ -14,13 +12,12 @@ _tasks/
|
||||
.agents/**
|
||||
.claude/**
|
||||
.gemini/**
|
||||
.code-forge/**
|
||||
.config/**
|
||||
.data/**
|
||||
.logs/**
|
||||
.tests/**
|
||||
.coverage/**
|
||||
/coverage/
|
||||
coverage/
|
||||
.dist/**
|
||||
.next/**
|
||||
.build/**
|
||||
@@ -46,7 +43,6 @@ memory-bank/
|
||||
|
||||
# Root-level underscore-prefixed directories (private/draft — never commit)
|
||||
/_*/
|
||||
/_*
|
||||
|
||||
# Draft features documentation (internal only)
|
||||
docs/new-features/
|
||||
@@ -60,6 +56,10 @@ node_modules/
|
||||
*.map
|
||||
.DS_Store
|
||||
|
||||
# Obsidian sync plugin — committed for community distribution
|
||||
!obsidian-plugin/
|
||||
obsidian-plugin/node_modules/
|
||||
|
||||
# Serena AI assistant config (local-only tool, not project code)
|
||||
.serena/
|
||||
|
||||
@@ -71,11 +71,9 @@ yarn-error.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
# Local gitleaks artifacts (do not commit)
|
||||
gitleaks-local.json
|
||||
!.env.example
|
||||
!.env.homolog.example
|
||||
!.env.devin-bridge.example
|
||||
!.env.homolog.example
|
||||
# Provider API keys (never commit)
|
||||
*.api-key
|
||||
.nvidia-api-key
|
||||
@@ -88,7 +86,7 @@ gitleaks-local.json
|
||||
next-env.d.ts
|
||||
|
||||
# data and logs
|
||||
/data/
|
||||
data/
|
||||
.data/
|
||||
logs/*
|
||||
test_output.log
|
||||
@@ -110,7 +108,7 @@ open-sse/test/*
|
||||
test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
/cloud/
|
||||
cloud/
|
||||
.tmp/
|
||||
|
||||
# Security Analysis (standalone project with own git)
|
||||
@@ -124,8 +122,6 @@ app.log
|
||||
deploy.sh
|
||||
docker-compose.minimal.yml
|
||||
|
||||
# Docker Compose override (local-only, never commit)
|
||||
docker-compose.override.yml
|
||||
|
||||
# Backup directories
|
||||
app.__qa_backup/
|
||||
@@ -161,7 +157,6 @@ vscode-extension/
|
||||
|
||||
# Empty/dangling files
|
||||
typescript
|
||||
/MAX
|
||||
|
||||
# Gemini Antigravity agent data
|
||||
.gemini/
|
||||
@@ -205,16 +200,18 @@ scripts/i18n/_pending-keys.json
|
||||
.claude/worktrees/
|
||||
.codegraph/
|
||||
|
||||
# Test executable shims belong in the OS temporary directory, not the repository root
|
||||
/.fakebin-*/
|
||||
|
||||
# Fumadocs generated source
|
||||
.source/
|
||||
/.source/
|
||||
|
||||
# Temporary local worktrees used to build unpublished npm tarballs
|
||||
/.deploy-build-*/
|
||||
|
||||
# AI agent local settings and configs
|
||||
.agents/
|
||||
.antigravitycli/
|
||||
/.claude/
|
||||
.claude/
|
||||
!tests/fixtures/devin-bridge/e2e-workspace/.claude/
|
||||
!tests/fixtures/devin-bridge/e2e-workspace/.claude/**
|
||||
|
||||
# PR Reviews and local feedback files
|
||||
pr_reviews*.json
|
||||
@@ -229,26 +226,6 @@ CODEX-SETUP-PROMPT.md
|
||||
# Quality ratchet — métricas efêmeras (baseline commitado em config/quality/; métricas não)
|
||||
config/quality/quality-metrics.json
|
||||
|
||||
# Electron desktop build output unpacked into the repo root.
|
||||
# `electron-builder` (squirrel-windows target) unpacks the packaged app — the
|
||||
# entire Chromium runtime, ~24k files — directly into the repository root.
|
||||
# Every rule below is ROOT-ANCHORED (leading `/`) on purpose: a bare `locales/`
|
||||
# or `resources/` would also swallow tracked sources such as the CLI
|
||||
# translations in `bin/cli/locales/*.json`.
|
||||
/OmniRoute.exe
|
||||
/Uninstall OmniRoute.exe
|
||||
/uninstallerIcon.ico
|
||||
/locales/
|
||||
/resources/
|
||||
/*.pak
|
||||
/*.dll
|
||||
/icudtl.dat
|
||||
/snapshot_blob.bin
|
||||
/v8_context_snapshot.bin
|
||||
/vk_swiftshader_icd.json
|
||||
/LICENSE.electron.txt
|
||||
/LICENSES.chromium.html
|
||||
|
||||
# Runtime logs (diretório local, nunca versionado)
|
||||
/logs/
|
||||
-home-diegosouzapw-dev-automações-bots-yt-downloader-20260504 .txt
|
||||
@@ -261,19 +238,21 @@ omniroute.md
|
||||
|
||||
# mise configuration
|
||||
mise.toml
|
||||
_artifacts/ # release-green artifacts
|
||||
# release-green artifacts (.gitignore has no inline comments — a trailing
|
||||
# `# ...` becomes part of the pattern, so it must sit on its own line).
|
||||
# Already covered by /_*/ above; kept explicit for discoverability.
|
||||
_artifacts/
|
||||
.claude-flow/
|
||||
|
||||
# ESLint file cache (npm run lint --cache / complexity ratchets)
|
||||
.eslintcache
|
||||
.eslintcache-complexity
|
||||
/.eslintcache-*
|
||||
|
||||
|
||||
# CI/local quality artifacts (eslint-results.json, quality-ratchet.md, etc.)
|
||||
.artifacts/
|
||||
/perf-audit*.md
|
||||
/quality-ratchet/
|
||||
# Isolated Devin bridge workspaces, evidence, and test databases
|
||||
.sandbox/
|
||||
|
||||
# Homologation E2E suite (npm run homolog) — real-environment credentials + report output
|
||||
.env.homolog
|
||||
@@ -281,18 +260,12 @@ tests/homolog/.auth/
|
||||
tests/homolog/ui/.auth/
|
||||
homolog-report/
|
||||
docker-compose.yml.bak
|
||||
.playwright-cli/
|
||||
# Playwright screenshot/log output. Today every artifact happens to land inside
|
||||
# output/**/.playwright-cli/ (covered above), but anything written directly to
|
||||
# output/ would otherwise show up as untracked.
|
||||
/output/
|
||||
|
||||
# _tasks e um repo git SEPARADO (ver AGENTS.md). A linha _tasks/ (com barra) NAO
|
||||
# ignora um SYMLINK chamado _tasks; /_tasks (ancorado) cobre arquivo/symlink/dir na raiz
|
||||
# e impede que um git add -A recapture o symlink (incidente 2026-08-08).
|
||||
# _tasks e um repo git SEPARADO (ver AGENTS.md). _tasks/ (com barra) NAO ignora um
|
||||
# SYMLINK _tasks; /_tasks (ancorado) cobre symlink/dir na raiz (incidente 2026-08-08).
|
||||
/_tasks
|
||||
|
||||
# CLI local cache/state
|
||||
.playwright-cli
|
||||
|
||||
# Ad-hoc test sandboxes (never tracked — may contain local DBs)
|
||||
/.sandbox/
|
||||
.aider*
|
||||
|
||||
# check:install-upgrade work trees (~12 GB, disposable)
|
||||
/.install-upgrade/
|
||||
|
||||
@@ -87,14 +87,9 @@
|
||||
'''latencyP\d{2}Ms''',
|
||||
'''interleaved-thinking-2025-05-14''',
|
||||
# v3.8.49 pre-flight (2026-07-28). Nenhum dos dois e credencial:
|
||||
# - chave de localStorage do banner de patrocinio (#8723; #10200 bumpou v1->v2,
|
||||
# generalizado para -v\d+ no round 3 de base-reds #9985), so um identificador de UI;
|
||||
# - chave de localStorage do banner de patrocinio (#8723), so um identificador de UI;
|
||||
# - x-api-key PUBLICO do Firefly web (documentado em open-sse/utils/publicCreds.ts:207);
|
||||
# as duas ocorrencias sinalizadas estao em COMENTARIOS JSDoc, o runtime le de resolvePublicCred().
|
||||
'''omniroute-kimi-sponsor-banner-dismissed-v\d+''',
|
||||
# CheaperInference sponsor banner localStorage key (upstream #11196 /
|
||||
# eb5797370). Same UI-identifier pattern as the kimi banner above, not a
|
||||
# credential; the generic-api-key rule flags the long hyphenated string.
|
||||
'''omniroute-cheaperinference-sponsor-banner-dismissed-v\d+''',
|
||||
'''omniroute-kimi-sponsor-banner-dismissed-v1''',
|
||||
'''SunbreakWebUI1''',
|
||||
]
|
||||
|
||||
40
.mailmap
40
.mailmap
@@ -1,40 +0,0 @@
|
||||
# .mailmap — canonical author identities for git log/shortlog/blame.
|
||||
#
|
||||
# Why this file exists: between 2026-08-13 and 2026-08-26 this checkout carried a
|
||||
# `git config --local` whose user.name was one contributor's ("Xiangzhe" / @xz-dev)
|
||||
# and whose user.email was ANOTHER contributor's (@backryun). Every commit produced
|
||||
# on this machine in that window was therefore signed with @backryun's address —
|
||||
# 237 commits, all in the -0300 timezone, while @backryun's own work commits from
|
||||
# +0900 and continued normally throughout. The local override was removed on
|
||||
# 2026-08-26; this file repairs the RECORD without rewriting published history
|
||||
# (those commits live on release/v3.8.50 and release/v3.8.51, which other sessions
|
||||
# and open PRs build on — a rewrite would force-push both and orphan the v3.8.50 tag).
|
||||
#
|
||||
# Format: Canonical Name <canonical@email> Commit Name <commit@email>
|
||||
|
||||
# --- Maintainer: several addresses used over the project's life ---
|
||||
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> <diegosouza.pw@gmail.com>
|
||||
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> <diegosouza.pw@outlook.com>
|
||||
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> <diegosouzapw@users.noreply.github.com>
|
||||
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com>
|
||||
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Diego Souza <8016841+diegosouzapw@users.noreply.github.com>
|
||||
|
||||
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> <diego.souza.pw@gmail.com>
|
||||
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> <souzamiriamrodrigues790@gmail.com>
|
||||
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> <diego.souza@cdwasolutions.com.br>
|
||||
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> <diegosouzapw@devbox.local>
|
||||
|
||||
# --- The misattribution window: name Xiangzhe + @backryun's email, from -0300.
|
||||
# These are maintainer/session commits, NOT @backryun's contributions.
|
||||
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Xiangzhe <bakryun0718@proton.me>
|
||||
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Xiangzhe <diegosouza.pw@gmail.com>
|
||||
|
||||
# --- Xiangzhe (@xz-dev) — a distinct contributor; keep their own work intact ---
|
||||
Xiangzhe <32761048+xz-dev@users.noreply.github.com> <xz-dev@users.noreply.github.com>
|
||||
Xiangzhe <32761048+xz-dev@users.noreply.github.com> <xiangzhedev@gmail.com>
|
||||
|
||||
# --- @backryun's own alternate addresses (their real work, kept intact) ---
|
||||
backryun <24198422+backryun@users.noreply.github.com> <bakryun0718@proton.me>
|
||||
backryun <24198422+backryun@users.noreply.github.com> <backryun@daonlab.local>
|
||||
backryun <24198422+backryun@users.noreply.github.com> <busan011@ormbiz.co.kr>
|
||||
backryun <24198422+backryun@users.noreply.github.com> <backryun@users.noreply.github.com>
|
||||
@@ -94,10 +94,6 @@ vscode-extension/
|
||||
/_*/
|
||||
|
||||
# Consistent with .gitignore and .dockerignore
|
||||
.claude/
|
||||
.fakebin-*
|
||||
.eslintcache*
|
||||
_tasks/
|
||||
.DS_Store
|
||||
.idea/
|
||||
.config/
|
||||
|
||||
@@ -14,11 +14,3 @@ open-sse/config/freeModelCatalog.data.ts
|
||||
# Prettier reformats the frontmatter (blank line after ---), which makes the gate
|
||||
# fail on any skill that happens to pass through lint-staged.
|
||||
skills/*/SKILL.md
|
||||
|
||||
# check:changelog-integrity compares release bullets against the base as exact
|
||||
# strings. Prettier normalizes markdown emphasis inside them (*from* -> _from_)
|
||||
# and re-wraps table rows, so any PR that stages CHANGELOG.md would "lose" base
|
||||
# bullets and turn the merge-integrity job red. The changelog is generated and
|
||||
# reconciled by scripts/release/*, which are its formatter of record.
|
||||
CHANGELOG.md
|
||||
docs/i18n/*/CHANGELOG.md
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"clean": "rm -rf dist",
|
||||
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/feature-defaults.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts tests/model-allowlist.test.ts tests/log-level.test.ts tests/effort-tier-variants.test.ts tests/naming.test.ts tests/free-budget-magnitude.test.ts",
|
||||
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/feature-defaults.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts tests/model-allowlist.test.ts",
|
||||
"prepublishOnly": "npm run clean && npm run build && npm test"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,47 +36,39 @@ function fmt(level: LogLevel, msg: string, tag?: string): string {
|
||||
return `${prefix} [${level.toUpperCase()}] ${msg}`;
|
||||
}
|
||||
|
||||
function buildLogger(getLevel: () => LogLevel) {
|
||||
return {
|
||||
error(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(getLevel(), "error")) console.error(fmt("error", msg), ...args);
|
||||
},
|
||||
warn(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(getLevel(), "warn")) console.warn(fmt("warn", msg), ...args);
|
||||
},
|
||||
info(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(getLevel(), "info")) console.warn(fmt("info", msg), ...args);
|
||||
},
|
||||
debug(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(getLevel(), "debug")) console.warn(fmt("debug", msg), ...args);
|
||||
},
|
||||
/** Always emit regardless of level (for critical init breadcrumbs). */
|
||||
always(msg: string, ...args: unknown[]): void {
|
||||
console.warn(TAG, msg, ...args);
|
||||
},
|
||||
export const logger = {
|
||||
error(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(_level, "error")) console.error(fmt("error", msg), ...args);
|
||||
},
|
||||
warn(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(_level, "warn")) console.warn(fmt("warn", msg), ...args);
|
||||
},
|
||||
info(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(_level, "info")) console.warn(fmt("info", msg), ...args);
|
||||
},
|
||||
debug(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(_level, "debug")) console.warn(fmt("debug", msg), ...args);
|
||||
},
|
||||
/** Always emit regardless of level (for critical init breadcrumbs). */
|
||||
always(msg: string, ...args: unknown[]): void {
|
||||
console.warn(TAG, msg, ...args);
|
||||
},
|
||||
|
||||
// ── Tagged child loggers ────────────────────────────────────────────
|
||||
child(tag: string) {
|
||||
return {
|
||||
error: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(getLevel(), "error") && console.error(fmt("error", msg, tag), ...args),
|
||||
warn: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(getLevel(), "warn") && console.warn(fmt("warn", msg, tag), ...args),
|
||||
info: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(getLevel(), "info") && console.warn(fmt("info", msg, tag), ...args),
|
||||
debug: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(getLevel(), "debug") && console.warn(fmt("debug", msg, tag), ...args),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type Logger = ReturnType<typeof buildLogger>;
|
||||
|
||||
/** Create an instance-scoped logger whose level cannot be changed by other plugin instances. */
|
||||
export function createLogger(level: LogLevel): Logger {
|
||||
return buildLogger(() => level);
|
||||
}
|
||||
|
||||
/** Backward-compatible module-global logger controlled by setLogLevel(). */
|
||||
export const logger: Logger = buildLogger(() => _level);
|
||||
// ── Tagged child loggers ──────────────────────────────────────────────
|
||||
child(tag: string) {
|
||||
return {
|
||||
error: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(_level, "error") &&
|
||||
console.error(fmt("error", msg, tag), ...args),
|
||||
warn: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(_level, "warn") &&
|
||||
console.warn(fmt("warn", msg, tag), ...args),
|
||||
info: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(_level, "info") &&
|
||||
console.warn(fmt("info", msg, tag), ...args),
|
||||
debug: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(_level, "debug") &&
|
||||
console.warn(fmt("debug", msg, tag), ...args),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -23,11 +23,27 @@ const ALIAS_UPPER_MAX_CHARS = 5;
|
||||
|
||||
// ── Auto Combo Types ─────────────────────────────────────────────────────
|
||||
|
||||
export type AutoVariant = "coding" | "fast" | "cheap" | "offline" | "smart" | "lkgp";
|
||||
export type AutoVariant =
|
||||
| "coding"
|
||||
| "fast"
|
||||
| "cheap"
|
||||
| "offline"
|
||||
| "smart"
|
||||
| "lkgp";
|
||||
|
||||
export const AUTO_VARIANTS: AutoVariant[] = ["coding", "fast", "cheap", "offline", "smart", "lkgp"];
|
||||
export const AUTO_VARIANTS: AutoVariant[] = [
|
||||
"coding",
|
||||
"fast",
|
||||
"cheap",
|
||||
"offline",
|
||||
"smart",
|
||||
"lkgp",
|
||||
];
|
||||
|
||||
export const AUTO_VARIANT_DESCRIPTIONS: Record<AutoVariant | "default", string> = {
|
||||
export const AUTO_VARIANT_DESCRIPTIONS: Record<
|
||||
AutoVariant | "default",
|
||||
string
|
||||
> = {
|
||||
default: "Best provider via scoring",
|
||||
coding: "Quality-first for code tasks",
|
||||
fast: "Latency-optimized routing",
|
||||
@@ -67,15 +83,24 @@ function titleCaseAlias(alias: string): string {
|
||||
* 3. Neither → undefined.
|
||||
*/
|
||||
export function shortProviderLabel(
|
||||
enrichment: { providerDisplayName?: string; providerAlias?: string } | undefined
|
||||
enrichment:
|
||||
| { providerDisplayName?: string; providerAlias?: string }
|
||||
| undefined,
|
||||
): string | undefined {
|
||||
if (!enrichment) return undefined;
|
||||
const raw =
|
||||
typeof enrichment.providerDisplayName === "string" ? enrichment.providerDisplayName.trim() : "";
|
||||
typeof enrichment.providerDisplayName === "string"
|
||||
? enrichment.providerDisplayName.trim()
|
||||
: "";
|
||||
if (raw.length > 0 && raw.length <= PROVIDER_LABEL_MAX_CHARS) return raw;
|
||||
const alias = typeof enrichment.providerAlias === "string" ? enrichment.providerAlias.trim() : "";
|
||||
const alias =
|
||||
typeof enrichment.providerAlias === "string"
|
||||
? enrichment.providerAlias.trim()
|
||||
: "";
|
||||
if (alias.length > 0) {
|
||||
return alias.length <= ALIAS_UPPER_MAX_CHARS ? alias.toUpperCase() : titleCaseAlias(alias);
|
||||
return alias.length <= ALIAS_UPPER_MAX_CHARS
|
||||
? alias.toUpperCase()
|
||||
: titleCaseAlias(alias);
|
||||
}
|
||||
// Long displayName with no alias to fall back on: keep the long label
|
||||
// rather than dropping the provider prefix entirely.
|
||||
@@ -106,33 +131,10 @@ export function normaliseFreeLabel(name: string): string {
|
||||
|
||||
// ── Free Budget Formatting ────────────────────────────────────────────────
|
||||
|
||||
/** Scales, largest first, so the unit is chosen by descending magnitude. */
|
||||
const TOKEN_UNITS = [
|
||||
[1e9, "B"],
|
||||
[1e6, "M"],
|
||||
[1e3, "K"],
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Format a token count as a short magnitude string: `25M`, `1.5K`, `999`.
|
||||
*
|
||||
* The unit has to be picked from the value that will actually be *printed*,
|
||||
* not from the raw input. `toFixed(1)` rounds to the nearest tenth, so at the
|
||||
* K scale 999_950 and above render as `1000.0` — and by then the M branch has
|
||||
* already been skipped, producing `1000K` for a number that is `1M`. The same
|
||||
* carry turns just under a billion into `1000M`. When the rounded value reaches
|
||||
* the next scale, re-render at that scale instead.
|
||||
*/
|
||||
function fmtTokens(n: number): string {
|
||||
for (let i = 0; i < TOKEN_UNITS.length; i++) {
|
||||
const [scale, suffix] = TOKEN_UNITS[i]!;
|
||||
if (n < scale) continue;
|
||||
const value = Number((n / scale).toFixed(1));
|
||||
// `Number()` also drops a trailing `.0`, which the previous regex did.
|
||||
if (value < 1000 || i === 0) return `${value}${suffix}`;
|
||||
const [nextScale, nextSuffix] = TOKEN_UNITS[i - 1]!;
|
||||
return `${Number((n / nextScale).toFixed(1))}${nextSuffix}`;
|
||||
}
|
||||
if (n >= 1e9) return (n / 1e9).toFixed(1).replace(/\.0$/, "") + "B";
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, "") + "M";
|
||||
if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, "") + "K";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
@@ -182,11 +184,15 @@ export function formatFreeBudget(params: {
|
||||
*/
|
||||
export function formatAutoComboName(
|
||||
variant: AutoVariant | undefined,
|
||||
candidateCount?: number
|
||||
candidateCount?: number,
|
||||
): string {
|
||||
const label = variant ? variant.charAt(0).toUpperCase() + variant.slice(1) : "Default";
|
||||
const label = variant
|
||||
? variant.charAt(0).toUpperCase() + variant.slice(1)
|
||||
: "Default";
|
||||
const count =
|
||||
typeof candidateCount === "number" && candidateCount > 0 ? ` (${candidateCount}p)` : "";
|
||||
typeof candidateCount === "number" && candidateCount > 0
|
||||
? ` (${candidateCount}p)`
|
||||
: "";
|
||||
return `Auto: ${label}${count}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,22 +13,6 @@ import {
|
||||
forceSyncOmniRouteModels,
|
||||
type OmniRouteFetchCache,
|
||||
} from "../src/index.js";
|
||||
import { getLogLevel, setLogLevel } from "../src/logger.js";
|
||||
|
||||
async function captureConsole(run: () => Promise<void>): Promise<string[]> {
|
||||
const lines: string[] = [];
|
||||
const originalError = console.error;
|
||||
const originalWarn = console.warn;
|
||||
console.error = (...args: unknown[]) => lines.push(args.map(String).join(" "));
|
||||
console.warn = (...args: unknown[]) => lines.push(args.map(String).join(" "));
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
test("sanitizeAutoSyncIntervalMs: unset → default 300000", () => {
|
||||
assert.equal(sanitizeAutoSyncIntervalMs(undefined), DEFAULT_AUTO_SYNC_INTERVAL_MS);
|
||||
@@ -51,10 +35,7 @@ test("sanitizeAutoSyncIntervalMs: keeps valid values", () => {
|
||||
|
||||
test("parseOmniRoutePluginOptions accepts autoSyncIntervalMs including 0", () => {
|
||||
assert.equal(parseOmniRoutePluginOptions({ autoSyncIntervalMs: 0 }).autoSyncIntervalMs, 0);
|
||||
assert.equal(
|
||||
parseOmniRoutePluginOptions({ autoSyncIntervalMs: 120_000 }).autoSyncIntervalMs,
|
||||
120_000
|
||||
);
|
||||
assert.equal(parseOmniRoutePluginOptions({ autoSyncIntervalMs: 120_000 }).autoSyncIntervalMs, 120_000);
|
||||
});
|
||||
|
||||
test("resolveOmniRoutePluginOptions defaults autoSyncIntervalMs to 300000", () => {
|
||||
@@ -131,76 +112,6 @@ test("forceSyncOmniRouteModels: fetches, populates cache, returns count", async
|
||||
assert.equal(entry.expiresAt, 1_000_000 + resolved.modelCacheTtl);
|
||||
});
|
||||
|
||||
test("forceSyncOmniRouteModels suppresses successful lifecycle output at error level", async () => {
|
||||
const previousLevel = getLogLevel();
|
||||
const cache: OmniRouteFetchCache = new Map();
|
||||
const resolved = resolveOmniRoutePluginOptions({
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://omniroute.example/v1",
|
||||
features: {
|
||||
autoCombos: false,
|
||||
combos: false,
|
||||
compressionMetadata: false,
|
||||
diskCache: false,
|
||||
enrichment: false,
|
||||
logLevel: "error",
|
||||
usableOnly: false,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
setLogLevel("error");
|
||||
const lines = await captureConsole(async () => {
|
||||
const result = await forceSyncOmniRouteModels({
|
||||
resolved,
|
||||
cache,
|
||||
readAuthJson: async () => ({ omniroute: { type: "api", key: "test-key" } }),
|
||||
fetcher: async () => [{ id: "model-a", object: "model" }],
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
});
|
||||
|
||||
assert.deepEqual(lines, []);
|
||||
} finally {
|
||||
setLogLevel(previousLevel);
|
||||
}
|
||||
});
|
||||
|
||||
test("forceSyncOmniRouteModels preserves successful lifecycle output at info level", async () => {
|
||||
const previousLevel = getLogLevel();
|
||||
const cache: OmniRouteFetchCache = new Map();
|
||||
const resolved = resolveOmniRoutePluginOptions({
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://omniroute.example/v1",
|
||||
features: {
|
||||
autoCombos: false,
|
||||
combos: false,
|
||||
compressionMetadata: false,
|
||||
diskCache: false,
|
||||
enrichment: false,
|
||||
logLevel: "info",
|
||||
usableOnly: false,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
setLogLevel("info");
|
||||
const lines = await captureConsole(async () => {
|
||||
const result = await forceSyncOmniRouteModels({
|
||||
resolved,
|
||||
cache,
|
||||
readAuthJson: async () => ({ omniroute: { type: "api", key: "test-key" } }),
|
||||
fetcher: async () => [{ id: "model-a", object: "model" }],
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
});
|
||||
|
||||
assert.equal(lines.filter((line) => line.includes("force sync ok")).length, 1);
|
||||
} finally {
|
||||
setLogLevel(previousLevel);
|
||||
}
|
||||
});
|
||||
|
||||
test("forceSyncOmniRouteModels: missing auth returns error", async () => {
|
||||
const cache: OmniRouteFetchCache = new Map();
|
||||
const resolved = resolveOmniRoutePluginOptions({
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { mapRawModelToModelV2 } from "../src/index.ts";
|
||||
|
||||
test("mapRawModelToModelV2: bare combo ids stay unprefixed (#10345)", () => {
|
||||
const combo = mapRawModelToModelV2(
|
||||
{
|
||||
id: "gpt-5.6-sol",
|
||||
owned_by: "combo",
|
||||
context_length: 272000,
|
||||
max_output_tokens: 8192,
|
||||
},
|
||||
{ providerId: "omniroute", baseURL: "https://or.example.com/v1" }
|
||||
);
|
||||
assert.equal(combo.id, "gpt-5.6-sol");
|
||||
assert.equal(combo.providerID, "omniroute");
|
||||
|
||||
const slashed = mapRawModelToModelV2(
|
||||
{
|
||||
id: "cx/gpt-5.6-sol",
|
||||
owned_by: "combo",
|
||||
context_length: 272000,
|
||||
},
|
||||
{ providerId: "omniroute", baseURL: "https://or.example.com/v1" }
|
||||
);
|
||||
assert.equal(slashed.id, "cx/gpt-5.6-sol");
|
||||
|
||||
const ordinary = mapRawModelToModelV2(
|
||||
{ id: "claude-primary", context_length: 200000 },
|
||||
{ providerId: "omniroute", baseURL: "https://or.example.com/v1" }
|
||||
);
|
||||
assert.equal(ordinary.id, "omniroute/claude-primary");
|
||||
});
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
createOmniRouteProviderHook,
|
||||
OmniRoutePlugin,
|
||||
resolveOmniRoutePluginOptions,
|
||||
_resetInflightRefresh,
|
||||
type OmniRouteCombosFetcher,
|
||||
type OmniRouteEnrichmentEntry,
|
||||
type OmniRouteEnrichmentFetcher,
|
||||
@@ -48,16 +47,6 @@ import {
|
||||
type OmniRouteStaticProviderEntry,
|
||||
} from "../src/index.js";
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Test isolation: reset the module-level in-flight refresh guard between
|
||||
// tests so a detached refresh from a previous test doesn't leak into the
|
||||
// next one.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.beforeEach(() => {
|
||||
_resetInflightRefresh();
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Fixtures
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -238,7 +227,7 @@ test("config: with valid auth.json + apiKey + baseURL → mutates input.provider
|
||||
// Stripped per-model shape: name + cap flags + modalities + (optional)
|
||||
// cost. OC's SDK static schema accepts only `limit.{context,output}` —
|
||||
// `limit.input` is NOT in the SDK shape and gets dropped silently.
|
||||
const claude = entry.models["claude-sonnet-4-6"];
|
||||
const claude = entry.models["opencode-omniroute/claude-sonnet-4-6"];
|
||||
assert.ok(claude, "claude model surfaced");
|
||||
assert.equal(claude.name, "claude-sonnet-4-6");
|
||||
assert.equal(claude.attachment, true);
|
||||
@@ -259,7 +248,7 @@ test("config: with valid auth.json + apiKey + baseURL → mutates input.provider
|
||||
|
||||
// Combo surfaces under bare key + LCD'd
|
||||
// (gemini's reasoning=false → combo reasoning=false).
|
||||
const combo = entry.models["claude-tier"];
|
||||
const combo = entry.models["omniroute/claude-tier"];
|
||||
assert.ok(combo, "combo surfaced under bare key");
|
||||
assert.equal(combo.name, "Claude Tier");
|
||||
assert.equal(combo.reasoning, false, "LCD: any member reasoning=false → combo reasoning=false");
|
||||
@@ -482,10 +471,10 @@ test("config: combos fetcher throws → emit models-only catalog (no combos in m
|
||||
assert.ok(entry);
|
||||
const ids = Object.keys(entry.models).sort();
|
||||
assert.deepEqual(ids, [
|
||||
"claude-sonnet-4-6",
|
||||
"gemini-3-flash",
|
||||
"opencode-omniroute/claude-sonnet-4-6",
|
||||
"opencode-omniroute/gemini-3-flash",
|
||||
]);
|
||||
assert.equal(entry.models["claude-tier"], undefined, "no combo entry");
|
||||
assert.equal(entry.models["omniroute/claude-tier"], undefined, "no combo entry");
|
||||
assert.ok(
|
||||
logger.entries.some((e) => String(e[0]).includes("/api/combos fetch failed")),
|
||||
"combos-fetch breadcrumb emitted"
|
||||
@@ -734,7 +723,7 @@ test("buildStaticProviderEntry: stripped per-model shape matches sibling @omniro
|
||||
}
|
||||
|
||||
// Sanity: claude entry has all expected stripped fields.
|
||||
const claude = block.models["claude-sonnet-4-6"];
|
||||
const claude = block.models["opencode-omniroute/claude-sonnet-4-6"];
|
||||
assert.equal(typeof claude.name, "string");
|
||||
assert.equal(typeof claude.attachment, "boolean");
|
||||
assert.equal(typeof claude.reasoning, "boolean");
|
||||
@@ -759,39 +748,8 @@ test("buildStaticProviderEntry: hidden combos are excluded", () => {
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
assert.equal(block.models["claude-tier"], undefined);
|
||||
assert.ok(block.models["claude-sonnet-4-6"]);
|
||||
});
|
||||
|
||||
test("buildStaticProviderEntry: expected raw auto twin does not warn and auto combo wins", () => {
|
||||
const resolved = resolveOmniRoutePluginOptions({ providerId: "omniroute" });
|
||||
const warnings: string[] = [];
|
||||
const originalWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => warnings.push(args.map(String).join(" "));
|
||||
|
||||
let block: OmniRouteStaticProviderEntry;
|
||||
try {
|
||||
block = buildStaticProviderEntry(
|
||||
[{ id: "auto/coding" }],
|
||||
[],
|
||||
resolved,
|
||||
"https://or.example/v1",
|
||||
"sk-test",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
[{ id: "auto/coding", name: "Auto Coding", variant: "coding", candidateCount: 5 }]
|
||||
);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
|
||||
assert.equal(Object.keys(block.models).filter((key) => key === "auto/coding").length, 1);
|
||||
assert.equal(block.models["auto/coding"].tool_call, true, "auto-combo entry wins over raw twin");
|
||||
assert.deepEqual(
|
||||
warnings.filter((warning) => warning.includes("collides with an existing model")),
|
||||
[]
|
||||
);
|
||||
assert.equal(block.models["omniroute/claude-tier"], undefined);
|
||||
assert.ok(block.models["opencode-omniroute/claude-sonnet-4-6"]);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -807,7 +765,7 @@ test("buildStaticProviderEntry: emits modalities.input from raw.input_modalities
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
const claude = block.models["claude-sonnet-4-6"];
|
||||
const claude = block.models["opencode-omniroute/claude-sonnet-4-6"];
|
||||
assert.deepEqual(claude.modalities?.input, ["text", "image"]);
|
||||
assert.deepEqual(claude.modalities?.output, ["text"]);
|
||||
});
|
||||
@@ -821,7 +779,7 @@ test("buildStaticProviderEntry: never emits limit.input (OC SDK rejects it)", ()
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
const claude = block.models["claude-sonnet-4-6"];
|
||||
const claude = block.models["opencode-omniroute/claude-sonnet-4-6"];
|
||||
assert.equal((claude.limit as Record<string, unknown>).input, undefined);
|
||||
assert.equal(typeof claude.limit?.context, "number");
|
||||
assert.equal(typeof claude.limit?.output, "number");
|
||||
@@ -849,7 +807,7 @@ test("buildStaticProviderEntry: emits cost when enrichment carries pricing", ()
|
||||
"sk-test",
|
||||
enrichment
|
||||
);
|
||||
const claude = block.models["claude-sonnet-4-6"];
|
||||
const claude = block.models["opencode-omniroute/claude-sonnet-4-6"];
|
||||
assert.equal(claude.cost?.input, 3);
|
||||
assert.equal(claude.cost?.output, 15);
|
||||
assert.equal(claude.cost?.cache_read, 0.3);
|
||||
@@ -870,8 +828,8 @@ test("buildStaticProviderEntry: emits release_date when raw carries it; omits wh
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
assert.equal(block.models["claude-with-date"].release_date, "2026-02-19");
|
||||
assert.equal(block.models["gemini-3-flash"].release_date, undefined);
|
||||
assert.equal(block.models["opencode-omniroute/claude-with-date"].release_date, "2026-02-19");
|
||||
assert.equal(block.models["opencode-omniroute/gemini-3-flash"].release_date, undefined);
|
||||
});
|
||||
|
||||
test("buildStaticProviderEntry: combo modalities = intersection of members (LCD)", () => {
|
||||
@@ -900,7 +858,7 @@ test("buildStaticProviderEntry: combo modalities = intersection of members (LCD)
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
const combo = block.models["mixed-tier"];
|
||||
const combo = block.models["omniroute/mixed-tier"];
|
||||
assert.ok(combo, "combo emitted under slug key");
|
||||
// claude has text+image, text-only has text → intersection drops image.
|
||||
assert.deepEqual(combo.modalities?.input, ["text"]);
|
||||
@@ -1009,10 +967,10 @@ test("config: enrichment fetched + name overlaid on raw-model entries", async ()
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry);
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
assert.equal(entry.models["gemini-3-flash"].name, "Gemini 3 Flash");
|
||||
assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
assert.equal(entry.models["opencode-omniroute/gemini-3-flash"].name, "Gemini 3 Flash");
|
||||
// Combo names still come from /api/combos — enrichment overlay does NOT touch combos.
|
||||
assert.equal(entry.models["claude-tier"].name, "Claude Tier");
|
||||
assert.equal(entry.models["omniroute/claude-tier"].name, "Claude Tier");
|
||||
assert.equal(enrichmentFetcher.callCount(), 1);
|
||||
});
|
||||
|
||||
@@ -1042,7 +1000,7 @@ test("config: features.enrichment=false skips enrichment fetch + keeps raw-id na
|
||||
assert.ok(entry);
|
||||
assert.equal(enrichmentFetcher.callCount(), 0, "enrichment fetch suppressed by feature flag");
|
||||
assert.equal(
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"claude-sonnet-4-6",
|
||||
"raw id retained"
|
||||
);
|
||||
@@ -1069,7 +1027,7 @@ test("config: enrichment fetcher throws → soft-fail (warn + raw-id static cata
|
||||
];
|
||||
assert.ok(entry, "static block still published on enrichment failure");
|
||||
assert.equal(
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"claude-sonnet-4-6",
|
||||
"raw id retained"
|
||||
);
|
||||
@@ -1271,20 +1229,17 @@ test("config: diskCache hydrates stale snapshot when /v1/models throws", async (
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(
|
||||
entry.models["claude-sonnet-4-6"],
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"],
|
||||
"stale snapshot hydrated into static block"
|
||||
);
|
||||
assert.equal(
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"Claude Sonnet 4.6 (cached)",
|
||||
"stale enrichment also reused"
|
||||
);
|
||||
assert.equal(writes, 0, "disk write skipped when live fetch failed");
|
||||
assert.ok(
|
||||
logger.entries.some((e) =>
|
||||
String(e[0]).includes("using stale disk cache") ||
|
||||
String(e[0]).includes("warm startup from disk snapshot")
|
||||
),
|
||||
logger.entries.some((e) => String(e[0]).includes("using stale disk cache")),
|
||||
"disk-cache hydration breadcrumb emitted"
|
||||
);
|
||||
});
|
||||
@@ -1326,7 +1281,7 @@ test("config: cached rawEnrichment from earlier provider hook is reused (no refe
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@@ -1377,12 +1332,12 @@ test("config: providerTag (default-on) prepends '<provider> - ' to enriched raw-
|
||||
];
|
||||
assert.ok(entry);
|
||||
assert.equal(
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"Claude - Claude Sonnet 4.6"
|
||||
);
|
||||
assert.equal(entry.models["gemini-3-flash"].name, "Gemini - Gemini 3 Flash");
|
||||
assert.equal(entry.models["opencode-omniroute/gemini-3-flash"].name, "Gemini - Gemini 3 Flash");
|
||||
// Combos stay untouched — `Combo: ` prefix already conveys multi-upstream.
|
||||
assert.equal(entry.models["claude-tier"].name, "Claude Tier");
|
||||
assert.equal(entry.models["omniroute/claude-tier"].name, "Claude Tier");
|
||||
});
|
||||
|
||||
test("config: providerTag=false suppresses the suffix", async () => {
|
||||
@@ -1409,7 +1364,7 @@ test("config: providerTag=false suppresses the suffix", async () => {
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"Claude Sonnet 4.6",
|
||||
"enriched name kept, provider tag suppressed"
|
||||
);
|
||||
@@ -1441,7 +1396,7 @@ test("config: providerTag falls back to UPPER(alias) when providerDisplayName mi
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "CC - Claude Sonnet 4.6");
|
||||
assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "CC - Claude Sonnet 4.6");
|
||||
});
|
||||
|
||||
test("config: providerTag skipped entirely when neither providerDisplayName nor providerAlias set", async () => {
|
||||
@@ -1468,7 +1423,7 @@ test("config: providerTag skipped entirely when neither providerDisplayName nor
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
});
|
||||
|
||||
test("config: providerTag is idempotent — second hook call doesn't double-suffix", async () => {
|
||||
@@ -1496,7 +1451,7 @@ test("config: providerTag is idempotent — second hook call doesn't double-suff
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(
|
||||
entryA.models["claude-sonnet-4-6"].name,
|
||||
entryA.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"Claude - Claude Sonnet 4.6"
|
||||
);
|
||||
|
||||
@@ -1507,7 +1462,7 @@ test("config: providerTag is idempotent — second hook call doesn't double-suff
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(
|
||||
entryB.models["claude-sonnet-4-6"].name,
|
||||
entryB.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"Claude - Claude Sonnet 4.6"
|
||||
);
|
||||
});
|
||||
@@ -1561,7 +1516,7 @@ test("buildStaticProviderEntry: nested combo-ref context is the bottleneck acros
|
||||
);
|
||||
// Pre-fix: Parent would advertise 200_000 (only raw-big counted).
|
||||
// Post-fix: Parent should advertise 8_000 (TinyCombo bottleneck).
|
||||
const parent = block.models["parent"];
|
||||
const parent = block.models["omniroute/parent"];
|
||||
assert.ok(parent, "Parent combo must be in the static catalog");
|
||||
assert.equal(parent.limit?.context, 8_000);
|
||||
});
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* effort_tiers loop — plugin maps server-declared tiers to ModelV2 variants.
|
||||
* Blind mapping (I3): no owned_by/provider knowledge here — the SERVER gates
|
||||
* eligibility (shouldExposeSyncedEffortVariants). Absence semantics (M3):
|
||||
* no tiers => NO variants key at all (an empty object would also kill
|
||||
* opencode's own fallback for non-tiered models).
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mapRawModelToModelV2, type OmniRouteRawModelEntry } from "../src/index.js";
|
||||
|
||||
const CTX = { providerId: "omniroute", baseURL: "http://127.0.0.1:20128" } as const;
|
||||
|
||||
test("maps declared tiers to reasoningEffort variants", () => {
|
||||
const raw: OmniRouteRawModelEntry = {
|
||||
id: "oc/x-preview-f-free",
|
||||
owned_by: "opencode",
|
||||
capabilities: { reasoning: true, effort_tiers: ["low", "high", "max"] },
|
||||
};
|
||||
const model = mapRawModelToModelV2(raw, { ...CTX });
|
||||
const variants = (model as unknown as Record<string, unknown>).variants as
|
||||
Record<string, Record<string, unknown>> | undefined;
|
||||
assert.ok(variants, "variants key present when tiers declared");
|
||||
assert.deepEqual(Object.keys(variants).sort(), ["high", "low", "max"]);
|
||||
assert.deepEqual(variants.max, { reasoningEffort: "max" });
|
||||
assert.deepEqual(variants.low, { reasoningEffort: "low" });
|
||||
});
|
||||
|
||||
test("no tiers => NO variants key (not an empty object)", () => {
|
||||
const raw: OmniRouteRawModelEntry = {
|
||||
id: "plain-model",
|
||||
capabilities: { reasoning: true },
|
||||
};
|
||||
const model = mapRawModelToModelV2(raw, { ...CTX }) as unknown as Record<string, unknown>;
|
||||
assert.equal("variants" in model, false);
|
||||
});
|
||||
|
||||
test("empty or malformed tiers array => NO variants key", () => {
|
||||
const empty = mapRawModelToModelV2(
|
||||
{ id: "m", capabilities: { effort_tiers: [] } },
|
||||
{ ...CTX }
|
||||
) as unknown as Record<string, unknown>;
|
||||
assert.equal("variants" in empty, false);
|
||||
|
||||
const junk = mapRawModelToModelV2(
|
||||
{ id: "m", capabilities: { effort_tiers: [42, null, "ok"] as unknown as string[] } },
|
||||
{ ...CTX }
|
||||
) as unknown as Record<string, unknown>;
|
||||
const variants = junk.variants as Record<string, Record<string, unknown>> | undefined;
|
||||
assert.deepEqual(Object.keys(variants ?? {}), ["ok"], "non-string tokens dropped");
|
||||
});
|
||||
|
||||
test("static registry entry WITH tiers also gets variants (N1 blast radius)", () => {
|
||||
const raw: OmniRouteRawModelEntry = {
|
||||
id: "some-static-model",
|
||||
owned_by: "registry",
|
||||
capabilities: { effort_tiers: ["minimal", "high"] },
|
||||
};
|
||||
const model = mapRawModelToModelV2(raw, { ...CTX }) as unknown as Record<string, unknown>;
|
||||
const variants = model.variants as Record<string, Record<string, unknown>> | undefined;
|
||||
assert.deepEqual(Object.keys(variants ?? {}).sort(), ["high", "minimal"]);
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* Magnitude-crossover regression for the free-budget suffix
|
||||
* (`formatFreeBudget` -> `fmtTokens` in @omniroute/opencode-plugin/src/naming.ts).
|
||||
*
|
||||
* `fmtTokens` picked its unit from the raw input and then rounded with
|
||||
* `toFixed(1)`. Rounding can carry a value into the next magnitude *after* that
|
||||
* branch has been skipped, so 999_950..999_999 rendered as "1000K" rather than
|
||||
* "1M", and just under a billion rendered as "1000M" rather than "1B".
|
||||
*
|
||||
* These budgets are not always round numbers: `monthlyTokens` is derived from the
|
||||
* remote Radar feed (`tokensPerMonth`) and can be replaced wholesale by a
|
||||
* user-local override, so the crossover band is reachable with real data.
|
||||
*
|
||||
* Kept in its own file rather than added to naming.test.ts so this does not
|
||||
* collide with the coverage being added for `formatFreeBudget` in #11660.
|
||||
*/
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { formatFreeBudget } from "../src/naming.js";
|
||||
|
||||
/** `recurring-daily` is the shortest path from a token count to a rendered suffix. */
|
||||
const daily = (monthlyTokens: number) =>
|
||||
formatFreeBudget({ freeType: "recurring-daily", monthlyTokens }).replace(" tokens/day", "");
|
||||
|
||||
test("fmtTokens: a rounded K value that reaches 1000 is promoted to M", () => {
|
||||
// 999_950 is the true boundary, not 999_999: toFixed(1) rounds to the nearest
|
||||
// tenth, so 999.95K is the first value that carries to "1000.0".
|
||||
assert.equal(daily(999_950), "1M");
|
||||
assert.equal(daily(999_999), "1M");
|
||||
});
|
||||
|
||||
test("fmtTokens: a rounded M value that reaches 1000 is promoted to B", () => {
|
||||
assert.equal(daily(999_950_000), "1B");
|
||||
assert.equal(daily(999_999_999), "1B");
|
||||
});
|
||||
|
||||
test("fmtTokens: values just below the rounding boundary keep their own unit", () => {
|
||||
// The promotion must not fire early — 999.9K still rounds to 999.9, not 1000.
|
||||
assert.equal(daily(999_949), "999.9K");
|
||||
assert.equal(daily(999_499), "999.5K");
|
||||
assert.equal(daily(999_499_999), "999.5M");
|
||||
});
|
||||
|
||||
test("fmtTokens: ordinary magnitudes are unchanged", () => {
|
||||
assert.equal(daily(0), "0");
|
||||
assert.equal(daily(999), "999");
|
||||
assert.equal(daily(1_000), "1K");
|
||||
assert.equal(daily(1_500), "1.5K");
|
||||
assert.equal(daily(1_000_000), "1M");
|
||||
assert.equal(daily(1_500_000), "1.5M");
|
||||
assert.equal(daily(25_000_000), "25M");
|
||||
assert.equal(daily(1_234_567), "1.2M");
|
||||
assert.equal(daily(1_000_000_000), "1B");
|
||||
assert.equal(daily(2_500_000_000), "2.5B");
|
||||
});
|
||||
|
||||
test("fmtTokens: B is the top unit, so a carry there has nowhere to go", () => {
|
||||
// Deliberately pinned: promoting past B would need a unit that does not exist,
|
||||
// so "1000B" is the intended output rather than an oversight.
|
||||
assert.equal(daily(999_999_999_999), "1000B");
|
||||
});
|
||||
|
||||
test("formatFreeBudget: the promotion applies to every token-bearing branch", () => {
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "recurring-monthly", monthlyTokens: 999_999 }),
|
||||
"1M tokens/month"
|
||||
);
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "recurring-credit", creditTokens: 999_999 }),
|
||||
"1M credits"
|
||||
);
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "one-time-initial", creditTokens: 999_999 }),
|
||||
"1M credits (one-time)"
|
||||
);
|
||||
});
|
||||
@@ -1,326 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import type { Config } from "@opencode-ai/plugin";
|
||||
|
||||
import {
|
||||
createOmniRouteConfigHook,
|
||||
createOmniRouteProviderHook,
|
||||
defaultOmniRouteAutoCombosFetcher,
|
||||
OmniRoutePlugin,
|
||||
type OmniRouteRawModelEntry,
|
||||
} from "../src/index.js";
|
||||
import { createLogger, getLogLevel, logger, setLogLevel, type LogLevel } from "../src/logger.js";
|
||||
|
||||
type ConsoleMethod = "error" | "info" | "log" | "warn";
|
||||
type ConsoleEntries = Record<ConsoleMethod, unknown[][]>;
|
||||
|
||||
const fakeInput = {} as Parameters<typeof OmniRoutePlugin>[0];
|
||||
const consoleMethods: ConsoleMethod[] = ["error", "info", "log", "warn"];
|
||||
|
||||
async function captureConsole(run: () => Promise<void>): Promise<ConsoleEntries> {
|
||||
const entries: ConsoleEntries = { error: [], info: [], log: [], warn: [] };
|
||||
const originals = Object.fromEntries(
|
||||
consoleMethods.map((method) => [method, console[method]])
|
||||
) as Record<ConsoleMethod, typeof console.warn>;
|
||||
|
||||
for (const method of consoleMethods) {
|
||||
console[method] = (...args: unknown[]) => {
|
||||
entries[method].push(args);
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
for (const method of consoleMethods) console[method] = originals[method];
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function rendered(entries: ConsoleEntries): string[] {
|
||||
return consoleMethods.flatMap((method) =>
|
||||
entries[method].map((args) => args.map((arg) => String(arg)).join(" "))
|
||||
);
|
||||
}
|
||||
|
||||
async function capturePluginLifecycle(args: {
|
||||
level: LogLevel;
|
||||
autoSyncIntervalMs: number;
|
||||
invokeConfig?: boolean;
|
||||
}): Promise<string[]> {
|
||||
const previousDataDir = process.env.OPENCODE_DATA_DIR;
|
||||
const previousLevel = getLogLevel();
|
||||
const dataDir = await mkdtemp(join(tmpdir(), "omniroute-log-level-"));
|
||||
process.env.OPENCODE_DATA_DIR = dataDir;
|
||||
|
||||
try {
|
||||
const entries = await captureConsole(async () => {
|
||||
const hooks = await OmniRoutePlugin(fakeInput, {
|
||||
autoSyncIntervalMs: args.autoSyncIntervalMs,
|
||||
features: { logLevel: args.level },
|
||||
});
|
||||
if (args.invokeConfig) {
|
||||
assert.equal(typeof hooks.config, "function");
|
||||
await hooks.config!({} as Config);
|
||||
}
|
||||
});
|
||||
return rendered(entries);
|
||||
} finally {
|
||||
setLogLevel(previousLevel);
|
||||
if (previousDataDir === undefined) delete process.env.OPENCODE_DATA_DIR;
|
||||
else process.env.OPENCODE_DATA_DIR = previousDataDir;
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("logLevel error suppresses the initialization banner", async () => {
|
||||
const lines = await capturePluginLifecycle({ level: "error", autoSyncIntervalMs: 0 });
|
||||
|
||||
assert.equal(lines.filter((line) => line.includes("initialized")).length, 0);
|
||||
});
|
||||
|
||||
test("logLevel error suppresses the auto-sync enabled lifecycle message", async () => {
|
||||
const lines = await capturePluginLifecycle({ level: "error", autoSyncIntervalMs: 60_000 });
|
||||
|
||||
assert.equal(lines.filter((line) => line.includes("auto-sync enabled")).length, 0);
|
||||
});
|
||||
|
||||
test("logLevel error suppresses factory config-shim diagnostics", async () => {
|
||||
const lines = await capturePluginLifecycle({
|
||||
level: "error",
|
||||
autoSyncIntervalMs: 0,
|
||||
invokeConfig: true,
|
||||
});
|
||||
|
||||
assert.equal(lines.filter((line) => line.includes("config shim skipped")).length, 0);
|
||||
});
|
||||
|
||||
test("logLevel debug preserves startup and config-shim diagnostics", async () => {
|
||||
const lines = await capturePluginLifecycle({
|
||||
level: "debug",
|
||||
autoSyncIntervalMs: 60_000,
|
||||
invokeConfig: true,
|
||||
});
|
||||
|
||||
assert.ok(
|
||||
lines.some((line) => line.includes("initialized")),
|
||||
"initialization banner emitted"
|
||||
);
|
||||
assert.ok(
|
||||
lines.some((line) => line.includes("auto-sync enabled")),
|
||||
"auto-sync message emitted"
|
||||
);
|
||||
assert.ok(
|
||||
lines.some((line) => line.includes("config shim skipped")),
|
||||
"config breadcrumb emitted"
|
||||
);
|
||||
});
|
||||
|
||||
test("debug instance retains config diagnostics after an error instance is created", async () => {
|
||||
const lines = rendered(
|
||||
await captureConsole(async () => {
|
||||
const debugHooks = await OmniRoutePlugin(fakeInput, {
|
||||
autoSyncIntervalMs: 0,
|
||||
features: { logLevel: "debug" },
|
||||
});
|
||||
await OmniRoutePlugin(fakeInput, {
|
||||
autoSyncIntervalMs: 0,
|
||||
features: { logLevel: "error" },
|
||||
});
|
||||
await debugHooks.config!({} as Config);
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(lines.filter((line) => line.includes("config shim skipped")).length, 1);
|
||||
});
|
||||
|
||||
test("error instance keeps config diagnostics suppressed after a debug instance is created", async () => {
|
||||
const lines = rendered(
|
||||
await captureConsole(async () => {
|
||||
const errorHooks = await OmniRoutePlugin(fakeInput, {
|
||||
autoSyncIntervalMs: 0,
|
||||
features: { logLevel: "error" },
|
||||
});
|
||||
await OmniRoutePlugin(fakeInput, {
|
||||
autoSyncIntervalMs: 0,
|
||||
features: { logLevel: "debug" },
|
||||
});
|
||||
await errorHooks.config!({} as Config);
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(lines.filter((line) => line.includes("config shim skipped")).length, 0);
|
||||
});
|
||||
|
||||
test("error-level config fetch failures remain visible as concise injected-logger messages", async () => {
|
||||
const entries: unknown[][] = [];
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{
|
||||
baseURL: "https://omniroute.example/v1",
|
||||
features: {
|
||||
autoCombos: false,
|
||||
diskCache: false,
|
||||
enrichment: false,
|
||||
logLevel: "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
readAuthJson: async () => ({
|
||||
"opencode-omniroute": { type: "api", key: "test-key" },
|
||||
}),
|
||||
fetcher: async () => {
|
||||
throw new Error("models unavailable");
|
||||
},
|
||||
combosFetcher: async () => {
|
||||
throw new Error("combos unavailable");
|
||||
},
|
||||
logger: {
|
||||
warn: (...args: unknown[]) => {
|
||||
entries.push(args);
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await hook({} as Config);
|
||||
|
||||
assert.equal(entries.length, 2, "both genuine fetch failures remain visible");
|
||||
assert.deepEqual(
|
||||
entries.map((args) => args.length),
|
||||
[1, 1],
|
||||
"each failure is emitted as one concise argument"
|
||||
);
|
||||
const lines = entries.map(([message]) => String(message));
|
||||
assert.ok(
|
||||
lines.some((line) => line.includes("/v1/models") && line.includes("models unavailable"))
|
||||
);
|
||||
assert.ok(
|
||||
lines.some((line) => line.includes("/api/combos") && line.includes("combos unavailable"))
|
||||
);
|
||||
assert.equal(
|
||||
entries.flat().some((arg) => arg instanceof Error),
|
||||
false,
|
||||
"no raw Error object emitted"
|
||||
);
|
||||
});
|
||||
|
||||
test("logger error output remains visible at error level", async () => {
|
||||
const previousLevel = getLogLevel();
|
||||
try {
|
||||
setLogLevel("error");
|
||||
const lines = rendered(
|
||||
await captureConsole(async () => {
|
||||
logger.error("genuine startup failure");
|
||||
})
|
||||
);
|
||||
assert.ok(lines.some((line) => line.includes("genuine startup failure")));
|
||||
} finally {
|
||||
setLogLevel(previousLevel);
|
||||
}
|
||||
});
|
||||
|
||||
const MINIMAL_MODELS: OmniRouteRawModelEntry[] = [
|
||||
{
|
||||
id: "claude-primary",
|
||||
object: "model",
|
||||
owned_by: "combo",
|
||||
capabilities: { tool_calling: true, reasoning: true, vision: true, thinking: true },
|
||||
context_length: 200000,
|
||||
max_output_tokens: 64000,
|
||||
input_modalities: ["text", "image"],
|
||||
output_modalities: ["text"],
|
||||
},
|
||||
];
|
||||
|
||||
function providerHookWithLevel(level: LogLevel, baseURL?: string) {
|
||||
return createOmniRouteProviderHook(
|
||||
{
|
||||
baseURL,
|
||||
features: { autoCombos: false, enrichment: false, logLevel: level },
|
||||
},
|
||||
{
|
||||
fetcher: async () => MINIMAL_MODELS,
|
||||
combosFetcher: async () => {
|
||||
throw new Error("combos boom");
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
test("logLevel error suppresses provider.models() fallback warnings and the catalog-refresh breadcrumb", async () => {
|
||||
const hook = providerHookWithLevel("error", "https://or.example.com/v1");
|
||||
const lines = rendered(
|
||||
await captureConsole(async () => {
|
||||
await hook.models!({} as never, { auth: { type: "api", key: "sk-x" } as never });
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(lines.filter((line) => line.includes("combos fetch failed")).length, 0);
|
||||
assert.equal(lines.filter((line) => line.includes("catalog refreshed")).length, 0);
|
||||
});
|
||||
|
||||
test("logLevel debug preserves the provider.models() catalog-refresh breadcrumb", async () => {
|
||||
const hook = providerHookWithLevel("debug", "https://or.example.com/v1");
|
||||
const lines = rendered(
|
||||
await captureConsole(async () => {
|
||||
await hook.models!({} as never, { auth: { type: "api", key: "sk-x" } as never });
|
||||
})
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
lines.some((line) => line.includes("catalog refreshed")),
|
||||
"catalog-refresh breadcrumb emitted at debug level"
|
||||
);
|
||||
});
|
||||
|
||||
test("no baseURL resolvable stays visible at error level", async () => {
|
||||
const hook = providerHookWithLevel("error");
|
||||
const lines = rendered(
|
||||
await captureConsole(async () => {
|
||||
await hook.models!({} as never, { auth: { type: "api", key: "sk-x" } as never });
|
||||
})
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
lines.some((line) => line.includes("no baseURL resolvable")),
|
||||
"genuine misconfiguration error remains visible at error level"
|
||||
);
|
||||
});
|
||||
|
||||
test("default auto-combos fetcher 404 warning respects the threaded logger level", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
(globalThis as { fetch: unknown }).fetch = (async () => ({
|
||||
status: 404,
|
||||
ok: false,
|
||||
})) as typeof fetch;
|
||||
try {
|
||||
const silent = await captureConsole(async () => {
|
||||
await defaultOmniRouteAutoCombosFetcher(
|
||||
"https://or.example.com/v1",
|
||||
"sk-x",
|
||||
5_000,
|
||||
createLogger("error")
|
||||
);
|
||||
});
|
||||
assert.equal(rendered(silent).length, 0, "404 warning suppressed at error level");
|
||||
|
||||
const loud = await captureConsole(async () => {
|
||||
await defaultOmniRouteAutoCombosFetcher(
|
||||
"https://or.example.com/v1",
|
||||
"sk-x",
|
||||
5_000,
|
||||
createLogger("warn")
|
||||
);
|
||||
});
|
||||
assert.ok(
|
||||
rendered(loud).some((line) => line.includes("/api/combos/auto not available")),
|
||||
"404 warning emitted at warn level"
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* Tests for `formatFreeBudget` (@omniroute/opencode-plugin/src/naming.ts):
|
||||
* formats a free-tier model's budget info into a short human-readable
|
||||
* suffix, branching on `freeType`.
|
||||
*/
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { formatFreeBudget, type FreeModelFreeType } from "../src/naming.js";
|
||||
|
||||
test("formatFreeBudget: recurring-daily formats tokens/day", () => {
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "recurring-daily", monthlyTokens: 25_000_000 }),
|
||||
"25M tokens/day"
|
||||
);
|
||||
});
|
||||
|
||||
test("formatFreeBudget: recurring-monthly formats tokens/month", () => {
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "recurring-monthly", monthlyTokens: 1_000_000 }),
|
||||
"1M tokens/month"
|
||||
);
|
||||
});
|
||||
|
||||
test("formatFreeBudget: recurring-credit formats credits", () => {
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "recurring-credit", creditTokens: 10_000_000 }),
|
||||
"10M credits"
|
||||
);
|
||||
});
|
||||
|
||||
test("formatFreeBudget: one-time-initial formats credits with (one-time) suffix", () => {
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "one-time-initial", creditTokens: 1_000_000 }),
|
||||
"1M credits (one-time)"
|
||||
);
|
||||
});
|
||||
|
||||
test("formatFreeBudget: keyless has no token/credit args", () => {
|
||||
assert.equal(formatFreeBudget({ freeType: "keyless" }), "(keyless)");
|
||||
});
|
||||
|
||||
test("formatFreeBudget: discontinued has no token/credit args", () => {
|
||||
assert.equal(formatFreeBudget({ freeType: "discontinued" }), "(discontinued)");
|
||||
});
|
||||
|
||||
test("formatFreeBudget: missing token/credit counts default to 0", () => {
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "recurring-daily" }),
|
||||
"0 tokens/day"
|
||||
);
|
||||
});
|
||||
|
||||
test("formatFreeBudget: unrecognised freeType falls through to the default branch", () => {
|
||||
// `freeType` is populated from catalog data at runtime, so a value the
|
||||
// build doesn't know about is reachable even though TypeScript treats the
|
||||
// `default:` arm as dead code for a well-typed caller.
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "some-future-type" as FreeModelFreeType }),
|
||||
""
|
||||
);
|
||||
});
|
||||
|
||||
test("formatFreeBudget: sub-1K token count is not abbreviated", () => {
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "recurring-daily", monthlyTokens: 500 }),
|
||||
"500 tokens/day"
|
||||
);
|
||||
});
|
||||
|
||||
test("formatFreeBudget: the 999_999 rounding wart is fixed — promotes to 1M", () => {
|
||||
// `toFixed(1)` rounds 999999/1e3 up to "1000.0" before the `>= 1e6` threshold
|
||||
// check has a chance to apply. fmtTokens now promotes a rounded-up "1000" in
|
||||
// any unit to the next unit up, so this correctly reads "1M" instead of the
|
||||
// old "1000K" wart.
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "recurring-daily", monthlyTokens: 999_999 }),
|
||||
"1M tokens/day"
|
||||
);
|
||||
});
|
||||
@@ -111,9 +111,7 @@ test("#6859: createOmniRouteProviderHook end-to-end — catalog keys/providerID
|
||||
// `opencode-omniroute`. Confirmed against the issue's own curl repro
|
||||
// (`model: "opencode-omniroute/hermes-smart-stack"` → "No active
|
||||
// credentials for provider: opencode-omniroute").
|
||||
// #9175 tightened this further: OC's `getModel` looks models up by BARE id,
|
||||
// so combo dict keys now carry NO prefix at all (not even `omniroute/`).
|
||||
test("#7976/#9175: buildStaticProviderEntry keys combos by bare slug (no prefix at all — never the OC-gate providerId)", () => {
|
||||
test("#7976: buildStaticProviderEntry keys bare-slug combo ids with the unprefixed omnirouteProviderId (no double OC-gate prefix)", () => {
|
||||
const resolved = resolveOmniRoutePluginOptions({ providerId: "omniroute" });
|
||||
assert.equal(resolved.providerId, "opencode-omniroute");
|
||||
assert.equal(resolved.omnirouteProviderId, "omniroute");
|
||||
@@ -133,7 +131,7 @@ test("#7976/#9175: buildStaticProviderEntry keys combos by bare slug (no prefix
|
||||
"sk-test"
|
||||
);
|
||||
|
||||
assert.deepEqual(Object.keys(block.models), ["hermes-smart-stack"]);
|
||||
assert.deepEqual(Object.keys(block.models), ["omniroute/hermes-smart-stack"]);
|
||||
assert.equal(
|
||||
block.models["opencode-omniroute/hermes-smart-stack"],
|
||||
undefined,
|
||||
|
||||
@@ -104,10 +104,7 @@ test("models: extracts apiKey from ctx.auth (type=api) and calls fetcher with it
|
||||
// #6859: dynamic-hook catalog keys use the unprefixed omnirouteProviderId
|
||||
// ("omniroute"), not the OC-gate-prefixed hook.id ("opencode-omniroute") —
|
||||
// that prefix must never leak into anything OmniRoute's server parses.
|
||||
// #10345/#10821: bare combo ids (owned_by: "combo") stay unprefixed —
|
||||
// OpenCode looks up `-m <plugin>/<combo>` as model id `<combo>` under the
|
||||
// plugin provider, so `claude-primary` here carries no provider prefix.
|
||||
assert.ok(out["claude-primary"]);
|
||||
assert.ok(out["omniroute/claude-primary"]);
|
||||
});
|
||||
|
||||
test("models: returns {} when ctx.auth is null/undefined/wrong-type/empty-key", async () => {
|
||||
@@ -162,15 +159,11 @@ test("models: maps a sample /v1/models entry to ModelV2 (sanity)", async () => {
|
||||
// omnirouteProviderId ("omniroute") — the OC-gate prefix ("opencode-")
|
||||
// must stay OC-internal (hook.id / AuthHook.provider) and never leak into
|
||||
// anything OmniRoute's own server parses for credential lookup.
|
||||
// #10345/#10821: bare **combo** ids (owned_by: "combo", e.g.
|
||||
// "claude-primary") must also stay unprefixed — OpenCode looks up
|
||||
// `-m <plugin>/<combo>` as model id `<combo>` under the plugin provider.
|
||||
const claude = out["claude-primary"];
|
||||
const claude = out["omniroute/claude-primary"];
|
||||
assert.ok(claude, "claude-primary present");
|
||||
// `mapRawModelToModelV2` leaves bare combo ids unprefixed (see
|
||||
// src/index.ts mapRawModelToModelV2) so OC's `-m <plugin>/<combo>` lookup
|
||||
// resolves the combo id directly.
|
||||
assert.equal(claude.id, "claude-primary");
|
||||
// `mapRawModelToModelV2` stamps the provider prefix on the id so OC's
|
||||
// static-catalog reader resolves `(providerID, modelID)` from the key.
|
||||
assert.equal(claude.id, "omniroute/claude-primary");
|
||||
assert.equal(claude.name, "claude-primary");
|
||||
assert.equal(claude.providerID, "omniroute");
|
||||
assert.equal(claude.api.id, "openai-compatible");
|
||||
|
||||
@@ -1,827 +0,0 @@
|
||||
/**
|
||||
* Warm-startup + parallel-refresh tests for the opencode-plugin config shim.
|
||||
*
|
||||
* Covers `createOmniRouteConfigHook(opts, deps)`:
|
||||
* - (a) Warm startup: cache miss + matching snapshot → provider block
|
||||
* populated from snapshot data (not live fetch data).
|
||||
* - (b) Fingerprint mismatch: reader returns undefined → no warm publish,
|
||||
* falls through to awaited fetch (cold-start behavior).
|
||||
* - (c) Successful parallel refresh: all fetchers resolve → cache updated,
|
||||
* disk snapshot written.
|
||||
* - (d) Failed refresh keeps the snapshot: warm-served + models fetcher
|
||||
* rejects → no disk overwrite, block stays at warm-snapshot shape.
|
||||
* - (e) Parallelism: all six fetchers start concurrently (not sequential).
|
||||
* - (f) Soft-fail parity under Promise.allSettled: per-endpoint
|
||||
* fallbacks + logger.warn breadcrumbs preserved.
|
||||
* - (g) No double-refresh: concurrent hook invocations on the same cacheKey
|
||||
* trigger only one refresh (in-flight guard).
|
||||
* - (h) features.diskCache: false disables the warm read entirely.
|
||||
*
|
||||
* Mocking strategy: every dependency is DI-injected at hook construction
|
||||
* (same pattern as config-shim.test.ts). No global monkey-patching.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { Config } from "@opencode-ai/plugin";
|
||||
|
||||
import {
|
||||
createOmniRouteConfigHook,
|
||||
resolveOmniRoutePluginOptions,
|
||||
_resetInflightRefresh,
|
||||
type OmniRouteAutoCombosFetcher,
|
||||
type OmniRouteCombosFetcher,
|
||||
type OmniRouteCompressionMetaFetcher,
|
||||
type OmniRouteEnrichmentEntry,
|
||||
type OmniRouteEnrichmentFetcher,
|
||||
type OmniRouteEnrichmentMap,
|
||||
type OmniRouteFetchCache,
|
||||
type OmniRouteModelsFetcher,
|
||||
type OmniRouteProviderConnection,
|
||||
type OmniRouteProvidersFetcher,
|
||||
type OmniRouteRawAutoCombo,
|
||||
type OmniRouteRawCombo,
|
||||
type OmniRouteRawModelEntry,
|
||||
type OmniRouteReadAuthJson,
|
||||
type OmniRouteStaticProviderEntry,
|
||||
type OmniRouteDiskSnapshotReader,
|
||||
type OmniRouteDiskSnapshotWriter,
|
||||
type OmniRouteCompressionCombo,
|
||||
} from "../src/index.js";
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Test isolation: reset the module-level in-flight refresh guard between
|
||||
// tests so a detached refresh from a previous test doesn't leak into the
|
||||
// next one (same cacheKey, different cache instance).
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.beforeEach(() => {
|
||||
_resetInflightRefresh();
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Fixtures
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const MODEL_CLAUDE: OmniRouteRawModelEntry = {
|
||||
id: "claude-sonnet-4-6",
|
||||
capabilities: {
|
||||
tool_calling: true,
|
||||
reasoning: true,
|
||||
vision: true,
|
||||
thinking: false,
|
||||
temperature: true,
|
||||
},
|
||||
context_length: 200_000,
|
||||
max_output_tokens: 64_000,
|
||||
max_input_tokens: 180_000,
|
||||
input_modalities: ["text", "image"],
|
||||
output_modalities: ["text"],
|
||||
};
|
||||
|
||||
const MODEL_GEMINI: OmniRouteRawModelEntry = {
|
||||
id: "gemini-3-flash",
|
||||
capabilities: { tool_calling: true, reasoning: false, vision: true, thinking: false },
|
||||
context_length: 1_000_000,
|
||||
max_output_tokens: 8_192,
|
||||
input_modalities: ["text", "image"],
|
||||
output_modalities: ["text"],
|
||||
};
|
||||
|
||||
const COMBO_CLAUDE_TIER: OmniRouteRawCombo = {
|
||||
id: "combo-claude-tier",
|
||||
name: "Claude Tier",
|
||||
models: [
|
||||
{ id: "s1", kind: "model", model: "claude-sonnet-4-6", weight: 100 },
|
||||
{ id: "s2", kind: "model", model: "gemini-3-flash", weight: 50 },
|
||||
],
|
||||
};
|
||||
|
||||
const AUTO_COMBO: OmniRouteRawAutoCombo = {
|
||||
id: "auto",
|
||||
name: "Auto",
|
||||
};
|
||||
|
||||
const COMPRESSION_COMBO: OmniRouteCompressionCombo = {
|
||||
id: "ctx-combo-1",
|
||||
name: "Context Combo",
|
||||
pipeline: "gzip",
|
||||
};
|
||||
|
||||
const CONNECTION_CLAUDE: OmniRouteProviderConnection = {
|
||||
id: "c1",
|
||||
provider: "claude",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
};
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// DI stub helpers
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function stubReadAuthJson(
|
||||
value: Record<string, unknown> | undefined | null
|
||||
): OmniRouteReadAuthJson {
|
||||
return async () => value as never;
|
||||
}
|
||||
|
||||
function immediateFetcher<T extends (...args: unknown[]) => Promise<unknown>>(
|
||||
payload: ReturnType<T> extends Promise<infer U> ? U : never
|
||||
): T & { callCount: () => number; startedAt: () => number | undefined } {
|
||||
let n = 0;
|
||||
let start: number | undefined;
|
||||
const f = async (..._args: unknown[]) => {
|
||||
start = Date.now();
|
||||
n++;
|
||||
return payload;
|
||||
};
|
||||
return Object.assign(f as T, { callCount: () => n, startedAt: () => start });
|
||||
}
|
||||
|
||||
function throwingFetcher<T extends (...args: unknown[]) => Promise<unknown>>(
|
||||
msg = "ECONNREFUSED"
|
||||
): T & { callCount: () => number } {
|
||||
let n = 0;
|
||||
const f = async (..._args: unknown[]) => {
|
||||
n++;
|
||||
throw new Error(msg);
|
||||
};
|
||||
return Object.assign(f as T, { callCount: () => n });
|
||||
}
|
||||
|
||||
interface WarnCapture {
|
||||
warn: (...args: unknown[]) => void;
|
||||
entries: unknown[][];
|
||||
}
|
||||
|
||||
function captureWarn(): WarnCapture {
|
||||
const entries: unknown[][] = [];
|
||||
return {
|
||||
warn: (...args: unknown[]) => {
|
||||
entries.push(args);
|
||||
},
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
||||
function makeInput(initialProvider: Record<string, unknown> = {}): Config {
|
||||
return { provider: initialProvider } as unknown as Config;
|
||||
}
|
||||
|
||||
/** Build a valid auth.json stub for the default providerId. */
|
||||
function authStub() {
|
||||
return stubReadAuthJson({
|
||||
"opencode-omniroute": {
|
||||
type: "api",
|
||||
key: "sk-test",
|
||||
baseURL: "https://or.example.com/v1",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// (a) Warm startup: cache miss + matching snapshot → provider block populated
|
||||
// from snapshot data (not live fetch data)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("warm-startup: snapshot data used when snapshot is present", async () => {
|
||||
// Live fetch returns MODEL_CLAUDE, but snapshot has MODEL_GEMINI.
|
||||
// With warm startup, the block should contain the snapshot data.
|
||||
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
|
||||
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
|
||||
const autoCombosFetcher = immediateFetcher<OmniRouteAutoCombosFetcher>([]);
|
||||
const enrichmentFetcher = immediateFetcher<OmniRouteEnrichmentFetcher>(new Map());
|
||||
const compressionMetaFetcher = immediateFetcher<OmniRouteCompressionMetaFetcher>([]);
|
||||
const providersFetcher = immediateFetcher<OmniRouteProvidersFetcher>([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
const snapshot: Omit<import("../src/index.js").OmniRouteFetchCacheEntry, "expiresAt"> = {
|
||||
rawModels: [MODEL_GEMINI],
|
||||
rawCombos: [],
|
||||
rawAutoCombos: [],
|
||||
rawEnrichment: new Map(),
|
||||
rawCompressionCombos: [],
|
||||
rawConnections: [],
|
||||
};
|
||||
|
||||
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => snapshot;
|
||||
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute" },
|
||||
{
|
||||
readAuthJson: authStub(),
|
||||
fetcher,
|
||||
combosFetcher,
|
||||
autoCombosFetcher,
|
||||
enrichmentFetcher,
|
||||
compressionMetaFetcher,
|
||||
providersFetcher,
|
||||
diskSnapshotReader,
|
||||
diskSnapshotWriter,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const provider = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider;
|
||||
const entry = provider["opencode-omniroute"];
|
||||
assert.ok(entry, "provider entry published");
|
||||
|
||||
// With warm startup, the block should contain the snapshot data (GEMINI),
|
||||
// not the live fetch data (CLAUDE). This is the key assertion: the warm
|
||||
// snapshot is served first, and the live refresh updates the cache in the
|
||||
// background. On the next hook invocation, the cache will have the fresh data.
|
||||
const hasGemini = entry.models["opencode-omniroute/gemini-3-flash"] !== undefined;
|
||||
const hasClaude = entry.models["opencode-omniroute/claude-sonnet-4-6"] !== undefined;
|
||||
assert.ok(
|
||||
hasGemini || hasClaude,
|
||||
"provider block has at least one model"
|
||||
);
|
||||
|
||||
// The warm-startup breadcrumb should be emitted.
|
||||
assert.ok(
|
||||
logger.entries.some((e) =>
|
||||
String(e[0]).includes("warm startup from disk snapshot")
|
||||
),
|
||||
"warm-startup breadcrumb emitted"
|
||||
);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// (b) Fingerprint mismatch: reader returns undefined → no warm publish,
|
||||
// falls through to awaited fetch
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("warm-startup: fingerprint mismatch → no warm publish, awaited fetch", async () => {
|
||||
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
|
||||
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
// Reader returns undefined → fingerprint mismatch or missing snapshot.
|
||||
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
|
||||
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute" },
|
||||
{
|
||||
readAuthJson: authStub(),
|
||||
fetcher,
|
||||
combosFetcher,
|
||||
diskSnapshotReader,
|
||||
diskSnapshotWriter,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry, "provider entry published from live fetch");
|
||||
// Live fetch data, not snapshot data.
|
||||
assert.ok(
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"],
|
||||
"live fetch model present"
|
||||
);
|
||||
assert.equal(fetcher.callCount(), 1, "fetcher was called (awaited cold path)");
|
||||
// No warm-startup breadcrumb when no snapshot.
|
||||
assert.ok(
|
||||
!logger.entries.some((e) =>
|
||||
String(e[0]).includes("warm startup from disk snapshot")
|
||||
),
|
||||
"no warm-startup breadcrumb when no snapshot"
|
||||
);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// (c) Successful parallel refresh: all fetchers resolve → cache updated,
|
||||
// disk snapshot written, block re-published with fresh data
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("warm-startup: parallel refresh updates cache + writes snapshot", async () => {
|
||||
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
|
||||
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([COMBO_CLAUDE_TIER]);
|
||||
const autoCombosFetcher = immediateFetcher<OmniRouteAutoCombosFetcher>([AUTO_COMBO]);
|
||||
const enrichmentFetcher = immediateFetcher<OmniRouteEnrichmentFetcher>(
|
||||
new Map<string, OmniRouteEnrichmentEntry>([
|
||||
["claude-sonnet-4-6", { name: "Claude Sonnet 4.6" }],
|
||||
])
|
||||
);
|
||||
const compressionMetaFetcher = immediateFetcher<OmniRouteCompressionMetaFetcher>([
|
||||
COMPRESSION_COMBO,
|
||||
]);
|
||||
const providersFetcher = immediateFetcher<OmniRouteProvidersFetcher>([CONNECTION_CLAUDE]);
|
||||
const logger = captureWarn();
|
||||
|
||||
const snapshot: Omit<import("../src/index.js").OmniRouteFetchCacheEntry, "expiresAt"> = {
|
||||
rawModels: [MODEL_GEMINI],
|
||||
rawCombos: [],
|
||||
rawAutoCombos: [],
|
||||
rawEnrichment: new Map(),
|
||||
rawCompressionCombos: [],
|
||||
rawConnections: [],
|
||||
};
|
||||
|
||||
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => snapshot;
|
||||
let snapshotWrites = 0;
|
||||
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {
|
||||
snapshotWrites++;
|
||||
};
|
||||
|
||||
const sharedCache: OmniRouteFetchCache = new Map();
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute", modelCacheTtl: 60_000 },
|
||||
{
|
||||
readAuthJson: authStub(),
|
||||
fetcher,
|
||||
combosFetcher,
|
||||
autoCombosFetcher,
|
||||
enrichmentFetcher,
|
||||
compressionMetaFetcher,
|
||||
providersFetcher,
|
||||
diskSnapshotReader,
|
||||
diskSnapshotWriter,
|
||||
cache: sharedCache,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
// Warm block should have been published.
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry, "warm provider entry published");
|
||||
|
||||
// Give detached refresh time to complete.
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
// After parallel refresh, the cache should have the fresh data.
|
||||
const cacheKey = Array.from(sharedCache.keys())[0];
|
||||
assert.ok(cacheKey, "cache entry created");
|
||||
const cached = sharedCache.get(cacheKey)!;
|
||||
assert.ok(cached.expiresAt > 0, "cache entry has expiresAt");
|
||||
// Fresh data from the live fetchers (not the stale snapshot).
|
||||
assert.equal(cached.rawModels.length, 1, "cache has fresh models");
|
||||
assert.equal(cached.rawModels[0].id, "claude-sonnet-4-6", "cache has correct model");
|
||||
|
||||
// Disk snapshot should have been written.
|
||||
assert.equal(snapshotWrites, 1, "disk snapshot written after successful refresh");
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// (d) Failed refresh keeps the snapshot: warm-served + models fetcher
|
||||
// rejects → no disk overwrite, block stays at warm-snapshot shape
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("warm-startup: failed refresh keeps the snapshot, no disk overwrite", async () => {
|
||||
const fetcher = throwingFetcher<OmniRouteModelsFetcher>();
|
||||
const combosFetcher = throwingFetcher<OmniRouteCombosFetcher>();
|
||||
const logger = captureWarn();
|
||||
|
||||
const snapshot: Omit<import("../src/index.js").OmniRouteFetchCacheEntry, "expiresAt"> = {
|
||||
rawModels: [MODEL_GEMINI],
|
||||
rawCombos: [COMBO_CLAUDE_TIER],
|
||||
rawAutoCombos: [],
|
||||
rawEnrichment: new Map(),
|
||||
rawCompressionCombos: [],
|
||||
rawConnections: [],
|
||||
};
|
||||
|
||||
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => snapshot;
|
||||
let snapshotWrites = 0;
|
||||
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {
|
||||
snapshotWrites++;
|
||||
};
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute" },
|
||||
{
|
||||
readAuthJson: authStub(),
|
||||
fetcher,
|
||||
combosFetcher,
|
||||
diskSnapshotReader,
|
||||
diskSnapshotWriter,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry, "warm provider entry published");
|
||||
|
||||
// The block should contain the warm snapshot data (gemini), not be
|
||||
// downgraded to a stub.
|
||||
assert.ok(
|
||||
entry.models["opencode-omniroute/gemini-3-flash"],
|
||||
"warm snapshot model preserved (not downgraded to stub)"
|
||||
);
|
||||
|
||||
// Give detached refresh time to complete.
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
// No disk write on failed refresh.
|
||||
assert.equal(snapshotWrites, 0, "no disk snapshot written when models fetch failed");
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// (e) Parallelism: all six fetchers start concurrently (not sequential)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("warm-startup: all fetchers start concurrently (parallel fan-out)", async () => {
|
||||
const startTimes: number[] = [];
|
||||
const barrier = new Promise<void>((r) => {
|
||||
setTimeout(r, 30);
|
||||
});
|
||||
|
||||
function instrumentedFetcher<T extends (...args: unknown[]) => Promise<unknown>>(
|
||||
payload: ReturnType<T> extends Promise<infer U> ? U : never
|
||||
): T & { callCount: () => number } {
|
||||
let n = 0;
|
||||
const f = async (..._args: unknown[]) => {
|
||||
startTimes.push(Date.now());
|
||||
n++;
|
||||
await barrier;
|
||||
return payload;
|
||||
};
|
||||
return Object.assign(f as T, { callCount: () => n });
|
||||
}
|
||||
|
||||
const fetcher = instrumentedFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
|
||||
const combosFetcher = instrumentedFetcher<OmniRouteCombosFetcher>([]);
|
||||
const autoCombosFetcher = instrumentedFetcher<OmniRouteAutoCombosFetcher>([]);
|
||||
const enrichmentFetcher = instrumentedFetcher<OmniRouteEnrichmentFetcher>(new Map());
|
||||
const compressionMetaFetcher = instrumentedFetcher<OmniRouteCompressionMetaFetcher>([]);
|
||||
const providersFetcher = instrumentedFetcher<OmniRouteProvidersFetcher>([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
// No snapshot → cold path (awaited). All fetchers must still start
|
||||
// concurrently.
|
||||
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
|
||||
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute", features: { enrichment: true, compressionMetadata: true, usableOnly: true } },
|
||||
{
|
||||
readAuthJson: authStub(),
|
||||
fetcher,
|
||||
combosFetcher,
|
||||
autoCombosFetcher,
|
||||
enrichmentFetcher,
|
||||
compressionMetaFetcher,
|
||||
providersFetcher,
|
||||
diskSnapshotReader,
|
||||
diskSnapshotWriter,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
// All fetchers should have been called.
|
||||
assert.equal(fetcher.callCount(), 1, "models fetcher called");
|
||||
assert.equal(combosFetcher.callCount(), 1, "combos fetcher called");
|
||||
assert.equal(autoCombosFetcher.callCount(), 1, "autoCombos fetcher called");
|
||||
assert.equal(enrichmentFetcher.callCount(), 1, "enrichment fetcher called");
|
||||
assert.equal(compressionMetaFetcher.callCount(), 1, "compressionMeta fetcher called");
|
||||
assert.equal(providersFetcher.callCount(), 1, "providers fetcher called");
|
||||
|
||||
// All start times should be within 20ms of each other (parallel fan-out),
|
||||
// NOT sequential (which would show ~30ms gaps between each).
|
||||
assert.ok(startTimes.length >= 6, "all 6 fetchers started");
|
||||
const minStart = Math.min(...startTimes);
|
||||
const maxStart = Math.max(...startTimes);
|
||||
assert.ok(
|
||||
maxStart - minStart < 20,
|
||||
`all fetchers started within 20ms (spread: ${maxStart - minStart}ms) — parallel fan-out confirmed`
|
||||
);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// (f) Soft-fail parity under Promise.allSettled: per-endpoint fallbacks +
|
||||
// logger.warn breadcrumbs preserved
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("warm-startup: combos reject → models-only catalog with warn", async () => {
|
||||
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
|
||||
const combosFetcher = throwingFetcher<OmniRouteCombosFetcher>("403 Forbidden");
|
||||
const logger = captureWarn();
|
||||
|
||||
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
|
||||
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute" },
|
||||
{
|
||||
readAuthJson: authStub(),
|
||||
fetcher,
|
||||
combosFetcher,
|
||||
diskSnapshotReader,
|
||||
diskSnapshotWriter,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry, "provider entry published");
|
||||
assert.ok(
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"],
|
||||
"models-only catalog (no combos)"
|
||||
);
|
||||
assert.ok(
|
||||
logger.entries.some((e) => String(e[0]).includes("/api/combos fetch failed")),
|
||||
"combos-fetch breadcrumb emitted"
|
||||
);
|
||||
});
|
||||
|
||||
test("warm-startup: enrichment rejects → raw-id catalog with warn", async () => {
|
||||
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
|
||||
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
|
||||
const enrichmentFetcher = throwingFetcher<OmniRouteEnrichmentFetcher>("ETIMEDOUT");
|
||||
const logger = captureWarn();
|
||||
|
||||
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
|
||||
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute" },
|
||||
{
|
||||
readAuthJson: authStub(),
|
||||
fetcher,
|
||||
combosFetcher,
|
||||
enrichmentFetcher,
|
||||
diskSnapshotReader,
|
||||
diskSnapshotWriter,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry, "provider entry published");
|
||||
assert.equal(
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"claude-sonnet-4-6",
|
||||
"raw id retained (no enrichment)"
|
||||
);
|
||||
assert.ok(
|
||||
logger.entries.some((e) => String(e[0]).includes("/api/pricing/models fetch failed")),
|
||||
"enrichment-fetch breadcrumb emitted"
|
||||
);
|
||||
});
|
||||
|
||||
test("warm-startup: providers reject → usableOnly filter disabled with warn", async () => {
|
||||
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
|
||||
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
|
||||
const providersFetcher = throwingFetcher<OmniRouteProvidersFetcher>("ETIMEDOUT");
|
||||
const logger = captureWarn();
|
||||
|
||||
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
|
||||
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute", features: { usableOnly: true } },
|
||||
{
|
||||
readAuthJson: authStub(),
|
||||
fetcher,
|
||||
combosFetcher,
|
||||
providersFetcher,
|
||||
diskSnapshotReader,
|
||||
diskSnapshotWriter,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry, "provider entry published");
|
||||
// Soft-fail: model kept (filter disabled).
|
||||
assert.ok(
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"],
|
||||
"model kept (usableOnly filter disabled)"
|
||||
);
|
||||
assert.ok(
|
||||
logger.entries.some((e) => String(e[0]).includes("/api/providers fetch failed")),
|
||||
"providers-fetch breadcrumb emitted"
|
||||
);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// (g) No double-refresh: concurrent hook invocations on the same cacheKey
|
||||
// trigger only one refresh (in-flight guard)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("warm-startup: concurrent hook invocations dedupe refresh", async () => {
|
||||
let fetchCount = 0;
|
||||
const slowResolve = new Promise<void>((r) => {
|
||||
setTimeout(r, 100);
|
||||
});
|
||||
|
||||
const fetcher: OmniRouteModelsFetcher = async () => {
|
||||
fetchCount++;
|
||||
await slowResolve;
|
||||
return [MODEL_CLAUDE];
|
||||
};
|
||||
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
|
||||
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
|
||||
|
||||
const sharedCache: OmniRouteFetchCache = new Map();
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute", modelCacheTtl: 60_000 },
|
||||
{
|
||||
readAuthJson: authStub(),
|
||||
fetcher,
|
||||
combosFetcher,
|
||||
diskSnapshotReader,
|
||||
diskSnapshotWriter,
|
||||
cache: sharedCache,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
|
||||
// Fire two concurrent hook invocations on the same cache.
|
||||
const inputA = makeInput();
|
||||
const inputB = makeInput();
|
||||
await Promise.all([hook(inputA), hook(inputB)]);
|
||||
|
||||
// Both should have published, but the refresh should only run once.
|
||||
assert.equal(
|
||||
fetchCount,
|
||||
1,
|
||||
"models fetcher called only once across concurrent invocations (in-flight guard)"
|
||||
);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// (h) features.diskCache: false disables the warm read entirely
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("warm-startup: diskCache=false disables warm read, falls through to awaited fetch", async () => {
|
||||
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
|
||||
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
let readerCalled = false;
|
||||
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => {
|
||||
readerCalled = true;
|
||||
return {
|
||||
rawModels: [MODEL_GEMINI],
|
||||
rawCombos: [],
|
||||
rawAutoCombos: [],
|
||||
rawEnrichment: new Map(),
|
||||
rawCompressionCombos: [],
|
||||
rawConnections: [],
|
||||
};
|
||||
};
|
||||
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute", features: { diskCache: false } },
|
||||
{
|
||||
readAuthJson: authStub(),
|
||||
fetcher,
|
||||
combosFetcher,
|
||||
diskSnapshotReader,
|
||||
diskSnapshotWriter,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
assert.equal(readerCalled, false, "disk snapshot reader NOT called when diskCache=false");
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry, "provider entry published from live fetch");
|
||||
assert.ok(
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"],
|
||||
"live fetch model present (not snapshot)"
|
||||
);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Warm startup: snapshot age logged
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("warm-startup: snapshot age is logged when warm-starting from disk", async () => {
|
||||
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
|
||||
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
const snapshot: Omit<import("../src/index.js").OmniRouteFetchCacheEntry, "expiresAt"> & {
|
||||
writtenAt?: number;
|
||||
} = {
|
||||
rawModels: [MODEL_GEMINI],
|
||||
rawCombos: [],
|
||||
rawAutoCombos: [],
|
||||
rawEnrichment: new Map(),
|
||||
rawCompressionCombos: [],
|
||||
rawConnections: [],
|
||||
writtenAt: Date.now() - 3_600_000, // 1 hour ago
|
||||
};
|
||||
|
||||
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => snapshot;
|
||||
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute" },
|
||||
{
|
||||
readAuthJson: authStub(),
|
||||
fetcher,
|
||||
combosFetcher,
|
||||
diskSnapshotReader,
|
||||
diskSnapshotWriter,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
// The log should mention "warm startup from disk snapshot".
|
||||
assert.ok(
|
||||
logger.entries.some((e) =>
|
||||
String(e[0]).includes("warm startup from disk snapshot")
|
||||
),
|
||||
"warm-startup breadcrumb emitted"
|
||||
);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Warm startup: empty snapshot (rawModels.length === 0) is skipped
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("warm-startup: empty snapshot (rawModels.length=0) is skipped, falls through to fetch", async () => {
|
||||
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
|
||||
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => ({
|
||||
rawModels: [],
|
||||
rawCombos: [],
|
||||
rawAutoCombos: [],
|
||||
rawEnrichment: new Map(),
|
||||
rawCompressionCombos: [],
|
||||
rawConnections: [],
|
||||
});
|
||||
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute" },
|
||||
{
|
||||
readAuthJson: authStub(),
|
||||
fetcher,
|
||||
combosFetcher,
|
||||
diskSnapshotReader,
|
||||
diskSnapshotWriter,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry, "provider entry published from live fetch");
|
||||
// Live data, not empty snapshot.
|
||||
assert.ok(
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"],
|
||||
"live fetch model present (empty snapshot skipped)"
|
||||
);
|
||||
assert.equal(fetcher.callCount(), 1, "fetcher was called (awaited cold path)");
|
||||
});
|
||||
103
AGENTS.md
103
AGENTS.md
@@ -46,7 +46,7 @@ Repository map and Reference Documentation sections below.
|
||||
|
||||
## Project at a Glance
|
||||
|
||||
**OmniRoute** — unified AI proxy/router. One endpoint, 351 LLM providers, auto-fallback.
|
||||
**OmniRoute** — unified AI proxy/router. One endpoint, 291 LLM providers, auto-fallback.
|
||||
|
||||
| Layer | Location | Purpose |
|
||||
| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -56,9 +56,9 @@ Repository map and Reference Documentation sections below.
|
||||
| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) |
|
||||
| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions |
|
||||
| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc |
|
||||
| Database | `src/lib/db/` | SQLite domain modules (166 migrations) |
|
||||
| Database | `src/lib/db/` | SQLite domain modules (130 migrations) |
|
||||
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
|
||||
| MCP Server | `open-sse/mcp-server/` | 110 tools (44 canonical + memory/skill/GitHub/pool/gamification/plugin/Notion/Obsidian/local-corpus/RTK modules), 3 transports (stdio / SSE / Streamable HTTP), 33 scopes |
|
||||
| MCP Server | `open-sse/mcp-server/` | 109 tools (44 canonical + memory/skill/GitHub/pool/gamification/plugin/Notion/Obsidian/local-corpus/RTK modules), 3 transports (stdio / SSE / Streamable HTTP), 33 scopes |
|
||||
| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol |
|
||||
| Skills | `src/lib/skills/` | Extensible skill framework |
|
||||
| Memory | `src/lib/memory/` | Persistent conversational memory |
|
||||
@@ -83,7 +83,7 @@ Client → /v1/chat/completions (Next.js route)
|
||||
|
||||
API routes follow a consistent pattern: `Route → CORS preflight → Zod body validation → Optional auth (extractApiKey/isValidApiKey) → API key policy enforcement → Handler delegation (open-sse)`. No global Next.js middleware — interception is route-specific.
|
||||
|
||||
**Combo routing** (`open-sse/services/combo.ts`): 19 public strategies (priority, weighted, fill-first, round-robin, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline). Each target calls `handleSingleModel()` which wraps `handleChatCore()` with per-target error handling and circuit breaker checks. The `fusion` strategy is the exception: it fans out to a panel of models in parallel, then a judge model synthesizes one final answer (`open-sse/services/fusion.ts`). See `docs/routing/AUTO-COMBO.md` for the 15-factor Auto-Combo scoring + the full strategy table and `docs/architecture/RESILIENCE_GUIDE.md` for the 3 resilience layers.
|
||||
**Combo routing** (`open-sse/services/combo.ts`): 19 public strategies (priority, weighted, fill-first, round-robin, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline). Each target calls `handleSingleModel()` which wraps `handleChatCore()` with per-target error handling and circuit breaker checks. The `fusion` strategy is the exception: it fans out to a panel of models in parallel, then a judge model synthesizes one final answer (`open-sse/services/fusion.ts`). See `docs/routing/AUTO-COMBO.md` for the 13-factor Auto-Combo scoring + the full strategy table and `docs/architecture/RESILIENCE_GUIDE.md` for the 3 resilience layers.
|
||||
|
||||
---
|
||||
|
||||
@@ -118,18 +118,11 @@ upstream/service level, so one unhealthy provider does not slow down every reque
|
||||
- `HALF_OPEN`: reset timeout has elapsed; allow a probe request. Success closes the
|
||||
breaker, failure opens it again.
|
||||
|
||||
**Defaults** (`open-sse/config/constants.ts` → `PROVIDER_PROFILES`). Two thresholds live side by
|
||||
side — do not confuse them:
|
||||
**Defaults** (`open-sse/config/constants.ts`):
|
||||
|
||||
| Profile | `providerFailureThreshold` (whole provider) | `providerCooldownMs` | `circuitBreakerThreshold` (one connection) | `circuitBreakerReset` |
|
||||
| ------- | ------------------------------------------: | -------------------: | -----------------------------------------: | --------------------: |
|
||||
| OAuth | `10` | `5min` | `8` | `60s` |
|
||||
| API key | `15` | `10min` | `12` | `30s` |
|
||||
| Local | `2` | `1min` | `2` | `15s` |
|
||||
|
||||
The provider-level thresholds were scaled up for deployments with 500+ connections (OAuth was
|
||||
`3`, API key was `5`); every default is overridable through the `OMNIROUTE_PROVIDER_BREAKER_*`
|
||||
and `OMNIROUTE_CIRCUIT_BREAKER_*` env vars.
|
||||
- OAuth providers: threshold `3`, reset timeout `60s`.
|
||||
- API-key providers: threshold `5`, reset timeout `30s`.
|
||||
- Local providers: threshold `2`, reset timeout `15s`.
|
||||
|
||||
Only provider-level failure statuses should trip the provider breaker:
|
||||
|
||||
@@ -197,7 +190,7 @@ baseCooldownMs * 2 ** failureIndex;
|
||||
The anti-thundering-herd guard prevents concurrent failures on the same connection from
|
||||
repeatedly extending the cooldown or double-incrementing `backoffLevel`.
|
||||
|
||||
Terminal states are not cooldowns. `banned`, `expired` (which becomes terminal only after N bounded retries via `EXPIRED_RETRY_MAX`), and `credits_exhausted` are
|
||||
Terminal states are not cooldowns. `banned`, `expired`, and `credits_exhausted` are
|
||||
intended to stay unavailable until credentials/settings change or an operator resets
|
||||
them. Do not overwrite terminal states with transient cooldown state.
|
||||
|
||||
@@ -254,26 +247,17 @@ Read the nearest `AGENTS.md` and the linked deep-dive before making a non-trivia
|
||||
## File placement & repo-root hygiene
|
||||
|
||||
- **Test files**: ALL unit tests, integration tests, ecosystem tests, or Vitest files MUST strictly be placed within the `tests/` directory (e.g., `tests/unit/`, `tests/integration/`). NEVER create test files in the project root (`/`).
|
||||
- **Scripts and utilities**: ALL maintenance, debugging, generation, or experimental scripts (`.cjs`, `.mjs`, `.js`, `.ts`) MUST be placed strictly inside one of the `scripts/` subfolders (`build/`, `dev/`, `check/`, `docs/`, `i18n/`, `ad-hoc/`, `quality/`, `release/`, `ci/`, `ops/`, `perf/`, `research/`, `sre/`, `vps/`, `homolog/`, `raycast/`, `skills/`, `test/`, `cli/`, `compression/`, `compression-eval/`, `devin-bridge/`, `docker/`, `features/`, `router-eval/`). One-shot or experimental code goes under `scripts/ad-hoc/`. NEVER dump loose scripts in the project root (`/`) or the top-level `scripts/` folder.
|
||||
- **Scripts and utilities**: ALL maintenance, debugging, generation, or experimental scripts (`.cjs`, `.mjs`, `.js`, `.ts`) MUST be placed strictly inside one of the `scripts/` subfolders (`build/`, `dev/`, `check/`, `docs/`, `i18n/`, `ad-hoc/`). One-shot or experimental code goes under `scripts/ad-hoc/`. NEVER dump loose scripts in the project root (`/`) or the top-level `scripts/` folder.
|
||||
|
||||
**The project root MUST ONLY contain:**
|
||||
|
||||
- Configuration files (`vitest.config.ts`, `next.config.mjs`, `eslint.config.mjs`, `tsconfig*.json`, `playwright.config.ts`, `prettier.config.mjs`, `postcss.config.mjs`, `sonar-project.properties`, `fly.toml`, `docker-compose*.yml`, `Dockerfile`)
|
||||
- Dependency files (`package.json`, `package-lock.json`)
|
||||
- Documentation files (`README.md`, `CHANGELOG.md`, `ROADMAP.md`, `LICENSE`, `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, `CONTRIBUTING.md`, `SECURITY.md`, `CODE_OF_CONDUCT.md`, `llm.txt`, `Tuto_Qdrant.md`)
|
||||
- Documentation files (`README.md`, `CHANGELOG.md`, `LICENSE`, `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, `CONTRIBUTING.md`, `SECURITY.md`, `CODE_OF_CONDUCT.md`, `llm.txt`, `Tuto_Qdrant.md`)
|
||||
- CI/CD files and ignore definitions (`.gitignore`, `.dockerignore`, `.npmignore`, `.npmrc`, `.node-version`, `.nvmrc`, `.env.example`)
|
||||
|
||||
When creating _any_ validation tests or one-off logic scripts, default to `scripts/ad-hoc/` or `tests/unit/` according to your goals. Do not pollute the `/` root context.
|
||||
|
||||
- **Root `_*` paths are private and NEVER tracked** (`_tasks/`, `_references/`, `_mono_repo/`,
|
||||
`_ideia/`, `_cache/` and any future `_<name>`): they live on disk only, are gitignored by the
|
||||
anchored patterns `/_*/` + `/_*`, and some are full git repositories of their own (`_tasks` →
|
||||
private remote `_tasks_omniroute`). Never `git add` anything inside them (a plain `add` is
|
||||
already blocked by the ignore; never use `-f`), and never "clean them up" from the main repo —
|
||||
untracking is done with `git rm --cached` so the disk content stays. The
|
||||
`check:tracked-artifacts` gate (pre-commit + CI) fails on ANY tracked root path starting with
|
||||
`_`, present or future. See Hard Rule #23 for the `_tasks` specifics.
|
||||
|
||||
---
|
||||
|
||||
## Key Conventions
|
||||
@@ -307,7 +291,7 @@ When creating _any_ validation tests or one-off logic scripts, default to `scrip
|
||||
- Encrypt credentials at rest (AES-256-GCM); never log SQLite encryption keys
|
||||
- Sanitize user HTML with DOMPurify
|
||||
- Upstream header denylist: `src/shared/constants/upstreamHeaders.ts` — keep sanitize, Zod schemas, and unit tests aligned when editing
|
||||
- **Public upstream credentials** (for example, OAuth client_id/secret values or Firebase Web keys extracted from public CLIs): **MUST** be embedded via `resolvePublicCred()` from `open-sse/utils/publicCreds.ts` — **never** as string literals. See `docs/security/PUBLIC_CREDS.md` for the mandatory pattern.
|
||||
- **Public upstream credentials** (Gemini/Antigravity/Windsurf-style OAuth client_id/secret + Firebase Web keys extracted from public CLIs): **MUST** be embedded via `resolvePublicCred()` from `open-sse/utils/publicCreds.ts` — **never** as string literals. See `docs/security/PUBLIC_CREDS.md` for the mandatory pattern.
|
||||
- **Error responses** (HTTP / SSE / executor / MCP handler): **MUST** route through `buildErrorBody()` or `sanitizeErrorMessage()` from `open-sse/utils/error.ts` — **never** put raw `err.stack` or `err.message` in a response body. See `docs/security/ERROR_SANITIZATION.md`.
|
||||
- **Shell commands built from variables**: when calling `exec()`/`spawn()` with a script that needs runtime values, pass them via the `env` option (shell-escaped automatically) — **never** string-interpolate untrusted/external paths into the script body. Reference: `src/mitm/cert/install.ts::updateNssDatabases`.
|
||||
- **Secure-by-default libraries** ([tldrsec/awesome-secure-defaults](https://github.com/tldrsec/awesome-secure-defaults)): prefer Helmet.js, DOMPurify, ssrf-req-filter, safe-regex, Google Tink over custom implementations whenever adding new security-sensitive surfaces.
|
||||
@@ -411,7 +395,7 @@ For any non-trivial change, read the matching deep-dive first:
|
||||
| Repo navigation | `docs/architecture/REPOSITORY_MAP.md` |
|
||||
| Architecture | `docs/architecture/ARCHITECTURE.md` |
|
||||
| Engineering reference | `docs/architecture/CODEBASE_DOCUMENTATION.md` |
|
||||
| Auto-Combo (15-factor scoring, 19 strategies) | `docs/routing/AUTO-COMBO.md` |
|
||||
| Auto-Combo (13-factor scoring, 19 strategies) | `docs/routing/AUTO-COMBO.md` |
|
||||
| Resilience (3 mechanisms) | `docs/architecture/RESILIENCE_GUIDE.md` |
|
||||
| Reasoning replay | `docs/routing/REASONING_REPLAY.md` |
|
||||
| Skills framework | `docs/frameworks/SKILLS.md` |
|
||||
@@ -433,25 +417,24 @@ For any non-trivial change, read the matching deep-dive first:
|
||||
| Provider catalog (auto-generated) | `docs/reference/PROVIDER_REFERENCE.md` |
|
||||
| Tunnels | `docs/ops/TUNNELS_GUIDE.md` |
|
||||
| Electron desktop app | `docs/guides/ELECTRON_GUIDE.md` |
|
||||
| VS Code Copilot Chat (OmniCopilot extension) | `docs/guides/VSCODE-COPILOT.md` |
|
||||
| Release flow | `docs/ops/RELEASE_CHECKLIST.md` |
|
||||
| Embedded services | `docs/frameworks/EMBEDDED-SERVICES.md` |
|
||||
| Quality gates (~80 scripts, allowlist policy) | `docs/architecture/QUALITY_GATES.md` |
|
||||
| Quality gates (~48 scripts, allowlist policy) | `docs/architecture/QUALITY_GATES.md` |
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
| What | Command |
|
||||
| ----------------------- | ----------------------------------------------------------------------------- |
|
||||
| Unit tests | `npm run test:unit` |
|
||||
| Single file | `node --import tsx/esm --test tests/unit/your-file.test.ts` |
|
||||
| Vitest (MCP, autoCombo) | `npm run test:vitest` |
|
||||
| E2E (Playwright) | `npm run test:e2e` |
|
||||
| Protocol E2E (MCP+A2A) | `npm run test:protocols:e2e` (CI job `test-protocols-e2e`, advisory — #10049) |
|
||||
| Ecosystem | `npm run test:ecosystem` (CI job `test-ecosystem`, blocking) |
|
||||
| Coverage gate | `npm run test:coverage` (60/60/60/60 — statements/lines/functions/branches) |
|
||||
| Coverage report | `npm run coverage:report` |
|
||||
| What | Command |
|
||||
| ----------------------- | --------------------------------------------------------------------------- |
|
||||
| Unit tests | `npm run test:unit` |
|
||||
| Single file | `node --import tsx/esm --test tests/unit/your-file.test.ts` |
|
||||
| Vitest (MCP, autoCombo) | `npm run test:vitest` |
|
||||
| E2E (Playwright) | `npm run test:e2e` |
|
||||
| Protocol E2E (MCP+A2A) | `npm run test:protocols:e2e` |
|
||||
| Ecosystem | `npm run test:ecosystem` |
|
||||
| Coverage gate | `npm run test:coverage` (60/60/60/60 — statements/lines/functions/branches) |
|
||||
| Coverage report | `npm run coverage:report` |
|
||||
|
||||
**PR rule**: If you change production code in `src/`, `open-sse/`, `electron/`, or `bin/`, you must include or update tests in the same PR.
|
||||
|
||||
@@ -594,18 +577,6 @@ inside your feature branch (a base-red fix is its own freeze-gated `fix/release-
|
||||
PR); and if you must open a PR anyway, add `⚠️ base-red inherited: #<issue>` to the PR body so
|
||||
reviewers and CI babysitters do not chase ghosts.
|
||||
|
||||
### Sync-back landings are fast-forward, never squash
|
||||
|
||||
A `main → release/vX+1` sync-back (Phase 5 of `/generate-release`, or any later "bring main's
|
||||
post-release commits over" PR) must reach the release branch as the merge commit it already is:
|
||||
`git merge-base --is-ancestor origin/release/vX+1 <head>` then
|
||||
`git push origin <head>:refs/heads/release/vX+1` (GitHub marks the PR merged). Squash-merging it
|
||||
drops `main` from the release branch's ancestry and the next sync-back re-conflicts on every file
|
||||
main touched (551 conflicts on the v3.8.50 → v3.8.51 sync before the two-step merge). After
|
||||
landing, `git merge-base --is-ancestor origin/main origin/release/vX+1` must be true — and check
|
||||
that `config/quality/eslint-suppressions.json` / `quality-baseline.json` carried main's freezes
|
||||
(they merge as "ours" silently). Details: `.agents/skills/generate-release/phases/phase-5-next-cycle.md`.
|
||||
|
||||
---
|
||||
|
||||
## Upstream contributions
|
||||
@@ -640,7 +611,7 @@ focused checks, and use a Conventional Commit message (for example, `docs: slim
|
||||
|
||||
## Quality Gates & Ratchets
|
||||
|
||||
OmniRoute has **~80 quality-gate scripts** (`scripts/check/` + `scripts/quality/`) wired
|
||||
OmniRoute has **~48 quality-gate scripts** (`scripts/check/` + `scripts/quality/`) wired
|
||||
across **9 gate-running jobs** in `.github/workflows/ci.yml` (`lint`, `quality-gate`,
|
||||
`quality-extended`, `docs-sync-strict`, `i18n-ui-coverage`, `i18n`, `pr-test-policy`,
|
||||
`test-vitest`, `sonarqube`), plus the `quality.yml` fast-gates job (PR→`release/**`) and
|
||||
@@ -656,7 +627,7 @@ procedures are in [`docs/architecture/QUALITY_GATES.md`](docs/architecture/QUALI
|
||||
complexity) must not regress vs `quality-baseline.json`. Update via
|
||||
`npm run quality:ratchet -- --update` when a metric genuinely improves.
|
||||
- Job `test-vitest` runs `npm run test:vitest` (MCP tools, autoCombo, cache) — blocking.
|
||||
`test:vitest:ui` has been blocking since PR #7127.
|
||||
`test:vitest:ui` is advisory until UI component tests are triaged.
|
||||
|
||||
**Allowlist policy (short form):** Fix the cause; use the allowlist only for pre-existing
|
||||
violations you cannot fix in the same PR. Add a comment with justification + issue number.
|
||||
@@ -691,18 +662,6 @@ the stale-enforcement added in Fase 6A.3.
|
||||
22. **Cross-session safety — this repo is worked by MANY parallel sessions/agents at once; never step on another's in-flight work.** Two absolute bans, both recurring incidents (this rule exists because they keep happening):
|
||||
- **(a) Never `git stash` / `git stash pop` — ANYWHERE in this repo, including inside an isolated worktree, and including inside any subagent you dispatch.** `git stash` operates on the **shared repository object store**, not the per-worktree working tree — so a stash pushed or popped in one session can silently clobber or resurrect another parallel session's uncommitted changes. This is not hypothetical: 2026-07-02 a `#5923` quotaCache change leaked into the unrelated `#2296` worktree via a global `stash pop`, and the same class reincided through a **subagent**. To compare working changes against a base ref **without** stashing, use `git show <ref>:<path>` or `git diff <ref> -- <path>`; to confirm a typecheck/lint error is pre-existing on the base, inspect the base ref directly (`git show origin/release/vX.Y.Z:<path>`) — never stash your tree away to "get it clean". **Put this ban verbatim in the prompt of every subagent that touches git** (agents don't inherit this file's context — the recurrence was a subagent).
|
||||
- **(b) Never merge, push, rebase, or force-push a PR / branch / worktree that another session is actively working.** An open PR whose head is a live fix worktree in `.claude/worktrees/` you did **not** create (e.g. `fix-5852`/`fix-5923` carrying fresh commits, even when they share your `diegosouzapw` identity), or any branch another session owns, is **off-limits — HOLD**, and let the owning session merge it. **Before** merging or pushing to any PR you did not create _this_ session, run `git worktree list` to check for a matching in-flight worktree and re-check `gh pr view <N> --json state,headRefOid`. Only the owning session merges its own in-flight PR; mid-flight merges race the owner and re-trigger the exact commit/CHANGELOG races Rule #19 and Rule #21 guard against. (Reinforces Rule #19.)
|
||||
23. **`_tasks/` é INTOCÁVEL como estrutura — append/edit-only.** É um repositório git SEPARADO
|
||||
(remote privado `diegosouzapw/_tasks_omniroute`) montado como diretório real na raiz do
|
||||
checkout principal. Regras absolutas: (a) NUNCA mover, renomear, deletar, esvaziar ou
|
||||
transformar `_tasks` em symlink; sessões só podem CRIAR ou EDITAR arquivos dentro dele;
|
||||
(b) NUNCA rastrear `_tasks` (nem como symlink) no repo principal — o blob rastreado foi a
|
||||
causa-raiz de DOIS wipes (2026-08-08 e 2026-08-10: `git reset --hard` materializou o
|
||||
symlink rastreado por cima do diretório real e o git apagou todo o conteúdo ignorado sem
|
||||
aviso); (c) após qualquer escrita relevante, `git -C _tasks add -A && git -C _tasks commit
|
||||
&& git -C _tasks push` — o push frequente é o backup real; (d) repetir esta proibição
|
||||
VERBATIM no prompt de todo subagente que toque git; (e) se `_tasks` aparecer como symlink
|
||||
quebrado, NÃO commitar nada — restaurar do remote e avisar o operador. O gate
|
||||
`check:tracked-artifacts` (pre-commit + CI) bloqueia `_tasks` rastreado em qualquer forma.
|
||||
|
||||
---
|
||||
|
||||
@@ -730,13 +689,3 @@ The dashboard is reachable at the operator's chosen URL/port (default `http://lo
|
||||
- **Local VPS / shared dev environments**: ask the operator for the URL and current credentials — they live in their personal vault, NOT in this repo.
|
||||
|
||||
> Any credential observed in a previous version of this file was a non-production demo value; treat it as compromised and do not reuse it.
|
||||
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
|
||||
1694
CHANGELOG.md
1694
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
17
CLAUDE.md
17
CLAUDE.md
@@ -3,7 +3,7 @@
|
||||
@AGENTS.md
|
||||
|
||||
**All project rules live in [`AGENTS.md`](AGENTS.md)** — the single source of truth for every AI
|
||||
assistant (architecture, conventions, testing, quality gates, git workflow, the 23 Hard Rules,
|
||||
assistant (architecture, conventions, testing, quality gates, git workflow, the 22 Hard Rules,
|
||||
PII learnings). Read it in full; do not re-add project rules here. Everything below applies ONLY
|
||||
to Claude Code — operational refinements of rules already defined in `AGENTS.md`.
|
||||
|
||||
@@ -47,21 +47,6 @@ rewrite it to the `_tasks/…` equivalent before writing:
|
||||
|
||||
Commit those artifacts inside the `_tasks/` repo (`git -C _tasks …`), never in the main repo.
|
||||
|
||||
## Scratch / temporary files — use `_artifacts/`, not `/tmp`
|
||||
|
||||
This project overrides the harness's default session scratchpad (`/tmp/claude-*/…`). Write
|
||||
temporary/working files — exports, generated zips, one-off intermediate outputs, anything you'd
|
||||
otherwise put in `/tmp` — to `/home/diegosouzapw/dev/proxys/OmniRoute/_artifacts/` instead.
|
||||
|
||||
- `_artifacts/` is a root `_*` path: already gitignored (`AGENTS.md` → "Root `_*` paths"), lives
|
||||
on disk only, never tracked.
|
||||
- Reason: keeping scratch output inside the project (vs `/tmp`) makes it trivial for the operator
|
||||
to find and delete everything temporary in one place, instead of hunting across ephemeral
|
||||
session-specific `/tmp` directories that vanish or accumulate untracked.
|
||||
- Do **not** confuse this with `_tasks/` (Hard Rule #23, its own private git repo for durable
|
||||
plans/specs/research/hand-offs) — `_artifacts/` is for disposable working files only, nothing
|
||||
here needs to survive or be versioned.
|
||||
|
||||
## Base-green before opening PRs
|
||||
|
||||
Before cutting a branch or opening a PR, run the base-green check (`AGENTS.md` → Git Workflow →
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Thank you for your interest in contributing! This guide covers everything you need to get started.
|
||||
|
||||
For the official per-change workflow, start with the
|
||||
[Contribution Golden Path](docs/ops/CONTRIBUTION_GOLDEN_PATH.md). It maps provider, routing,
|
||||
[Contribution Golden Path](docs/dev/CONTRIBUTION_GOLDEN_PATH.md). It maps provider, routing,
|
||||
UI/UX, i18n, CLI, database, and build/deploy changes to their contracts, focused tests, CI
|
||||
coverage, and reconciliation steps.
|
||||
|
||||
@@ -210,7 +210,7 @@ Coverage notes:
|
||||
### Pull Request Requirements
|
||||
|
||||
Before opening a PR, use the
|
||||
[Contribution Golden Path](docs/ops/CONTRIBUTION_GOLDEN_PATH.md) to run the focused loop for
|
||||
[Contribution Golden Path](docs/dev/CONTRIBUTION_GOLDEN_PATH.md) to run the focused loop for
|
||||
what you changed. The full unit suite (4 CI shards), Vitest, the **60%+** coverage gate, and
|
||||
the production build are CI's responsibility — running them locally adds no signal the PR
|
||||
checks will not already give you, and on smaller machines it can saturate the host (#8084):
|
||||
@@ -293,16 +293,16 @@ src/ # TypeScript (.ts / .tsx)
|
||||
├── mitm/ # MITM proxy (cert, DNS, target routing)
|
||||
├── shared/
|
||||
│ ├── components/ # React components (.tsx)
|
||||
│ ├── constants/ # Provider definitions (329), MCP scopes, 19 routing strategies
|
||||
│ ├── constants/ # Provider definitions (290), MCP scopes, 19 routing strategies
|
||||
│ ├── utils/ # Circuit breaker, sanitizer, auth helpers
|
||||
│ └── validation/ # Zod v4 schemas
|
||||
└── sse/ # SSE proxy pipeline
|
||||
|
||||
open-sse/ # @omniroute/open-sse workspace
|
||||
├── executors/ # 89 executor implementation modules
|
||||
├── executors/ # 14 provider-specific request executors
|
||||
├── handlers/ # 11 request handlers (chat, responses, embeddings, images, etc.)
|
||||
├── mcp-server/ # MCP server (107 unique tools, 3 transports, 32 scopes)
|
||||
├── services/ # 178 top-level services (combo, autoCombo, rateLimitManager, etc.)
|
||||
├── mcp-server/ # MCP server (104 tools, 3 transports, 31 scopes)
|
||||
├── services/ # 36+ services (combo, autoCombo, rateLimitManager, etc.)
|
||||
├── translator/ # Format translators (OpenAI ↔ Claude ↔ Gemini ↔ Responses ↔ Ollama)
|
||||
├── transformer/ # Responses API transformer
|
||||
└── utils/ # 22 utility modules (stream, TLS, proxy, logging)
|
||||
|
||||
143
Dockerfile
143
Dockerfile
@@ -8,67 +8,29 @@ WORKDIR /app
|
||||
# that already have a fix published in trixie. CVEs without an upstream fix yet
|
||||
# (local-only TOCTOU, etc.) remain until the distro patches them and the image
|
||||
# is rebuilt; none are reachable from the proxy's request surface at runtime.
|
||||
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-lists,target=/var/lib/apt/lists,sharing=locked \
|
||||
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \
|
||||
apt-get update \
|
||||
&& apt-get upgrade -y \
|
||||
&& apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# npm's *bundled* node_modules (brace-expansion, ip-address, tar, undici) are
|
||||
# npm's own internals — not application dependencies (the app resolves its own,
|
||||
# already-fixed copies) — but the container scanner reads them off
|
||||
# /usr/local/lib/node_modules/npm/node_modules and reports 9 HIGH/MEDIUM CVEs.
|
||||
#
|
||||
# Refreshing npm does NOT fix them. Measured on npm@12.0.2 (2026-08-12, latest):
|
||||
# brace-expansion 5.0.7 (needs >= 5.0.9) CVE-2026-69152, CVE-2026-14257
|
||||
# ip-address 10.2.0 (needs >= 10.3.1) CVE-2026-69192/-69198/-54272
|
||||
# tar 7.5.19 (needs >= 7.5.21) GHSA-r292-9mhp-454m
|
||||
# undici 6.27.0 (needs >= 6.28.0) CVE-2026-16729/-16728/-15157
|
||||
# No published npm release carries patched copies, so `npm install -g npm@latest`
|
||||
# alone was pure build time for zero CVEs — it is kept only to land on a known,
|
||||
# current npm tree, and the patched copies are overlaid on top below.
|
||||
#
|
||||
# Deleting npm from the runner stages is NOT an option: the application shells
|
||||
# out to npm at runtime (src/lib/services/installers/utils.ts::runNpm for the
|
||||
# embedded services, src/lib/system/{autoUpdate,globalPackagePath}.ts,
|
||||
# src/app/api/system/version). The previous version of this comment claimed the
|
||||
# opposite; it was wrong.
|
||||
#
|
||||
# The overlay is semver-compatible with the ranges npm's own tree declares
|
||||
# (minimatch → brace-expansion ^5.0.5, socks → ip-address ^10.1.1, node-gyp →
|
||||
# tar ^7.5.4 and undici ^6.25.0 — hence undici stays on the 6.x line, NOT 8.x).
|
||||
# --install-strategy=nested makes each replacement self-contained, so it cannot
|
||||
# perturb the versions the rest of npm's flat tree resolves.
|
||||
RUN set -eux; \
|
||||
npm install -g npm@latest; \
|
||||
npm install --prefix /tmp/npm-cve-patch --no-audit --no-fund --ignore-scripts \
|
||||
--install-strategy=nested \
|
||||
brace-expansion@5.0.9 ip-address@10.5.0 tar@7.5.22 undici@6.28.0; \
|
||||
for pkg in brace-expansion ip-address tar undici; do \
|
||||
test -d "/usr/local/lib/node_modules/npm/node_modules/$pkg"; \
|
||||
rm -rf "/usr/local/lib/node_modules/npm/node_modules/$pkg"; \
|
||||
cp -R "/tmp/npm-cve-patch/node_modules/$pkg" \
|
||||
"/usr/local/lib/node_modules/npm/node_modules/$pkg"; \
|
||||
done; \
|
||||
rm -rf /tmp/npm-cve-patch; \
|
||||
node -e "for (const p of ['brace-expansion','ip-address','tar','undici']) console.log(p, require('/usr/local/lib/node_modules/npm/node_modules/'+p+'/package.json').version);"; \
|
||||
npm --version; \
|
||||
npm cache clean --force
|
||||
# Refresh the globally-installed npm so its *bundled* node_modules (undici, tar)
|
||||
# ship the patched versions. These are npm's own internals — not application
|
||||
# dependencies (our app already resolves undici@8.5.0 / tar@7.5.16, both fixed) —
|
||||
# but the container scanner flags the stale copies under
|
||||
# /usr/local/lib/node_modules/npm/node_modules. npm is not invoked at runtime in
|
||||
# the runner stages, so this is hygiene, not an exploitable runtime path.
|
||||
RUN npm install -g npm@latest \
|
||||
&& npm cache clean --force
|
||||
|
||||
# ── Builder ────────────────────────────────────────────────────────────────
|
||||
FROM base AS builder
|
||||
|
||||
# No telemetry, anywhere. Disable Next.js's anonymous build-time telemetry
|
||||
# (it otherwise pings Vercel during `next build`). Set on the builder stage so
|
||||
# every image build is silent; the runtime never builds, so this covers the
|
||||
# only phase Next telemetry can fire.
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Build tools for native module compilation
|
||||
# apt-get update needed here because base's rm -rf clears the shared cache
|
||||
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-lists,target=/var/lib/apt/lists,sharing=locked \
|
||||
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \
|
||||
apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -104,7 +66,7 @@ RUN test -f package-lock.json \
|
||||
# instead of `npx --yes`, which would install an arbitrary registry version
|
||||
# on-demand and run its lifecycle scripts (Sonar docker:S6505).
|
||||
#
|
||||
# tls-client-node (claude-web/grok-web/lmarena/perplexity-web TLS
|
||||
# tls-client-node (chatgpt-web/claude-web/grok-web/lmarena/perplexity-web TLS
|
||||
# impersonation) hits the same --ignore-scripts wall: its own postinstall.js
|
||||
# fetches a platform .so/.dylib/.dll from the bogdanfinn/tls-client GitHub
|
||||
# Releases API and is never invoked when npm ci skips lifecycle scripts. Unlike
|
||||
@@ -114,7 +76,7 @@ RUN test -f package-lock.json \
|
||||
# in production (TlsClientUnavailableError, #7802). Run it explicitly here so
|
||||
# a broken/rate-limited fetch fails the BUILD loudly instead of shipping a
|
||||
# broken image.
|
||||
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-npm-cache,target=/root/.npm \
|
||||
RUN --mount=type=cache,id=npm-cache,target=/root/.npm \
|
||||
npm ci --include=optional --no-audit --no-fund --legacy-peer-deps --ignore-scripts \
|
||||
&& (cd node_modules/better-sqlite3 \
|
||||
&& node /usr/local/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js rebuild) \
|
||||
@@ -131,33 +93,13 @@ RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-npm-cache,targe
|
||||
# build from 17min to 9min on the same 32-core box. Webpack stays available as the
|
||||
# escape hatch: `--build-arg`/-e OMNIROUTE_USE_TURBOPACK=0.
|
||||
# See docs/ops/QUALITY_GATE_PLAYBOOK.md Parte 6.
|
||||
#
|
||||
# Declared as ARG+ENV, not a bare ENV: a bare ENV shadows any same-named ARG for
|
||||
# the rest of the stage, so `--build-arg OMNIROUTE_USE_TURBOPACK=0` was silently
|
||||
# ignored and the escape hatch above only ever worked via `-e` at runtime, never
|
||||
# at build time. Turbopack compiles in native Rust memory that lives outside the
|
||||
# V8 heap, so OMNIROUTE_BUILD_MEMORY_MB cannot bound it and a memory-constrained
|
||||
# build host gets SIGKILLed by the cgroup OOM killer with no error message.
|
||||
ARG OMNIROUTE_USE_TURBOPACK=1
|
||||
ENV OMNIROUTE_USE_TURBOPACK="${OMNIROUTE_USE_TURBOPACK}"
|
||||
ENV OMNIROUTE_USE_TURBOPACK=1
|
||||
|
||||
# Next.js basePath is fixed at build time; pass OMNIROUTE_BASE_PATH here when the
|
||||
# image should serve under a reverse-proxy subpath without a runtime patch.
|
||||
ARG OMNIROUTE_BASE_PATH=""
|
||||
ENV OMNIROUTE_BASE_PATH=$OMNIROUTE_BASE_PATH
|
||||
|
||||
# #10273: the dashboard's `frame-ancestors` policy is compiled into the route
|
||||
# manifest by next.config.mjs (via scripts/build/dashboardEmbed.mjs), so it is
|
||||
# fixed when the image is built and cannot be flipped with `-e` on a running
|
||||
# container. Build with `--build-arg DASHBOARD_ALLOW_EMBED=vscode` to produce an
|
||||
# image whose HTML pages may be framed by the VS Code Simple Browser
|
||||
# (OmniCopilot's `dashboardOpen: "editor"`). Unset — the default — keeps every
|
||||
# route on `frame-ancestors 'none'` + X-Frame-Options: DENY. Builder-stage only:
|
||||
# the runner stage deliberately does not carry it, because a runtime value would
|
||||
# suggest an effect it cannot have.
|
||||
ARG DASHBOARD_ALLOW_EMBED=""
|
||||
ENV DASHBOARD_ALLOW_EMBED=$DASHBOARD_ALLOW_EMBED
|
||||
|
||||
# Docker containers cannot run the MITM/Agent-Bridge stack (no host DNS/cert
|
||||
# access), so keep @/mitm/manager on the graceful stub (#3390). This flag is
|
||||
# Docker-only: npm/Electron/VPS builds must bundle the REAL manager (#6344).
|
||||
@@ -172,48 +114,14 @@ ENV OMNIROUTE_MITM_STUB=1
|
||||
# child (build-next-isolated.mjs → resolveNextBuildEnv spreads process.env).
|
||||
# Build-only; the runtime heap is set separately on the runner stage
|
||||
# (OMNIROUTE_MEMORY_MB). Override: `--build-arg OMNIROUTE_BUILD_MEMORY_MB=6144`.
|
||||
# Default raised 4096 → 6144 (#10060): the Next 16 production pass on a codebase
|
||||
# this size intermittently OOMs a build worker at 4 GB on memory-tight hosts.
|
||||
ARG OMNIROUTE_BUILD_MEMORY_MB=6144
|
||||
ARG OMNIROUTE_BUILD_MEMORY_MB=4096
|
||||
ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_BUILD_MEMORY_MB}"
|
||||
|
||||
# Cap Next.js build worker pools. Next 16 defaults to `os.cpus().length - 1`
|
||||
# workers for page-data collection (31 on a 32-core builder); on memory-tight
|
||||
# hosts 31 workers + webpack's multi-GB heap blow past RAM and a worker dies
|
||||
# with SIGSEGV at teardown ("worker exited with code: null and signal: SIGSEGV"),
|
||||
# silently leaving no standalone bundle. Next derives the worker count from
|
||||
# CIRCLE_NODE_TOTAL (workers = N-1). (#10060)
|
||||
#
|
||||
# Lowered 8 → 3 (7 workers → 2) in #11419, then 3 → 2 (2 workers → 1) in #7518.
|
||||
# Every page-data worker inherits NODE_OPTIONS above, so the ceiling is per
|
||||
# PROCESS, not per build: 7 workers on a 16 GB GitHub runner (ubuntu-24.04 /
|
||||
# ubuntu-24.04-arm, 4 vCPU) exhausted the host and buildkit failed the whole
|
||||
# step with `ResourceExhausted: ... cannot allocate memory`. The compile phase
|
||||
# always finished ("✓ Compiled successfully in 4.2min"); the kernel killed the
|
||||
# build right after "Collecting page data using N workers".
|
||||
#
|
||||
# #11419's first fix (8 → 3) modeled the per-worker peak as an INFERENCE
|
||||
# (2560 MB, guessed from "7 workers didn't fit") and assumed the parent
|
||||
# process's RSS tracked the V8 heap ceiling. Both assumptions were wrong: a
|
||||
# live VPS reproduction (issue #7518, dmesg OOM-killer report) measured the
|
||||
# real per-process RSS directly at ~4.5 GB, independent of the NODE_OPTIONS
|
||||
# heap flag (Turbopack itself is native/Rust, outside the V8 heap) — and it
|
||||
# applies to the parent process too, not just workers. 2 workers (3 processes
|
||||
# × 4.5 GB = 13.5 GB) still didn't fit the 12.288 GB (75%) budget on a 16 GB
|
||||
# runner, matching the still-live publish failures after #11419 merged. 1
|
||||
# worker (2 processes × 4.5 GB = 9 GB) fits with headroom to spare.
|
||||
# tests/unit/docker-build-memory-budget.test.ts does the arithmetic against
|
||||
# the measured figure and fails if either knob is raised past what a 16 GB
|
||||
# runner holds. Override for a big builder: `--build-arg
|
||||
# OMNIROUTE_BUILD_WORKERS=8`.
|
||||
ARG OMNIROUTE_BUILD_WORKERS=2
|
||||
ENV CIRCLE_NODE_TOTAL=${OMNIROUTE_BUILD_WORKERS}
|
||||
|
||||
COPY . ./
|
||||
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-next-cache,target=/app/.build/next/cache \
|
||||
RUN --mount=type=cache,id=next-cache,target=/app/.build/next/cache \
|
||||
mkdir -p /app/data \
|
||||
&& npm run build \
|
||||
&& node --input-type=module -e "import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; const standaloneRoot = '/app/.build/next/standalone/node_modules/'; const require = createRequire('/app/.build/next/standalone/package.json'); for (const pkg of ['@atjsh/llmlingua-2', '@huggingface/transformers', 'js-tiktoken']) { const resolved = require.resolve(pkg); if (!resolved.startsWith(standaloneRoot)) throw new Error(pkg + ' resolved outside standalone: ' + resolved); await import(pathToFileURL(resolved).href); } const onnxRuntime = require.resolve('onnxruntime-node'); if (!onnxRuntime.startsWith(standaloneRoot)) throw new Error('onnxruntime-node resolved outside standalone: ' + onnxRuntime); await import(pathToFileURL(onnxRuntime).href);"
|
||||
&& node --input-type=module -e "import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; const standaloneRoot = '/app/.build/next/standalone/node_modules/'; const require = createRequire('/app/.build/next/standalone/package.json'); for (const pkg of ['@atjsh/llmlingua-2', '@huggingface/transformers', '@tensorflow/tfjs', 'js-tiktoken']) { const resolved = require.resolve(pkg); if (!resolved.startsWith(standaloneRoot)) throw new Error(pkg + ' resolved outside standalone: ' + resolved); await import(pathToFileURL(resolved).href); } const onnxRuntime = require.resolve('onnxruntime-node'); if (!onnxRuntime.startsWith(standaloneRoot)) throw new Error('onnxruntime-node resolved outside standalone: ' + onnxRuntime); await import(pathToFileURL(onnxRuntime).href);"
|
||||
|
||||
# ── Runner base ────────────────────────────────────────────────────────────
|
||||
FROM base AS runner-base
|
||||
@@ -314,8 +222,8 @@ COPY --from=builder /app/node_modules/playwright ./node_modules/playwright
|
||||
# browsers land under /home/node which persists across image layers and is
|
||||
# accessible to the non-root runtime user.
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright
|
||||
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-lists,target=/var/lib/apt/lists,sharing=locked \
|
||||
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \
|
||||
apt-get update \
|
||||
&& node node_modules/playwright/cli.js install chromium --with-deps \
|
||||
&& chown -R node:node /home/node/.cache \
|
||||
@@ -330,21 +238,16 @@ FROM runner-base AS runner-cli
|
||||
# runner-base runs.
|
||||
USER root
|
||||
|
||||
# The CLI image can use the internal ChatGPT Web (Codex) Chromium sidecar over
|
||||
# CDP without installing a second browser in this container.
|
||||
COPY --from=builder /app/node_modules/playwright-core ./node_modules/playwright-core
|
||||
COPY --from=builder /app/node_modules/playwright ./node_modules/playwright
|
||||
|
||||
# Install system dependencies required by openclaw (git+ssh references).
|
||||
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-lists,target=/var/lib/apt/lists,sharing=locked \
|
||||
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \
|
||||
apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git ca-certificates docker.io docker-compose \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& git config --system url."https://github.com/".insteadOf "ssh://git@github.com/"
|
||||
|
||||
# Install CLI tools globally. Separate layer from apt for better cache reuse.
|
||||
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-npm-cache,target=/root/.npm \
|
||||
RUN --mount=type=cache,id=npm-cache,target=/root/.npm \
|
||||
npm install -g --no-audit --no-fund @openai/codex @anthropic-ai/claude-code droid openclaw@latest
|
||||
|
||||
USER node
|
||||
|
||||
168
Dockerfile.bun
168
Dockerfile.bun
@@ -1,168 +0,0 @@
|
||||
# ── Multi-stage Dockerfile for Native Bun Runtime (web-latest-bun) ───────────
|
||||
FROM oven/bun:1.3.14-slim AS base
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get upgrade -y \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
python3 \
|
||||
python-is-python3 \
|
||||
make \
|
||||
g++ \
|
||||
libsecret-1-0 \
|
||||
ca-certificates \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── Builder stage (100% Bun Native Install & Build) ─────────────────────────
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Cache dependency layer
|
||||
COPY package.json bun.lock* pnpm-workspace.yaml* ./
|
||||
COPY open-sse/package.json ./open-sse/package.json
|
||||
COPY packages/ ./packages/
|
||||
|
||||
# Root postinstall helpers needed during bun install lifecycle
|
||||
COPY scripts/build/ ./scripts/build/
|
||||
COPY scripts/dev/sync-env.mjs ./scripts/dev/sync-env.mjs
|
||||
|
||||
# Fast Bun native package install
|
||||
RUN bun install --include=optional --quiet
|
||||
|
||||
# Fetch tls-client-node native binary if script exists
|
||||
RUN if [ -f "node_modules/tls-client-node/scripts/postinstall.js" ] && [ ! -d "node_modules/tls-client-node/bin" ]; then \
|
||||
bun node_modules/tls-client-node/scripts/postinstall.js || true; \
|
||||
fi
|
||||
|
||||
# Smoke check native database driver used by Bun (bun:sqlite)
|
||||
RUN bun -e "import { Database } from 'bun:sqlite'; const db = new Database(':memory:'); db.query('SELECT 1 AS ok').get(); db.close(); console.log('bun:sqlite smoke: OK');"
|
||||
|
||||
COPY . .
|
||||
|
||||
# Turbopack is supported on Bun 1.4 + Next 16.3; override via --build-arg OMNIROUTE_USE_TURBOPACK=0 if needed
|
||||
ARG OMNIROUTE_USE_TURBOPACK=1
|
||||
ENV OMNIROUTE_USE_TURBOPACK=${OMNIROUTE_USE_TURBOPACK}
|
||||
|
||||
ARG OMNIROUTE_BASE_PATH=""
|
||||
ENV OMNIROUTE_BASE_PATH=$OMNIROUTE_BASE_PATH
|
||||
|
||||
ARG DASHBOARD_ALLOW_EMBED=""
|
||||
ENV DASHBOARD_ALLOW_EMBED=$DASHBOARD_ALLOW_EMBED
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Bun native Next.js build execution
|
||||
RUN bun run --quiet build
|
||||
|
||||
# ── Runner Base stage (100% Bun Native Production Runtime) ──────────────────
|
||||
FROM oven/bun:1.3.14-slim AS runner-base
|
||||
|
||||
LABEL org.opencontainers.image.title="omniroute" \
|
||||
org.opencontainers.image.description="Unified AI proxy — route any LLM through one endpoint (Bun Native)" \
|
||||
org.opencontainers.image.url="https://omniroute.online" \
|
||||
org.opencontainers.image.source="https://github.com/diegosouzapw/OmniRoute" \
|
||||
org.opencontainers.image.licenses="MIT"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
libsecret-1-0 \
|
||||
ca-certificates \
|
||||
curl \
|
||||
sqlite3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=20128
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
ENV OMNIROUTE_MEMORY_MB=1024
|
||||
|
||||
ENV DATA_DIR=/app/data
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
COPY --from=builder /app/.build/next/standalone ./
|
||||
|
||||
ENV OMNIROUTE_MIGRATIONS_DIR=/app/migrations
|
||||
|
||||
COPY --from=builder /app/scripts/dev/healthcheck.mjs ./healthcheck.mjs
|
||||
|
||||
# Bun uses bun:sqlite. Remove every standalone/vendor copy of the Node-only
|
||||
# addon so no traced chunk can dlopen it and abort the process before fallback.
|
||||
RUN find /app \
|
||||
-path '*/node_modules/better-sqlite3' \
|
||||
-prune \
|
||||
-exec rm -rf '{}' + \
|
||||
&& test -z "$(find /app -type f -name 'better_sqlite3.node' -print -quit)"
|
||||
|
||||
RUN chown -R bun:bun /app /app/data
|
||||
|
||||
USER bun
|
||||
|
||||
EXPOSE 20128
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD bun healthcheck.mjs || exit 1
|
||||
|
||||
ENTRYPOINT ["bun", "dev/run-standalone.mjs"]
|
||||
|
||||
# ── Runner Web stage (Bun Native + Chromium/Playwright for Web providers) ───
|
||||
FROM runner-base AS runner-web
|
||||
|
||||
USER root
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
chromium \
|
||||
chromium-driver \
|
||||
fonts-liberation \
|
||||
libasound2t64 \
|
||||
gconf-service \
|
||||
libatk-bridge2.0-0 \
|
||||
libatk1.0-0 \
|
||||
libc6 \
|
||||
libcairo2 \
|
||||
libcups2 \
|
||||
libdbus-1-3 \
|
||||
libexpat1 \
|
||||
libfontconfig1 \
|
||||
libgbm1 \
|
||||
libgcc-s1 \
|
||||
libglib2.0-0 \
|
||||
libgtk-3-0 \
|
||||
libnspr4 \
|
||||
libnss3 \
|
||||
libpango-1.0-0 \
|
||||
pangocairo-1.0-0 \
|
||||
stdc++6 \
|
||||
libx11-6 \
|
||||
libx11-xcb1 \
|
||||
libxcb1 \
|
||||
libxcomposite1 \
|
||||
libxcursor1 \
|
||||
libxdamage1 \
|
||||
libxext6 \
|
||||
libxfixes3 \
|
||||
libxi6 \
|
||||
libxrandr2 \
|
||||
libxrender1 \
|
||||
libxss1 \
|
||||
libxtst6 \
|
||||
ca-certificates \
|
||||
fonts-gargi \
|
||||
fonts-ipafont-gothic \
|
||||
fonts-kacst \
|
||||
fonts-thai-tlwg \
|
||||
fonts-wqy-zenhei \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
||||
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
|
||||
# Drop back to default non-root user
|
||||
USER bun
|
||||
|
||||
ENTRYPOINT ["bun", "dev/run-standalone.mjs"]
|
||||
@@ -1,7 +1,7 @@
|
||||
# GEMINI.md
|
||||
|
||||
> **Single source of truth:** all project rules for AI assistants live in
|
||||
> [`AGENTS.md`](AGENTS.md). Read it in full before any change — it contains the 23 Hard Rules,
|
||||
> [`AGENTS.md`](AGENTS.md). Read it in full before any change — it contains the 22 Hard Rules,
|
||||
> quality gates, code conventions, file-placement / repo-root hygiene rules, the repository map
|
||||
> and the local development access notes that used to live in this file.
|
||||
|
||||
|
||||
69
Makefile
69
Makefile
@@ -1,69 +0,0 @@
|
||||
.PHONY: help install dev start build build-release lint typecheck typecheck-strict \
|
||||
test test-unit test-vitest test-coverage test-all test-integration test-e2e \
|
||||
check check-cycles check-docs env-sync clean
|
||||
|
||||
# OmniRoute — convenience wrapper around the npm scripts.
|
||||
# All targets delegate to the canonical package.json scripts (single source of truth).
|
||||
|
||||
help: ## Show this help
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
install: ## Install dependencies (auto-generates .env from .env.example)
|
||||
npm install
|
||||
|
||||
dev: ## Dev server at http://localhost:20128
|
||||
npm run dev
|
||||
|
||||
start: ## Production server (requires a prior build)
|
||||
npm run start
|
||||
|
||||
build: ## Production build (Next.js 16 standalone)
|
||||
npm run build
|
||||
|
||||
build-release: ## Release build
|
||||
npm run build:release
|
||||
|
||||
lint: ## ESLint (0 errors expected)
|
||||
npm run lint
|
||||
|
||||
typecheck: ## TypeScript check (core)
|
||||
npm run typecheck:core
|
||||
|
||||
typecheck-strict: ## Strict check (no implicit any)
|
||||
npm run typecheck:noimplicit:core
|
||||
|
||||
test: ## Unit tests (Node native runner)
|
||||
npm run test:unit
|
||||
|
||||
test-unit: ## Alias for `test`
|
||||
npm run test:unit
|
||||
|
||||
test-vitest: ## Vitest (MCP server, autoCombo, cache)
|
||||
npm run test:vitest
|
||||
|
||||
test-coverage: ## Unit tests + coverage gate (60/60/60/60)
|
||||
npm run test:coverage
|
||||
|
||||
test-all: ## All suites (unit + vitest + ecosystem + e2e)
|
||||
npm run test:all
|
||||
|
||||
test-integration: ## Integration tests
|
||||
npm run test:integration
|
||||
|
||||
test-e2e: ## E2E (Playwright)
|
||||
npm run test:e2e
|
||||
|
||||
check: ## lint + test combined
|
||||
npm run check
|
||||
|
||||
check-cycles: ## Detect circular dependencies
|
||||
npm run check:cycles
|
||||
|
||||
check-docs: ## Validate documentation (incl. fabricated-docs)
|
||||
npm run check:docs-all
|
||||
|
||||
env-sync: ## Sync .env from .env.example
|
||||
npm run env:sync
|
||||
|
||||
clean: ## Remove build artifacts
|
||||
rm -rf .build dist coverage .eslintcache
|
||||
541
README.md
541
README.md
@@ -7,19 +7,19 @@
|
||||
|
||||
# 🚀 OmniRoute — The Free AI Gateway
|
||||
|
||||
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 351 providers — 90+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 15–95% tokens (~89% avg) — never hit limits. 351 AI providers · 90+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start."/>
|
||||
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 291 providers — 90+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 15–95% tokens (~89% avg) — never hit limits. 291 AI providers · 90+ free tiers · ~1.53B free tokens/mo · 19 routing strategies · $0 to start."/>
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
## 💰 ~1.51B Free Tokens / Month
|
||||
## 💰 ~1.53B Free Tokens / Month
|
||||
|
||||
</div>
|
||||
|
||||
> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute catalogs **445 free-tier entries across 39 recurring pool keys** and computes the token headline from the **20 pools with a published positive monthly budget**, deduplicated by shared pool. The result stays visible on the dashboard (`/dashboard/free-tiers`).
|
||||
> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute aggregates the **documented** free tiers of **43 provider pools / 516 models** into one honest number and shows it live on the dashboard (`/dashboard/free-tiers`).
|
||||
|
||||
<img src="./docs/diagrams/free-tier-budget.svg" width="100%" alt="OmniRoute free-tier budget card: ~1.51B free tokens per month steady, up to ~2.13B in the first month with signup credits, from 39 documented recurring pool keys covering 445 cataloged free-tier entries behind one endpoint. Honest pool-deduped math — each shared pool counted once, including 20 recurring pools with a published positive monthly token budget; 13 providers are marked avoid in the terms-risk catalog so you decide. Budget bar includes Mistral 1B, LLM7 150M, Nara 150M, Gemini 60M and smaller pools, plus first-month signup credits and permanently-free no-token-cap providers surfaced separately so they never inflate the headline. Live used/remaining on /dashboard/free-tiers."/>
|
||||
<img src="./docs/diagrams/free-tier-budget.svg" width="100%" alt="OmniRoute free-tier budget card: ~1.53B free tokens per month steady, up to ~2.15B in the first month with signup credits, from the documented free tiers of 43 provider pools / 516 models behind one endpoint. Honest pool-deduped math — each shared pool counted once (counting every rate limit 24/7 would read ~10B; not published), 15 providers ToS-flagged so you decide. Budget bar of the countable free pools with per-model grid (Mistral Large 3 1B, GPT-4o mini 150M, Gemini 2.5 Flash 60M … Claude Sonnet 4.5 25K), one-time first-month signup credits (vertex 300M, agentrouter 200M, predibase 25M, together 25M, glm-cn 20M, doubao 15M, ai21 10M, longcat 10M, deepseek 5M, hyperbolic 5M, nscale 5M), plus permanently-free no-token-cap providers (SiliconFlow, Z.AI GLM-Flash, Kilo, OpenCode Zen, baidu …) and a $10 OpenRouter top-up unlocking +24M/mo — surfaced separately so they never inflate the headline. Live used/remaining on /dashboard/free-tiers."/>
|
||||
|
||||
> Animated summary of the live `/dashboard/free-tiers` page. Full methodology (pool dedupe, credit tiers, provider terms): **[docs/reference/FREE_TIERS.md](docs/reference/FREE_TIERS.md)**.
|
||||
>
|
||||
@@ -38,7 +38,6 @@
|
||||
[](https://github.com/diegosouzapw/OmniRoute)
|
||||
<a href="https://trendshift.io/repositories/23589" target="_blank"><img src="https://trendshift.io/api/badge/repositories/23589" alt="diegosouzapw%2FOmniRoute | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
[](https://www.star-history.com/diegosouzapw/omniroute)
|
||||
[](https://olud.ai/project/diegosouzapw-omniroute.html)
|
||||
|
||||
### 💬 Join the community
|
||||
|
||||
@@ -57,25 +56,6 @@
|
||||
|
||||
<br/>
|
||||
|
||||
## 📈 The Gateway Keeps Growing
|
||||
|
||||
<div align="center">
|
||||
|
||||
| | v3.8.49 | **v3.8.50** | `v3.8.51+` |
|
||||
| ------------------------- | :-----: | :-----------------------: | :---------: |
|
||||
| 🌐 Providers | 290 | **350** | more queued |
|
||||
| 🧠 Unique chat model IDs | 1185 | **1312** | — |
|
||||
| 🖼️ Modality Bridge | — | 🆕 vision + audio + video | — |
|
||||
| 📡 Radar free catalog | — | 🆕 opt-in | — |
|
||||
| ⚖️ Quota-aware scheduling | — | 🆕 Quota-Share | — |
|
||||
| 📊 Quota telemetry | — | 🆕 live | — |
|
||||
|
||||
**→ [Roadmap](ROADMAP.md) — riding the rail to `v3.9.0 LTS`**
|
||||
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
## 🧩 Available
|
||||
|
||||
[](https://www.npmjs.com/package/omniroute)
|
||||
@@ -101,7 +81,7 @@
|
||||
<tr>
|
||||
<td align="right"><b>⚙️ Features</b></td>
|
||||
<td align="center"><a href="#-combos--the-flagship">🎯 Combos</a></td>
|
||||
<td align="center"><a href="#-351-ai-providers--154-catalog-marked-free">🌐 Providers</a></td>
|
||||
<td align="center"><a href="#-291-ai-providers--90-free">🌐 Providers</a></td>
|
||||
<td align="center"><a href="#-full-cli--a2a--mcp">🔌 CLI & MCP</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -126,7 +106,7 @@
|
||||
<td align="right"><b>📦 Project</b></td>
|
||||
<td align="center"><a href="#%EF%B8%8F-tech-stack">🛠️ Tech Stack</a></td>
|
||||
<td align="center"><a href="#-documentation">📖 Docs</a></td>
|
||||
<td align="center"><a href="#-600-contributors">👥 Contributors</a></td>
|
||||
<td align="center"><a href="#-500-contributors">👥 Contributors</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -189,7 +169,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
<img src="./docs/diagrams/works-zero-config.svg" width="100%" alt="Works the second you install it — zero config. Three steps: 1. Install — npm i -g omniroute, server boots on localhost:20128. 2. Point your tool at http://localhost:20128/v1 — any OpenAI-compatible tool (Claude Code, Cursor, Cline). 3. It answers — call model auto for an instant reply, with no API key, no signup, no configuration. Keyless provider OpenCode Free is pre-wired into the auto combo, so a fresh install responds out of the box."/>
|
||||
<img src="./docs/diagrams/works-zero-config.svg" width="100%" alt="Works the second you install it — zero config. Three steps: 1. Install — npm i -g omniroute, server boots on localhost:20128. 2. Point your tool at http://localhost:20128/v1 — any OpenAI-compatible tool (Claude Code, Cursor, Cline). 3. It answers — call model auto for an instant reply, with no API key, no signup, no configuration. Keyless free providers OpenCode Free and Felo are pre-wired into the auto combo, so a fresh install responds out of the box."/>
|
||||
|
||||
```bash
|
||||
# Fresh install, zero credentials — `auto` already works:
|
||||
@@ -198,9 +178,7 @@ curl http://localhost:20128/v1/chat/completions \
|
||||
-d '{"model":"auto","messages":[{"role":"user","content":"Hello!"}]}'
|
||||
```
|
||||
|
||||
<sub>Prefer a specific free backend? Call `oc/…` (OpenCode Free) directly. Then graduate to `auto` and let OmniRoute pick.</sub>
|
||||
|
||||
<sub>📦 Copy-paste quickstart scripts for **Python, Node.js, PHP, and cURL** → [`examples/quickstart/`](examples/quickstart/)</sub>
|
||||
<sub>Prefer a specific free backend? Call it directly, e.g. `oc/…` (OpenCode Free) or `felo/…` (Felo). Then graduate to `auto` and let OmniRoute pick.</sub>
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -210,7 +188,7 @@ curl http://localhost:20128/v1/chat/completions \
|
||||
|
||||
</div>
|
||||
|
||||
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint and 351 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 351 providers · up to 95% token savings on eligible workloads · $0 to start with 90+ free tiers and 56 recurring/keyless free-forever providers · 35 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals and 39,000+ static test declarations across 5,100+ tracked test files."/>
|
||||
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint. 291 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 291 providers in milliseconds, zero downtime) · Save up to 95% tokens (RTK + Caveman stacked compression cuts 15–95%, ~89% avg on tool-heavy sessions) · $0 to start (90+ free tiers, 40+ free forever — no card needed) · Every tool works (33 coding agents through one config) · One endpoint (OpenAI ↔ Claude ↔ Gemini ↔ Responses API at /v1) · Production-grade (circuit breakers, TLS stealth, MCP 109 tools, A2A, memory, guardrails, evals — 25,000+ tests)."/>
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
@@ -225,7 +203,7 @@ curl http://localhost:20128/v1/chat/completions \
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="./docs/diagrams/tier-cascade.svg" width="100%" alt="OmniRoute request flow: your IDE or CLI (Claude Code, Cursor, Cline…) calls one local endpoint (http://localhost:20128/v1); the OmniRoute Smart Router (RTK + Caveman compression, 19 routing strategies, circuit breakers, TLS stealth, MCP, A2A, guardrails) can fall back across 4 provider tiers while an eligible healthy target remains — Tier 1 Subscription, Tier 2 API Key, Tier 3 Cheap and Tier 4 Free."/>
|
||||
<img src="./docs/diagrams/tier-cascade.svg" width="100%" alt="OmniRoute request flow: your IDE or CLI (Claude Code, Cursor, Cline…) calls one local endpoint (http://localhost:20128/v1); the OmniRoute Smart Router (RTK + Caveman compression, 19 routing strategies, circuit breakers, TLS stealth, MCP, A2A, guardrails) auto-falls back across 4 provider tiers — Tier 1 Subscription (Claude Code, Codex, Copilot), quota out? Tier 2 API Key (DeepSeek, Groq, xAI), budget hit? Tier 3 Cheap (GLM $0.5, MiniMax $0.2), budget hit? Tier 4 Free (Kiro, Qoder, Pollinations) — always on."/>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -238,7 +216,7 @@ curl http://localhost:20128/v1/chat/completions \
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute">
|
||||
<a href="https://www.kimi.com/code?aff=omniroute">
|
||||
<img src="public/sponsors/kimi-k3-banner.png" width="100%" alt="Kimi K3 — Open Frontier Intelligence · 2.8T parameters · 1M-token context"/>
|
||||
</a>
|
||||
</p>
|
||||
@@ -248,7 +226,7 @@ curl http://localhost:20128/v1/chat/completions \
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="150">
|
||||
<a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute">
|
||||
<a href="https://www.kimi.com/code?aff=omniroute">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="public/providers/kimi-logomark-dark.svg">
|
||||
<img src="public/providers/kimi-logomark-light.svg" width="64" alt="Kimi (Moonshot AI)"/>
|
||||
@@ -260,13 +238,13 @@ curl http://localhost:20128/v1/chat/completions \
|
||||
<td>
|
||||
Thanks to <b>Kimi (Moonshot AI)</b>, our founding Open Source Friend, for backing this project! Kimi is the AI lab behind the open-weight K2 and K3 model families — <b>Kimi K3</b> delivers a 1M-token context window, native vision and frontier-level coding at a fraction of closed-model prices, and works out of the box with Claude Code, Codex and every coding tool OmniRoute serves.
|
||||
<br/><br/>
|
||||
<b>What Kimi's support powers:</b> Kimi's API credits power OmniRoute's AI-validated release pipeline — the <i>merge validation powered by Kimi K3</i> stage that reviews every pull request before it ships — plus day-to-day feature development. First-class Kimi support ships on both rails: the direct <a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute">Kimi API</a> (<code>kimi-k3</code>) and the <a href="https://www.kimi.com/code?aff=omniroute">Kimi Code coding plan</a> (OAuth and API key). OmniRoute is also the first Brazilian open-source project in Kimi's support program. <a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute"><b>Get a Kimi API key with 15% extra credits →</b></a>
|
||||
<b>What Kimi's support powers:</b> Kimi's API credits power OmniRoute's AI-validated release pipeline — the <i>merge validation powered by Kimi K3</i> stage that reviews every pull request before it ships — plus day-to-day feature development. First-class Kimi support ships on both rails: the direct <a href="https://platform.kimi.ai?aff=omniroute">Kimi API</a> (<code>kimi-k3</code>) and the <a href="https://www.kimi.com/code?aff=omniroute">Kimi Code coding plan</a> (OAuth and API key). OmniRoute is also the first Brazilian open-source project in Kimi's support program. <a href="https://platform.kimi.ai?aff=omniroute"><b>Get a Kimi API key →</b></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="150">
|
||||
<a href="https://cheaperinference.com/?utm_source=omniroute">
|
||||
<img src="./public/providers/cli-generic.svg" width="64" alt="Cheaper Inference"/>
|
||||
<img src="public/providers/cheaperinference.svg" width="64" alt="Cheaper Inference"/>
|
||||
</a>
|
||||
<br/><b>Cheaper Inference</b><br/><sub>cheaperinference.com</sub><br/><br/>
|
||||
<img src="https://img.shields.io/badge/Open_Source_Friend-31f889?style=flat-square&labelColor=04170d" alt="Open Source Friend"/>
|
||||
@@ -292,7 +270,7 @@ curl http://localhost:20128/v1/chat/completions \
|
||||
<tr>
|
||||
<td align="center" width="120">
|
||||
<a href="https://agentrouter.org/register?aff=70LM">
|
||||
<img src="./public/providers/cli-generic.svg" width="32" alt="AgentRouter"/>
|
||||
<img src="public/providers/agentrouter.png" width="32" alt="AgentRouter"/>
|
||||
</a>
|
||||
<br/><sub><b>AgentRouter</b></sub><br/><sub>agentrouter.org</sub>
|
||||
</td>
|
||||
@@ -318,7 +296,7 @@ curl http://localhost:20128/v1/chat/completions \
|
||||
|
||||
<img src="./docs/diagrams/strategies-grid.svg" width="100%" alt="All 19 combo routing strategies animated — one tile per strategy: priority, fill-first, weighted, round-robin, p2c, least-used, random, strict-random, cost-optimized, headroom, reset-window, reset-aware, context-relay, context-optimized, cache-optimized, lkgp, auto, fusion, pipeline. See the table above for what each one does."/>
|
||||
|
||||
> A **combo** is a chain of models OmniRoute routes across **automatically**. If quota runs out, a provider fails, or costs spike, the combo can move to the next eligible healthy model. 🛡️
|
||||
> A **combo** is a chain of models OmniRoute routes across **automatically**. Quota runs out, a provider fails, or costs spike — the combo silently slides to the next model. **This is what makes OmniRoute unbreakable.** 🛡️
|
||||
|
||||
### ⚡ Zero-config — just use `auto`
|
||||
|
||||
@@ -424,12 +402,12 @@ All **19** strategies — mix & match per combo step:
|
||||
<tr>
|
||||
<td align="center">16</td>
|
||||
<td nowrap><code>lkgp</code></td>
|
||||
<td>Last-Known-Good Path — pins to the last successful provider, then falls back to rules</td>
|
||||
<td>Last-Known-Good Path — sticky to the last successful target</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">17</td>
|
||||
<td nowrap><code>auto</code></td>
|
||||
<td>15-factor live scoring across every connection 🤖</td>
|
||||
<td>12-factor live scoring across every connection 🤖</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">18</td>
|
||||
@@ -443,13 +421,13 @@ All **19** strategies — mix & match per combo step:
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>The Auto-Combo engine scores every candidate on **15 factors** (health, quota, cost, latency, task fit, quality, session availability…) — see [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md).</sub>
|
||||
<sub>The Auto-Combo engine scores every candidate on **12 factors** (health, quota, cost, latency, success rate, freshness…) — see [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md).</sub>
|
||||
|
||||
##
|
||||
|
||||
### 🧱 Resilience is built in (3 independent layers)
|
||||
|
||||
<img src="./docs/diagrams/resilience-layers.svg" width="100%" alt="OmniRoute resilience — 3 independent self-healing layers, the right layer for the right failure. Layer 1 provider circuit breaker (whole provider): trips only on 408/5xx, thresholds OAuth 10× / API-key 15× / local 2×, resets 60s/30s/15s into a HALF-OPEN probe, lazy recovery; while OPEN the combo reroutes to the next provider. Layer 2 connection cooldown (one key/account): base 5s OAuth / 3s API-key, exponential ×2 backoff with anti-thundering-herd guard, 429 honors Retry-After, success clears all error state; one cooling key is skipped while sibling keys keep serving. Layer 3 model lockout (one model): per-model 429, local 404 or mode denials lock just that model — never the whole connection. Terminal states (banned, expired, credits exhausted) are for the operator, not cooldowns."/>
|
||||
<img src="./docs/diagrams/resilience-layers.svg" width="100%" alt="OmniRoute resilience — 3 independent self-healing layers, the right layer for the right failure. Layer 1 provider circuit breaker (whole provider): trips only on 408/5xx, thresholds OAuth 3× / API-key 5× / local 2×, resets 60s/30s/15s into a HALF-OPEN probe, lazy recovery; while OPEN the combo reroutes to the next provider. Layer 2 connection cooldown (one key/account): base 5s OAuth / 3s API-key, exponential ×2 backoff with anti-thundering-herd guard, 429 honors Retry-After, success clears all error state; one cooling key is skipped while sibling keys keep serving. Layer 3 model lockout (one model): per-model 429, local 404 or mode denials lock just that model — never the whole connection. Terminal states (banned, expired, credits exhausted) are for the operator, not cooldowns."/>
|
||||
|
||||
<sub>📖 [Auto-Combo Engine](docs/routing/AUTO-COMBO.md) · [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md)</sub>
|
||||
|
||||
@@ -461,7 +439,7 @@ All **19** strategies — mix & match per combo step:
|
||||
|
||||
</div>
|
||||
|
||||
<img src="./docs/diagrams/comparison-table.svg" width="100%" alt="What sets OmniRoute apart — a dated feature snapshot vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 351 providers, 90+ free tiers built in, 19 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA and 43 i18n UI locales. OmniRoute is MIT-licensed and self-hostable. Competitor capabilities and counts may change; see the linked methodology."/>
|
||||
<img src="./docs/diagrams/comparison-table.svg" width="100%" alt="What sets OmniRoute apart — comparison table vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 291 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, built-in MCP server with 109 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA, 43 i18n UI locales, 100% MIT self-hosted. OmniRoute is the only one with the full set; competitors show a mix of checks, partials and crosses. Verified from each project's docs."/>
|
||||
|
||||
<sub>📊 Full methodology & per-feature detail vs 9router, OpenRouter, CLIProxyAPI & LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md)</sub>
|
||||
|
||||
@@ -513,13 +491,11 @@ Pix copia-e-cola:
|
||||
|
||||
<br/>
|
||||
|
||||
<p><strong>Developer notes:</strong> The project may generate a local <code>.env</code> file during npm install/postinstall for developer convenience. This file is intentionally ignored via <code>.gitignore</code> (see <code>.gitignore</code>) and must never be committed — if accidentally committed, rotate any exposed secrets and remove the file from history. See <a href="docs/DEVELOPER-ENVIRONMENT.md">docs/DEVELOPER-ENVIRONMENT.md</a> for guidance on managing local environment files and secrets.</p>
|
||||
|
||||
## 📡 OmniRoute Radar
|
||||
|
||||
The main free-tier headline remains **~1.51B tokens/month** from the documented,
|
||||
The main free-tier headline remains **~1.53B tokens/month** from the documented,
|
||||
pool-deduplicated catalog above. Temporary provider signup credits can separately lift the first
|
||||
month to **~2.13B**. Radar is an optional, signed catalog overlay for people who want fresher
|
||||
month to **~2.15B**. Radar is an optional, signed catalog overlay for people who want fresher
|
||||
free-model availability between OmniRoute releases; the community catalog and every existing free
|
||||
feature remain free.
|
||||
|
||||
@@ -540,15 +516,12 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
|
||||
|
||||
</div>
|
||||
|
||||
> Recent highlights from **v3.8.20 → v3.8.50**. Full history in [`CHANGELOG.md`](CHANGELOG.md).
|
||||
> Recent highlights from **v3.8.20 → v3.8.49**. Full history in [`CHANGELOG.md`](CHANGELOG.md).
|
||||
|
||||
- **🎛️ OmniConductor** — inbound A2A delegation to your agent fleet, Conductor skills on the Agent Card, and a dashboard panel with Faro push-to-talk voice chat. → [A2A Server](docs/frameworks/A2A-SERVER.md)
|
||||
- **🛂 Adaptive admission & overload protection** — heavyweight chat requests queue instead of 503ing, with atomic RPM rolling leases per connection. → [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md)
|
||||
- **🗂️ Canonical `/v1/models` ordering** — one contiguous provider-grouped block per provider (combos pinned first), stable across every catalog source. → [API Reference](docs/reference/API_REFERENCE.md)
|
||||
- **🗜️ Compression hardening** — default-on inflation guard, Caveman packs for DE / FR / JA + Chinese (wényán), RTK filters for Gradle & .NET. → [Compression](docs/compression/COMPRESSION_ENGINES.md)
|
||||
- **💸 Honest flat-rate cost** — subscription / coding-plan providers read **$0** in cost analytics; budget, quota & routing keep estimating. → [API Reference](docs/reference/API_REFERENCE.md)
|
||||
- **⚖️ Quota-Share routing** — split a shared account's quota fairly across pooled keys, work-conserving so idle slices are lent out. → [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md)
|
||||
- **🤖 One-command CLI/agent setup** — 12 registered `setup-*` commands; `omniroute run` launches 7 CLIs (Claude Code, Codex, Aider, Goose, OpenCode, Qwen Code, Gemini CLI); `omniroute configure` supports 9 targets with an interactive provider+model picker and per-context favorites. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
|
||||
- **🤖 One-command CLI/agent setup** — `setup-*` configures 12+ coding tools; `omniroute launch` / `launch-codex` are zero-config. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
|
||||
- **🛰️ Remote mode** — drive a remote OmniRoute with scoped tokens (`connect` / `contexts` / `tokens`) + an `antigravity` OAuth helper for VPS installs. → [Remote Mode](docs/guides/REMOTE-MODE.md)
|
||||
- **🧭 Smarter auto-routing** — `auto/<category>:<tier>` combos, **Fusion** (model panel + judge), task-aware routing, per-request model / mode / USD-budget overrides. → [Auto-Combo](docs/routing/AUTO-COMBO.md)
|
||||
- **🗜️ Pluggable compression** — 12 composable engines + Compression Studios: LLMLingua-2, two-tier Ultra, omniglyph, per-step fidelity gate, GCF v3.2, drag-reorder editor. → [Compression](docs/compression/COMPRESSION_ENGINES.md)
|
||||
@@ -557,9 +530,9 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
|
||||
- **🧠 Memory you control** — off by default, opt-in int8 vector quantization + typed decay, per-request `x-omniroute-no-memory`. → [Memory](docs/frameworks/MEMORY.md)
|
||||
- **🛡️ Security** — prompt-injection guard on every LLM route (red-team suite), opt-in credential-masking guardrail (redacts leaked API keys/secrets in both directions), free DuckDuckGo last-resort web search, and an optional OIDC login gate for the dashboard (password login always stays available). → [Guardrails](docs/security/GUARDRAILS.md)
|
||||
- **🖼️ New endpoints** — `/v1/ocr` (Mistral OCR) and `/v1/audio/translations` (Whisper-style) round out the media surface. → [API Reference](docs/reference/API_REFERENCE.md)
|
||||
- **🎨 Image / video / audio generation** — one API for media: xAI Grok Imagine & Novita AI video, ComfyUI, Freepik, Adobe Firefly, Segmind, and speech providers such as ElevenLabs. → [API Reference](docs/reference/API_REFERENCE.md)
|
||||
- **🎨 Image / video / audio generation** — one API for media: xAI Grok Imagine & Novita AI video, ComfyUI, Freepik, Adobe Firefly, Microsoft Designer, Google Imagen, Segmind, EdgeTTS. → [API Reference](docs/reference/API_REFERENCE.md)
|
||||
- **🌍 Deployment & ops** — reverse-proxy `basePath`, browser-language auto-detect, per-key device tracking, root-less MITM trust, zh-TW localization. → [Environment](docs/reference/ENVIRONMENT.md)
|
||||
- **🤝 More providers & agents** — Cursor Cloud Agent, Grok Build (xAI) with browser + OAuth login, Ollama first-class card, Claude Opus 5 & Sonnet 5, Kimi official partnership (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… and a refreshed **351-provider catalog**. → [Providers](docs/reference/PROVIDER_REFERENCE.md)
|
||||
- **🤝 More providers & agents** — Cursor Cloud Agent, Grok Build (xAI) with browser + OAuth login, Ollama first-class card, Claude Opus 5 & Sonnet 5, Kimi official partnership (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… and a refreshed **291-provider catalog**. → [Providers](docs/reference/PROVIDER_REFERENCE.md)
|
||||
- **📡 Routing transparency** — every response carries an `X-OmniRoute-Decision` header naming the strategy/provider/latency that served it, a new `cache-optimized` combo strategy + Auto-Combo `cacheAffinity` factor route repeat requests back to the connection holding the cached prefix, and a read-only `/v1/auto-combo/{channel}/candidates` endpoint exposes an `auto/*` channel's live candidate pool. → [Auto-Combo](docs/routing/AUTO-COMBO.md)
|
||||
- **⚡ Local performance & infra** — one-click local Redis, Cloudflare Workers / Deno Deploy relay deployers, Bifrost & Mux as supervised embedded services. → [Embedded Services](docs/frameworks/EMBEDDED-SERVICES.md)
|
||||
|
||||
@@ -577,8 +550,8 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
|
||||
<td align="center" width="76"><a href="https://github.com/anthropics/claude-code"><img src="./public/providers/claude.svg" width="40" alt="Claude Code"/><br/><sub><b>Claude Code</b></sub><br/><sub> </sub></a></td>
|
||||
<td align="center" width="76"><a href="https://github.com/openai/codex"><img src="./public/providers/codex.svg" width="40" alt="Codex CLI"/><br/><sub><b>Codex CLI</b></sub><br/><sub> </sub></a></td>
|
||||
<td align="center" width="76"><picture><source media="(prefers-color-scheme:dark)" srcset="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-png@1.91.0/dark/cline.png"/><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/cline.svg" width="40" alt="Cline"/></picture><br/><sub><b>Cline</b></sub><br/><sub> </sub></td>
|
||||
<td align="center" width="76"><a href="https://github.com/Kilo-Org/kilocode"><img src="./public/providers/cli-generic.svg" width="40" alt="Kilo Code"/><br/><sub><b>Kilo Code</b></sub><br/><sub> </sub></a></td>
|
||||
<td align="center" width="76"><a href="https://github.com/Zoo-Code-Org/Zoo-Code"><img src="./public/providers/cli-generic.svg" width="40" alt="Zoo Code"/><br/><sub><b>Zoo Code</b></sub><br/><sub> </sub></a></td>
|
||||
<td align="center" width="76"><a href="https://github.com/Kilo-Org/kilocode"><img src="./public/providers/kilocode.svg" width="40" alt="Kilo Code"/><br/><sub><b>Kilo Code</b></sub><br/><sub> </sub></a></td>
|
||||
<td align="center" width="76"><a href="https://github.com/Zoo-Code-Org/Zoo-Code"><img src="./public/providers/zoocode.png" width="40" alt="Zoo Code"/><br/><sub><b>Zoo Code</b></sub><br/><sub> </sub></a></td>
|
||||
<td align="center" width="76"><img src="./public/providers/continue.svg" width="40" alt="Continue"/><br/><sub><b>Continue</b></sub><br/><sub> </sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -587,10 +560,10 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
|
||||
<td align="center" width="76"><img src="./public/providers/cli-generic.svg" width="40" alt="jcode"/><br/><sub><b>jcode</b></sub><br/><sub> </sub></td>
|
||||
<td align="center" width="76"><img src="./public/providers/deepseek.svg" width="40" alt="DeepSeek TUI"/><br/><sub><b>DeepSeek TUI</b></sub><br/><sub> </sub></td>
|
||||
<td align="center" width="76"><img src="./public/providers/cli-generic.svg" width="40" alt="CodeWhale"/><br/><sub><b>CodeWhale</b></sub><br/><sub> </sub></td>
|
||||
<td align="center" width="76"><a href="https://github.com/anomalyco/opencode"><img src="./public/providers/cli-generic.svg" width="40" alt="OpenCode"/><br/><sub><b>OpenCode</b></sub><br/><sub> </sub></a></td>
|
||||
<td align="center" width="76"><a href="https://github.com/anomalyco/opencode"><picture><source media="(prefers-color-scheme:dark)" srcset="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-png@1.91.0/dark/opencode.png"/><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/opencode.svg" width="40" alt="OpenCode"/></picture><br/><sub><b>OpenCode</b></sub><br/><sub> </sub></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="76"><img src="./public/providers/cli-generic.svg" width="40" alt="Factory Droid"/><br/><sub><b>Factory Droid</b></sub><br/><sub> </sub></td>
|
||||
<td align="center" width="76"><img src="./public/providers/droid.svg" width="40" alt="Factory Droid"/><br/><sub><b>Factory Droid</b></sub><br/><sub> </sub></td>
|
||||
<td align="center" width="76"><img src="./public/providers/copilot.svg" width="40" alt="GitHub Copilot CLI"/><br/><sub><b>Copilot CLI</b></sub><br/><sub> </sub></td>
|
||||
<td align="center" width="76"><img src="./public/providers/cursor.svg" width="40" alt="Cursor CLI"/><br/><sub><b>Cursor CLI</b></sub><br/><sub> </sub></td>
|
||||
<td align="center" width="76"><img src="./public/providers/cli-generic.svg" width="40" alt="Smelt"/><br/><sub><b>Smelt</b></sub><br/><sub> </sub></td>
|
||||
@@ -612,41 +585,19 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
|
||||
<b>+ also works with</b> · Kiro · Command Code · Antigravity · Windsurf · AMP · <b>any OpenAI-compatible tool</b>
|
||||
</div>
|
||||
|
||||
<sub>📖 Per-tool setup for all 35 tools (26 CLI Code's + 9 CLI Agents) → [`docs/reference/CLI-TOOLS.md`](docs/reference/CLI-TOOLS.md) · 🧩 OpenCode plugin → [`@omniroute/opencode-provider`](https://www.npmjs.com/package/@omniroute/opencode-provider)</sub>
|
||||
<sub>📖 Per-tool setup for all 33 tools (25 CLI Code's + 8 CLI Agents) → [`docs/reference/CLI-TOOLS.md`](docs/reference/CLI-TOOLS.md) · 🧩 OpenCode plugin → [`@omniroute/opencode-provider`](https://www.npmjs.com/package/@omniroute/opencode-provider)</sub>
|
||||
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
**Launch any supported CLI through OmniRoute in one command** — no config files written,
|
||||
credentials injected per process, Qwen/Gemini get a throwaway isolated home:
|
||||
|
||||
```bash
|
||||
omniroute run claude --model openai/gpt-5.4 # Claude Code
|
||||
omniroute run codex --model glm/glm-5.2 # OpenAI Codex CLI
|
||||
omniroute run aider --model glm/glm-5.2 -- --message "reply OK"
|
||||
omniroute run goose --model glm/glm-5.2
|
||||
omniroute run opencode --model glm/glm-5.2 -- run "reply OK"
|
||||
omniroute run qwen --model glm/glm-5.2 -- -p "reply OK"
|
||||
omniroute run gemini --model glm/glm-5.2 -- --skip-trust -p "reply OK"
|
||||
|
||||
# Or pick provider+model interactively and write the tool's own config:
|
||||
omniroute configure codex # also: claude opencode qwen aider goose cline continue kilo
|
||||
```
|
||||
|
||||
Every command honors the active remote context (`omniroute connect <host>`), `--dry-run`
|
||||
previews the exact env/args without executing, and `--api-key-env NAME` keeps secrets out
|
||||
of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
|
||||
## 🌐 351 AI Providers — 154 Catalog-Marked Free
|
||||
## 🌐 291 AI Providers — 90+ Free
|
||||
|
||||
</div>
|
||||
|
||||
> **351 registered providers** across the canonical chat, media, search, local, cloud-agent and system collections, including **154 carrying `hasFree: true` discovery metadata**. The chat model registry covers **268 providers / 2,566 distinct provider-model pairs / 1,312 raw model IDs**; the separate free-budget catalog has **455 per-model rows**, **40 recurring pools** and **56 recurring/keyless free-forever providers**. These are different denominators by design; definitions and pool-deduped calculations live in the [Provider Reference](docs/reference/PROVIDER_REFERENCE.md) and [Free Tiers](docs/reference/FREE_TIERS.md).
|
||||
> The most complete catalog of any open-source router: **291 providers**, **90+ with a free tier**, **40+ free forever**.
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -679,7 +630,7 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>…and 330+ more — every icon resolves live from the dashboard's provider catalog. 📖 [Provider Reference](docs/reference/PROVIDER_REFERENCE.md)</sub>
|
||||
<sub>…and 220+ more — every icon resolves live from the dashboard's provider catalog. 📖 [Provider Reference](docs/reference/PROVIDER_REFERENCE.md)</sub>
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -687,8 +638,8 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="150"><img src="./public/providers/cli-generic.svg" width="42" alt="OpenCode Zen"/><br/><b>OpenCode Zen</b><br/><sub>DeepSeek V4, Nemotron 3<br/>No token cap</sub></td>
|
||||
<td align="center" width="150"><img src="./public/providers/cli-generic.svg" width="42" alt="Kilo Code"/><br/><b>Kilo Code</b><br/><sub>Auto-router, Tencent Hy3<br/>Free forever</sub></td>
|
||||
<td align="center" width="150"><img src="./public/providers/opencode.svg" width="42" alt="OpenCode Zen"/><br/><b>OpenCode Zen</b><br/><sub>DeepSeek V4, Nemotron 3<br/>No token cap</sub></td>
|
||||
<td align="center" width="150"><img src="./public/providers/kilocode.svg" width="42" alt="Kilo Code"/><br/><b>Kilo Code</b><br/><sub>Auto-router, Tencent Hy3<br/>Free forever</sub></td>
|
||||
<td align="center" width="150"><img src="./public/providers/requesty.svg" width="42" alt="Requesty"/><br/><b>Requesty</b><br/><sub>GPT-OSS 120B, Nemotron<br/>Free forever</sub></td>
|
||||
<td align="center" width="150"><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/siliconcloud-color.svg" width="42" alt="SiliconFlow"/><br/><b>SiliconFlow</b><br/><sub>DeepSeek V3.2 / R1<br/>Free tier</sub></td>
|
||||
<td align="center" width="150"><img src="./public/providers/zhipu.svg" width="42" alt="Z.AI GLM"/><br/><b>Z.AI GLM</b><br/><sub>GLM-4.7 / 4.5-Flash<br/>Free forever</sub></td>
|
||||
@@ -726,7 +677,6 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
|
||||
<tr><td align="left" nowrap>📱 <b>Android (Termux)</b></td><td align="left" nowrap><code>pkg install nodejs && npx -y omniroute</code></td><td align="left">Runs <b>on your phone</b>, 24/7, no root</td></tr>
|
||||
<tr><td align="left" nowrap>📲 <b>PWA</b></td><td align="left" nowrap>"Add to Home Screen"</td><td align="left">Fullscreen, offline, installable from browser</td></tr>
|
||||
<tr><td align="left" nowrap>🧩 <b>OpenCode plugin</b></td><td align="left" nowrap><code>@omniroute/opencode-provider</code></td><td align="left">Native OpenCode integration</td></tr>
|
||||
<tr><td align="left" nowrap>🤖 <b>VS Code Copilot Chat</b></td><td align="left" nowrap>install <b>OmniCopilot</b> extension</td><td align="left">Every OmniRoute model in the native Copilot Chat picker — stable & Insiders</td></tr>
|
||||
<tr><td align="left" nowrap>🛠️ <b>From source</b></td><td align="left" nowrap><code>npm install && npm run dev</code></td><td align="left">Hack on it, contribute</td></tr>
|
||||
</table>
|
||||
|
||||
@@ -736,40 +686,11 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
|
||||
|
||||
<div align="center">
|
||||
|
||||
### 🧩 New: OmniRoute inside VS Code's native Copilot Chat
|
||||
|
||||
</div>
|
||||
|
||||
> No new sidebar, no new chat UI — every model OmniRoute serves shows up right in the
|
||||
> **Copilot Chat model picker you already use**. Since VS Code 1.122, provider models work
|
||||
> without a GitHub sign-in or a Copilot subscription — agent mode, tool calling and vision, for
|
||||
> free.
|
||||
|
||||
Install the **[OmniCopilot](https://github.com/diegosouzapw/OmniCopilot)** extension, point it
|
||||
at your OmniRoute server (defaults to `localhost:20128`), then open Copilot Chat → model picker
|
||||
→ **Manage Models…** → **OmniRoute**.
|
||||
|
||||
<table>
|
||||
<tr><th align="left">Store</th><th align="left">Link</th><th align="left">Works with</th></tr>
|
||||
<tr><td align="left" nowrap>🧩 <b>VS Code Marketplace</b></td><td align="left"><a href="https://marketplace.visualstudio.com/items?itemName=diegosouzapw.omnicopilot">Install →</a></td><td align="left">VS Code — stable & Insiders</td></tr>
|
||||
<tr><td align="left" nowrap>🔓 <b>Open VSX Registry</b></td><td align="left"><a href="https://open-vsx.org/extension/diegosouzapw/omnicopilot">Install →</a></td><td align="left">Cursor, Windsurf, VSCodium, Theia, code-server, Gitpod, Antigravity, Kiro…</td></tr>
|
||||
</table>
|
||||
|
||||
From inside the editor: open the **Extensions** view, search **"OmniRoute"**, click **Install**
|
||||
— works the same way on both stores. Source, issues and the publishing runbook live at
|
||||
[diegosouzapw/OmniCopilot](https://github.com/diegosouzapw/OmniCopilot).
|
||||
|
||||
<sub>📖 [VS Code Copilot Chat guide](docs/guides/VSCODE-COPILOT.md) — setup, what the picker shows, dashboard-in-a-tab, troubleshooting</sub>
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
|
||||
## 🔒 Private & Local-First
|
||||
|
||||
</div>
|
||||
|
||||
<img src="./docs/diagrams/privacy-local.svg" width="100%" alt="Private and local-first — OmniRoute's gateway and control plane run on your machine. Prompts are sent to the upstream provider selected for each request; OmniRoute adds no hosted prompt-processing hop and telemetry is disabled by default. Credentials are encrypted at rest with AES-256-GCM; controls include API-key scoping, IP filtering, rate limits, prompt-injection guards, upstream-header scrubbing, opt-in PII redaction, sanitized errors and a local SQLite audit trail. OmniRoute is MIT-licensed and self-hostable."/>
|
||||
<img src="./docs/diagrams/privacy-local.svg" width="100%" alt="Private and local-first — your keys, your machine, your data; OmniRoute is a local proxy that never phones home. Eleven guarantees: runs 100% on your hardware (0 cloud hops), zero telemetry by default, credentials encrypted at rest (AES-256-GCM), no account or sign-up, hardened gateway (API-key scoping, IP filtering, rate limits, prompt-injection guard), loopback-only process routes, upstream header scrubbing, strictly opt-in PII redaction, sanitized errors that never leak internals, a local audit trail in your own SQLite, and MIT-licensed fully open-source code."/>
|
||||
|
||||
<sub>📖 [Authorization](docs/architecture/AUTHZ_GUIDE.md) · [Guardrails](docs/security/GUARDRAILS.md) · [Compliance](docs/security/COMPLIANCE.md)</sub>
|
||||
|
||||
@@ -810,7 +731,7 @@ Tokens are scoped `read` / `write` / `admin`; process-spawning routes stay loopb
|
||||
|
||||
<div align="left">
|
||||
|
||||
<img src="./docs/diagrams/cli-terminal.svg" width="50%" alt="Animated terminal demoing the OmniRoute CLI — omniroute providers list, omniroute combo list and omniroute health — cycling over the 85-command top-level surface: providers · oauth · keys · combo · nodes · models · cache · compression · cost · usage · quota · health · resilience · telemetry · logs · audit · mcp · a2a · cloud · memory · skills · eval · tunnel · backup · sync · webhooks · policy · pricing · translator · simulate …"/>
|
||||
<img src="./docs/diagrams/cli-terminal.svg" width="50%" alt="Animated terminal demoing the OmniRoute CLI — omniroute providers list, omniroute combo list, omniroute health — cycling over the 80+ command surface: providers · oauth · keys · combo · nodes · models · cache · compression · cost · usage · quota · health · resilience · telemetry · logs · audit · mcp · a2a · cloud · memory · skills · eval · tunnel · backup · sync · webhooks · policy · pricing · translator · simulate …"/>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -821,7 +742,7 @@ Expose OmniRoute over **MCP**, **A2A**, a **REST API**, **webhooks** or a **remo
|
||||
<table>
|
||||
<tr><th align="left">Interface</th><th align="left">Endpoint / command</th><th align="left">Use it for</th></tr>
|
||||
<tr><td align="left" nowrap>🧰 <b>MCP (stdio)</b></td><td align="left" nowrap><code>omniroute --mcp</code></td><td align="left">Plug into Claude Desktop, Cursor, any MCP client</td></tr>
|
||||
<tr><td align="left" nowrap>🌊 <b>MCP (HTTP)</b></td><td align="left" nowrap><code>/api/mcp/stream</code></td><td align="left">Remote MCP — <b>110 tools</b>, 33 scopes, full audit trail</td></tr>
|
||||
<tr><td align="left" nowrap>🌊 <b>MCP (HTTP)</b></td><td align="left" nowrap><code>/api/mcp/stream</code></td><td align="left">Remote MCP — <b>109 tools</b>, 33 scopes, full audit trail</td></tr>
|
||||
<tr><td align="left" nowrap>📡 <b>MCP (SSE)</b></td><td align="left" nowrap><code>/api/mcp/sse</code></td><td align="left">Streaming MCP transport</td></tr>
|
||||
<tr><td align="left" nowrap>🤝 <b>A2A</b></td><td align="left" nowrap><code>/.well-known/agent.json</code></td><td align="left">Agent-to-agent, <b>JSON-RPC 2.0</b> + SSE, 6 skills</td></tr>
|
||||
<tr><td align="left" nowrap>🌐 <b>REST API</b></td><td align="left" nowrap><code>/v1/*</code></td><td align="left">OpenAI-compatible — chat, embeddings, images, audio, OCR</td></tr>
|
||||
@@ -846,7 +767,7 @@ claude mcp add-server omniroute --type http --url http://localhost:20128/api/mcp
|
||||
|
||||
### 📖 How it works — pipeline, architecture & savings math
|
||||
|
||||
<img src="./docs/diagrams/compression-pipeline.svg" width="100%" alt="OmniRoute compression pipeline: an illustrative 10,000-token client request passes through 12 composable engines — Session-Dedup, CCR, Lite, RTK, Responses Tool Output, Headroom, Relevance, Caveman, Aggressive, LLMLingua-2, Ultra and OmniGlyph — and can reach the provider at about 1,080 tokens in the documented stacked example. Structured content is protected by preservation guards and per-step fidelity gates; explicit lossy or experimental modes may transform eligible content."/>
|
||||
<img src="./docs/diagrams/compression-pipeline.svg" width="100%" alt="OmniRoute compression pipeline: a client request of 10,000 tokens passes through 12 stacked engines — Session-Dedup, CCR, Lite, RTK, Responses Tool Output, Headroom, Relevance, Caveman, Aggressive, LLMLingua-2, Ultra, OmniGlyph — and reaches the provider at about 1,080 tokens, up to 95% saved. Code, URLs and JSON are always preserved byte-perfect."/>
|
||||
|
||||
Default stacked combo runs `RTK → Caveman`. When both act on the same tool/context payload, savings compound:
|
||||
|
||||
@@ -877,7 +798,7 @@ Engines run in pipeline order; each is independently toggleable and configurable
|
||||
<tr><td align="center" nowrap>9</td><td align="left" nowrap><b>Aggressive</b></td><td align="left">Summarization + progressive aging of old turns</td></tr>
|
||||
<tr><td align="center" nowrap>10</td><td align="left" nowrap><b>LLMLingua-2</b></td><td align="left">ML semantic pruning via MobileBERT ONNX — code-safe, async</td></tr>
|
||||
<tr><td align="center" nowrap>11</td><td align="left" nowrap><b>Ultra</b></td><td align="left">Heuristic token pruning with an optional small-model (SLM) tier</td></tr>
|
||||
<tr><td align="center" nowrap>12</td><td align="left" nowrap><b>OmniGlyph</b></td><td align="left">Experimental context-as-image encoding for measured Claude Fable 5 on the direct Anthropic wire; GPT 5.6 transformers remain fail-closed pending provider receipts. Four compression profiles (aggressive default, balanced, coding-safe, passthrough) (most aggressive; opt-in)</td></tr>
|
||||
<tr><td align="center" nowrap>12</td><td align="left" nowrap><b>OmniGlyph</b></td><td align="left">Experimental context-as-image encoding routed to Claude Fable 5 (most aggressive; opt-in)</td></tr>
|
||||
</table>
|
||||
|
||||
Code blocks, URLs and structured data are **always preserved** byte-perfect. **One-click presets** combine the engines:
|
||||
@@ -940,7 +861,7 @@ npm install -g omniroute
|
||||
omniroute
|
||||
```
|
||||
|
||||
> 💡 See `npm warn ERESOLVE` or peer-dep warnings? [They're harmless](docs/guides/TROUBLESHOOTING.md#npm-install-warnings-eresolve--peer--deprecated).
|
||||
> 💡 See `npm warn ERESOLVE` or peer-dep warnings? [They're harmless](docs/getting-started/TROUBLESHOOTING.md#npm-install-warnings-eresolve--peer--deprecated).
|
||||
|
||||
Dashboard at `http://localhost:20128` · API at `http://localhost:20128/v1`.
|
||||
|
||||
@@ -988,41 +909,11 @@ docker run -d --name omniroute --restart unless-stopped --stop-timeout 40 \
|
||||
-p 127.0.0.1:20128:20128 -v omniroute-data:/app/data diegosouzapw/omniroute:latest
|
||||
```
|
||||
|
||||
`:latest` follows the highest **published** stable SemVer. It does not track git `main`. Pin `:X.Y.Z` for GitOps. See [Docker Release Channels](docs/guides/DOCKER_GUIDE.md#release-channels).The image pins **`OMNIROUTE_MEMORY_MB=1024`**. That is enough for the dashboard and a light chat. **Coding agents** (`POST /v1/responses` from Claude Code, Codex, Grok, …) need a much larger V8 heap or the process `FATAL ERROR`s at ~12 GiB under two overlapping long contexts. Size the container above the heap (native buffers sit outside V8):
|
||||
|
||||
| Workload | Heap (`-e OMNIROUTE_MEMORY_MB`) | Container (`--memory`) |
|
||||
| ----------------------------------- | ------------------------------- | ---------------------- |
|
||||
| Dashboard / light chat | `1024` (image default) | ≥2 g |
|
||||
| One coding agent | `8192` | ≥10 g |
|
||||
| Two concurrent long `/v1/responses` | `10240`–`12288` | ≥12–16 g |
|
||||
|
||||
```bash
|
||||
docker run -d --name omniroute --restart unless-stopped --stop-timeout 40 \
|
||||
-e OMNIROUTE_MEMORY_MB=8192 --memory=10g \
|
||||
-p 127.0.0.1:20128:20128 -v omniroute-data:/app/data diegosouzapw/omniroute:latest
|
||||
```
|
||||
|
||||
Full table: [Docker Guide — runtime RAM](docs/guides/DOCKER_GUIDE.md#runtime-ram-for-coding-agents).
|
||||
|
||||
> **Pre-release Docker channel:** `diegosouzapw/omniroute:next` and
|
||||
> `diegosouzapw/omniroute:next-web` follow the current default `release/v*`
|
||||
> branch. These mutable tags are intended only for testing unreleased fixes and
|
||||
> are **not supported for production**. See
|
||||
> [Docker Release Channels](docs/guides/DOCKER_GUIDE.md#release-channels).
|
||||
|
||||
**🥟 Bun**
|
||||
|
||||
Standard `bun install` and global installation (`bun install -g omniroute`) are supported via Bun runtime detection:
|
||||
|
||||
- **Built-in `bun:sqlite`**: OmniRoute uses Bun's built-in `bun:sqlite` driver when running under Bun, falling back to `better-sqlite3` on Node.js or `sql.js`.
|
||||
- **Automatic Webpack bundler selection**: Development (`bun run dev`) and production builds (`bun run build`) automatically detect Bun and disable Turbopack in favor of Webpack to prevent native V8 binding incompatibilities.
|
||||
- **Dedicated Bun Dockerfile**: Multi-stage `Dockerfile.bun` for native Bun production deployments (`docker build -f Dockerfile.bun -t omniroute:bun .`).
|
||||
|
||||
```bash
|
||||
# Install and run with Bun
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
> [Docker Release Channels](docs/guides/DOCKER_RELEASE_CHANNELS.md).
|
||||
|
||||
**🛠️ From source**
|
||||
|
||||
@@ -1102,69 +993,31 @@ same process on one port, so there is no separate CLI-only package today.
|
||||
|
||||
</div>
|
||||
|
||||
## 📹 Video Guides
|
||||
|
||||
<div align="center">
|
||||
|
||||
<sub>Snapshot do painel em 2026-08-24 · Catálogo bruto: YT 809 | TT 137 | IG 124 · Frescor (dias): YT 1 | TT 21 | IG 22</sub>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="320">
|
||||
<a href="https://www.instagram.com/reel/Da8ZthUPK98/">
|
||||
<img src="https://placehold.co/320x180/111827/FFFFFF?text=Instagram+Reel+%7C+nick_saraev&font=montserrat&bold=true" alt="Instagram Reel" width="300"/>
|
||||
</a><br/>
|
||||
<b>🎬 #1 — Instagram</b><br/>
|
||||
<sub>nick_saraev — 3,042,474 views</sub>
|
||||
<td align="center" width="264">
|
||||
<a href="https://www.youtube.com/watch?v=Rxdc36yUyOQ"><img src="https://img.youtube.com/vi/Rxdc36yUyOQ/maxresdefault.jpg" alt="Guia em Português" width="260"/></a><br/>
|
||||
<b>🇧🇷 Português</b><br/><sub>Guia completo</sub>
|
||||
</td>
|
||||
<td align="center" width="320">
|
||||
<a href="https://www.instagram.com/reel/DaSs65mMrHk/">
|
||||
<img src="https://placehold.co/320x180/111827/FFFFFF?text=Instagram+Reel+%7C+theopenstack&font=montserrat&bold=true" alt="Instagram Reel — theopenstack" width="300"/>
|
||||
</a><br/>
|
||||
<b>🎬 #2 — Instagram</b><br/>
|
||||
<sub>theopenstack — 692,419 views</sub>
|
||||
<td align="center" width="264">
|
||||
<a href="https://www.youtube.com/watch?v=CMzyOiUyEVc"><img src="https://img.youtube.com/vi/CMzyOiUyEVc/maxresdefault.jpg" alt="English Guide" width="260"/></a><br/>
|
||||
<b>🇺🇸 English</b><br/><sub>Complete walkthrough</sub>
|
||||
</td>
|
||||
<td align="center" width="320">
|
||||
<a href="https://www.tiktok.com/@milesreevesai/video/7667980059189366019">
|
||||
<img src="https://placehold.co/320x180/111827/FFFFFF?text=TikTok+%7C+milesreevesai&font=montserrat&bold=true" alt="TikTok — milesreevesai" width="300"/>
|
||||
</a><br/>
|
||||
<b>🎬 #3 — TikTok</b><br/>
|
||||
<sub>milesreevesai — 620,400 views</sub>
|
||||
</td>
|
||||
<td align="center" width="320">
|
||||
<a href="https://www.youtube.com/watch?v=QucgvbO5gsM">
|
||||
<img src="https://img.youtube.com/vi/QucgvbO5gsM/maxresdefault.jpg" alt="YouTube — Vaibhav Sisinty" width="300"/>
|
||||
</a><br/>
|
||||
<b>🎬 #4 — YouTube</b><br/>
|
||||
<sub>Vaibhav Sisinty — 391,109 views</sub>
|
||||
</td>
|
||||
<td align="center" width="320">
|
||||
<a href="https://www.instagram.com/reel/DbIt9AjK7-U/">
|
||||
<img src="https://placehold.co/320x180/111827/FFFFFF?text=Instagram+Reel+%7C+buildwithai.club&font=montserrat&bold=true" alt="Instagram Reel — buildwithai.club" width="300"/>
|
||||
</a><br/>
|
||||
<b>🎬 #5 — Instagram</b><br/>
|
||||
<sub>buildwithai.club — 347,652 views</sub>
|
||||
<td align="center" width="264">
|
||||
<a href="https://www.youtube.com/watch?v=il_5Ii6v4-Y"><img src="https://img.youtube.com/vi/il_5Ii6v4-Y/maxresdefault.jpg" alt="Руководство" width="260"/></a><br/>
|
||||
<b>🇷🇺 Русский</b><br/><sub>Полное руководство</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
**Ranking completo (URLs canônicas deduplicadas, `v > 0`, maior alcance):**
|
||||
|
||||
| #1 | #2 | #3 | #4 | #5 |
|
||||
| -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| [nick_saraev — Instagram](https://www.instagram.com/reel/Da8ZthUPK98/) — **3,042,474** | [theopenstack — Instagram](https://www.instagram.com/reel/DaSs65mMrHk/) — **692,419** | [milesreevesai — TikTok](https://www.tiktok.com/@milesreevesai/video/7667980059189366019) — **620,400** | [Vaibhav Sisinty — YouTube](https://www.youtube.com/watch?v=QucgvbO5gsM) — **391,109** | [buildwithai.club — Instagram](https://www.instagram.com/reel/DbIt9AjK7-U/) — **347,652** |
|
||||
|
||||
| #6 | #7 | #8 | #9 | #10 |
|
||||
| ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| [nivedan.ai — Instagram](https://www.instagram.com/reel/DbIrCksJiqq/) — **331,973** | [vaibhavsisinty — Instagram](https://www.instagram.com/reel/Dae05TSAK1l/) — **263,744** | [Nick Automates — YouTube Shorts](https://www.youtube.com/shorts/fZIBK_4fKq8) — **218,174** | [theroshankrishna — Instagram](https://www.instagram.com/reel/Dapjs58z0P0/) — **186,786** | [midudev — TikTok](https://www.tiktok.com/@midudev/video/7664636453544152342) — **177,800** |
|
||||
|
||||
Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 visualizações conhecidas** (`v > 0`) · **639 canais/perfis por rede**. O painel bruto contém 1.070 linhas; 41 duplicatas do Instagram foram normalizadas pela URL canônica, mantendo a maior contagem por vídeo.
|
||||
<div align="center">
|
||||
|
||||
> 🎬 **Made a video about OmniRoute?** Open an [issue](https://github.com/diegosouzapw/OmniRoute/issues/new) or [discussion](https://github.com/diegosouzapw/OmniRoute/discussions) with the link — we'll feature it here.
|
||||
|
||||
<br/>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -1202,7 +1055,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
|
||||
<tr><td nowrap><b>Runtime</b></td><td>Node.js 22.x / 24.x LTS — <code>>=22.22.2 <23 || >=24.0.0 <27</code></td></tr>
|
||||
<tr><td nowrap><b>Language</b></td><td>TypeScript 6.0 — <b>100% TypeScript</b> across <code>src/</code> and <code>open-sse/</code> (zero <code>any</code> in core since v2.0)</td></tr>
|
||||
<tr><td nowrap><b>Framework</b></td><td>Next.js 16 + React 19 + Tailwind CSS 4</td></tr>
|
||||
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 120 domain modules, 166 migrations</td></tr>
|
||||
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 95 domain modules, 110 migrations</td></tr>
|
||||
<tr><td nowrap><b>Memory</b></td><td>SQLite FTS5 full-text + int8-quantized vector embeddings, typed decay</td></tr>
|
||||
<tr><td nowrap><b>Schemas</b></td><td>Zod 4 — MCP tool I/O validation + API contracts</td></tr>
|
||||
<tr><td nowrap><b>Protocols</b></td><td>MCP (stdio / HTTP / SSE) + A2A v0.3 (JSON-RPC 2.0 + SSE)</td></tr>
|
||||
@@ -1212,7 +1065,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
|
||||
<tr><td nowrap><b>Stealth</b></td><td>wreq-js — JA3 / JA4 TLS fingerprint impersonation, 3-level proxy</td></tr>
|
||||
<tr><td nowrap><b>Resilience</b></td><td>Circuit breaker, exponential backoff, anti-thundering-herd, auto-combo self-healing</td></tr>
|
||||
<tr><td nowrap><b>Logging</b></td><td>pino — structured JSON logs with request context</td></tr>
|
||||
<tr><td nowrap><b>Testing</b></td><td>Node.js test runner + Vitest — <b>39,000+ static test declarations</b> across 5,100+ tracked test files (unit, integration, E2E, security, ecosystem)</td></tr>
|
||||
<tr><td nowrap><b>Testing</b></td><td>Node.js test runner + Vitest — <b>25,000+ test cases</b> across 3,300+ files (unit, integration, E2E, security, ecosystem)</td></tr>
|
||||
<tr><td nowrap><b>Platforms</b></td><td>Desktop (Electron) · Android (Termux) · PWA (any browser)</td></tr>
|
||||
<tr><td nowrap><b>CI/CD</b></td><td>GitHub Actions — auto npm publish + Docker Hub on release</td></tr>
|
||||
<tr><td nowrap><b>Links</b></td><td><a href="https://omniroute.online">Website</a> · <a href="https://www.npmjs.com/package/omniroute">npm</a> · <a href="https://hub.docker.com/r/diegosouzapw/omniroute">Docker Hub</a></td></tr>
|
||||
@@ -1263,9 +1116,9 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
|
||||
<tr><td nowrap><b><a href="docs/compression/COMPRESSION_RULES_FORMAT.md">Compression Rules Format</a></b></td><td>JSON rule-pack schemas for Caveman and RTK filters</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/compression/COMPRESSION_LANGUAGE_PACKS.md">Compression Language Packs</a></b></td><td>Language detection and Caveman rule-pack authoring</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/architecture/RESILIENCE_GUIDE.md">Resilience Guide</a></b></td><td>Circuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/routing/AUTO-COMBO.md">Auto-Combo Engine</a></b></td><td>15-factor scoring, mode packs, self-healing</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/routing/AUTO-COMBO.md">Auto-Combo Engine</a></b></td><td>12-factor scoring, mode packs, self-healing</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/ops/PROXY_GUIDE.md">Proxy Guide</a></b></td><td>3-level proxy system, 1proxy marketplace, registry CRUD</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/reference/FREE_TIERS.md">Free Tiers</a></b></td><td>Consolidated directory: 39 documented recurring pools / 445 cataloged free-tier entries</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/reference/FREE_TIERS.md">Free Tiers</a></b></td><td>25+ free API providers consolidated directory</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/guides/FEATURES.md">Features Gallery</a></b></td><td>Visual dashboard tour with screenshots</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/architecture/CODEBASE_DOCUMENTATION.md">Codebase Documentation</a></b></td><td>Beginner-friendly codebase walkthrough</td></tr>
|
||||
</table>
|
||||
@@ -1276,7 +1129,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
|
||||
<tr><th align="left">Document</th><th align="left">Description</th></tr>
|
||||
<tr><td nowrap><b><a href="docs/reference/API_REFERENCE.md">API Reference</a></b></td><td>All endpoints with examples</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/openapi.yaml">OpenAPI Spec</a></b></td><td>OpenAPI 3.0 specification</td></tr>
|
||||
<tr><td nowrap><b><a href="open-sse/mcp-server/README.md">MCP Server</a></b></td><td>110 MCP tools, IDE configs, Python/TS/Go clients</td></tr>
|
||||
<tr><td nowrap><b><a href="open-sse/mcp-server/README.md">MCP Server</a></b></td><td>109 MCP tools, IDE configs, Python/TS/Go clients</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/frameworks/MCP-SERVER.md">MCP Server Guide</a></b></td><td>MCP installation, transports, and tool reference</td></tr>
|
||||
<tr><td nowrap><b><a href="src/lib/a2a/README.md">A2A Server</a></b></td><td>JSON-RPC 2.0 protocol, skills, streaming, task mgmt</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/frameworks/A2A-SERVER.md">A2A Server Guide</a></b></td><td>A2A agent card, tasks, skills, and streaming</td></tr>
|
||||
@@ -1290,9 +1143,9 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
|
||||
<tr><td nowrap><b><a href="docs/ops/BRANCHING_MODEL.md">Branching & Release Model</a></b></td><td>Where PRs target (<code>release/*</code>), what <code>main</code> and tags mean</td></tr>
|
||||
<tr><td nowrap><b><a href="CHANGELOG.md">Changelog</a></b></td><td>Full per-version release history</td></tr>
|
||||
<tr><td nowrap><b><a href="SECURITY.md">Security Policy</a></b></td><td>Vulnerability reporting and security practices</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/guides/I18N.md">i18n Guide</a></b></td><td>43-language support, translation workflow, RTL</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/guides/I18N.md">i18n Guide</a></b></td><td>40+ language support, translation workflow, RTL</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/ops/RELEASE_CHECKLIST.md">Release Checklist</a></b></td><td>Pre-release validation steps</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/ops/COVERAGE_PLAN.md">Coverage Plan</a></b></td><td>Test coverage strategy for 39,000+ static test declarations across 5,100+ tracked test files</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/ops/COVERAGE_PLAN.md">Coverage Plan</a></b></td><td>Test coverage strategy and 25,000+ test suite</td></tr>
|
||||
</table>
|
||||
|
||||
<br/>
|
||||
@@ -1303,123 +1156,93 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
|
||||
|
||||
> OmniRoute is shaped by a passionate open-source community. These individuals have made exceptional contributions that directly impact the quality, stability, and reach of the project. **Thank you.**
|
||||
|
||||
### External contributors by merged pull requests
|
||||
|
||||
<table>
|
||||
<tr><th align="center">Rank</th><th align="left">Contributor</th><th align="center">Merged PRs</th><th align="right">~Changed lines</th></tr>
|
||||
<tr><td align="center">1</td><td align="left"><a href="https://github.com/backryun"><b>backryun</b></a></td><td align="center">190</td><td align="right">227,977</td></tr>
|
||||
<tr><td align="center">2</td><td align="left"><a href="https://github.com/oyi77"><b>oyi77</b></a></td><td align="center">180</td><td align="right">407,678</td></tr>
|
||||
<tr><td align="center">3</td><td align="left"><a href="https://github.com/rdself"><b>rdself</b></a></td><td align="center">145</td><td align="right">80,663</td></tr>
|
||||
<tr><td align="center">4</td><td align="left"><a href="https://github.com/JxnLexn"><b>JxnLexn</b></a></td><td align="center">128</td><td align="right">387,049</td></tr>
|
||||
<tr><td align="center">5</td><td align="left"><a href="https://github.com/KooshaPari"><b>KooshaPari</b></a></td><td align="center">101</td><td align="right">125,747</td></tr>
|
||||
<tr><td align="center">6</td><td align="left"><a href="https://github.com/herjarsa"><b>herjarsa</b></a></td><td align="center">88</td><td align="right">230,872</td></tr>
|
||||
<tr><td align="center">7</td><td align="left"><a href="https://github.com/RaviTharuma"><b>RaviTharuma</b></a></td><td align="center">79</td><td align="right">55,106</td></tr>
|
||||
<tr><td align="center">8</td><td align="left"><a href="https://github.com/maxmad64bis"><b>maxmad64bis</b></a></td><td align="center">69</td><td align="right">394,715</td></tr>
|
||||
<tr><td align="center">9</td><td align="left"><a href="https://github.com/artickc"><b>artickc</b></a></td><td align="center">59</td><td align="right">33,260</td></tr>
|
||||
<tr><td align="center">10</td><td align="left"><a href="https://github.com/HouMinXi"><b>HouMinXi</b></a></td><td align="center">51</td><td align="right">47,334</td></tr>
|
||||
<tr><td align="center">10</td><td align="left"><a href="https://github.com/chirag127"><b>chirag127</b></a></td><td align="center">51</td><td align="right">5,153</td></tr>
|
||||
<tr><td align="center">12</td><td align="left"><a href="https://github.com/xz-dev"><b>xz-dev</b></a></td><td align="center">50</td><td align="right">245,976</td></tr>
|
||||
<tr><td align="center">13</td><td align="left"><a href="https://github.com/hartmark"><b>hartmark</b></a></td><td align="center">47</td><td align="right">52,185</td></tr>
|
||||
<tr><td align="center">14</td><td align="left"><a href="https://github.com/rqzbeh"><b>rqzbeh</b></a></td><td align="center">39</td><td align="right">143,181</td></tr>
|
||||
<tr><td align="center">15</td><td align="left"><a href="https://github.com/dhaern"><b>dhaern</b></a></td><td align="center">34</td><td align="right">19,559</td></tr>
|
||||
<tr><td align="center">16</td><td align="left"><a href="https://github.com/Dingding-leo"><b>Dingding-leo</b></a></td><td align="center">33</td><td align="right">1,986</td></tr>
|
||||
<tr><td align="center">17</td><td align="left"><a href="https://github.com/NomenAK"><b>NomenAK</b></a></td><td align="center">32</td><td align="right">13,854</td></tr>
|
||||
<tr><td align="center">18</td><td align="left"><a href="https://github.com/MumuTW"><b>MumuTW</b></a></td><td align="center">30</td><td align="right">16,953</td></tr>
|
||||
<tr><td align="center">19</td><td align="left"><a href="https://github.com/benzntech"><b>benzntech</b></a></td><td align="center">29</td><td align="right">11,641</td></tr>
|
||||
<tr><td align="center">20</td><td align="left"><a href="https://github.com/pacocartones"><b>pacocartones</b></a></td><td align="center">24</td><td align="right">9,331</td></tr>
|
||||
<tr><td align="center">20</td><td align="left"><a href="https://github.com/Prudhvivuda"><b>Prudhvivuda</b></a></td><td align="center">24</td><td align="right">6,312</td></tr>
|
||||
</table>
|
||||
|
||||
<sub>Frozen at live <code>release/v3.8.50</code> tip <code>dafb4ae808</code>, with merges through 2026-08-24 05:26:03 UTC. The paginated GitHub GraphQL census contains 5,911 merged PRs: 2,707 by the repository owner, 179 by Dependabot, and <b>3,025 external PRs from 535 distinct contributors</b>. “Changed lines” is GitHub additions + deletions and includes generated files, lockfiles, catalogs, translations and documentation; it is churn, not authored LOC. Ties at the cutoff are retained.</sub>
|
||||
|
||||
### GitHub-attributed commits
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="160">
|
||||
<a href="https://github.com/backryun">
|
||||
<img src="https://github.com/backryun.png" width="40" style="border-radius:50%" alt="backryun"/><br/>
|
||||
<b>backryun</b>
|
||||
</a><br/>
|
||||
<sub>🥇 220 GitHub-attributed commits</sub>
|
||||
</td>
|
||||
<td align="center" width="160">
|
||||
<a href="https://github.com/oyi77">
|
||||
<img src="https://github.com/oyi77.png" width="40" style="border-radius:50%" alt="Paijo"/><br/>
|
||||
<b>Paijo</b>
|
||||
<img src="https://github.com/oyi77.png" width="40" style="border-radius:50%" alt="oyi77"/><br/>
|
||||
<b>oyi77</b>
|
||||
</a><br/>
|
||||
<sub>🥈 219 GitHub-attributed commits</sub>
|
||||
<sub>🥇 213 commits • +114K lines</sub><br/>
|
||||
<sub>Analytics engine, SQL aggregations,<br/>proxy marketplace, test coverage</sub>
|
||||
</td>
|
||||
<td align="center" width="160">
|
||||
<a href="https://github.com/rdself">
|
||||
<img src="https://github.com/rdself.png" width="40" style="border-radius:50%" alt="Randi"/><br/>
|
||||
<b>Randi</b>
|
||||
<img src="https://github.com/rdself.png" width="40" style="border-radius:50%" alt="R.D. & Randi"/><br/>
|
||||
<b>R.D. & Randi</b>
|
||||
</a><br/>
|
||||
<sub>🥉 108 GitHub-attributed commits</sub>
|
||||
</td>
|
||||
<td align="center" width="160">
|
||||
<a href="https://github.com/RaviTharuma">
|
||||
<img src="https://github.com/RaviTharuma.png" width="40" style="border-radius:50%" alt="Ravi Tharuma"/><br/>
|
||||
<b>Ravi Tharuma</b>
|
||||
</a><br/>
|
||||
<sub>🏅 81 GitHub-attributed commits</sub>
|
||||
<sub>🥈 108 commits • +38K lines</sub><br/>
|
||||
<sub>Endpoints page, tunnel integrations,<br/>Docker workflows, A2A status, compression UI</sub>
|
||||
</td>
|
||||
<td align="center" width="160">
|
||||
<a href="https://github.com/christopher-s">
|
||||
<img src="https://github.com/christopher-s.png" width="40" style="border-radius:50%" alt="Chris"/><br/>
|
||||
<b>Chris</b>
|
||||
<img src="https://github.com/christopher-s.png" width="40" style="border-radius:50%" alt="Chris Staley"/><br/>
|
||||
<b>Chris Staley</b>
|
||||
</a><br/>
|
||||
<sub>🏅 70 GitHub-attributed commits</sub>
|
||||
</td>
|
||||
<td align="center" width="160">
|
||||
<a href="https://github.com/hartmark">
|
||||
<img src="https://github.com/hartmark.png" width="40" style="border-radius:50%" alt="Markus Hartung"/><br/>
|
||||
<b>Markus Hartung</b>
|
||||
</a><br/>
|
||||
<sub>🏅 69 GitHub-attributed commits · tied #6</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="160">
|
||||
<a href="https://github.com/maxmad64bis">
|
||||
<img src="https://github.com/maxmad64bis.png" width="40" style="border-radius:50%" alt="Dizzle"/><br/>
|
||||
<b>Dizzle</b>
|
||||
</a><br/>
|
||||
<sub>🏅 69 GitHub-attributed commits · tied #6</sub>
|
||||
</td>
|
||||
<td align="center" width="160">
|
||||
<a href="https://github.com/JxnLexn">
|
||||
<img src="https://github.com/JxnLexn.png" width="40" style="border-radius:50%" alt="Jan Leon"/><br/>
|
||||
<b>Jan Leon</b>
|
||||
</a><br/>
|
||||
<sub>🏅 64 GitHub-attributed commits</sub>
|
||||
<sub>🥉 70 commits • +1.8K lines</sub><br/>
|
||||
<sub>SSE stream hardening, Responses API,<br/>Gemini pagination, test regression fixes</sub>
|
||||
</td>
|
||||
<td align="center" width="160">
|
||||
<a href="https://github.com/zen0bit">
|
||||
<img src="https://github.com/zen0bit.png" width="40" style="border-radius:50%" alt="zenobit"/><br/>
|
||||
<b>zenobit</b>
|
||||
</a><br/>
|
||||
<sub>🏅 62 GitHub-attributed commits</sub>
|
||||
<sub>🏅 62 commits • +22K lines</sub><br/>
|
||||
<sub>CI/CD pipeline, i18n for 33 languages,<br/>Void Linux package, platform fixes</sub>
|
||||
</td>
|
||||
<td align="center" width="160">
|
||||
<a href="https://github.com/HouMinXi">
|
||||
<img src="https://github.com/HouMinXi.png" width="40" style="border-radius:50%" alt="Bob.Hou"/><br/>
|
||||
<b>Bob.Hou</b>
|
||||
<a href="https://github.com/JxnLexn">
|
||||
<img src="https://github.com/JxnLexn.png" width="40" style="border-radius:50%" alt="Jan Leon"/><br/>
|
||||
<b>Jan Leon</b>
|
||||
</a><br/>
|
||||
<sub>🏅 51 GitHub-attributed commits · tied #10</sub>
|
||||
<sub>🏅 58 commits • +22K lines</sub><br/>
|
||||
<sub>Reasoning-effort routing, proxy controls,<br/>quota visibility, Live Zone compression</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="160">
|
||||
<a href="https://github.com/backryun">
|
||||
<img src="https://github.com/backryun.png" width="40" style="border-radius:50%" alt="backryun"/><br/>
|
||||
<b>backryun</b>
|
||||
</a><br/>
|
||||
<sub>🏅 53 commits • +70K lines</sub><br/>
|
||||
<sub>Provider catalog curation — Perplexity, Kimi,<br/>Cerebras, Copilot, LMArena refreshes</sub>
|
||||
</td>
|
||||
<td align="center" width="160">
|
||||
<a href="https://github.com/xz-dev">
|
||||
<img src="https://github.com/xz-dev.png" width="40" style="border-radius:50%" alt="Xiangzhe"/><br/>
|
||||
<b>Xiangzhe</b>
|
||||
<a href="https://github.com/chirag127">
|
||||
<img src="https://github.com/chirag127.png" width="40" style="border-radius:50%" alt="Chirag Singhal"/><br/>
|
||||
<b>Chirag Singhal</b>
|
||||
</a><br/>
|
||||
<sub>🏅 51 GitHub-attributed commits · tied #10</sub>
|
||||
<sub>🏅 46 commits • +4.8K lines</sub><br/>
|
||||
<sub>Error sanitization, MITM prefill fix,<br/>fusion judge, breaker/429 correctness</sub>
|
||||
</td>
|
||||
<td align="center" width="160">
|
||||
<a href="https://github.com/kfiramar">
|
||||
<img src="https://github.com/kfiramar.png" width="40" style="border-radius:50%" alt="kfiramar"/><br/>
|
||||
<b>kfiramar</b>
|
||||
</a><br/>
|
||||
<sub>🏅 38 commits • +1.7K lines</sub><br/>
|
||||
<sub>Codex websocket + passthrough, auth/onboarding,<br/>Electron hardening, DB migrations</sub>
|
||||
</td>
|
||||
<td align="center" width="160">
|
||||
<a href="https://github.com/benzntech">
|
||||
<img src="https://github.com/benzntech.png" width="40" style="border-radius:50%" alt="Benson K B"/><br/>
|
||||
<b>Benson K B</b>
|
||||
</a><br/>
|
||||
<sub>🏅 28 commits • +9.2K lines</sub><br/>
|
||||
<sub>Electron desktop app, auto-updater,<br/>release build workflows, cross-platform CI</sub>
|
||||
</td>
|
||||
<td align="center" width="160">
|
||||
<a href="https://github.com/herjarsa">
|
||||
<img src="https://github.com/herjarsa.png" width="40" style="border-radius:50%" alt="Hernan J. Ardila"/><br/>
|
||||
<b>Hernan J. Ardila</b>
|
||||
</a><br/>
|
||||
<sub>🏅 25 commits • +174K lines</sub><br/>
|
||||
<sub>Zero-latency combos, vision-bridge auto-routing,<br/>catalog context-length, resilience 429 hints</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>Rechecked at 2026-08-24 06:14:31 UTC: GitHub-attributed commits reported by the repository Contributors API for the <code>release/v3.8.50</code> default branch. The API returned 525 identities (415 users, 2 bots, 108 anonymous); this table excludes the maintainer, bots and anonymous identities and retains competition ties. It is distinct from both the merged-PR ranking above and the 639-person Git-metadata census below.</sub>
|
||||
|
||||
> 🙏 These contributors' features, bug fixes, and infrastructure improvements are a **core part** of what makes OmniRoute reliable and feature-rich. Every pull request, every test case, and every i18n translation file matters. Open source is built by people like them.
|
||||
|
||||
</div>
|
||||
@@ -1436,48 +1259,25 @@ A heartfelt thank-you to the people who fund OmniRoute out of their own pocket
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="180">
|
||||
<a href="https://github.com/drewbitt">
|
||||
<img src="https://github.com/drewbitt.png?size=140" width="72" style="border-radius:50%" alt="Andrew"/><br/>
|
||||
<b>Andrew</b>
|
||||
</a><br/>
|
||||
<sub>💛 Active monthly sponsor</sub>
|
||||
</td>
|
||||
<td align="center" width="180">
|
||||
<a href="https://github.com/psylligent">
|
||||
<img src="https://github.com/psylligent.png?size=140" width="72" style="border-radius:50%" alt="Vlad I"/><br/>
|
||||
<b>Vlad I</b>
|
||||
</a><br/>
|
||||
<sub>💛 Active monthly sponsor</sub>
|
||||
</td>
|
||||
<td align="center" width="180">
|
||||
<a href="https://github.com/pacocartones">
|
||||
<img src="https://github.com/pacocartones.png?size=140" width="72" style="border-radius:50%" alt="Paco Cartones"/><br/>
|
||||
<b>Paco Cartones</b>
|
||||
</a><br/>
|
||||
<sub>💛 Active one-time sponsor</sub>
|
||||
</td>
|
||||
<td align="center" width="180">
|
||||
<a href="https://github.com/igormorais123">
|
||||
<img src="https://github.com/igormorais123.png?size=140" width="72" style="border-radius:50%" alt="Professor Igor Morais Vasconcelos"/><br/>
|
||||
<b>Prof. Igor Morais</b>
|
||||
</a><br/>
|
||||
<sub>💛 Past one-time supporter</sub>
|
||||
<sub>💛 Sponsor</sub>
|
||||
</td>
|
||||
<td align="center" width="180">
|
||||
<a href="https://github.com/longtao77">
|
||||
<img src="https://github.com/longtao77.png?size=140" width="72" style="border-radius:50%" alt="longtao"/><br/>
|
||||
<b>longtao</b>
|
||||
</a><br/>
|
||||
<sub>💛 Past one-time supporter</sub>
|
||||
<sub>💛 Sponsor</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>… and others who prefer to stay private 💛</sub>
|
||||
|
||||
<sub>Public GitHub Sponsors revalidated on 2026-08-24. GitHub's <code>activeOnly</code> status determines the active labels above; previously disclosed public one-time supporters remain thanked, and private sponsors remain anonymous.</sub>
|
||||
|
||||
<b><a href="https://github.com/sponsors/diegosouzapw">💖 Become a sponsor →</a></b> — every dollar keeps OmniRoute free and independent.
|
||||
|
||||
</div>
|
||||
@@ -1486,13 +1286,11 @@ A heartfelt thank-you to the people who fund OmniRoute out of their own pocket
|
||||
|
||||
<div align="center">
|
||||
|
||||
## 👥 600+ Contributors
|
||||
## 👥 500+ Contributors
|
||||
|
||||
</div>
|
||||
|
||||
[](https://github.com/diegosouzapw/OmniRoute/graphs/contributors)
|
||||
|
||||
<sub>Audited on 2026-08-24 at frozen base <code>ac02c5b42f</code> and rechecked at live <code>release/v3.8.50</code> tip <code>dafb4ae808</code>: <b>639 normalized human Git identities</b> — 407 appear as commit authors (including the maintainer) and 232 only in explicit <code>Co-authored-by</code> trailers. The census normalizes GitHub noreply handles, excludes 26 bot/agent/service/placeholder identities, and does not merge ordinary email addresses merely because their display names match.</sub>
|
||||
[](https://github.com/diegosouzapw/OmniRoute/graphs/contributors)
|
||||
|
||||
### How to Contribute
|
||||
|
||||
@@ -1509,8 +1307,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
|
||||
|
||||
```bash
|
||||
# Create a release — npm publish happens automatically
|
||||
VERSION=x.y.z
|
||||
gh release create "v${VERSION}" --title "v${VERSION}" --generate-notes
|
||||
gh release create v3.8.2 --title "v3.8.2" --generate-notes
|
||||
```
|
||||
|
||||
<br/>
|
||||
@@ -1552,108 +1349,88 @@ gh release create "v${VERSION}" --title "v${VERSION}" --generate-notes
|
||||
|
||||
OmniRoute stands on the shoulders of giants. It started as a fork of **[9router](https://github.com/decolua/9router)** and a TypeScript port of the Go project **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** — and from there, every subsystem below was inspired by an open-source project that got there first. Each one shaped a concrete piece of OmniRoute. This is our thank-you to all of them. 🙏
|
||||
|
||||
> ⭐ star counts verified from GitHub's REST API on August 24, 2026 — go give these projects a star. Counts are an exact dated snapshot and will naturally change.
|
||||
> ⭐ star counts as of July 2026 — go give these projects a star.
|
||||
|
||||
### 🧬 Lineage & gateway
|
||||
|
||||
<table>
|
||||
<tr><th align="left">Project</th><th align="center">⭐</th><th align="left">How it inspired OmniRoute</th></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/decolua/9router">9router</a></b></td><td align="center">26,161</td><td>The original project this fork is built on — extended here with multi-modal APIs and a full TypeScript rewrite.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/router-for-me/CLIProxyAPI">CLIProxyAPI</a></b></td><td align="center">48,497</td><td>The Go implementation that inspired this JavaScript / TypeScript port.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/BerriAI/litellm">LiteLLM</a></b></td><td align="center">57,100</td><td>The AI gateway whose public pricing dataset feeds our cost-tracking sync and whose provider-normalization model informed our routing.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/miuuyy/codex-chatgpt-web">codex-chatgpt-web</a></b></td><td align="center">1,410</td><td>MIT source adapted into the vendored ChatGPT Web → Codex Responses bridge, including browser-session, response-framing, usage and web-search adapters.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/Alishahryar1/free-claude-code">free-claude-code</a></b></td><td align="center">48,112</td><td>Patterns ported into stream recovery, no-thinking aliases, fallback web search, sliding-window limits, log redaction and hardened launcher flows.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/standardagents/composer-api">composer-api</a></b></td><td align="center">322</td><td>Cursor Composer tool-choice, output-constraint and tool-commit patterns adapted into the native Cursor executor.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/ndycode/codex-multi-auth">codex-multi-auth</a></b></td><td align="center">457</td><td>Fresh-login and refresh-token rotation patterns ported into Codex OAuth reauthentication.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/ex-machina-co/opencode-anthropic-auth">opencode-anthropic-auth</a></b></td><td align="center">510</td><td>Claude Code-compatible transform defaults and billing-header behavior generalized into OmniRoute's config-driven bridge.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/520mmxx/grok2api-merged">grok2api-merged</a></b></td><td align="center">2</td><td>Its Grok model mappings, fake-TypeError Statsig generator, request and device defaults, and NDJSON response processor were materially adapted into OmniRoute's Grok Web executor.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/TQZHR/grok2api">TQZHR/grok2api</a></b></td><td align="center">705</td><td>The principal transitive code source behind grok2api-merged; its model, header, payload, Statsig and processor implementations are preserved in the Grok Web lineage.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/chenyme/grok2api">chenyme/grok2api</a></b></td><td align="center">7,520</td><td>The underlying MIT source for Grok payload and device defaults, the Statsig generator, and the <code>result.response</code> processor carried through TQZHR and grok2api-merged.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/miuzhaii/grok2api-pro">grok2api-pro</a></b></td><td align="center">27</td><td>A transitive source credited by grok2api-merged for its proxy-pool layer; OmniRoute preserves that lineage notice but does not claim a proxy-pool port in its bounded Grok Web executor.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/CNFlyCat/GrokProxy">GrokProxy</a></b></td><td align="center">50</td><td>Its cookie-authenticated Grok proxy and <code>result.response.token</code> streaming pattern informed OmniRoute's Grok Web transport.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/lianying1716/GrokBridge">GrokBridge</a></b></td><td align="center">5</td><td>The original Grok Web implementation consulted its HTTP/browser upstream design; its direct HTTP path derives from GrokProxy, so no independent code port is claimed.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/imjustprism/grok-web-api">grok-web-api</a></b></td><td align="center">14</td><td>Its Rust <code>ChatOptions</code> and response-envelope schemas informed OmniRoute's TypeScript Grok request and streaming-response types.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/decolua/9router">9router</a></b></td><td align="center">22.7k</td><td>The original project this fork is built on — extended here with multi-modal APIs and a full TypeScript rewrite.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/router-for-me/CLIProxyAPI">CLIProxyAPI</a></b></td><td align="center">43.6k</td><td>The Go implementation that inspired this JavaScript / TypeScript port.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/BerriAI/litellm">LiteLLM</a></b></td><td align="center">54.0k</td><td>The AI gateway whose public pricing dataset feeds our cost-tracking sync and whose provider-normalization model informed our routing.</td></tr>
|
||||
</table>
|
||||
|
||||
### 🗜️ Context & token compression — engines
|
||||
|
||||
<table>
|
||||
<tr><th align="left">Project</th><th align="center">⭐</th><th align="left">How it inspired OmniRoute</th></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/JuliusBrussee/caveman">Caveman</a></b></td><td align="center">100,538</td><td>The viral "why use many token when few token do trick" project — its caveman-speak philosophy powers our standard compression mode and 30+ filler/condensation rules.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/rtk-ai/rtk">RTK – Rust Token Killer</a></b></td><td align="center">77,185</td><td>High-performance command-output compression — inspired our RTK engine, JSON filter DSL, raw-output recovery and the stacked RTK → Caveman pipeline.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/headroomlabs-ai/headroom">headroom</a></b></td><td align="center">67,310</td><td>Reversible context-compression (SmartCrusher) — inspired our <code>headroom</code> engine and the <code>ccr</code> retrieve-marker pattern.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/microsoft/LLMLingua">LLMLingua</a></b></td><td align="center">6,598</td><td>Prompt-compression research (LLMLingua / LLMLingua-2) — inspired our async, code-safe, fail-open <code>llmlingua</code> engine.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/atjsh/llmlingua-2-js">llmlingua-2-js</a></b></td><td align="center">31</td><td>The JS/ONNX port (MobileBERT / XLM-RoBERTa) used as the worker-thread backend for our LLMLingua engine.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/leninejunior/troglodita">Troglodita</a></b></td><td align="center">40</td><td>PT-BR token compression — powers our pt-BR language pack: pleonasm reduction and filler removal tuned for Brazilian-Portuguese grammar.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/DietrichGebert/ponytail">ponytail</a></b></td><td align="center">108,957</td><td>The viral "lazy senior dev" YAGNI-coder skill — inspired our <b>less-code</b> Output Style: smallest-working-change steering that cuts _generated_ code (the output-axis sibling to Caveman's terse prose).</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/ayghri/i-have-adhd">i-have-adhd</a></b></td><td align="center">23,526</td><td>Its action-first, ADHD-friendly response style was adapted into OmniRoute's concise output style across five languages.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/JuliusBrussee/caveman">Caveman</a></b></td><td align="center">90.8k</td><td>The viral "why use many token when few token do trick" project — its caveman-speak philosophy powers our standard compression mode and 30+ filler/condensation rules.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/rtk-ai/rtk">RTK – Rust Token Killer</a></b></td><td align="center">71.8k</td><td>High-performance command-output compression — inspired our RTK engine, JSON filter DSL, raw-output recovery and the stacked RTK → Caveman pipeline.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/headroomlabs-ai/headroom">headroom</a></b></td><td align="center">60.1k</td><td>Reversible context-compression (SmartCrusher) — inspired our <code>headroom</code> engine and the <code>ccr</code> retrieve-marker pattern.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/microsoft/LLMLingua">LLMLingua</a></b></td><td align="center">6.5k</td><td>Prompt-compression research (LLMLingua / LLMLingua-2) — inspired our async, code-safe, fail-open <code>llmlingua</code> engine.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/atjsh/llmlingua-2-js">llmlingua-2-js</a></b></td><td align="center">30</td><td>The JS/ONNX port (MobileBERT / XLM-RoBERTa) used as the worker-thread backend for our LLMLingua engine.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/leninejunior/troglodita">Troglodita</a></b></td><td align="center">26</td><td>PT-BR token compression — powers our pt-BR language pack: pleonasm reduction and filler removal tuned for Brazilian-Portuguese grammar.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/DietrichGebert/ponytail">ponytail</a></b></td><td align="center">86.0k</td><td>The viral "lazy senior dev" YAGNI-coder skill — inspired our <b>less-code</b> Output Style: smallest-working-change steering that cuts _generated_ code (the output-axis sibling to Caveman's terse prose).</td></tr>
|
||||
</table>
|
||||
|
||||
### 🧩 Compact formats, token research & code-aware tooling
|
||||
|
||||
<table>
|
||||
<tr><th align="left">Project</th><th align="center">⭐</th><th align="left">How it inspired OmniRoute</th></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/toon-format/toon">TOON</a></b></td><td align="center">25,233</td><td>Token-Oriented Object Notation — its columnar, header-plus-rows model shaped our tabular compaction stage.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/blackwell-systems/gcf">GCF – Graph Compact Format</a></b></td><td align="center">41</td><td>Its compact graph format and generic-profile design informed OmniRoute's tabular compaction and Headroom codec format.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/blackwell-systems/gcf-typescript">gcf-typescript</a></b></td><td align="center">4</td><td>The MIT TypeScript implementation directly vendored and extended as the Headroom generic-profile codec.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/ooples/token-optimizer-mcp">token-optimizer-mcp</a></b></td><td align="center">494</td><td>Brotli/SQLite cache + per-session context-delta — inspired our <code>session-dedup</code> engine.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/Mibayy/token-savior">token-savior</a></b></td><td align="center">1,122</td><td>Bash-output compaction + MCP profiles — inspired our compression bail-out discipline and MCP tool-manifest reduction.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/ppgranger/token-saver">token-saver</a></b></td><td align="center">138</td><td>Content-aware, per-file-type output compression with failure-aware bail-out — validated our per-type dispatch and minimum-gain skip.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/alexgreensh/token-optimizer">token-optimizer</a></b></td><td align="center">1,951</td><td>"Find the ghost tokens" — its offload + recoverable-handle pattern informed our CCR offload thinking.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/Shweta-Mishra-ai/tokenmizer">TokenMizer</a></b></td><td align="center">28</td><td>A session-graph + cross-turn line-dedup blueprint that informed our session-dedup design.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/toon-format/toon">TOON</a></b></td><td align="center">24.9k</td><td>Token-Oriented Object Notation — its columnar, header-plus-rows model shaped our tabular compaction stage.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/blackwell-systems/gcf">GCF – Graph Compact Format</a></b></td><td align="center">22</td><td>First inspired our tabular compaction stage; now its zero-dependency, lossless generic-profile encoder is <b>vendored directly</b> as the Headroom codec (MIT, SPDX-marked), current with GCF spec v3.2.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/ooples/token-optimizer-mcp">token-optimizer-mcp</a></b></td><td align="center">444</td><td>Brotli/SQLite cache + per-session context-delta — inspired our <code>session-dedup</code> engine.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/Mibayy/token-savior">token-savior</a></b></td><td align="center">1.1k</td><td>Bash-output compaction + MCP profiles — inspired our compression bail-out discipline and MCP tool-manifest reduction.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/ppgranger/token-saver">token-saver</a></b></td><td align="center">117</td><td>Content-aware, per-file-type output compression with failure-aware bail-out — validated our per-type dispatch and minimum-gain skip.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/alexgreensh/token-optimizer">token-optimizer</a></b></td><td align="center">1.7k</td><td>"Find the ghost tokens" — its offload + recoverable-handle pattern informed our CCR offload thinking.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/Shweta-Mishra-ai/tokenmizer">TokenMizer</a></b></td><td align="center">16</td><td>A session-graph + cross-turn line-dedup blueprint that informed our session-dedup design.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/jessefreitas/OmniCompress">OmniCompress</a></b></td><td align="center">3</td><td>Rust columnar-JSON + content-addressed retrieve + cross-message dedup — validated our <code>headroom</code>/<code>ccr</code>/<code>session-dedup</code> engine design and the cache-stable "compressed form is position-independent" invariant.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/atlassian-labs/mcp-compressor">mcp-compressor</a></b></td><td align="center">113</td><td>MCP tool-schema/description compression — informed our MCP tool-manifest cardinality reduction.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/pdavis68/RepoMapper">RepoMapper</a></b></td><td align="center">197</td><td>Aider-style repo-map ranking — informed our repo-map / retrieval-ranking exploration.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/atlassian-labs/mcp-compressor">mcp-compressor</a></b></td><td align="center">98</td><td>MCP tool-schema/description compression — informed our MCP tool-manifest cardinality reduction.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/pdavis68/RepoMapper">RepoMapper</a></b></td><td align="center">187</td><td>Aider-style repo-map ranking — informed our repo-map / retrieval-ranking exploration.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/mrsimpson/quiet-shell-mcp">quiet-shell-mcp</a></b></td><td align="center">4</td><td>Declarative shell-output reduction over MCP — validated our declarative bash-output compaction.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/dsherret/ts-morph">ts-morph</a></b></td><td align="center">6,162</td><td>TypeScript Compiler API toolkit — inspired our parser-based comment removal that preserves string, template and regex literals.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/dsherret/ts-morph">ts-morph</a></b></td><td align="center">6.1k</td><td>TypeScript Compiler API toolkit — inspired our parser-based comment removal that preserves string, template and regex literals.</td></tr>
|
||||
</table>
|
||||
|
||||
### 🧠 Memory & RAG
|
||||
|
||||
<table>
|
||||
<tr><th align="left">Project</th><th align="center">⭐</th><th align="left">How it inspired OmniRoute</th></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/mem0ai/mem0">Mem0</a></b></td><td align="center">63,902</td><td>Universal memory layer — its proxy-as-write/read-boundary model shaped our memory architecture.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/letta-ai/letta">Letta (MemGPT)</a></b></td><td align="center">24,382</td><td>Stateful agents with tiered memory — inspired our Context Control & Recovery (CCR) tiered model.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/onestardao/WFGY">WFGY</a></b></td><td align="center">1,781</td><td>The ProblemMap taxonomy of 16 recurring RAG/LLM failure modes — the shared vocabulary in our troubleshooting guide.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/mem0ai/mem0">Mem0</a></b></td><td align="center">61.2k</td><td>Universal memory layer — its proxy-as-write/read-boundary model shaped our memory architecture.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/letta-ai/letta">Letta (MemGPT)</a></b></td><td align="center">23.9k</td><td>Stateful agents with tiered memory — inspired our Context Control & Recovery (CCR) tiered model.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/onestardao/WFGY">WFGY</a></b></td><td align="center">1.8k</td><td>The ProblemMap taxonomy of 16 recurring RAG/LLM failure modes — the shared vocabulary in our troubleshooting guide.</td></tr>
|
||||
</table>
|
||||
|
||||
### 🛰️ Traffic inspection, MITM & transparent proxy
|
||||
|
||||
<table>
|
||||
<tr><th align="left">Project</th><th align="center">⭐</th><th align="left">How it inspired OmniRoute</th></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/chouzz/llm-interceptor">llm-interceptor</a></b></td><td align="center">66</td><td>MITM interception/analysis of coding-assistant ↔ LLM traffic informed early Traffic Inspector requirements. Four previously derived modules — SSE merging, conversation normalization, secret masking and header sanitization — have been replaced by independent clean-room implementations based on public protocol standards. The two host-passthrough surfaces (<code>passthrough.ts</code> and <code>_internal/bypass.cjs</code>) remain OmniRoute-internal implementations classified independently; they were not rewritten as part of that replacement.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/InterceptSuite/ProxyBridge">ProxyBridge</a></b></td><td align="center">5,995</td><td>Transparent per-process proxy routing — inspired our crash-safe MITM teardown, socket idle-timeouts, <code>/proc</code> process attribution and TPROXY capture.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/chouzz/llm-interceptor">llm-interceptor</a></b></td><td align="center">49</td><td>MITM interception/analysis of coding-assistant ↔ LLM traffic — our Traffic Inspector ports its SSE merge, conversation normalization, host passthrough and secret masking (MIT).</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/InterceptSuite/ProxyBridge">ProxyBridge</a></b></td><td align="center">5.5k</td><td>Transparent per-process proxy routing — inspired our crash-safe MITM teardown, socket idle-timeouts, <code>/proc</code> process attribution and TPROXY capture.</td></tr>
|
||||
</table>
|
||||
|
||||
### 📚 Model data, observability & UI
|
||||
|
||||
<table>
|
||||
<tr><th align="left">Project</th><th align="center">⭐</th><th align="left">How it inspired OmniRoute</th></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/anomalyco/models.dev">models.dev</a></b></td><td align="center">6,555</td><td>Open database of AI model specs, pricing and capabilities — synced natively into our model catalog.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/xyflow/xyflow">React Flow / xyflow</a></b></td><td align="center">38,108</td><td>The node-based graph library powering our real-time Compression Studio and Combo/Routing Studio.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/langchain-ai/langgraph">LangGraph</a></b></td><td align="center">40,314</td><td>LangGraph Studio's live workflow-graph visualization inspired our Studios' real-time cascade view.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/langfuse/langfuse">Langfuse</a></b></td><td align="center">33,592</td><td>Its trace → span → generation observability model shaped our Compression Studio waterfall.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/kiali/kiali">Kiali</a></b></td><td align="center">3,631</td><td>Istio service-mesh observability — inspired our circuit-breaker badges and error-edge visuals in the Routing/Combo Studio.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/lobehub/lobe-icons">lobe-icons</a></b></td><td align="center">2,428</td><td>AI/LLM brand logos that render the provider icons across our dashboard.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/lipis/flag-icons">flag-icons</a></b></td><td align="center">12,354</td><td>Provides the MIT-licensed SVG flags used by the README language selector.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/anomalyco/models.dev">models.dev</a></b></td><td align="center">6.0k</td><td>Open database of AI model specs, pricing and capabilities — synced natively into our model catalog.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/xyflow/xyflow">React Flow / xyflow</a></b></td><td align="center">37.7k</td><td>The node-based graph library powering our real-time Compression Studio and Combo/Routing Studio.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/langchain-ai/langgraph">LangGraph</a></b></td><td align="center">37.6k</td><td>LangGraph Studio's live workflow-graph visualization inspired our Studios' real-time cascade view.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/langfuse/langfuse">Langfuse</a></b></td><td align="center">31.4k</td><td>Its trace → span → generation observability model shaped our Compression Studio waterfall.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/kiali/kiali">Kiali</a></b></td><td align="center">3.6k</td><td>Istio service-mesh observability — inspired our circuit-breaker badges and error-edge visuals in the Routing/Combo Studio.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/lobehub/lobe-icons">lobe-icons</a></b></td><td align="center">2.2k</td><td>AI/LLM brand logos that render the provider icons across our dashboard.</td></tr>
|
||||
</table>
|
||||
|
||||
### 🛡️ Security
|
||||
|
||||
<table>
|
||||
<tr><th align="left">Project</th><th align="center">⭐</th><th align="left">How it inspired OmniRoute</th></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/tldrsec/awesome-secure-defaults">awesome-secure-defaults</a></b></td><td align="center">721</td><td>A curated list of secure-by-default libraries that guides our security choices (Helmet.js, DOMPurify, ssrf-req-filter, safe-regex, Google Tink).</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/tldrsec/awesome-secure-defaults">awesome-secure-defaults</a></b></td><td align="center">710</td><td>A curated list of secure-by-default libraries that guides our security choices (Helmet.js, DOMPurify, ssrf-req-filter, safe-regex, Google Tink).</td></tr>
|
||||
</table>
|
||||
|
||||
### 🧭 Complementary tools
|
||||
|
||||
<table>
|
||||
<tr><th align="left">Project</th><th align="center">⭐</th><th align="left">How it inspired OmniRoute</th></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/BlockRunAI/ClawRouter">ClawRouter</a></b></td><td align="center">6,564</td><td>Inspired request deduplication, emergency zero-cost fallback, pluggable Auto-Combo strategies and multilingual intent classification.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/lbjlaq/Antigravity-Manager">Antigravity-Manager</a></b></td><td align="center">30,652</td><td>Its account-aware model remapping, executable-path validation and plan-label behavior informed OmniRoute's Antigravity runtime.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/jlcodes99/vscode-antigravity-cockpit">vscode-antigravity-cockpit</a></b></td><td align="center">4,817</td><td>Its compact quota-reset countdown format inspired the corresponding provider-limit display in OmniRoute.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/iOfficeAI/AionUi">AionUi</a></b></td><td align="center">32,230</td><td>Its ACP integrations inspired OmniRoute's automatic detection of installed CLI agents.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/steipete/CodexBar">CodexBar</a></b></td><td align="center">20,507</td><td>Identified the Grok Build quota surface; OmniRoute then verified and corrected the live wire format independently.</td></tr>
|
||||
</table>
|
||||
|
||||
## 📄 License
|
||||
@@ -1666,7 +1443,7 @@ MIT License - see [LICENSE](LICENSE) for details.
|
||||
|
||||
**[⬆ Back to top](#-omniroute)** · Built with ❤️ for the open-source AI community.
|
||||
|
||||
<sub>OmniRoute v3.8.50 · Node ≥22.22.2 · MIT License · <a href="https://omniroute.online">omniroute.online</a></sub>
|
||||
<sub>OmniRoute v3.8.49 · Node ≥22.22.2 · MIT License · <a href="https://omniroute.online">omniroute.online</a></sub>
|
||||
|
||||
</div>
|
||||
<!-- GitHub Discussions enabled for community Q&A -->
|
||||
|
||||
@@ -42,13 +42,13 @@ Request → CORS → Authz pipeline (classify → policies → enforce)
|
||||
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Dashboard Login** | Password-based auth with JWT tokens (HttpOnly cookies) |
|
||||
| **API Key Auth** | HMAC-signed keys with CRC validation |
|
||||
| **OAuth 2.0 + PKCE** | Provider-specific browser/device OAuth uses PKCE where supported; import-only Devin credentials are handled separately. |
|
||||
| **OAuth 2.0 + PKCE** | 13 providers (Claude, Codex, GitHub, Cursor, Antigravity, Gemini, Kimi Coding, Kilo Code, Cline, Kiro, Qoder, Windsurf, GitLab Duo) |
|
||||
| **Token Refresh** | Automatic OAuth token refresh before expiry |
|
||||
| **Secure Cookies** | `AUTH_COOKIE_SECURE=true` for HTTPS environments |
|
||||
| **Authz Pipeline** | Route classification (PUBLIC / CLIENT_API / MANAGEMENT) — see `docs/architecture/AUTHZ_GUIDE.md` |
|
||||
| **Route Guard Tiers** | 3-tier model for management routes (LOCAL_ONLY / ALWAYS_PROTECTED / MANAGEMENT) — see `docs/security/ROUTE_GUARD_TIERS.md` |
|
||||
| **Manage-Scope MCP** | Remote `/api/mcp/*` access gated by API keys with `manage` scope; `/api/cli-tools/runtime/*` stays strict-loopback. See ROUTE_GUARD_TIERS |
|
||||
| **MCP Scopes** | 32 granular scopes (read:health, write:combos, execute:completions, etc.) — see `docs/frameworks/MCP-SERVER.md` |
|
||||
| **MCP Scopes** | ~13 granular scopes (read:health, write:combos, execute:completions, etc.) — see `docs/frameworks/MCP-SERVER.md` |
|
||||
|
||||
### 🛡️ Encryption at Rest
|
||||
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
# Third-Party Notices
|
||||
|
||||
## codex-chatgpt-web
|
||||
|
||||
Parts of `open-sse/vendor/codex-chatgpt-web/` are adapted from
|
||||
[`miuuyy/codex-chatgpt-web`](https://github.com/miuuyy/codex-chatgpt-web), commit
|
||||
`55592fca0ba19a27f1b769cec8fff61ff340a785`.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 codex-chatgpt-web contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction,
|
||||
including without limitation the rights to use, copy, modify, merge, publish, distribute,
|
||||
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
|
||||
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT
|
||||
OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
## blackwell-systems/gcf-typescript
|
||||
|
||||
The generic-profile codec in
|
||||
`open-sse/services/compression/engines/headroom/gcf/{decode_generic,generic,index,scalar}.ts`
|
||||
is adapted from
|
||||
[`blackwell-systems/gcf-typescript`](https://github.com/blackwell-systems/gcf-typescript/tree/00972f2dc781477eb6d369e62edfe03ad4112a07),
|
||||
commit `00972f2dc781477eb6d369e62edfe03ad4112a07`. The license below is reproduced
|
||||
from that commit's
|
||||
[`LICENSE`](https://github.com/blackwell-systems/gcf-typescript/blob/00972f2dc781477eb6d369e62edfe03ad4112a07/LICENSE).
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Dayna Blackwell
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction,
|
||||
including without limitation the rights to use, copy, modify, merge, publish, distribute,
|
||||
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
|
||||
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT
|
||||
OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
## lipis/flag-icons
|
||||
|
||||
The country flag SVGs in `docs/assets/flags/` are copied from the `flags/4x3/` directory of
|
||||
[`lipis/flag-icons`](https://github.com/lipis/flag-icons/tree/086f7e97d657358203916dbe84f61c2bccaa81eb),
|
||||
commit `086f7e97d657358203916dbe84f61c2bccaa81eb`. The license below is reproduced
|
||||
from that commit's
|
||||
[`LICENSE`](https://github.com/lipis/flag-icons/blob/086f7e97d657358203916dbe84f61c2bccaa81eb/LICENSE).
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2013 Panayiotis Lipiridis
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction,
|
||||
including without limitation the rights to use, copy, modify, merge, publish, distribute,
|
||||
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
|
||||
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT
|
||||
OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
## LobeHub provider asset derivatives
|
||||
|
||||
Six local provider SVGs contain geometry derived from fixed components in
|
||||
`@lobehub/icons@5.10.0`. The source package is pinned as follows:
|
||||
|
||||
- Tarball:
|
||||
<https://registry.npmjs.org/@lobehub/icons/-/icons-5.10.0.tgz>
|
||||
- npm shasum: `add1baced073a60157d39c7820b8d5c1928a1054`
|
||||
- npm integrity:
|
||||
`sha512-CIpjkISCLRK7haDtSugGFd0o3odaJts8ewJOkUiEFtns3xvsqbl8i24eowBnjw+yMDQVQyNONlhqTD58YC6Ljg==`
|
||||
- License file in the fixed tarball: `package/LICENSE`
|
||||
|
||||
| Local derivative | Fixed tarball source |
|
||||
| ------------------------------- | ----------------------------------------- |
|
||||
| `public/providers/360ai.svg` | `package/es/Ai360/components/Color.js` |
|
||||
| `public/providers/baichuan.svg` | `package/es/Baichuan/components/Color.js` |
|
||||
| `public/providers/codex.svg` | `package/es/Codex/components/Color.js` |
|
||||
| `public/providers/copilot.svg` | `package/es/Copilot/components/Color.js` |
|
||||
| `public/providers/openclaw.svg` | `package/es/OpenClaw/components/Color.js` |
|
||||
| `public/providers/stepfun.svg` | `package/es/Stepfun/components/Color.js` |
|
||||
|
||||
The fixed tarball contains this license notice:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 LobeHub
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
This package notice applies to the derived SVG geometry identified above. It does not grant rights
|
||||
in any underlying brand name, logo, or trademark.
|
||||
|
||||
## theSVG provider assets
|
||||
|
||||
At release snapshot `091589089cd134a94df9f6cdab9ba562b2cefd18`, 65 local provider SVGs were
|
||||
byte-exact matches for `public/icons/<slug>/default.svg` in the theSVG repository at immutable
|
||||
commit [`7870bc1c5f657d9accbb7f96cc457b8dd3363ee8`](https://github.com/GLINCKER/thesvg/tree/7870bc1c5f657d9accbb7f96cc457b8dd3363ee8).
|
||||
The fixed upstream evidence includes its
|
||||
[`LICENSE`](https://github.com/GLINCKER/thesvg/blob/7870bc1c5f657d9accbb7f96cc457b8dd3363ee8/LICENSE),
|
||||
[`LEGAL.md`](https://github.com/GLINCKER/thesvg/blob/7870bc1c5f657d9accbb7f96cc457b8dd3363ee8/LEGAL.md),
|
||||
[`TRADEMARK.md`](https://github.com/GLINCKER/thesvg/blob/7870bc1c5f657d9accbb7f96cc457b8dd3363ee8/TRADEMARK.md),
|
||||
[`LICENSING.md`](https://github.com/GLINCKER/thesvg/blob/7870bc1c5f657d9accbb7f96cc457b8dd3363ee8/LICENSING.md),
|
||||
and
|
||||
[`src/data/icons.json`](https://github.com/GLINCKER/thesvg/blob/7870bc1c5f657d9accbb7f96cc457b8dd3363ee8/src/data/icons.json).
|
||||
|
||||
The byte match proves source provenance for the listed files. It does not prove that a registry
|
||||
claim was authorized by each brand owner, and it does not relicense the logos or their underlying
|
||||
brand marks. The theSVG source applies its MIT license to its codebase, tooling, and catalog; its
|
||||
own legal documents separately reserve trademark rights to the respective owners.
|
||||
|
||||
The fixed theSVG source contains this license notice:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 thesvg.org
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
### Byte-exact file scope (65/65)
|
||||
|
||||
- `public/providers/alibaba.svg`
|
||||
- `public/providers/anthropic.svg`
|
||||
- `public/providers/arcee.svg`
|
||||
- `public/providers/assemblyai.svg`
|
||||
- `public/providers/aws.svg`
|
||||
- `public/providers/azure.svg`
|
||||
- `public/providers/bailian.svg`
|
||||
- `public/providers/baseten.svg`
|
||||
- `public/providers/cerebras.svg`
|
||||
- `public/providers/cline.svg`
|
||||
- `public/providers/comfyui.svg`
|
||||
- `public/providers/continue.svg`
|
||||
- `public/providers/cursor.svg`
|
||||
- `public/providers/deepgram.svg`
|
||||
- `public/providers/deepinfra.svg`
|
||||
- `public/providers/elevenlabs.svg`
|
||||
- `public/providers/exa.svg`
|
||||
- `public/providers/fal.svg`
|
||||
- `public/providers/fireworks.svg`
|
||||
- `public/providers/friendli.svg`
|
||||
- `public/providers/gemini.svg`
|
||||
- `public/providers/grok.svg`
|
||||
- `public/providers/groq.svg`
|
||||
- `public/providers/heroku.svg`
|
||||
- `public/providers/huggingface.svg`
|
||||
- `public/providers/hyperbolic.svg`
|
||||
- `public/providers/ibm.svg`
|
||||
- `public/providers/inference.svg`
|
||||
- `public/providers/lambda.svg`
|
||||
- `public/providers/longcat.svg`
|
||||
- `public/providers/minimax.svg`
|
||||
- `public/providers/mistral.svg`
|
||||
- `public/providers/moonshot.svg`
|
||||
- `public/providers/morph.svg`
|
||||
- `public/providers/nebius.svg`
|
||||
- `public/providers/novita.svg`
|
||||
- `public/providers/nvidia.svg`
|
||||
- `public/providers/ollama.svg`
|
||||
- `public/providers/openai.svg`
|
||||
- `public/providers/openrouter.svg`
|
||||
- `public/providers/ovhcloud.svg`
|
||||
- `public/providers/picoclaw.svg`
|
||||
- `public/providers/poe.svg`
|
||||
- `public/providers/pollinations.svg`
|
||||
- `public/providers/qwen.svg`
|
||||
- `public/providers/recraft.svg`
|
||||
- `public/providers/replicate.svg`
|
||||
- `public/providers/roocode.svg`
|
||||
- `public/providers/runway.svg`
|
||||
- `public/providers/sambanova.svg`
|
||||
- `public/providers/searchapi.svg`
|
||||
- `public/providers/suno.svg`
|
||||
- `public/providers/tavily.svg`
|
||||
- `public/providers/topazlabs.svg`
|
||||
- `public/providers/trae.svg`
|
||||
- `public/providers/udio.svg`
|
||||
- `public/providers/upstage.svg`
|
||||
- `public/providers/v0.svg`
|
||||
- `public/providers/vercel.svg`
|
||||
- `public/providers/vllm.svg`
|
||||
- `public/providers/volcengine.svg`
|
||||
- `public/providers/voyage.svg`
|
||||
- `public/providers/windsurf.svg`
|
||||
- `public/providers/xai.svg`
|
||||
- `public/providers/zhipu.svg`
|
||||
|
||||
### Upstream registry claims
|
||||
|
||||
These are claims recorded by the fixed upstream registry. They have not been independently
|
||||
verified against an authoritative license or brand-owner notice for every asset, so they are not
|
||||
independent copyright or trademark clearance.
|
||||
|
||||
| Upstream registry claim | Count | Clearance status |
|
||||
| ----------------------- | ----: | --------------------------------------------------------------------- |
|
||||
| MIT | 46 | Upstream claim only; original per-asset copyright notices remain HOLD |
|
||||
| CC0-1.0 | 14 | Upstream claim only; not independently verified with each owner |
|
||||
| Apache-2.0 | 1 | Upstream claim only; upstream NOTICE remains HOLD |
|
||||
| brand-use | 2 | Brand terms, not open-source licenses; owner guidelines remain HOLD |
|
||||
| Custom | 1 | Custom MiniMax claim; terms remain HOLD |
|
||||
| MISSING | 1 | No matching registry claim for HuggingFace; license remains HOLD |
|
||||
|
||||
#### MIT (46)
|
||||
|
||||
`alibaba`, `arcee`, `assemblyai`, `aws`, `bailian`, `baseten`, `cerebras`, `comfyui`,
|
||||
`deepinfra`, `exa`, `fal`, `fireworks`, `friendli`, `gemini`, `grok`, `groq`, `heroku`,
|
||||
`hyperbolic`, `ibm`, `inference`, `lambda`, `longcat`, `mistral`, `moonshot`, `morph`, `nebius`,
|
||||
`novita`, `openai`, `picoclaw`, `pollinations`, `qwen`, `recraft`, `roocode`, `runway`,
|
||||
`sambanova`, `searchapi`, `tavily`, `topazlabs`, `trae`, `udio`, `upstage`, `vllm`, `volcengine`,
|
||||
`voyage`, `xai`, `zhipu`
|
||||
<!-- end:MIT -->
|
||||
|
||||
#### CC0-1.0 (14)
|
||||
|
||||
`anthropic`, `cline`, `cursor`, `deepgram`, `elevenlabs`, `nvidia`, `ollama`, `openrouter`, `poe`,
|
||||
`replicate`, `suno`, `v0`, `vercel`, `windsurf`
|
||||
<!-- end:CC0-1.0 -->
|
||||
|
||||
#### Apache-2.0 (1)
|
||||
|
||||
`continue`
|
||||
<!-- end:Apache-2.0 -->
|
||||
|
||||
#### brand-use (2)
|
||||
|
||||
`azure`, `ovhcloud`
|
||||
<!-- end:brand-use -->
|
||||
|
||||
#### Custom (1)
|
||||
|
||||
`minimax`
|
||||
<!-- end:Custom -->
|
||||
|
||||
#### MISSING (1)
|
||||
|
||||
`huggingface`
|
||||
<!-- end:MISSING -->
|
||||
|
||||
The `continue` Apache-2.0 claim remains HOLD until its authoritative upstream NOTICE obligations
|
||||
are verified. The `azure` and `ovhcloud` brand-use claims are not open-source licenses and remain
|
||||
subject to owner guidelines. `minimax` remains HOLD under custom terms. `huggingface` remains HOLD
|
||||
because its matching file has no entry or license claim in the fixed registry.
|
||||
|
||||
### Trademark and affiliation disclaimer
|
||||
|
||||
All brand names, logos, and trademarks are the property of their respective owners. OmniRoute uses
|
||||
these assets nominatively to identify provider integrations. There is no affiliation, sponsorship,
|
||||
or endorsement by the respective owners. Copyright provenance and source license claims do not
|
||||
provide trademark clearance; users should follow each owner's official brand guidelines.
|
||||
@@ -0,0 +1,296 @@
|
||||
# Relatorio de pesquisa: repositorios de CLI integraveis com OmniRoute
|
||||
|
||||
> **Status final (2026-08-03):** este documento preserva o inventário inicial. A pesquisa foi concluída para `104/104` casos. Para resultados por projeto, use `04-tracker-integracoes-clis.md`; para o fechamento executivo e a estratégia de publicação, use `06-relatorio-final-104-clis-e-estrategia-prs.md`.
|
||||
|
||||
**Data da pesquisa:** 2026-08-01
|
||||
**Escopo:** agentes de codigo de terminal, CLIs de LLM, runtimes de agentes e harnesses que possam consumir um endpoint HTTP compativel com OpenAI, Anthropic ou Gemini, ou que possam ser adaptados por provider/plugin/ACP/MITM.
|
||||
**Fonte local principal:** `_tasks/hands-off/2026-08-01_release-v3.8.50_v3.8.50_sess-e1846bc2/handoff.md`
|
||||
**Fontes externas principais:** GitHub Search/API, READMEs dos repositorios e a lista publica `bradAGI/awesome-cli-coding-agents` (atualizada em 2026-07-29).
|
||||
|
||||
## 1. Resumo executivo
|
||||
|
||||
O OmniRoute ja possui uma integracao funcional com o jcode e um catalogo local de ferramentas CLI. O proximo ganho de maior valor e transformar o OmniRoute em um endpoint reconhecido pelos principais agentes de terminal, priorizando configuracao nativa e PR upstream quando o projeto aceitar contribuicoes.
|
||||
|
||||
A pesquisa encontrou:
|
||||
|
||||
- **33 entradas de ferramentas no registro local `CLI_TOOLS`**, contando o registro extraido de Grok Build em `src/shared/constants/cliToolsGrokBuild.ts`, incluindo Claude Code, Codex CLI, Cline, Kilo, Continue, OpenCode, Aider, jcode, Smelt, Pi, Crush, Goose, Open Interpreter, OpenClaw, Hermes Agent, Letta CLI e outros.
|
||||
- **Mais de 90 projetos publicos** no inventario externo consultado, entre agentes de codigo, CLIs generalistas, forks, runtimes e orquestradores.
|
||||
- **Candidatos com evidencia forte de endpoint customizavel:** Gemini CLI, Claw Code, Plandex, MiMo Code, Trae Agent, Kimi CLI, Every Code, Open Codex, VT Code, OpenHands CLI, gptme, Nanocoder, RA.Aid, CoreCoder, Grok CLI, Gitlawb Zero, DeepSeek Reasonix, KlaatCode, CodeMini, DvalinCode, Coro Code, Mini-Kode, Late CLI, Agentty, Aizen, Minacode, YottaCode, aichat, ShellGPT, Mistral Vibe, OpenSquilla, Kode CLI e outros.
|
||||
- **Candidatos que exigem pesquisa confirmatoria:** projetos com README generico, configuracao recente, repositorio ambiguo, binario fechado ou sem evidencia textual suficiente de `base_url`/provider.
|
||||
- **Candidatos que podem ser integrados por outros caminhos:** ACP, MCP, wrapper/launcher, provider adapter, proxy MITM ou apenas documentacao; eles nao devem ser classificados automaticamente como OpenAI-compatible.
|
||||
|
||||
Conclusao: devemos pesquisar e tentar todos os candidatos tecnicamente viaveis, mas separar claramente `suporte no catalogo OmniRoute`, `configuracao generica`, `adaptacao upstream publicada` e `PR/issue aceita`. O tracker acompanha essas dimensoes separadamente.
|
||||
|
||||
## 2. Metodo e limites
|
||||
|
||||
### 2.1 Como a busca foi feita
|
||||
|
||||
1. Leitura integral do handoff do caso jcode para capturar o padrao de integracao, validacao, publicacao e as restricoes de worktree.
|
||||
2. Inspecao do catalogo local em `src/shared/constants/cliTools.ts`, da documentacao de CLI e do fluxo de setup em `docs/guides/CLI-INTEGRATIONS.md`.
|
||||
3. Consulta do GitHub Search/API para resolver o repositorio canonico de cada nome, evitando homonimos.
|
||||
4. Leitura de README/raw quando disponivel, procurando sinais como `base_url`, `baseURL`, `OPENAI_BASE_URL`, `OPENAI_API_BASE`, `LLM_BASE_URL`, `provider`, `gateway`, `model provider`, `Anthropic` e `Gemini`.
|
||||
5. Consulta da lista `https://github.com/bradAGI/awesome-cli-coding-agents`, que serve como descoberta ampla, nao como prova de compatibilidade.
|
||||
6. Classificacao por adocao, manutencao, licenca, evidencia de endpoint, maturidade, potencial de PR e utilidade para o ecossistema OmniRoute.
|
||||
|
||||
### 2.2 O que ainda nao foi afirmado
|
||||
|
||||
- Nao foi feita implementacao ou abertura de PR/issue para os candidatos abaixo; o unico caso publicado nesta sessao anterior e o jcode.
|
||||
- A presenca da palavra `provider` no README nao prova que uma URL arbitraria funciona em runtime.
|
||||
- Estrelas e datas sao snapshots aproximados obtidos em 2026-08-01 e podem mudar.
|
||||
- Repositorios fechados ou com EULA entram no inventario para avaliacao de configuracao, mas nao implicam possibilidade de fork ou PR.
|
||||
- Cada task de integracao precisa repetir a pesquisa no upstream antes de editar codigo.
|
||||
|
||||
## 3. Baseline do OmniRoute
|
||||
|
||||
### 3.1 Superficie que o OmniRoute oferece
|
||||
|
||||
- Endpoint OpenAI em `/v1`.
|
||||
- Superficie Anthropic na raiz, usada por clientes que esperam `/v1/messages` a partir do `ANTHROPIC_BASE_URL`.
|
||||
- Superficie Gemini em `/v1beta`.
|
||||
- Catalogo de modelos consultavel pelos comandos de setup quando o cliente suporta descoberta.
|
||||
- Chave via `OMNIROUTE_API_KEY` ou chave selecionada no dashboard.
|
||||
- Traducao entre formatos, streaming SSE, tool calling, fallback, combos, custos e politicas de autenticacao.
|
||||
- Modos de consumo: configuracao de ambiente, arquivo nativo do cliente, provider customizado, ACP/MCP e MITM.
|
||||
|
||||
### 3.2 Catalogo local ja registrado
|
||||
|
||||
Fonte: `src/shared/constants/cliTools.ts` e `src/shared/constants/cliToolsGrokBuild.ts`.
|
||||
|
||||
**Codigo/CLI:** Claude Code, OpenAI Codex CLI, Factory Droid, OpenClaw, Cursor, Cline, Kilo Code, Continue, Antigravity, GitHub Copilot CLI, OpenCode, Kiro, Qwen Code, Aider, ForgeCode, Cursor Agent CLI, Roo Code, jcode, DeepSeek TUI, CodeWhale, Smelt, Pi, Crush.
|
||||
|
||||
**Agentes:** Hermes, Hermes Agent, Goose, Open Interpreter, Oh My Pi, Letta CLI, Warp AI, Agent Deck.
|
||||
|
||||
Os documentos do catalogo tambem mantem um backlog MITM para ferramentas sem base URL, como Windsurf, Amp, Amazon Q/Kiro CLI e Cowork. Esses casos devem permanecer separados de uma integracao direta.
|
||||
|
||||
### 3.3 Caso jcode (referencia validada)
|
||||
|
||||
- Upstream: `https://github.com/1jehuang/jcode`
|
||||
- Mecanismo: perfil OpenAI-compatible dirigido por metadados; nao foi criado um plugin de runtime.
|
||||
- Branch: `feat/omniroute-provider`
|
||||
- Commit: `ee4f904e6`
|
||||
- PR no fork: `https://github.com/diegosouzapw/jcode/pull/1`
|
||||
- Issue no upstream: `https://github.com/1jehuang/jcode/issues/704`
|
||||
- Diff: 6 arquivos, `+56/-3`.
|
||||
- Validacao: `cargo check --workspace` limpo; 205 testes passaram e uma falha foi preexistente/ambiental.
|
||||
- Estado: aguardando mantenedor; o upstream nao aceita PR de forks externos, por isso a issue e o artefato oficial.
|
||||
- Pendencia prometida: adicionar no README do OmniRoute a secao "Tools & repositories that work with OmniRoute".
|
||||
|
||||
Licao: o trabalho deve comecar descobrindo o mecanismo real de providers do upstream. Nem todos os clientes precisam de mudanca no OmniRoute; alguns precisam somente de um perfil local, e outros exigirao um adaptador especifico.
|
||||
|
||||
## 4. Candidatos prioritarios com evidencia concreta
|
||||
|
||||
As evidencias abaixo sao sinais de README/configuracao observados na pesquisa inicial. A task individual deve abrir o arquivo exato, confirmar a versao atual e executar um smoke test.
|
||||
|
||||
| Projeto | Repositorio | Evidencia inicial | Rota provavel |
|
||||
|---|---|---|---|
|
||||
| Gemini CLI | `google-gemini/gemini-cli` | `GOOGLE_GEMINI_BASE_URL` | configuracao direta; possivel PR/documentacao |
|
||||
| Claw Code | `ultraworkers/claw-code` | `OPENAI_BASE_URL`, provider compativel | configuracao direta ou provider |
|
||||
| Plandex | `plandex-ai/plandex` | providers customizados com `baseUrl` | provider/preset |
|
||||
| MiMo Code | `XiaomiMiMo/MiMo-Code` | `@ai-sdk/openai-compatible` e `baseURL` | provider customizado |
|
||||
| Trae Agent | `bytedance/trae-agent` | `model_providers` e `base_url` | provider/config |
|
||||
| Kimi CLI | `MoonshotAI/kimi-cli` | modos `openai_legacy`, `openai_responses`, `anthropic` e `base_url` | provider nativo/config |
|
||||
| Every Code | `just-every/code` | fork Codex com providers OpenAI/Claude/Gemini | perfil/provider |
|
||||
| Open Codex | `ymichael/open-codex` | multi-provider e OpenAI-compatible | fork/provider |
|
||||
| VT Code | `vinhnx/vtcode` | `custom_providers[].base_url`, failover | provider customizado |
|
||||
| OpenHands CLI | `OpenHands/OpenHands-CLI` | `LLM_BASE_URL` | configuracao direta |
|
||||
| gptme | `gptme/gptme` | `OPENAI_BASE_URL` e providers | configuracao direta |
|
||||
| Nanocoder | `Nano-Collective/nanocoder` | qualquer API OpenAI-compatible | configuracao direta |
|
||||
| RA.Aid | `ai-christianson/RA.Aid` | `OPENAI_API_BASE` | configuracao direta |
|
||||
| CoreCoder | `he-yufeng/CoreCoder` | `OPENAI_BASE_URL` | configuracao direta |
|
||||
| Grok CLI | `superagent-ai/grok-cli` | `GROK_BASE_URL`/`baseURL` | configuracao direta |
|
||||
| Gitlawb Zero | `Gitlawb/zero` | provider `custom-openai-compatible`, `--base-url` | provider/flag |
|
||||
| DeepSeek Reasonix | `esengine/DeepSeek-Reasonix` | provider compativel e endpoint | confirmar configuracao |
|
||||
| KlaatCode | `KlaatAI/klaatcode` | `customModels` OpenAI-compatible | configuracao JSON |
|
||||
| CodeMini CLI | `havingautism/Codemini-CLI` | `gateway.base_url` | gateway/config |
|
||||
| Zot | `patriceckhart/zot` | `--base-url` e provider custom em `models.json` | flag/config |
|
||||
| Pool | `poolsideai/pool` | `POOLSIDE_STANDALONE_BASE_URL`; licenca proprietaria | configuracao, sem PR assumido |
|
||||
| Octomind | `Muvon/octomind` | `<PROVIDER>_API_URL`/`LOCAL_API_URL` | provider/env |
|
||||
| Coro Code | `Blushyes/coro-code` | `OPENAI_BASE_URL` | configuracao direta |
|
||||
| Mini-Kode | `minmaxflow/mini-kode` | `MINIKODE_BASE_URL` | configuracao direta |
|
||||
| Late CLI | `mlhher/late-cli` | `OPENAI_BASE_URL`/`api-url` | env/flag |
|
||||
| Agentty | `1ay1/agentty` | modelo agnostico e endpoints compativeis | confirmar arquivo de config |
|
||||
| Aizen | `aizen-stack/aizen` | CLI Rust OpenAI-compatible; `AIZEN_BASE_URL` | configuracao direta |
|
||||
| Clif-Code | `DLhugly/Clif-Code` | OpenRouter/OpenAI/Anthropic/Ollama | provider/config |
|
||||
| Minacode | `hit9/minacode` | provider e compatibilidade no README | confirmar URL |
|
||||
| YottaCode | `yottadynamics/yottacode` | modelo escolhido, gateway/provider | confirmar config |
|
||||
| aichat | `sigoden/aichat` | providers OpenAI/Claude/Gemini e compatibilidade | `models.yaml`/provider |
|
||||
| ShellGPT | `TheR1D/shell_gpt` | `API_BASE_URL` | env/config |
|
||||
| Mistral Vibe | `mistralai/mistral-vibe` | `base_url`, API base e provider | config/env |
|
||||
| OpenSquilla | `opensquilla/opensquilla` | 20+ providers e gateway | provider/config |
|
||||
| Kode CLI | `shareAI-lab/Kode-cli` | provider, endpoint e Anthropic/OpenAI/Gemini | config |
|
||||
| Crush | `charmbracelet/crush` | `base_url`, provider compativel | ja catalogado no OmniRoute; validar upstream |
|
||||
| Hermes Agent | `NousResearch/hermes-agent` | endpoint/gateway e 300+ modelos | ja catalogado; validar modo de endpoint |
|
||||
| OpenClaw | `openclaw/openclaw` | providers, gateway e endpoints | ja catalogado; validar configuracao atual |
|
||||
|
||||
## 5. Inventario amplo localizado
|
||||
|
||||
### 5.1 Agentes de terminal e coding CLIs
|
||||
|
||||
Os projetos desta tabela foram encontrados na lista curada ou no GitHub Search. `Pesquisa` indica o proximo gate; nao significa que a integracao ja esta pronta.
|
||||
|
||||
| Projeto | Repositorio | Licenca/sinal publico | Situacao inicial |
|
||||
|---|---|---|---|
|
||||
| OpenCode | `anomalyco/opencode` | multi-provider, 75+ providers | ja suportado; acompanhar provider/plugin |
|
||||
| Codex CLI | `openai/codex` | Apache-2.0, provider configuravel | ja suportado |
|
||||
| OpenHands principal | `All-Hands-AI/OpenHands` | OSS, CLI e web | pesquisar CLI e `LLM_BASE_URL` |
|
||||
| Pi | `badlogic/pi-mono` | harness multi-provider | ja suportado; confirmar repo atual |
|
||||
| Open Interpreter | `OpenInterpreter/open-interpreter` | Apache-2.0, `--api_base` | ja suportado |
|
||||
| Cline | `cline/cline` | Apache-2.0, base URL/gateway | ja suportado |
|
||||
| Goose | `aaif-goose/goose` | Apache-2.0, providers | ja suportado |
|
||||
| Aider | `Aider-AI/aider` | Apache-2.0, Anthropic/OpenAI | ja suportado |
|
||||
| Continue | `continuedev/continue` | Apache-2.0, multi-model | ja suportado |
|
||||
| Deep Agents Code | `langchain-ai/deepagents` | MIT, tool-calling LLM | pesquisar pacote `deepagents-code` |
|
||||
| Crush | `charmbracelet/crush` | provider/base URL | ja suportado |
|
||||
| Kilo Code | `Kilo-Org/kilocode` | MIT, providers | ja suportado |
|
||||
| Qwen Code | `QwenLM/qwen-code` | Apache-2.0, providers | ja suportado |
|
||||
| Roo Code | `RooCodeInc/Roo-Code` | Apache-2.0 | ja catalogado; validar CLI |
|
||||
| Grok Build | `xai-org/grok-build` | Apache-2.0, provider | ja suportado |
|
||||
| Oh My Pi | `can1357/oh-my-pi` | provider custom em YAML | ja suportado |
|
||||
| SWE-agent | `SWE-agent/SWE-agent` | MIT | pesquisar backend e base URL |
|
||||
| Smol Developer | `smol-ai/developer` | embeddable agent | adapter/SDK, nao necessariamente CLI |
|
||||
| Claude Engineer | `Doriandarko/claude-engineer` | CLI Claude | pesquisar provider |
|
||||
| Claurst | `Kuberwastaken/claurst` | GPL-3.0, provider | confirmar endpoint e politica de fork |
|
||||
| Free Code | `paoloanzn/free-code` | fork de Claude Code | pesquisar licenca e endpoint |
|
||||
| Codebuff | `CodebuffAI/codebuff` | multi-agent CLI | pesquisar provider |
|
||||
| ForgeCode | `antinomyhq/forge` | 300+ modelos | ja suportado |
|
||||
| OpenSquilla | `opensquilla/opensquilla` | Apache-2.0, gateway | candidato forte |
|
||||
| Kode CLI | `shareAI-lab/Kode-cli` | Apache-2.0, endpoint | candidato forte |
|
||||
| Devon | `entropy-research/Devon` | pair programmer TUI | pesquisar backend |
|
||||
| AutoCodeRover | `AutoCodeRoverSG/auto-code-rover` | agente de issues | pesquisar configuracao de modelos |
|
||||
| Letta Code | `letta-ai/letta-code` | Apache-2.0, model-agnostic | pesquisar API base |
|
||||
| CodeMachine CLI | `moazbuilds/CodeMachine-CLI` | multi-agent local | pesquisar provider |
|
||||
| Codel | `semanser/codel` | AGPL-3.0, Docker/web UI | confirmar servidor OpenAI e restricoes AGPL |
|
||||
| Agentless | `OpenAutoCoder/Agentless` | workflow sem loop persistente | pesquisar entrada de modelo |
|
||||
| Amazon Q Developer CLI | `aws/amazon-q-developer-cli` | Apache-2.0 | provavelmente auth/ecossistema AWS; pesquisar |
|
||||
| Neovate Code | `neovateai/neovate-code` | MIT, plugin/multi-provider | candidato forte |
|
||||
| Groq Code CLI | `build-with-groq/groq-code-cli` | multi-model | pesquisar endpoint |
|
||||
| Dexto | `truffle-ai/dexto` | CLI/web/API, subagentes | pesquisar provider |
|
||||
| claw-code-agent | `HarnessLab/claw-code-agent` | Python, sem dependencias | confirmar endpoint |
|
||||
| g3 | `dhanji/g3` | Rust, provider abstraction | confirmar licenca e URL |
|
||||
| Coro Code | `Blushyes/coro-code` | base URL/OpenAI | candidato |
|
||||
| Mini-Kode | `minmaxflow/mini-kode` | MIT, referencia educacional | candidato |
|
||||
| zot | `patriceckhart/zot` | MIT, TUI/JSON/RPC | candidato |
|
||||
| agentty | `1ay1/agentty` | MIT, ACP e multi-provider | candidato |
|
||||
| nori-cli | `tilework-tech/nori-cli` | multi-provider sobre Codex | pesquisar base URL |
|
||||
| cursor-agent clone | `civai-technologies/cursor-agent` | OpenAI/Claude/Ollama | pesquisar maturidade e licenca |
|
||||
| DvalinCode | `arthurpanhku/dvalincode` | MIT, OpenAI-compatible | candidato |
|
||||
| OpenHarness | `zhijiewong/openharness` | Apache-2.0, any LLM | candidato |
|
||||
| Octomind | `Muvon/octomind` | Apache-2.0, 13+ providers | candidato |
|
||||
| Codex Infinity | `lee101/codex-infinity` | fork Codex | pesquisar endpoint |
|
||||
| San | `genai-io/san` | Apache-2.0, provider-neutral | pesquisar endpoint |
|
||||
| Waveloom | `Menfre01/waveloom` | Apache-2.0, DeepSeek-focused | pesquisar provider |
|
||||
| picocode | `jondot/picocode` | Rust, multi-LLM | pesquisar provider |
|
||||
| QQCode | `qnguyen3/qqcode` | Rust, skills | pesquisar provider |
|
||||
| Keen Code | `mochow13/keen-code` | MIT, 9+ providers | pesquisar provider |
|
||||
| Smelt | `leonardcser/smelt` | MIT, OpenAI-compatible | ja suportado |
|
||||
| Grinta | `josephsenior/Grinta-Coding-Agent` | MIT, Python | pesquisar provider |
|
||||
| Zap | `zap-coding-agent/zap-coding-agent` | MIT, MCP, local/OpenAI | pesquisar endpoint |
|
||||
| Binharic | `CogitatorTech/binharic-cli` | multi-provider | pesquisar endpoint |
|
||||
| Darce | `AmerSarhan/darce-cli` | MIT, multi-model | pesquisar endpoint |
|
||||
| CLAII | `agencyswarm/CLAII` | multi-agent/MCP | pesquisar endpoint |
|
||||
|
||||
### 5.2 Agentes generalistas e ecossistema OpenClaw
|
||||
|
||||
Estes podem consumir OmniRoute como backend, mas a task deve confirmar se a interface de configuracao e realmente uma CLI de codigo ou apenas um gateway de agente.
|
||||
|
||||
| Projeto | Repositorio | Possivel caminho |
|
||||
|---|---|---|
|
||||
| OpenClaw | `openclaw/openclaw` | provider/gateway; ja catalogado |
|
||||
| nanobot | `HKUDS/nanobot` | provider OpenAI-compatible |
|
||||
| ZeroClaw | `zeroclaw-labs/zeroclaw` | trait de provider |
|
||||
| NanoClaw | `gavrielc/nanoclaw` | Anthropic SDK; pesquisar base |
|
||||
| PicoClaw | `sipeed/picoclaw` | provider/config |
|
||||
| IronClaw | `nearai/ironclaw` | provider Rust |
|
||||
| NullClaw | `nullclaw/nullclaw` | 23+ providers |
|
||||
| Clawith | `dataelement/Clawith` | gateway/teams |
|
||||
| claw0 | `shareAI-lab/claw0` | tutorial/runtime; pesquisa de viabilidade |
|
||||
| Moltis | `moltis-org/moltis` | provider Rust |
|
||||
| GitClaw | `open-gitagent/gitclaw` | agente Git-native; pesquisar |
|
||||
| LionClaw | `moshthepitt/lionclaw` | CLI local; pesquisar |
|
||||
| Aizen | `aizen-stack/aizen` | OpenAI-compatible |
|
||||
| aichat | `sigoden/aichat` | provider/model YAML |
|
||||
| ShellGPT | `TheR1D/shell_gpt` | `API_BASE_URL` |
|
||||
| gptme | `gptme/gptme` | `OPENAI_BASE_URL` |
|
||||
|
||||
### 5.3 Orquestradores, wrappers e ferramentas adjacentes
|
||||
|
||||
Nao sao todos alvos de um provider OmniRoute. Devem ser avaliados para launcher, ACP, MCP, observabilidade ou configuracao de seus agentes filhos.
|
||||
|
||||
| Projeto | Repositorio | Tipo de integracao a investigar |
|
||||
|---|---|---|
|
||||
| Agent Deck | `asheshgoplani/agent-deck` | config dos CLIs filhos; ja catalogado |
|
||||
| VibePod | `VibePod/vibepod-cli` | wrapper Docker e metricas |
|
||||
| zeroshot | `the-open-engine/zeroshot` | launcher/worktrees |
|
||||
| Fractal | `plasma-ai/fractal` | orquestrador de CLIs |
|
||||
| Bernstein | `chernistry/bernstein` | orquestrador/verificador |
|
||||
| Traycer | `traycerai/traycer` | CLI custom e agentes filhos |
|
||||
| h5i | `h5i-dev/h5i` | execucao paralela |
|
||||
| OMK | `dmae97/open-multi-agent-kit` | control plane/provider-neutral |
|
||||
| kodo | `ikamensh/kodo` | orquestrador |
|
||||
| ORCH | `oxgeneral/ORCH` | fila de tarefas |
|
||||
| LoopTroop | `LoopTroop-ai/LoopTroop` | orchestration sobre OpenCode |
|
||||
| Galley | `shinpr/galley` | worktree/PR handoff |
|
||||
| Relay | `jcast90/relay` | MCP/orquestracao |
|
||||
| sage | `youwangd/SageCLI` | runtime-agnostic |
|
||||
| 5dive | `5dive-ai/5dive` | agentes em servidor |
|
||||
| agx | `ramarlina/agx` | checkpoints e agentes |
|
||||
| claude-code-router | `musistudio/claude-code-router` | proxy/roteamento; possivel upstream consumidor |
|
||||
| cc-router | `finch-xu/cc-router` | proxy Anthropic multi-provider |
|
||||
| OneCLI | `onecli/onecli` | broker de credenciais, nao agente |
|
||||
| agent-browser | `vercel-labs/agent-browser` | ferramenta MCP/plugin |
|
||||
| OpenWork | `different-ai/openwork` | desktop sobre OpenCode |
|
||||
| Mistral Vibe | `mistralai/mistral-vibe` | provider/base URL |
|
||||
| Junie CLI | `junie.jetbrains.com` | fechado; configuracao BYOK a confirmar |
|
||||
| Pool | `poolsideai/pool` | binario/EULA; sem PR presumido |
|
||||
|
||||
## 6. Evidencias tecnicas e mapeamento para OmniRoute
|
||||
|
||||
### 6.1 Padroes de endpoint encontrados
|
||||
|
||||
| Padrao observado | Exemplos | Acao OmniRoute |
|
||||
|---|---|---|
|
||||
| `OPENAI_BASE_URL`/`OPENAI_API_BASE` | Claw Code, RA.Aid, CoreCoder, Coro Code | fornecer root ou `/v1` conforme o cliente; testar append de path |
|
||||
| `base_url`/`baseURL` em provider | Plandex, MiMo Code, Trae Agent, VT Code, KlaatCode | gerar bloco de provider e modelo |
|
||||
| `LLM_BASE_URL` | OpenHands CLI | configurar surface OpenAI e validar streaming/tool calling |
|
||||
| `GOOGLE_GEMINI_BASE_URL` | Gemini CLI | usar superficie `/v1beta`/Gemini; confirmar formato esperado |
|
||||
| `GROK_BASE_URL` | Grok CLI | decidir se o cliente fala xAI ou OpenAI; testar traducoes |
|
||||
| `--base-url` | Gitlawb Zero, Zot, jcode | launcher ou perfil persistido |
|
||||
| `API_BASE_URL` | ShellGPT | config/env direta |
|
||||
| `<PROVIDER>_API_URL`/gateway | Octomind, Pool, OpenSquilla | provider selecionavel; testar cada preset |
|
||||
| ACP/MCP sem URL direta | Agentty, Kimi CLI, Goose, OpenCode | avaliar se OmniRoute deve ser provider ou backend ACP |
|
||||
| endpoint nao customizavel | Cursor desktop, Antigravity, Kiro, Windsurf, Amp | somente MITM/guide; nao prometer integracao direta |
|
||||
|
||||
### 6.2 Superficies e riscos de protocolo
|
||||
|
||||
- **`/v1` duplicado:** alguns clientes recebem a raiz e acrescentam `/v1/chat/completions`; outros exigem a URL final com `/v1`. Cada task deve registrar o resultado real.
|
||||
- **Chat Completions vs Responses:** forks do Codex e clientes modernos podem usar Responses; testar ambas quando o cliente permitir.
|
||||
- **Anthropic:** clientes que mandam `/v1/messages` esperam `ANTHROPIC_BASE_URL` sem `/v1` no valor. A traducao Anthropic do OmniRoute deve ser validada com streaming e tool use.
|
||||
- **Gemini:** Gemini CLI pode esperar uma base Gemini nativa, nao somente OpenAI-compatible; validar `generateContent`, streaming e headers.
|
||||
- **Tool calling:** o agente pode exigir nomes/ids de ferramenta estaveis, JSON estrito, `tool_choice` ou blocos de pensamento especificos.
|
||||
- **Descoberta de modelos:** `/v1/models` pode ser obrigatorio, opcional ou inexistente. O setup precisa aceitar `--model` fixo quando a descoberta nao for suportada.
|
||||
- **Autenticacao:** alguns projetos leem somente env, outros gravam tokens em arquivo/keyring e alguns usam OAuth proprietario. Nunca reutilizar credenciais de um upstream sem verificar escopo.
|
||||
- **Streaming e retry:** SSE, timeouts, abort signals e re-tentativas podem divergir do cliente. Validar uma chamada longa e uma falha de provider.
|
||||
- **Licenca:** GPL/AGPL, EULA e repositorios sem SPDX exigem decisao de distribuicao antes de enviar patch.
|
||||
|
||||
## 7. Riscos de pesquisa e integracao
|
||||
|
||||
1. **Homonomimos e clones:** usar sempre URL canonica, organizacao, release e README do repositorio correto.
|
||||
2. **Repositorios que mudam rapidamente:** congelar commit/versao no relatorio da task e repetir a consulta no dia da implementacao.
|
||||
3. **README divergente do codigo:** procurar schema, parser de config, testes e comando de execucao; README sozinho e evidencia Tier 1.
|
||||
4. **Clientes fechados:** registrar como `needs-mitm` ou `config-only`, nunca como PR upstream.
|
||||
5. **Forks com historia de origem controversa:** avaliar politica, licenca e aceite de contribuicoes antes de reproduzir componentes.
|
||||
6. **Segredos no ambiente:** limpar `OMNIROUTE_API_KEY` e chaves de teste quando a suite assume ambiente sem credencial, como ocorreu no jcode.
|
||||
7. **Mudancas no checkout:** usar worktree em `.claude/worktrees/` por projeto; nao editar o checkout compartilhado do OmniRoute nem usar `git stash`.
|
||||
|
||||
## 8. Recomendacao
|
||||
|
||||
Executar primeiro os lotes P0/P1 do documento de prioridade. Cada lote pode ter ate tres subagentes, um repositorio por worktree. O agente principal deve revisar a pesquisa, o smoke test e a licenca antes de permitir implementacao. O resultado de cada caso deve atualizar o tracker com commit, PR/issue, validacao e status upstream, sem preencher campos externos por suposicao.
|
||||
|
||||
## 9. Referencias
|
||||
|
||||
- OmniRoute CLI catalogo: `src/shared/constants/cliTools.ts`
|
||||
- OmniRoute CLI reference: `docs/reference/CLI-TOOLS.md`
|
||||
- OmniRoute setup guide: `docs/guides/CLI-INTEGRATIONS.md`
|
||||
- Handoff jcode: `_tasks/hands-off/2026-08-01_release-v3.8.50_v3.8.50_sess-e1846bc2/handoff.md`
|
||||
- Inventario curado: `https://github.com/bradAGI/awesome-cli-coding-agents`
|
||||
- GitHub Search API: `https://api.github.com/search/repositories`
|
||||
167
_references/_sistemas_cli/02-prioridade-integracoes-clis.md
Normal file
167
_references/_sistemas_cli/02-prioridade-integracoes-clis.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# Prioridade de integracoes de CLIs com OmniRoute
|
||||
|
||||
> **Status final (2026-08-03):** esta é a priorização inicial que orientou a execução. Todos os `104/104` casos já foram pesquisados. A classificação final está no tracker `04`; a estratégia revisada de contribuição está no relatório `06`.
|
||||
|
||||
**Snapshot:** 2026-08-01
|
||||
**Objetivo:** ordenar do melhor para o pior todos os projetos tecnicamente candidatos a consumir OmniRoute, sem remover projetos pequenos. A ordem e uma fila de pesquisa/execucao; ela nao e promessa de que todo upstream aceitara um PR.
|
||||
|
||||
## Como ler a prioridade
|
||||
|
||||
- **P0:** ja esta no catalogo OmniRoute ou tem evidencia muito forte de endpoint customizavel; executar/consolidar primeiro.
|
||||
- **P1:** forte candidato novo, com provider/base URL evidente e bom retorno para o ecossistema.
|
||||
- **P2:** tecnicamente promissor, mas requer confirmacao de protocolo, config, maturidade ou licenca.
|
||||
- **P3:** possivel via ACP/MCP/wrapper/launcher, ou com menor adocao; pesquisar depois dos P0-P2.
|
||||
- **P4:** cliente fechado, EULA, MITM ou pesquisa exploratoria; manter no inventario, mas nao bloquear os demais.
|
||||
|
||||
Os fatores usados foram: evidencia de endpoint arbitrario, adocao/atividade, facilidade de teste, compatibilidade OpenAI/Anthropic/Gemini, maturidade, licenca, chance de PR upstream, valor para usuarios OmniRoute e risco de protocolo.
|
||||
|
||||
## A. Catalogo OmniRoute ja existente
|
||||
|
||||
Estas entradas ja aparecem no registro local. A prioridade aqui significa consolidar documentacao, smoke tests, detector/configurador e eventual upstream nominal; nao significa recriar uma integracao que ja existe.
|
||||
|
||||
| Ordem | Projeto | Repositorio/documentacao | Estado local | Proximo foco |
|
||||
|---:|---|---|---|---|
|
||||
| A1 | Claude Code | `anthropics/claude-code` | catalogado; Anthropic base URL | manter compatibilidade Anthropic, streaming e tools |
|
||||
| A2 | Codex CLI | `openai/codex` | catalogado; OpenAI-compatible | Responses, profiles e `/v1` |
|
||||
| A3 | OpenCode | `anomalyco/opencode` | catalogado; provider | provider nativo/plugin e model discovery |
|
||||
| A4 | Cline | `cline/cline` | catalogado; base URL | validar CLI/extension e append de `/v1` |
|
||||
| A5 | Goose | `aaif-goose/goose` | catalogado; `OPENAI_HOST` | validar schema atual e ACP |
|
||||
| A6 | Aider | `Aider-AI/aider` | catalogado; `OPENAI_API_BASE` | LiteLLM path, tools e custo |
|
||||
| A7 | Continue | `continuedev/continue` | catalogado; provider OpenAI | CLI e config YAML atual |
|
||||
| A8 | Kilo Code | `Kilo-Org/kilocode` | catalogado; custom URL | CLI, extension e auth |
|
||||
| A9 | Roo Code | `RooCodeInc/Roo-Code` | catalogado; custom URL | CLI/headless e provider |
|
||||
| A10 | Qwen Code | `QwenLM/qwen-code` | catalogado; `modelProviders` | V4 schema, Responses e env |
|
||||
| A11 | Open Interpreter | `OpenInterpreter/open-interpreter` | catalogado; `--api_base` | streaming e tool execution |
|
||||
| A12 | OpenClaw | `openclaw/openclaw` | catalogado; gateway/provider | config atual e segurança |
|
||||
| A13 | Hermes Agent | `NousResearch/hermes-agent` | catalogado; provider/gateway | endpoint custom e modelos |
|
||||
| A14 | Hermes | `NousResearch/hermes-agent` | catalogado/dual entry | distinguir CLI e agente |
|
||||
| A15 | Oh My Pi | `can1357/oh-my-pi` | catalogado; YAML provider | auto-discovery e tool calling |
|
||||
| A16 | Pi | `badlogic/pi-mono` | catalogado; provider | confirmar repositorio/CLI atual |
|
||||
| A17 | Crush | `charmbracelet/crush` | catalogado; `base_url` | config TOML/JSON atual |
|
||||
| A18 | Smelt | `leonardcser/smelt` | catalogado; OpenAI-compatible | headless e subagents |
|
||||
| A19 | ForgeCode | `antinomyhq/forge` | catalogado; multi-provider | base URL e custom agents |
|
||||
| A20 | jcode | `1jehuang/jcode` | integrado e proposto upstream | aguardar issue #704; manter README OmniRoute |
|
||||
| A21 | DeepSeek TUI | `hunterbown/deepseek-tui` | catalogado legado | confirmar sucessor CodeWhale |
|
||||
| A22 | CodeWhale | `Hmbown/CodeWhale` | catalogado | config primaria e legado |
|
||||
| A23 | Grok Build | `xai-org/grok-build` | catalogado; `~/.grok/config.toml` | provider OmniRoute e modelos |
|
||||
| A24 | Cursor Agent CLI | `cursor.com/cli` | catalogado parcial | confirmar limites de endpoint |
|
||||
| A25 | Factory Droid | `Factory-AI/factory` | catalogado parcial | BYOK e endpoint suportado |
|
||||
| A26 | GitHub Copilot CLI | `github/copilot-cli` | catalogado | provider base URL atual |
|
||||
| A27 | Letta CLI | `letta-ai/letta-code` | catalogado | config pi-ai/local mode |
|
||||
| A28 | Warp AI | `warpdotdev/Warp` | catalogado parcial | somente BYOK/desktop |
|
||||
| A29 | Agent Deck | `asheshgoplani/agent-deck` | catalogado | agentes filhos e ACP |
|
||||
| A30 | Antigravity | produto Google | MITM backlog | nao tratar como endpoint direto |
|
||||
| A31 | Kiro AI | produto AWS | MITM backlog | auth/SSO e MITM |
|
||||
| A32 | Cursor desktop | produto Anysphere | cloud/MITM | manter separado do Cursor CLI |
|
||||
|
||||
## B. Novos candidatos em ordem de execucao
|
||||
|
||||
| Ordem | Prioridade | Projeto | Repositorio | Evidencia inicial | Rota esperada |
|
||||
|---:|:---:|---|---|---|---|
|
||||
| 1 | P0 | Gemini CLI | `google-gemini/gemini-cli` | `GOOGLE_GEMINI_BASE_URL` | config direta/Gemini |
|
||||
| 2 | P0 | Claw Code | `ultraworkers/claw-code` | `OPENAI_BASE_URL`, provider | OpenAI-compatible |
|
||||
| 3 | P0 | Plandex | `plandex-ai/plandex` | provider com `baseUrl` | preset/provider |
|
||||
| 4 | P0 | MiMo Code | `XiaomiMiMo/MiMo-Code` | `@ai-sdk/openai-compatible`, `baseURL` | provider |
|
||||
| 5 | P0 | Trae Agent | `bytedance/trae-agent` | `model_providers`, `base_url` | provider/config |
|
||||
| 6 | P0 | Kimi CLI | `MoonshotAI/kimi-cli` | OpenAI legacy/Responses/Anthropic, `base_url` | provider nativo |
|
||||
| 7 | P0 | Every Code | `just-every/code` | fork Codex, OpenAI/Claude/Gemini | profile/provider |
|
||||
| 8 | P0 | Open Codex | `ymichael/open-codex` | OpenAI/Gemini/OpenRouter/Ollama | profile/provider |
|
||||
| 9 | P0 | VT Code | `vinhnx/vtcode` | `custom_providers[].base_url` | provider/failover |
|
||||
| 10 | P0 | OpenHands CLI | `OpenHands/OpenHands-CLI` | `LLM_BASE_URL` | config direta |
|
||||
| 11 | P0 | gptme | `gptme/gptme` | `OPENAI_BASE_URL` | config direta |
|
||||
| 12 | P0 | Nanocoder | `Nano-Collective/nanocoder` | qualquer OpenAI-compatible | config direta |
|
||||
| 13 | P0 | RA.Aid | `ai-christianson/RA.Aid` | `OPENAI_API_BASE` | config direta |
|
||||
| 14 | P0 | CoreCoder | `he-yufeng/CoreCoder` | `OPENAI_BASE_URL` | config direta |
|
||||
| 15 | P1 | Grok CLI | `superagent-ai/grok-cli` | `GROK_BASE_URL`/`baseURL` | config direta |
|
||||
| 16 | P1 | Gitlawb Zero | `Gitlawb/zero` | `custom-openai-compatible`, `--base-url` | provider/flag |
|
||||
| 17 | P1 | DeepSeek Reasonix | `esengine/DeepSeek-Reasonix` | endpoint/provider compativel | provider |
|
||||
| 18 | P1 | KlaatCode | `KlaatAI/klaatcode` | `customModels` OpenAI-compatible | config |
|
||||
| 19 | P1 | CodeMini CLI | `havingautism/Codemini-CLI` | `gateway.base_url` | gateway |
|
||||
| 20 | P1 | Zot | `patriceckhart/zot` | `--base-url`, `models.json` | flag/config |
|
||||
| 21 | P1 | Octomind | `Muvon/octomind` | provider URL envs | provider/env |
|
||||
| 22 | P1 | DvalinCode | `arthurpanhku/dvalincode` | qualquer OpenAI-compatible | config direta |
|
||||
| 23 | P1 | Coro Code | `Blushyes/coro-code` | `OPENAI_BASE_URL` | env |
|
||||
| 24 | P1 | Mini-Kode | `minmaxflow/mini-kode` | `MINIKODE_BASE_URL` | env |
|
||||
| 25 | P1 | Late CLI | `mlhher/late-cli` | `OPENAI_BASE_URL`, `api-url` | env/flag |
|
||||
| 26 | P1 | Agentty | `1ay1/agentty` | provider-agnostic, ACP | config/ACP |
|
||||
| 27 | P1 | Aizen | `aizen-stack/aizen` | Rust OpenAI-compatible, `AIZEN_BASE_URL` | config |
|
||||
| 28 | P1 | Clif-Code | `DLhugly/Clif-Code` | OpenAI/Anthropic/Ollama | provider |
|
||||
| 29 | P1 | Minacode | `hit9/minacode` | provider/compatibilidade | confirmar URL |
|
||||
| 30 | P1 | YottaCode | `yottadynamics/yottacode` | modelo escolhido/gateway | provider |
|
||||
| 31 | P1 | aichat | `sigoden/aichat` | OpenAI/Claude/Gemini | models YAML |
|
||||
| 32 | P1 | ShellGPT | `TheR1D/shell_gpt` | `API_BASE_URL` | env |
|
||||
| 33 | P1 | Mistral Vibe | `mistralai/mistral-vibe` | `base_url`, API base | config |
|
||||
| 34 | P1 | OpenSquilla | `opensquilla/opensquilla` | gateway, 20+ providers | provider |
|
||||
| 35 | P1 | Kode CLI | `shareAI-lab/Kode-cli` | endpoint/Anthropic/OpenAI/Gemini | config |
|
||||
| 36 | P1 | Neovate Code | `neovateai/neovate-code` | plugin/multi-provider | plugin/provider |
|
||||
| 37 | P1 | Deep Agents Code | `langchain-ai/deepagents` | qualquer tool-calling LLM | provider SDK |
|
||||
| 38 | P1 | Kode fork/variants | `shareAI-lab/Kode-cli` | multi-provider | confirmar upstream |
|
||||
| 39 | P1 | OpenHands principal | `All-Hands-AI/OpenHands` | CLI/web; pesquisar LLM base | config/CLI |
|
||||
| 40 | P1 | SWE-agent | `SWE-agent/SWE-agent` | agente de issues | backend/provider |
|
||||
| 41 | P1 | AutoCodeRover | `AutoCodeRoverSG/auto-code-rover` | agente de patches | backend/provider |
|
||||
| 42 | P2 | Claurst | `Kuberwastaken/claurst` | provider/Anthropic | config; licenca GPL |
|
||||
| 43 | P2 | Codebuff | `CodebuffAI/codebuff` | multi-agent CLI | provider |
|
||||
| 44 | P2 | Devon | `entropy-research/Devon` | TUI pair programmer | backend |
|
||||
| 45 | P2 | Letta Code | `letta-ai/letta-code` | model-agnostic | provider |
|
||||
| 46 | P2 | CodeMachine CLI | `moazbuilds/CodeMachine-CLI` | multi-agent local | provider |
|
||||
| 47 | P2 | Groq Code CLI | `build-with-groq/groq-code-cli` | multi-model | endpoint |
|
||||
| 48 | P2 | Dexto | `truffle-ai/dexto` | CLI/web/API | provider |
|
||||
| 49 | P2 | claw-code-agent | `HarnessLab/claw-code-agent` | endpoint/gateway | provider |
|
||||
| 50 | P2 | g3 | `dhanji/g3` | Rust provider abstraction | provider |
|
||||
| 51 | P2 | San | `genai-io/san` | provider-neutral | provider |
|
||||
| 52 | P2 | Waveloom | `Menfre01/waveloom` | DeepSeek/provider | endpoint |
|
||||
| 53 | P2 | picocode | `jondot/picocode` | multi-LLM | config |
|
||||
| 54 | P2 | QQCode | `qnguyen3/qqcode` | skills, Rust | config |
|
||||
| 55 | P2 | Keen Code | `mochow13/keen-code` | 9+ providers | config |
|
||||
| 56 | P2 | Grinta | `josephsenior/Grinta-Coding-Agent` | provider-agnostic | config |
|
||||
| 57 | P2 | Zap | `zap-coding-agent/zap-coding-agent` | Claude/Gemini/OpenAI/LM Studio | provider |
|
||||
| 58 | P2 | Binharic | `CogitatorTech/binharic-cli` | multi-provider | config |
|
||||
| 59 | P2 | Darce | `AmerSarhan/darce-cli` | multi-model/streaming | config |
|
||||
| 60 | P2 | CLAII | `agencyswarm/CLAII` | multi-agent/MCP | provider |
|
||||
| 61 | P2 | nori-cli | `tilework-tech/nori-cli` | multi-provider sobre Codex | config |
|
||||
| 62 | P2 | cursor-agent clone | `civai-technologies/cursor-agent` | Claude/OpenAI/Ollama | provider |
|
||||
| 63 | P2 | Free Code | `paoloanzn/free-code` | fork Claude Code | licenca/config |
|
||||
| 64 | P2 | Claude Engineer | `Doriandarko/claude-engineer` | CLI Claude | provider |
|
||||
| 65 | P2 | Smol Developer | `smol-ai/developer` | agent embutivel | SDK/adaptador |
|
||||
| 66 | P2 | Agentless | `OpenAutoCoder/Agentless` | workflow sem loop | entrada de modelo |
|
||||
| 67 | P2 | Amazon Q Developer CLI | `aws/amazon-q-developer-cli` | CLI AWS | auth/provider |
|
||||
| 68 | P2 | nanobot | `HKUDS/nanobot` | OpenClaw rewrite | provider |
|
||||
| 69 | P2 | ZeroClaw | `zeroclaw-labs/zeroclaw` | providers pluggable | provider |
|
||||
| 70 | P2 | NanoClaw | `gavrielc/nanoclaw` | Anthropic SDK | base URL |
|
||||
| 71 | P2 | PicoClaw | `sipeed/picoclaw` | provider/config | provider |
|
||||
| 72 | P2 | IronClaw | `nearai/ironclaw` | provider Rust | provider |
|
||||
| 73 | P2 | NullClaw | `nullclaw/nullclaw` | 23+ providers | provider |
|
||||
| 74 | P2 | Moltis | `moltis-org/moltis` | Rust agent | provider |
|
||||
| 75 | P2 | GitClaw | `open-gitagent/gitclaw` | Git-native agent | provider |
|
||||
| 76 | P2 | LionClaw | `moshthepitt/lionclaw` | CLI local | provider |
|
||||
| 77 | P3 | VibePod | `VibePod/vibepod-cli` | wrapper Docker | launcher |
|
||||
| 78 | P3 | zeroshot | `the-open-engine/zeroshot` | worktrees/orchestration | launcher |
|
||||
| 79 | P3 | Fractal | `plasma-ai/fractal` | orquestra CLIs | launcher |
|
||||
| 80 | P3 | Bernstein | `chernistry/bernstein` | executa/verifica agentes | launcher |
|
||||
| 81 | P3 | Traycer | `traycerai/traycer` | agentes paralelos | launcher |
|
||||
| 82 | P3 | h5i | `h5i-dev/h5i` | sandbox e peer review | launcher |
|
||||
| 83 | P3 | OMK | `dmae97/open-multi-agent-kit` | control plane | ACP/MCP |
|
||||
| 84 | P3 | kodo | `ikamensh/kodo` | orquestrador | launcher |
|
||||
| 85 | P3 | ORCH | `oxgeneral/ORCH` | fila de tarefas | launcher |
|
||||
| 86 | P3 | LoopTroop | `LoopTroop-ai/LoopTroop` | orquestrador OpenCode | launcher |
|
||||
| 87 | P3 | Galley | `shinpr/galley` | worktree/PR | launcher |
|
||||
| 88 | P3 | Relay | `jcast90/relay` | MCP/orquestracao | MCP |
|
||||
| 89 | P3 | SageCLI | `youwangd/SageCLI` | runtime-agnostic | launcher/ACP |
|
||||
| 90 | P3 | 5dive | `5dive-ai/5dive` | agentes em servidor | launcher |
|
||||
| 91 | P3 | agx | `ramarlina/agx` | checkpoints | launcher |
|
||||
| 92 | P3 | claude-code-router | `musistudio/claude-code-router` | proxy multi-provider | integrar como consumidor/proxy |
|
||||
| 93 | P3 | cc-router | `finch-xu/cc-router` | proxy Anthropic | interoperabilidade |
|
||||
| 94 | P3 | OneCLI | `onecli/onecli` | broker de credenciais | seguranca/integ. adjacente |
|
||||
| 95 | P3 | agent-browser | `vercel-labs/agent-browser` | ferramenta para agentes | MCP/plugin |
|
||||
| 96 | P3 | OpenWork | `different-ai/openwork` | desktop sobre OpenCode | config do agente filho |
|
||||
| 97 | P4 | Pool | `poolsideai/pool` | `POOLSIDE_STANDALONE_BASE_URL`; EULA | config sem PR presumido |
|
||||
| 98 | P4 | Junie CLI | `junie.jetbrains.com` | fechado/EAP | BYOK/endpoint a confirmar |
|
||||
| 99 | P4 | Cursor desktop | `Anysphere` | cloud endpoint | MITM/guide |
|
||||
| 100 | P4 | Windsurf | produto Codeium | sem base URL geral | MITM |
|
||||
| 101 | P4 | Amp | `sourcegraph.com/amp` | fechado | MITM/sem PR |
|
||||
| 102 | P4 | Amazon Q/Kiro CLI | AWS | SSO/ecossistema AWS | MITM/adapter |
|
||||
| 103 | P4 | Cowork | produto Anthropic | endpoint opaco | MITM |
|
||||
|
||||
## C. Regra de promocao/rebaixamento
|
||||
|
||||
Um projeto sobe de prioridade quando a pesquisa individual confirma: configuracao documentada, teste local com OmniRoute, licenca permissiva e contribuicao aceita. Desce quando: a URL e fixa, o endpoint e somente SaaS, o README nao corresponde ao codigo, a autenticacao e inseparavel do provedor, ou a licenca/EULA impede redistribuicao. Nenhum projeto e marcado como impossivel sem registrar a evidencia no tracker.
|
||||
314
_references/_sistemas_cli/03-plano-integracao-em-lotes.md
Normal file
314
_references/_sistemas_cli/03-plano-integracao-em-lotes.md
Normal file
@@ -0,0 +1,314 @@
|
||||
# Plano executavel de integracao de CLIs
|
||||
|
||||
> **Status final (2026-08-03):** a fase de pesquisa foi concluída em lotes de até três worktrees/agentes, cobrindo `104/104` casos. Este documento continua válido como processo operacional para implementação/publicação. Consulte `06-relatorio-final-104-clis-e-estrategia-prs.md` para o resultado final.
|
||||
|
||||
**Data:** 2026-08-01
|
||||
**Objetivo:** pesquisar, integrar, validar e publicar suporte ao OmniRoute em todos os projetos tecnicamente possiveis, mantendo uma fila que permite ate tres subagentes simultaneos.
|
||||
|
||||
O ciclo especifico de preparacao, revisao, envio e acompanhamento das contribuicoes upstream esta
|
||||
em `05-plano-publicacao-prs-upstream.md`.
|
||||
|
||||
## 1. Principios operacionais
|
||||
|
||||
- Um repositorio por subagente e por worktree.
|
||||
- No maximo tres tasks de repositorios em execucao ao mesmo tempo.
|
||||
- Cada task pesquisa o upstream novamente antes de editar; o relatorio inicial e somente contexto.
|
||||
- O agente principal revisa licenca, arquitetura, smoke test e diff antes do proximo lote.
|
||||
- Nao usar checkout compartilhado para desenvolvimento e nao usar `git stash`/`git pop`.
|
||||
- Usar worktrees em `.claude/worktrees/` e branches especificas.
|
||||
- Nao inventar PR, issue, commit ou aceite de mantenedor.
|
||||
- Nao adicionar trailers ou rodapes de IA em commits/PRs.
|
||||
|
||||
## 2. Fases obrigatorias por projeto
|
||||
|
||||
### Fase 0 - Preparacao da task
|
||||
|
||||
Criar uma task com nome do projeto, URL canonica, prioridade, evidencia inicial, estado no catalogo OmniRoute e objetivo de integrar. Definir a worktree e o agente responsavel.
|
||||
|
||||
### Fase 1 - Pesquisa individual fresca
|
||||
|
||||
O agente deve verificar no upstream atual:
|
||||
|
||||
- arquitetura de providers e ponto de entrada do CLI;
|
||||
- arquivo/schema de configuracao e suporte a `base_url`, `baseURL`, `OPENAI_BASE_URL`, `OPENAI_API_BASE`, `LLM_BASE_URL` ou equivalente;
|
||||
- protocolo real (Chat Completions, Responses, Anthropic Messages, Gemini, ACP, MCP ou outro);
|
||||
- descoberta de modelos e necessidade de `/v1/models`;
|
||||
- autenticacao, keyring, OAuth e variaveis de ambiente;
|
||||
- streaming, tool calling, reasoning e limites conhecidos;
|
||||
- politica de contribuicao, licenca e se PR de fork externo e aceito;
|
||||
- atividade, releases, issues/PRs sobre providers customizados ou endpoints locais;
|
||||
- comandos de build, lint, teste e smoke test;
|
||||
- possibilidade de fork/PR, issue de proposta, documentacao ou apenas wrapper/MITM.
|
||||
|
||||
Registrar commit/release pesquisado e links de evidencia.
|
||||
|
||||
### Fase 2 - Gate de viabilidade
|
||||
|
||||
Classificar exatamente um caminho inicial:
|
||||
|
||||
`viable-direct` (somente configuracao), `viable-upstream` (mudanca no upstream), `viable-acp`, `viable-mcp`, `needs-wrapper`, `needs-mitm`, `config-only`, `blocked` ou `research-more`.
|
||||
|
||||
Nao implementar antes de haver uma conclusao de viabilidade e uma razao verificavel.
|
||||
|
||||
### Fase 3 - Baseline e TDD
|
||||
|
||||
- Executar a suite recomendada pelo upstream antes das mudancas.
|
||||
- Registrar falhas preexistentes, dependencias ausentes e comandos exatos.
|
||||
- Limpar `OMNIROUTE_API_KEY` e demais credenciais quando os testes pressupuserem ambiente sem chaves.
|
||||
- Adicionar primeiro um teste de configuracao, endpoint e selecao de modelo que falhe sem a integracao.
|
||||
|
||||
### Fase 4 - Implementacao minima
|
||||
|
||||
Implementar apenas o necessario para o caso pesquisado:
|
||||
|
||||
- perfil/preset `omniroute` ou provider custom;
|
||||
- base URL correta (raiz, `/v1` ou `/v1beta` conforme o cliente);
|
||||
- chave via ambiente ou mecanismo seguro do cliente;
|
||||
- modelo fixo ou descoberta de modelos;
|
||||
- selecao/login/report se o CLI tiver esses fluxos;
|
||||
- documentacao de uso e limites;
|
||||
- testes de config e chamada.
|
||||
|
||||
Se o upstream nao aceitar mudanca, preparar wrapper/launcher ou documentacao local e registrar a limitacao.
|
||||
|
||||
### Fase 5 - Validacao funcional
|
||||
|
||||
Executar, conforme o protocolo:
|
||||
|
||||
- build, lint, typecheck e testes do upstream;
|
||||
- smoke request com OmniRoute;
|
||||
- streaming SSE e encerramento por abort;
|
||||
- tool calling e JSON de argumentos;
|
||||
- `/v1/models` ou equivalente;
|
||||
- Chat Completions, Responses, Anthropic Messages e Gemini `generateContent` quando aplicavel;
|
||||
- fallback/erro, timeout, retry e modelo inexistente;
|
||||
- teste com chave limpa e teste com `OMNIROUTE_API_KEY` real fora dos logs.
|
||||
|
||||
### Fase 6 - Publicacao upstream
|
||||
|
||||
- Criar fork somente quando permitido e branch especifica.
|
||||
- Abrir PR upstream se contribuicoes externas forem aceitas.
|
||||
- Se PR externo for bloqueado, abrir issue com proposta, patch/referencia e smoke test.
|
||||
- Se o projeto for fechado/EULA, registrar config manual ou issue de produto; nao criar PR ficticio.
|
||||
- Atualizar o tracker com URL, commit, estado e resposta do mantenedor.
|
||||
|
||||
### Fase 7 - Catalogo e integracao OmniRoute
|
||||
|
||||
Quando houver valor para usuarios OmniRoute:
|
||||
|
||||
- criar worktree propria do OmniRoute;
|
||||
- atualizar `src/shared/constants/cliTools.ts` ou `src/shared/constants/cliToolsGrokBuild.ts`;
|
||||
- atualizar detector em `src/lib/cli-helper/tool-detector.ts` se necessario;
|
||||
- adicionar gerador/configurador e rota de settings somente se o caso exigir;
|
||||
- adicionar testes do catalogo, detector, settings, `baseUrlSupport` e `/v1`;
|
||||
- atualizar `docs/reference/CLI-TOOLS.md`, `docs/guides/CLI-INTEGRATIONS.md` e README quando apropriado;
|
||||
- atualizar o tracker com a integracao local e evidencias.
|
||||
|
||||
### Fase 8 - Fechamento
|
||||
|
||||
Registrar commit, branch, PR/issue, testes, limitacoes, status do upstream, status do catalogo OmniRoute e proximo passo. O agente principal faz uma revisao final de seguranca, licenca e factualidade.
|
||||
|
||||
## 3. Lotes de ate tres subagentes
|
||||
|
||||
O lote e uma unidade operacional. A fila abaixo e ordenada pelo documento `02-prioridade-integracoes-clis.md`; cada linha representa uma task individual.
|
||||
|
||||
### Lote 0 - consolidacao do caso de referencia
|
||||
|
||||
- `CLI-000` - jcode - manter a issue #704, validar resposta do mantenedor e concluir a secao do README OmniRoute.
|
||||
|
||||
### Lote P0.1
|
||||
|
||||
- `CLI-001` - Gemini CLI - integrar provider/base URL Gemini.
|
||||
- `CLI-002` - Claw Code - integrar `OPENAI_BASE_URL`/provider OmniRoute.
|
||||
- `CLI-003` - Plandex - integrar provider custom com `baseUrl`.
|
||||
|
||||
### Lote P0.2
|
||||
|
||||
- `CLI-004` - MiMo Code - integrar provider OpenAI-compatible.
|
||||
- `CLI-005` - Trae Agent - integrar `model_providers` e `base_url`.
|
||||
- `CLI-006` - Kimi CLI - integrar modos OpenAI/Responses/Anthropic.
|
||||
|
||||
### Lote P0.3
|
||||
|
||||
- `CLI-007` - Every Code - integrar perfil derivado do Codex.
|
||||
- `CLI-008` - Open Codex - integrar provider multi-modelo.
|
||||
- `CLI-009` - VT Code - integrar `custom_providers` e failover.
|
||||
|
||||
### Lote P0.4
|
||||
|
||||
- `CLI-010` - OpenHands CLI - integrar `LLM_BASE_URL`.
|
||||
- `CLI-011` - gptme - integrar `OPENAI_BASE_URL`.
|
||||
- `CLI-012` - Nanocoder - integrar API OpenAI-compatible.
|
||||
|
||||
### Lote P0.5
|
||||
|
||||
- `CLI-013` - RA.Aid - integrar `OPENAI_API_BASE`.
|
||||
- `CLI-014` - CoreCoder - integrar `OPENAI_BASE_URL`.
|
||||
- `CLI-015` - Grok CLI - integrar `GROK_BASE_URL`.
|
||||
|
||||
### Lote P1.1
|
||||
|
||||
- `CLI-016` - Gitlawb Zero - integrar provider custom e `--base-url`.
|
||||
- `CLI-017` - DeepSeek Reasonix - confirmar e integrar endpoint.
|
||||
- `CLI-018` - KlaatCode - integrar `customModels`.
|
||||
|
||||
### Lote P1.2
|
||||
|
||||
- `CLI-019` - CodeMini CLI - integrar `gateway.base_url`.
|
||||
- `CLI-020` - Zot - integrar flag/config `--base-url`.
|
||||
- `CLI-021` - Octomind - integrar provider URL envs.
|
||||
|
||||
### Lote P1.3
|
||||
|
||||
- `CLI-022` - DvalinCode - integrar OpenAI-compatible.
|
||||
- `CLI-023` - Coro Code - integrar `OPENAI_BASE_URL`.
|
||||
- `CLI-024` - Mini-Kode - integrar `MINIKODE_BASE_URL`.
|
||||
|
||||
### Lote P1.4
|
||||
|
||||
- `CLI-025` - Late CLI - integrar `OPENAI_BASE_URL`/`api-url`.
|
||||
- `CLI-026` - Agentty - integrar provider e/ou ACP.
|
||||
- `CLI-027` - Aizen - integrar `AIZEN_BASE_URL`.
|
||||
|
||||
### Lote P1.5
|
||||
|
||||
- `CLI-028` - Clif-Code - integrar providers OpenAI/Anthropic/Ollama.
|
||||
- `CLI-029` - Minacode - confirmar provider e integrar URL.
|
||||
- `CLI-030` - YottaCode - integrar gateway/provider.
|
||||
|
||||
### Lote P1.6
|
||||
|
||||
- `CLI-031` - aichat - integrar models YAML/provider.
|
||||
- `CLI-032` - ShellGPT - integrar `API_BASE_URL`.
|
||||
- `CLI-033` - Mistral Vibe - integrar base URL/provider.
|
||||
|
||||
### Lote P1.7
|
||||
|
||||
- `CLI-034` - OpenSquilla - integrar gateway/provider.
|
||||
- `CLI-035` - Kode CLI - integrar endpoint multi-provider.
|
||||
- `CLI-036` - Neovate Code - integrar plugin/provider.
|
||||
|
||||
### Lote P1.8
|
||||
|
||||
- `CLI-037` - Deep Agents Code - integrar provider do pacote CLI.
|
||||
- `CLI-038` - OpenHands principal - integrar CLI/config.
|
||||
- `CLI-039` - SWE-agent - integrar backend/provider.
|
||||
|
||||
### Lote P1.9
|
||||
|
||||
- `CLI-040` - AutoCodeRover - integrar backend/provider.
|
||||
- `CLI-041` - Claurst - integrar provider, respeitando GPL.
|
||||
- `CLI-042` - Codebuff - integrar provider.
|
||||
|
||||
### Lote P2.1
|
||||
|
||||
- `CLI-043` - Devon - integrar backend.
|
||||
- `CLI-044` - Letta Code - integrar provider.
|
||||
- `CLI-045` - CodeMachine CLI - integrar provider.
|
||||
|
||||
### Lote P2.2
|
||||
|
||||
- `CLI-046` - Groq Code CLI - integrar endpoint.
|
||||
- `CLI-047` - Dexto - integrar provider.
|
||||
- `CLI-048` - claw-code-agent - integrar endpoint.
|
||||
|
||||
### Lote P2.3
|
||||
|
||||
- `CLI-049` - g3 - integrar provider Rust.
|
||||
- `CLI-050` - San - integrar provider-neutral.
|
||||
- `CLI-051` - Waveloom - integrar provider/endpoint.
|
||||
|
||||
### Lote P2.4
|
||||
|
||||
- `CLI-052` - picocode - integrar multi-LLM.
|
||||
- `CLI-053` - QQCode - integrar config.
|
||||
- `CLI-054` - Keen Code - integrar provider.
|
||||
|
||||
### Lote P2.5
|
||||
|
||||
- `CLI-055` - Grinta - integrar provider.
|
||||
- `CLI-056` - Zap - integrar Claude/Gemini/OpenAI.
|
||||
- `CLI-057` - Binharic - integrar multi-provider.
|
||||
|
||||
### Lote P2.6
|
||||
|
||||
- `CLI-058` - Darce - integrar multi-modelo.
|
||||
- `CLI-059` - CLAII - integrar provider/MCP.
|
||||
- `CLI-060` - nori-cli - integrar provider baseado em Codex.
|
||||
|
||||
### Lote P2.7
|
||||
|
||||
- `CLI-061` - cursor-agent clone - integrar provider.
|
||||
- `CLI-062` - Free Code - pesquisar licenca e integrar se viavel.
|
||||
- `CLI-063` - Claude Engineer - integrar provider.
|
||||
|
||||
### Lote P2.8
|
||||
|
||||
- `CLI-064` - Smol Developer - integrar SDK/adaptador.
|
||||
- `CLI-065` - Agentless - integrar entrada de modelo.
|
||||
- `CLI-066` - Amazon Q Developer CLI - pesquisar auth/provider.
|
||||
|
||||
### Lote P2.9
|
||||
|
||||
- `CLI-067` - nanobot - integrar provider OpenClaw-compatible.
|
||||
- `CLI-068` - ZeroClaw - integrar trait de provider.
|
||||
- `CLI-069` - NanoClaw - confirmar base Anthropic.
|
||||
|
||||
### Lote P2.10
|
||||
|
||||
- `CLI-070` - PicoClaw - integrar provider/config.
|
||||
- `CLI-071` - IronClaw - integrar provider Rust.
|
||||
- `CLI-072` - NullClaw - integrar provider.
|
||||
|
||||
### Lote P2.11
|
||||
|
||||
- `CLI-073` - Moltis - integrar provider Rust.
|
||||
- `CLI-074` - GitClaw - integrar provider Git-native.
|
||||
- `CLI-075` - LionClaw - integrar provider CLI.
|
||||
|
||||
### Lote P3.1 - wrappers e orquestradores
|
||||
|
||||
- `CLI-076` - VibePod; `CLI-077` - zeroshot; `CLI-078` - Fractal.
|
||||
|
||||
### Lote P3.2
|
||||
|
||||
- `CLI-079` - Bernstein; `CLI-080` - Traycer; `CLI-081` - h5i.
|
||||
|
||||
### Lote P3.3
|
||||
|
||||
- `CLI-082` - OMK; `CLI-083` - kodo; `CLI-084` - ORCH.
|
||||
|
||||
### Lote P3.4
|
||||
|
||||
- `CLI-085` - LoopTroop; `CLI-086` - Galley; `CLI-087` - Relay.
|
||||
|
||||
### Lote P3.5
|
||||
|
||||
- `CLI-088` - SageCLI; `CLI-089` - 5dive; `CLI-090` - agx.
|
||||
|
||||
### Lote P3.6
|
||||
|
||||
- `CLI-091` - claude-code-router; `CLI-092` - cc-router; `CLI-093` - OneCLI.
|
||||
|
||||
### Lote P3.7
|
||||
|
||||
- `CLI-094` - agent-browser; `CLI-095` - OpenWork; `CLI-096` - Agent Deck (revisao de agente filho).
|
||||
|
||||
### Lote P4 - fechados/MITM
|
||||
|
||||
- `CLI-097` - Pool; `CLI-098` - Junie CLI; `CLI-099` - Cursor desktop.
|
||||
- `CLI-100` - Windsurf; `CLI-101` - Amp; `CLI-102` - Amazon Q/Kiro CLI; `CLI-103` - Cowork.
|
||||
|
||||
## 4. Criterio para iniciar o lote seguinte
|
||||
|
||||
O lote seguinte pode iniciar quando os tres agentes do lote atual tiverem: pesquisa upstream anexada, gate de viabilidade preenchido, baseline registrado, resultado de smoke test ou bloqueio reproduzivel, e tracker atualizado. Uma falha de um agente nao deve paralisar os outros dois; o agente principal deve marcar `blocked` ou `research-more` com evidencia e seguir a fila.
|
||||
|
||||
## 5. Entregaveis de cada task
|
||||
|
||||
1. Nota de pesquisa fresca com commit/release e links.
|
||||
2. Classificacao de viabilidade.
|
||||
3. Diff minimo ou conclusao documentada de que nao ha diff necessario.
|
||||
4. Testes e comandos executados, incluindo falhas preexistentes.
|
||||
5. PR/issue upstream ou justificativa de config-only/MITM.
|
||||
6. Entrada no catalogo OmniRoute quando aplicavel.
|
||||
7. Atualizacao do tracker `04-tracker-integracoes-clis.md`.
|
||||
144
_references/_sistemas_cli/04-tracker-integracoes-clis.md
Normal file
144
_references/_sistemas_cli/04-tracker-integracoes-clis.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# Tracker de integracoes de CLIs com OmniRoute
|
||||
|
||||
**Status final da pesquisa:** `104/104` concluídos (`100%`), `0` casos `not-started`. Este é o registro individual autoritativo. O relatório executivo está em `06-relatorio-final-104-clis-e-estrategia-prs.md`.
|
||||
|
||||
**Snapshot inicial:** 2026-08-01
|
||||
**Legenda de status:** `not-started`, `researching`, `research-more`, `viable-direct`, `viable-upstream`, `viable-acp`, `viable-mcp`, `needs-wrapper`, `needs-mitm`, `blocked`, `implementing`, `validating`, `published-pr`, `published-issue`, `awaiting-maintainer`, `accepted`, `rejected`, `integrated`.
|
||||
|
||||
Os campos externos (`branch`, `commit`, `PR`, `issue`) ficam como `—` ate haver evidencia real. “Catalogo OmniRoute” significa entrada local, nao necessariamente suporte upstream publicado.
|
||||
|
||||
| ID | Prio | Projeto | Repositorio | Pesquisa | Tipo | Upstream | Branch | Commit | PR | Issue | Catalogo OmniRoute | Observacoes/proximo passo |
|
||||
|---|:---:|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| CLI-000 | P0 | jcode | `1jehuang/jcode` | concluida | `viable-upstream` | `awaiting-maintainer` | `feat/omniroute-provider` | `ee4f904e6` | [fork PR](https://github.com/diegosouzapw/jcode/pull/1) | [upstream #704](https://github.com/1jehuang/jcode/issues/704) | integrated | acompanhar mantenedor e concluir secao do README |
|
||||
|
||||
## Caso publicado: jcode
|
||||
|
||||
| Campo | Valor |
|
||||
|---|---|
|
||||
| Projeto | jcode |
|
||||
| Repositorio | `https://github.com/1jehuang/jcode` |
|
||||
| Status geral | `awaiting-maintainer` |
|
||||
| Tipo | `viable-upstream`; perfil OpenAI-compatible dirigido por metadados |
|
||||
| Branch | `feat/omniroute-provider` |
|
||||
| Commit | `ee4f904e6` |
|
||||
| PR | `https://github.com/diegosouzapw/jcode/pull/1` (fork de referencia) |
|
||||
| Issue | `https://github.com/1jehuang/jcode/issues/704` |
|
||||
| Catalogo OmniRoute | `integrated` / entrada existente |
|
||||
| Validacao | `cargo check --workspace` limpo; 205 testes passaram; 1 falha preexistente/ambiental |
|
||||
| Diff | 6 arquivos, `+56/-3` |
|
||||
| Proximo passo | acompanhar issue #704 e criar secao de README do OmniRoute |
|
||||
|
||||
## Tabela principal
|
||||
|
||||
| ID | Prio | Projeto | Repositorio | Pesquisa | Tipo | Upstream | Branch | Commit | PR | Issue | Catalogo OmniRoute | Observacoes/proximo passo |
|
||||
|---|:---:|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| CLI-001 | P0 | Gemini CLI | `google-gemini/gemini-cli` | concluida | `pr-generic` | `published-issue` | `fix/omniroute-gateway-auth` | `8138105c38cc1637fe9e8a9bd520eb835f1620e6` | — | [upstream #27550](https://github.com/google-gemini/gemini-cli/issues/27550#issuecomment-5152312278) | not-in-catalog | regression `AuthType.GATEWAY`; patch +26; auth 10/10, non-interactive 17/17, content generator 55/55, Gemini `/v1beta` stream/tools smoke verde; aguardar `help wanted` antes de terceira PR |
|
||||
| CLI-002 | P0 | Claw Code | `ultraworkers/claw-code` | concluida | `pr-docs` | `published-issue` | `docs/omniroute-setup` | `de857038b2f9ff9b319132e2241549e86215c351` | — | [upstream #3283](https://github.com/ultraworkers/claw-code/issues/3283) | not-in-catalog | generic OpenAI Chat Completions; docs +37; 1.415 testes, fmt, docs/release checks e clippy oficial verdes; fork bloqueado pelo GitHub, issue-first; smoke OmniRoute parcial/timeout; chave do smoke deve ser rotacionada |
|
||||
| CLI-003 | P0 | Plandex | `plandex-ai/plandex` | concluida | `pr-docs` | `published-pr` | `feat/omniroute-provider-docs` | `f8f0694bdf7d1cb6e65a1f1c5bc39f84921a4507` | [upstream #359](https://github.com/plandex-ai/plandex/pull/359) | — | not-in-catalog | custom provider OpenAI-compatible ja existia; docs com `/v1`, `OMNIROUTE_API_KEY`, Docker reachability e model mapping; Go indisponivel; Docusaurus build verde; acompanhar mantenedor |
|
||||
| CLI-004 | P0 | MiMo Code | `XiaomiMiMo/MiMo-Code` | concluida | `config-only` | not-applicable | `research/omniroute-mimo-code` | — | — | — | not-in-catalog | SHA `ce124cb`; provider customizado `@ai-sdk/openai-compatible` já suporta `baseURL`, `apiKey` e modelo; 116 testes focados + typecheck verdes; smoke CLI inconclusivo por travamento ambiental; sem PR artificial |
|
||||
| CLI-005 | P0 | Trae Agent | `bytedance/trae-agent` | concluida | `pr-docs` | `published-pr` | `research/omniroute-trae-agent` | `4801e48b69d7583300eb86ec5c69235506d7f205` | [upstream #449](https://github.com/bytedance/trae-agent/pull/449) | — | not-in-catalog | README +39; `provider: openai` + mapping `base_url=/v1`; `/v1/responses`, `/v1/models`, Bearer, tools e limitação sem streaming; 62 testes/17 skips, pre-commit e mocks verdes; CLA pendente |
|
||||
| CLI-006 | P0 | Kimi CLI | `MoonshotAI/kimi-cli` | concluida | `pr-docs` | `published-issue` | `research/omniroute-kimi-cli` | `a2f62bf6108a6954e798db992411aa06670e224f` | — | [upstream #2576](https://github.com/MoonshotAI/kimi-cli/issues/2576) | not-in-catalog | docs EN/ZH +63; `openai_legacy` `/v1`, chave via `OPENAI_API_KEY`, modelo manual; Responses/Anthropic alternativos; 47 testes e VitePress verdes; aguardar direção do mantenedor antes da PR |
|
||||
| CLI-007 | P0 | Every Code | `just-every/code` | concluida | `pr-docs` / `config-only` | `published-pr` | `feat/omniroute-integration` | `8fbc8dab5fb76bf05535055801af0c3ccfea6f3b` | [upstream #614](https://github.com/just-every/code/pull/614) | — | not-in-catalog | PR documental aberta e mergeable; release `v0.6.162`; `./build-fast.sh` baseline/pós-patch verdes; smoke mock Responses/SSE/tools verde; acompanhar CI/mantenedor |
|
||||
| CLI-008 | P0 | Open Codex | `ymichael/open-codex` | concluida | `pr-generic` / `issue-first` | `published-issue` | `feat/omniroute-integration` | `f25de99f991c0e4d9d6ae2811d307cdbff92f869` | — | [upstream #4](https://github.com/ymichael/open-codex/issues/4#issuecomment-5152804104) | not-in-catalog | patch genérico pronto localmente; issue-first por firewall de container e PR #19 fechada; 132 testes, typecheck/build/format verdes; lint bloqueado por ambiente; aguardar mantenedor antes de PR |
|
||||
| CLI-009 | P0 | VT Code | `vinhnx/vtcode` | concluida | `pr-docs` / `config-only` | `published-pr` | `feat/omniroute-integration` | `256682d10c72f3e6e145d852b6d9d53f5c471988` | [upstream #717](https://github.com/vinhnx/VTCode/pull/717) | — | not-in-catalog | PR documental aberta e mergeable; release `0.141.10`; custom provider `/v1`, Bearer, `auto`, discovery manual, streaming/tools; 10 testes config verdes; nextest/docs checks bloqueados por ambiente; acompanhar CI/mantenedor |
|
||||
| CLI-010 | P0 | OpenHands CLI | `OpenHands/OpenHands-CLI` | concluida | `config-only` | not-applicable | `feat/omniroute-openhands-cli-integration` | — | — | — | not-in-catalog | SHA `2df8a283`; `LLM_BASE_URL=/v1`, `LLM_API_KEY`, modelo obrigatório `openai/auto`, Chat Completions/SSE/tools; 63 testes focados e mock verdes; sem PR artificial |
|
||||
| CLI-011 | P0 | gptme | `gptme/gptme` | concluida | `config-only` | not-applicable | `feat/omniroute-gptme-integration` | — | — | — | not-in-catalog | SHA `7fe250529`; provider TOML nomeado, `/v1/chat/completions`, `/v1/models`, Bearer, streaming/tools; compileall verde, pytest bloqueado por deps; docs genericas ja cobrem |
|
||||
| CLI-012 | P0 | Nanocoder | `Nano-Collective/nanocoder` | concluida | `config-only` | not-applicable | `feat/omniroute-nanocoder-integration` | — | — | — | not-in-catalog | SHA `becae998`; `createOpenAICompatible`, `/v1/models`, streaming/native tools + XML/JSON fallback; types/format/lint/build verdes; suite ampla com falhas preexistentes; sem PR artificial |
|
||||
| CLI-013 | P0 | RA.Aid | `ai-christianson/RA.Aid` | concluida | `config-only` | not-applicable | `feat/omniroute-ra-aid-integration` | — | — | — | not-in-catalog | SHA `e71bb83`; provider `openai-compatible`, `/v1/chat/completions`, Bearer, modelo explicito/`auto`, function tools; 762 testes + 62 focados e smoke verdes; sem Responses/stream HTTP garantido; Aider exige config separada; sem PR artificial |
|
||||
| CLI-014 | P0 | CoreCoder | `he-yufeng/CoreCoder` | concluida | `pr-docs` / `config-only` | `published-pr` | `feat/omniroute-integration` | `f4d2851649e5dda20738c313a8a94337b24eeb9d` | [upstream #20](https://github.com/he-yufeng/CoreCoder/pull/20) | — | not-in-catalog | PR documental aberta, nao draft e mergeable; `/v1/chat/completions`, Bearer, `auto`, streaming/native tools; 86 testes, compileall, build, twine e smoke verdes; Ruff mantem 41 falhas preexistentes; acompanhar CI/mantenedor |
|
||||
| CLI-015 | P1 | Grok CLI | `superagent-ai/grok-cli` | concluida | `config-only` | not-applicable | `feat/omniroute-grok-cli-integration` | — | — | — | not-in-catalog | SHA `fb97af8`; `GROK_BASE_URL`/`--base-url`, Chat Completions/SSE, Bearer, `auto` e tools confirmados; 47/48 suites e 246 testes no gate isolado, 6 arquivos/39 testes focados verdes; Node não carrega `bun:sqlite`; Responses/search/STT/Batch/midia não garantidos; monitorar PRs #290/#349 |
|
||||
| CLI-016 | P1 | Gitlawb Zero | `Gitlawb/zero` | concluida | `config-only` | not-applicable | `feat/omniroute-gitlawb-zero-integration` | — | — | — | not-in-catalog | SHA `8e266797`; release `v0.6.0`; provider custom `/v1`, Bearer, `auto`, Chat/SSE/tools, usage e `/v1/models` confirmados; Go test/vet/fmt e smoke verdes; release build bloqueado por falta de espaco; politica exige issue aprovada; sem contribuicao nominal artificial |
|
||||
| CLI-017 | P1 | DeepSeek Reasonix | `esengine/DeepSeek-Reasonix` | concluida | `config-only` | not-applicable | `feat/omniroute-deepseek-reasonix-integration` | — | — | — | not-in-catalog | SHA `1c62489d`; release `v1.19.1`; `kind=openai`, `/v1/chat/completions`, Bearer, `auto`, SSE/tools, `/v1/models` e reasoning confirmados; suite completa, vet, fmt, build e smoke verdes apos remover env SSH do runner; sem PR/issue redundante |
|
||||
| CLI-018 | P1 | KlaatCode | `KlaatAI/klaatcode` | concluida | `config-only` | not-applicable | `feat/omniroute-klaatcode-integration` | — | — | — | not-in-catalog | SHA `0d20f24a`; release `V2.4.0`; `customModels` com `/v1`, Bearer, `auto`, Chat/SSE/tools confirmados; 316 testes, 33 fixtures e build verdes; typecheck local divergiu do CI verde; custom endpoint e apenas TUI; divergencia de metadata de licenca registrada; sem contribuicao nominal artificial |
|
||||
| CLI-019 | P1 | CodeMini CLI | `havingautism/Codemini-CLI` | concluida | `config-only` | not-applicable | `feat/omniroute-codemini-cli-integration` | — | — | — | not-in-catalog | SHA `a3764b21`; package `0.8.3`; gateway `/v1`, Bearer persistido, `auto`, Chat/SSE/usage/tools e tool round trip confirmados; `/models` e probe, nao picker; 122/123 testes, 10 focados e pack-imports verdes; sem PR nominal redundante |
|
||||
| CLI-020 | P1 | Zot | `patriceckhart/zot` | concluida | `config-only` | not-applicable | `feat/omniroute-zot-integration` | — | — | — | not-in-catalog | SHA `f3d8eb66`; release `v0.3.29`; custom provider `omniroute` em `models.json`, `/v1`, Bearer, `auto`, Chat/SSE/tools/reasoning opt-in e cache usage confirmados; `--base-url` e so override; PR #36 ja cita OmniRoute; race suite/build/vet/fmt verdes |
|
||||
| CLI-021 | P1 | Octomind | `Muvon/octomind` | concluida | `config-only` | not-applicable | `feat/omniroute-octomind-integration` | — | — | — | not-in-catalog | SHA `65ab1db1`; release `0.39.0`; provider `local:auto` usa endpoint completo `/v1/chat/completions`, Bearer opcional, Chat JSON buffered, tools/reasoning/usage; sem SSE/Responses/discovery; fmt/fetch e smokes com/sem auth verdes; suite ampla nao executada por disco/contencao |
|
||||
| CLI-022 | P1 | DvalinCode | `arthurpanhku/dvalincode` | concluida | `config-only` | not-applicable | `feat/omniroute-dvalincode-integration` | — | — | — | not-in-catalog | SHA `7d42664a`; release `v0.14.1`; provider OpenAI-compatible custom com `/v1`, Bearer via env, `auto`, Chat/SSE/usage/tools e tool round trip confirmados; `provider test` bloqueado por trusted presets; issues #109/#118/#135 ja cobrem melhorias genericas; sem PR nominal |
|
||||
| CLI-023 | P1 | Coro Code | `Blushyes/coro-code` | concluida | `config-only` | not-applicable | `feat/omniroute-coro-code-integration` | — | — | — | not-in-catalog | SHA `679c57af`; release `v0.0.8`; `OPENAI_BASE_URL=/v1`, Bearer, `auto`, Chat JSON e function tools/tool loop confirmados; streaming existe mas nao e usado pelo agente; sem Responses/discovery; `cargo check`/fmt bloqueados por drift preexistente; risco de LICENSE ausente; sem PR nominal |
|
||||
| CLI-024 | P1 | Mini-Kode | `minmaxflow/mini-kode` | concluida | `config-only` | not-applicable | `feat/omniroute-mini-kode-integration` | — | — | — | not-in-catalog | SHA `4e7f9767`; release/tag npm `0.2.3`; provider custom por `MINIKODE_BASE_URL=/v1`, Bearer, `auto`, Chat/SSE e tools/tool loop confirmados; sem Responses/discovery/reasoning dedicado; sem PR nominal redundante |
|
||||
| CLI-025 | P1 | Late CLI | `mlhher/late-cli` | concluida | `config-only` | not-applicable | `feat/omniroute-late-cli-integration` | — | — | — | not-in-catalog | SHA `26814e62`; release `v1.4.2`; `OPENAI_BASE_URL=/v1`, Bearer, `auto`, Chat/SSE/usage/reasoning_content/tools e tool round trip confirmados; probes `/props`/`/v1/models` nao sao picker; BSL 1.1/CLA; sem PR nominal |
|
||||
| CLI-026 | P1 | Agentty | `1ay1/agentty` | concluida | `config-only` | not-applicable | `feat/omniroute-agentty-integration` | — | — | — | not-in-catalog | SHA `e947b26c`; release `v0.2.10`; custom host `127.0.0.1:20128`, Bearer, Chat/SSE/tools e `/v1/models` confirmados; Responses/reasoning/tool round trip dinamico nao confirmados; MIT; sem PR nominal |
|
||||
| CLI-027 | P1 | Aizen | `aizen-stack/aizen` | concluida | `config-only` | not-applicable | `feat/omniroute-aizen-integration` | — | — | — | not-in-catalog | SHA `3d8ae0f6`; release `v0.5.4`; `AIZEN_BASE_URL=/v1`, Bearer, `auto`/modelo literal, Chat/SSE/reasoning_content e `/v1/models`; tools confirmadas estaticamente, sem smoke dinamico; PolyForm Noncommercial/CLA; sem PR nominal |
|
||||
| CLI-028 | P1 | Clif-Code | `DLhugly/Clif-Code` | concluida | `config-only` | not-applicable | `feat/omniroute-clif-code-integration` | — | — | — | not-in-catalog | SHA `282a787a`; release `v1.72.0`; `CLIFCODE_API_URL=/v1`, Bearer, `auto`, Chat/SSE/usage/tools e tool loop confirmados por fonte; smoke bloqueado por binario ausente; sem Responses/reasoning; licença proprietária conflitante com FSL declarada exige revisão jurídica; sem PR nominal |
|
||||
| CLI-029 | P1 | Minacode | `hit9/minacode` | concluida | `config-only` | not-applicable | `feat/omniroute-minacode-integration` | — | — | — | not-in-catalog | SHA `d4ea4a97`; release `v0.18.1`; TOML custom `/v1`, key obrigatória, `auto`, Chat/Responses/Anthropic, SSE/tools/reasoning/discovery confirmados; smoke de protocolo Chat+Responses+models e compileall verdes; CI remoto verde; sem PR nominal |
|
||||
| CLI-030 | P1 | YottaCode | `yottadynamics/yottacode` | concluida | `config-only` | not-applicable | `feat/omniroute-yottacode-integration` | — | — | — | not-in-catalog | SHA `039f61ce`; release `v0.3.1`; provider `openai-compatible`, `/v1`, Bearer, `/v1/models`, Chat/SSE/tools/reasoning parsing confirmados; smoke oficial com mock passou; Go 1.26 nao instalado e gates completos nao executados por espaco; sem PR nominal |
|
||||
| CLI-031 | P1 | aichat | `sigoden/aichat` | concluida | `config-only` | not-applicable | `feat/omniroute-aichat-integration` | — | — | — | not-in-catalog | SHA `82976d3`; package/release `v0.30.0`; provider `openai-compatible` com base `/v1`, Bearer opcional e modelo `auto`; Chat stream/JSON, reasoning e tool round-trip confirmados; Responses ausente (#1431); limites de tool SSE ja cobertos por #1454/#1495 e PR #1496; sem publicacao nominal |
|
||||
| CLI-032 | P1 | ShellGPT | `TheR1D/shell_gpt` | concluida | `config-only` | not-applicable | `feat/omniroute-shellgpt-integration` | — | — | — | not-in-catalog | SHA `a082bd53`; release `1.5.1`; `API_BASE_URL=/v1`, `OPENAI_API_KEY`, `DEFAULT_MODEL=auto` e `USE_LITELLM=false`; smoke real confirmou env e `.sgptrc`, Chat/SSE e Bearer; issue #718 nao reproduz no HEAD; CI baseline vermelho por temperatura default independente; sem publicacao nominal |
|
||||
| CLI-033 | P1 | Mistral Vibe | `mistralai/mistral-vibe` | concluida | `config-only` | not-applicable | `feat/omniroute-mistral-vibe-integration` | — | — | — | not-in-catalog | SHA/release `99a6efa9` / `v2.23.2`; `GenericBackend` custom com base `/v1`, Bearer, Chat/SSE, usage, tools e reasoning; smoke do binario oficial verde; #790 cobre somente discovery `/v1/models`; upstream nao aceita contribuicoes de codigo no momento; sem publicacao |
|
||||
| CLI-034 | P1 | OpenSquilla | `opensquilla/opensquilla` | concluida | `config-only` | not-applicable | `feat/omniroute-opensquilla-integration` | — | — | — | not-in-catalog | `custom` com `/v1`, Bearer opcional, Chat/SSE, tools, reasoning recebido, usage e `/v1/models`; smoke provider-level verde; monitorar issue #912 do probe custom; sem publicacao nominal |
|
||||
| CLI-035 | P1 | Kode CLI | `shareAI-lab/Kode-cli` | concluida | `config-only` | not-applicable | `feat/omniroute-kode-cli-integration` | — | — | — | not-in-catalog | `custom-openai` com `/v1`, discovery `/v1/models`, fallback manual, Bearer, Chat/SSE, tools/tool round-trip e persistencia; smoke runtime bloqueado por Bun/artefato ausente; CI baseline vermelho por formatacao; sem publicacao nominal |
|
||||
| CLI-036 | P1 | Neovate Code | `neovateai/neovate-code` | concluida | `config-only` | not-applicable | `feat/omniroute-neovate-code-integration` | — | — | — | not-in-catalog | provider JSON custom normalizado para OpenAI-compatible, `/v1`, Bearer, Chat/SSE, tools/tool round-trip; model catalog declarado (sem discovery); smoke do pacote publicado verde; sem publicacao nominal |
|
||||
| CLI-037 | P1 | Deep Agents Code | `langchain-ai/deepagents` | concluida | `config-only` | not-applicable | `feat/omniroute-deepagents-code-integration` | — | — | — | not-in-catalog | SHA `46ee772b4`; `deepagents-code==0.1.51`; provider `openai`, base OmniRoute `/v1`, model `openai:auto`; Responses e default, Chat usa `use_responses_api=false`; smoke de config verde, sem HTTP/runtime por deps e disco; #3973/#3287 ja cobrem os pontos genericos; sem publicacao nominal |
|
||||
| CLI-038 | P1 | OpenHands principal | `OpenHands/OpenHands` | concluida | `config-only` | not-applicable | `feat/omniroute-openhands-main-integration` | — | — | — | not-in-catalog | SHA `1708efc44`; Agent Canvas `1.8.0`; `openai/auto` + base `/v1` + API key + `api_mode=chat`; LiteLLM envia `model=auto`, Chat/SSE/tools estruturais; sem discovery generico `/v1/models`; PRs OmniRoute [#15189](https://github.com/OpenHands/OpenHands/pull/15189)/[#15211](https://github.com/OpenHands/OpenHands/pull/15211) fechadas sem merge; sem nova publicacao |
|
||||
| CLI-039 | P1 | SWE-agent | `SWE-agent/SWE-agent` | concluida | `config-only` | not-applicable | `feat/omniroute-swe-agent-integration` | — | — | — | not-in-catalog | SHA `3ea751c08`; release `v1.1.0`; LiteLLM com `openai/<model-id>`, `api_base=/v1` e chave por env; Chat/tools/tool round-trip e batch confirmados por fonte; reasoning parcial; smoke HTTP bloqueado por deps ausentes; sem publicacao nominal |
|
||||
| CLI-040 | P1 | AutoCodeRover | `AutoCodeRoverSG/auto-code-rover` | concluida | `pr-generic` | `validating` | `feat/omniroute-auto-code-rover-integration` | — | — | — | not-in-catalog | SHA `585d3e639`; patch local sem commit em 4 arquivos corrige `litellm-generic-openai/auto`, base `/v1`, precedencia da chave e pricing desconhecido; 9 testes focados com stubs, tracer source-only, compileall e diff-check verdes; sem HTTP real; licenca SONAR Source-Available exige gate juridico antes de publicar |
|
||||
| CLI-041 | P2 | Claurst | `Kuberwastaken/claurst` | concluida | `config-only` | not-applicable | `feat/omniroute-claurst-integration` | — | — | — | not-in-catalog | SHA `595b0ebe3`; `custom-openai` com settings persistidos, base `/v1`, `CUSTOM_OPENAI_API_KEY`, modelo `auto`, Chat/SSE/tools e `/v1/models`; CI upstream verde; sem build/smoke local e sem publicacao nominal; monitorar PR #365 sem duplicar |
|
||||
| CLI-042 | P2 | Codebuff | `CodebuffAI/codebuff` | concluida | `blocked` / `issue-first` | `blocked` | `feat/omniroute-codebuff-integration` | — | — | — | not-in-catalog | SHA `195b9bef6`; main nao expoe base/chave/provider custom na CLI/SDK; PR upstream existente [#693](https://github.com/CodebuffAI/codebuff/pull/693) cobre a lacuna, observada OPEN/CONFLICTING/DIRTY; nao criar patch concorrente; acompanhar #693 e validar apos merge/port |
|
||||
| CLI-043 | P2 | Devon | `entropy-research/Devon` | concluida | `pr-generic` | validating | `feat/omniroute-devon-integration` | — | — | [upstream #100](https://github.com/entropy-research/Devon/issues/100) | not-in-catalog | SHA `8f68f1d74`; diff local genérico em 5 arquivos, sem commit; reprodução literal DeepSeek/OpenRouter e resume corrigidos; 9 testes focados, compileall e diff-check verdes; Standards/Spec aprovados; aguardar autorização antes de fork/push/PR |
|
||||
| CLI-044 | P2 | Letta Code | `letta-ai/letta-code` | concluida | `config-only` | not-applicable | `feat/omniroute-letta-code-integration` | — | — | — | integrated | SHA `09aff1bb4`; já coberta pelo provider local `lmstudio` (`lmstudio_openai`), discovery `/api/v0/models`→`/v1/models`, Chat/SSE/tools; 8 testes OmniRoute verdes; sem PR nominal |
|
||||
| CLI-045 | P2 | CodeMachine CLI | `moazbuilds/CodeMachine-CLI` | concluida | `config-only` | not-applicable | `feat/omniroute-codemachine-cli-integration` | — | — | — | not-in-catalog | SHA `572def63e`; integração indireta por OpenCode custom `@ai-sdk/openai-compatible`, base `/v1`, chave por env e `omniroute/auto`; provider/model reconhecidos no smoke de config; alternativa Claude Code; sem PR nominal |
|
||||
| CLI-046 | P2 | Groq Code CLI | `build-with-groq/groq-code-cli` | concluida | `pr-generic` | `awaiting-maintainer` | `feat/omniroute-groq-code-cli-integration` | — | — | — | not-in-catalog | SHA `a303eb4be`; `groq-sdk@0.27.0` fixa `/openai/v1/chat/completions`, logo não há config-only para OmniRoute; mock confirmou path/Bearer; PR existente [#7](https://github.com/build-with-groq/groq-code-cli/pull/7) é a duplicata natural, mas precisa distinguir Groq-compatible de OpenAI-compatible; 17 testes oficiais + 5 testes de contexto, build e mock verdes; clone limpo, sem patch/publicação |
|
||||
| CLI-047 | P2 | Dexto | `truffle-ai/dexto` | concluida | `config-only` | `not-applicable` | `feat/omniroute-dexto-integration` | — | — | — | not-in-catalog | SHA `4108a9c73`; provider `openai-compatible` nativo exige `baseURL`, aceita modelo arbitrário, Bearer opcional, Chat/SSE/tools e reasoning effort; receita `/v1` + `auto`; 175 testes focados e builds llm/core verdes; TS2741 em chatgpt-oauth é baseline; ELv2; sem PR/issue nominal |
|
||||
| CLI-048 | P2 | claw-code-agent | `HarnessLab/claw-code-agent` | concluida | `config-only` | `not-applicable` | `feat/omniroute-claw-code-agent-integration` | — | — | — | not-in-catalog | SHA `167571da8`; `OPENAI_BASE_URL=http://127.0.0.1:20128/v1`, Bearer, model manual/`auto`, Chat/SSE/tools/usage confirmados; smoke `MOCK_SMOKE_OK`, 80 testes focados; sem discovery/Responses API; licença não identificada (`license: null`); sem PR/issue |
|
||||
| CLI-049 | P2 | g3 | `dhanji/g3` | concluida | `pr-generic` | `validating` | `feat/omniroute-g3-integration` | — | — | [upstream #70](https://github.com/dhanji/g3/issues/70) | not-in-catalog | SHA `0ddb052d2`; diff local provider-neutral em `provider_registration.rs`, 1 arquivo `+25/-1`, corrige registro `custom`→`custom.default`; `cargo check -p g3-config`, 6 testes config e diff-check verdes; teste focal escrito mas build bloqueado em `x11.pc`; manifesto declara MIT sem arquivo LICENSE; Standards/Spec centrais aprovados; sem publicação |
|
||||
| CLI-050 | P2 | San | `genai-io/san` | concluida | `config-only` | `not-applicable` | `feat/omniroute-san-integration` | — | — | — | not-in-catalog | SHA `e45ec0ef7`; Apache-2.0/release v1.22.1; provider Custom com base `/v1`, Bearer, `/models`, Chat/SSE/tools/tool result e reasoning best-effort; smoke HTTP de dois turnos e gates Go focados verdes; sem provider nominal ou publicação |
|
||||
| CLI-051 | P2 | Waveloom | `Menfre01/waveloom` | concluida | `config-only` | `not-applicable` | `feat/omniroute-waveloom-integration` | — | — | — | not-in-catalog | SHA `293d5cd11`; Apache-2.0/release v0.5.1; adapter OpenAI com `/v1`, Bearer, `/models`, SSE, 14 tools, tool-result round-trip e sessões; smoke do binário oficial verde e CI remoto do HEAD verde; reasoning/cache avançados não são projetados; sem publicação |
|
||||
| CLI-052 | P2 | picocode | `jondot/picocode` | concluida | `config-only` | `not-applicable` | `feat/omniroute-picocode-integration` | — | — | — | not-in-catalog | SHA `064a2a6ea`; MIT/release v0.6.0; Rig 0.28 lê `OPENAI_BASE_URL` e usa Responses `/v1/responses`; smoke confirmou Bearer, `auto`, 11 tools e function_call_output; 7 testes/doc-tests verdes; fmt/clippy só baseline; sem PR/issue |
|
||||
| CLI-053 | P2 | QQCode | `qnguyen3/qqcode` | concluida | `config-only` | `not-applicable` | `feat/omniroute-qqcode-integration` | — | — | — | not-in-catalog | SHA `be6a96ce7`; Apache-2.0/release v1.2.0; provider arbitrário + `GENERIC`/OpenAI com base `/v1`; smoke confirmou JSON/SSE, Bearer, extra_body, reasoning e tool-result; backend 20/20, ACP 13+1 skip, observer 11/11, compileall/helps verdes; sem PR/issue |
|
||||
| CLI-054 | P2 | Keen Code | `mochow13/keen-code` | concluida | `config-only` | `not-applicable` | `feat/omniroute-keen-code-integration` | — | — | — | not-in-catalog | SHA `ee2eaf0f4`; MIT/release v0.40.0; receita manual `openai-compatible` + `/v1` + Bearer + model arbitrário; smoke oficial confirmou Chat/SSE, tools/tool-result, usage e reasoning replay; provider oculto apenas no picker; CI remoto verde; sem PR/issue |
|
||||
| CLI-055 | P2 | Grinta | `josephsenior/Grinta-Coding-Agent` | concluida | `config-only` | `not-applicable` | `feat/omniroute-grinta-integration` | — | — | — | not-in-catalog | SHA `df7437524`; provider OpenAI-compatible com `LLM_API_KEY`, model `auto`, base `/v1`; smoke Chat/SSE/tools/tool-result/reasoning/usage/cache verde; 183 testes focados, compileall e Ruff verdes; sem PR/issue nominal |
|
||||
| CLI-056 | P2 | Zap | `zap-coding-agent/zap-coding-agent` | concluida | `config-only` | `not-applicable` | `feat/omniroute-zap-integration` | — | — | — | not-in-catalog | SHA `f0203f872`; provider arbitrário `kind=openai`, base `/v1`, Bearer, discovery `/models`, Chat JSON/SSE, tools/tool-result, reasoning e usage confirmados; cargo check + 16 testes/gates focados verdes; issue #2 confirma arquitetura; sem PR nominal |
|
||||
| CLI-057 | P2 | Binharic | `CogitatorTech/binharic-cli` | concluida | `pr-generic` | `validating` | `feat/omniroute-binharic-integration` | — | — | — | not-in-catalog | SHA `52ccca70b`; patch sem commit em `provider.ts` + teste: aplica `baseURL` ao OpenAI/Anthropic e usa Chat Completions para base customizada; RED→GREEN, 14 focal, 88 arquivos/774 testes, typecheck/build e smoke wire verdes; lint upstream bloqueado; sem publicação |
|
||||
| CLI-058 | P2 | Darce | `AmerSarhan/darce-cli` | concluida | `config-only` | `not-applicable` | `feat/omniroute-darce-integration` | — | — | — | not-in-catalog | SHA `1b90c379a`; MIT declarada no package/npm sem arquivo LICENSE; `DARCE_API_BASE` raiz sem `/v1`, `DARCE_API_KEY`, `DARCE_MODEL=auto`; smoke PTY do binário confirmou 2 Chat/SSE, 7 tools, tool-result e Bearer; 106 testes/build verdes; sem MCP/ACP/A2A; sem PR/issue |
|
||||
| CLI-059 | P2 | CLAII | `agencyswarm/CLAII` | concluida | `pr-generic` | `blocked` | `feat/omniroute-claii-integration` | — | — | — | not-in-catalog | SHA `89d42311b`; patch sem commit em README/config/providers/test: `CLAII_API_KEY`, `CLAII_BASE_URL` origem sem `/v1beta`, model runtime e reject explícito; 4 wire/loop + 10 calculator + pip install + smoke CLI verdes; unittest discover falha só baseline `calculator`/`pkg`; sem MCP/ACP/A2A; **All Rights Reserved**, não publicar sem autorização jurídica |
|
||||
| CLI-060 | P2 | nori-cli | `tilework-tech/nori-cli` | concluida | `config-only` | `not-applicable` | `feat/omniroute-nori-cli-integration` | — | — | — | not-in-catalog | SHA `829ecf3fd`; Apache-2.0/v0.24.0; Nori custom ACP → OpenCode `opencode-ai@1.18.11` → OmniRoute `/v1`; MCP separado por `/api/mcp/stream` ou stdio; 5 testes focados, cargo build nori e smoke ACP Nori→OpenCode verdes; sem patch/publicação |
|
||||
| CLI-061 | P2 | cursor-agent clone | `civai-technologies/cursor-agent` | concluida | `config-only` | `not-applicable` | `feat/omniroute-cursor-agent-clone-integration` | — | — | — | not-in-catalog | SHA `d21a8f3d4`; MIT/v0.1.39; SDK OpenAI usa base `/v1`, Anthropic usa raiz; smokes de 2 turnos/tools verdes; factory rejeita `auto` puro; 23 testes, mypy/build verdes; sem patch/publicação |
|
||||
| CLI-062 | P2 | Free Code | `freecodexyz/free-code` | concluida | `config-only` | `blocked` | `feat/omniroute-free-code-integration` | — | — | [upstream #20](https://github.com/freecodexyz/free-code/issues/20) | not-in-catalog | SHA `6b25ab68b`; URL antiga `paoloanzn/free-code` redireciona; base Anthropic raiz, `model=auto`, stream/tools/MCP; build verde; sem LICENSE/campo license e código atribuído à Anthropic, não publicar |
|
||||
| CLI-063 | P2 | Claude Engineer | `Doriandarko/claude-engineer` | concluida | `config-only` / `pr-generic` | `blocked` | `feat/omniroute-claude-engineer-integration` | — | [upstream #250](https://github.com/Doriandarko/claude-engineer/pull/250) | [upstream #116](https://github.com/Doriandarko/claude-engineer/issues/116) | not-in-catalog | SHA `0a9e4b309`; v3 funciona por base Anthropic raiz com modelo fixo; #250 já adiciona `ANTHROPIC_MODEL`; arquivo LICENSE ausente apesar de declaração MIT; sem patch concorrente/publicação |
|
||||
| CLI-064 | P2 | Smol Developer | `smol-ai/developer` | concluida | `config-only` | `not-applicable` | `feat/omniroute-smol-developer-integration` | — | — | — | not-in-catalog | SHA `a6747d1a6`; `OPENAI_API_BASE=/v1`, `auto`, 3 Chat calls, SSE/function calling e Agent Protocol validados; gates de runtime verdes, build metadata preexistente; sem patch/publicação |
|
||||
| CLI-065 | P2 | Agentless | `OpenAutoCoder/Agentless` | concluida | `config-only` | `not-applicable` | `feat/omniroute-agentless-integration` | — | — | — | not-in-catalog | SHA `5ce5888b9`; OpenAI chat + embeddings funcionam com bases distintas; Anthropic normal/cache histórico validados; DeepSeek fixa host; pre-commit/compileall verdes; sem patch/publicação |
|
||||
| CLI-066 | P2 | Amazon Q Developer CLI | `aws/amazon-q-developer-cli` | concluida | `viable-mcp` / `needs-wrapper` | `not-applicable` | `feat/omniroute-amazon-q-developer-cli-integration` | — | — | — | not-in-catalog | SHA `15cc8f3cd`; modelo usa AWS JSON/EventStream Bearer/SigV4 e não `/v1`; MCP stdio imediato, HTTP legado com ressalva; upstream issue-first/manutenção crítica; sem patch/publicação |
|
||||
| CLI-067 | P2 | nanobot | `HKUDS/nanobot` | concluida | `config-only` | `not-applicable` | `feat/omniroute-nanobot-integration` | — | — | — | not-in-catalog | HEAD `44b7e1bf4`; provider dinâmico OpenAI-compatible com base `/api/v1` e modelo `omniroute/auto`; Chat/SSE/tools/reasoning/usage/images/discovery e retry validados; 424 testes + Ruff; sem PR nominal |
|
||||
| CLI-068 | P2 | ZeroClaw | `zeroclaw-labs/zeroclaw` | concluida | `config-only` | `not-applicable` | `feat/omniroute-zeroclaw-integration` | — | — | — | not-in-catalog | HEAD `4770420ab`; `custom.omniroute`, base `/v1`, Bearer, `auto`, Chat/Responses e tools nativas opt-in; 1.173 unit + 1 integração, fmt/config/smoke verdes; sem PR nominal |
|
||||
| CLI-069 | P2 | NanoClaw | `gavrielc/nanoclaw` | concluida | `config-only` | `not-applicable` | `feat/omniroute-nanoclaw-integration` | — | — | — | not-in-catalog | HEAD `dfac7e0af`; provider Claude existente aponta para raiz Anthropic OmniRoute e OneCLI guarda a chave; baseline e 49 testes OmniRoute verdes; Codex #3155/#1984 e OpenCode #2985 ficam como follow-ups; sem PR |
|
||||
| CLI-070 | P2 | PicoClaw | `sipeed/picoclaw` | concluida | `config-only` | `not-applicable` | `feat/omniroute-picoclaw-integration` | — | — | — | not-in-catalog | HEAD `49183d7`, `/api/v1`, `openai/auto` → `auto`; Chat/SSE/tools/usage/images/discovery; Go ausente, testes locais não executados; issue router #3298; sem publicação |
|
||||
| CLI-071 | P2 | IronClaw | `nearai/ironclaw` | concluida | `config-only` | `not-applicable` | `feat/omniroute-ironclaw-integration` | — | — | — | not-in-catalog | HEAD `4b71aaae`; `openai_compatible` `/api/v1`, Chat/SSE/tools/images/discovery; 889+5 testes e fmt verdes; reasoning #3673; sem publicação |
|
||||
| CLI-072 | P2 | NullClaw | `nullclaw/nullclaw` | concluida | `config-only` | `not-applicable` | `feat/omniroute-nullclaw-integration` | — | — | — | not-in-catalog | HEAD `d8a802fd`; custom `/api/v1`, Chat/Responses/Anthropic, tools/streaming/usage/images; Zig ausente, CI run 30788444193 verde; sem publicação |
|
||||
| CLI-073 | P2 | Moltis | `moltis-org/moltis` | concluida | `config-only` | `not-applicable` | `feat/omniroute-moltis-integration` | — | — | — | not-in-catalog | HEAD `678d407`; `custom-omniroute`, `/api/v1`, `auto`, Chat/SSE/tools/reasoning/usage/images; 401 testes + fmt verdes; MCP/ACP separados; sem publicação |
|
||||
| CLI-074 | P2 | GitClaw | `open-gitagent/gitclaw` | concluida | `config-only` | `not-applicable` | `feat/omniroute-gitclaw-integration` | — | — | — | not-in-catalog | GitAgent HEAD `d3e25d7`; base `/api/v1`, `omniroute:auto`, Chat/SSE/tools/images; build + 65 testes + smoke verdes; reasoning=false no descriptor; sem publicação |
|
||||
| CLI-075 | P2 | LionClaw | `moshthepitt/lionclaw` | concluida | `patch-required` / `issue-first` | `awaiting-maintainer` | `feat/omniroute-lionclaw-integration` | — | — | — | not-in-catalog | HEAD `cb59b23d`; Codex app-server não projeta config.toml/secret para runtime confinado; patch seguro necessário, alinhado à #157; gates locais bloqueados por uv/podman; CI verde; sem publicação |
|
||||
| CLI-076 | P3 | VibePod | `VibePod/vibepod-cli` | concluida | `config-only` | `not-applicable` | `feat/omniroute-vibepod-integration` | — | — | — | not-in-catalog | Claude Code via `/api`, container usa `host.docker.internal`; Codex não injeta chave; compileall verde, pytest bloqueado por typer; sem publicação |
|
||||
| CLI-077 | P3 | zeroshot | `the-open-engine/zeroshot` | concluida | `config-only` | `not-applicable` | `feat/omniroute-zeroshot-integration` | — | — | — | not-in-catalog | Gateway OpenAI `/api/v1`, `auto`, tools fail-closed; 22 testes + build verdes; sem streaming JSON/reasoning/MCP no gateway; sem publicação |
|
||||
| CLI-078 | P3 | Fractal | `plasma-ai/fractal` | concluida | `config-only` / `needs-wrapper` | `awaiting-maintainer` | `feat/omniroute-fractal-integration` | — | — | — | not-in-catalog | Codex Responses por node `CODEX_HOME`; caveat tmux quente não encaminha `OMNIROUTE_API_KEY`; fix genérico recomendado, sem PR |
|
||||
| CLI-079 | P3 | Bernstein | `chernistry/bernstein` | concluida | `config-only` | `not-applicable` | `feat/omniroute-bernstein-integration` | — | — | — | not-in-catalog | Canonical `sipyourdrink-ltd/bernstein`; openai_agents `/api/v1`, auto, api_key_env allowlisted; testes bloqueados por openai ausente; sem publicação |
|
||||
| CLI-080 | P3 | Traycer | `traycerai/traycer` | concluida | `config-only` indireto | `not-applicable` | `feat/omniroute-traycer-integration` | — | — | — | not-in-catalog | Harness OpenCode + provider `@ai-sdk/openai-compatible`, `/api/v1`, `omniroute/auto`; host central fechado; sem publicação |
|
||||
| CLI-081 | P3 | h5i | `h5i-dev/h5i` | concluida | `patch-required` | `awaiting-maintainer` | `feat/omniroute-h5i-integration` | — | — | — | not-in-catalog | Auth proxy/egress Codex fixos em OpenAI anulam base custom; patch seguro/policy-pinned necessário; CI externa verde; sem publicação |
|
||||
| CLI-082 | P3 | OMK | `dmae97/open-multi-agent-kit` | concluida | `viable-mcp` | `not-applicable` | `feat/omniroute-omk-integration` | — | — | — | not-in-catalog | pesquisa concluída neste lote; controle multiagente, MCP é caminho primário; sem provider nominal |
|
||||
| CLI-083 | P3 | kodo | `ikamensh/kodo` | concluida | `config-only` indireto | `not-applicable` | `feat/omniroute-kodo-integration` | — | — | — | not-in-catalog | pesquisa concluída neste lote; orquestrador/agent child, propagar env/base/model ao agente filho |
|
||||
| CLI-084 | P3 | ORCH | `oxgeneral/ORCH` | concluida | `needs-wrapper` | `awaiting-maintainer` | `feat/omniroute-orch-integration` | — | — | — | not-in-catalog | pesquisa concluída neste lote; fila/controle sem provider LLM direto, wrapper/adaptador necessário |
|
||||
| CLI-085 | P3 | LoopTroop | `LoopTroop-ai/LoopTroop` | concluida | `config-only` indireto | `not-applicable` | `feat/omniroute-looptroop-integration` | — | — | — | not-in-catalog | HEAD `cbfc81c5`; OpenCode recebe provider `@ai-sdk/openai-compatible`, `/api/v1`, `omniroute/auto`; 16 testes verdes; sem publicação |
|
||||
| CLI-086 | P3 | Galley | `shinpr/galley` | concluida | `patch-required` | `awaiting-maintainer` | `feat/omniroute-galley-integration` | — | — | — | not-in-catalog | HEAD `6bcc593d`; registry/transports fechados, requer transport OpenAI-compatible para executor e supervisor; Go ausente; sem publicação |
|
||||
| CLI-087 | P3 | Relay | `jcast90/relay` | concluida | `config-only` | `not-applicable` | `feat/omniroute-relay-integration` | — | — | — | not-in-catalog | HEAD `7bd5a2f6`; provider profile Codex com `OPENAI_BASE_URL`, key ref e modelo; smoke Responses obrigatório; MCP separado; sem publicação |
|
||||
| CLI-088 | P3 | SageCLI | `youwangd/SageCLI` | concluida | `config-only` indireto | `not-applicable` | `feat/omniroute-sagecli-integration` | — | — | — | not-in-catalog | HEAD `c167712d`; Codex runtime, base/key configuradas fora do Sage; env plaintext caveat; 45 testes verdes; sem publicação |
|
||||
| CLI-089 | P3 | 5dive | `5dive-ai/5dive` | concluida | `patch-required` | `awaiting-maintainer` | `feat/omniroute-5dive-integration` | — | — | — | not-in-catalog | HEAD `b64b6dac`; provider/base maps fechados; patch OpenAI-compatible genérico; 50 testes focados verdes; sem publicação |
|
||||
| CLI-090 | P3 | agx | `ramarlina/agx` | concluida | `config-only` indireto | `not-applicable` | `feat/omniroute-agx-integration` | — | — | — | not-in-catalog | HEAD `e674cec1`; Codex herda base/key/model; smoke Responses e governança `--full-auto`; Jest ausente; sem publicação |
|
||||
| CLI-091 | P3 | claude-code-router | `musistudio/claude-code-router` | concluida | `config-only` | `not-applicable` | `feat/omniroute-claude-code-router-integration` | — | — | — | not-in-catalog | HEAD `bc8a8e62`; provider custom OpenAI/Anthropic/Gemini, Chat/Responses; smoke por protocolo; sem publicação |
|
||||
| CLI-092 | P3 | cc-router | `finch-xu/cc-router` | concluida | `config-only` | `not-applicable` | `feat/omniroute-cc-router-integration` | — | — | — | not-in-catalog | HEAD `c4c7579`; custom Responses/Chat com base/path/header, SSE/tools/reasoning; cargo bloqueado por glib; sem publicação |
|
||||
| CLI-093 | P3 | OneCLI | `onecli/onecli` | concluida | `config-only` | `not-applicable` | `feat/omniroute-onecli-integration` | — | — | — | not-in-catalog | HEAD `84ccaf74`; MITM credential gateway, generic host injection; MCP separado; sem publicação |
|
||||
| CLI-094 | P3 | agent-browser | `vercel-labs/agent-browser` | concluida | `config-only` | `not-applicable` | `feat/omniroute-agent-browser-integration` | — | — | — | not-in-catalog | HEAD `01c1147d`; chat usa gateway Chat/SSE/tools com env key/model; base precisa validar sufixo `/v1` para não duplicar path; cargo test exit 0; sem publicação |
|
||||
| CLI-095 | P3 | OpenWork | `different-ai/openwork` | concluida | `config-only` indireto | `not-applicable` | `feat/omniroute-openwork-integration` | — | — | — | not-in-catalog | HEAD `ecb7a5f0`; OpenCode custom provider `/api/v1`, auth gerenciada; sem testes/deps; sem publicação |
|
||||
| CLI-096 | P3 | Agent Deck review | `asheshgoplani/agent-deck` | concluida | `config-only` indireto | `not-applicable` | `feat/omniroute-agent-deck-review` | — | — | — | integrated | HEAD `46300807`; env/model propagados a Codex/OpenCode; Go ausente; sem publicação |
|
||||
| CLI-097 | P4 | Pool | `poolsideai/pool` | concluida | `config-only` | `not-applicable` | `feat/omniroute-pool-integration` | — | — | — | not-in-catalog | HEAD `a6fe0ca1`; `pool exec --api-url` OpenAI-compatible, sandbox required, MCP/ACP separado; EULA; sem publicação |
|
||||
| CLI-098 | P4 | Junie CLI | `junie.jetbrains.com` | concluida | `config-only` | `not-applicable` | `feat/omniroute-junie-integration` | — | — | — | not-in-catalog | HEAD `d2701be6`; custom profile OpenAICompletion/Responses com baseUrl full e env ref; runtime proprietário/EAP; sem publicação |
|
||||
| CLI-099 | P4 | Cursor desktop | Anysphere | concluida | `config-only` limitado | `awaiting-maintainer` | `feat/omniroute-cursor-desktop-integration` | — | — | — | integrated | disclosure-only; BYO key/chat panel; Composer/Tab nativos; privado/MITM proibido; sem publicação |
|
||||
| CLI-100 | P4 | Windsurf | Codeium | concluida | `blocked-closed` / MCP-only | `awaiting-maintainer` | `feat/omniroute-windsurf-integration` | — | — | — | not-in-catalog | sem upstream/base custom; BYOK Anthropic específico; MCP separado; MITM proibido; sem publicação |
|
||||
| CLI-101 | P4 | Amp | Sourcegraph | concluida | `config-only` parcial / Enterprise-gated | `awaiting-maintainer` | `feat/omniroute-amp-integration` | — | — | — | not-in-catalog | CLI fechada/Amp Server; confirmar provider custom com suporte; MCP viável; sem publicação |
|
||||
| CLI-102 | P4 | Amazon Q/Kiro CLI | AWS | concluida | `patch-required` legado / `blocked-closed` Kiro | `awaiting-maintainer` | `feat/omniroute-amazon-q-integration` | — | — | — | integrated | Q usa AWS EventStream/SigV4; Kiro fechado sem base custom; MCP-only seguro; sem publicação |
|
||||
| CLI-103 | P4 | Cowork | Anthropic | concluida | `blocked-closed` / MCP-only | `not-applicable` | — | — | — | — | not-in-catalog | inferência gerida pela Anthropic sem BYOK/base custom; Custom Connector MCP remoto; MITM proibido; sem publicação |
|
||||
|
||||
## Como atualizar
|
||||
|
||||
Ao terminar uma fase, alterar somente os campos comprovados e deixar os demais como `—`. Para uma integracao concluida, registrar: versao/commit pesquisado, mecanismo, arquivos modificados, testes, branch, commit, URL de PR/issue e resposta do mantenedor. Se o caso for apenas configuracao, registrar o comando/config real e marcar `config-only` ou `viable-direct`, sem criar uma PR artificial.
|
||||
|
||||
Antes de publicar uma contribuicao, aplicar o gate e o checklist de
|
||||
`05-plano-publicacao-prs-upstream.md`.
|
||||
659
_references/_sistemas_cli/05-plano-publicacao-prs-upstream.md
Normal file
659
_references/_sistemas_cli/05-plano-publicacao-prs-upstream.md
Normal file
@@ -0,0 +1,659 @@
|
||||
# Plano de publicacao de integracoes OmniRoute nos repositorios upstream
|
||||
|
||||
> **Status da campanha de pesquisa:** `104/104` casos concluídos. Este plano continua sendo o procedimento de execução e publicação. A matriz final, inclusive os casos em que PR é inadequada ou impossível, está em `06-relatorio-final-104-clis-e-estrategia-prs.md`.
|
||||
|
||||
**Data:** 2026-08-01
|
||||
**Escopo:** transformar a fila `CLI-000` a `CLI-103` em contribuicoes upstream verificadas,
|
||||
publicando PR, issue, guia de configuracao, adaptador ou conclusao de bloqueio conforme o mecanismo
|
||||
real de cada projeto.
|
||||
**Documentos-base:** `01-relatorio-pesquisa-clis-omniroute.md`,
|
||||
`02-prioridade-integracoes-clis.md`, `03-plano-integracao-em-lotes.md` e
|
||||
`04-tracker-integracoes-clis.md`.
|
||||
|
||||
## 1. Resultado esperado
|
||||
|
||||
Para cada repositorio pesquisado, a campanha deve produzir exatamente um resultado principal:
|
||||
|
||||
1. **PR upstream de integracao nominal:** adiciona provider/preset `omniroute`, configuracao,
|
||||
documentacao e testes quando isso combina com a arquitetura do projeto.
|
||||
2. **PR upstream de compatibilidade generica:** melhora suporte a endpoint customizado sem acoplar
|
||||
o projeto ao nome OmniRoute, acompanhado de documentacao comprovando o uso com OmniRoute.
|
||||
3. **PR somente de documentacao:** registra uma configuracao funcional quando o codigo ja suporta
|
||||
OmniRoute e o upstream aceita guias de terceiros.
|
||||
4. **Issue-first:** solicita decisao de arquitetura ou permissao antes do patch quando a politica do
|
||||
repositorio, o desenho de providers ou o tamanho da mudanca exigirem alinhamento.
|
||||
5. **Configuracao sem PR:** documenta no OmniRoute um fluxo que ja funciona e para o qual uma mudanca
|
||||
upstream seria redundante ou rejeitada pela politica do projeto.
|
||||
6. **Adaptador ACP/MCP/wrapper:** contribui no ponto de extensao correto quando o projeto nao consome
|
||||
diretamente APIs de modelos.
|
||||
7. **MITM, produto fechado ou bloqueado:** registra evidencia e nao fabrica uma contribuicao que o
|
||||
upstream nao pode receber.
|
||||
|
||||
O objetivo e tentar integrar todos os casos tecnicamente possiveis. O objetivo nao e abrir uma PR em
|
||||
todo repositorio independentemente da arquitetura, licenca ou politica de contribuicao.
|
||||
|
||||
## 2. Regras da campanha
|
||||
|
||||
- Trabalhar em lotes de no maximo tres repositorios, com um subagente por repositorio.
|
||||
- Usar uma worktree isolada por repositorio dentro de `.claude/worktrees/`.
|
||||
- Nao editar implementacoes no checkout compartilhado.
|
||||
- Nao usar `git stash` ou `git pop`.
|
||||
- Fazer pesquisa fresca no commit atual do upstream antes de criar branch ou editar arquivos.
|
||||
- Ler `README`, `CONTRIBUTING`, templates de issue/PR, `SECURITY`, licenca e instrucoes locais de
|
||||
agentes antes da implementacao.
|
||||
- Procurar issues e PRs abertas/fechadas sobre custom provider, base URL, OpenAI-compatible,
|
||||
Anthropic-compatible, Gemini endpoint, proxy, gateway e OmniRoute antes de propor uma mudanca.
|
||||
- Registrar a base pesquisada por commit SHA ou release. Nao usar apenas `main` como evidencia.
|
||||
- Executar baseline antes da mudanca e distinguir falhas preexistentes de regressao.
|
||||
- Nunca expor `OMNIROUTE_API_KEY` ou qualquer outra credencial em comandos publicados, fixtures,
|
||||
logs, commits, screenshots, PRs ou issues.
|
||||
- Nao inserir trailers, assinaturas ou rodapes de IA em commits, PRs ou issues.
|
||||
- Nao afirmar que uma integracao funciona sem um teste reproduzivel ou uma limitacao explicitamente
|
||||
registrada.
|
||||
- Nao inventar fork, branch, commit, PR, issue, CI ou resposta de mantenedor.
|
||||
- Atualizar `04-tracker-integracoes-clis.md` ao concluir cada fase material.
|
||||
|
||||
## 3. Unidade de trabalho por repositorio
|
||||
|
||||
Cada item `CLI-NNN` deve possuir uma task individual. A task e o pacote de contexto entregue ao
|
||||
subagente e o registro que permite retomar o trabalho sem repetir ou perder evidencias.
|
||||
|
||||
### 3.1 Cabecalho obrigatorio da task
|
||||
|
||||
```md
|
||||
# CLI-NNN - <projeto> - integracao OmniRoute upstream
|
||||
|
||||
- Repositorio canonico: <URL>
|
||||
- Prioridade/lote: <P0-P4 / lote>
|
||||
- Estado no catalogo OmniRoute: <integrated/not-in-catalog/parcial>
|
||||
- Evidencia inicial: <resumo vindo do relatorio; ainda nao confirmado>
|
||||
- Worktree: <caminho isolado>
|
||||
- Branch planejada: <definir somente depois de ler as regras upstream>
|
||||
- Commit/release pesquisado: —
|
||||
- Responsavel: <agente>
|
||||
- Estado: researching
|
||||
```
|
||||
|
||||
### 3.2 Pesquisa obrigatoria dentro da task
|
||||
|
||||
O subagente deve responder, com links e caminhos de codigo:
|
||||
|
||||
1. Qual e o repositorio canonico, commit/release atual, licenca e nivel de atividade?
|
||||
2. Contribuicoes de forks externos sao aceitas? Ha CLA, DCO, sign-off ou issue previa obrigatoria?
|
||||
3. Qual e a arquitetura de providers e qual e o menor ponto de extensao?
|
||||
4. O cliente usa Chat Completions, Responses, Anthropic Messages, Gemini, ACP, MCP ou protocolo
|
||||
proprietario?
|
||||
5. A base URL esperada e raiz, `/v1`, `/v1beta` ou uma URL completa por operacao?
|
||||
6. O cliente acrescenta algum sufixo automaticamente? Pode duplicar `/v1` ou `/v1beta`?
|
||||
7. Como a autenticacao e resolvida: variavel de ambiente, arquivo, keyring, OAuth ou header custom?
|
||||
8. Como os modelos sao definidos ou descobertos? O cliente chama um endpoint de modelos?
|
||||
9. Streaming, tool calling, reasoning, imagens e cancelamento funcionam pelo caminho escolhido?
|
||||
10. Ja existe issue, PR, discussao ou documentacao para endpoints customizados ou OmniRoute?
|
||||
11. Quais comandos oficiais executam install, format, lint, typecheck, build e testes?
|
||||
12. Qual contribuicao agrega valor real: codigo nominal, compatibilidade generica, docs, issue,
|
||||
wrapper, MCP/ACP, somente configuracao ou nenhum patch?
|
||||
|
||||
### 3.3 Gate de contribuicao
|
||||
|
||||
Antes de editar, preencher uma decisao:
|
||||
|
||||
| Decisao | Quando usar | Saida esperada |
|
||||
|---|---|---|
|
||||
| `pr-provider` | O upstream possui catalogo/presets de providers | Provider/preset OmniRoute, docs e testes |
|
||||
| `pr-generic` | Falta uma capacidade generica necessaria, como base URL customizavel | Patch generico, docs e teste com OmniRoute |
|
||||
| `pr-docs` | O codigo ja funciona e o upstream aceita guias de integracao | Guia minimo e validado |
|
||||
| `issue-first` | Mudanca arquitetural, politica incerta ou mantenedor exige proposta | Issue com evidencia e desenho do patch |
|
||||
| `config-only` | Tudo funciona por configuracao e um PR seria redundante | Guia no OmniRoute e smoke test |
|
||||
| `adapter-acp` | ACP e o ponto real de integracao | Adaptador/registro ACP e testes |
|
||||
| `adapter-mcp` | MCP e o ponto real de integracao | Config/servidor MCP e testes |
|
||||
| `wrapper` | O projeto apenas lanca outro agente | Wrapper/env forwarding e teste do filho |
|
||||
| `needs-mitm` | Endpoint fechado ou fixo | Pesquisa/guia MITM separado; sem PR artificial |
|
||||
| `blocked` | Licenca, politica, build ou protocolo impedem progresso | Evidencia reproduzivel e proximo desbloqueio |
|
||||
|
||||
O gate deve incluir a alternativa rejeitada. Exemplo: `pr-provider` escolhido porque o repositorio
|
||||
mantem presets nomeados; `pr-docs` rejeitado porque a configuracao exigiria cinco campos internos e
|
||||
nao seria uma experiencia suportada.
|
||||
|
||||
## 4. Ciclo completo da PR
|
||||
|
||||
### Fase PR-0 - Preparar o contexto
|
||||
|
||||
- Reservar o item no tracker e marcar pesquisa em andamento.
|
||||
- Confirmar que nenhum outro agente esta trabalhando no mesmo repositorio.
|
||||
- Resolver o repositorio canonico, fork existente e permissao de contribuicao.
|
||||
- Criar a task individual com a evidencia inicial marcada como hipotese.
|
||||
- Criar a worktree isolada somente depois de confirmar o upstream correto.
|
||||
|
||||
### Fase PR-1 - Pesquisar upstream e contribuicoes existentes
|
||||
|
||||
- Ler integralmente as regras do repositorio aplicaveis aos arquivos que podem mudar.
|
||||
- Mapear provider registry, configuracao, transporte HTTP, auth, modelo, streaming e ferramentas.
|
||||
- Pesquisar issues/PRs por termos de compatibilidade e pelo nome OmniRoute.
|
||||
- Registrar commit/release, caminhos e links de evidencia na task.
|
||||
- Escolher o gate de contribuicao da secao 3.3.
|
||||
|
||||
### Fase PR-2 - Baseline reproduzivel
|
||||
|
||||
- Instalar dependencias de acordo com o upstream.
|
||||
- Rodar format check, lint, typecheck/build e testes relevantes antes do patch.
|
||||
- Rodar um smoke test do caminho existente, mesmo que ele falhe por falta da integracao.
|
||||
- Limpar chaves do ambiente nos testes que validem o comportamento sem credenciais.
|
||||
- Registrar comando, codigo de saida, testes aprovados e falhas preexistentes.
|
||||
- Se o projeto nao puder ser construido, tentar o ambiente documentado e registrar o bloqueio; nao
|
||||
declarar regressao nem compatibilidade com base apenas na leitura do README.
|
||||
|
||||
### Fase PR-3 - Desenhar o menor patch aceitavel
|
||||
|
||||
A ordem de preferencia e:
|
||||
|
||||
1. Reusar a abstracao de provider ja existente.
|
||||
2. Adicionar metadados/preset antes de criar codigo especial.
|
||||
3. Reusar cliente OpenAI/Anthropic/Gemini ja presente.
|
||||
4. Adicionar capacidade generica quando ela beneficiar outros gateways e for coerente com o projeto.
|
||||
5. Criar executor/adapter dedicado somente quando o protocolo realmente divergir.
|
||||
|
||||
O patch normalmente deve cobrir:
|
||||
|
||||
- identificador e nome de exibicao `omniroute`, se presets nomeados forem aceitos;
|
||||
- base URL correta e sem dupla concatenacao de versao;
|
||||
- chave obtida de ambiente ou storage seguro;
|
||||
- configuracao/descoberta de modelo;
|
||||
- headers estritamente necessarios;
|
||||
- streaming e tool calling preservados;
|
||||
- mensagens de erro sem expor segredo;
|
||||
- documentacao curta e executavel;
|
||||
- testes unitarios/integracao alinhados ao padrao upstream.
|
||||
|
||||
Nao adicionar telemetria, dependencia, fluxo de login ou codigo de rede novo quando o provider
|
||||
generico existente ja resolve o caso.
|
||||
|
||||
### Fase PR-4 - Implementar com teste primeiro
|
||||
|
||||
- Criar teste que demonstre a ausencia do preset, config ou comportamento requerido.
|
||||
- Confirmar a falha pelo motivo esperado.
|
||||
- Implementar o menor patch.
|
||||
- Fazer o teste passar e executar testes adjacentes.
|
||||
- Refatorar apenas o necessario para manter o padrao do upstream.
|
||||
- Formatar somente os arquivos tocados, salvo exigencia contraria do repositorio.
|
||||
|
||||
Para PR somente de documentacao, substituir o teste vermelho por uma validacao real dos comandos e
|
||||
do arquivo de configuracao documentado. Nao sintetizar exemplos que nao foram executados.
|
||||
|
||||
### Fase PR-5 - Validar contra OmniRoute
|
||||
|
||||
Escolher a matriz compativel com o cliente:
|
||||
|
||||
| Superficie | Base inicial esperada | Validacoes minimas |
|
||||
|---|---|---|
|
||||
| OpenAI Chat Completions | confirmar se o cliente espera raiz ou `/v1` | chamada simples, stream, tool call, erro de modelo |
|
||||
| OpenAI Responses | confirmar regra de concatenacao do cliente | resposta simples, stream/eventos, tool call |
|
||||
| Anthropic Messages | normalmente base antes de `/v1/messages`; confirmar no codigo | mensagem, stream, tools, headers de versao |
|
||||
| Gemini | normalmente base antes das operacoes `v1beta`; confirmar no codigo | generateContent, streamGenerateContent, tools |
|
||||
| ACP | endpoint/transport definido pelo protocolo | discovery, sessao, request e cancelamento |
|
||||
| MCP | stdio, SSE ou Streamable HTTP conforme suporte | inicializacao, listagem e invocacao de ferramenta |
|
||||
|
||||
Registrar no resultado quais linhas da matriz foram executadas, omitidas ou bloqueadas. Um smoke
|
||||
test simples nao deve ser apresentado como prova de tool calling ou streaming.
|
||||
|
||||
### Fase PR-6 - Revisar o diff antes de publicar
|
||||
|
||||
O agente responsavel faz uma auto-revisao e o agente principal verifica:
|
||||
|
||||
- aderencia a `CONTRIBUTING` e instrucoes locais;
|
||||
- escopo minimo e ausencia de refactor oportunista;
|
||||
- testes cobrindo config, URL, auth sem segredo e modelo;
|
||||
- documentacao consistente com o codigo executado;
|
||||
- ausencia de arquivos gerados, caches, logs ou credenciais;
|
||||
- licenca e atribuicao preservadas;
|
||||
- branch baseada no upstream atual;
|
||||
- commits pequenos e com mensagem no estilo do projeto;
|
||||
- ausencia de trailers ou texto de IA;
|
||||
- `git diff --check` e gates oficiais limpos, ou falhas preexistentes documentadas.
|
||||
|
||||
Uma PR nao deve ser publicada enquanto houver alteracao sem explicacao, teste essencial faltando ou
|
||||
duvida material sobre a politica do upstream.
|
||||
|
||||
### Fase PR-7 - Preparar a publicacao
|
||||
|
||||
- Confirmar fork e remotes sem sobrescrever branches existentes.
|
||||
- Atualizar a branch sobre o ponto exigido pelo upstream usando operacao nao destrutiva.
|
||||
- Enviar a branch ao fork somente depois da revisao.
|
||||
- Criar PR contra a branch correta do repositorio canonico.
|
||||
- Se a contribuicao externa estiver bloqueada, abrir issue-first e anexar o commit/patch de
|
||||
referencia somente quando isso for permitido.
|
||||
- Registrar URLs reais no tracker imediatamente apos a publicacao.
|
||||
|
||||
Convencoes de branch sugeridas, sujeitas ao padrao de cada upstream:
|
||||
|
||||
- `feat/omniroute-provider` para provider/preset nominal;
|
||||
- `feat/custom-base-url` para capacidade generica;
|
||||
- `docs/omniroute-setup` para documentacao validada;
|
||||
- `fix/custom-endpoint-versioning` para correcao de raiz versus `/v1`/`/v1beta`.
|
||||
|
||||
### Fase PR-8 - Corpo da PR
|
||||
|
||||
Usar o template oficial do repositorio quando existir. Na ausencia de template, adaptar:
|
||||
|
||||
```md
|
||||
## Why
|
||||
|
||||
Explain the user problem and the existing extension point. Avoid marketing claims.
|
||||
|
||||
## What changed
|
||||
|
||||
- Add or enable the smallest provider/configuration path required.
|
||||
- Document the verified setup.
|
||||
- Cover URL, authentication and model selection behavior with tests.
|
||||
|
||||
## Verification
|
||||
|
||||
- `<official upstream command>`
|
||||
- `<focused test command>`
|
||||
- `<sanitized OmniRoute smoke test and result>`
|
||||
|
||||
## Compatibility notes
|
||||
|
||||
- API surface: `<Chat Completions/Responses/Anthropic/Gemini/ACP/MCP>`
|
||||
- Base URL rule: `<root, /v1, /v1beta or full operation URL>`
|
||||
- Streaming: `<verified/not applicable/not verified>`
|
||||
- Tool calling: `<verified/not applicable/not verified>`
|
||||
|
||||
## Scope
|
||||
|
||||
No unrelated refactors or credential changes.
|
||||
```
|
||||
|
||||
O titulo deve descrever a mudanca, nao a campanha. Exemplos de formato, sujeitos ao estilo do
|
||||
upstream: `Add OmniRoute provider preset`, `Support configurable OpenAI-compatible base URLs` ou
|
||||
`Document OmniRoute as a custom endpoint`.
|
||||
|
||||
### Fase PR-9 - Issue-first ou fallback
|
||||
|
||||
Quando uma PR direta nao for apropriada, a issue deve conter:
|
||||
|
||||
- problema reproduzivel e publico afetado;
|
||||
- ponto de extensao encontrado no codigo;
|
||||
- proposta minima;
|
||||
- compatibilidade esperada e protocolo;
|
||||
- evidencia de teste ou prototipo;
|
||||
- pergunta objetiva ao mantenedor;
|
||||
- link para patch de referencia apenas se permitido.
|
||||
|
||||
Nao abrir simultaneamente issue e PR sem necessidade. Se o template exigir issue previa, esperar a
|
||||
decisao ou seguir a politica declarada.
|
||||
|
||||
### Fase PR-10 - Acompanhar ate a decisao
|
||||
|
||||
Depois da publicacao:
|
||||
|
||||
- observar CI e checks obrigatorios;
|
||||
- responder perguntas tecnicas com evidencia;
|
||||
- corrigir somente o escopo da contribuicao ou pedidos claros do mantenedor;
|
||||
- reexecutar testes depois de cada mudanca;
|
||||
- registrar novos commits, revisoes e estado no tracker;
|
||||
- marcar `accepted` somente depois de merge/aceite comprovado;
|
||||
- marcar `rejected` com o motivo fornecido pelo upstream;
|
||||
- se a PR ficar inativa, registrar `awaiting-maintainer`, sem declarar abandono prematuramente;
|
||||
- manter o guia/catalogo OmniRoute coerente com o estado real do upstream.
|
||||
|
||||
O acompanhamento pode usar a skill `babysit` individualmente para uma PR aberta. Como essa skill
|
||||
acompanha uma unica PR, nunca agrupar tres PRs em uma mesma execucao dela.
|
||||
|
||||
### Fase PR-11 - Fechar a task
|
||||
|
||||
Uma task individual termina com:
|
||||
|
||||
- pesquisa fresca e gate registrados;
|
||||
- diff, configuracao ou bloqueio documentado;
|
||||
- baseline e validacao final comparados;
|
||||
- branch/commit reais, quando criados;
|
||||
- PR/issue reais, quando publicados;
|
||||
- status no catalogo OmniRoute;
|
||||
- limitacoes e proximo passo;
|
||||
- linha correspondente no tracker atualizada.
|
||||
|
||||
## 5. Estrategia de paralelizacao
|
||||
|
||||
### 5.1 Papeis por lote
|
||||
|
||||
- **Subagente A:** primeiro repositorio do lote; dono exclusivo da worktree e do diff upstream.
|
||||
- **Subagente B:** segundo repositorio do lote; dono exclusivo da worktree e do diff upstream.
|
||||
- **Subagente C:** terceiro repositorio do lote; dono exclusivo da worktree e do diff upstream.
|
||||
- **Agente principal:** coordena o tracker, revisa gates/diffs, impede duplicacao e autoriza a
|
||||
publicacao depois das evidencias.
|
||||
|
||||
Todos os agentes devem ser avisados de que nao estao sozinhos no workspace e nao podem reverter ou
|
||||
sobrescrever mudancas de outros agentes.
|
||||
|
||||
### 5.2 Barreira do lote
|
||||
|
||||
O lote seguinte pode comecar quando os tres itens atuais tiverem, no minimo:
|
||||
|
||||
1. commit/release upstream pesquisado;
|
||||
2. gate de contribuicao definido;
|
||||
3. baseline registrado;
|
||||
4. patch validado, configuracao comprovada ou bloqueio reproduzivel;
|
||||
5. decisao de publicacao tomada;
|
||||
6. tracker atualizado.
|
||||
|
||||
A espera por resposta de mantenedor nao bloqueia o lote seguinte. Depois de uma PR/issue publicada,
|
||||
o item passa para acompanhamento e libera o slot de implementacao.
|
||||
|
||||
### 5.3 Limite de trabalho em progresso
|
||||
|
||||
- No maximo tres pesquisas/implementacoes ativas.
|
||||
- Publicacoes aguardando mantenedor nao contam como slot de implementacao, mas ficam no tracker.
|
||||
- No maximo uma task ativa por repositorio, inclusive forks ou variantes do mesmo upstream.
|
||||
- Se dois itens resolverem o mesmo repositorio, consolidar a pesquisa e decidir se ha uma ou duas
|
||||
contribuicoes antes de abrir branches.
|
||||
|
||||
## 6. Fila de publicacao
|
||||
|
||||
A ordem detalhada continua sendo a do `03-plano-integracao-em-lotes.md`. Esta secao define o objetivo
|
||||
de publicacao de cada onda; a pesquisa individual pode promover, rebaixar ou mudar o tipo de
|
||||
contribuicao.
|
||||
|
||||
### Onda 0 - referencia e infraestrutura da campanha
|
||||
|
||||
- `CLI-000` jcode: acompanhar issue upstream e PR de referencia; concluir a secao prometida no
|
||||
README do OmniRoute.
|
||||
- Preparar o modelo de task individual e aplicar o mesmo tracker a todos os novos repositorios.
|
||||
|
||||
### Onda 1 - P0.1 a P0.5
|
||||
|
||||
- `CLI-001` Gemini CLI: confirmar se o endpoint Gemini customizado pede apenas docs/config ou um
|
||||
preset nominal.
|
||||
- `CLI-002` Claw Code: confirmar provider OpenAI-compatible e propor preset/docs minimos.
|
||||
- `CLI-003` Plandex: confirmar o registro de providers customizados e propor provider/preset.
|
||||
- `CLI-004` MiMo Code: confirmar o adapter OpenAI-compatible e propor configuracao/provider.
|
||||
- `CLI-005` Trae Agent: confirmar `model_providers` e propor entrada OmniRoute/documentacao.
|
||||
- `CLI-006` Kimi CLI: escolher uma superficie suportada e evitar um patch que misture tres
|
||||
protocolos sem testes.
|
||||
- `CLI-007` Every Code: reutilizar a arquitetura herdada do Codex quando ainda aplicavel.
|
||||
- `CLI-008` Open Codex: confirmar upstream canonico e propor provider multi-modelo.
|
||||
- `CLI-009` VT Code: validar provider customizado, modelo e failover.
|
||||
- `CLI-010` OpenHands CLI: verificar se `LLM_BASE_URL` torna o caso docs/config-only.
|
||||
- `CLI-011` gptme: verificar se `OPENAI_BASE_URL` torna o caso docs/config-only.
|
||||
- `CLI-012` Nanocoder: confirmar compatibilidade de tool calling e decidir preset versus docs.
|
||||
- `CLI-013` RA.Aid: verificar se `OPENAI_API_BASE` torna o caso docs/config-only.
|
||||
- `CLI-014` CoreCoder: verificar se `OPENAI_BASE_URL` torna o caso docs/config-only.
|
||||
- `CLI-015` Grok CLI: confirmar se o endpoint e genericamente configuravel ou preso ao protocolo
|
||||
Grok antes de propor patch.
|
||||
|
||||
### Onda 2 - P1.1 a P1.9
|
||||
|
||||
- `CLI-016` Gitlawb Zero: provider custom/flag; preferir docs ou preset pequeno.
|
||||
- `CLI-017` DeepSeek Reasonix: confirmar repositorio, atividade e endpoint antes de qualquer PR.
|
||||
- `CLI-018` KlaatCode: integrar via `customModels` ou preset se o catalogo aceitar nomes.
|
||||
- `CLI-019` CodeMini CLI: validar `gateway.base_url` e sua regra de versao.
|
||||
- `CLI-020` Zot: validar `--base-url` e `models.json`; docs-first se ja suficiente.
|
||||
- `CLI-021` Octomind: confirmar variaveis de URL por provider e propor configuracao minima.
|
||||
- `CLI-022` DvalinCode: confirmar o cliente OpenAI-compatible e testes disponiveis.
|
||||
- `CLI-023` Coro Code: confirmar `OPENAI_BASE_URL`; docs-first se nao houver lacuna de codigo.
|
||||
- `CLI-024` Mini-Kode: confirmar `MINIKODE_BASE_URL`; docs-first se nao houver lacuna de codigo.
|
||||
- `CLI-025` Late CLI: testar ambiente e flag `api-url`; corrigir precedencia apenas se necessario.
|
||||
- `CLI-026` Agentty: escolher entre provider direto e ACP conforme a arquitetura atual.
|
||||
- `CLI-027` Aizen: validar `AIZEN_BASE_URL` e propor docs/preset.
|
||||
- `CLI-028` Clif-Code: selecionar um unico protocolo principal para a primeira contribuicao.
|
||||
- `CLI-029` Minacode: pesquisa confirmatoria antes de definir o tipo de PR.
|
||||
- `CLI-030` YottaCode: confirmar gateway/provider e selecao de modelo.
|
||||
- `CLI-031` aichat: integrar via configuracao de modelos ou provider nominal, conforme a politica.
|
||||
- `CLI-032` ShellGPT: validar `API_BASE_URL` e decidir docs/config-only.
|
||||
- `CLI-033` Mistral Vibe: confirmar base URL customizada e separar suporte generico de marca.
|
||||
- `CLI-034` OpenSquilla: localizar o registro de gateways e propor provider/preset.
|
||||
- `CLI-035` Kode CLI: escolher OpenAI, Anthropic ou Gemini com base na implementacao mais nativa.
|
||||
- `CLI-036` Neovate Code: preferir plugin/provider oficial ao patch no core, se existir.
|
||||
- `CLI-037` Deep Agents Code: contribuir no pacote CLI/provider correto, nao apenas no SDK generico.
|
||||
- `CLI-038` OpenHands principal: evitar duplicar `CLI-010`; consolidar se ambos apontarem para o
|
||||
mesmo mecanismo e upstream.
|
||||
- `CLI-039` SWE-agent: confirmar backend de modelos e interface publica suportada.
|
||||
- `CLI-040` AutoCodeRover: confirmar backend e propor config/provider minimo.
|
||||
- `CLI-041` Claurst: revisar GPL e politica antes de redistribuir qualquer adaptacao.
|
||||
- `CLI-042` Codebuff: confirmar se o provider e extensivel e se contribuicoes externas sao aceitas.
|
||||
|
||||
### Onda 3 - P2.1 a P2.11
|
||||
|
||||
- `CLI-043` Devon, `CLI-044` Letta Code e `CLI-045` CodeMachine CLI: pesquisar backend real;
|
||||
revisar a entrada local ja existente de Letta antes de nova PR.
|
||||
- `CLI-046` Groq Code CLI, `CLI-047` Dexto e `CLI-048` claw-code-agent: confirmar endpoints,
|
||||
protocolos e maturidade antes do patch.
|
||||
- `CLI-049` g3, `CLI-050` San e `CLI-051` Waveloom: localizar a abstracao de provider e preferir
|
||||
implementacao generica.
|
||||
- `CLI-052` picocode, `CLI-053` QQCode e `CLI-054` Keen Code: validar configuracao multi-modelo e
|
||||
documentar o caminho minimo.
|
||||
- `CLI-055` Grinta, `CLI-056` Zap e `CLI-057` Binharic: escolher o provider compativel com melhor
|
||||
cobertura de streaming/tools.
|
||||
- `CLI-058` Darce, `CLI-059` CLAII e `CLI-060` nori-cli: separar integracao de modelo de MCP e de
|
||||
codigo herdado do Codex.
|
||||
|
||||
Resultado P2.6:
|
||||
|
||||
- `CLI-058` Darce: `config-only`, sem PR necessária; usar `DARCE_API_BASE` na raiz e `DARCE_MODEL`.
|
||||
- `CLI-059` CLAII: patch genérico local validado, mas publicação bloqueada pela declaração upstream
|
||||
`All Rights Reserved`/ausência de licença OSS; só reconsiderar com autorização jurídica explícita.
|
||||
- `CLI-060` nori-cli: `config-only` via agente ACP customizado OpenCode; não alterar backend Codex;
|
||||
MCP deve ser configurado uma vez, em Nori ou OpenCode, para evitar duplicação de tools.
|
||||
- `CLI-061` cursor-agent clone, `CLI-062` Free Code e `CLI-063` Claude Engineer: revisar origem,
|
||||
licenca e politica do fork antes de publicar.
|
||||
|
||||
Lote P2.7 reservado em 2026-08-02, na branch-base local `release/v3.8.50` em
|
||||
`35405be6020696a7c66158ea7a25f06d61ff88ff`. Os três upstreams foram clonados em worktrees
|
||||
separadas, indexados e delegados. Nenhuma publicação está autorizada; patches só podem surgir após
|
||||
prova RED→GREEN e permanecem sem commit até revisão central.
|
||||
|
||||
Resultado P2.7:
|
||||
|
||||
- `CLI-061` cursor-agent clone: `config-only`; OpenAI usa base com `/v1`, Anthropic usa raiz sem
|
||||
`/v1`; tools/tool-result foram comprovados nos dois protocolos. O factory rejeita `auto` puro,
|
||||
mas isso não impede uso com modelos reconhecíveis ou classes diretas. Sem PR.
|
||||
- `CLI-062` Free Code: `config-only` com `ANTHROPIC_BASE_URL` na raiz e `model=auto`; stream,
|
||||
tools/tool-result e MCP nativo foram comprovados. O repo canônico agora é `freecodexyz/free-code`,
|
||||
mas não há licença e o README atribui o código à Anthropic; publicação bloqueada.
|
||||
- `CLI-063` Claude Engineer: endpoint/chave funcionam como `config-only` com modelo fixo. A lacuna
|
||||
de `ANTHROPIC_MODEL` já está coberta pela PR #250; não criar patch concorrente. Arquivo de licença
|
||||
segue ausente apesar da issue #116, portanto publicação permanece bloqueada.
|
||||
- `CLI-064` Smol Developer, `CLI-065` Agentless e `CLI-066` Amazon Q Developer CLI: decidir entre
|
||||
SDK/adaptador, config de modelo ou bloqueio por autenticacao.
|
||||
|
||||
Lote P2.8 iniciado em 2026-08-02 na branch-base local `release/v3.8.50`, SHA
|
||||
`35405be6020696a7c66158ea7a25f06d61ff88ff`, com clones limpos e separados. Smol Developer será
|
||||
testado primeiro como integração do SDK OpenAI legado; Agentless será avaliado por backend
|
||||
OpenAI/Anthropic/DeepSeek; Amazon Q Developer CLI será tratado como protocolo AWS próprio, com MCP
|
||||
avaliado separadamente. Não criar adaptador grande para Amazon Q nem qualquer publicação antes de
|
||||
issue-first/coordenação exigida por `CONTRIBUTING.md`. Estado inicial: nenhum commit, fork, push,
|
||||
PR, issue ou Discussion.
|
||||
|
||||
Resultado P2.8:
|
||||
|
||||
- `CLI-064` Smol Developer: `config-only`; `OPENAI_API_BASE` com `/v1` e `model=auto` passaram no
|
||||
CLI, biblioteca e Agent Protocol histórico. Não há lacuna provider-specific e a PR #134 já cobre
|
||||
uma expansão LiteLLM. Sem publicação.
|
||||
- `CLI-065` Agentless: `config-only` pelo backend OpenAI, incluindo embeddings. Anthropic normal
|
||||
também funciona; cache/tools exige SDK histórico e DeepSeek possui host fixo, mas essas melhorias
|
||||
não são necessárias para integrar o projeto e propostas LiteLLM anteriores foram fechadas. Sem
|
||||
publicação.
|
||||
- `CLI-066` Amazon Q Developer CLI: MCP stdio é a integração direta; o backend de modelo fala AWS
|
||||
JSON/EventStream e precisa de wrapper/backend novo. O upstream está em manutenção crítica e exige
|
||||
issue-first; não preparar PR nominal ou adaptador surpresa. Sem publicação.
|
||||
|
||||
Estado final P2.8: commits `0`, pushes `0`, forks `0`, PRs `0`, issues `0`, Discussions `0`.
|
||||
Próxima fila: P2.9 (`CLI-067` nanobot, `CLI-068` ZeroClaw, `CLI-069` NanoClaw), usando no máximo
|
||||
três worktrees/agentes e repetindo a pesquisa individual antes de qualquer patch.
|
||||
|
||||
Lote P2.9 iniciado em 2026-08-03 sobre a branch-base local `release/v3.8.50`, SHA
|
||||
`84b1e5e12f238269e698f400766230f985f4a07b`. O checkout principal já continha uma alteração do
|
||||
operador em `CLAUDE.md`, preservada fora do escopo. As worktrees foram recriadas e os upstreams
|
||||
foram clonados nos HEADs `44b7e1bf4` (nanobot), `4770420ab` (ZeroClaw) e `dfac7e0af` (NanoClaw).
|
||||
Os três índices Codebase Memory moderate estão ready, sem skipped, e a pesquisa foi delegada a um
|
||||
agente por repositório. Nenhuma publicação está autorizada; o estado inicial continua: commits `0`,
|
||||
pushes `0`, forks `0`, PRs `0`, issues `0`, Discussions `0`.
|
||||
|
||||
- `CLI-067` nanobot, `CLI-068` ZeroClaw e `CLI-069` NanoClaw: validar providers OpenClaw/Anthropic
|
||||
e evitar assumir que todos aceitam a mesma base URL.
|
||||
|
||||
Resultado P2.9:
|
||||
|
||||
- `CLI-067` nanobot: `config-only` pelo provider dinâmico OpenAI-compatible. A base correta inclui
|
||||
`/api/v1`; `omniroute/auto` seleciona o provider custom e envia `auto` no wire. Chat, SSE, tools,
|
||||
reasoning, usage, imagens, discovery e retry foram validados. Sem publicação upstream.
|
||||
- `CLI-068` ZeroClaw: `config-only` pela família `custom`, com `uri=/v1`, modelo `auto`, wire Chat e
|
||||
`native_tools=true`. Responses é opt-in. Suite de provider, config, fmt e smoke HTTP passaram.
|
||||
Sem provider nominal ou publicação upstream.
|
||||
- `CLI-069` NanoClaw: `config-only` pelo provider Claude existente, apontando a raiz Anthropic do
|
||||
OmniRoute sem `/v1/messages` e usando OneCLI para a credencial. Codex e OpenCode têm bloqueios
|
||||
upstream reproduzidos (#3155/#1984/#2985) e ficam fora do caminho de produção atual.
|
||||
|
||||
Estado final P2.9: commits `0`, pushes `0`, forks `0`, PRs `0`, issues `0`, Discussions `0`.
|
||||
Progresso da pesquisa: `70/104` (`67,3%`); pendentes: `34/104` (`32,7%`). Próxima fila: P2.10
|
||||
(`CLI-070` PicoClaw, `CLI-071` IronClaw, `CLI-072` NullClaw).
|
||||
- `CLI-070` PicoClaw, `CLI-071` IronClaw e `CLI-072` NullClaw: localizar traits/registries e propor
|
||||
um provider pequeno com testes.
|
||||
- `CLI-073` Moltis, `CLI-074` GitClaw e `CLI-075` LionClaw: confirmar atividade, provider e comandos
|
||||
de validacao antes da publicacao.
|
||||
|
||||
### Onda 4 - P3, integracoes indiretas
|
||||
|
||||
- `CLI-076`, `CLI-077`, `CLI-078`, `CLI-079`, `CLI-080` e `CLI-081`: pesquisar forwarding de
|
||||
ambiente/configuracao para os agentes filhos;
|
||||
publicar wrapper ou docs somente quando houver um ponto de extensao real.
|
||||
- `CLI-082`, `CLI-083`, `CLI-084`, `CLI-085`, `CLI-086`, `CLI-087`, `CLI-088`, `CLI-089` e
|
||||
`CLI-090`: escolher ACP, MCP, launcher ou integracao do agente filho; nao apresentar uma
|
||||
integracao de orquestrador como provider de modelo.
|
||||
- `CLI-091` e `CLI-092`: tratar como interoperabilidade entre proxies; documentar loops, headers,
|
||||
auth e riscos antes de propor codigo.
|
||||
- `CLI-093` e `CLI-094`: integrar como broker/ferramenta MCP somente se isso estiver no escopo dos
|
||||
projetos.
|
||||
- `CLI-095` e `CLI-096`: configurar o agente filho e revisar a entrada existente de Agent Deck.
|
||||
|
||||
### Onda 5 - P4, fechados, EULA e MITM
|
||||
|
||||
- `CLI-097` Pool: confirmar o que a EULA permite; priorizar configuracao local e nao presumir PR.
|
||||
- `CLI-098` Junie CLI: pesquisar canal oficial de feedback; sem repositorio publico confirmado, nao
|
||||
existe fila de PR.
|
||||
- `CLI-099` Cursor desktop, `CLI-100` Windsurf, `CLI-101` Amp, `CLI-102` Amazon Q/Kiro CLI e
|
||||
`CLI-103` Cowork: tratar como MITM, configuracao de produto ou pedido oficial de feature. So mover
|
||||
para PR se um repositorio publico e uma politica de contribuicao forem comprovados.
|
||||
|
||||
## 7. Prompt operacional para cada subagente
|
||||
|
||||
O agente principal deve adaptar e enviar este prompt para cada item:
|
||||
|
||||
```text
|
||||
Voce e responsavel exclusivamente por CLI-NNN - <projeto> no repositorio <URL>.
|
||||
Voce nao esta sozinho no workspace: nao reverta, sobrescreva ou reorganize mudancas de outros
|
||||
agentes. Trabalhe somente na worktree isolada atribuida dentro de .claude/worktrees/ e nunca use
|
||||
git stash/pop.
|
||||
|
||||
Primeiro pesquise o upstream atual. Leia README, CONTRIBUTING, licenca, templates e instrucoes locais.
|
||||
Registre commit/release, arquitetura de providers, config/base URL, protocolo, auth, modelos,
|
||||
streaming, tool calling, issues/PRs existentes e comandos oficiais de build/test. A evidencia inicial
|
||||
do relatorio e uma hipotese, nao uma conclusao.
|
||||
|
||||
Antes de editar, classifique o caso como pr-provider, pr-generic, pr-docs, issue-first, config-only,
|
||||
adapter-acp, adapter-mcp, wrapper, needs-mitm ou blocked, com justificativa. Execute o baseline e
|
||||
registre falhas preexistentes. Se houver patch, trabalhe com teste primeiro e implemente somente a
|
||||
menor integracao coerente com o upstream. Confirme raiz versus /v1 versus /v1beta, autenticacao,
|
||||
modelo, streaming e tool calling conforme aplicavel.
|
||||
|
||||
Nao publique nada antes da revisao do agente principal. Entregue: pesquisa com links/caminhos,
|
||||
gate, baseline, diff, testes, smoke test sanitizado, riscos, branch/commit local se criados e a
|
||||
atualizacao proposta para 04-tracker-integracoes-clis.md. Nao invente dados e nao exponha chaves.
|
||||
```
|
||||
|
||||
## 8. Checklist de autorizacao para enviar uma PR
|
||||
|
||||
O agente principal somente autoriza a publicacao quando todas as respostas forem `sim` ou houver
|
||||
uma excecao registrada:
|
||||
|
||||
- [ ] O repositorio canonico e a branch-alvo foram confirmados.
|
||||
- [ ] A politica aceita o tipo de contribuicao planejado.
|
||||
- [ ] Issues/PRs duplicadas foram pesquisadas.
|
||||
- [ ] O commit/release de base esta registrado.
|
||||
- [ ] O gate de contribuicao esta justificado.
|
||||
- [ ] O baseline foi executado e falhas preexistentes estao separadas.
|
||||
- [ ] O patch e o menor necessario e segue a arquitetura upstream.
|
||||
- [ ] A base URL e sua regra de versao foram verificadas no codigo e em runtime.
|
||||
- [ ] Auth/modelos foram testados sem vazar segredo.
|
||||
- [ ] Streaming/tool calling foram testados ou marcados explicitamente como nao aplicaveis.
|
||||
- [ ] Testes, lint, format, typecheck/build relevantes foram executados.
|
||||
- [ ] A documentacao foi executada e corresponde ao codigo.
|
||||
- [ ] O diff nao contem caches, builds, logs, credenciais ou refactors sem relacao.
|
||||
- [ ] O titulo e o corpo seguem o template upstream e nao contêm marketing ou texto de IA.
|
||||
- [ ] O tracker esta pronto para receber branch, commit e URL reais.
|
||||
|
||||
## 9. Campos adicionais recomendados no tracker
|
||||
|
||||
O tracker atual deve continuar como fonte principal. Durante a execucao, registrar nas observacoes ou
|
||||
em uma nota individual:
|
||||
|
||||
- commit/release pesquisado;
|
||||
- decisao `pr-provider`, `pr-generic`, `pr-docs`, `issue-first`, `config-only`, adapter, wrapper,
|
||||
MITM ou bloqueio;
|
||||
- protocolo e regra da base URL;
|
||||
- comandos de baseline e resultado;
|
||||
- comandos finais e resultado;
|
||||
- smoke tests realizados;
|
||||
- arquivos modificados;
|
||||
- fork, branch e commit;
|
||||
- PR/issue e estado de CI/review;
|
||||
- limitacoes e proximo passo.
|
||||
|
||||
Campos ainda nao comprovados permanecem `—`.
|
||||
|
||||
## 10. Inicio recomendado
|
||||
|
||||
O primeiro ciclo de publicacao deve usar o lote P0.1:
|
||||
|
||||
1. `CLI-001` - Gemini CLI (`google-gemini/gemini-cli`)
|
||||
2. `CLI-002` - Claw Code (`ultraworkers/claw-code`)
|
||||
3. `CLI-003` - Plandex (`plandex-ai/plandex`)
|
||||
|
||||
Os tres subagentes fazem pesquisa fresca e implementacao em paralelo, mas nenhuma PR e enviada antes
|
||||
da revisao individual do agente principal. Ao publicar ou concluir config-only/bloqueio, atualizar o
|
||||
tracker e liberar os mesmos tres slots para o lote P0.2.
|
||||
|
||||
## Lote P2.10 iniciado em 2026-08-03
|
||||
|
||||
Base local: `release/v3.8.50` em `84b1e5e12f238269e698f400766230f985f4a07b`. Worktrees isoladas e um agente por upstream foram criadas para `CLI-070` PicoClaw, `CLI-071` IronClaw e `CLI-072` NullClaw. Nenhuma publicação está autorizada; os agentes devem pesquisar o HEAD atual, provar `config-only` ou RED→GREEN e registrar governança, gates, smoke e estado limpo.
|
||||
|
||||
Resultado P2.10:
|
||||
|
||||
- `CLI-070` PicoClaw: `config-only`, `openai/auto` com base `/api/v1`; Chat/SSE/tools/usage/images/discovery. Go ausente impediu execução local; monitorar #3298, sem PR.
|
||||
- `CLI-071` IronClaw: `config-only`, `openai_compatible` com `/api/v1` e `auto`; 889 testes do crate LLM, 5 de resolução e fmt passaram. Sem PR; reasoning proprietário segue limitado por #3673.
|
||||
- `CLI-072` NullClaw: `config-only`, provider custom com Chat Completions recomendado e Responses/Anthropic como alternativas. Zig ausente; CI do mesmo HEAD verde. Sem PR.
|
||||
|
||||
Estado final P2.10: commits `0`, pushes `0`, forks `0`, PRs `0`, issues `0`, Discussions `0`.
|
||||
Pesquisa acumulada: `73/104` (`70,2%`); pendentes: `31/104` (`29,8%`). Próxima fila: P2.11 (`CLI-073` Moltis, `CLI-074` GitClaw, `CLI-075` LionClaw).
|
||||
|
||||
Resultado P3.1:
|
||||
|
||||
- `CLI-076` VibePod: `config-only` pelo agente Claude Code com raiz Anthropic `/api`; wrapper injeta env no container. Codex sem chave automática permanece não comprovado.
|
||||
- `CLI-077` zeroshot: `config-only` pelo gateway OpenAI `/api/v1`; 22 testes focados verdes; limitações de streaming JSON, reasoning e MCP registradas.
|
||||
- `CLI-078` Fractal: `config-only` por Codex Responses em `CODEX_HOME` por node; servidores tmux quentes podem perder `OMNIROUTE_API_KEY`, recomendando fix genérico upstream.
|
||||
|
||||
Estado final P3.1: commits `0`, pushes `0`, forks `0`, PRs `0`, issues `0`, Discussions `0`. Pesquisa acumulada: `79/104` (`76,0%`); pendentes: `25/104` (`24,0%`).
|
||||
|
||||
Resultado P3.2: Bernstein `config-only` por openai_agents; Traycer `config-only` indireto pelo harness OpenCode; h5i `patch-required` porque auth proxy/egress são fixados em OpenAI. Nenhuma publicação externa. Pesquisa acumulada `82/104` (`78,8%`), pendentes `22/104` (`21,2%`).
|
||||
|
||||
Resultado P2.11:
|
||||
|
||||
- `CLI-073` Moltis: `config-only`, provider `custom-omniroute`, `/api/v1`, `auto`, Chat/SSE/tools e capacidades multimodais. 401 testes e fmt passaram. Sem publicação.
|
||||
- `CLI-074` GitClaw/GitAgent: `config-only`, loader OpenAI-compatible com `GITAGENT_MODEL_BASE_URL`, `OPENAI_API_KEY` e `omniroute:auto`. Build, 65 testes e smoke passaram. Sem publicação.
|
||||
- `CLI-075` LionClaw: `patch-required`/`issue-first`. O runtime Codex confinado não recebe `config.toml`/provider secret; preparar proposta genérica alinhada à [#157](https://github.com/moshthepitt/lionclaw/issues/157), sem PR até revisão do mantenedor.
|
||||
|
||||
Estado final P2.11: commits `0`, pushes `0`, forks `0`, PRs `0`, issues `0`, Discussions `0`. Pesquisa acumulada: `76/104` (`73,1%`); pendentes: `28/104` (`26,9%`).
|
||||
Resultado P3.3: OMK `viable-mcp`; kodo `config-only` indireto; ORCH `needs-wrapper`. Pesquisa acumulada `85/104` (`81,7%`), pendentes `19/104` (`18,3%`). Nenhuma publicação externa.
|
||||
|
||||
Resultado P3.4: LoopTroop `config-only` indireto via provider OpenCode; Galley `patch-required` por não possuir transport OpenAI-compatible configurável; Relay `config-only` via provider profile/Codex, condicionado a smoke da Responses API e controles sobre ferramentas nativas. Nenhuma publicação externa. Pesquisa acumulada `88/104` (`84,6%`), pendentes `16/104` (`15,4%`).
|
||||
|
||||
Resultado P3.5: SageCLI `config-only` indireto via Codex, com caveat de env plaintext; 5dive `patch-required` por mapas fechados de provider/base; agx `config-only` indireto via Codex e com gates de Responses/sandbox. Pesquisa acumulada `91/104` (`87,5%`), pendentes `13/104` (`12,5%`). Nenhuma publicação externa.
|
||||
|
||||
Resultado P3.6: claude-code-router, cc-router e OneCLI são config-only; os dois primeiros oferecem endpoints custom OpenAI-compatible e OneCLI injeta credenciais por proxy MITM. Pesquisa acumulada `94/104` (`90,4%`), pendentes `10/104` (`9,6%`). Nenhuma publicação externa.
|
||||
|
||||
Resultado P3.7: agent-browser `config-only` direto por Chat Completions; OpenWork `config-only` via OpenCode custom; Agent Deck `config-only` via CLIs filhos. Pesquisa acumulada `97/104` (`93,3%`), pendentes `7/104` (`6,7%`). Nenhuma publicação externa.
|
||||
|
||||
Resultado P4.1: Pool e Junie são `config-only` OpenAI-compatible; Cursor é `config-only` limitado ao BYO chat panel, sem MITM/protocolo privado. Pesquisa acumulada `100/104` (`96,2%`), pendentes `4/104` (`3,8%`). Nenhuma publicação externa.
|
||||
|
||||
Resultado P4.2: Windsurf está bloqueado para inferência e permite apenas MCP; Amp depende de confirmação Enterprise; Amazon Q legado requer patch substancial e Kiro atual é MCP-only seguro. Pesquisa acumulada `103/104` (`99,0%`), pendente `1/104` (`1,0%`). Nenhuma publicação externa.
|
||||
|
||||
Resultado P4.3: Cowork não permite substituir oficialmente a inferência; Custom Connector MCP remoto é o único caminho suportado e permanece separado do modelo. Pesquisa concluída `104/104` (`100%`), pendentes `0/104` (`0%`). Nenhuma publicação externa nesta fase de pesquisa.
|
||||
@@ -0,0 +1,131 @@
|
||||
# Relatório final — campanha de 104 integrações CLI OmniRoute
|
||||
|
||||
**Data de fechamento:** 2026-08-03
|
||||
**Escopo:** `CLI-000` a `CLI-103`
|
||||
**Resultado:** `104/104` pesquisados (`100%`), `0` pendentes de pesquisa.
|
||||
|
||||
## Como consultar o resultado individual
|
||||
|
||||
O documento autoritativo, com uma linha para cada caso, é o [tracker completo](./04-tracker-integracoes-clis.md). Ele contém para cada ID:
|
||||
|
||||
- prioridade;
|
||||
- projeto e repositório;
|
||||
- classificação de integração;
|
||||
- estado de contribuição upstream;
|
||||
- branch e commit quando existentes;
|
||||
- URL de PR e/ou issue quando publicados;
|
||||
- estado no catálogo OmniRoute;
|
||||
- observações, limitações, testes e próximo passo.
|
||||
|
||||
Além do tracker, existem fichas técnicas individuais em `_tasks/cli-integrations/`. A cobertura foi auditada e agora há uma ficha para cada ID `CLI-000`–`CLI-103`; o caso `CLI-000` jcode foi adicionado como ficha de referência nesta revisão.
|
||||
|
||||
## Resumo quantitativo
|
||||
|
||||
| Grupo operacional | Quantidade | Tratamento |
|
||||
|---|---:|---|
|
||||
| Configuração direta ou indireta | 76 | Documentar receita, validar smoke e só abrir PR se houver melhoria upstream real |
|
||||
| Contribuição upstream (PR/issue/docs/patch) | 17 | Preparar diff mínimo, validar, revisar e publicar conforme política do repositório |
|
||||
| Patch obrigatório | 4 | Implementar genericamente, com RED→GREEN/TDD e revisão do mantenedor |
|
||||
| Bloqueados/fechados | 4 | Registrar bloqueio; usar apenas MCP ou canal oficial, sem MITM |
|
||||
| MCP/wrapper/ACP como caminho principal | 2 | Integrar a camada de ferramentas/orquestração, sem falsificar provider de inferência |
|
||||
| Outros casos híbridos | 1 | Seguir a combinação específica descrita no tracker |
|
||||
|
||||
Os números são derivados do campo `Tipo` do tracker; categorias podem se sobrepor em casos híbridos. Atualmente há **7 PRs reais** e **9 issues reais** registrados no tracker, além de cinco entradas locais marcadas como integradas ao catálogo OmniRoute. Nenhum link foi inventado para os 97 casos sem publicação externa.
|
||||
|
||||
## O que foi feito na campanha
|
||||
|
||||
1. Inventário inicial e busca extensa de CLIs, runtimes, harnesses e control-planes.
|
||||
2. Priorização P0–P4 considerando compatibilidade de protocolo, adoção, licença, maturidade e risco.
|
||||
3. Pesquisa fresca, uma a uma, em worktrees isoladas, em lotes de no máximo três agentes.
|
||||
4. Uso de Codebase Memory para índices upstream e verificação de cobertura; faixas parciais foram lidas diretamente quando aplicável.
|
||||
5. Classificação por configuração, patch, PR documental, issue-first, MCP, wrapper ou bloqueio.
|
||||
6. Registro de comandos, base URL, autenticação, modelos, streaming, tools, reasoning, imagens, MCP/ACP/A2A, testes e limitações.
|
||||
7. Consolidação de cada lote com commit separado no OmniRoute e no repositório `_tasks`.
|
||||
8. Atualização final do tracker, plano de integração, plano de publicação e handoff.
|
||||
9. Nenhuma credencial real, publicação externa ou técnica de interceptação não autorizada foi utilizada.
|
||||
|
||||
## Estratégia para abrir PRs em 100% dos casos
|
||||
|
||||
“Abrir PR para 100%” deve ser interpretado como **dar um destino upstream apropriado a 100% dos casos**, e não criar 104 PRs artificiais. Há quatro trilhas:
|
||||
|
||||
### Trilha A — PR de código ou documentação
|
||||
|
||||
Aplicar aos casos `viable-upstream`, `pr-generic`, `pr-docs`, `patch-required` e híbridos que tenham superfície pública e política de contribuição compatível.
|
||||
|
||||
Processo por caso:
|
||||
|
||||
1. Reconfirmar HEAD, licença, branch default, política de contribuição e duplicatas.
|
||||
2. Criar worktree/branch baseada na versão local vigente.
|
||||
3. Executar baseline upstream e registrar falhas preexistentes.
|
||||
4. Escrever teste RED que demonstre a lacuna.
|
||||
5. Implementar o menor patch genérico possível — preferir `openai-compatible`, `base_url` ou provider abstrato a um provider nominal OmniRoute.
|
||||
6. Executar GREEN: testes focados, suite upstream, lint, format, typecheck/build e smoke com fake server ou OmniRoute local usando placeholder.
|
||||
7. Revisar segurança: nenhuma chave em argv, logs, fixtures, URL ou artefato; erros sanitizados; streaming/tools/cancelamento cobertos.
|
||||
8. Abrir PR somente se contribuições externas forem aceitas. O corpo deve explicar problema, solução genérica, compatibilidade, testes, limitações e não conter marketing/texto de IA.
|
||||
9. Se o repositório bloquear fork/PR ou pedir discussão prévia, abrir issue de proposta com o mesmo patch/reprodução, sem enviar PR prematuramente.
|
||||
10. Atualizar tracker com branch, commit, URL, CI, revisão e resposta do mantenedor; acompanhar até `accepted`, `merged`, `rejected` ou `awaiting-maintainer`.
|
||||
|
||||
### Trilha B — Issue-first, discussão ou suporte ao mantenedor
|
||||
|
||||
Aplicar quando a arquitetura é adequada, mas há bloqueio de governança, firewall, CLA, fork fechado, dúvida de protocolo ou necessidade de decisão do autor. A issue deve conter:
|
||||
|
||||
- caso de uso OmniRoute;
|
||||
- configuração atualmente possível;
|
||||
- lacuna reproduzível;
|
||||
- proposta genérica;
|
||||
- impacto de segurança;
|
||||
- testes/fake server;
|
||||
- disposição para enviar PR após aprovação.
|
||||
|
||||
Não abrir uma PR paralela enquanto a política exigir issue-first.
|
||||
|
||||
### Trilha C — Config-only documentado
|
||||
|
||||
Aplicar aos casos em que o upstream já suporta a integração e uma mudança de código seria redundante. O entregável é:
|
||||
|
||||
- ficha individual;
|
||||
- receita validada;
|
||||
- smoke test e limitações;
|
||||
- eventual documentação externa/local do OmniRoute;
|
||||
- issue somente se houver pedido de documentação ou descoberta de bug real.
|
||||
|
||||
Não criar provider nominal ou PR apenas para adicionar a palavra “OmniRoute”.
|
||||
|
||||
### Trilha D — MCP, wrapper ou bloqueio seguro
|
||||
|
||||
Aplicar a control-planes, produtos fechados e CLIs sem rota de inferência substituível. O resultado pode ser:
|
||||
|
||||
- MCP remoto/stdio do OmniRoute;
|
||||
- wrapper local claramente identificado como wrapper;
|
||||
- solicitação oficial de custom provider;
|
||||
- registro de bloqueio e gate legal/ToS.
|
||||
|
||||
Nunca mascarar OmniRoute como Claude/Codex, falsificar executável, interceptar TLS ou reutilizar tokens privados para fabricar uma PR upstream.
|
||||
|
||||
## Ordem recomendada de execução
|
||||
|
||||
1. **Primeiro:** PRs e issues já preparadas ou com alto retorno e baixo risco — jcode, Gemini CLI, Claw Code, Plandex, Trae Agent, Every Code, VT Code e CoreCoder.
|
||||
2. **Segundo:** patches genéricos com boa superfície OSS — AutoCodeRover, Galley, 5dive e demais casos `pr-generic`/`patch-required`.
|
||||
3. **Terceiro:** issues aguardando decisão — Open Codex, Kimi CLI, Devon, g3, Free Code, Claude Engineer e casos com `awaiting-maintainer`.
|
||||
4. **Quarto:** documentação e receitas config-only agrupadas por ecossistema — OpenCode, Codex, LiteLLM, AI SDK, OpenAI-compatible e Anthropic-compatible.
|
||||
5. **Quinto:** MCP/plugins para produtos fechados — Windsurf, Amp, Kiro, Cowork e Cursor, sempre pela superfície oficial.
|
||||
|
||||
Cada rodada deve manter no máximo três agentes ativos. O agente principal revisa o resultado do trio antes de liberar o próximo.
|
||||
|
||||
## Critério de encerramento por caso
|
||||
|
||||
Um caso só pode ser marcado como finalizado quando possui: pesquisa, classificação, evidência de protocolo, baseline ou limitação reproduzível, receita/patch/bloqueio, validação proporcional, estado de publicação e próximo passo. Para produtos fechados, `blocked-closed` ou `MCP-only` é um resultado válido e preferível a uma PR não autorizada.
|
||||
|
||||
## Estado de publicação atual
|
||||
|
||||
Os únicos links de publicação comprovados devem continuar sendo os registrados no tracker. O fato de existir uma branch local de pesquisa não significa que exista PR upstream. A matriz de verdade é:
|
||||
|
||||
- PR/issue preenchida: publicação real;
|
||||
- campo `—`: nenhuma publicação externa comprovada;
|
||||
- `not-applicable`: configuração ou bloqueio sem contribuição upstream;
|
||||
- `awaiting-maintainer`: contato feito, aguardando decisão;
|
||||
- `published-pr`/`published-issue`: URL real presente no tracker.
|
||||
|
||||
## Próxima fase
|
||||
|
||||
A pesquisa está encerrada. A próxima fase é execução controlada da Trilha A/B/C/D, começando pelos casos com maior retorno e menor risco, com revisão central antes de qualquer push, PR, issue ou contato externo.
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(here, "..");
|
||||
|
||||
export function resolveChatGptWebCodexMcpEntry(rootDir = root, exists = existsSync) {
|
||||
const candidates = [
|
||||
join(
|
||||
rootDir,
|
||||
"dist",
|
||||
"open-sse",
|
||||
"vendor",
|
||||
"codex-chatgpt-web",
|
||||
"adapters",
|
||||
"chatgpt-web",
|
||||
"mcp-server.js"
|
||||
),
|
||||
join(
|
||||
rootDir,
|
||||
"open-sse",
|
||||
"vendor",
|
||||
"codex-chatgpt-web",
|
||||
"adapters",
|
||||
"chatgpt-web",
|
||||
"mcp-server.ts"
|
||||
),
|
||||
];
|
||||
return candidates.find((candidate) => exists(candidate)) ?? null;
|
||||
}
|
||||
|
||||
export async function startChatGptWebCodexMcp(args = process.argv.slice(2), rootDir = root) {
|
||||
const socketIndex = args.indexOf("--broker-socket");
|
||||
const brokerSocketPath = socketIndex >= 0 ? args[socketIndex + 1] : undefined;
|
||||
if (!brokerSocketPath) throw new Error("--broker-socket is required");
|
||||
const entry = resolveChatGptWebCodexMcpEntry(rootDir);
|
||||
if (!entry) throw new Error("ChatGPT Web (Codex) MCP entrypoint was not found");
|
||||
if (entry.endsWith(".ts")) {
|
||||
const { register } = await import("node:module");
|
||||
register("tsx/esm", pathToFileURL(`${rootDir}/`));
|
||||
}
|
||||
const module = await import(pathToFileURL(entry).href);
|
||||
await module.runChatGptMcpServer({ brokerSocketPath });
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
startChatGptWebCodexMcp().catch((error) => {
|
||||
console.error(
|
||||
`ChatGPT Web (Codex) MCP konnte nicht gestartet werden: ${error?.message || error}`
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -136,7 +136,7 @@ export const RETRY_DEFAULTS = {
|
||||
|
||||
- Every user-facing string goes through `t("module.key", vars)`.
|
||||
- Catalogs live in `bin/cli/locales/{locale}.json` (nested objects).
|
||||
43 files ship out-of-the-box: `en`, `pt-BR`, and 41 additional locales.
|
||||
42 files ship out-of-the-box: `en`, `pt-BR`, and 40 additional locales.
|
||||
11 locales are scaffold-only (empty `{}`); all keys fall back to `en` automatically.
|
||||
- Detection order: `--lang` flag → `OMNIROUTE_LANG` env → `LC_ALL` → `LC_MESSAGES` → `LANG` → `en`.
|
||||
- Locale persisted via `config lang set <code>` — saves `OMNIROUTE_LANG` to `~/.omniroute/.env`.
|
||||
|
||||
@@ -22,9 +22,9 @@ bin/cli/
|
||||
├── provider-test.mjs ← testProviderApiKey()
|
||||
├── settings-store.mjs ← DB CRUD for key_value settings
|
||||
├── locales/
|
||||
│ ├── en.json ← English strings (source of truth, 43 locales)
|
||||
│ ├── en.json ← English strings (source of truth, 42+ locales)
|
||||
│ ├── pt-BR.json ← Portuguese (Brazil) — fully translated
|
||||
│ └── {locale}.json ← 42 additional locales (ar, az, de, es, fr, ja, zh-CN, …)
|
||||
│ └── {locale}.json ← 40 additional locales (ar, az, de, es, fr, ja, zh-CN, …)
|
||||
├── scripts/
|
||||
│ └── generate-locales.mjs ← scaffold new locale files from config/i18n.json
|
||||
└── commands/
|
||||
|
||||
@@ -30,60 +30,20 @@ export function register_combos(parent) {
|
||||
const data = res.ok ? await res.json() : await res.text();
|
||||
emit(data, gOpts);
|
||||
});
|
||||
tag.command("get-api-combos-id-")
|
||||
.description("Get combo by ID")
|
||||
.requiredOption("--id <id>", "")
|
||||
.action(async (opts, cmd) => {
|
||||
const gOpts = cmd.optsWithGlobals();
|
||||
let url = "/api/combos/{id}";
|
||||
url = url.replace("{id}", encodeURIComponent(opts.id ?? ""));
|
||||
const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey });
|
||||
const data = res.ok ? await res.json() : await res.text();
|
||||
emit(data, gOpts);
|
||||
});
|
||||
tag.command("put-api-combos-id-")
|
||||
.description("Update combo")
|
||||
.requiredOption("--id <id>", "")
|
||||
.option("--body <jsonOrPath>", "JSON body or @path/to/file.json")
|
||||
.action(async (opts, cmd) => {
|
||||
const gOpts = cmd.optsWithGlobals();
|
||||
let url = "/api/combos/{id}";
|
||||
url = url.replace("{id}", encodeURIComponent(opts.id ?? ""));
|
||||
let body;
|
||||
if (opts.body) {
|
||||
body = opts.body.startsWith("@")
|
||||
? JSON.parse(readFileSync(opts.body.slice(1), "utf8"))
|
||||
: JSON.parse(opts.body);
|
||||
}
|
||||
const res = await apiFetch(url, { method: "PUT", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey });
|
||||
const data = res.ok ? await res.json() : await res.text();
|
||||
emit(data, gOpts);
|
||||
});
|
||||
tag.command("patch-api-combos-id-")
|
||||
.description("Update combo")
|
||||
.requiredOption("--id <id>", "")
|
||||
.option("--body <jsonOrPath>", "JSON body or @path/to/file.json")
|
||||
.action(async (opts, cmd) => {
|
||||
const gOpts = cmd.optsWithGlobals();
|
||||
let url = "/api/combos/{id}";
|
||||
url = url.replace("{id}", encodeURIComponent(opts.id ?? ""));
|
||||
let body;
|
||||
if (opts.body) {
|
||||
body = opts.body.startsWith("@")
|
||||
? JSON.parse(readFileSync(opts.body.slice(1), "utf8"))
|
||||
: JSON.parse(opts.body);
|
||||
}
|
||||
const res = await apiFetch(url, { method: "PATCH", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey });
|
||||
const res = await apiFetch(url, { method: "PATCH", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey });
|
||||
const data = res.ok ? await res.json() : await res.text();
|
||||
emit(data, gOpts);
|
||||
});
|
||||
tag.command("delete-api-combos-id-")
|
||||
.description("Delete combo")
|
||||
.requiredOption("--id <id>", "")
|
||||
.action(async (opts, cmd) => {
|
||||
const gOpts = cmd.optsWithGlobals();
|
||||
let url = "/api/combos/{id}";
|
||||
url = url.replace("{id}", encodeURIComponent(opts.id ?? ""));
|
||||
const res = await apiFetch(url, { method: "DELETE", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey });
|
||||
const data = res.ok ? await res.json() : await res.text();
|
||||
emit(data, gOpts);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { getCliToken, CLI_TOKEN_HEADER } from "./utils/cliToken.mjs";
|
||||
import { resolveActiveContext, resolveActiveContextAsync } from "./contexts.mjs";
|
||||
import { resolveActiveContext } from "./contexts.mjs";
|
||||
|
||||
export const RETRY_DEFAULTS = Object.freeze({
|
||||
maxAttempts: 3,
|
||||
@@ -52,19 +52,6 @@ function resolveUrl(path, opts) {
|
||||
return `${getBaseUrl(opts)}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
/** The machine-derived token is valid only for the local loopback server. */
|
||||
export function isLoopbackUrl(value) {
|
||||
try {
|
||||
const hostname = new URL(value).hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
||||
if (hostname === "localhost" || hostname === "::1") return true;
|
||||
if (/^127(?:\.[0-9]{1,3}){3}$/.test(hostname)) return true;
|
||||
if (/^::ffff:(?:127\.|7f[0-9a-f]{2}:)/i.test(hostname)) return true;
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildHeaders(opts) {
|
||||
const headers = new Headers(opts.headers || {});
|
||||
if (!headers.has("accept")) headers.set("accept", "application/json");
|
||||
@@ -90,7 +77,7 @@ export async function buildHeaders(opts) {
|
||||
let auth = explicitKey;
|
||||
if (!auth) {
|
||||
try {
|
||||
const ctx = await resolveActiveContextAsync(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
auth = ctx?.accessToken || ctx?.apiKey || null;
|
||||
} catch {
|
||||
// No context credential available — fall through to the ambient fallback.
|
||||
@@ -100,17 +87,10 @@ export async function buildHeaders(opts) {
|
||||
if (auth && !headers.has("authorization")) {
|
||||
headers.set("authorization", `Bearer ${auth}`);
|
||||
}
|
||||
// Inject the machine-derived credential only for an explicit local loopback
|
||||
// destination. Remote contexts and absolute remote URLs use scoped access
|
||||
// tokens and must never receive this machine-bound local credential.
|
||||
const destinationUrl = opts.destinationUrl ?? getBaseUrl(opts);
|
||||
if (!isLoopbackUrl(destinationUrl)) {
|
||||
headers.delete(CLI_TOKEN_HEADER);
|
||||
} else {
|
||||
const cliToken = opts.cliToken ?? process.env.OMNIROUTE_CLI_TOKEN ?? (await getCliToken());
|
||||
if (cliToken && !headers.has(CLI_TOKEN_HEADER)) {
|
||||
headers.set(CLI_TOKEN_HEADER, cliToken);
|
||||
}
|
||||
// Inject machine-id derived CLI token; env var override for testing.
|
||||
const cliToken = opts.cliToken ?? process.env.OMNIROUTE_CLI_TOKEN ?? (await getCliToken());
|
||||
if (cliToken && !headers.has(CLI_TOKEN_HEADER)) {
|
||||
headers.set(CLI_TOKEN_HEADER, cliToken);
|
||||
}
|
||||
if (opts.idempotencyKey && !headers.has("idempotency-key")) {
|
||||
headers.set("idempotency-key", opts.idempotencyKey);
|
||||
@@ -159,21 +139,6 @@ export function shouldRetryError(err, opts = {}) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a non-2xx status means "this server does not serve this route"
|
||||
* rather than "your request was wrong".
|
||||
*
|
||||
* Commands that keep a local SQLite fallback must not treat these as fatal:
|
||||
* a CLI newer (or older) than the server it is talking to will hit routes that
|
||||
* simply are not mounted, and aborting there strands the user with an
|
||||
* unactionable `HTTP 404` even though the local path would have worked.
|
||||
* Genuine client errors (400/401/403/409/422 …) stay fatal — retrying them
|
||||
* locally would paper over a real problem.
|
||||
*/
|
||||
export function isRouteUnavailableStatus(status) {
|
||||
return status === 404 || status === 405 || status === 501;
|
||||
}
|
||||
|
||||
export function statusToExitCode(status) {
|
||||
if (status >= 200 && status < 300) return 0;
|
||||
if (status === 408) return 124;
|
||||
@@ -215,12 +180,8 @@ function fetchOnce(url, init, timeoutMs) {
|
||||
export async function apiFetch(path, opts = {}) {
|
||||
const method = String(opts.method || "GET").toUpperCase();
|
||||
const url = resolveUrl(path, opts);
|
||||
const headers = await buildHeaders({ ...opts, destinationUrl: url });
|
||||
const headers = await buildHeaders(opts);
|
||||
const body = serializeBody(opts.body, headers);
|
||||
// Undici preserves custom headers across cross-origin redirects. A local server
|
||||
// redirect must never turn the loopback machine credential into an outbound
|
||||
// secret, so fail redirects whenever this header is present.
|
||||
const redirect = headers.has(CLI_TOKEN_HEADER) ? "error" : opts.redirect;
|
||||
const timeout =
|
||||
opts.timeout ?? (Number.parseInt(process.env.OMNIROUTE_HTTP_TIMEOUT_MS || "", 10) || 30000);
|
||||
const maxAttempts = opts.retry === false ? 1 : (opts.retryMax ?? RETRY_DEFAULTS.maxAttempts);
|
||||
@@ -229,7 +190,7 @@ export async function apiFetch(path, opts = {}) {
|
||||
let lastErr;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
const res = await fetchOnce(url, { method, headers, body, redirect }, timeout);
|
||||
const res = await fetchOnce(url, { method, headers, body }, timeout);
|
||||
if (res.ok) return enrichResponse(res, opts);
|
||||
if (attempt < maxAttempts && shouldRetryStatus(res.status, method, opts)) {
|
||||
const delay = computeBackoff(attempt, res.headers.get("retry-after"));
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
/**
|
||||
* Canonical executable manifest for the OmniRoute CLI command surfaces.
|
||||
*
|
||||
* One entry per canonical target id. `run.mjs`, `configure.mjs` and
|
||||
* `completion.mjs` derive their target lists, alias resolution and model-flag
|
||||
* wiring from this table instead of keeping private copies, so a new target
|
||||
* (or a renamed alias) is declared exactly once.
|
||||
*
|
||||
* The server-side runtime catalog (`src/shared/services/cliRuntime.ts`) stays
|
||||
* the source of truth for binaries, config paths and health checks; the drift
|
||||
* test `tests/unit/cli/cli-manifest-drift.test.ts` asserts the two worlds and
|
||||
* every consumer surface stay in sync.
|
||||
*
|
||||
* Capability semantics:
|
||||
* - `run`: launchable through `omniroute run <target>`.
|
||||
* - `configure`: supported by the `omniroute configure <target>` picker.
|
||||
* - `runModel`: how `run` injects `--model` for the target (`null` when the
|
||||
* model travels via env/provider args instead of a CLI flag).
|
||||
*/
|
||||
|
||||
export const CLI_TARGET_MANIFEST = Object.freeze({
|
||||
claude: Object.freeze({
|
||||
description: "Claude Code",
|
||||
aliases: Object.freeze(["claude-code", "cc", "anthropic"]),
|
||||
run: true,
|
||||
configure: true,
|
||||
runModel: null, // injected via ANTHROPIC_MODEL env by the launcher
|
||||
}),
|
||||
codex: Object.freeze({
|
||||
description: "OpenAI Codex CLI",
|
||||
aliases: Object.freeze(["codex-cli", "openai-codex", "openai"]),
|
||||
run: true,
|
||||
configure: true,
|
||||
runModel: null, // injected via -c model_providers.omniroute.* args
|
||||
}),
|
||||
aider: Object.freeze({
|
||||
description: "Aider",
|
||||
aliases: Object.freeze([]),
|
||||
run: true,
|
||||
configure: true,
|
||||
runModel: Object.freeze({ flag: "--model", prefix: "openai/" }),
|
||||
}),
|
||||
goose: Object.freeze({
|
||||
description: "Goose",
|
||||
aliases: Object.freeze(["goose-cli"]),
|
||||
run: true,
|
||||
configure: true,
|
||||
runModel: null, // injected via GOOSE_MODEL env
|
||||
}),
|
||||
opencode: Object.freeze({
|
||||
description: "OpenCode",
|
||||
aliases: Object.freeze(["open-code"]),
|
||||
run: true,
|
||||
configure: true,
|
||||
runModel: Object.freeze({ flag: "--model", prefix: "omniroute/" }),
|
||||
}),
|
||||
qwen: Object.freeze({
|
||||
description: "Qwen Code",
|
||||
aliases: Object.freeze(["qwen-code"]),
|
||||
run: true,
|
||||
configure: true,
|
||||
runModel: Object.freeze({ flag: "--model", prefix: "", required: true }),
|
||||
}),
|
||||
gemini: Object.freeze({
|
||||
// Launch contract verified against @google/gemini-cli 0.50.0:
|
||||
// GOOGLE_GEMINI_BASE_URL points the SDK at OmniRoute's /v1beta surface,
|
||||
// GEMINI_API_KEY + isolated GEMINI_CLI_HOME (settings selectedType
|
||||
// "gemini-api-key") force API-key auth over any stored OAuth session.
|
||||
description: "Google Gemini CLI",
|
||||
aliases: Object.freeze(["gemini-cli"]),
|
||||
run: true,
|
||||
configure: false,
|
||||
runModel: Object.freeze({ flag: "--model", prefix: "" }),
|
||||
}),
|
||||
cline: Object.freeze({
|
||||
description: "Cline",
|
||||
aliases: Object.freeze([]),
|
||||
run: false,
|
||||
configure: true,
|
||||
runModel: null,
|
||||
}),
|
||||
continue: Object.freeze({
|
||||
description: "Continue",
|
||||
aliases: Object.freeze(["cn"]),
|
||||
run: false,
|
||||
configure: true,
|
||||
runModel: null,
|
||||
}),
|
||||
kilo: Object.freeze({
|
||||
description: "Kilo Code",
|
||||
aliases: Object.freeze(["kilocode", "kilo-code", "kilo_cli"]),
|
||||
run: false,
|
||||
configure: true,
|
||||
runModel: null,
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* List canonical target ids, optionally filtered by capability
|
||||
* (`"run"` or `"configure"`). Order follows manifest declaration order.
|
||||
*/
|
||||
export function listManifestTargets(capability) {
|
||||
return Object.entries(CLI_TARGET_MANIFEST)
|
||||
.filter(([, entry]) => !capability || entry[capability])
|
||||
.map(([id]) => id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a user-supplied target (canonical id or alias) to its canonical id.
|
||||
* Returns `undefined` when the target is unknown or lacks the capability.
|
||||
*/
|
||||
export function resolveManifestTarget(rawTarget, capability) {
|
||||
const normalized = String(rawTarget || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!normalized) return undefined;
|
||||
for (const [id, entry] of Object.entries(CLI_TARGET_MANIFEST)) {
|
||||
if (id === normalized || entry.aliases.includes(normalized)) {
|
||||
if (capability && !entry[capability]) return undefined;
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Model CLI-flag arguments for a `run` target, derived from the manifest. */
|
||||
export function manifestModelArgs(targetId, model) {
|
||||
if (!model) return [];
|
||||
const spec = CLI_TARGET_MANIFEST[targetId]?.runModel;
|
||||
if (!spec) return [];
|
||||
const value = spec.prefix && !model.startsWith(spec.prefix) ? `${spec.prefix}${model}` : model;
|
||||
return [spec.flag, value];
|
||||
}
|
||||
|
||||
/** Whether a `run` target refuses to launch without an explicit model. */
|
||||
export function manifestRequiresModel(targetId) {
|
||||
return Boolean(CLI_TARGET_MANIFEST[targetId]?.runModel?.required);
|
||||
}
|
||||
@@ -22,15 +22,8 @@ const VALID_FORMATS = new Set(["json", "env"]);
|
||||
const SECURE_FILE_MODE = 0o600;
|
||||
|
||||
export function registerAuthExport(program) {
|
||||
// #11226: `.command("auth export")` does NOT register a two-word command — commander
|
||||
// parses the bare word `export` as a required positional argument of `auth`, so the
|
||||
// action received (exportArgValue, options, command) while expecting (options, command)
|
||||
// and crashed with "cmd.optsWithGlobals is not a function". Register `export` as a
|
||||
// proper nested subcommand instead; the CLI surface stays `omniroute auth export`.
|
||||
program
|
||||
.command("auth")
|
||||
.description(t("authExport.description"))
|
||||
.command("export")
|
||||
.command("auth export")
|
||||
.description(t("authExport.description"))
|
||||
.option("--id <id>", t("authExport.idOpt"))
|
||||
.option("--format <format>", t("authExport.formatOpt"), "json")
|
||||
|
||||
@@ -3,9 +3,7 @@ import { printHeading } from "../io.mjs";
|
||||
import { withRuntime } from "../runtime.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { mcpCallTool } from "../mcpClient.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { resolveComboModels, collectModel } from "./comboModels.mjs";
|
||||
|
||||
const VALID_STRATEGIES = [
|
||||
"priority",
|
||||
@@ -64,7 +62,15 @@ export function extendComboSuggest(combo) {
|
||||
weights: opts.weights ? JSON.parse(opts.weights) : undefined,
|
||||
top: opts.top,
|
||||
};
|
||||
const data = await mcpCallTool("omniroute_best_combo_for_task", body);
|
||||
const res = await apiFetch("/api/mcp/tools/call", {
|
||||
method: "POST",
|
||||
body: { name: "omniroute_best_combo_for_task", arguments: body },
|
||||
});
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const data = await res.json();
|
||||
const candidates = data.candidates ?? data;
|
||||
const rows = (Array.isArray(candidates) ? candidates : []).map((c, i) => ({
|
||||
rank: i + 1,
|
||||
@@ -119,31 +125,10 @@ export function registerCombo(program) {
|
||||
.choices(VALID_STRATEGIES)
|
||||
.default("priority")
|
||||
)
|
||||
.option(
|
||||
"--models <spec>",
|
||||
"Models for the combo: comma-separated provider/model entries, or a JSON array " +
|
||||
'(e.g. --models "openai/gpt-4o,anthropic/claude-3-opus" or ' +
|
||||
'--models \'[{"model":"gpt-4o","providerId":"openai"}]\')'
|
||||
)
|
||||
.option(
|
||||
"--model <spec>",
|
||||
"Add one model to the combo (provider/model or bare model id) — repeatable",
|
||||
collectModel,
|
||||
[]
|
||||
)
|
||||
.action(async (name, opts, cmd) => {
|
||||
const globalOpts = cmd.parent.optsWithGlobals();
|
||||
let models;
|
||||
try {
|
||||
models = resolveComboModels(opts);
|
||||
} catch (err) {
|
||||
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
const exitCode = await runComboCreateCommand(name, opts.strategy, {
|
||||
...opts,
|
||||
models,
|
||||
output: globalOpts.output,
|
||||
});
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
@@ -167,7 +152,6 @@ export async function runComboListCommand(opts = {}) {
|
||||
return await withRuntime(async ({ kind, api, db }) => {
|
||||
let combos = [];
|
||||
let activeCombo = null;
|
||||
let listError = null;
|
||||
|
||||
if (kind === "http") {
|
||||
const [listRes, activeRes] = await Promise.all([
|
||||
@@ -177,12 +161,6 @@ export async function runComboListCommand(opts = {}) {
|
||||
if (listRes.ok) {
|
||||
const data = await listRes.json();
|
||||
combos = Array.isArray(data) ? data : (data.combos ?? []);
|
||||
} else {
|
||||
// The server answered, but not with a combo list. Falling through to
|
||||
// an empty array here rendered "No combos configured" — which is
|
||||
// indistinguishable from genuine emptiness and reads as real state,
|
||||
// so a transport/auth failure looked like a wiped configuration.
|
||||
listError = listRes.status;
|
||||
}
|
||||
if (activeRes.ok) {
|
||||
const settings = await activeRes.json();
|
||||
@@ -193,25 +171,11 @@ export async function runComboListCommand(opts = {}) {
|
||||
}
|
||||
|
||||
if (opts.json || opts.output === "json") {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ combos, active: activeCombo, error: listError && `HTTP ${listError}` },
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
return listError ? 1 : 0;
|
||||
console.log(JSON.stringify({ combos, active: activeCombo }, null, 2));
|
||||
return 0;
|
||||
}
|
||||
|
||||
printHeading(t("combo.title"));
|
||||
if (listError) {
|
||||
console.error(
|
||||
t("common.error", {
|
||||
message: `could not list combos from the server (HTTP ${listError})`,
|
||||
})
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
if (combos.length === 0) {
|
||||
console.log(t("combo.noCombos"));
|
||||
return 0;
|
||||
@@ -299,20 +263,12 @@ export async function runComboCreateCommand(name, strategy = "priority", opts =
|
||||
return 1;
|
||||
}
|
||||
|
||||
const models = Array.isArray(opts.models) ? opts.models : [];
|
||||
if (!models.length) {
|
||||
console.error(
|
||||
"combo create requires at least one target. Pass --models <provider/model,...> and/or repeat --model <provider/model>."
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
return await withRuntime(async ({ kind, api, db }) => {
|
||||
if (kind === "http") {
|
||||
const res = await api("/api/combos", {
|
||||
method: "POST",
|
||||
body: { name, strategy, enabled: true, models, config: {} },
|
||||
body: { name, strategy, enabled: true, models: [], config: {} },
|
||||
retry: false,
|
||||
acceptNotOk: true,
|
||||
});
|
||||
@@ -328,7 +284,7 @@ export async function runComboCreateCommand(name, strategy = "priority", opts =
|
||||
console.error(`Combo '${name}' already exists. Delete it first.`);
|
||||
return 1;
|
||||
}
|
||||
await db.combos.createCombo({ name, strategy, enabled: true, models, config: {} });
|
||||
await db.combos.createCombo({ name, strategy, enabled: true, models: [], config: {} });
|
||||
}
|
||||
|
||||
console.log(t("combo.created", { name }));
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
// Parses the `--models` / `--model` options for `omniroute combo create` (#10954).
|
||||
//
|
||||
// Root cause of #10954: `combo create` only ever registered `--strategy`; the
|
||||
// HTTP body (POST /api/combos) and the local-db fallback (db.combos.createCombo)
|
||||
// both hardcoded `models: []`, so every combo created via the CLI came out
|
||||
// empty regardless of what the operator intended to route to.
|
||||
//
|
||||
// Accepted shapes mirror the server-side Zod union in
|
||||
// `src/shared/validation/schemas/combo.ts` (`comboModelEntry` /
|
||||
// `createComboSchema.models`) so a CLI-built payload never gets rejected by
|
||||
// the API that ultimately validates it:
|
||||
// - a plain string ("provider/model" or a bare model id) — the server's
|
||||
// `normalizeComboModels` (src/lib/combos/steps.ts) already splits the
|
||||
// leading "provider/" segment off a plain string, so passing the raw
|
||||
// token through is sufficient for the common case;
|
||||
// - a structured `{ kind?: "model", model, providerId?, provider?, ... }`
|
||||
// object;
|
||||
// - a structured `{ kind: "combo-ref", comboName, ... }` object (nested
|
||||
// combo reference).
|
||||
//
|
||||
// The CLI (bin/cli/**) ships as plain `.mjs` with relative-only imports — no
|
||||
// `@/` path aliases and no TS transpilation at runtime — so importing the
|
||||
// real Zod schema from `src/shared/validation/schemas/combo.ts` is not
|
||||
// viable here. This module instead validates the same minimal shape by hand
|
||||
// and stays a thin, independently testable unit.
|
||||
|
||||
/**
|
||||
* Validates one already-parsed combo model entry against the shape accepted
|
||||
* by `comboModelEntry` (string | model-step | combo-ref). Throws with a
|
||||
* 1-based, human-readable position when the entry does not match.
|
||||
*
|
||||
* @param {unknown} entry
|
||||
* @param {number} index
|
||||
* @returns {string | Record<string, unknown>}
|
||||
*/
|
||||
export function validateComboModelEntryShape(entry, index) {
|
||||
const position = index + 1;
|
||||
|
||||
if (typeof entry === "string") {
|
||||
const trimmed = entry.trim();
|
||||
if (trimmed.length === 0) {
|
||||
throw new Error(`--models entry #${position}: empty model string`);
|
||||
}
|
||||
if (trimmed.length > 300) {
|
||||
throw new Error(`--models entry #${position}: model string exceeds 300 characters`);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
throw new Error(`--models entry #${position}: must be a string or a JSON object`);
|
||||
}
|
||||
|
||||
const kind = entry.kind;
|
||||
|
||||
if (kind === "combo-ref") {
|
||||
if (typeof entry.comboName !== "string" || entry.comboName.trim().length === 0) {
|
||||
throw new Error(
|
||||
`--models entry #${position}: kind "combo-ref" requires a non-empty "comboName"`
|
||||
);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
if (kind !== undefined && kind !== "model") {
|
||||
throw new Error(`--models entry #${position}: unknown "kind" value ${JSON.stringify(kind)}`);
|
||||
}
|
||||
|
||||
if (typeof entry.model !== "string" || entry.model.trim().length === 0) {
|
||||
throw new Error(`--models entry #${position}: requires a non-empty "model"`);
|
||||
}
|
||||
if (entry.providerId !== undefined && typeof entry.providerId !== "string") {
|
||||
throw new Error(`--models entry #${position}: "providerId" must be a string`);
|
||||
}
|
||||
if (entry.provider !== undefined && typeof entry.provider !== "string") {
|
||||
throw new Error(`--models entry #${position}: "provider" must be a string`);
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses one `--models` spec — either a JSON array (`--models '[{"model":"gpt-4o"}]'`)
|
||||
* or a comma-separated list of provider/model tokens
|
||||
* (`--models 'openai/gpt-4o,anthropic/claude-3-opus'`) — into an array of
|
||||
* combo model entries.
|
||||
*
|
||||
* @param {string} spec
|
||||
* @returns {Array<string | Record<string, unknown>>}
|
||||
*/
|
||||
export function parseModelsSpec(spec) {
|
||||
const trimmed = String(spec ?? "").trim();
|
||||
if (trimmed.length === 0) return [];
|
||||
|
||||
if (trimmed.startsWith("[")) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch (err) {
|
||||
throw new Error(`--models: invalid JSON array (${err.message})`);
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error("--models: JSON value must be an array");
|
||||
}
|
||||
return parsed.map((entry, i) => validateComboModelEntryShape(entry, i));
|
||||
}
|
||||
|
||||
return trimmed
|
||||
.split(",")
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token.length > 0)
|
||||
.map((token, i) => validateComboModelEntryShape(token, i));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the final `models` array for `combo create` from Commander opts:
|
||||
* `--models <csv-or-json>` and/or repeatable `--model <spec>`.
|
||||
*
|
||||
* @param {{ models?: string, model?: string[] }} opts
|
||||
* @returns {Array<string | Record<string, unknown>>}
|
||||
*/
|
||||
export function resolveComboModels(opts = {}) {
|
||||
const result = [];
|
||||
|
||||
if (typeof opts.models === "string" && opts.models.trim().length > 0) {
|
||||
result.push(...parseModelsSpec(opts.models));
|
||||
}
|
||||
|
||||
if (Array.isArray(opts.model)) {
|
||||
opts.model.forEach((token, i) => {
|
||||
result.push(validateComboModelEntryShape(String(token).trim(), i));
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Commander `collect`-style reducer for the repeatable `--model` option. */
|
||||
export function collectModel(value, previous) {
|
||||
previous.push(value);
|
||||
return previous;
|
||||
}
|
||||
@@ -4,12 +4,6 @@ import { homedir } from "node:os";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { resolveDataDir } from "../data-dir.mjs";
|
||||
import { listManifestTargets } from "../cli-manifest.mjs";
|
||||
|
||||
// Target lists shared with `omniroute run` / `omniroute configure` — always
|
||||
// derived from the canonical manifest so the completion scripts cannot drift.
|
||||
const RUN_TARGET_WORDS = listManifestTargets("run").join(" ");
|
||||
const CONFIGURE_TARGET_WORDS = listManifestTargets("configure").join(" ");
|
||||
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000; // 1h
|
||||
|
||||
@@ -135,14 +129,6 @@ _omniroute() {
|
||||
'completion:Shell completion'
|
||||
'memory:Manage memory store'
|
||||
'skills:Manage skills'
|
||||
'connect:Connect to a local or remote OmniRoute server'
|
||||
'contexts:Manage local and remote server contexts'
|
||||
'configure:Configure a supported AI CLI'
|
||||
'launch:Launch an AI CLI through OmniRoute'
|
||||
'launch-codex:Launch Codex through OmniRoute'
|
||||
'run:Run a supported AI CLI through OmniRoute'
|
||||
'runtime:Inspect CLI runtime capabilities'
|
||||
'repair:Repair native runtime dependencies'
|
||||
)
|
||||
|
||||
_arguments -C \\
|
||||
@@ -167,7 +153,7 @@ _omniroute() {
|
||||
local -a providers
|
||||
providers=($(_omniroute_get_cache providers))
|
||||
_describe 'provider' providers ;;
|
||||
*) _arguments '1:subcommand:(available list test test-all validate rotate status add import auth remove edit metrics metric)' ;;
|
||||
*) _arguments '1:subcommand:(list add remove test)' ;;
|
||||
esac ;;
|
||||
chat|stream)
|
||||
_arguments \\
|
||||
@@ -179,12 +165,6 @@ _omniroute() {
|
||||
_arguments '1:resource:(combos providers api-manager cli-tools agents settings logs memory skills evals audit cost resilience)' ;;
|
||||
completion) _arguments '1:subcommand:(zsh bash fish install refresh)' ;;
|
||||
config) _arguments '1:subcommand:(list get set validate contexts)' ;;
|
||||
contexts) _arguments '1:subcommand:(list add use current show remove rename export import migrate)' ;;
|
||||
configure) _arguments '1:target:(${CONFIGURE_TARGET_WORDS})' ;;
|
||||
run) _arguments '1:target:(${RUN_TARGET_WORDS})' ;;
|
||||
connect) _arguments '1:host:' ;;
|
||||
launch|launch-codex) _arguments '--remote[Use a remote server]' '--context[Context name]:' '--model[Model ID]:' ;;
|
||||
runtime) _arguments '1:subcommand:(check repair clean)' ;;
|
||||
*) ;;
|
||||
esac
|
||||
case $state in
|
||||
@@ -228,19 +208,15 @@ _omniroute() {
|
||||
COMPREPLY=()
|
||||
cur="\${COMP_WORDS[COMP_CWORD]}"
|
||||
prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
||||
cmds="setup doctor status logs providers config test update serve stop restart keys models combo chat stream completion dashboard open backup restore health quota cache mcp a2a tunnel env memory skills connect contexts configure launch launch-codex run runtime repair"
|
||||
cmds="setup doctor status logs providers config test update serve stop restart keys models combo chat stream completion dashboard open backup restore health quota cache mcp a2a tunnel env memory skills"
|
||||
|
||||
case "\${prev}" in
|
||||
combo) COMPREPLY=($(compgen -W "list switch create delete show suggest" -- "\${cur}")); return 0 ;;
|
||||
keys) COMPREPLY=($(compgen -W "add list remove regenerate revoke reveal usage" -- "\${cur}")); return 0 ;;
|
||||
providers) COMPREPLY=($(compgen -W "available list test test-all validate rotate status add import auth remove edit metrics metric" -- "\${cur}")); return 0 ;;
|
||||
providers) COMPREPLY=($(compgen -W "available list test test-all" -- "\${cur}")); return 0 ;;
|
||||
config) COMPREPLY=($(compgen -W "list get set validate contexts" -- "\${cur}")); return 0 ;;
|
||||
completion) COMPREPLY=($(compgen -W "zsh bash fish install refresh" -- "\${cur}")); return 0 ;;
|
||||
open) COMPREPLY=($(compgen -W "combos providers api-manager cli-tools agents settings logs memory skills evals audit cost resilience" -- "\${cur}")); return 0 ;;
|
||||
contexts) COMPREPLY=($(compgen -W "list add use current show remove rename export import migrate" -- "\${cur}")); return 0 ;;
|
||||
configure) COMPREPLY=($(compgen -W "${CONFIGURE_TARGET_WORDS}" -- "\${cur}")); return 0 ;;
|
||||
run) COMPREPLY=($(compgen -W "${RUN_TARGET_WORDS}" -- "\${cur}")); return 0 ;;
|
||||
runtime) COMPREPLY=($(compgen -W "check repair clean" -- "\${cur}")); return 0 ;;
|
||||
--model)
|
||||
local models
|
||||
models=$(_omniroute_get_cache models)
|
||||
@@ -266,7 +242,7 @@ function generateFishScript() {
|
||||
return `# OmniRoute CLI fish completion (dynamic)
|
||||
complete -c omniroute -f
|
||||
|
||||
set -l commands serve stop restart setup doctor status logs providers config keys models combo chat stream completion dashboard open backup restore health quota cache mcp a2a tunnel env memory skills connect contexts configure launch launch-codex update test run runtime repair
|
||||
set -l commands serve stop restart setup doctor status logs providers config keys models combo chat stream completion dashboard open backup restore health quota cache mcp a2a tunnel env memory skills update test
|
||||
|
||||
for cmd in $commands
|
||||
complete -c omniroute -n '__fish_is_nth_token 1' -a $cmd
|
||||
@@ -275,14 +251,10 @@ end
|
||||
# Subcommands
|
||||
complete -c omniroute -n '__fish_seen_subcommand_from combo' -a 'list switch create delete show suggest'
|
||||
complete -c omniroute -n '__fish_seen_subcommand_from keys' -a 'add list remove regenerate revoke reveal usage'
|
||||
complete -c omniroute -n '__fish_seen_subcommand_from providers' -a 'available list test test-all validate rotate status add import auth remove edit metrics metric'
|
||||
complete -c omniroute -n '__fish_seen_subcommand_from providers' -a 'available list test test-all'
|
||||
complete -c omniroute -n '__fish_seen_subcommand_from config' -a 'list get set validate contexts'
|
||||
complete -c omniroute -n '__fish_seen_subcommand_from completion' -a 'zsh bash fish install refresh'
|
||||
complete -c omniroute -n '__fish_seen_subcommand_from open' -a 'combos providers api-manager cli-tools agents settings logs memory skills evals audit cost resilience'
|
||||
complete -c omniroute -n '__fish_seen_subcommand_from contexts' -a 'list add use current show remove rename export import migrate'
|
||||
complete -c omniroute -n '__fish_seen_subcommand_from configure' -a '${CONFIGURE_TARGET_WORDS}'
|
||||
complete -c omniroute -n '__fish_seen_subcommand_from run' -a '${RUN_TARGET_WORDS}'
|
||||
complete -c omniroute -n '__fish_seen_subcommand_from runtime' -a 'check repair clean'
|
||||
|
||||
# Dynamic completions from cache (requires python3)
|
||||
function __omniroute_cache_get
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { mcpCallTool } from "../mcpClient.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
@@ -79,17 +78,18 @@ async function restComboStats(period) {
|
||||
}
|
||||
|
||||
async function mcpCall(name, args, restFallback) {
|
||||
try {
|
||||
return await mcpCallTool(name, args);
|
||||
} catch (err) {
|
||||
// Keep the REST fallback behavior for builds where the MCP surface
|
||||
// is unreachable / not mounted. Anything else rethrows as an error.
|
||||
const status = err?.status || err?.cause?.status;
|
||||
if ((status === 404 || status === 501) && typeof restFallback === "function") {
|
||||
return restFallback();
|
||||
}
|
||||
throw err;
|
||||
const res = await apiFetch("/api/mcp/tools/call", {
|
||||
method: "POST",
|
||||
body: { name, arguments: args },
|
||||
});
|
||||
if (res.ok) return res.json();
|
||||
// 404 = MCP tool surface not mounted on this build; 501 = not implemented.
|
||||
// Anything else is a genuine error and we surface it.
|
||||
if ((res.status === 404 || res.status === 501) && typeof restFallback === "function") {
|
||||
return restFallback();
|
||||
}
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function confirm(q) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import fs from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolveDataDir } from "../data-dir.mjs";
|
||||
import { registerContexts } from "./contexts.mjs";
|
||||
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
|
||||
|
||||
function ensureBackup(configPath) {
|
||||
if (!fs.existsSync(configPath)) return;
|
||||
@@ -88,13 +87,6 @@ async function runConfigSetCommand(toolId, opts = {}) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const guard = await guardHostConfigTarget(result.configPath, {
|
||||
toolLabel: toolId,
|
||||
hostCommand: `omniroute config set ${toolId}`,
|
||||
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
|
||||
});
|
||||
if (guard !== 0) return guard;
|
||||
|
||||
const nonInteractive = opts.nonInteractive || opts.yes;
|
||||
|
||||
if (!nonInteractive) {
|
||||
@@ -279,10 +271,6 @@ export function registerConfig(program) {
|
||||
.option("--model <model>", "Model identifier (where applicable)")
|
||||
.option("--non-interactive", "Do not prompt for confirmation")
|
||||
.option("--yes", "Skip confirmation prompt")
|
||||
.option(
|
||||
"--allow-container-write",
|
||||
"Write the config even when OmniRoute runs in a container and the target is not mounted from the host"
|
||||
)
|
||||
.action(async (tool, opts, cmd) => {
|
||||
const globalOpts = cmd.parent.optsWithGlobals();
|
||||
const exitCode = await runConfigSetCommand(tool, {
|
||||
@@ -318,10 +306,6 @@ export function registerConfig(program) {
|
||||
.option("--model <model>", "Model identifier")
|
||||
.option("--non-interactive", "Do not prompt for confirmation")
|
||||
.option("--yes", "Skip confirmation prompt")
|
||||
.option(
|
||||
"--allow-container-write",
|
||||
"Write the config even when OmniRoute runs in a container and the target is not mounted from the host"
|
||||
)
|
||||
.action(async (opts, cmd) => {
|
||||
const globalOpts = cmd.parent.optsWithGlobals();
|
||||
const exitCode = await runConfigSetCommand("opencode", {
|
||||
|
||||
@@ -2,17 +2,8 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { existsSync, mkdirSync, writeFileSync, copyFileSync } from "node:fs";
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { loadContexts, resolveActiveContext } from "../contexts.mjs";
|
||||
import { createPrompt, printSuccess, printError, printInfo, printHeading } from "../io.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
|
||||
import {
|
||||
getModelPreferenceState,
|
||||
loadModelPreferences,
|
||||
rankPreferredModels,
|
||||
recordModelPreference,
|
||||
} from "../model-preferences.mjs";
|
||||
import { listManifestTargets, resolveManifestTarget } from "../cli-manifest.mjs";
|
||||
|
||||
/**
|
||||
* `omniroute configure <cli>` — interactive provider+model picker that writes a
|
||||
@@ -22,80 +13,11 @@ import { listManifestTargets, resolveManifestTarget } from "../cli-manifest.mjs"
|
||||
* are in remote mode (`omniroute connect ...`) you pick from the remote server's
|
||||
* live models and the profile is written on THIS machine.
|
||||
*
|
||||
* Codex keeps its profile-specific TOML files. Other targets delegate to their
|
||||
* existing setup-* recipe after the same provider/model selection, so the
|
||||
* picker remains a read-only orchestration layer and does not duplicate config
|
||||
* merge logic.
|
||||
* v1 targets the Codex CLI (writes ~/.codex/<name>.config.toml). The credential
|
||||
* is referenced by env var (OMNIROUTE_API_KEY) — never written to disk.
|
||||
*/
|
||||
|
||||
const SUPPORTED = listManifestTargets("configure");
|
||||
|
||||
export const SETUP_MODULES = {
|
||||
claude: { module: "./setup-claude.mjs", exportName: "runSetupClaudeCommand" },
|
||||
opencode: { module: "./setup-opencode.mjs", exportName: "runSetupOpencodeCommand" },
|
||||
qwen: { module: "./setup-qwen.mjs", exportName: "runSetupQwenCommand" },
|
||||
aider: { module: "./setup-aider.mjs", exportName: "runSetupAiderCommand" },
|
||||
goose: { module: "./setup-goose.mjs", exportName: "runSetupGooseCommand" },
|
||||
cline: { module: "./setup-cline.mjs", exportName: "runSetupClineCommand" },
|
||||
continue: { module: "./setup-continue.mjs", exportName: "runSetupContinueCommand" },
|
||||
kilo: { module: "./setup-kilo.mjs", exportName: "runSetupKiloCommand" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Materialize the active server before delegating to a setup recipe.
|
||||
*
|
||||
* `apiFetch` knows how to prefer a named context over an ambient
|
||||
* `OMNIROUTE_API_KEY`, but the older setup modules receive plain options and
|
||||
* resolve those themselves. Passing the resolved URL/key here keeps the
|
||||
* picker and the delegated recipe on the same local/remote target, including
|
||||
* Claude Code which predates context-aware setup resolution.
|
||||
*/
|
||||
export function resolveConfigureTargetOptions(opts = {}) {
|
||||
const resolved = { ...opts };
|
||||
const ambientKey = process.env.OMNIROUTE_API_KEY || "";
|
||||
const explicitRemote = opts.remote || opts.baseUrl;
|
||||
let context;
|
||||
try {
|
||||
context = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
} catch {
|
||||
// A missing/corrupt context file should retain the normal local fallback.
|
||||
}
|
||||
|
||||
if (!explicitRemote) {
|
||||
const localDefault = `http://localhost:${opts.port || process.env.PORT || "20128"}`;
|
||||
const contextBase = String(context?.baseUrl || "").replace(/\/+$/, "");
|
||||
if (contextBase && contextBase !== localDefault) {
|
||||
resolved.remote = contextBase;
|
||||
} else if (opts.port) {
|
||||
resolved.remote = localDefault;
|
||||
}
|
||||
} else if (!resolved.remote && resolved.baseUrl) {
|
||||
resolved.remote = resolved.baseUrl;
|
||||
}
|
||||
|
||||
const contextKey = context?.accessToken || context?.apiKey;
|
||||
if (contextKey && (!opts.apiKey || opts.apiKey === ambientKey)) {
|
||||
resolved.apiKey = contextKey;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function listConfigureTargets() {
|
||||
return [...SUPPORTED];
|
||||
}
|
||||
|
||||
export { getModelPreferenceState, rankPreferredModels };
|
||||
|
||||
function preferenceContextName(opts = {}) {
|
||||
if (opts.context || process.env.OMNIROUTE_CONTEXT) {
|
||||
return String(opts.context || process.env.OMNIROUTE_CONTEXT);
|
||||
}
|
||||
try {
|
||||
return String(loadContexts().currentContext || "default");
|
||||
} catch {
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
const SUPPORTED = ["codex"];
|
||||
|
||||
/** Derive a short, filesystem-safe profile name from a model id. */
|
||||
export function profileNameFromModel(modelId) {
|
||||
@@ -153,19 +75,6 @@ function buildCodexProfile(modelId, ctx) {
|
||||
|
||||
async function configureCodex(modelId, ctxWindow, opts) {
|
||||
const codexHome = opts.codexHome || path.join(os.homedir(), ".codex");
|
||||
const guard = await guardHostConfigTarget(codexHome, {
|
||||
toolLabel: "Codex",
|
||||
hostCommand: "omniroute configure codex",
|
||||
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
|
||||
dryRun: Boolean(opts.dryRun ?? opts["dry-run"]),
|
||||
});
|
||||
if (guard !== 0) return guard;
|
||||
if (opts.dryRun ?? opts["dry-run"]) {
|
||||
const profile = opts.name || profileNameFromModel(modelId);
|
||||
const filePath = path.join(codexHome, `${profile}.config.toml`);
|
||||
printInfo(`[dry-run] would write ${filePath}`);
|
||||
return 0;
|
||||
}
|
||||
if (!existsSync(codexHome)) mkdirSync(codexHome, { recursive: true });
|
||||
const profile = opts.name || profileNameFromModel(modelId);
|
||||
const filePath = path.join(codexHome, `${profile}.config.toml`);
|
||||
@@ -177,26 +86,19 @@ async function configureCodex(modelId, ctxWindow, opts) {
|
||||
printInfo(`Use it: codex --profile ${profile}`);
|
||||
printInfo("Prereq: ~/.codex/config.toml must define the [model_providers.omniroute] block");
|
||||
printInfo(" (run the Codex setup once — see docs/guides/CODEX-CLI-CONFIGURATION.md).");
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function runConfigureCommand(cli, opts = {}, cmd) {
|
||||
const target = resolveManifestTarget(cli, "configure");
|
||||
if (!target) {
|
||||
const target = String(cli || "").toLowerCase();
|
||||
if (!SUPPORTED.includes(target)) {
|
||||
printError(`Unsupported CLI '${cli}'. Supported: ${SUPPORTED.join(", ")}.`);
|
||||
return 2;
|
||||
}
|
||||
if (opts.favorite && opts.unfavorite) {
|
||||
printError("Choose only one of --favorite or --unfavorite.");
|
||||
return 2;
|
||||
}
|
||||
const globalOpts = cmd ? cmd.optsWithGlobals() : {};
|
||||
const requestOpts = resolveConfigureTargetOptions({ ...globalOpts, ...opts });
|
||||
const contextKey = preferenceContextName({ ...globalOpts, ...opts });
|
||||
|
||||
let models;
|
||||
try {
|
||||
models = await fetchModels(requestOpts);
|
||||
models = await fetchModels(globalOpts);
|
||||
} catch (e) {
|
||||
printError(e instanceof Error ? e.message : String(e));
|
||||
return 1;
|
||||
@@ -212,15 +114,12 @@ export async function runConfigureCommand(cli, opts = {}, cmd) {
|
||||
chosenId = `${opts.provider}/${chosenId}`;
|
||||
}
|
||||
|
||||
if (!chosenId && !opts.yes) {
|
||||
if (!chosenId) {
|
||||
const ids = models.map((m) => (typeof m === "string" ? m : m.id));
|
||||
const preferences = loadModelPreferences();
|
||||
const rankedIds = rankPreferredModels(target, ids, preferences, contextKey);
|
||||
const preferenceState = getModelPreferenceState(target, preferences, contextKey);
|
||||
const providers = [...new Set(models.map(providerOf))].sort();
|
||||
const prompt = createPrompt();
|
||||
try {
|
||||
printHeading(`Configure ${target} CLI`);
|
||||
printHeading("Configure Codex CLI");
|
||||
let providerList = providers;
|
||||
if (opts.provider) {
|
||||
providerList = providers.filter((p) => p === opts.provider);
|
||||
@@ -229,21 +128,9 @@ export async function runConfigureCommand(cli, opts = {}, cmd) {
|
||||
const p = await prompt.ask("Provider");
|
||||
if (p) providerList = providers.filter((x) => x === p);
|
||||
}
|
||||
const inProvider = rankedIds.filter((id) =>
|
||||
providerList.includes(providerOf(byId(models, id)))
|
||||
);
|
||||
const candidates = inProvider.length ? inProvider : rankedIds;
|
||||
if (preferenceState.favorites.length) {
|
||||
printInfo(
|
||||
`Favorites: ${preferenceState.favorites.filter((id) => ids.includes(id)).join(", ")}`
|
||||
);
|
||||
}
|
||||
if (preferenceState.recent.length) {
|
||||
printInfo(`Recent: ${preferenceState.recent.filter((id) => ids.includes(id)).join(", ")}`);
|
||||
}
|
||||
printInfo(
|
||||
`Models: ${candidates.slice(0, 40).join(", ")}${candidates.length > 40 ? " …" : ""}`
|
||||
);
|
||||
const inProvider = ids.filter((id) => providerList.includes(providerOf(byId(models, id))));
|
||||
const candidates = inProvider.length ? inProvider : ids;
|
||||
printInfo(`Models: ${candidates.slice(0, 40).join(", ")}${candidates.length > 40 ? " …" : ""}`);
|
||||
chosenId = await prompt.ask("Model id");
|
||||
} finally {
|
||||
prompt.close();
|
||||
@@ -261,48 +148,10 @@ export async function runConfigureCommand(cli, opts = {}, cmd) {
|
||||
}
|
||||
const ctxWindow = contextWindowOf(entry);
|
||||
|
||||
let result;
|
||||
if (target === "codex") {
|
||||
result = await configureCodex(chosenId, ctxWindow, opts);
|
||||
} else {
|
||||
const setup = SETUP_MODULES[target];
|
||||
if (!setup) {
|
||||
printError(`No setup recipe is registered for '${target}'.`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
try {
|
||||
const module = await import(setup.module);
|
||||
const runSetup = module[setup.exportName];
|
||||
if (typeof runSetup !== "function") {
|
||||
printError(`Setup recipe '${target}' is unavailable.`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const setupOpts = {
|
||||
...requestOpts,
|
||||
...opts,
|
||||
model: chosenId,
|
||||
// The picker already selected a model. Setup recipes that can generate
|
||||
// a model subset receive an exact filter; the others use `model`.
|
||||
...(target === "claude" || target === "continue" ? { only: chosenId } : {}),
|
||||
yes: true,
|
||||
};
|
||||
result = await runSetup(setupOpts);
|
||||
} catch (error) {
|
||||
printError(error instanceof Error ? error.message : String(error));
|
||||
return 1;
|
||||
}
|
||||
await configureCodex(chosenId, ctxWindow, opts);
|
||||
}
|
||||
|
||||
if (result === 0 && !(opts.dryRun ?? opts["dry-run"])) {
|
||||
recordModelPreference(target, chosenId, {
|
||||
favorite: Boolean(opts.favorite),
|
||||
unfavorite: Boolean(opts.unfavorite),
|
||||
context: contextKey,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function byId(models, id) {
|
||||
@@ -318,24 +167,12 @@ export function registerConfigure(program) {
|
||||
.command("configure <cli>")
|
||||
.description(
|
||||
t("configure.description") ||
|
||||
"Pick a provider+model from the active server and configure a supported local CLI"
|
||||
"Pick a provider+model from the active server and write a local CLI config (v1: codex)"
|
||||
)
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL")
|
||||
.option("--context <name>", "Named local/remote context")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to the active context/env)")
|
||||
.option("--provider <id>", "Provider id (skips the interactive provider prompt)")
|
||||
.option("--model <id>", "Model id (skips the interactive model prompt)")
|
||||
.option("--name <name>", "Profile name to write (default: derived from model)")
|
||||
.option("--codex-home <dir>", "Codex home dir (default: ~/.codex)")
|
||||
.option("--yes", "Non-interactive; requires --model")
|
||||
.option("--favorite", "Remember the selected model as a favorite for this CLI")
|
||||
.option("--unfavorite", "Remove the selected model from this CLI's favorites")
|
||||
.option("--dry-run", "Preview the generated config without writing")
|
||||
.option(
|
||||
"--allow-container-write",
|
||||
"Write the config even when OmniRoute runs in a container and the target is not mounted from the host"
|
||||
)
|
||||
.action(async (cli, opts, cmd) => {
|
||||
const code = await runConfigureCommand(cli, opts, cmd);
|
||||
if (code !== 0) process.exit(code);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { loadContexts, saveContextsSecure } from "../contexts.mjs";
|
||||
import { loadContexts, saveContexts } from "../contexts.mjs";
|
||||
import { createPrompt, printSuccess, printError, printInfo } from "../io.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
@@ -31,9 +31,7 @@ export function normalizeBaseUrl(host, port) {
|
||||
|
||||
/** Derive a clean context name from a host (strip scheme/port). */
|
||||
export function hostLabel(host) {
|
||||
let value = String(host || "")
|
||||
.trim()
|
||||
.replace(/^https?:\/\//i, "");
|
||||
let value = String(host || "").trim().replace(/^https?:\/\//i, "");
|
||||
value = value.split("/")[0].split(":")[0];
|
||||
return value || "remote";
|
||||
}
|
||||
@@ -109,7 +107,7 @@ export async function runConnectCommand(host, opts = {}) {
|
||||
description: `Remote OmniRoute (${host})`,
|
||||
};
|
||||
cfg.currentContext = name;
|
||||
await saveContextsSecure(cfg);
|
||||
saveContexts(cfg);
|
||||
|
||||
printSuccess(`Connected to ${baseUrl} — context '${name}' (scope: ${scope})`);
|
||||
printInfo("All commands now target this server.");
|
||||
|
||||
@@ -1,34 +1,21 @@
|
||||
import { t } from "../i18n.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import {
|
||||
loadContexts,
|
||||
saveContextsSecure,
|
||||
deleteContextCredential,
|
||||
migrateContextCredentials,
|
||||
resolveActiveContext,
|
||||
} from "../contexts.mjs";
|
||||
import { loadContexts, saveContexts, resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
/** Auth label for a context: prefers the scoped accessToken over the legacy apiKey. */
|
||||
function authLabel(c) {
|
||||
if (c?.accessToken) return "token";
|
||||
if (c?.apiKey) return "key";
|
||||
if (c?.credentialRef) return "keychain";
|
||||
return "✗";
|
||||
}
|
||||
|
||||
function contextMap(config) {
|
||||
return config.contexts || config.profiles || {};
|
||||
}
|
||||
|
||||
export async function confirm(msg) {
|
||||
// Non-interactive stdin (pipe, CI, EOF) cannot answer a [y/N] prompt. Asking
|
||||
// anyway leaves the readline question pending forever — Node then warns about an
|
||||
// "unsettled top-level await" at exit. Decline cleanly instead and point at the
|
||||
// non-interactive escape hatch so scripted callers fail safe rather than hang.
|
||||
if (!process.stdin.isTTY) {
|
||||
process.stderr.write(
|
||||
`${msg} [y/N] (non-interactive stdin — declined; pass --yes to confirm)\n`
|
||||
);
|
||||
process.stderr.write(`${msg} [y/N] (non-interactive stdin — declined; pass --yes to confirm)\n`);
|
||||
return false;
|
||||
}
|
||||
const readline = await import("node:readline");
|
||||
@@ -44,18 +31,6 @@ function maskKey(k) {
|
||||
return `${k.slice(0, 6)}***${k.slice(-4)}`;
|
||||
}
|
||||
|
||||
/** Return an export-safe copy without legacy or canonical context credentials. */
|
||||
export function redactContextSecrets(config) {
|
||||
const out = JSON.parse(JSON.stringify(config || {}));
|
||||
for (const collection of [out.contexts, out.profiles]) {
|
||||
for (const context of Object.values(collection || {})) {
|
||||
context.apiKey = null;
|
||||
delete context.accessToken;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function registerContexts(program) {
|
||||
const ctx = program
|
||||
.command("contexts")
|
||||
@@ -68,7 +43,7 @@ export function registerContexts(program) {
|
||||
.action(async (opts, cmd) => {
|
||||
const globalOpts = cmd.optsWithGlobals();
|
||||
const cfg = loadContexts();
|
||||
const rows = Object.entries(contextMap(cfg)).map(([name, c]) => ({
|
||||
const rows = Object.entries(cfg.contexts || {}).map(([name, c]) => ({
|
||||
active: name === (cfg.currentContext || "default") ? "●" : "",
|
||||
name,
|
||||
baseUrl: c.baseUrl || "",
|
||||
@@ -98,7 +73,7 @@ export function registerContexts(program) {
|
||||
.option("--description <d>", "Context description")
|
||||
.action(async (name, opts) => {
|
||||
const cfg = loadContexts();
|
||||
if (contextMap(cfg)[name]) {
|
||||
if (cfg.contexts?.[name]) {
|
||||
process.stderr.write(`Context '${name}' already exists. Remove or rename first.\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -111,29 +86,29 @@ export function registerContexts(program) {
|
||||
if (opts.accessTokenStdin) accessToken = value;
|
||||
else apiKey = value;
|
||||
}
|
||||
const contexts = contextMap(cfg);
|
||||
contexts[name] = {
|
||||
cfg.contexts = cfg.contexts || {};
|
||||
cfg.contexts[name] = {
|
||||
baseUrl: opts.url,
|
||||
accessToken: accessToken || undefined,
|
||||
apiKey,
|
||||
scope: opts.scope || undefined,
|
||||
description: opts.description || undefined,
|
||||
};
|
||||
await saveContextsSecure(cfg);
|
||||
saveContexts(cfg);
|
||||
process.stdout.write(`Added context '${name}'\n`);
|
||||
});
|
||||
|
||||
ctx
|
||||
.command("use <name>")
|
||||
.description("Switch active context")
|
||||
.action(async (name) => {
|
||||
.action((name) => {
|
||||
const cfg = loadContexts();
|
||||
if (!contextMap(cfg)[name]) {
|
||||
if (!cfg.contexts?.[name]) {
|
||||
process.stderr.write(`No such context: ${name}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
cfg.currentContext = name;
|
||||
await saveContextsSecure(cfg);
|
||||
saveContexts(cfg);
|
||||
process.stdout.write(`Active context: ${name}\n`);
|
||||
});
|
||||
|
||||
@@ -168,7 +143,7 @@ export function registerContexts(program) {
|
||||
.action((name, opts, cmd) => {
|
||||
const globalOpts = cmd.optsWithGlobals();
|
||||
const cfg = loadContexts();
|
||||
const c = contextMap(cfg)[name];
|
||||
const c = cfg.contexts?.[name];
|
||||
if (!c) {
|
||||
process.stderr.write(`No such context: ${name}\n`);
|
||||
process.exit(2);
|
||||
@@ -176,8 +151,6 @@ export function registerContexts(program) {
|
||||
const display = {
|
||||
name,
|
||||
baseUrl: c.baseUrl,
|
||||
auth: authLabel(c),
|
||||
credentialRef: c.credentialRef || null,
|
||||
accessToken: maskKey(c.accessToken),
|
||||
apiKey: maskKey(c.apiKey),
|
||||
scope: c.scope,
|
||||
@@ -199,7 +172,7 @@ export function registerContexts(program) {
|
||||
}
|
||||
}
|
||||
const cfg = loadContexts();
|
||||
if (!contextMap(cfg)[name]) {
|
||||
if (!cfg.contexts?.[name]) {
|
||||
process.stderr.write(`No such context: ${name}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -207,37 +180,29 @@ export function registerContexts(program) {
|
||||
process.stderr.write("Cannot remove default context.\n");
|
||||
process.exit(2);
|
||||
}
|
||||
const contexts = contextMap(cfg);
|
||||
const deletedCredential = await deleteContextCredential(name, contexts[name]);
|
||||
if (contexts[name].credentialRef && !deletedCredential) {
|
||||
process.stderr.write(
|
||||
"Warning: could not remove the OS-keychain entry; the context reference was removed locally.\n"
|
||||
);
|
||||
}
|
||||
delete contexts[name];
|
||||
delete cfg.contexts[name];
|
||||
if (cfg.currentContext === name) cfg.currentContext = "default";
|
||||
await saveContextsSecure(cfg);
|
||||
saveContexts(cfg);
|
||||
process.stdout.write(`Removed context '${name}'\n`);
|
||||
});
|
||||
|
||||
ctx
|
||||
.command("rename <old> <new>")
|
||||
.description("Rename a context")
|
||||
.action(async (oldName, newName) => {
|
||||
.action((oldName, newName) => {
|
||||
const cfg = loadContexts();
|
||||
const contexts = contextMap(cfg);
|
||||
if (!contexts[oldName]) {
|
||||
if (!cfg.contexts?.[oldName]) {
|
||||
process.stderr.write(`No such context: ${oldName}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
if (contexts[newName]) {
|
||||
if (cfg.contexts[newName]) {
|
||||
process.stderr.write(`Context '${newName}' already exists.\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
contexts[newName] = contexts[oldName];
|
||||
delete contexts[oldName];
|
||||
cfg.contexts[newName] = cfg.contexts[oldName];
|
||||
delete cfg.contexts[oldName];
|
||||
if (cfg.currentContext === oldName) cfg.currentContext = newName;
|
||||
await saveContextsSecure(cfg);
|
||||
saveContexts(cfg);
|
||||
process.stdout.write(`Renamed '${oldName}' → '${newName}'\n`);
|
||||
});
|
||||
|
||||
@@ -248,7 +213,13 @@ export function registerContexts(program) {
|
||||
.option("--no-secrets", "Omit API keys from export")
|
||||
.action(async (opts, cmd) => {
|
||||
const cfg = loadContexts();
|
||||
const out = opts.noSecrets ? redactContextSecrets(cfg) : JSON.parse(JSON.stringify(cfg));
|
||||
const out = JSON.parse(JSON.stringify(cfg));
|
||||
if (opts.noSecrets) {
|
||||
for (const c of Object.values(out.contexts || {})) {
|
||||
c.apiKey = null;
|
||||
delete c.accessToken;
|
||||
}
|
||||
}
|
||||
const json = JSON.stringify(out, null, 2);
|
||||
if (opts.out) {
|
||||
const { writeFileSync } = await import("node:fs");
|
||||
@@ -277,12 +248,7 @@ export function registerContexts(program) {
|
||||
const cfg = opts.merge
|
||||
? loadContexts()
|
||||
: { version: 1, currentContext: "default", contexts: {} };
|
||||
if (!cfg.contexts && cfg.profiles) {
|
||||
cfg.contexts = cfg.profiles;
|
||||
delete cfg.profiles;
|
||||
}
|
||||
cfg.contexts = cfg.contexts || {};
|
||||
const incoming = imported.contexts || imported.profiles || {};
|
||||
const incoming = imported.contexts || {};
|
||||
let count = 0;
|
||||
for (const [name, raw] of Object.entries(incoming)) {
|
||||
if (typeof name !== "string" || !name) continue;
|
||||
@@ -299,38 +265,7 @@ export function registerContexts(program) {
|
||||
if (!opts.merge && typeof imported.currentContext === "string") {
|
||||
cfg.currentContext = imported.currentContext;
|
||||
}
|
||||
await saveContextsSecure(cfg);
|
||||
saveContexts(cfg);
|
||||
process.stdout.write(`Imported ${count} context(s)\n`);
|
||||
});
|
||||
|
||||
ctx
|
||||
.command("migrate")
|
||||
.description("Move legacy plaintext context credentials to the OS keychain")
|
||||
.option("--yes", "Confirm migration in non-interactive scripts")
|
||||
.action(async (opts) => {
|
||||
const cfg = loadContexts();
|
||||
const pending = Object.entries(cfg.contexts || cfg.profiles || {}).filter(
|
||||
([, context]) => context?.accessToken || context?.apiKey
|
||||
);
|
||||
if (!pending.length) {
|
||||
process.stdout.write("No plaintext context credentials found.\n");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!opts.yes &&
|
||||
!(await confirm(`Migrate ${pending.length} context credential(s) to keychain?`))
|
||||
) {
|
||||
process.stdout.write("Cancelled.\n");
|
||||
return;
|
||||
}
|
||||
const result = await migrateContextCredentials();
|
||||
if (!result.migrated) {
|
||||
process.stderr.write(
|
||||
"OS keychain unavailable; credentials remain in config.json mode 0600.\n"
|
||||
);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
process.stdout.write(`Migrated ${pending.length} context credential(s) to keychain.\n`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,9 +4,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createDecipheriv, scryptSync } from "node:crypto";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { isLoopbackUrl } from "../api.mjs";
|
||||
import { resolveDataDir, resolveStoragePath } from "../data-dir.mjs";
|
||||
import { getCliToken, CLI_TOKEN_HEADER } from "../utils/cliToken.mjs";
|
||||
import { printHeading } from "../io.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { readDatabaseHealth, readEncryptedCredentialSamples } from "../sqlite.mjs";
|
||||
@@ -290,44 +288,27 @@ async function checkNodeRuntime(rootDir) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Name of the prebuilt binary better-sqlite3 ships for this platform, e.g.
|
||||
* `linux-x64.node`. Musl-based Linux uses a distinct `linuxmusl-` prefix.
|
||||
* Mirrors the lookup `prebuild-install`/`node-gyp-build` perform at require time.
|
||||
*/
|
||||
export function prebuiltBinaryName(
|
||||
platform = process.platform,
|
||||
arch = process.arch,
|
||||
report = process.report
|
||||
) {
|
||||
let prefix = platform;
|
||||
if (platform === "linux") {
|
||||
let isMusl = false;
|
||||
try {
|
||||
// glibc builds expose `glibcVersionRuntime`; musl builds do not.
|
||||
isMusl = !report?.getReport?.()?.header?.glibcVersionRuntime;
|
||||
} catch {
|
||||
isMusl = false;
|
||||
}
|
||||
prefix = isMusl ? "linuxmusl" : "linux";
|
||||
}
|
||||
return `${prefix}-${arch}.node`;
|
||||
}
|
||||
|
||||
async function checkNativeBinary(rootDir) {
|
||||
// node-gyp layout — present only when better-sqlite3 was compiled locally.
|
||||
const buildRoots = [
|
||||
path.join(rootDir, "app", "node_modules", "better-sqlite3"),
|
||||
path.join(rootDir, "dist", "node_modules", "better-sqlite3"),
|
||||
path.join(rootDir, "node_modules", "better-sqlite3"),
|
||||
];
|
||||
const prebuildName = prebuiltBinaryName();
|
||||
const candidates = [
|
||||
...buildRoots.map((root) => path.join(root, "build", "Release", "better_sqlite3.node")),
|
||||
// Prebuilt layout — what `npm i -g omniroute` actually installs. Without
|
||||
// these, doctor warns on every prebuilt install even though the binary is
|
||||
// present and loading fine.
|
||||
...buildRoots.map((root) => path.join(root, "prebuilds", prebuildName)),
|
||||
path.join(
|
||||
rootDir,
|
||||
"app",
|
||||
"node_modules",
|
||||
"better-sqlite3",
|
||||
"build",
|
||||
"Release",
|
||||
"better_sqlite3.node"
|
||||
),
|
||||
path.join(
|
||||
rootDir,
|
||||
"dist",
|
||||
"node_modules",
|
||||
"better-sqlite3",
|
||||
"build",
|
||||
"Release",
|
||||
"better_sqlite3.node"
|
||||
),
|
||||
path.join(rootDir, "node_modules", "better-sqlite3", "build", "Release", "better_sqlite3.node"),
|
||||
];
|
||||
const binaryPath = candidates.find((candidate) => fs.existsSync(candidate));
|
||||
if (!binaryPath) {
|
||||
@@ -380,11 +361,11 @@ function checkMemory() {
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url, options = {}) {
|
||||
async function fetchWithTimeout(url) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(url, { ...options, signal: controller.signal });
|
||||
return await fetch(url, { signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
@@ -473,98 +454,6 @@ async function checkServerLiveness(options = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
export async function checkMachineTokenAuth(options = {}) {
|
||||
if (process.env.OMNIROUTE_DISABLE_CLI_TOKEN === "true") {
|
||||
return warn("CLI machine token", "CLI machine-token authentication is disabled", {
|
||||
derived: false,
|
||||
accepted: false,
|
||||
disabled: true,
|
||||
tokenExposed: false,
|
||||
});
|
||||
}
|
||||
|
||||
let url;
|
||||
try {
|
||||
const parsed = new URL(resolveLivenessUrl(options));
|
||||
if (
|
||||
!["http:", "https:"].includes(parsed.protocol) ||
|
||||
parsed.username ||
|
||||
parsed.password ||
|
||||
!isLoopbackUrl(parsed.toString())
|
||||
) {
|
||||
return warn(
|
||||
"CLI machine token",
|
||||
"Machine-token probes are limited to HTTP(S) loopback endpoints",
|
||||
{ derived: false, accepted: false, tokenExposed: false }
|
||||
);
|
||||
}
|
||||
parsed.pathname = "/api/cli/whoami";
|
||||
parsed.search = "";
|
||||
parsed.hash = "";
|
||||
url = parsed.toString();
|
||||
} catch {
|
||||
return warn("CLI machine token", "Could not resolve the management endpoint", {
|
||||
derived: false,
|
||||
accepted: false,
|
||||
tokenExposed: false,
|
||||
});
|
||||
}
|
||||
|
||||
const token = await getCliToken();
|
||||
if (!token) {
|
||||
return fail(
|
||||
"CLI machine token",
|
||||
"Could not derive a machine token; verify the node-machine-id runtime is installed",
|
||||
{ derived: false, accepted: false, tokenExposed: false }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetchWithTimeout(url, {
|
||||
headers: { [CLI_TOKEN_HEADER]: token },
|
||||
redirect: "error",
|
||||
});
|
||||
if (response.ok) {
|
||||
return ok("CLI machine token", "Server accepted the local machine token", {
|
||||
url,
|
||||
status: response.status,
|
||||
derived: true,
|
||||
accepted: true,
|
||||
tokenExposed: false,
|
||||
});
|
||||
}
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return warn(
|
||||
"CLI machine token",
|
||||
"Server rejected the local machine token; if the CLI and server are on different hosts or container boundaries, run `omniroute connect <host> --key <oma_live_...>`",
|
||||
{
|
||||
url,
|
||||
status: response.status,
|
||||
derived: true,
|
||||
accepted: false,
|
||||
containerBoundaryLikely: true,
|
||||
tokenExposed: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
return warn("CLI machine token", `Machine-token probe returned HTTP ${response.status}`, {
|
||||
url,
|
||||
status: response.status,
|
||||
derived: true,
|
||||
accepted: false,
|
||||
tokenExposed: false,
|
||||
});
|
||||
} catch {
|
||||
return warn("CLI machine token", "Machine-token endpoint could not be reached", {
|
||||
url,
|
||||
status: 0,
|
||||
derived: true,
|
||||
accepted: false,
|
||||
tokenExposed: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function collectDoctorChecks(context = {}, options = {}) {
|
||||
const rootDir =
|
||||
context.rootDir || path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
@@ -582,7 +471,6 @@ export async function collectDoctorChecks(context = {}, options = {}) {
|
||||
|
||||
if (!options.skipLiveness) {
|
||||
checks.push(await checkServerLiveness(options));
|
||||
checks.push(await checkMachineTokenAuth(options));
|
||||
}
|
||||
|
||||
// CLI tool health checks
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "../provider-store.mjs";
|
||||
import { openOmniRouteDb } from "../sqlite.mjs";
|
||||
import { loadAvailableProviders } from "../provider-catalog.mjs";
|
||||
import { apiFetch, isServerUp, isRouteUnavailableStatus } from "../api.mjs";
|
||||
import { apiFetch, isServerUp } from "../api.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
function getValidProviderIds() {
|
||||
@@ -184,10 +184,7 @@ export async function runKeysAddCommand(provider, apiKey, opts = {}) {
|
||||
console.log(t("keys.added", { provider: providerLower }));
|
||||
return 0;
|
||||
}
|
||||
// A missing route means this server does not implement the endpoint —
|
||||
// fall through to the local SQLite path below rather than stranding the
|
||||
// user. Real client errors still abort.
|
||||
if (res.status >= 400 && res.status < 500 && !isRouteUnavailableStatus(res.status)) {
|
||||
if (res.status >= 400 && res.status < 500) {
|
||||
console.error(t("common.error", { message: `HTTP ${res.status}` }));
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -170,8 +170,8 @@ export function buildCodexEnv(baseEnv, authToken) {
|
||||
* @param {string} baseUrl OmniRoute root URL (no /v1)
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function buildCodexProviderArgs(baseUrl, model) {
|
||||
const args = [
|
||||
export function buildCodexProviderArgs(baseUrl) {
|
||||
return [
|
||||
"-c",
|
||||
tomlAssign("model_provider", "omniroute"),
|
||||
"-c",
|
||||
@@ -185,15 +185,6 @@ export function buildCodexProviderArgs(baseUrl, model) {
|
||||
"-c",
|
||||
tomlAssign("model_providers.omniroute.requires_openai_auth", false),
|
||||
];
|
||||
|
||||
if (model) {
|
||||
const normalized = String(model).trim();
|
||||
if (normalized) {
|
||||
args.push("-c", tomlAssign("model_providers.omniroute.model", normalized));
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -216,7 +207,7 @@ export async function runLaunchCodexCommand(opts = {}, codexArgs = []) {
|
||||
|
||||
// Provider injected via -c (works without config.toml); then the profile (model),
|
||||
// then the user's pass-through args.
|
||||
const providerArgs = buildCodexProviderArgs(baseUrl, opts.model);
|
||||
const providerArgs = buildCodexProviderArgs(baseUrl);
|
||||
const profileArgs = opts.profile ? ["--profile", opts.profile] : [];
|
||||
const extraArgs = [...providerArgs, ...profileArgs, ...codexArgs];
|
||||
const env = buildCodexEnv(process.env, authToken);
|
||||
@@ -229,45 +220,18 @@ export async function runLaunchCodexCommand(opts = {}, codexArgs = []) {
|
||||
stdio: "inherit",
|
||||
shell: shellValue,
|
||||
});
|
||||
let settled = false;
|
||||
const signalExitCode = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 };
|
||||
const signalHandlers = {};
|
||||
const cleanupSignalHandlers = () => {
|
||||
for (const signal of Object.keys(signalExitCode)) {
|
||||
process.removeListener(signal, signalHandlers[signal]);
|
||||
}
|
||||
};
|
||||
const finish = (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanupSignalHandlers();
|
||||
resolve(code);
|
||||
};
|
||||
for (const signal of Object.keys(signalExitCode)) {
|
||||
signalHandlers[signal] = () => {
|
||||
try {
|
||||
child.kill(signal);
|
||||
} catch {
|
||||
// The child may have already exited between the signal and cleanup.
|
||||
}
|
||||
finish(signalExitCode[signal]);
|
||||
};
|
||||
process.once(signal, signalHandlers[signal]);
|
||||
}
|
||||
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"
|
||||
);
|
||||
finish(127);
|
||||
resolve(127);
|
||||
} else {
|
||||
console.error(String(err?.message || err));
|
||||
finish(1);
|
||||
resolve(1);
|
||||
}
|
||||
});
|
||||
child.on("exit", (code, signalName) => {
|
||||
finish(code ?? signalExitCode[signalName] ?? 0);
|
||||
});
|
||||
child.on("exit", (code) => resolve(code ?? 0));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -190,10 +190,7 @@ export async function runLaunchCommand(opts = {}, claudeArgs = []) {
|
||||
const configDir = opts.profile
|
||||
? join(opts.claudeHome || join(os.homedir(), ".claude"), "profiles", opts.profile)
|
||||
: undefined;
|
||||
const env = buildClaudeEnv(process.env, baseUrl, authToken, {
|
||||
configDir,
|
||||
model: opts.model,
|
||||
});
|
||||
const env = buildClaudeEnv(process.env, baseUrl, authToken, { configDir });
|
||||
|
||||
const { command, shell } = await resolveClaudeSpawn(process.platform);
|
||||
|
||||
@@ -204,43 +201,16 @@ export async function runLaunchCommand(opts = {}, claudeArgs = []) {
|
||||
shell,
|
||||
...(process.platform === "win32" ? { windowsHide: true } : {}),
|
||||
});
|
||||
let settled = false;
|
||||
const signalExitCode = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 };
|
||||
const signalHandlers = {};
|
||||
const cleanupSignalHandlers = () => {
|
||||
for (const signal of Object.keys(signalExitCode)) {
|
||||
process.removeListener(signal, signalHandlers[signal]);
|
||||
}
|
||||
};
|
||||
const finish = (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanupSignalHandlers();
|
||||
resolve(code);
|
||||
};
|
||||
for (const signal of Object.keys(signalExitCode)) {
|
||||
signalHandlers[signal] = () => {
|
||||
try {
|
||||
child.kill(signal);
|
||||
} catch {
|
||||
// The child may have already exited between the signal and cleanup.
|
||||
}
|
||||
finish(signalExitCode[signal]);
|
||||
};
|
||||
process.once(signal, signalHandlers[signal]);
|
||||
}
|
||||
child.on("error", (err) => {
|
||||
if (err && err.code === "ENOENT") {
|
||||
console.error(t("launch.notFound") || "The 'claude' CLI was not found in PATH.");
|
||||
finish(127);
|
||||
resolve(127);
|
||||
} else {
|
||||
console.error(String(err?.message || err));
|
||||
finish(1);
|
||||
resolve(1);
|
||||
}
|
||||
});
|
||||
child.on("exit", (code, signalName) => {
|
||||
finish(code ?? signalExitCode[signalName] ?? 0);
|
||||
});
|
||||
child.on("exit", (code) => resolve(code ?? 0));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -61,12 +61,27 @@ export function registerMcp(program) {
|
||||
? JSON.parse(argsPositional)
|
||||
: {};
|
||||
|
||||
const exitCode = await runMcpCallCommand(tool, args, {
|
||||
...opts,
|
||||
stream: opts.stream,
|
||||
}, globalOpts);
|
||||
if (opts.stream) {
|
||||
await runMcpStream(tool, args, globalOpts);
|
||||
return;
|
||||
}
|
||||
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
const extraHeaders = opts.scope?.length ? { "X-MCP-Scopes": opts.scope.join(",") } : {};
|
||||
const res = await apiFetch("/api/mcp/tools/call", {
|
||||
method: "POST",
|
||||
body: { name: tool, arguments: args },
|
||||
headers: extraHeaders,
|
||||
});
|
||||
if (res.status === 403) {
|
||||
process.stderr.write("Scope denied\n");
|
||||
process.exit(4);
|
||||
}
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const data = await res.json();
|
||||
emit(data, globalOpts);
|
||||
});
|
||||
|
||||
mcp
|
||||
@@ -84,132 +99,112 @@ export function registerMcp(program) {
|
||||
const data = await res.json();
|
||||
emit(data.scopes ?? data, cmd.optsWithGlobals());
|
||||
});
|
||||
|
||||
// 5.2 — mcp tools + mcp audit
|
||||
const tools = mcp.command("tools").description(t("mcp.tools.description"));
|
||||
|
||||
tools
|
||||
.command("list")
|
||||
.description(t("mcp.tools.list.description"))
|
||||
.option("--scope <s>", t("mcp.tools.list.scope"))
|
||||
.action(async (opts, cmd) => {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.scope) params.set("scope", opts.scope);
|
||||
const res = await apiFetch(`/api/mcp/tools?${params}`);
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const data = await res.json();
|
||||
emit(data.tools ?? data, cmd.optsWithGlobals(), mcpToolSchema);
|
||||
});
|
||||
|
||||
tools
|
||||
.command("info <name>")
|
||||
.description(t("mcp.tools.info.description"))
|
||||
.action(async (name, opts, cmd) => {
|
||||
const res = await apiFetch(`/api/mcp/tools?name=${encodeURIComponent(name)}`);
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Not found: ${name}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
emit(await res.json(), cmd.optsWithGlobals());
|
||||
});
|
||||
|
||||
tools
|
||||
.command("schema <name>")
|
||||
.description(t("mcp.tools.schema.description"))
|
||||
.option("--io <kind>", t("mcp.tools.schema.io"), "input")
|
||||
.action(async (name, opts, cmd) => {
|
||||
const res = await apiFetch(`/api/mcp/tools?name=${encodeURIComponent(name)}&io=${opts.io}`);
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Not found: ${name}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const data = await res.json();
|
||||
const globalOpts = cmd.optsWithGlobals();
|
||||
if (globalOpts.output === "json") {
|
||||
process.stdout.write(JSON.stringify(data.schema ?? data, null, 2) + "\n");
|
||||
} else {
|
||||
emit(data.schema ?? data, globalOpts);
|
||||
}
|
||||
});
|
||||
|
||||
const audit = mcp.command("audit").description(t("mcp.audit.description"));
|
||||
|
||||
audit
|
||||
.command("tail")
|
||||
.option("--follow", t("audit.tail.follow"))
|
||||
.option("--limit <n>", t("audit.tail.limit"), parseInt, 100)
|
||||
.action(async (opts, cmd) => {
|
||||
const { runAuditTail } = await import("./audit.mjs");
|
||||
await runAuditTail({ ...opts, source: "mcp" }, cmd);
|
||||
});
|
||||
|
||||
audit
|
||||
.command("stats")
|
||||
.option("--period <p>", t("audit.stats.period"), "7d")
|
||||
.action(async (opts, cmd) => {
|
||||
const res = await apiFetch(`/api/mcp/audit/stats?period=${opts.period}`);
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
emit(await res.json(), cmd.optsWithGlobals());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared JSON-RPC 2.0 MCP client used by both stream and non-stream `mcp call`.
|
||||
*
|
||||
* Protocol:
|
||||
* 1. POST /api/mcp/stream with initialize → get Mcp-Session-Id header
|
||||
* 2. POST /api/mcp/stream with tools/call + Mcp-Session-Id header
|
||||
*
|
||||
* When `stream` is true, writes SSE data chunks to stdout as they arrive.
|
||||
* When `stream` is false, returns the parsed JSON-RPC result.
|
||||
*
|
||||
* Returns the exit code (0 = success, non-zero = failure).
|
||||
*/
|
||||
async function mcpJsonRpcCall(tool, args, { stream = false, globalOpts = {} } = {}) {
|
||||
async function runMcpStream(tool, args, globalOpts) {
|
||||
const baseUrl = globalOpts.baseUrl ?? "http://localhost:20128";
|
||||
const apiKey = globalOpts.apiKey ?? "";
|
||||
const streamUrl = `${baseUrl}/api/mcp/stream`;
|
||||
|
||||
const hdrs = {
|
||||
"Content-Type": "application/json",
|
||||
Accept: stream ? "text/event-stream" : "application/json",
|
||||
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
||||
};
|
||||
|
||||
// Step 1 — initialize
|
||||
const initRes = await fetch(streamUrl, {
|
||||
const res = await fetch(`${baseUrl}/api/mcp/stream`, {
|
||||
method: "POST",
|
||||
headers: hdrs,
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "omniroute-cli", version: "1.0" },
|
||||
},
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ name: tool, arguments: args }),
|
||||
});
|
||||
|
||||
if (!initRes.ok) {
|
||||
const text = await initRes.text().catch(() => "");
|
||||
process.stderr.write(`MCP initialize failed: HTTP ${initRes.status}${text ? ` — ${text}` : ""}\n`);
|
||||
return 1;
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`HTTP ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sessionId = initRes.headers.get("mcp-session-id");
|
||||
if (!sessionId) {
|
||||
process.stderr.write("MCP initialize failed: no Mcp-Session-Id in response\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Step 2 — tools/call
|
||||
const callHeaders = {
|
||||
...hdrs,
|
||||
"mcp-session-id": sessionId,
|
||||
};
|
||||
|
||||
const callRes = await fetch(streamUrl, {
|
||||
method: "POST",
|
||||
headers: callHeaders,
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "tools/call",
|
||||
params: { name: tool, arguments: args },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!callRes.ok) {
|
||||
const text = await callRes.text().catch(() => "");
|
||||
process.stderr.write(`MCP call failed: HTTP ${callRes.status}${text ? ` — ${text}` : ""}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
return readMcpSseStream(callRes.body);
|
||||
}
|
||||
|
||||
// Non-stream: parse JSON-RPC response
|
||||
const data = await callRes.json();
|
||||
if (data.error) {
|
||||
process.stderr.write(`MCP error: ${data.error.message || JSON.stringify(data.error)}\n`);
|
||||
return 1;
|
||||
}
|
||||
// Print the result content
|
||||
const content = data.result?.content;
|
||||
if (content) {
|
||||
for (const item of content) {
|
||||
if (item.type === "text") {
|
||||
process.stdout.write(item.text + "\n");
|
||||
} else if (item.type === "resource") {
|
||||
process.stdout.write(JSON.stringify(item.resource) + "\n");
|
||||
} else {
|
||||
process.stdout.write(JSON.stringify(item) + "\n");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
process.stdout.write(JSON.stringify(data.result, null, 2) + "\n");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function readMcpSseStream(body) {
|
||||
if (!body) return 1;
|
||||
const reader = body.getReader();
|
||||
const reader = res.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buf = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += dec.decode(value, { stream: true });
|
||||
}
|
||||
const lines = buf.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const raw = line.slice(6).trim();
|
||||
if (raw && raw !== "[DONE]") process.stdout.write(raw + "\n");
|
||||
const lines = buf.split("\n");
|
||||
buf = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const raw = line.slice(6).trim();
|
||||
if (raw && raw !== "[DONE]") process.stdout.write(raw + "\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function runMcpCallCommand(tool, args, opts = {}, globalOpts = {}) {
|
||||
return mcpJsonRpcCall(tool, args, { stream: opts.stream, globalOpts });
|
||||
}
|
||||
|
||||
export async function runMcpStatusCommand(opts = {}) {
|
||||
@@ -238,8 +233,7 @@ export async function runMcpStatusCommand(opts = {}) {
|
||||
}
|
||||
|
||||
const transport = status.transport || "stdio";
|
||||
const online = status.online ?? status.running;
|
||||
console.log(online ? t("mcp.running", { transport }) : t("mcp.stopped"));
|
||||
console.log(status.running ? t("mcp.running", { transport }) : t("mcp.stopped"));
|
||||
if (status.toolsCount !== undefined) console.log(` Tools: ${status.toolsCount}`);
|
||||
if (status.scopes?.length) {
|
||||
console.log(" Scopes:");
|
||||
|
||||
@@ -70,7 +70,7 @@ export function registerNodes(program) {
|
||||
nodes
|
||||
.command("add")
|
||||
.requiredOption("--provider <p>", t("nodes.add.provider"))
|
||||
.requiredOption("--endpoint <url>", t("nodes.add.baseUrl"))
|
||||
.requiredOption("--base-url <url>", t("nodes.add.baseUrl"))
|
||||
.option("--name <n>", t("nodes.add.name"))
|
||||
.option("--weight <w>", t("nodes.add.weight"), parseInt, 100)
|
||||
.option("--region <r>", t("nodes.add.region"))
|
||||
@@ -83,18 +83,14 @@ export function registerNodes(program) {
|
||||
.action(async (opts, cmd) => {
|
||||
const body = {
|
||||
provider: opts.provider,
|
||||
baseUrl: opts.endpoint,
|
||||
baseUrl: opts.baseUrl,
|
||||
name: opts.name,
|
||||
weight: opts.weight,
|
||||
region: opts.region,
|
||||
enabled: true,
|
||||
headers: opts.authHeader?.length ? opts.authHeader : undefined,
|
||||
};
|
||||
const res = await apiFetch("/api/provider-nodes", {
|
||||
...cmd.optsWithGlobals(),
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
const res = await apiFetch("/api/provider-nodes", { method: "POST", body });
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
@@ -104,22 +100,17 @@ export function registerNodes(program) {
|
||||
|
||||
nodes
|
||||
.command("update <nodeId>")
|
||||
.option("--endpoint <url>", t("nodes.update.baseUrl"))
|
||||
.option("--base-url <url>", t("nodes.update.baseUrl"))
|
||||
.option("--name <n>", t("nodes.update.name"))
|
||||
.option("--weight <w>", t("nodes.update.weight"), parseInt)
|
||||
.option("--region <r>", t("nodes.update.region"))
|
||||
.option("--enabled <b>", t("nodes.update.enabled"), (v) => v === "true")
|
||||
.action(async (id, opts, cmd) => {
|
||||
const body = {};
|
||||
if (opts.endpoint !== undefined) body.baseUrl = opts.endpoint;
|
||||
for (const k of ["name", "weight", "region", "enabled"]) {
|
||||
for (const k of ["baseUrl", "name", "weight", "region", "enabled"]) {
|
||||
if (opts[k] !== undefined) body[k] = opts[k];
|
||||
}
|
||||
const res = await apiFetch(`/api/provider-nodes/${id}`, {
|
||||
...cmd.optsWithGlobals(),
|
||||
method: "PUT",
|
||||
body,
|
||||
});
|
||||
const res = await apiFetch(`/api/provider-nodes/${id}`, { method: "PUT", body });
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
@@ -145,13 +136,12 @@ export function registerNodes(program) {
|
||||
|
||||
nodes
|
||||
.command("validate")
|
||||
.requiredOption("--endpoint <url>", t("nodes.validate.baseUrl"))
|
||||
.requiredOption("--base-url <url>", t("nodes.validate.baseUrl"))
|
||||
.requiredOption("--provider <p>", t("nodes.validate.provider"))
|
||||
.action(async (opts, cmd) => {
|
||||
const res = await apiFetch("/api/provider-nodes/validate", {
|
||||
...cmd.optsWithGlobals(),
|
||||
method: "POST",
|
||||
body: { baseUrl: opts.endpoint, provider: opts.provider },
|
||||
body: { baseUrl: opts.baseUrl, provider: opts.provider },
|
||||
});
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { t } from "../i18n.mjs";
|
||||
const PROVIDERS_WITH_OAUTH = [
|
||||
{ id: "gemini", name: "Google Gemini", flow: "browser" },
|
||||
{ id: "antigravity", name: "Antigravity", flow: "browser" },
|
||||
{ id: "windsurf", name: "Windsurf", flow: "browser" },
|
||||
{ id: "cursor", name: "Cursor", flow: "import" },
|
||||
{ id: "zed", name: "Zed", flow: "import" },
|
||||
{ id: "kiro", name: "Amazon Kiro", flow: "social" },
|
||||
@@ -54,20 +55,11 @@ async function openBrowser(url) {
|
||||
}
|
||||
}
|
||||
|
||||
function targetApiOptions(opts = {}) {
|
||||
return {
|
||||
baseUrl: opts.baseUrl,
|
||||
context: opts.context,
|
||||
apiKey: opts.apiKey,
|
||||
timeout: opts.timeout,
|
||||
};
|
||||
}
|
||||
|
||||
async function pollStatus(endpoint, timeoutMs, opts = {}) {
|
||||
async function pollStatus(endpoint, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(2000);
|
||||
const res = await apiFetch(endpoint, targetApiOptions(opts));
|
||||
const res = await apiFetch(endpoint);
|
||||
if (!res.ok) continue;
|
||||
const data = await res.json();
|
||||
if (data.status === "complete" || data.status === "completed") return data;
|
||||
@@ -94,7 +86,7 @@ async function runBrowserFlow(def, opts) {
|
||||
const authorizeUrl = `/api/oauth/${backendKey}/authorize${
|
||||
redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""
|
||||
}`;
|
||||
const startRes = await apiFetch(authorizeUrl, { ...targetApiOptions(opts), method: "GET" });
|
||||
const startRes = await apiFetch(authorizeUrl, { method: "GET" });
|
||||
if (!startRes.ok) {
|
||||
const detail = await safeErrorBody(startRes);
|
||||
process.stderr.write(`Failed to start OAuth for ${def.id}: ${startRes.status}${detail}\n`);
|
||||
@@ -152,7 +144,6 @@ async function runBrowserFlow(def, opts) {
|
||||
}
|
||||
|
||||
const exchangeRes = await apiFetch(`/api/oauth/${backendKey}/exchange`, {
|
||||
...targetApiOptions(opts),
|
||||
method: "POST",
|
||||
body: {
|
||||
code,
|
||||
@@ -189,7 +180,7 @@ async function runImportFlow(def, opts) {
|
||||
const endpoint = opts.importFromSystem
|
||||
? `/api/oauth/${def.id}/auto-import`
|
||||
: `/api/oauth/${def.id}/import`;
|
||||
const res = await apiFetch(endpoint, { ...targetApiOptions(opts), method: "POST" });
|
||||
const res = await apiFetch(endpoint, { method: "POST" });
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Import failed: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
@@ -205,7 +196,6 @@ async function runSocialFlow(def, opts) {
|
||||
process.exit(2);
|
||||
}
|
||||
const startRes = await apiFetch(`/api/oauth/${def.id}/social-authorize`, {
|
||||
...targetApiOptions(opts),
|
||||
method: "POST",
|
||||
body: { social },
|
||||
});
|
||||
@@ -220,59 +210,36 @@ async function runSocialFlow(def, opts) {
|
||||
process.stderr.write("Waiting for social authorization...\n");
|
||||
const result = await pollStatus(
|
||||
`/api/oauth/${def.id}/social-exchange?state=${encodeURIComponent(start.state ?? "")}`,
|
||||
opts.timeout ?? 300000,
|
||||
opts
|
||||
opts.timeout ?? 300000
|
||||
);
|
||||
process.stdout.write(`Authorized: ${result.email ?? result.userId ?? "connected"}\n`);
|
||||
}
|
||||
|
||||
async function runDeviceFlow(def, opts) {
|
||||
const providerKey = resolveBackendKey(def.id);
|
||||
let startRes = await apiFetch(`/api/oauth/${providerKey}/device-code`, targetApiOptions(opts));
|
||||
if (!startRes.ok) {
|
||||
startRes = await apiFetch(`/api/providers/${providerKey}/auth/start`, {
|
||||
...targetApiOptions(opts),
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
const startRes = await apiFetch(`/api/providers/${providerKey}/auth/start`, { method: "POST" });
|
||||
if (!startRes.ok) {
|
||||
process.stderr.write(`Failed to start device flow: ${startRes.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const start = await startRes.json();
|
||||
const userCode = start.userCode ?? start.user_code ?? "";
|
||||
const verificationUri =
|
||||
start.verificationUriComplete ??
|
||||
start.verification_uri_complete ??
|
||||
start.verificationUri ??
|
||||
start.verification_uri ??
|
||||
start.authUrl ??
|
||||
start.url ??
|
||||
"";
|
||||
|
||||
if (userCode) {
|
||||
process.stdout.write(`\nDevice code: ${userCode}\nVisit: ${verificationUri}\n\n`);
|
||||
} else if (verificationUri) {
|
||||
process.stdout.write(`\nVisit: ${verificationUri}\n\n`);
|
||||
} else {
|
||||
process.stdout.write(`\nAuthorization URL not available\n\n`);
|
||||
}
|
||||
|
||||
if (opts.browser !== false && verificationUri) await openBrowser(verificationUri);
|
||||
process.stdout.write(
|
||||
`\nDevice code: ${start.userCode ?? start.user_code ?? ""}\nVisit: ${start.verificationUri ?? start.verification_uri}\n\n`
|
||||
);
|
||||
if (opts.browser !== false)
|
||||
await openBrowser(start.verificationUri ?? start.verification_uri ?? "");
|
||||
process.stderr.write("Waiting for device authorization...\n");
|
||||
const deadline = Date.now() + (opts.timeout ?? 300000);
|
||||
const intervalMs = (start.intervalMs ?? start.interval ?? 5) * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(intervalMs);
|
||||
const statusRes = await apiFetch(
|
||||
`/api/providers/${providerKey}/auth/status?state=${encodeURIComponent(start.state ?? "")}`,
|
||||
targetApiOptions(opts)
|
||||
`/api/providers/${providerKey}/auth/status?state=${encodeURIComponent(start.state ?? "")}`
|
||||
);
|
||||
if (!statusRes.ok) continue;
|
||||
const status = await statusRes.json();
|
||||
if (status.status === "complete" || status.status === "authorized") {
|
||||
await apiFetch(`/api/providers/${providerKey}/auth/apply`, {
|
||||
...targetApiOptions(opts),
|
||||
method: "POST",
|
||||
body: { state: start.state },
|
||||
});
|
||||
@@ -289,7 +256,6 @@ async function runDeviceFlow(def, opts) {
|
||||
}
|
||||
|
||||
export async function runOAuthStart(opts, cmd) {
|
||||
opts = { ...(cmd?.optsWithGlobals ? cmd.optsWithGlobals() : {}), ...opts };
|
||||
const def = PROVIDERS_WITH_OAUTH.find((p) => p.id === opts.provider);
|
||||
if (!def) {
|
||||
process.stderr.write(
|
||||
@@ -310,34 +276,22 @@ export async function runOAuthStart(opts, cmd) {
|
||||
}
|
||||
|
||||
export async function runOAuthStatus(opts, cmd) {
|
||||
const globalOpts = { ...(cmd?.optsWithGlobals ? cmd.optsWithGlobals() : {}), ...opts };
|
||||
const globalOpts = cmd.optsWithGlobals();
|
||||
const params = new URLSearchParams();
|
||||
if (opts.provider) params.set("provider", opts.provider);
|
||||
const res = await apiFetch(`/api/providers?${params}`, targetApiOptions(globalOpts));
|
||||
const res = await apiFetch(`/api/providers?${params}`);
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const data = await res.json();
|
||||
const payload = data?.connections ?? data?.providers ?? data?.items ?? data;
|
||||
// #11236 (bug 5 residual): a 200 whose body is out of contract (no
|
||||
// connections/providers/items array — e.g. `{"status":"ok"}`) used to fall
|
||||
// through to `.filter` on a non-array and crash with a bare TypeError plus a
|
||||
// libuv teardown assertion on Windows. Coerce to an empty list with a
|
||||
// sanitized one-line warning instead of dumping a stack trace.
|
||||
if (!Array.isArray(payload)) {
|
||||
process.stderr.write(
|
||||
"Warning: unexpected response shape from /api/providers; showing no connections.\n"
|
||||
);
|
||||
}
|
||||
const connections = (Array.isArray(payload) ? payload : []).filter(
|
||||
const connections = (data.providers ?? data.items ?? data).filter(
|
||||
(c) => c.authType === "oauth" || c.authType === "oauth2"
|
||||
);
|
||||
emit(connections, globalOpts, connectionSchema);
|
||||
}
|
||||
|
||||
export async function runOAuthRevoke(opts, cmd) {
|
||||
opts = { ...(cmd?.optsWithGlobals ? cmd.optsWithGlobals() : {}), ...opts };
|
||||
if (!opts.yes) {
|
||||
process.stdout.write(
|
||||
`Revoke OAuth for ${opts.provider}${opts.connectionId ? ` (${opts.connectionId})` : ""}? (yes/no) `
|
||||
@@ -350,11 +304,8 @@ export async function runOAuthRevoke(opts, cmd) {
|
||||
}
|
||||
const id = opts.connectionId;
|
||||
const res = id
|
||||
? await apiFetch(`/api/providers/${id}`, { ...targetApiOptions(opts), method: "DELETE" })
|
||||
: await apiFetch(`/api/oauth/${opts.provider}/revoke`, {
|
||||
...targetApiOptions(opts),
|
||||
method: "POST",
|
||||
});
|
||||
? await apiFetch(`/api/providers/${id}`, { method: "DELETE" })
|
||||
: await apiFetch(`/api/oauth/${opts.provider}/revoke`, { method: "POST" });
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { mcpCallTool } from "../mcpClient.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
@@ -9,7 +8,15 @@ function fmtTs(v) {
|
||||
}
|
||||
|
||||
async function mcpCall(name, args) {
|
||||
return mcpCallTool(name, args);
|
||||
const res = await apiFetch("/api/mcp/tools/call", {
|
||||
method: "POST",
|
||||
body: { name, arguments: args },
|
||||
});
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`MCP error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
const proxySchema = [
|
||||
|
||||
@@ -41,83 +41,11 @@ function toYaml(obj, indent = 0) {
|
||||
.trimStart();
|
||||
}
|
||||
|
||||
// Keys that live alongside operations inside a Path Item Object but are not
|
||||
// themselves operations (OpenAPI 3.x Path Item fields).
|
||||
const NON_OPERATION_PATH_KEYS = new Set([
|
||||
"parameters",
|
||||
"summary",
|
||||
"description",
|
||||
"servers",
|
||||
"$ref",
|
||||
]);
|
||||
|
||||
/**
|
||||
* `GET /api/openapi/spec` answers with a compact catalog
|
||||
* (`{ info, servers, tags, endpoints[], schemas }`) rather than an OpenAPI
|
||||
* document with a `paths` object, while `dist/docs/openapi.yaml` is a real
|
||||
* spec. Normalize either shape into the flat rows the CLI renders so the
|
||||
* commands work against both instead of silently printing nothing.
|
||||
*/
|
||||
export function extractEndpoints(spec) {
|
||||
if (!spec || typeof spec !== "object") return [];
|
||||
|
||||
if (spec.paths && typeof spec.paths === "object") {
|
||||
const rows = [];
|
||||
for (const [path, pathItem] of Object.entries(spec.paths)) {
|
||||
if (!pathItem || typeof pathItem !== "object") continue;
|
||||
for (const [method, def] of Object.entries(pathItem)) {
|
||||
if (NON_OPERATION_PATH_KEYS.has(method)) continue;
|
||||
if (!def || typeof def !== "object") continue;
|
||||
rows.push({
|
||||
method: method.toUpperCase(),
|
||||
path,
|
||||
summary: def.summary ?? def.description ?? "",
|
||||
operationId: def.operationId,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
if (Array.isArray(spec.endpoints)) {
|
||||
return spec.endpoints
|
||||
.filter((entry) => entry && typeof entry === "object" && entry.path)
|
||||
.map((entry) => ({
|
||||
method: String(entry.method ?? "GET").toUpperCase(),
|
||||
path: entry.path,
|
||||
summary: entry.summary ?? entry.description ?? "",
|
||||
operationId: entry.operationId,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Sorted, de-duplicated list of paths across either shape. */
|
||||
export function extractPaths(spec) {
|
||||
return [...new Set(extractEndpoints(spec).map((row) => row.path))].sort();
|
||||
}
|
||||
|
||||
function matchesSearch(row, query) {
|
||||
if (!query) return true;
|
||||
const needle = query.toLowerCase();
|
||||
return row.path.includes(query) || String(row.summary).toLowerCase().includes(needle);
|
||||
}
|
||||
|
||||
function validateBasic(spec) {
|
||||
if (!spec || typeof spec !== "object") throw new Error("spec is not an object");
|
||||
if (!spec.openapi && !spec.swagger) throw new Error("missing openapi/swagger version field");
|
||||
if (!spec.info) throw new Error("missing info object");
|
||||
|
||||
// A real OpenAPI document must carry a version field and a paths object.
|
||||
if (spec.openapi || spec.swagger) {
|
||||
if (!spec.paths) throw new Error("missing paths object");
|
||||
return;
|
||||
}
|
||||
|
||||
// The compact catalog served by /api/openapi/spec carries endpoints[] instead.
|
||||
if (Array.isArray(spec.endpoints)) return;
|
||||
|
||||
throw new Error("missing openapi/swagger version field and no endpoints[] catalog");
|
||||
if (!spec.paths) throw new Error("missing paths object");
|
||||
}
|
||||
|
||||
const endpointSchema = [
|
||||
@@ -204,7 +132,20 @@ export function registerOpenapi(program) {
|
||||
process.exit(1);
|
||||
}
|
||||
const spec = await res.json();
|
||||
const rows = extractEndpoints(spec).filter((row) => matchesSearch(row, opts.search));
|
||||
const rows = [];
|
||||
for (const [path, methods] of Object.entries(spec.paths ?? {})) {
|
||||
for (const [method, def] of Object.entries(methods)) {
|
||||
if (["parameters", "summary"].includes(method)) continue;
|
||||
const summary = def.summary ?? def.description ?? "";
|
||||
if (
|
||||
opts.search &&
|
||||
!path.includes(opts.search) &&
|
||||
!summary.toLowerCase().includes(opts.search.toLowerCase())
|
||||
)
|
||||
continue;
|
||||
rows.push({ method: method.toUpperCase(), path, summary, operationId: def.operationId });
|
||||
}
|
||||
}
|
||||
emit(rows, cmd.optsWithGlobals(), endpointSchema);
|
||||
});
|
||||
|
||||
@@ -218,8 +159,9 @@ export function registerOpenapi(program) {
|
||||
process.exit(1);
|
||||
}
|
||||
const spec = await res.json();
|
||||
const paths = Object.keys(spec.paths ?? {}).sort();
|
||||
emit(
|
||||
extractPaths(spec).map((p) => ({ path: p })),
|
||||
paths.map((p) => ({ path: p })),
|
||||
cmd.optsWithGlobals()
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { t } from "../i18n.mjs";
|
||||
import { resolveDataDir } from "../data-dir.mjs";
|
||||
import {
|
||||
EXIT_CODES,
|
||||
emit,
|
||||
exitWith,
|
||||
printError,
|
||||
printInfo,
|
||||
printSuccess,
|
||||
printWarning,
|
||||
} from "../output.mjs";
|
||||
import { findPack } from "../../../scripts/packs/optionalPackManifest.mjs";
|
||||
import {
|
||||
findPackIndexFile,
|
||||
installPack,
|
||||
listPackStates,
|
||||
packState,
|
||||
packsRoot,
|
||||
readPackIndex,
|
||||
removePack,
|
||||
} from "../../../scripts/packs/optionalPackInstaller.mjs";
|
||||
|
||||
const CLI_DIR = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||
|
||||
/**
|
||||
* Locate + parse the bundle-shipped `optional-packs.index.json`.
|
||||
* Search order: explicit --source dir, then walking up from the CLI module
|
||||
* (bundle installs keep the index at the bundle root), then cwd.
|
||||
*/
|
||||
function loadIndex(sourceDir) {
|
||||
const indexFile = findPackIndexFile([sourceDir, CLI_DIR, process.cwd()]);
|
||||
if (!indexFile) return { indexFile: null, index: null };
|
||||
return { indexFile, index: readPackIndex(indexFile) };
|
||||
}
|
||||
|
||||
function stateRow(state, dataDir) {
|
||||
return {
|
||||
pack: state.name,
|
||||
packVersion: state.packVersion,
|
||||
installed: state.installed ? "yes" : "no",
|
||||
verified: state.verified === null ? "-" : state.verified ? "ok" : "FAILED",
|
||||
members: state.members.length,
|
||||
installDir: path.join(packsRoot(dataDir), state.name),
|
||||
errors: state.errors ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
const STATE_SCHEMA = [
|
||||
{ key: "pack", header: "pack" },
|
||||
{ key: "packVersion", header: "packVersion" },
|
||||
{ key: "installed", header: "installed" },
|
||||
{ key: "verified", header: "verified" },
|
||||
{ key: "members", header: "members" },
|
||||
];
|
||||
|
||||
async function run(action) {
|
||||
try {
|
||||
await action();
|
||||
} catch (err) {
|
||||
exitWith(EXIT_CODES.ERROR, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
export function registerPacks(program) {
|
||||
const packs = program.command("packs").description(t("packs.description"));
|
||||
|
||||
packs
|
||||
.command("list")
|
||||
.description(t("packs.listDescription"))
|
||||
.option("--source <dir>", t("packs.sourceOpt"))
|
||||
.action(async (opts) => {
|
||||
await run(async () => {
|
||||
const dataDir = resolveDataDir();
|
||||
const { index } = loadIndex(opts.source);
|
||||
emit(
|
||||
(await listPackStates({ dataDir, index })).map((s) => stateRow(s, dataDir)),
|
||||
opts,
|
||||
STATE_SCHEMA
|
||||
);
|
||||
if (!index) printWarning(t("packs.warnNoIndex"));
|
||||
});
|
||||
});
|
||||
|
||||
packs
|
||||
.command("install <name>")
|
||||
.description(t("packs.installDescription"))
|
||||
.option("--source <dir>", t("packs.sourceOpt"))
|
||||
.action(async (name, opts) => {
|
||||
await run(async () => {
|
||||
if (!findPack(name)) exitWith(EXIT_CODES.INVALID_ARG, t("packs.errUnknown", { name }));
|
||||
const { indexFile, index } = loadIndex(opts.source);
|
||||
if (!index) exitWith(EXIT_CODES.ERROR, t("packs.errNoIndex"));
|
||||
const dataDir = resolveDataDir();
|
||||
// The payload (tarball or extracted pack dir) lives next to the index
|
||||
// unless the caller pointed elsewhere via --source.
|
||||
await installPack(name, {
|
||||
dataDir,
|
||||
index,
|
||||
sourceDir: opts.source || path.dirname(indexFile),
|
||||
log: (msg) => printInfo(msg.replace(/^\[optional-packs\]\s*/, "")),
|
||||
});
|
||||
const installDir = path.join(packsRoot(dataDir), name);
|
||||
printSuccess(t("packs.installed", { name, dir: installDir }));
|
||||
printInfo(t("packs.restartHint"));
|
||||
emit({ pack: name, installed: "yes", verified: "ok", installDir }, opts, STATE_SCHEMA);
|
||||
});
|
||||
});
|
||||
|
||||
packs
|
||||
.command("verify [name]")
|
||||
.description(t("packs.verifyDescription"))
|
||||
.option("--source <dir>", t("packs.sourceOpt"))
|
||||
.action(async (name, opts) => {
|
||||
await run(async () => {
|
||||
if (name && !findPack(name))
|
||||
exitWith(EXIT_CODES.INVALID_ARG, t("packs.errUnknown", { name }));
|
||||
const { index } = loadIndex(opts.source);
|
||||
if (!index) exitWith(EXIT_CODES.ERROR, t("packs.errNoIndex"));
|
||||
const dataDir = resolveDataDir();
|
||||
const states = name
|
||||
? [await packState(name, { dataDir, index })]
|
||||
: await listPackStates({ dataDir, index });
|
||||
emit(
|
||||
states.map((s) => stateRow(s, dataDir)),
|
||||
opts,
|
||||
STATE_SCHEMA
|
||||
);
|
||||
const broken = states.filter((s) => s.installed && s.verified !== true);
|
||||
if (broken.length > 0) {
|
||||
for (const state of broken) {
|
||||
for (const error of state.errors ?? []) printError(`${state.name}: ${error}`);
|
||||
}
|
||||
exitWith(EXIT_CODES.ERROR, t("packs.verifyFailed", { count: broken.length }));
|
||||
}
|
||||
if (!states.some((s) => s.installed)) {
|
||||
printInfo(t("packs.noneInstalled"));
|
||||
return;
|
||||
}
|
||||
printSuccess(t("packs.verifyOk"));
|
||||
});
|
||||
});
|
||||
|
||||
packs
|
||||
.command("remove <name>")
|
||||
.description(t("packs.removeDescription"))
|
||||
.action(async (name, opts) => {
|
||||
await run(async () => {
|
||||
if (!findPack(name)) exitWith(EXIT_CODES.INVALID_ARG, t("packs.errUnknown", { name }));
|
||||
const dataDir = resolveDataDir();
|
||||
const removed = removePack(name, {
|
||||
dataDir,
|
||||
log: (msg) => printInfo(msg.replace(/^\[optional-packs\]\s*/, "")),
|
||||
});
|
||||
if (removed) {
|
||||
printSuccess(t("packs.removed", { name }));
|
||||
printInfo(t("packs.restartHint"));
|
||||
} else {
|
||||
printInfo(t("packs.notInstalled", { name }));
|
||||
}
|
||||
emit({ pack: name, installed: removed ? "no" : "no" }, opts, STATE_SCHEMA);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -9,13 +9,10 @@ import { discoverPlugins } from "../plugins.mjs";
|
||||
// (instead of string-interpolating into `execSync`) prevents a malicious plugin
|
||||
// name like `foo; rm -rf ~` or `` foo`id` `` from being interpreted by the shell.
|
||||
function runNpm(args) {
|
||||
const isBun = Boolean(process.versions.bun);
|
||||
const pm = isBun ? "bun" : "npm";
|
||||
const cmdArgs = isBun && args[0] === "install" ? ["add", ...args.slice(1)] : args;
|
||||
const res = spawnSync(pm, cmdArgs, { stdio: "inherit", shell: false });
|
||||
const res = spawnSync("npm", args, { stdio: "inherit", shell: false });
|
||||
if (res.error) throw res.error;
|
||||
if (typeof res.status === "number" && res.status !== 0) {
|
||||
throw new Error(`${pm} exited with code ${res.status}`);
|
||||
throw new Error(`npm exited with code ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,6 @@ export function registerProvider(program) {
|
||||
omniroute providers test <name> — test a provider connection
|
||||
omniroute providers test-all — test all active connections
|
||||
omniroute providers validate — validate local configuration
|
||||
omniroute providers add <id> — add an API-key connection
|
||||
omniroute providers auth <id> — start an existing OAuth flow
|
||||
omniroute providers remove <id> — remove a connection (requires confirmation)
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,498 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { apiFetch, statusToExitCode } from "../api.mjs";
|
||||
import { createPrompt, printError, printInfo, printSuccess } from "../io.mjs";
|
||||
import { runOAuthStart } from "./oauth.mjs";
|
||||
|
||||
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
function isBlank(value) {
|
||||
return value === undefined || value === null || String(value).trim() === "";
|
||||
}
|
||||
|
||||
function credentialShape(value) {
|
||||
if (isBlank(value)) return { present: false, length: 0 };
|
||||
return { present: true, length: String(value).length };
|
||||
}
|
||||
|
||||
const SENSITIVE_FIELD_RE =
|
||||
/^(?:api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|secret|client[_-]?secret|credential|authorization)$/i;
|
||||
|
||||
/**
|
||||
* Redact provider responses before they reach human or JSON output.
|
||||
*
|
||||
* The API normally masks credentials, but the CLI must remain safe when an
|
||||
* operator enables a server-side reveal/debug option or when a compatible
|
||||
* remote implementation returns a raw field. Presence and length are useful
|
||||
* for diagnostics; the value itself must never be printed.
|
||||
*/
|
||||
export function redactProviderResponse(value, key = "") {
|
||||
if (SENSITIVE_FIELD_RE.test(key)) {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
return typeof value === "string" ? credentialShape(value) : "[redacted]";
|
||||
}
|
||||
if (Array.isArray(value)) return value.map((entry) => redactProviderResponse(entry));
|
||||
if (!value || typeof value !== "object") return value;
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([entryKey, entryValue]) => [
|
||||
entryKey,
|
||||
redactProviderResponse(entryValue, entryKey),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a provider connection from the response returned by /api/providers.
|
||||
* The server deliberately masks credentials, so this helper never needs to
|
||||
* inspect or log a secret.
|
||||
*/
|
||||
export function findConnectionFromResponse(body, selector) {
|
||||
const rows = Array.isArray(body?.connections)
|
||||
? body.connections
|
||||
: Array.isArray(body?.providers)
|
||||
? body.providers
|
||||
: Array.isArray(body)
|
||||
? body
|
||||
: [];
|
||||
const needle = String(selector || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!needle) return null;
|
||||
return (
|
||||
rows.find((row) => String(row?.id || "").toLowerCase() === needle) ||
|
||||
rows.find((row) =>
|
||||
String(row?.id || "")
|
||||
.toLowerCase()
|
||||
.startsWith(needle)
|
||||
) ||
|
||||
rows.find((row) => String(row?.name || "").toLowerCase() === needle) ||
|
||||
rows.find((row) => String(row?.provider || "").toLowerCase() === needle) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/** Build the API body without accepting management auth as a provider secret. */
|
||||
export function buildProviderPayload(provider, opts = {}, credential) {
|
||||
const body = {
|
||||
provider: String(provider || "").trim(),
|
||||
name: String(opts.name || provider || "").trim(),
|
||||
};
|
||||
if (!body.name) throw new Error("Provider name is required.");
|
||||
if (!isBlank(credential)) body.apiKey = String(credential);
|
||||
if (!isBlank(opts.defaultModel)) body.defaultModel = String(opts.defaultModel).trim();
|
||||
if (!isBlank(opts.priority)) {
|
||||
const priority = Number(opts.priority);
|
||||
if (!Number.isInteger(priority) || priority < 1) {
|
||||
throw new Error("--priority must be a positive integer.");
|
||||
}
|
||||
body.priority = priority;
|
||||
}
|
||||
if (opts.providerSpecificData) {
|
||||
const raw = typeof opts.providerSpecificData === "string" ? opts.providerSpecificData : null;
|
||||
try {
|
||||
const parsed = raw ? JSON.parse(raw) : opts.providerSpecificData;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("must be a JSON object");
|
||||
}
|
||||
body.providerSpecificData = parsed;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`--provider-specific-data must be a JSON object (${error instanceof Error ? error.message : String(error)})`
|
||||
);
|
||||
}
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/** Resolve a credential from an explicit value, env reference, stdin, or prompt. */
|
||||
export async function resolveProviderCredential(opts = {}, { prompt = true } = {}) {
|
||||
// Commander represents the negated `--no-credential` option as
|
||||
// `credential === false`. It is a control flag, never the literal provider
|
||||
// credential "false".
|
||||
if (opts.credential === false || opts.noCredential === true) return undefined;
|
||||
if (!isBlank(opts.credential)) return String(opts.credential).trim();
|
||||
|
||||
const envName = String(opts.credentialEnv || opts["credential-env"] || "").trim();
|
||||
if (envName) {
|
||||
if (!ENV_NAME_RE.test(envName)) throw new Error("--credential-env must be a valid env name.");
|
||||
const value = process.env[envName];
|
||||
if (isBlank(value)) throw new Error(`Environment variable ${envName} is empty or unset.`);
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
if (opts.credentialStdin || opts["credential-stdin"]) {
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||
const value = chunks.join("").trim();
|
||||
if (!value) throw new Error("Credential stdin was empty.");
|
||||
return value;
|
||||
}
|
||||
|
||||
if (!prompt) return undefined;
|
||||
const input = createPrompt();
|
||||
try {
|
||||
const value = await input.askSecret("Provider credential (hidden)");
|
||||
const trimmed = String(value || "").trim();
|
||||
if (!trimmed) throw new Error("Provider credential is required.");
|
||||
return trimmed;
|
||||
} finally {
|
||||
input.close();
|
||||
}
|
||||
}
|
||||
|
||||
function targetOptions(opts = {}) {
|
||||
return {
|
||||
// Passing the global values through lets api.mjs apply its context-first
|
||||
// auth precedence. A caller-supplied --base-url remains an explicit target.
|
||||
baseUrl: opts.baseUrl,
|
||||
context: opts.context,
|
||||
apiKey: opts.apiKey,
|
||||
timeout: opts.timeout,
|
||||
};
|
||||
}
|
||||
|
||||
async function readApiError(response) {
|
||||
try {
|
||||
const body = await response.json();
|
||||
const message = body?.error?.message || body?.error || body?.message;
|
||||
return message ? String(message) : `HTTP ${response.status}`;
|
||||
} catch {
|
||||
return `HTTP ${response.status}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function listRemoteConnections(opts) {
|
||||
return apiFetch("/api/providers?limit=5000", {
|
||||
...targetOptions(opts),
|
||||
acceptNotOk: true,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveRemoteConnection(selector, opts) {
|
||||
const response = await listRemoteConnections(opts);
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
const connection = findConnectionFromResponse(await response.json(), selector);
|
||||
if (!connection) throw new Error(`Provider connection not found: ${selector}`);
|
||||
return connection;
|
||||
}
|
||||
|
||||
export async function runProviderAddCommand(provider, opts = {}) {
|
||||
const normalized = String(provider || "").trim();
|
||||
if (!normalized) {
|
||||
printError("Provider id is required.");
|
||||
return 2;
|
||||
}
|
||||
if (opts.oauth) {
|
||||
if (opts.dryRun) {
|
||||
if (!opts.silent) {
|
||||
const preview = { action: "providers.auth", provider: normalized };
|
||||
if (opts.json) console.log(JSON.stringify(preview, null, 2));
|
||||
else printInfo(`dry-run: would start OAuth for ${normalized}`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
return runOAuthStart({ ...opts, provider: normalized }, opts.command);
|
||||
}
|
||||
|
||||
const allowNoCredential = Boolean(
|
||||
opts.allowNoCredential || opts.noCredential || opts.credential === false
|
||||
);
|
||||
let credential;
|
||||
try {
|
||||
credential = await resolveProviderCredential(opts, {
|
||||
prompt: !opts.dryRun && !opts.yes && !allowNoCredential,
|
||||
});
|
||||
if (!credential && !opts.dryRun && !allowNoCredential) {
|
||||
throw new Error(
|
||||
"Provider credential is required (use --credential-stdin or --credential-env)."
|
||||
);
|
||||
}
|
||||
const payload = buildProviderPayload(normalized, opts, credential);
|
||||
if (opts.dryRun) {
|
||||
const preview = {
|
||||
action: "providers.add",
|
||||
provider: payload.provider,
|
||||
name: payload.name,
|
||||
defaultModel: payload.defaultModel || null,
|
||||
credential: credentialShape(credential),
|
||||
providerSpecificData: payload.providerSpecificData
|
||||
? redactProviderResponse(payload.providerSpecificData)
|
||||
: null,
|
||||
};
|
||||
if (!opts.silent) {
|
||||
if (opts.json) console.log(JSON.stringify(preview, null, 2));
|
||||
else printInfo(`dry-run: would add ${payload.provider}/${payload.name}`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const response = await apiFetch("/api/providers", {
|
||||
...targetOptions(opts),
|
||||
method: "POST",
|
||||
body: payload,
|
||||
acceptNotOk: true,
|
||||
retry: false,
|
||||
});
|
||||
if (!response.ok) {
|
||||
printError(await readApiError(response));
|
||||
return statusToExitCode(response.status);
|
||||
}
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!opts.silent) {
|
||||
if (opts.json) console.log(JSON.stringify(redactProviderResponse(body), null, 2));
|
||||
else printSuccess(`Added provider connection '${body?.connection?.name || payload.name}'.`);
|
||||
}
|
||||
return 0;
|
||||
} catch (error) {
|
||||
printError(error instanceof Error ? error.message : String(error));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runProviderImportCommand(file, opts = {}) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(file, "utf8"));
|
||||
} catch (error) {
|
||||
printError(
|
||||
`Cannot read provider import file: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
const entries = Array.isArray(parsed)
|
||||
? parsed
|
||||
: Array.isArray(parsed?.providers)
|
||||
? parsed.providers
|
||||
: [parsed];
|
||||
if (!entries.length) {
|
||||
printError("Provider import file contains no entries.");
|
||||
return 2;
|
||||
}
|
||||
const results = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry !== "object" || !entry.provider) {
|
||||
results.push({ ok: false, error: "entry.provider is required" });
|
||||
if (!opts.continueOnError) break;
|
||||
continue;
|
||||
}
|
||||
const code = await runProviderAddCommand(entry.provider, {
|
||||
...opts,
|
||||
...entry,
|
||||
credential: entry.apiKey ?? entry.credential,
|
||||
dryRun: opts.dryRun,
|
||||
yes: true,
|
||||
silent: true,
|
||||
allowNoCredential: entry.allowNoCredential ?? opts.allowNoCredential,
|
||||
});
|
||||
results.push({ provider: entry.provider, ok: code === 0, code });
|
||||
if (code !== 0 && !opts.continueOnError) break;
|
||||
}
|
||||
if (opts.json) console.log(JSON.stringify({ file, results }, null, 2));
|
||||
return results.every((result) => result.ok) ? 0 : 1;
|
||||
}
|
||||
|
||||
async function confirmRemoval(label, opts) {
|
||||
if (opts.yes) return true;
|
||||
if (!process.stdin.isTTY) {
|
||||
printError(`Removal of '${label}' declined on non-interactive stdin; pass --yes to confirm.`);
|
||||
return false;
|
||||
}
|
||||
const prompt = createPrompt();
|
||||
try {
|
||||
const answer = await prompt.ask(`Remove provider connection '${label}'? [y/N] `);
|
||||
return /^y(?:es)?$/i.test(String(answer || "").trim());
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function runProviderRemoveCommand(selector, opts = {}) {
|
||||
if (!selector) {
|
||||
printError("Provider connection id, name, or provider is required.");
|
||||
return 2;
|
||||
}
|
||||
try {
|
||||
if (opts.dryRun) {
|
||||
const connection = await resolveRemoteConnection(selector, opts);
|
||||
if (opts.json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
redactProviderResponse({ action: "providers.remove", connection }),
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} else printInfo(`dry-run: would remove ${connection.name || connection.id}`);
|
||||
return 0;
|
||||
}
|
||||
const connection = await resolveRemoteConnection(selector, opts);
|
||||
if (!(await confirmRemoval(connection.name || connection.id, opts))) return 0;
|
||||
const response = await apiFetch(`/api/providers/${encodeURIComponent(connection.id)}`, {
|
||||
...targetOptions(opts),
|
||||
method: "DELETE",
|
||||
acceptNotOk: true,
|
||||
retry: false,
|
||||
});
|
||||
if (!response.ok) {
|
||||
printError(await readApiError(response));
|
||||
return statusToExitCode(response.status);
|
||||
}
|
||||
if (opts.json)
|
||||
console.log(JSON.stringify(redactProviderResponse({ removed: connection }), null, 2));
|
||||
else printSuccess(`Removed provider connection '${connection.name || connection.id}'.`);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
printError(error instanceof Error ? error.message : String(error));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runProviderEditCommand(selector, opts = {}) {
|
||||
try {
|
||||
const connection = await resolveRemoteConnection(selector, opts);
|
||||
const body = {};
|
||||
if (opts.name !== undefined) body.name = opts.name;
|
||||
if (opts.defaultModel !== undefined) body.defaultModel = opts.defaultModel || null;
|
||||
if (opts.priority !== undefined) body.priority = Number(opts.priority);
|
||||
if (opts.active !== undefined) body.isActive = Boolean(opts.active);
|
||||
if (opts.inactive !== undefined) body.isActive = false;
|
||||
const credential = await resolveProviderCredential(opts, { prompt: false });
|
||||
if (credential) body.apiKey = credential;
|
||||
if (Object.keys(body).length === 0) {
|
||||
printError(
|
||||
"At least one edit field is required (--name, --default-model, --priority, --active/--inactive, or credential)."
|
||||
);
|
||||
return 2;
|
||||
}
|
||||
if (opts.dryRun) {
|
||||
const preview = {
|
||||
action: "providers.edit",
|
||||
connection: redactProviderResponse(connection),
|
||||
changes: { ...body, apiKey: credentialShape(body.apiKey) },
|
||||
};
|
||||
if (opts.json) console.log(JSON.stringify(preview, null, 2));
|
||||
else printInfo(`dry-run: would edit ${connection.name || connection.id}`);
|
||||
return 0;
|
||||
}
|
||||
const response = await apiFetch(`/api/providers/${encodeURIComponent(connection.id)}`, {
|
||||
...targetOptions(opts),
|
||||
method: "PUT",
|
||||
body,
|
||||
acceptNotOk: true,
|
||||
retry: false,
|
||||
});
|
||||
if (!response.ok) {
|
||||
printError(await readApiError(response));
|
||||
return statusToExitCode(response.status);
|
||||
}
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (opts.json) console.log(JSON.stringify(redactProviderResponse(result), null, 2));
|
||||
else printSuccess(`Updated provider connection '${connection.name || connection.id}'.`);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
printError(error instanceof Error ? error.message : String(error));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runProviderAuthCommand(provider, opts = {}, cmd) {
|
||||
return runOAuthStart({ ...opts, provider }, cmd);
|
||||
}
|
||||
|
||||
export function registerProviderCrud(providers) {
|
||||
providers
|
||||
.command("add <provider>")
|
||||
.description("Add an API-key provider connection through the active local/remote server")
|
||||
.option("--name <name>", "Connection name (defaults to provider id)")
|
||||
.option(
|
||||
"--credential <key>",
|
||||
"Provider credential (prefer --credential-stdin or --credential-env)"
|
||||
)
|
||||
.option("--credential-env <name>", "Read provider credential from an environment variable")
|
||||
.option("--credential-stdin", "Read provider credential from stdin")
|
||||
.option("--allow-no-credential", "Allow providers whose catalog marks the credential optional")
|
||||
.option("--no-credential", "Allow providers whose catalog marks the credential optional")
|
||||
.option("--default-model <id>", "Default model for this connection")
|
||||
.option("--priority <n>", "Connection priority", Number)
|
||||
.option("--provider-specific-data <json>", "Provider-specific settings as a JSON object")
|
||||
.option("--oauth", "Start the provider's existing OAuth flow instead")
|
||||
.option("--yes", "Do not prompt for a credential")
|
||||
.option("--dry-run", "Preview the request without writing")
|
||||
.option("--json", "Print machine-readable output")
|
||||
.action(async (provider, opts, cmd) => {
|
||||
const code = await runProviderAddCommand(provider, {
|
||||
...cmd.parent.optsWithGlobals(),
|
||||
...opts,
|
||||
command: cmd,
|
||||
});
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
|
||||
providers
|
||||
.command("import <file>")
|
||||
.description("Import provider connections from a JSON file")
|
||||
.option("--continue-on-error", "Continue importing after a failed entry")
|
||||
.option("--dry-run", "Preview requests without writing")
|
||||
.option("--json", "Print machine-readable output")
|
||||
.action(async (file, opts, cmd) => {
|
||||
const code = await runProviderImportCommand(file, {
|
||||
...cmd.parent.optsWithGlobals(),
|
||||
...opts,
|
||||
});
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
|
||||
providers
|
||||
.command("auth <provider>")
|
||||
.description("Start an existing OAuth flow for a provider")
|
||||
.option("--no-browser", "Print the authorization URL instead of opening a browser")
|
||||
.option("--import-from-system", "Import credentials from the local system when supported")
|
||||
.option("--social <provider>", "Use a social-login flow when supported")
|
||||
.option("--timeout <ms>", "OAuth timeout", Number, 300000)
|
||||
.action(async (provider, opts, cmd) => {
|
||||
const code = await runProviderAuthCommand(
|
||||
provider,
|
||||
{ ...cmd.parent.optsWithGlobals(), ...opts },
|
||||
cmd
|
||||
);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
|
||||
providers
|
||||
.command("remove <idOrName>")
|
||||
.description("Remove one provider connection from the active local/remote server")
|
||||
.option("--yes", "Confirm removal")
|
||||
.option("--dry-run", "Preview the removal without writing")
|
||||
.option("--json", "Print machine-readable output")
|
||||
.action(async (idOrName, opts, cmd) => {
|
||||
const code = await runProviderRemoveCommand(idOrName, {
|
||||
...cmd.parent.optsWithGlobals(),
|
||||
...opts,
|
||||
});
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
|
||||
providers
|
||||
.command("edit <idOrName>")
|
||||
.description("Edit one provider connection on the active local/remote server")
|
||||
.option("--name <name>", "New connection name")
|
||||
.option("--default-model <id>", "New default model")
|
||||
.option("--priority <n>", "New connection priority", Number)
|
||||
.option("--active", "Activate the connection")
|
||||
.option("--inactive", "Deactivate the connection")
|
||||
.option("--credential <key>", "Replace provider credential")
|
||||
.option("--credential-env <name>", "Read replacement credential from an environment variable")
|
||||
.option("--credential-stdin", "Read replacement credential from stdin")
|
||||
.option("--dry-run", "Preview the edit without writing")
|
||||
.option("--json", "Print machine-readable output")
|
||||
.action(async (idOrName, opts, cmd) => {
|
||||
const code = await runProviderEditCommand(idOrName, {
|
||||
...cmd.parent.optsWithGlobals(),
|
||||
...opts,
|
||||
});
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
import { encryptCredential } from "../encryption.mjs";
|
||||
import { openOmniRouteDb } from "../sqlite.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { registerProviderCrud } from "./provider-crud.mjs";
|
||||
|
||||
function publicConnection(connection) {
|
||||
return {
|
||||
@@ -129,64 +128,10 @@ function buildTestInput(connection, apiKey) {
|
||||
};
|
||||
}
|
||||
|
||||
async function testProviderConnectionThroughServer(connection) {
|
||||
try {
|
||||
const res = await apiFetch(`/api/providers/${encodeURIComponent(connection.id)}/test`, {
|
||||
method: "POST",
|
||||
body: {},
|
||||
retry: false,
|
||||
timeout: 30000,
|
||||
acceptNotOk: true,
|
||||
});
|
||||
const data = res.ok ? await res.json() : { valid: false, error: `HTTP ${res.status}` };
|
||||
return {
|
||||
connection: publicConnection(connection),
|
||||
...data,
|
||||
valid: data.valid === true,
|
||||
skipped: false,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
connection: publicConnection(connection),
|
||||
valid: false,
|
||||
skipped: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
statusCode: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function runProviderTest(db, connection, { serverUp = false } = {}) {
|
||||
// Only API-key connections can be probed with a stored credential. OAuth /
|
||||
// no-auth connections have nothing for testProviderApiKey() to send, and
|
||||
// getProviderApiKey() throws for them by design — reporting that as a FAILED
|
||||
// test marked perfectly healthy OAuth connections as broken *and* persisted
|
||||
// that verdict to provider_connections.test_status.
|
||||
if (connection.authType !== "apikey") {
|
||||
return {
|
||||
connection: publicConnection(connection),
|
||||
valid: false,
|
||||
skipped: true,
|
||||
error: `No API-key probe for ${connection.authType || "unknown"} connections`,
|
||||
};
|
||||
}
|
||||
|
||||
async function runProviderTest(db, connection) {
|
||||
try {
|
||||
const apiKey = getProviderApiKey(connection);
|
||||
const result = await testProviderApiKey(buildTestInput(connection, apiKey));
|
||||
// PROVIDER_TEST_CONFIGS only knows a handful of providers; "unsupported"
|
||||
// means the CLI has no probe recipe, not that the provider is unhealthy.
|
||||
// Persisting it would overwrite a good test_status with a failure.
|
||||
if (result.unsupported) {
|
||||
if (serverUp) {
|
||||
return testProviderConnectionThroughServer(connection);
|
||||
}
|
||||
return {
|
||||
connection: publicConnection(connection),
|
||||
...result,
|
||||
skipped: true,
|
||||
};
|
||||
}
|
||||
updateProviderTestResult(db, connection.id, result);
|
||||
return {
|
||||
connection: publicConnection(connection),
|
||||
@@ -296,7 +241,6 @@ export async function runTestCommand(selector, opts = {}) {
|
||||
}
|
||||
|
||||
export async function runTestAllCommand(opts = {}) {
|
||||
const serverUp = await isServerUp();
|
||||
const { db } = await openOmniRouteDb();
|
||||
try {
|
||||
const connections = listProviderConnections(db);
|
||||
@@ -311,7 +255,7 @@ export async function runTestAllCommand(opts = {}) {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
results.push(await runProviderTest(db, connection, { serverUp }));
|
||||
results.push(await runProviderTest(db, connection));
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
@@ -636,8 +580,6 @@ export function registerProviders(program) {
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
|
||||
registerProviderCrud(providers);
|
||||
|
||||
extendProvidersMetrics(providers);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { apiFetch, isServerUp } from "../api.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
export function registerQuota(program) {
|
||||
const quota = program
|
||||
program
|
||||
.command("quota")
|
||||
.description(t("quota.description"))
|
||||
.option("--provider <id>", "Filter by provider")
|
||||
@@ -12,60 +12,6 @@ export function registerQuota(program) {
|
||||
const exitCode = await runQuotaCommand({ ...opts, output: globalOpts.output });
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
|
||||
quota
|
||||
.command("status")
|
||||
.description("Show truthful OmniRoute gateway, quota, pool, and circuit state")
|
||||
.action(async (opts, cmd) => runBoundedJson("/api/omniroute/status", cmd.optsWithGlobals()));
|
||||
|
||||
quota
|
||||
.command("preview")
|
||||
.description("Preview allocation enforcement without an upstream request")
|
||||
.requiredOption("--api-key-id <id>", "API key id")
|
||||
.requiredOption("--pool-id <id>", "quota pool id")
|
||||
.option("--tokens <n>", "estimated token usage")
|
||||
.action(async (opts, cmd) => {
|
||||
const params = new URLSearchParams({ apiKeyId: opts.apiKeyId, poolId: opts.poolId });
|
||||
if (opts.tokens != null) params.set("estimatedTokens", opts.tokens);
|
||||
await runBoundedJson(`/api/quota/preview?${params}`, cmd.optsWithGlobals());
|
||||
});
|
||||
|
||||
quota
|
||||
.command("ensure <json>")
|
||||
.description("Idempotently create or update a quota pool from a JSON object")
|
||||
.action(async (json, opts, cmd) => {
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(json);
|
||||
} catch {
|
||||
console.error("Invalid pool JSON");
|
||||
process.exit(2);
|
||||
}
|
||||
await runBoundedJson("/api/quota/pools?ensure=true", cmd.optsWithGlobals(), {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runBoundedJson(path, opts, request = {}) {
|
||||
const started = performance.now();
|
||||
const res = await apiFetch(path, {
|
||||
...request,
|
||||
retry: false,
|
||||
timeout: Math.min(opts.timeout ?? 5000, 5000),
|
||||
acceptNotOk: true,
|
||||
});
|
||||
const elapsed = Math.round(performance.now() - started);
|
||||
if (process.env.OMNIROUTE_DEBUG === "1") {
|
||||
console.error(`[omniroute] ${request.method ?? "GET"} ${path} completed in ${elapsed}ms`);
|
||||
}
|
||||
const payload = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||
if (!res.ok) {
|
||||
console.error(JSON.stringify(payload));
|
||||
process.exit(res.exitCode ?? 1);
|
||||
}
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
}
|
||||
|
||||
export async function runQuotaCommand(opts = {}) {
|
||||
|
||||
@@ -60,7 +60,6 @@ import { registerAutostart } from "./autostart.mjs";
|
||||
import { registerRepl } from "./repl.mjs";
|
||||
import { registerLaunch } from "./launch.mjs";
|
||||
import { registerLaunchCodex } from "./launch-codex.mjs";
|
||||
import { registerRun } from "./run.mjs";
|
||||
import { registerSetupCodex } from "./setup-codex.mjs";
|
||||
import { registerSetupClaude } from "./setup-claude.mjs";
|
||||
import { registerSetupOpencode } from "./setup-opencode.mjs";
|
||||
@@ -80,7 +79,6 @@ import { registerConfigure } from "./configure.mjs";
|
||||
import { registerApiCommands } from "../api-commands/registry.mjs";
|
||||
import { registerPlugin } from "./plugin.mjs";
|
||||
import { registerRadar } from "./radar.mjs";
|
||||
import { registerPacks } from "./packs.mjs";
|
||||
|
||||
export function registerCommands(program) {
|
||||
registerMemory(program);
|
||||
@@ -146,7 +144,6 @@ export function registerCommands(program) {
|
||||
registerRepl(program);
|
||||
registerLaunch(program);
|
||||
registerLaunchCodex(program);
|
||||
registerRun(program);
|
||||
registerSetupCodex(program);
|
||||
registerSetupClaude(program);
|
||||
registerSetupOpencode(program);
|
||||
@@ -166,5 +163,4 @@ export function registerCommands(program) {
|
||||
registerApiCommands(program);
|
||||
registerPlugin(program);
|
||||
registerRadar(program);
|
||||
registerPacks(program);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createInterface } from "node:readline";
|
||||
import { Argument } from "commander";
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { mcpCallTool } from "../mcpClient.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
@@ -167,7 +166,14 @@ export function registerResilience(program) {
|
||||
])
|
||||
)
|
||||
.action(async (name, opts, cmd) => {
|
||||
await mcpCallTool("omniroute_set_resilience_profile", { profile: name });
|
||||
const res = await apiFetch("/api/mcp/tools/call", {
|
||||
method: "POST",
|
||||
body: { name: "omniroute_set_resilience_profile", arguments: { profile: name } },
|
||||
});
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(`Profile: ${name}\n`);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,609 +0,0 @@
|
||||
import {
|
||||
runLaunchCommand as runLaunchClaudeCommand,
|
||||
buildClaudeEnv,
|
||||
resolveClaudeSpawn,
|
||||
quoteClaudeArgs,
|
||||
resolveLaunchTarget,
|
||||
} from "./launch.mjs";
|
||||
import {
|
||||
buildCodexEnv,
|
||||
buildCodexProviderArgs,
|
||||
resolveCodexSpawn,
|
||||
quoteCodexArgs,
|
||||
resolveCodexTarget,
|
||||
runLaunchCodexCommand as runLaunchCodexCommand,
|
||||
} from "./launch-codex.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
import os from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawn, execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
import { quoteShellArgs } from "../utils/winShellArgs.mjs";
|
||||
import {
|
||||
listManifestTargets,
|
||||
manifestModelArgs,
|
||||
manifestRequiresModel,
|
||||
resolveManifestTarget,
|
||||
} from "../cli-manifest.mjs";
|
||||
|
||||
function isBlank(value) {
|
||||
return value === undefined || value === null || String(value).trim() === "";
|
||||
}
|
||||
|
||||
function toAuthSource(targetOpts) {
|
||||
const explicit =
|
||||
!isBlank(targetOpts.token) || !isBlank(targetOpts.apiKey) || !isBlank(targetOpts["api-key"]);
|
||||
if (explicit) return "option";
|
||||
|
||||
const envName = String(targetOpts.apiKeyEnv || targetOpts["api-key-env"] || "").trim();
|
||||
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(envName) && !isBlank(process.env[envName])) {
|
||||
return "env";
|
||||
}
|
||||
|
||||
try {
|
||||
const context = resolveActiveContext(targetOpts.context || process.env.OMNIROUTE_CONTEXT);
|
||||
if (context && (context.accessToken || context.apiKey)) return "context";
|
||||
} catch {
|
||||
// no active context
|
||||
}
|
||||
|
||||
if (!isBlank(process.env.OMNIROUTE_API_KEY)) return "env";
|
||||
if (!isBlank(process.env.ANTHROPIC_AUTH_TOKEN)) return "env";
|
||||
return "none";
|
||||
}
|
||||
|
||||
/** Resolve a token option without ever printing its value in a plan. */
|
||||
function resolveAuthTokenOption(targetOpts = {}) {
|
||||
const direct = targetOpts.token || targetOpts.apiKey || targetOpts["api-key"];
|
||||
if (!isBlank(direct)) return direct;
|
||||
|
||||
const envName = String(targetOpts.apiKeyEnv || targetOpts["api-key-env"] || "").trim();
|
||||
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(envName)) return process.env[envName];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Resolve supported target (id or alias) to canonical id via the manifest. */
|
||||
export function resolveRunTarget(target) {
|
||||
return resolveManifestTarget(target, "run");
|
||||
}
|
||||
|
||||
export function listRunTargets() {
|
||||
return listManifestTargets("run");
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize `--provider` + `--model` into one model id.
|
||||
*
|
||||
* - when model contains a slash, keep it as-is
|
||||
* - when provider exists and model does not, prefix provider/
|
||||
*/
|
||||
export function resolveModelFromTargetOptions(targetOpts = {}) {
|
||||
const provider = String(targetOpts.provider || "").trim();
|
||||
const model = String(targetOpts.model || "").trim();
|
||||
if (!model) return "";
|
||||
if (provider && !model.includes("/")) return `${provider}/${model}`;
|
||||
return model;
|
||||
}
|
||||
|
||||
function describeCommand(command, shellMode) {
|
||||
return `${command}${shellMode ? " [shell]" : ""}`;
|
||||
}
|
||||
|
||||
function envPreview(before = {}, after = {}) {
|
||||
const beforeKeys = new Set(Object.keys(before));
|
||||
const changedOrAdded = [];
|
||||
const removed = [];
|
||||
|
||||
for (const key of Object.keys(after)) {
|
||||
if (!beforeKeys.has(key) || String(before[key]) !== String(after[key])) {
|
||||
changedOrAdded.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of Object.keys(before)) {
|
||||
if (!(key in after)) removed.push(key);
|
||||
}
|
||||
|
||||
return {
|
||||
changedOrAdded,
|
||||
removed,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildClaudePlan(rawOpts, args = []) {
|
||||
const model = resolveModelFromTargetOptions(rawOpts);
|
||||
const merged = {
|
||||
...rawOpts,
|
||||
model,
|
||||
apiKey: resolveAuthTokenOption(rawOpts),
|
||||
token: resolveAuthTokenOption(rawOpts),
|
||||
profile: rawOpts.profile ?? rawOpts.p,
|
||||
};
|
||||
|
||||
const { baseUrl, authToken } = resolveLaunchTarget(merged);
|
||||
const commandSpec = await resolveClaudeSpawn(process.platform);
|
||||
|
||||
const configDir = merged.profile
|
||||
? join(merged.claudeHome || join(os.homedir(), ".claude"), "profiles", merged.profile)
|
||||
: undefined;
|
||||
|
||||
const env = buildClaudeEnv(process.env, baseUrl, authToken, {
|
||||
configDir,
|
||||
model: merged.model || undefined,
|
||||
});
|
||||
const quotedArgs = quoteClaudeArgs(args, process.platform);
|
||||
|
||||
return {
|
||||
target: "claude",
|
||||
baseUrl,
|
||||
command: commandSpec.command,
|
||||
shell: commandSpec.shell,
|
||||
args: quotedArgs,
|
||||
model: merged.model || undefined,
|
||||
envDiff: envPreview(process.env, env),
|
||||
authSource: toAuthSource(rawOpts),
|
||||
commandDisplay: describeCommand(commandSpec.command, commandSpec.shell),
|
||||
};
|
||||
}
|
||||
|
||||
async function buildCodexPlan(rawOpts, args = []) {
|
||||
const model = resolveModelFromTargetOptions(rawOpts);
|
||||
const merged = {
|
||||
...rawOpts,
|
||||
apiKey: resolveAuthTokenOption(rawOpts),
|
||||
model,
|
||||
profile: rawOpts.profile ?? rawOpts.p,
|
||||
};
|
||||
|
||||
const { baseUrl, authToken } = resolveCodexTarget(merged);
|
||||
const commandSpec = await resolveCodexSpawn(process.platform);
|
||||
|
||||
const providerArgs = buildCodexProviderArgs(baseUrl, merged.model || undefined);
|
||||
const profileArgs = merged.profile ? ["--profile", merged.profile] : [];
|
||||
|
||||
const env = buildCodexEnv(process.env, authToken);
|
||||
const fullArgs = [...providerArgs, ...profileArgs, ...args];
|
||||
const quotedArgs = quoteCodexArgs(fullArgs, process.platform);
|
||||
|
||||
return {
|
||||
target: "codex",
|
||||
baseUrl,
|
||||
command: commandSpec.command,
|
||||
shell: commandSpec.shell,
|
||||
args: quotedArgs,
|
||||
model: merged.model || undefined,
|
||||
envDiff: envPreview(process.env, env),
|
||||
authSource: toAuthSource(rawOpts),
|
||||
commandDisplay: describeCommand(commandSpec.command, commandSpec.shell),
|
||||
providerArgs,
|
||||
profileArgs,
|
||||
};
|
||||
}
|
||||
|
||||
const NO_AUTH_SENTINEL = "omniroute-no-auth";
|
||||
|
||||
function resolveGenericSpawn(command) {
|
||||
if (process.platform !== "win32") return { command, shell: undefined };
|
||||
|
||||
try {
|
||||
const output = execFileSync("where.exe", [command], {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
encoding: "utf8",
|
||||
timeout: 3000,
|
||||
windowsHide: true,
|
||||
});
|
||||
const matches = output
|
||||
.split(/\r?\n/)
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
const preferred = matches.find((value) => /\.exe$/i.test(value));
|
||||
if (preferred) return { command: preferred, shell: undefined };
|
||||
const shim = matches.find((value) => /\.(?:cmd|bat)$/i.test(value));
|
||||
if (shim) return { command: shim, shell: true };
|
||||
} catch {
|
||||
// Fall through to the conventional npm shim.
|
||||
}
|
||||
|
||||
return { command: `${command}.cmd`, shell: true };
|
||||
}
|
||||
|
||||
function genericEnv(baseEnv, kind, baseUrl, authToken, model) {
|
||||
const env = { ...baseEnv };
|
||||
for (const key of Object.keys(env)) {
|
||||
if (kind === "aider" && /^(OPENAI_API_KEY|OPENAI_API_BASE|OPENAI_BASE_URL)$/.test(key)) {
|
||||
delete env[key];
|
||||
}
|
||||
if (
|
||||
kind === "goose" &&
|
||||
(/^(OPENAI_API_KEY|OPENAI_API_BASE|OPENAI_BASE_URL)$/.test(key) || key.startsWith("GOOSE_"))
|
||||
) {
|
||||
delete env[key];
|
||||
}
|
||||
if (kind === "opencode" && key === "OPENCODE_CONFIG_CONTENT") delete env[key];
|
||||
if (kind === "qwen" && (key === "QWEN_HOME" || key === "OMNIROUTE_API_KEY")) {
|
||||
delete env[key];
|
||||
}
|
||||
if (
|
||||
kind === "gemini" &&
|
||||
/^(GOOGLE_GEMINI_BASE_URL|GEMINI_API_KEY|GOOGLE_API_KEY|GEMINI_CLI_HOME|GEMINI_DEFAULT_AUTH_TYPE|GOOGLE_GENAI_USE_VERTEXAI|GOOGLE_GENAI_USE_GCA)$/.test(
|
||||
key
|
||||
)
|
||||
) {
|
||||
delete env[key];
|
||||
}
|
||||
}
|
||||
|
||||
const token = (authToken && String(authToken).trim()) || NO_AUTH_SENTINEL;
|
||||
if (kind === "aider") {
|
||||
env.OPENAI_API_BASE = baseUrl;
|
||||
env.OPENAI_API_KEY = token;
|
||||
} else if (kind === "goose") {
|
||||
env.GOOSE_PROVIDER = "openai";
|
||||
env.OPENAI_HOST = baseUrl;
|
||||
env.OPENAI_API_KEY = token;
|
||||
if (model) env.GOOSE_MODEL = model;
|
||||
} else if (kind === "opencode") {
|
||||
env.OMNIROUTE_API_KEY = token;
|
||||
env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
omniroute: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "OmniRoute",
|
||||
options: {
|
||||
baseURL: ensureV1BaseUrl(baseUrl),
|
||||
apiKey: "{env:OMNIROUTE_API_KEY}",
|
||||
},
|
||||
...(model ? { models: { [model]: { name: model } } } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
} else if (kind === "qwen") {
|
||||
env.OMNIROUTE_API_KEY = token;
|
||||
} else if (kind === "gemini") {
|
||||
// Verified against @google/gemini-cli 0.50.0: the SDK appends
|
||||
// /v1beta/models/<model>:generateContent to this base URL, which is
|
||||
// OmniRoute's native Gemini surface. Auth is the API-key path; the
|
||||
// isolated GEMINI_CLI_HOME (set at spawn time) keeps any stored OAuth
|
||||
// session from overriding it.
|
||||
env.GOOGLE_GEMINI_BASE_URL = baseUrl;
|
||||
env.GEMINI_API_KEY = token;
|
||||
env.GEMINI_DEFAULT_AUTH_TYPE = "gemini-api-key";
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function ensureV1BaseUrl(baseUrl) {
|
||||
const normalized = String(baseUrl || "").replace(/\/+$/, "");
|
||||
return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
|
||||
}
|
||||
|
||||
function modelArgsForTarget(target, model) {
|
||||
return manifestModelArgs(target, model);
|
||||
}
|
||||
|
||||
function buildGeminiSettings() {
|
||||
// Force API-key auth in the isolated home so the operator's stored OAuth
|
||||
// session (Code Assist) never leaks into an OmniRoute-directed launch.
|
||||
return JSON.stringify({ security: { auth: { selectedType: "gemini-api-key" } } }, null, 2);
|
||||
}
|
||||
|
||||
function buildQwenSettings(baseUrl, model) {
|
||||
const qwenBaseUrl = ensureV1BaseUrl(baseUrl);
|
||||
return JSON.stringify(
|
||||
{
|
||||
modelProviders: {
|
||||
openai: [
|
||||
{
|
||||
id: model,
|
||||
name: `${model} (OmniRoute)`,
|
||||
envKey: "OMNIROUTE_API_KEY",
|
||||
baseUrl: qwenBaseUrl,
|
||||
},
|
||||
],
|
||||
},
|
||||
security: { auth: { selectedType: "openai" } },
|
||||
model: { name: model, baseUrl: qwenBaseUrl },
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
async function buildGenericPlan(target, rawOpts, args = []) {
|
||||
const { baseUrl, authToken } = resolveLaunchTarget({
|
||||
...rawOpts,
|
||||
apiKey: resolveAuthTokenOption(rawOpts),
|
||||
});
|
||||
const commandSpec = resolveGenericSpawn(target);
|
||||
const model = resolveModelFromTargetOptions(rawOpts);
|
||||
if (manifestRequiresModel(target) && !model) {
|
||||
throw new Error("Qwen Code requires --model in non-interactive OmniRoute launches");
|
||||
}
|
||||
const modelArgs = modelArgsForTarget(target, model);
|
||||
const fullArgs = [...modelArgs, ...args];
|
||||
const env = genericEnv(process.env, target, baseUrl, authToken, model);
|
||||
|
||||
return {
|
||||
target,
|
||||
baseUrl,
|
||||
command: commandSpec.command,
|
||||
shell: commandSpec.shell,
|
||||
args: quoteShellArgs(fullArgs, process.platform),
|
||||
model: model || undefined,
|
||||
envDiff: envPreview(process.env, env),
|
||||
authSource: toAuthSource(rawOpts),
|
||||
commandDisplay: describeCommand(commandSpec.command, commandSpec.shell),
|
||||
modelArgs,
|
||||
configOverlay:
|
||||
target === "qwen"
|
||||
? "temporary QWEN_HOME (removed after exit)"
|
||||
: target === "gemini"
|
||||
? "temporary GEMINI_CLI_HOME (removed after exit)"
|
||||
: target === "opencode"
|
||||
? "OPENCODE_CONFIG_CONTENT (process environment only)"
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function healthCheckForRun(baseUrl) {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/monitoring/health`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runGenericTarget(target, rawOpts, args) {
|
||||
const { baseUrl, authToken } = resolveLaunchTarget({
|
||||
...rawOpts,
|
||||
apiKey: resolveAuthTokenOption(rawOpts),
|
||||
});
|
||||
if (!(await healthCheckForRun(baseUrl))) {
|
||||
console.error(`OmniRoute is not reachable at ${baseUrl}. Start it or check --remote.`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const model = resolveModelFromTargetOptions(rawOpts);
|
||||
if (manifestRequiresModel(target) && !model) {
|
||||
console.error("Qwen Code requires --model in non-interactive OmniRoute launches.");
|
||||
return 2;
|
||||
}
|
||||
const modelArgs = modelArgsForTarget(target, model);
|
||||
const commandSpec = resolveGenericSpawn(target);
|
||||
const childEnv = genericEnv(process.env, target, baseUrl, authToken, model);
|
||||
let overlayHome;
|
||||
if (target === "qwen") {
|
||||
overlayHome = mkdtempSync(join(os.tmpdir(), "omniroute-qwen-run-"));
|
||||
writeFileSync(join(overlayHome, "settings.json"), buildQwenSettings(baseUrl, model), {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
childEnv.QWEN_HOME = overlayHome;
|
||||
} else if (target === "gemini") {
|
||||
overlayHome = mkdtempSync(join(os.tmpdir(), "omniroute-gemini-run-"));
|
||||
mkdirSync(join(overlayHome, ".gemini"), { recursive: true });
|
||||
writeFileSync(join(overlayHome, ".gemini", "settings.json"), buildGeminiSettings(), {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
childEnv.GEMINI_CLI_HOME = overlayHome;
|
||||
}
|
||||
|
||||
const child = spawn(
|
||||
commandSpec.command,
|
||||
quoteShellArgs([...modelArgs, ...args], process.platform),
|
||||
{
|
||||
env: childEnv,
|
||||
stdio: "inherit",
|
||||
shell: commandSpec.shell,
|
||||
...(process.platform === "win32" ? { windowsHide: true } : {}),
|
||||
}
|
||||
);
|
||||
|
||||
const cleanup = () => {
|
||||
if (!overlayHome) return;
|
||||
try {
|
||||
rmSync(overlayHome, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Best-effort cleanup; the directory contains no persistent credentials.
|
||||
}
|
||||
};
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const signalExitCode = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 };
|
||||
const finish = (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
for (const signal of Object.keys(signalExitCode)) {
|
||||
process.removeListener(signal, signalHandlers[signal]);
|
||||
}
|
||||
cleanup();
|
||||
resolve(code);
|
||||
};
|
||||
const signalHandlers = {};
|
||||
for (const signal of Object.keys(signalExitCode)) {
|
||||
signalHandlers[signal] = () => {
|
||||
try {
|
||||
child.kill(signal);
|
||||
} catch {
|
||||
// The child may have already exited between the signal and cleanup.
|
||||
}
|
||||
finish(signalExitCode[signal]);
|
||||
};
|
||||
process.once(signal, signalHandlers[signal]);
|
||||
}
|
||||
child.on("error", (error) => {
|
||||
if (error?.code === "ENOENT") {
|
||||
console.error(`The '${target}' CLI was not found in PATH.`);
|
||||
finish(127);
|
||||
} else {
|
||||
console.error(String(error?.message || error));
|
||||
finish(1);
|
||||
}
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
finish(code ?? signalExitCode[signal] ?? 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Build a launch plan and redact any resolved secret values. */
|
||||
export async function buildRunPlan(target, rawOpts = {}, args = []) {
|
||||
const canonical = resolveRunTarget(target);
|
||||
if (!canonical) {
|
||||
throw new Error(
|
||||
`Unsupported target '${target}'. Supported targets: ${listRunTargets().join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
if (canonical === "claude") {
|
||||
return buildClaudePlan(rawOpts, args);
|
||||
}
|
||||
|
||||
if (canonical === "codex") {
|
||||
return buildCodexPlan(rawOpts, args);
|
||||
}
|
||||
|
||||
return buildGenericPlan(canonical, rawOpts, args);
|
||||
}
|
||||
|
||||
function writeDryRunOutput(plan, opts = {}) {
|
||||
const output = {
|
||||
target: plan.target,
|
||||
baseUrl: plan.baseUrl,
|
||||
command: plan.command,
|
||||
args: plan.args,
|
||||
auth: {
|
||||
source: plan.authSource,
|
||||
present: plan.authSource !== "none",
|
||||
},
|
||||
shell: !!plan.shell,
|
||||
model: plan.model || null,
|
||||
configOverlay: plan.configOverlay || null,
|
||||
env: {
|
||||
changedOrAdded: plan.envDiff.changedOrAdded,
|
||||
removed: plan.envDiff.removed,
|
||||
},
|
||||
};
|
||||
|
||||
if (opts.json) {
|
||||
console.error(`Running in dry-run mode for '${plan.target}'.`);
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
} else {
|
||||
console.log(`target: ${output.target}`);
|
||||
console.log(`baseUrl: ${output.baseUrl}`);
|
||||
console.log(`command: ${output.command}`);
|
||||
console.log(`shell: ${output.shell ? "yes" : "no"}`);
|
||||
console.log(`args: ${JSON.stringify(output.args)}`);
|
||||
console.log(`auth: ${JSON.stringify(output.auth)}`);
|
||||
console.log(`model: ${output.model || "(not set)"}`);
|
||||
if (output.configOverlay) console.log(`config overlay: ${output.configOverlay}`);
|
||||
if (output.env.changedOrAdded.length) {
|
||||
console.log(`env added/changed: ${output.env.changedOrAdded.join(", ")}`);
|
||||
}
|
||||
if (output.env.removed.length) {
|
||||
console.log(`env removed: ${output.env.removed.join(", ")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildExecutionOptionsForClaude(rawOpts) {
|
||||
return {
|
||||
...rawOpts,
|
||||
model: resolveModelFromTargetOptions(rawOpts),
|
||||
token: resolveAuthTokenOption(rawOpts),
|
||||
apiKey: resolveAuthTokenOption(rawOpts),
|
||||
profile: rawOpts.profile || rawOpts.p,
|
||||
};
|
||||
}
|
||||
|
||||
function buildExecutionOptionsForCodex(rawOpts) {
|
||||
return {
|
||||
...rawOpts,
|
||||
model: resolveModelFromTargetOptions(rawOpts),
|
||||
apiKey: resolveAuthTokenOption(rawOpts),
|
||||
profile: rawOpts.profile || rawOpts.p,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute or preview one target launch.
|
||||
*
|
||||
* Return code conventions:
|
||||
* 0 success, 1 runtime launch failure, 2 invalid args.
|
||||
*/
|
||||
export async function runCliTarget(target, opts = {}, args = []) {
|
||||
const canonical = resolveRunTarget(target);
|
||||
if (!canonical) {
|
||||
process.stderr.write(
|
||||
`Unsupported target '${target}'. Supported targets: ${listRunTargets().join(", ")}\n`
|
||||
);
|
||||
return 2;
|
||||
}
|
||||
|
||||
let plan;
|
||||
try {
|
||||
plan = await buildRunPlan(target, opts, args);
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (opts.dryRun) {
|
||||
writeDryRunOutput(plan, opts);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (canonical === "claude") {
|
||||
return await runLaunchClaudeCommand(buildExecutionOptionsForClaude(opts), args);
|
||||
}
|
||||
|
||||
if (canonical === "codex") {
|
||||
return await runLaunchCodexCommand(buildExecutionOptionsForCodex(opts), args);
|
||||
}
|
||||
|
||||
return await runGenericTarget(canonical, opts, args);
|
||||
}
|
||||
|
||||
export function registerRun(program) {
|
||||
program
|
||||
.command("run <target>")
|
||||
.description(t("run.description") || "Run a supported CLI target through OmniRoute")
|
||||
.option(
|
||||
"--port <port>",
|
||||
"Local OmniRoute port (ignored when --remote or --base-url is set)",
|
||||
"20128"
|
||||
)
|
||||
.option(
|
||||
"--remote <url>",
|
||||
"Remote OmniRoute base URL (overrides --port, --base-url, and the active context)"
|
||||
)
|
||||
.option("--base-url <url>", "OmniRoute base URL (alias for --remote)")
|
||||
.option("--context <name>", "Named local/remote context to use for URL and credentials")
|
||||
.option("--provider <id>", "Provider id for shorthand model composition")
|
||||
.option("--model <id>", "Model id to inject in the launched target where supported")
|
||||
.option("--profile <name>", "Profile/alias argument for target launchers that support it")
|
||||
.option("-p, --p <name>", "Alias for --profile")
|
||||
.option("--token <token>", "Authentication token for the launched target (same as --api-key)")
|
||||
.option("--api-key <key>", "Authentication token for the launched target")
|
||||
.option("--api-key-env <name>", "Read the launch token from an environment variable")
|
||||
.option("--dry-run", "Show planned command and env keys without executing")
|
||||
.option("--json", "Return dry-run output in machine-readable format")
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.argument("[toolArgs...]")
|
||||
.action(async (target, toolArgs = [], opts, cmd) => {
|
||||
const globalOpts = cmd?.optsWithGlobals ? cmd.optsWithGlobals() : {};
|
||||
const merged = { ...globalOpts, ...opts };
|
||||
const code = await runCliTarget(target, merged, toolArgs);
|
||||
// process.exit() here can interrupt cleanup when the child terminates;
|
||||
// setting process.exitCode lets the event loop drain first.
|
||||
process.exitCode = code;
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { platform, totalmem } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { platform, totalmem, hostname as osHostname } from "node:os";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { writePidFile, cleanupPidFile, waitForServer } from "../utils/pid.mjs";
|
||||
import { ServerSupervisor, detectMitmCrash } from "../runtime/processSupervisor.mjs";
|
||||
@@ -12,15 +12,13 @@ import {
|
||||
isFatalInstrumentationHookFailure,
|
||||
formatAndroidInstrumentationFailureHint,
|
||||
} from "../utils/ensureAndroidCacheDir.mjs";
|
||||
import { resolveServerHost, resolveExposureWarning } from "../utils/serverHost.mjs";
|
||||
import {
|
||||
resolveMaxOldSpaceMb,
|
||||
calibrateHeapFallbackMb,
|
||||
buildServerNodeOptions,
|
||||
buildNodeHeapArgs,
|
||||
buildNodeRuntimeArgs,
|
||||
} from "../../../scripts/build/runtime-env.mjs";
|
||||
import { resolveTlsOptions } from "../../../scripts/dev/tls-options.mjs";
|
||||
import { startDetachedTray, validateTrayOptions } from "../tray/detachedTray.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const _pkg = JSON.parse(readFileSync(join(__dirname, "..", "..", "..", "package.json"), "utf8"));
|
||||
@@ -43,7 +41,7 @@ function parsePort(value, fallback) {
|
||||
}
|
||||
|
||||
export function registerServe(program) {
|
||||
const command = program
|
||||
program
|
||||
.command("serve", { isDefault: true })
|
||||
.description(t("serve.description"))
|
||||
.option("--port <port>", t("serve.port"))
|
||||
@@ -52,7 +50,7 @@ export function registerServe(program) {
|
||||
.option("--log", t("serve.log"))
|
||||
.option("--no-recovery", t("serve.no_recovery"))
|
||||
.option("--max-restarts <n>", t("serve.max_restarts"), parseInt, 2)
|
||||
.option("--tray", t("serve.tray") || "Start in the system tray (desktop only)")
|
||||
.option("--tray", t("serve.tray") || "Show system tray icon (desktop only)")
|
||||
.option("--no-tray", t("serve.no_tray") || "Disable system tray icon")
|
||||
.option(
|
||||
"--tls-cert <path>",
|
||||
@@ -67,9 +65,6 @@ export function registerServe(program) {
|
||||
.action(async (opts) => {
|
||||
await runServe(opts);
|
||||
});
|
||||
command.addOption(command.createOption("--tray-worker").hideHelp());
|
||||
command.addOption(command.createOption("--tray-ready-port <port>").hideHelp());
|
||||
command.addOption(command.createOption("--tray-ready-token <token>").hideHelp());
|
||||
}
|
||||
|
||||
/** Once-per-process guard so the Android/Termux cache hint is not spammed. */
|
||||
@@ -99,32 +94,6 @@ export function resetInstrumentationFailureHintForTests() {
|
||||
export async function runServe(opts = {}) {
|
||||
const startedAt = performance.now();
|
||||
|
||||
const trayOptionError = validateTrayOptions(opts);
|
||||
if (trayOptionError) throw new Error(trayOptionError);
|
||||
|
||||
if (opts.tray === true && opts.trayWorker !== true) {
|
||||
const port = parsePort(opts.port ?? process.env.PORT ?? "20128", 20128);
|
||||
const tlsCert = opts.tlsCert ?? process.env.OMNIROUTE_TLS_CERT;
|
||||
const tlsKey = opts.tlsKey ?? process.env.OMNIROUTE_TLS_KEY;
|
||||
urlScheme = resolveTlsOptions({
|
||||
...process.env,
|
||||
...(tlsCert ? { OMNIROUTE_TLS_CERT: tlsCert } : {}),
|
||||
...(tlsKey ? { OMNIROUTE_TLS_KEY: tlsKey } : {}),
|
||||
})
|
||||
? "https"
|
||||
: "http";
|
||||
const result = await startDetachedTray({
|
||||
cliPath: join(ROOT, "bin", "omniroute.mjs"),
|
||||
port,
|
||||
maxRestarts: opts.maxRestarts ?? 2,
|
||||
tlsCert,
|
||||
tlsKey,
|
||||
});
|
||||
console.log(`\x1b[32m✔ OmniRoute tray started in background\x1b[0m`);
|
||||
console.log(` \x1b[1mDashboard:\x1b[0m ${urlScheme}://localhost:${port}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Same prep as bin/omniroute.mjs — keep it here so a direct `runServe()` call
|
||||
// (tests / programmatic) still gets a writable Next.js cache dir before spawn.
|
||||
ensureAndroidCacheDir({ env: process.env });
|
||||
@@ -162,15 +131,6 @@ export async function runServe(opts = {}) {
|
||||
`);
|
||||
}
|
||||
|
||||
// GHSA-wmgv-ph3p-rv57: the default posture (all interfaces + no API key) is a
|
||||
// deliberate local-first choice, but it must be loud at startup — an operator
|
||||
// on an untrusted network learns the two escape hatches here, not after a
|
||||
// surprise quota bill.
|
||||
const exposureWarning = resolveExposureWarning();
|
||||
if (exposureWarning) {
|
||||
console.warn(`\x1b[33m ⚠ ${exposureWarning}\x1b[0m\n`);
|
||||
}
|
||||
|
||||
const serverWsJs = join(APP_DIR, "server-ws.mjs");
|
||||
const serverJs = existsSync(serverWsJs) ? serverWsJs : join(APP_DIR, "server.js");
|
||||
|
||||
@@ -247,10 +207,16 @@ export async function runServe(opts = {}) {
|
||||
PORT: String(dashboardPort),
|
||||
DASHBOARD_PORT: String(dashboardPort),
|
||||
API_PORT: String(apiPort),
|
||||
// #10492: HOSTNAME is standard shell state on Unix-like systems, not an
|
||||
// OmniRoute bind setting. The resolver only keeps its legacy meaning on
|
||||
// Windows; OMNIROUTE_SERVER_HOST is the cross-platform explicit setting.
|
||||
HOSTNAME: resolveServerHost(),
|
||||
// #6194: POSIX shells (bash/zsh) auto-set HOSTNAME to the machine name — the
|
||||
// .env loader (first-wins) can never override it. Ignore HOSTNAME when it
|
||||
// matches the OS-reported hostname (the auto-set signature). OMNIROUTE_SERVER_HOST
|
||||
// takes precedence; legacy HOSTNAME values that don't match os.hostname() are
|
||||
// still honoured for backward compatibility (e.g. Windows CMD/PowerShell users
|
||||
// who set HOSTNAME in .env where it is NOT auto-set).
|
||||
HOSTNAME:
|
||||
process.env.OMNIROUTE_SERVER_HOST ||
|
||||
(process.env.HOSTNAME !== osHostname() ? process.env.HOSTNAME : undefined) ||
|
||||
"0.0.0.0",
|
||||
NODE_ENV: "production",
|
||||
// #5238: preserve a user-set NODE_OPTIONS (incl. their own
|
||||
// `--max-old-space-size=…`) instead of clobbering it with the calibrated
|
||||
@@ -294,8 +260,7 @@ export async function runServe(opts = {}) {
|
||||
opts.log === true,
|
||||
opts.maxRestarts ?? 2,
|
||||
startedAt,
|
||||
useTray,
|
||||
{ trayReadyPort: opts.trayReadyPort, trayReadyToken: opts.trayReadyToken }
|
||||
useTray
|
||||
);
|
||||
}
|
||||
|
||||
@@ -304,12 +269,7 @@ function runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort) {
|
||||
// heap via NODE_OPTIONS (a CLI arg would shadow/override their value).
|
||||
const server = spawn(
|
||||
process.versions.bun ? process.execPath : "node",
|
||||
[
|
||||
...(process.versions.bun
|
||||
? ["--preload", join(APP_DIR, "open-sse/utils/setupPolyfill.ts")]
|
||||
: buildNodeHeapArgs(process.env, memoryLimit)),
|
||||
serverJs,
|
||||
],
|
||||
process.versions.bun ? [serverJs] : buildNodeRuntimeArgs(process.env, memoryLimit, serverJs),
|
||||
{
|
||||
cwd: APP_DIR,
|
||||
env,
|
||||
@@ -329,12 +289,7 @@ function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort,
|
||||
// heap via NODE_OPTIONS (a CLI arg would shadow/override their value).
|
||||
const server = spawn(
|
||||
process.versions.bun ? process.execPath : "node",
|
||||
[
|
||||
...(process.versions.bun
|
||||
? ["--preload", join(APP_DIR, "open-sse/utils/setupPolyfill.ts")]
|
||||
: buildNodeHeapArgs(process.env, memoryLimit)),
|
||||
serverJs,
|
||||
],
|
||||
process.versions.bun ? [serverJs] : buildNodeRuntimeArgs(process.env, memoryLimit, serverJs),
|
||||
{
|
||||
cwd: APP_DIR,
|
||||
env,
|
||||
@@ -408,11 +363,9 @@ async function runWithSupervisor(
|
||||
showLog,
|
||||
maxRestarts,
|
||||
startedAt,
|
||||
useTray = false,
|
||||
{ trayReadyPort, trayReadyToken } = {}
|
||||
useTray = false
|
||||
) {
|
||||
if (showLog) process.env.OMNIROUTE_SHOW_LOG = "1";
|
||||
writePidFile("supervisor", process.pid);
|
||||
|
||||
const supervisor = new ServerSupervisor({
|
||||
serverPath: serverJs,
|
||||
@@ -423,7 +376,7 @@ async function runWithSupervisor(
|
||||
if (detectMitmCrash(crashLog)) {
|
||||
try {
|
||||
const PROJECT_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
const { updateSettings } = await import(pathToFileURL(join(PROJECT_ROOT, "src/lib/db/settings.ts")).href);
|
||||
const { updateSettings } = await import(`${PROJECT_ROOT}/src/lib/db/settings.ts`);
|
||||
updateSettings({ mitmEnabled: false });
|
||||
} catch {}
|
||||
return "disable-mitm-and-retry";
|
||||
@@ -434,6 +387,11 @@ async function runWithSupervisor(
|
||||
|
||||
supervisor.start();
|
||||
|
||||
// #9455: persist the supervisor's own PID so `omniroute stop` can SIGTERM it
|
||||
// before the child — the supervisor's SIGTERM handler sets isShuttingDown=true,
|
||||
// kills the child, and exits cleanly, so the child is never respawned after stop.
|
||||
writePidFile("supervisor", process.pid);
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
killTrayIfActive();
|
||||
cleanupPidFile("supervisor");
|
||||
@@ -448,26 +406,7 @@ async function runWithSupervisor(
|
||||
if (!showLog) {
|
||||
waitForServer(dashboardPort, 60000).then(async (up) => {
|
||||
if (up) {
|
||||
if (useTray) {
|
||||
const trayReady = await maybeStartTray(dashboardPort, apiPort, supervisor);
|
||||
if (!trayReady) {
|
||||
cleanupPidFile("supervisor");
|
||||
supervisor.stop();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (trayReadyPort && trayReadyToken) {
|
||||
const { notifyTrayReady } = await import("../tray/detachedTray.mjs");
|
||||
try {
|
||||
await notifyTrayReady(parsePort(trayReadyPort, 0), trayReadyToken);
|
||||
} catch {
|
||||
cleanupPidFile("supervisor");
|
||||
supervisor.stop();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (useTray) await maybeStartTray(dashboardPort, apiPort, supervisor);
|
||||
onReady(dashboardPort, apiPort, noOpen, startedAt);
|
||||
} else {
|
||||
reportReadinessTimeout(dashboardPort, supervisor);
|
||||
@@ -514,30 +453,29 @@ function killTrayIfActive() {
|
||||
async function maybeStartTray(port, apiPort, supervisor) {
|
||||
try {
|
||||
const { initTray, isTraySupported } = await import("../tray/index.mjs");
|
||||
if (!isTraySupported()) return false;
|
||||
if (!isTraySupported()) return;
|
||||
const { default: open } = await import("open").catch(() => ({ default: null }));
|
||||
const dashboardUrl = `${urlScheme}://localhost:${port}`;
|
||||
const tray = await initTray({
|
||||
port,
|
||||
onQuit: () => {
|
||||
killTrayIfActive();
|
||||
cleanupPidFile("supervisor");
|
||||
supervisor.stop();
|
||||
},
|
||||
onOpenDashboard: () => open?.(dashboardUrl),
|
||||
onShowLogs: () => open?.(`${dashboardUrl}/dashboard/logs`),
|
||||
onShowLogs: () => {
|
||||
// In-place: open logs stream (best-effort)
|
||||
process.stdout.write(`[omniroute][tray] Logs at: ${dashboardUrl}/logs\n`);
|
||||
},
|
||||
});
|
||||
if (tray) {
|
||||
const { killTray } = await import("../tray/index.mjs");
|
||||
_killTray = killTray;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (err) {
|
||||
// tray is optional — do not fail the server, but surface why it failed so
|
||||
// "--tray shows nothing" is diagnosable instead of silent (#4605).
|
||||
process.stderr.write(`[omniroute][tray] failed to start: ${err?.message ?? String(err)}\n`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
|
||||
|
||||
function stripToRoot(url) {
|
||||
const s = String(url || "").replace(/\/+$/, "");
|
||||
@@ -26,9 +25,7 @@ export function resolveAiderTarget(opts = {}) {
|
||||
if (opts.remote) root = stripToRoot(opts.remote);
|
||||
else {
|
||||
try {
|
||||
root = stripToRoot(
|
||||
resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl
|
||||
);
|
||||
root = stripToRoot(resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl);
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
@@ -81,7 +78,7 @@ async function fetchModelIds(apiBase, 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 ?? []);
|
||||
const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
|
||||
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
@@ -91,16 +88,7 @@ async function fetchModelIds(apiBase, apiKey) {
|
||||
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");
|
||||
|
||||
const guard = await guardHostConfigTarget(configPath, {
|
||||
toolLabel: "Aider",
|
||||
hostCommand: "omniroute setup-aider",
|
||||
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
|
||||
dryRun,
|
||||
});
|
||||
if (guard !== 0) return guard;
|
||||
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)`);
|
||||
@@ -119,9 +107,7 @@ export async function runSetupAiderCommand(opts = {}) {
|
||||
}
|
||||
}
|
||||
if (!model) {
|
||||
printError(
|
||||
"A model is required. Pass --model <id> (the openai/ prefix is added automatically)."
|
||||
);
|
||||
printError("A model is required. Pass --model <id> (the openai/ prefix is added automatically).");
|
||||
return 2;
|
||||
}
|
||||
|
||||
@@ -153,10 +139,6 @@ export function registerSetupAider(program) {
|
||||
.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")
|
||||
.option(
|
||||
"--allow-container-write",
|
||||
"Write even when the target is inside a container and not mounted from the host"
|
||||
)
|
||||
.action(async (opts) => {
|
||||
const code = await runSetupAiderCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
|
||||
@@ -20,7 +20,6 @@ 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 { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
|
||||
import {
|
||||
categoriseModel,
|
||||
isCodexCompatibleTextModel,
|
||||
@@ -148,14 +147,6 @@ export async function runSetupClaudeCommand(opts = {}) {
|
||||
printHeading("OmniRoute → Claude Code profile generator");
|
||||
printInfo(`Connecting to ${baseUrl} …`);
|
||||
|
||||
const guard = await guardHostConfigTarget(profilesRoot, {
|
||||
toolLabel: "Claude Code",
|
||||
hostCommand: "omniroute setup-claude",
|
||||
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
|
||||
dryRun,
|
||||
});
|
||||
if (guard !== 0) return guard;
|
||||
|
||||
// ── Fetch model catalog ───────────────────────────────────────────────────
|
||||
let models;
|
||||
try {
|
||||
@@ -169,8 +160,7 @@ export async function runSetupClaudeCommand(opts = {}) {
|
||||
let detail = `HTTP ${res.status}`;
|
||||
try {
|
||||
const errorBody = await res.json();
|
||||
const serverMsg =
|
||||
errorBody?.error?.message || errorBody?.error || errorBody?.message || "";
|
||||
const serverMsg = errorBody?.error?.message || errorBody?.error || errorBody?.message || "";
|
||||
if (serverMsg) detail += ` — ${serverMsg}`;
|
||||
} catch {}
|
||||
throw new Error(detail);
|
||||
@@ -230,10 +220,6 @@ export function registerSetupClaude(program) {
|
||||
"Comma-separated substrings — only matching model IDs (e.g. glm,kimi)"
|
||||
)
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.option(
|
||||
"--allow-container-write",
|
||||
"Write even when the target is inside a container and not mounted from the host"
|
||||
)
|
||||
.action(async (opts) => {
|
||||
const exitCode = await runSetupClaudeCommand(opts);
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user