mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-18 21:22:28 +03:00
Compare commits
2 Commits
fix/releas
...
fix/10225-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d473172c1 | ||
|
|
cad308942b |
107
.env.example
107
.env.example
@@ -246,11 +246,6 @@ OMNIROUTE_USE_TURBOPACK=1
|
||||
# hints in production logs.
|
||||
# OMNIROUTE_PROXY_FETCH_DEBUG=true
|
||||
|
||||
# Set to "true" or "1" to include client/egress IPs and the account prefix in
|
||||
# the verbose `[ProxyEgress]` process-log line (src/lib/proxyLogger.ts). Kept
|
||||
# OFF by default so the process log does not leak IPs or the account prefix.
|
||||
# PROXY_LOG_INCLUDE_IPS=true
|
||||
|
||||
# Set to any non-empty value to emit `[omniroute completion]` diagnostics from
|
||||
# the CLI shell-completion cache paths (read/refresh/write) in
|
||||
# bin/cli/commands/completion.mjs. Off by default — these caches fail silently
|
||||
@@ -375,16 +370,6 @@ ALLOW_API_KEY_REVEAL=false
|
||||
# OMNIROUTE_CHAT_HARD_MAX_BODY_BYTES=52428800
|
||||
# Maximum heavyweight requests simultaneously admitted in one process. Default 1.
|
||||
# OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT=1
|
||||
# Heap-pressure shed ratio (heapUsed/heap_size_limit) for the structural admission gate
|
||||
# (#10183, #10268): a second concurrent heavyweight request past OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT
|
||||
# is only shed with a retryable 503 when the heap is ALSO under this much pressure — on a
|
||||
# healthy heap it is admitted instead. Range (0, 1]. Default 0.75.
|
||||
# OMNIROUTE_CHAT_ADMISSION_HEAP_SHED_RATIO=0.75
|
||||
# Bounded extra capacity for the healthy-heap fast path above OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT
|
||||
# (#10437): once this many concurrent leases are active through the healthy-heap bypass,
|
||||
# further busy requests fall through to the same bounded-wait/shed path used under real heap
|
||||
# pressure. 0 disables the bypass entirely. Default 1.
|
||||
# OMNIROUTE_CHAT_ADMISSION_HEALTHY_HEADROOM=1
|
||||
# Message count that classifies an otherwise small body as heavyweight. Default 200.
|
||||
# OMNIROUTE_CHAT_HEAVY_MESSAGE_COUNT=200
|
||||
# Tool count that classifies an otherwise small body as heavyweight. Default 64.
|
||||
@@ -408,12 +393,6 @@ ALLOW_API_KEY_REVEAL=false
|
||||
# OMNIROUTE_CHAT_VIRTUAL_TTL_MS=60000
|
||||
# Per-connection virtual admission lanes (#9654): max concurrent sessions (lanes). Default 64.
|
||||
# OMNIROUTE_CHAT_VIRTUAL_MAX_SESSIONS=64
|
||||
# Adaptive runtime virtual admission lanes (#9654): master switch for the per-tenant
|
||||
# adaptive gate (system 2, open-sse/services/admission). NOTE: the TTL/MAX_SESSIONS
|
||||
# vars above tune the byte-level per-connection lanes (system 1); this switch enables
|
||||
# the adaptive runtime lanes. Dashboard feature flag of the same name; env wins over
|
||||
# the dashboard override; restart required. Default: off.
|
||||
# OMNIROUTE_CHAT_VIRTUAL_LANES=1
|
||||
|
||||
# Hard cap (bytes) for a non-streaming upstream response buffered fully into memory
|
||||
# (#5152). Past this the upstream reader is cancelled and the request fails fast
|
||||
@@ -735,16 +714,6 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
||||
# Allow OmniRoute to write CLI config files (token refresh, etc.).
|
||||
# CLI_ALLOW_CONFIG_WRITES=true
|
||||
|
||||
# Force container detection on (1/true) or off (0/false). Leave unset for auto-detect
|
||||
# via /.dockerenv, /run/.containerenv, cgroup markers, or KUBERNETES_SERVICE_HOST.
|
||||
# Used by: src/shared/utils/containerEnv.ts — gates ephemeral-home CLI config writes.
|
||||
# OMNIROUTE_CONTAINER=1
|
||||
|
||||
# Allow CLI-tool config writes into an unmounted container path anyway (default off).
|
||||
# Prefer host-side `omniroute configure` / Remote Mode, or a bind-mounted CLI_CONFIG_HOME.
|
||||
# CLI equivalent: --allow-container-write. Used by: src/shared/utils/containerEnv.ts
|
||||
# OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE=true
|
||||
|
||||
# Auto-sync CLI profile files after provider model discovery changes. OPT-IN, default OFF for
|
||||
# both. When enabled, writes only the tool's profile files (~/.codex/*.config.toml or
|
||||
# ~/.claude/profiles/<name>/settings.json); never changes the active/default config. Both also
|
||||
@@ -763,27 +732,9 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
||||
# CLI_CONTINUE_BIN=cn
|
||||
# CLI_QODER_BIN=qoder
|
||||
# CLI_QWEN_BIN=qwen
|
||||
# CLI_AIDER_BIN=aider
|
||||
# CLI_GOOSE_BIN=goose
|
||||
# CLI_GEMINI_BIN=gemini
|
||||
# CLI_AUGGIE_BIN=auggie
|
||||
# AUGGIE_BIN=auggie
|
||||
|
||||
# ── ZCode (Z.ai GLM coding-plan CLI) local provider ──
|
||||
# The local "zcode" provider talks to the authenticated ZCode app-server over a
|
||||
# custom framed stdio protocol. Overrides below tune that stdio lifecycle.
|
||||
# ZCODE_BIN=zcode
|
||||
# ZCODE_ARGS=["--some-flag"]
|
||||
# ZCODE_CWD=
|
||||
# ZCODE_PROVIDER_ID=builtin:zai-coding-plan
|
||||
# ZCODE_SERVER_RUNTIME_ROOT=~/.zcode/server
|
||||
# ZCODE_SERVER_NODE=~/.zcode/server/node
|
||||
# ZCODE_SERVER_ENTRY=~/.zcode/server/zcode-server.cjs
|
||||
# ZCODE_STARTUP_TIMEOUT_MS=10000
|
||||
# ZCODE_RPC_TIMEOUT_MS=30000
|
||||
# ZCODE_TURN_TIMEOUT_MS=120000
|
||||
# ZCODE_POLL_INTERVAL_MS=250
|
||||
|
||||
# Override the Hermes Agent home directory (where OmniRoute reads/writes the
|
||||
# Hermes CLI config). Matches the env var the Hermes PowerShell installer sets
|
||||
# on Windows (%LOCALAPPDATA%\hermes); defaults to ~/.hermes when unset.
|
||||
@@ -824,13 +775,6 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
||||
# Used by: bin/cli/program.mjs, bin/cli/api.mjs (remote mode).
|
||||
# OMNIROUTE_CONTEXT=
|
||||
|
||||
# Disable the optional OS keychain backend for CLI remote-context credentials.
|
||||
# When enabled, context tokens stay in config.json with mode 0600 and the CLI
|
||||
# prints a one-time fallback warning. Useful for deliberate headless/container
|
||||
# operation; leave unset to use keytar when the native backend is available.
|
||||
# Used by: bin/cli/contexts.mjs.
|
||||
# OMNIROUTE_CONTEXT_KEYCHAIN_DISABLED=0
|
||||
|
||||
# Enforce scope-based access control on MCP tool calls.
|
||||
# Used by: open-sse/mcp-server/server.ts — rejects calls outside allowed scopes.
|
||||
# OMNIROUTE_MCP_ENFORCE_SCOPES=false
|
||||
@@ -1284,14 +1228,6 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# hatches that are referenced in code today.
|
||||
# DEEPSEEK_API_KEY=
|
||||
# NVIDIA_API_KEY=
|
||||
# Jina Foundation API + Reader fallback when no dashboard jina-ai / jina-reader
|
||||
# connection exists. Dashboard keys always win (fill-first).
|
||||
# JINA_AI_API_KEY=
|
||||
# JINA_API_KEY=
|
||||
# Gemini / Google AI Studio embeddings fallback when no dashboard gemini
|
||||
# connection exists. Dashboard keys always win (fill-first).
|
||||
# GEMINI_API_KEY=
|
||||
# GOOGLE_API_KEY=
|
||||
|
||||
# Windsurf / Devin CLI direct API key.
|
||||
# Used by: open-sse/executors/devin-cli.ts — bypasses OAuth when set.
|
||||
@@ -1583,10 +1519,6 @@ APP_LOG_TO_FILE=true
|
||||
# Default: 100000
|
||||
# PROXY_LOGS_TABLE_MAX_ROWS=100000
|
||||
|
||||
# Include client/egress IPs and account prefixes in [ProxyEgress] console logs.
|
||||
# Default: false (the dashboard/database proxy-log records retain full details).
|
||||
# PROXY_LOG_INCLUDE_IPS=false
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 17. MEMORY OPTIMIZATION (Low-RAM / Docker)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -1687,16 +1619,6 @@ APP_LOG_TO_FILE=true
|
||||
# Used by: src/shared/constants/featureFlagDefinitions.ts, src/lib/arenaEloSync.ts
|
||||
# ARENA_ELO_SYNC_ENABLED=true
|
||||
|
||||
# How model ids are prefixed in GET /v1/models. "dual" (default) advertises BOTH the
|
||||
# short alias prefix and the canonical provider prefix for each model (cc/claude-sonnet-4-6
|
||||
# AND claude/claude-sonnet-4-6) so client configs that hardcoded either form keep working —
|
||||
# which roughly doubles the catalog. "alias" emits one id per model; "canonical" emits only
|
||||
# the full provider-id prefix (and drops providers whose alias is already canonical).
|
||||
# A client can override per request with GET /v1/models?prefix=alias instead.
|
||||
# Also configurable from Dashboard > Settings > Feature Flags.
|
||||
# Used by: src/shared/constants/featureFlagDefinitions.ts, src/app/api/v1/models/catalog.ts
|
||||
# MODELS_CATALOG_PREFIX_MODE=dual
|
||||
|
||||
# Sync interval in seconds. Default: 86400 (24 hours).
|
||||
# ARENA_ELO_SYNC_INTERVAL=86400
|
||||
|
||||
@@ -1805,12 +1727,6 @@ APP_LOG_TO_FILE=true
|
||||
# Used by: open-sse/executors/cloudflare-ai.ts
|
||||
# CLOUDFLARE_ACCOUNT_ID=
|
||||
|
||||
# ── Cloudflare AI Playground ──
|
||||
# Full desktop Chrome binary path, used when Playwright's bundled Chromium is
|
||||
# blocked by the headless fingerprint check.
|
||||
# Used by: open-sse/executors/cloudflare-playground.ts
|
||||
# CLOUDFLARE_PLAYGROUND_CHROME_PATH=
|
||||
|
||||
# ── Deno Deploy proxy relay (#4643 / 9router#1437) ──
|
||||
# Override the Deno Deploy REST API base used by the proxy-pool relay deployer.
|
||||
# Default: https://api.deno.com/v2 (omit unless mocking).
|
||||
@@ -1953,13 +1869,6 @@ APP_LOG_TO_FILE=true
|
||||
# PROXY_AUTO_REMOVE=false
|
||||
# Consecutive failures before an auto-remove fires. Default: 3.
|
||||
# PROXY_AUTO_REMOVE_AFTER=3
|
||||
# Set "true" to let the scheduler auto-disable (status "dead") proxies after
|
||||
# repeated failures instead of deleting them. Non-destructive alternative to
|
||||
# PROXY_AUTO_REMOVE — the row stays in the registry, drops out of pool/rotation
|
||||
# resolution immediately, and is automatically re-activated once it starts
|
||||
# answering probes again. Shares the PROXY_AUTO_REMOVE_AFTER threshold above.
|
||||
# If both PROXY_AUTO_REMOVE and PROXY_AUTO_DISABLE are "true", auto-remove wins.
|
||||
# PROXY_AUTO_DISABLE=false
|
||||
# Let automated reachability probes (the scheduler + the "Test All" button) WRITE
|
||||
# a proxy's status. Default "false": probes are read-only and never deactivate a
|
||||
# proxy — only the operator sets active/inactive (a flaky probe must not strand an
|
||||
@@ -2464,12 +2373,6 @@ APP_LOG_TO_FILE=true
|
||||
# intended to be published as `omniroute-secure`. See SECURITY.md.
|
||||
# OMNIROUTE_BUILD_PROFILE=full
|
||||
|
||||
# Skip emitting `.tar.gz` tarballs during optional-pack staging for the Electron
|
||||
# standalone tree (pack directories + optional-packs.index.json are still produced).
|
||||
# Used by the desktop release workflow to trim artifact upload size.
|
||||
# Default (when unset): 1 (tarballs emitted). Set to 0 to disable.
|
||||
# OMNIROUTE_OPTIONAL_PACK_TAR=1
|
||||
|
||||
# Electron smoke harness (used by scripts/dev/smoke-electron-packaged.mjs).
|
||||
# ELECTRON_SMOKE_URL=http://127.0.0.1:20128/login
|
||||
# ELECTRON_SMOKE_TIMEOUT_MS=45000
|
||||
@@ -2851,13 +2754,3 @@ QUOTA_STORE_DRIVER=sqlite
|
||||
# Spokesperson (Faro) base URL for the dashboard chat proxy (/api/conductor/ask).
|
||||
# Used by: src/lib/conductor/faroProxy.ts
|
||||
# CONDUCTOR_SPOKESPERSON_URL=http://127.0.0.1:7920
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# QUOTA-AWARE PROVIDER SCHEDULING (opt-in, Phase 2)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# When enabled, routing skips connections whose configured per-window token
|
||||
# budget (rateLimitOverrides.tpm) cannot afford the estimated request cost —
|
||||
# before dispatching — instead of waiting for a 429. Fail-open: connections
|
||||
# without a configured budget are always considered affordable. Requires the
|
||||
# provider_quota_state table (migration 148).
|
||||
# OMNIROUTE_QUOTA_AWARE_ROUTING=0
|
||||
|
||||
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
@@ -697,12 +697,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:
|
||||
@@ -739,7 +738,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
|
||||
|
||||
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@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
- uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
queries: security-extended
|
||||
- uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
- uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
2
.github/workflows/dast-smoke.yml
vendored
2
.github/workflows/dast-smoke.yml
vendored
@@ -37,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"
|
||||
|
||||
2
.github/workflows/docker-publish.yml
vendored
2
.github/workflows/docker-publish.yml
vendored
@@ -372,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.6
|
||||
uses: github/codeql-action/upload-sarif@v4.37.4
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: trivy-image
|
||||
|
||||
116
.github/workflows/electron-release.yml
vendored
116
.github/workflows/electron-release.yml
vendored
@@ -55,75 +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
|
||||
- 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
|
||||
@@ -135,27 +69,19 @@ 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
|
||||
@@ -167,6 +93,14 @@ jobs:
|
||||
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:
|
||||
@@ -182,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"
|
||||
@@ -204,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:
|
||||
@@ -252,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
|
||||
|
||||
12
.github/workflows/quality.yml
vendored
12
.github/workflows/quality.yml
vendored
@@ -137,7 +137,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)
|
||||
@@ -181,7 +181,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:
|
||||
@@ -430,7 +430,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.
|
||||
@@ -476,7 +476,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.
|
||||
@@ -516,7 +516,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:
|
||||
@@ -583,7 +583,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)
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,7 +1,6 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# project-specific directories
|
||||
/output/
|
||||
.slim/deepwork/
|
||||
.omnivscodeagent/
|
||||
omnirouteCloud/
|
||||
@@ -70,8 +69,6 @@ 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
|
||||
@@ -288,6 +285,3 @@ docker-compose.yml.bak
|
||||
|
||||
# CLI local cache/state
|
||||
.playwright-cli
|
||||
|
||||
# Ad-hoc test sandboxes (never tracked — may contain local DBs)
|
||||
/.sandbox/
|
||||
|
||||
@@ -56,7 +56,7 @@ 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 (153 migrations) |
|
||||
| Database | `src/lib/db/` | SQLite domain modules (148 migrations) |
|
||||
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
|
||||
| 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 |
|
||||
@@ -433,7 +433,6 @@ 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` |
|
||||
|
||||
@@ -167,7 +167,6 @@ _Living section — regenerated 2026-08-12 from all cycle commits (cycle open `e
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **providers**: honor `PATCH /api/providers/[id]` so `omniroute providers rotate` stops 405ing (the OpenAPI spec and CLI already use PATCH) (PR #10366)
|
||||
- **executors**: fix internal timeout misclassified as client disconnect (499) for 7 niche executors — pass TimeoutError reason to controller.abort() (#8197 side-finding)
|
||||
- test(combo): guard auto/best-free never leaks the combo name as a model (#7754)
|
||||
- fix(vision-bridge): describe-model no longer returns unreachable "openai/gpt-4o-mini" when every vision-capable provider is unreachable on the instance — returns null instead and surfaces a clear error (#8430)
|
||||
|
||||
22
Dockerfile
22
Dockerfile
@@ -8,8 +8,8 @@ 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 \
|
||||
@@ -61,8 +61,8 @@ FROM base AS builder
|
||||
|
||||
# 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/*
|
||||
@@ -108,7 +108,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) \
|
||||
@@ -158,7 +158,7 @@ ARG OMNIROUTE_BUILD_MEMORY_MB=4096
|
||||
ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_BUILD_MEMORY_MB}"
|
||||
|
||||
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', '@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);"
|
||||
@@ -262,8 +262,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 \
|
||||
@@ -284,15 +284,15 @@ COPY --from=builder /app/node_modules/playwright-core ./node_modules/playwright-
|
||||
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
|
||||
|
||||
120
README.md
120
README.md
@@ -513,8 +513,6 @@ 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.53B tokens/month** from the documented,
|
||||
@@ -548,7 +546,7 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
|
||||
- **🗜️ 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** — `setup-*` configures 12+ coding tools; `omniroute run` launches 7 CLIs (Claude Code, Codex, Aider, Goose, OpenCode, Qwen Code, Gemini CLI) with zero config written; `omniroute configure` is an interactive provider+model picker with 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)
|
||||
@@ -612,34 +610,12 @@ 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 34 tools (26 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>
|
||||
<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">
|
||||
|
||||
## 🌐 340 AI Providers — 90+ Free
|
||||
@@ -726,7 +702,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,35 +711,6 @@ 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>
|
||||
@@ -1072,69 +1018,31 @@ same process on one port, so there is no separate CLI-only package today.
|
||||
|
||||
</div>
|
||||
|
||||
## 📹 Video Guides
|
||||
|
||||
<div align="center">
|
||||
|
||||
<sub>Dados de cobertura social em 2026-08-17 · YT: 741 | TT: 137 | IG: 124 · Frescor (dias): YT 0 · TT 14 · IG 15</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 — 1,628,910 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.youtube.com/watch?v=QucgvbO5gsM">
|
||||
<img src="https://img.youtube.com/vi/QucgvbO5gsM/maxresdefault.jpg" alt="YouTube — Vaibhav Sisinty" width="300"/>
|
||||
</a><br/>
|
||||
<b>🎬 #2 — YouTube</b><br/>
|
||||
<sub>Vaibhav Sisinty — 373,084 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.youtube.com/shorts/fZIBK_4fKq8">
|
||||
<img src="https://img.youtube.com/vi/fZIBK_4fKq8/maxresdefault.jpg" alt="YouTube Shorts" width="300"/>
|
||||
</a><br/>
|
||||
<b>🎬 #3 — YouTube Shorts</b><br/>
|
||||
<sub>Nick Automates — 207,714 views</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+Top+1&font=montserrat&bold=true" alt="TikTok Thumbnail" width="300"/>
|
||||
</a><br/>
|
||||
<b>🎬 #4 — TikTok</b><br/>
|
||||
<sub>milesreevesai — 620,400 views</sub>
|
||||
</td>
|
||||
<td align="center" width="320">
|
||||
<a href="https://www.youtube.com/watch?v=LkP6ocAoQkk">
|
||||
<img src="https://img.youtube.com/vi/LkP6ocAoQkk/maxresdefault.jpg" alt="Valency Labs" width="300"/>
|
||||
</a><br/>
|
||||
<b>🎬 #5 — YouTube</b><br/>
|
||||
<sub>Valency Labs — 135,974 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 (`v > 0`, maior alcance):**
|
||||
|
||||
| #1 | #2 | #3 | #4 | #5 |
|
||||
| -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| [nick_saraev — Instagram](https://www.instagram.com/reel/Da8ZthUPK98/) — **1,628,910** | [milesreevesai — TikTok](https://www.tiktok.com/@milesreevesai/video/7667980059189366019) — **620,400** | [Vaibhav Sisinty — YouTube](https://www.youtube.com/watch?v=QucgvbO5gsM) — **373,084** | [Nick Automates — YouTube Shorts](https://www.youtube.com/shorts/fZIBK_4fKq8) — **207,714** | [midudev — TikTok](https://www.tiktok.com/@midudev/video/7664636453544152342) — **177,800** |
|
||||
|
||||
| #6 | #7 | #8 | #9 | #10 |
|
||||
| ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| [theopenstack — Instagram](https://www.instagram.com/reel/DaSs65mMrHk/) — **155,453** | [t.ghoush.ai — TikTok](https://www.tiktok.com/@t.ghoush.ai/video/7669497680527248656) — **152,800** | [Valency Labs — YouTube](https://www.youtube.com/watch?v=LkP6ocAoQkk) — **135,974** | [Asati — YouTube](https://www.youtube.com/watch?v=JjPtJcqwhqg) — **126,130** | [Vaibhav Sisinty — YouTube](https://www.youtube.com/watch?v=NuNDpeZYQ28) — **122,672** |
|
||||
|
||||
Métricas de validação: 1002 vídeos rastreados · 7,069,190 visualizações conhecidas · 595 perfis/canais · 13+ idiomas · 13+ criadores.
|
||||
<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">
|
||||
|
||||
@@ -1172,7 +1080,7 @@ Métricas de validação: 1002 vídeos rastreados · 7,069,190 visualizações c
|
||||
<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, 153 migrations</td></tr>
|
||||
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 117 domain modules, 148 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>
|
||||
|
||||
@@ -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,
|
||||
@@ -77,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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -54,20 +54,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 +85,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 +143,6 @@ async function runBrowserFlow(def, opts) {
|
||||
}
|
||||
|
||||
const exchangeRes = await apiFetch(`/api/oauth/${backendKey}/exchange`, {
|
||||
...targetApiOptions(opts),
|
||||
method: "POST",
|
||||
body: {
|
||||
code,
|
||||
@@ -189,7 +179,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 +195,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,18 +209,14 @@ 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);
|
||||
const 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);
|
||||
@@ -248,14 +233,12 @@ async function runDeviceFlow(def, opts) {
|
||||
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 },
|
||||
});
|
||||
@@ -272,7 +255,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(
|
||||
@@ -293,23 +275,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 connections = (data.connections ?? data.providers ?? data.items ?? data).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) `
|
||||
@@ -322,11 +303,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,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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -605,8 +604,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,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;
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { platform, totalmem } from "node:os";
|
||||
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,7 +12,6 @@ import {
|
||||
isFatalInstrumentationHookFailure,
|
||||
formatAndroidInstrumentationFailureHint,
|
||||
} from "../utils/ensureAndroidCacheDir.mjs";
|
||||
import { resolveServerHost } from "../utils/serverHost.mjs";
|
||||
import {
|
||||
resolveMaxOldSpaceMb,
|
||||
calibrateHeapFallbackMb,
|
||||
@@ -208,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
|
||||
|
||||
@@ -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 {
|
||||
@@ -229,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);
|
||||
|
||||
@@ -16,7 +16,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) {
|
||||
let s = String(url || "").replace(/\/+$/, "");
|
||||
@@ -29,14 +28,11 @@ export function resolveClineTarget(opts = {}) {
|
||||
if (opts.remote) baseUrl = stripToRoot(opts.remote);
|
||||
else {
|
||||
try {
|
||||
baseUrl = stripToRoot(
|
||||
resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl
|
||||
);
|
||||
baseUrl = stripToRoot(resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl);
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
if (!baseUrl)
|
||||
baseUrl = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
if (!baseUrl) baseUrl = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
let apiKey = opts.apiKey ?? opts["api-key"];
|
||||
if (!apiKey) {
|
||||
@@ -85,7 +81,7 @@ async function fetchModelIds(baseUrl, apiKey) {
|
||||
const res = await fetch(`${baseUrl}/v1/models`, { headers, signal: AbortSignal.timeout(8000) });
|
||||
if (!res.ok) return [];
|
||||
const body = await res.json();
|
||||
const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []);
|
||||
const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
|
||||
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
@@ -97,14 +93,6 @@ export async function runSetupClineCommand(opts = {}) {
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const clineDir = opts.clineDir ?? opts["cline-dir"] ?? join(os.homedir(), ".cline", "data");
|
||||
|
||||
const guard = await guardHostConfigTarget(clineDir, {
|
||||
toolLabel: "Cline",
|
||||
hostCommand: "omniroute setup-cline",
|
||||
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
|
||||
dryRun,
|
||||
});
|
||||
if (guard !== 0) return guard;
|
||||
|
||||
printHeading("OmniRoute → Cline (OpenAI-compatible)");
|
||||
printInfo(`Server: ${baseUrl}`);
|
||||
|
||||
@@ -134,18 +122,7 @@ export async function runSetupClineCommand(opts = {}) {
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\n── [dry-run] ${gsPath} ──`);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
actModeApiProvider: globalState.actModeApiProvider,
|
||||
planModeApiProvider: globalState.planModeApiProvider,
|
||||
openAiBaseUrl: globalState.openAiBaseUrl,
|
||||
openAiModelId: globalState.openAiModelId,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
console.log(JSON.stringify({ actModeApiProvider: globalState.actModeApiProvider, planModeApiProvider: globalState.planModeApiProvider, openAiBaseUrl: globalState.openAiBaseUrl, openAiModelId: globalState.openAiModelId }, null, 2));
|
||||
console.log(`\n── [dry-run] ${secPath} ── (openAiApiKey: ${apiKey ? "set" : "sk_omniroute"})`);
|
||||
} else {
|
||||
if (!existsSync(clineDir)) mkdirSync(clineDir, { recursive: true });
|
||||
@@ -156,9 +133,7 @@ export async function runSetupClineCommand(opts = {}) {
|
||||
}
|
||||
|
||||
// The VS Code extension uses opaque globalStorage — can't be file-written.
|
||||
printInfo(
|
||||
"\nFor the Cline VS Code extension, set these in its Settings → API (OpenAI Compatible):"
|
||||
);
|
||||
printInfo("\nFor the Cline VS Code extension, set these in its Settings → API (OpenAI Compatible):");
|
||||
printInfo(` Base URL: ${baseUrl} (NOT /v1 — Cline appends it)`);
|
||||
printInfo(` API Key: <your OMNIROUTE_API_KEY>`);
|
||||
printInfo(` Model: ${model}`);
|
||||
@@ -178,10 +153,6 @@ export function registerSetupCline(program) {
|
||||
.option("--cline-dir <dir>", "Cline data dir (default: ~/.cline/data)")
|
||||
.option("--yes", "Non-interactive: do not prompt (requires --model)")
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.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 runSetupClineCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
|
||||
@@ -16,7 +16,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 { t } from "../i18n.mjs";
|
||||
|
||||
// ── Model categorisation ──────────────────────────────────────────────────────
|
||||
@@ -307,14 +306,6 @@ export async function runSetupCodexCommand(opts = {}) {
|
||||
const onlyFilter = opts.only ? opts.only.split(",").map((s) => s.trim()) : null;
|
||||
|
||||
printHeading(`OmniRoute → Codex CLI profile generator`);
|
||||
|
||||
const guard = await guardHostConfigTarget(codexHome, {
|
||||
toolLabel: "Codex",
|
||||
hostCommand: "omniroute setup-codex",
|
||||
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
|
||||
dryRun,
|
||||
});
|
||||
if (guard !== 0) return guard;
|
||||
printInfo(`Connecting to ${baseUrl} …`);
|
||||
|
||||
// ── Fetch model catalog ───────────────────────────────────────────────────
|
||||
@@ -389,10 +380,6 @@ export function registerSetupCodex(program) {
|
||||
"Comma-separated substrings — only generate profiles for matching model IDs (e.g. glm,kimi)"
|
||||
)
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.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 runSetupCodexCommand(opts);
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
|
||||
@@ -14,7 +14,6 @@ import os from "node:os";
|
||||
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
import { categoriseModel } from "./setup-codex.mjs";
|
||||
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
|
||||
|
||||
const SECRET_REF = "${{ secrets.OMNIROUTE_API_KEY }}";
|
||||
|
||||
@@ -93,7 +92,7 @@ async function fetchModelIds(apiBase, apiKey) {
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
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 (e) {
|
||||
throw new Error(`Could not fetch models: ${e.message}`);
|
||||
@@ -103,22 +102,8 @@ async function fetchModelIds(apiBase, apiKey) {
|
||||
export async function runSetupContinueCommand(opts = {}) {
|
||||
const { apiBase, apiKey } = resolveContinueTarget(opts);
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const only = opts.only
|
||||
? opts.only
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: null;
|
||||
const configPath =
|
||||
opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".continue", "config.yaml");
|
||||
|
||||
const guard = await guardHostConfigTarget(configPath, {
|
||||
toolLabel: "Continue",
|
||||
hostCommand: "omniroute setup-continue",
|
||||
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
|
||||
dryRun,
|
||||
});
|
||||
if (guard !== 0) return guard;
|
||||
const only = opts.only ? opts.only.split(",").map((s) => s.trim()).filter(Boolean) : null;
|
||||
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".continue", "config.yaml");
|
||||
|
||||
printHeading("OmniRoute → Continue (config.yaml)");
|
||||
printInfo(`apiBase: ${apiBase}`);
|
||||
@@ -165,7 +150,7 @@ export async function runSetupContinueCommand(opts = {}) {
|
||||
printInfo("\nProvide the key (config.yaml references it, not stores it):");
|
||||
printInfo(" cn CLI: export OMNIROUTE_API_KEY=... (read from your shell)");
|
||||
printInfo(" IDE: echo 'OMNIROUTE_API_KEY=...' >> ~/.continue/.env");
|
||||
printInfo('Run: cn -p "reply OK"');
|
||||
printInfo("Run: cn -p \"reply OK\"");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -181,10 +166,6 @@ export function registerSetupContinue(program) {
|
||||
.option("--only <patterns>", "Comma-separated substrings — keep only matching model IDs")
|
||||
.option("--config-path <path>", "config.yaml path (default: ~/.continue/config.yaml)")
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.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 runSetupContinueCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
|
||||
@@ -13,7 +13,6 @@ import os from "node:os";
|
||||
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
import { categoriseModel } from "./setup-codex.mjs";
|
||||
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
|
||||
|
||||
const API_KEY_REF = "$OMNIROUTE_API_KEY";
|
||||
|
||||
@@ -88,29 +87,15 @@ async function fetchModelIds(baseUrl, apiKey) {
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
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);
|
||||
}
|
||||
|
||||
export async function runSetupCrushCommand(opts = {}) {
|
||||
const { baseUrl, apiKey } = resolveCrushTarget(opts);
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const only = opts.only
|
||||
? opts.only
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: null;
|
||||
const configPath =
|
||||
opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".config", "crush", "crush.json");
|
||||
|
||||
const guard = await guardHostConfigTarget(configPath, {
|
||||
toolLabel: "Crush",
|
||||
hostCommand: "omniroute setup-crush",
|
||||
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
|
||||
dryRun,
|
||||
});
|
||||
if (guard !== 0) return guard;
|
||||
const only = opts.only ? opts.only.split(",").map((s) => s.trim()).filter(Boolean) : null;
|
||||
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".config", "crush", "crush.json");
|
||||
|
||||
printHeading("OmniRoute → Crush (openai-compat)");
|
||||
printInfo(`base_url: ${baseUrl}`);
|
||||
@@ -135,17 +120,13 @@ export async function runSetupCrushCommand(opts = {}) {
|
||||
|
||||
if (dryRun) {
|
||||
console.log("\n" + (out.length > 3500 ? out.slice(0, 3500) + "\n… (truncated)" : out));
|
||||
printInfo(
|
||||
`[dry-run] ${provider.models.length} model(s) under providers.omniroute → ${configPath}`
|
||||
);
|
||||
printInfo(`[dry-run] ${provider.models.length} model(s) under providers.omniroute → ${configPath}`);
|
||||
return 0;
|
||||
}
|
||||
mkdirSync(join(configPath, ".."), { recursive: true });
|
||||
writeFileSync(configPath, out, "utf8");
|
||||
printSuccess(`Wrote ${configPath} (${provider.models.length} models under providers.omniroute)`);
|
||||
printInfo(
|
||||
"Provide the key (config references $OMNIROUTE_API_KEY): export OMNIROUTE_API_KEY=..."
|
||||
);
|
||||
printInfo("Provide the key (config references $OMNIROUTE_API_KEY): export OMNIROUTE_API_KEY=...");
|
||||
printInfo("Then run: crush");
|
||||
return 0;
|
||||
}
|
||||
@@ -160,10 +141,6 @@ export function registerSetupCrush(program) {
|
||||
.option("--only <patterns>", "Comma-separated substrings — keep only matching model IDs")
|
||||
.option("--config-path <path>", "crush.json path (default: ~/.config/crush/crush.json)")
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.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 runSetupCrushCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
|
||||
import { printHeading, printInfo, printSuccess } from "../io.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
import { isContainerRuntime } from "../utils/config-home-guard.mjs";
|
||||
|
||||
function ensureV1(url) {
|
||||
const s = String(url || "").replace(/\/+$/, "");
|
||||
@@ -72,7 +71,7 @@ async function fetchModelIds(apiBase, apiKey) {
|
||||
});
|
||||
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 [];
|
||||
@@ -85,32 +84,19 @@ export async function runSetupCursorCommand(opts = {}) {
|
||||
printInfo(`Server: ${apiBase}`);
|
||||
|
||||
let models = [];
|
||||
const only = opts.only
|
||||
? opts.only
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: null;
|
||||
const only = opts.only ? opts.only.split(",").map((s) => s.trim()).filter(Boolean) : null;
|
||||
const ids = await fetchModelIds(apiBase, apiKey);
|
||||
models = only ? ids.filter((id) => only.some((f) => id.includes(f))) : ids;
|
||||
|
||||
console.log("\n" + buildCursorInstructions({ apiBase, models }));
|
||||
printSuccess("\nCursor is configured manually (no file written — Cursor's storage is opaque).");
|
||||
if (await isContainerRuntime()) {
|
||||
printInfo(
|
||||
"Note: this ran inside a container, so the base URL above is the container's own view. " +
|
||||
"Use the address the host reaches OmniRoute on (e.g. the published port) in Cursor's settings."
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerSetupCursor(program) {
|
||||
program
|
||||
.command("setup-cursor")
|
||||
.description(
|
||||
"Print the steps to point Cursor at OmniRoute (chat panel; Cursor config is not file-writable)"
|
||||
)
|
||||
.description("Print the steps to point Cursor at OmniRoute (chat panel; Cursor config is not file-writable)")
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
|
||||
|
||||
@@ -14,7 +14,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(/\/+$/, "");
|
||||
@@ -27,9 +26,7 @@ export function resolveGooseTarget(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 */
|
||||
}
|
||||
@@ -83,7 +80,7 @@ async function fetchModelIds(host, apiKey) {
|
||||
const res = await fetch(`${host}/v1/models`, { headers, signal: AbortSignal.timeout(8000) });
|
||||
if (!res.ok) return [];
|
||||
const body = await res.json();
|
||||
const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []);
|
||||
const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
|
||||
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
@@ -93,16 +90,7 @@ async function fetchModelIds(host, apiKey) {
|
||||
export async function runSetupGooseCommand(opts = {}) {
|
||||
const { host, apiKey } = resolveGooseTarget(opts);
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const configPath =
|
||||
opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".config", "goose", "config.yaml");
|
||||
|
||||
const guard = await guardHostConfigTarget(configPath, {
|
||||
toolLabel: "Goose",
|
||||
hostCommand: "omniroute setup-goose",
|
||||
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
|
||||
dryRun,
|
||||
});
|
||||
if (guard !== 0) return guard;
|
||||
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".config", "goose", "config.yaml");
|
||||
|
||||
printHeading("OmniRoute → Goose (openai-compatible)");
|
||||
printInfo(`OPENAI_HOST: ${host} (no /v1 — Goose appends it)`);
|
||||
@@ -140,16 +128,14 @@ export async function runSetupGooseCommand(opts = {}) {
|
||||
|
||||
printInfo("\nProvide the key (Goose reads it from the env / OS keyring):");
|
||||
console.log(buildGooseEnvRecipe({ host, model }));
|
||||
printInfo('Then run: goose session (or: goose run -t "reply OK")');
|
||||
printInfo("Then run: goose session (or: goose run -t \"reply OK\")");
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerSetupGoose(program) {
|
||||
program
|
||||
.command("setup-goose")
|
||||
.description(
|
||||
"Configure Goose for OmniRoute: write ~/.config/goose/config.yaml + print the env recipe"
|
||||
)
|
||||
.description("Configure Goose for OmniRoute: write ~/.config/goose/config.yaml + print the env recipe")
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
|
||||
@@ -157,10 +143,6 @@ export function registerSetupGoose(program) {
|
||||
.option("--config-path <path>", "config.yaml path (default: ~/.config/goose/config.yaml)")
|
||||
.option("--yes", "Non-interactive: do not prompt (requires --model)")
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.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 runSetupGooseCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
|
||||
@@ -14,7 +14,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";
|
||||
|
||||
/** Ensure the URL ends with /v1 (Kilo appends /chat/completions to it). */
|
||||
function ensureV1(url) {
|
||||
@@ -62,11 +61,7 @@ export function buildKiloAuth(existing, { apiKey, baseUrl, model }) {
|
||||
/** Merge the kilocode.* keys into VS Code settings.json (extension surface). */
|
||||
export function buildKiloVscodeSettings(existing, { apiKey, baseUrl, model }) {
|
||||
const s = { ...(existing || {}) };
|
||||
s["kilocode.customProvider"] = {
|
||||
name: "OmniRoute",
|
||||
baseURL: baseUrl,
|
||||
apiKey: apiKey || "sk_omniroute",
|
||||
};
|
||||
s["kilocode.customProvider"] = { name: "OmniRoute", baseURL: baseUrl, apiKey: apiKey || "sk_omniroute" };
|
||||
s["kilocode.defaultModel"] = model;
|
||||
return s;
|
||||
}
|
||||
@@ -90,7 +85,7 @@ async function fetchModelIds(root, apiKey) {
|
||||
});
|
||||
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 [];
|
||||
@@ -100,22 +95,9 @@ async function fetchModelIds(root, apiKey) {
|
||||
export async function runSetupKiloCommand(opts = {}) {
|
||||
const { baseUrl, apiKey } = resolveKiloTarget(opts);
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const authPath =
|
||||
opts.authPath ??
|
||||
opts["auth-path"] ??
|
||||
join(os.homedir(), ".local", "share", "kilo", "auth.json");
|
||||
|
||||
const guard = await guardHostConfigTarget(authPath, {
|
||||
toolLabel: "Kilo Code",
|
||||
hostCommand: "omniroute setup-kilo",
|
||||
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
|
||||
dryRun,
|
||||
});
|
||||
if (guard !== 0) return guard;
|
||||
const authPath = opts.authPath ?? opts["auth-path"] ?? join(os.homedir(), ".local", "share", "kilo", "auth.json");
|
||||
const vscodePath =
|
||||
opts.vscodeSettings ??
|
||||
opts["vscode-settings"] ??
|
||||
join(os.homedir(), ".config", "Code", "User", "settings.json");
|
||||
opts.vscodeSettings ?? opts["vscode-settings"] ?? join(os.homedir(), ".config", "Code", "User", "settings.json");
|
||||
|
||||
printHeading("OmniRoute → Kilo Code (OpenAI-compatible)");
|
||||
printInfo(`Server: ${baseUrl}`);
|
||||
@@ -134,9 +116,7 @@ export async function runSetupKiloCommand(opts = {}) {
|
||||
}
|
||||
}
|
||||
if (!model) {
|
||||
printError(
|
||||
"A model is required. Pass --model <id> (Kilo's extension has no model auto-discovery)."
|
||||
);
|
||||
printError("A model is required. Pass --model <id> (Kilo's extension has no model auto-discovery).");
|
||||
return 2;
|
||||
}
|
||||
|
||||
@@ -152,19 +132,12 @@ export async function runSetupKiloCommand(opts = {}) {
|
||||
console.log(`\n── [dry-run] ${authPath} ──`);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
"openai-compatible": {
|
||||
...auth["openai-compatible"],
|
||||
apiKey: apiKey ? "set" : "sk_omniroute",
|
||||
},
|
||||
},
|
||||
{ "openai-compatible": { ...auth["openai-compatible"], apiKey: apiKey ? "set" : "sk_omniroute" } },
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
console.log(
|
||||
`\n── [dry-run] ${vscodePath} ── ${vscodeExists ? "(would merge kilocode.* keys)" : "(skipped — file absent)"}`
|
||||
);
|
||||
console.log(`\n── [dry-run] ${vscodePath} ── ${vscodeExists ? "(would merge kilocode.* keys)" : "(skipped — file absent)"}`);
|
||||
} else {
|
||||
mkdirSync(join(authPath, ".."), { recursive: true });
|
||||
writeFileSync(authPath, JSON.stringify(auth, null, 2) + "\n", "utf8");
|
||||
@@ -194,20 +167,10 @@ export function registerSetupKilo(program) {
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
|
||||
.option("--model <id>", "Model id for Kilo (required unless picked interactively)")
|
||||
.option(
|
||||
"--auth-path <path>",
|
||||
"Kilo CLI auth.json path (default: ~/.local/share/kilo/auth.json)"
|
||||
)
|
||||
.option(
|
||||
"--vscode-settings <path>",
|
||||
"VS Code settings.json (default: ~/.config/Code/User/settings.json)"
|
||||
)
|
||||
.option("--auth-path <path>", "Kilo CLI auth.json path (default: ~/.local/share/kilo/auth.json)")
|
||||
.option("--vscode-settings <path>", "VS Code settings.json (default: ~/.config/Code/User/settings.json)")
|
||||
.option("--yes", "Non-interactive: do not prompt (requires --model)")
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.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 runSetupKiloCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
|
||||
@@ -30,7 +30,6 @@ import os from "node:os";
|
||||
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -317,13 +316,6 @@ export async function runSetupOpenCodeCommand(opts = {}) {
|
||||
printInfo(`OpenCode config dir: ${opencodeConfigDir}`);
|
||||
printInfo(`OpenCode data dir: ${opencodeDataDir}`);
|
||||
|
||||
const guard = await guardHostConfigTarget(opencodeConfigDir, {
|
||||
toolLabel: "OpenCode",
|
||||
hostCommand: "omniroute setup opencode",
|
||||
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
|
||||
});
|
||||
if (guard !== 0) return { exitCode: guard };
|
||||
|
||||
// 1. Resolve bundled plugin
|
||||
let pluginInfo;
|
||||
try {
|
||||
@@ -428,10 +420,6 @@ export function registerSetupOpenCode(setupCommand) {
|
||||
false
|
||||
)
|
||||
.option("--non-interactive", "Do not prompt; skip the auth login step", false)
|
||||
.option(
|
||||
"--allow-container-write",
|
||||
"Write even when the target is inside a container and not mounted from the host"
|
||||
)
|
||||
.action(async (opts, cmd) => {
|
||||
// The parent `setup` command uses cmd.optsWithGlobals(); we mirror
|
||||
// that here so global flags (--json, --base-url, --api-key) still
|
||||
|
||||
@@ -14,7 +14,6 @@ import { basename, dirname } from "node:path";
|
||||
import { applyEdits, modify, parse, printParseErrorCode } from "jsonc-parser";
|
||||
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
|
||||
|
||||
const ENV_KEY_REF = "{env:OMNIROUTE_API_KEY}";
|
||||
const JSON_FORMATTING_OPTIONS = { insertSpaces: true, tabSize: 2 };
|
||||
@@ -120,15 +119,6 @@ export async function runSetupOpencodeCommand(opts = {}) {
|
||||
const { resolveOpencodeConfigPath } =
|
||||
await import("../../../src/shared/services/opencodeConfigPath.ts");
|
||||
configPath = resolveOpencodeConfigPath();
|
||||
|
||||
const guard = await guardHostConfigTarget(configPath, {
|
||||
toolLabel: "OpenCode",
|
||||
hostCommand: "omniroute setup-opencode",
|
||||
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
|
||||
dryRun,
|
||||
});
|
||||
if (guard !== 0) return guard;
|
||||
|
||||
raw = await generateOpencodeConfig({
|
||||
baseUrl,
|
||||
apiKey,
|
||||
@@ -173,10 +163,6 @@ export function registerSetupOpencode(program) {
|
||||
.option("--model <id>", "Set the default top-level model (omniroute/<id>)")
|
||||
.option("--only <patterns>", "Comma-separated substrings — keep only matching model IDs")
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.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 runSetupOpencodeCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
normalizeQwenCodeBaseUrl,
|
||||
} from "../../../src/shared/services/qwenCodeConfig.ts";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
|
||||
import { createPrompt, printError, printHeading, printInfo, printSuccess } from "../io.mjs";
|
||||
|
||||
/** Resolve base URL and key from flags, active context, then local defaults. */
|
||||
@@ -103,16 +102,6 @@ export async function runSetupQwenCommand(opts = {}) {
|
||||
printHeading("OmniRoute → Qwen Code (OpenAI-compatible)");
|
||||
printInfo(`baseUrl: ${baseUrl}`);
|
||||
|
||||
for (const target of [settingsPath, envPath]) {
|
||||
const guard = await guardHostConfigTarget(target, {
|
||||
toolLabel: "Qwen Code",
|
||||
hostCommand: "omniroute setup-qwen",
|
||||
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
|
||||
dryRun,
|
||||
});
|
||||
if (guard !== 0) return guard;
|
||||
}
|
||||
|
||||
let model = String(opts.model || "").trim();
|
||||
if (!model && !opts.yes) {
|
||||
const modelIds = await fetchModelIds(baseUrl, apiKey);
|
||||
@@ -170,10 +159,6 @@ export function registerSetupQwen(program) {
|
||||
.option("--env-path <path>", "Qwen Code .env path")
|
||||
.option("--yes", "Non-interactive; requires --model")
|
||||
.option("--dry-run", "Print settings without writing files or secrets")
|
||||
.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 runSetupQwenCommand(opts);
|
||||
if (code !== 0) process.exitCode = code;
|
||||
|
||||
@@ -16,7 +16,6 @@ import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
|
||||
|
||||
function ensureV1(url) {
|
||||
const s = String(url || "").replace(/\/+$/, "");
|
||||
@@ -90,7 +89,7 @@ async function fetchModelIds(baseUrl, apiKey) {
|
||||
});
|
||||
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 [];
|
||||
@@ -100,20 +99,9 @@ async function fetchModelIds(baseUrl, apiKey) {
|
||||
export async function runSetupRooCommand(opts = {}) {
|
||||
const { baseUrl, apiKey } = resolveRooTarget(opts);
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const importPath =
|
||||
opts.importPath ?? opts["import-path"] ?? join(os.homedir(), ".omniroute", "roo-settings.json");
|
||||
|
||||
const guard = await guardHostConfigTarget(importPath, {
|
||||
toolLabel: "Roo Code",
|
||||
hostCommand: "omniroute setup-roo",
|
||||
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
|
||||
dryRun,
|
||||
});
|
||||
if (guard !== 0) return guard;
|
||||
const importPath = opts.importPath ?? opts["import-path"] ?? join(os.homedir(), ".omniroute", "roo-settings.json");
|
||||
const vscodePath =
|
||||
opts.vscodeSettings ??
|
||||
opts["vscode-settings"] ??
|
||||
join(os.homedir(), ".config", "Code", "User", "settings.json");
|
||||
opts.vscodeSettings ?? opts["vscode-settings"] ?? join(os.homedir(), ".config", "Code", "User", "settings.json");
|
||||
|
||||
printHeading("OmniRoute → Roo Code (OpenAI-compatible)");
|
||||
printInfo(`Server: ${baseUrl}`);
|
||||
@@ -142,27 +130,8 @@ export async function runSetupRooCommand(opts = {}) {
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\n── [dry-run] ${importPath} ──`);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
...importDoc,
|
||||
providerProfiles: {
|
||||
...importDoc.providerProfiles,
|
||||
apiConfigs: {
|
||||
OmniRoute: {
|
||||
...importDoc.providerProfiles.apiConfigs.OmniRoute,
|
||||
openAiApiKey: apiKey ? "set" : "sk_omniroute",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
console.log(
|
||||
`\n── [dry-run] ${vscodePath} ── ${vscodeExists ? "(would set roo-cline.autoImportSettingsPath)" : "(skipped — file absent)"}`
|
||||
);
|
||||
console.log(JSON.stringify({ ...importDoc, providerProfiles: { ...importDoc.providerProfiles, apiConfigs: { OmniRoute: { ...importDoc.providerProfiles.apiConfigs.OmniRoute, openAiApiKey: apiKey ? "set" : "sk_omniroute" } } } }, null, 2));
|
||||
console.log(`\n── [dry-run] ${vscodePath} ── ${vscodeExists ? "(would set roo-cline.autoImportSettingsPath)" : "(skipped — file absent)"}`);
|
||||
} else {
|
||||
mkdirSync(join(importPath, ".."), { recursive: true });
|
||||
writeFileSync(importPath, JSON.stringify(importDoc, null, 2) + "\n", "utf8");
|
||||
@@ -192,20 +161,10 @@ export function registerSetupRoo(program) {
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
|
||||
.option("--model <id>", "Model id for Roo (required unless picked interactively)")
|
||||
.option(
|
||||
"--import-path <path>",
|
||||
"Roo import JSON path (default: ~/.omniroute/roo-settings.json)"
|
||||
)
|
||||
.option(
|
||||
"--vscode-settings <path>",
|
||||
"VS Code settings.json (default: ~/.config/Code/User/settings.json)"
|
||||
)
|
||||
.option("--import-path <path>", "Roo import JSON path (default: ~/.omniroute/roo-settings.json)")
|
||||
.option("--vscode-settings <path>", "VS Code settings.json (default: ~/.config/Code/User/settings.json)")
|
||||
.option("--yes", "Non-interactive: do not prompt (requires --model)")
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.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 runSetupRooCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
|
||||
@@ -133,29 +133,6 @@ async function setupProvider(db, opts, prompt, nonInteractive) {
|
||||
return connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the `setup` subcommand options with the program-level ones.
|
||||
*
|
||||
* The program declares a global `--api-key` (the OmniRoute *server* key, see
|
||||
* bin/cli/program.mjs) and `setup` declares its own `--api-key` (the *provider*
|
||||
* key). Commander binds the value to the program-level option, so the
|
||||
* subcommand's `opts.apiKey` is always `undefined` and `--add-provider` failed
|
||||
* with "Provider API key is required" even when `--api-key` was passed. Falling
|
||||
* back to the global value also makes `OMNIROUTE_API_KEY` work, which the error
|
||||
* message already told users to use.
|
||||
*
|
||||
* @param {Record<string, unknown>} opts Subcommand options.
|
||||
* @param {Record<string, unknown>} globalOpts Result of `cmd.optsWithGlobals()`.
|
||||
* @returns {Record<string, unknown>} Options to hand to `runSetupCommand`.
|
||||
*/
|
||||
export function mergeSetupOptions(opts, globalOpts) {
|
||||
return {
|
||||
...opts,
|
||||
apiKey: opts.apiKey ?? globalOpts.apiKey,
|
||||
output: globalOpts.output,
|
||||
};
|
||||
}
|
||||
|
||||
export function registerSetup(program) {
|
||||
program
|
||||
.command("setup")
|
||||
@@ -172,7 +149,7 @@ export function registerSetup(program) {
|
||||
.option("--list", "List all supported CLI tools")
|
||||
.action(async (opts, cmd) => {
|
||||
const globalOpts = cmd.optsWithGlobals();
|
||||
const exitCode = await runSetupCommand(mergeSetupOptions(opts, globalOpts));
|
||||
const exitCode = await runSetupCommand({ ...opts, output: globalOpts.output });
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ async function _runAllProviders(opts) {
|
||||
return 1;
|
||||
}
|
||||
const data = await res.json();
|
||||
const connections = (data.connections ?? data.providers ?? data.items ?? data).filter(
|
||||
const connections = (data.providers ?? data.items ?? data).filter(
|
||||
(c) => c.authType === "apikey" || c.testStatus !== "unavailable"
|
||||
);
|
||||
if (connections.length === 0) {
|
||||
|
||||
@@ -3,108 +3,6 @@ import { join, dirname } from "node:path";
|
||||
import { resolveDataDir } from "./data-dir.mjs";
|
||||
|
||||
const CONFIG_VERSION = 1;
|
||||
const KEYCHAIN_SERVICE = "omniroute-cli";
|
||||
const KEYCHAIN_DISABLED = /^(1|true|yes|on)$/i.test(
|
||||
String(process.env.OMNIROUTE_CONTEXT_KEYCHAIN_DISABLED || "")
|
||||
);
|
||||
|
||||
// `keytar` is optional and native. Keeping it behind a small interface lets
|
||||
// headless installs use the same CLI without requiring libsecret/Keychain at
|
||||
// install time, while tests can inject a deterministic fake backend.
|
||||
let keychainBackend = null;
|
||||
let keychainOperational = true;
|
||||
let warnedPlaintextFallback = false;
|
||||
const credentialCache = new Map();
|
||||
|
||||
function isKeychainBackend(value) {
|
||||
return (
|
||||
value &&
|
||||
typeof value.getPassword === "function" &&
|
||||
typeof value.setPassword === "function" &&
|
||||
typeof value.deletePassword === "function"
|
||||
);
|
||||
}
|
||||
|
||||
async function loadKeychainBackend() {
|
||||
if (KEYCHAIN_DISABLED) return null;
|
||||
try {
|
||||
const imported = await import("keytar");
|
||||
const candidate = isKeychainBackend(imported?.default) ? imported.default : imported;
|
||||
return isKeychainBackend(candidate) ? candidate : null;
|
||||
} catch {
|
||||
// Native keychain modules are optional and commonly unavailable in
|
||||
// containers. The secure file fallback is handled explicitly below.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseCredential(value) {
|
||||
if (!value || typeof value !== "string") return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
||||
const result = {};
|
||||
if (typeof parsed.accessToken === "string" && parsed.accessToken) {
|
||||
result.accessToken = parsed.accessToken;
|
||||
}
|
||||
if (typeof parsed.apiKey === "string" && parsed.apiKey) result.apiKey = parsed.apiKey;
|
||||
return result.accessToken || result.apiKey ? result : null;
|
||||
} catch {
|
||||
// Older/externally managed entries may contain one raw token.
|
||||
return { accessToken: value };
|
||||
}
|
||||
}
|
||||
|
||||
function credentialForContext(context) {
|
||||
const ref = context && typeof context.credentialRef === "string" ? context.credentialRef : "";
|
||||
return ref ? credentialCache.get(ref) || null : null;
|
||||
}
|
||||
|
||||
function applyCachedCredential(context) {
|
||||
const cached = credentialForContext(context);
|
||||
if (!cached) return { ...context };
|
||||
return { ...context, ...cached };
|
||||
}
|
||||
|
||||
async function hydrateCredentialCache(cfg) {
|
||||
if (!keychainBackend || !keychainOperational) return;
|
||||
const contexts = cfg?.contexts || cfg?.profiles || {};
|
||||
for (const context of Object.values(contexts)) {
|
||||
const ref = context && typeof context === "object" ? context.credentialRef : null;
|
||||
if (!ref || credentialCache.has(ref)) continue;
|
||||
try {
|
||||
const parsed = parseCredential(await keychainBackend.getPassword(KEYCHAIN_SERVICE, ref));
|
||||
if (parsed) credentialCache.set(ref, parsed);
|
||||
} catch {
|
||||
keychainOperational = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function warnPlaintextFallback() {
|
||||
if (warnedPlaintextFallback) return;
|
||||
warnedPlaintextFallback = true;
|
||||
process.stderr.write(
|
||||
"Warning: OS keychain unavailable; context credentials use config.json mode 0600 fallback.\n"
|
||||
);
|
||||
}
|
||||
|
||||
function readConfigFile() {
|
||||
try {
|
||||
if (!existsSync(configPath())) return defaultConfig();
|
||||
const parsed = JSON.parse(readFileSync(configPath(), "utf8"));
|
||||
return parsed && typeof parsed === "object" ? parsed : defaultConfig();
|
||||
} catch {
|
||||
return defaultConfig();
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve keychain state before importing commands can call the synchronous
|
||||
// compatibility helpers below. Credentials themselves stay in memory; only a
|
||||
// stable reference is persisted in config.json when keytar is available.
|
||||
keychainBackend = await loadKeychainBackend();
|
||||
await hydrateCredentialCache(readConfigFile());
|
||||
|
||||
export function configPath() {
|
||||
return join(resolveDataDir(), "config.json");
|
||||
@@ -121,13 +19,14 @@ function defaultConfig() {
|
||||
}
|
||||
|
||||
export function loadContexts() {
|
||||
return readConfigFile();
|
||||
try {
|
||||
if (!existsSync(configPath())) return defaultConfig();
|
||||
return JSON.parse(readFileSync(configPath(), "utf8"));
|
||||
} catch {
|
||||
return defaultConfig();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous compatibility writer. New credential-bearing code should use
|
||||
* `saveContextsSecure()` so tokens are moved to the OS keychain when possible.
|
||||
*/
|
||||
export function saveContexts(cfg) {
|
||||
const path = configPath();
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
@@ -137,116 +36,6 @@ export function saveContexts(cfg) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** Stable keychain reference; the reference itself is safe to persist in JSON. */
|
||||
export function contextCredentialRef(name) {
|
||||
return `${KEYCHAIN_SERVICE}:context:${encodeURIComponent(String(name))}`;
|
||||
}
|
||||
|
||||
/** Expose a non-secret capability status for diagnostics and tests. */
|
||||
export function getContextKeychainStatus() {
|
||||
return {
|
||||
available: Boolean(keychainBackend && keychainOperational),
|
||||
disabled: KEYCHAIN_DISABLED,
|
||||
fallback: !keychainBackend || !keychainOperational,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Store context credentials through keytar and write only a credentialRef to
|
||||
* config.json. If keytar cannot be used, preserve the credential in the
|
||||
* mode-0600 file and emit one explicit warning instead of breaking headless
|
||||
* installs.
|
||||
*/
|
||||
export async function saveContextsSecure(cfg) {
|
||||
const source = cfg && typeof cfg === "object" ? cfg : defaultConfig();
|
||||
const next = JSON.parse(JSON.stringify(source));
|
||||
next.version = next.version || CONFIG_VERSION;
|
||||
if (!next.contexts && next.profiles) {
|
||||
next.contexts = next.profiles;
|
||||
delete next.profiles;
|
||||
}
|
||||
next.contexts = next.contexts || {};
|
||||
|
||||
for (const [name, raw] of Object.entries(next.contexts)) {
|
||||
const context = raw && typeof raw === "object" ? raw : {};
|
||||
const accessToken = typeof context.accessToken === "string" ? context.accessToken : "";
|
||||
const apiKey = typeof context.apiKey === "string" ? context.apiKey : "";
|
||||
const hasCredential = Boolean(accessToken || apiKey);
|
||||
|
||||
if (hasCredential && keychainBackend && keychainOperational) {
|
||||
const ref =
|
||||
typeof context.credentialRef === "string" && context.credentialRef
|
||||
? context.credentialRef
|
||||
: contextCredentialRef(name);
|
||||
try {
|
||||
await keychainBackend.setPassword(
|
||||
KEYCHAIN_SERVICE,
|
||||
ref,
|
||||
JSON.stringify({
|
||||
...(accessToken ? { accessToken } : {}),
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
})
|
||||
);
|
||||
credentialCache.set(ref, {
|
||||
...(accessToken ? { accessToken } : {}),
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
});
|
||||
context.credentialRef = ref;
|
||||
delete context.accessToken;
|
||||
delete context.apiKey;
|
||||
} catch {
|
||||
keychainOperational = false;
|
||||
warnPlaintextFallback();
|
||||
}
|
||||
} else if (hasCredential) {
|
||||
warnPlaintextFallback();
|
||||
}
|
||||
|
||||
next.contexts[name] = context;
|
||||
}
|
||||
|
||||
saveContexts(next);
|
||||
return {
|
||||
usedKeychain: Boolean(keychainBackend && keychainOperational),
|
||||
config: next,
|
||||
};
|
||||
}
|
||||
|
||||
/** Remove the keychain entry associated with a context, if one exists. */
|
||||
export async function deleteContextCredential(name, context) {
|
||||
const cfg = loadContexts();
|
||||
const candidate = context || cfg.contexts?.[name] || cfg.profiles?.[name] || {};
|
||||
const ref = candidate.credentialRef || contextCredentialRef(name);
|
||||
credentialCache.delete(ref);
|
||||
if (!keychainBackend || !keychainOperational) return false;
|
||||
try {
|
||||
await keychainBackend.deletePassword(KEYCHAIN_SERVICE, ref);
|
||||
return true;
|
||||
} catch {
|
||||
keychainOperational = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Explicitly migrate legacy plaintext context credentials. */
|
||||
export async function migrateContextCredentials() {
|
||||
const cfg = loadContexts();
|
||||
const pending = Object.values(cfg.contexts || cfg.profiles || {}).some(
|
||||
(context) => context?.accessToken || context?.apiKey
|
||||
);
|
||||
if (!pending) return { migrated: false, pending: false, ...getContextKeychainStatus() };
|
||||
const result = await saveContextsSecure(cfg);
|
||||
return { migrated: result.usedKeychain, pending: true, ...getContextKeychainStatus() };
|
||||
}
|
||||
|
||||
/** Test-only backend injection; no secret is returned by this function. */
|
||||
export async function setContextKeychainBackendForTests(backend) {
|
||||
keychainBackend = isKeychainBackend(backend) ? backend : null;
|
||||
keychainOperational = true;
|
||||
credentialCache.clear();
|
||||
await hydrateCredentialCache(readConfigFile());
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active context for a CLI invocation.
|
||||
*
|
||||
@@ -265,13 +54,7 @@ export function resolveActiveContext(overrideName) {
|
||||
const contexts = cfg.contexts || cfg.profiles || {};
|
||||
const name = overrideName || cfg.currentContext || cfg.activeProfile || "default";
|
||||
const found = contexts[name] || contexts.default;
|
||||
if (found) return applyCachedCredential(found);
|
||||
if (found) return found;
|
||||
if (cfg.baseUrl) return { baseUrl: cfg.baseUrl };
|
||||
return { baseUrl: `http://localhost:${process.env.PORT || "20128"}` };
|
||||
}
|
||||
|
||||
/** Async variant for callers that need to observe a just-created keychain entry. */
|
||||
export async function resolveActiveContextAsync(overrideName) {
|
||||
await hydrateCredentialCache(readConfigFile());
|
||||
return resolveActiveContext(overrideName);
|
||||
}
|
||||
|
||||
@@ -1287,9 +1287,6 @@
|
||||
"notRunning": "OmniRoute is not reachable at {port}. Start it with 'omniroute serve'.",
|
||||
"notFound": "The 'claude' CLI was not found in PATH."
|
||||
},
|
||||
"run": {
|
||||
"description": "Launch a supported CLI target through OmniRoute"
|
||||
},
|
||||
"setupClaude": {
|
||||
"description": "Generate ~/.claude/profiles Claude Code profiles from the OmniRoute model catalog"
|
||||
},
|
||||
@@ -1300,30 +1297,12 @@
|
||||
"description": "Manage scoped CLI access tokens (remote mode)"
|
||||
},
|
||||
"configure": {
|
||||
"description": "Pick a provider+model from the active server and configure a supported local CLI"
|
||||
"description": "Pick a provider+model from the active server and write a local CLI config"
|
||||
},
|
||||
"launchCodex": {
|
||||
"description": "Launch Codex CLI pointed at OmniRoute (local or remote VPS)"
|
||||
},
|
||||
"setupCodex": {
|
||||
"description": "Generate ~/.codex profile files from OmniRoute live model catalog"
|
||||
},
|
||||
"packs": {
|
||||
"description": "Manage optional runtime packs (ML / browser automation)",
|
||||
"listDescription": "List optional packs and their install state",
|
||||
"installDescription": "Install an optional pack into DATA_DIR",
|
||||
"verifyDescription": "Verify installed packs against the shipped checksum index",
|
||||
"removeDescription": "Remove an installed optional pack",
|
||||
"sourceOpt": "Directory holding pack payloads and the pack index",
|
||||
"warnNoIndex": "optional-packs.index.json not found — install/verify are unavailable in this checkout (desktop bundles ship it)",
|
||||
"errUnknown": "unknown pack: {name}",
|
||||
"errNoIndex": "pack index not found; pass --source <dir> holding the pack payload (desktop bundles ship it next to the app)",
|
||||
"installed": "pack \"{name}\" installed and verified at {dir}",
|
||||
"restartHint": "restart the OmniRoute server (or desktop app) so the runtime picks the pack up",
|
||||
"removed": "pack \"{name}\" removed",
|
||||
"notInstalled": "pack \"{name}\" was not installed",
|
||||
"verifyOk": "all installed packs verified",
|
||||
"verifyFailed": "{count} pack(s) failed verification",
|
||||
"noneInstalled": "no optional packs installed"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1284,9 +1284,6 @@
|
||||
"notRunning": "OmniRoute não está acessível em {port}. Inicie com 'omniroute serve'.",
|
||||
"notFound": "O CLI 'claude' não foi encontrado no PATH."
|
||||
},
|
||||
"run": {
|
||||
"description": "Inicia um alvo de CLI compatível pelo OmniRoute"
|
||||
},
|
||||
"setupClaude": {
|
||||
"description": "Gera profiles do Claude Code em ~/.claude/profiles a partir do catálogo de modelos do OmniRoute"
|
||||
},
|
||||
@@ -1297,30 +1294,12 @@
|
||||
"description": "Gerencia tokens de acesso CLI com escopo (modo remoto)"
|
||||
},
|
||||
"configure": {
|
||||
"description": "Escolhe um provedor+modelo do servidor ativo e configura uma CLI local compatível"
|
||||
"description": "Escolhe um provedor+modelo do servidor ativo e grava uma configuração de CLI local"
|
||||
},
|
||||
"launchCodex": {
|
||||
"description": "Inicia o Codex CLI apontando para o OmniRoute (local ou VPS remoto)"
|
||||
},
|
||||
"setupCodex": {
|
||||
"description": "Gera os arquivos de perfil ~/.codex a partir do catálogo de modelos ao vivo do OmniRoute"
|
||||
},
|
||||
"packs": {
|
||||
"description": "Gerencia packs opcionais de runtime (ML / automação de navegador)",
|
||||
"listDescription": "Lista os packs opcionais e seu estado de instalação",
|
||||
"installDescription": "Instala um pack opcional no DATA_DIR",
|
||||
"verifyDescription": "Verifica os packs instalados contra o índice de checksums embarcado",
|
||||
"removeDescription": "Remove um pack opcional instalado",
|
||||
"sourceOpt": "Diretório com os payloads dos packs e o índice de packs",
|
||||
"warnNoIndex": "optional-packs.index.json não encontrado — install/verify indisponíveis neste checkout (instaladores desktop o embarcam)",
|
||||
"errUnknown": "pack desconhecido: {name}",
|
||||
"errNoIndex": "índice de packs não encontrado; passe --source <dir> com o payload do pack (instaladores desktop o embarcam ao lado do app)",
|
||||
"installed": "pack \"{name}\" instalado e verificado em {dir}",
|
||||
"restartHint": "reinicie o servidor OmniRoute (ou o app desktop) para o runtime reconhecer o pack",
|
||||
"removed": "pack \"{name}\" removido",
|
||||
"notInstalled": "o pack \"{name}\" não estava instalado",
|
||||
"verifyOk": "todos os packs instalados verificados",
|
||||
"verifyFailed": "{count} pack(s) falharam na verificação",
|
||||
"noneInstalled": "nenhum pack opcional instalado"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { resolveDataDir } from "./data-dir.mjs";
|
||||
|
||||
const PREFERENCES_VERSION = 1;
|
||||
const MAX_RECENT = 12;
|
||||
const MAX_FAVORITES = 32;
|
||||
|
||||
export function modelPreferencesPath() {
|
||||
return join(resolveDataDir(), "model-preferences.json");
|
||||
}
|
||||
|
||||
function defaultPreferences() {
|
||||
return { version: PREFERENCES_VERSION, targets: {}, contexts: {} };
|
||||
}
|
||||
|
||||
export function loadModelPreferences() {
|
||||
try {
|
||||
const path = modelPreferencesPath();
|
||||
if (!existsSync(path)) return defaultPreferences();
|
||||
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return defaultPreferences();
|
||||
}
|
||||
return {
|
||||
version: PREFERENCES_VERSION,
|
||||
targets: parsed.targets && typeof parsed.targets === "object" ? parsed.targets : {},
|
||||
contexts: parsed.contexts && typeof parsed.contexts === "object" ? parsed.contexts : {},
|
||||
};
|
||||
} catch {
|
||||
return defaultPreferences();
|
||||
}
|
||||
}
|
||||
|
||||
function saveModelPreferences(preferences) {
|
||||
const path = modelPreferencesPath();
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, JSON.stringify(preferences, null, 2));
|
||||
try {
|
||||
chmodSync(path, 0o600);
|
||||
} catch {
|
||||
// Best effort on platforms without POSIX modes.
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeIds(values) {
|
||||
return [...new Set((Array.isArray(values) ? values : []).filter((id) => typeof id === "string"))];
|
||||
}
|
||||
|
||||
function targetState(preferences, target, contextKey) {
|
||||
const raw = contextKey
|
||||
? preferences.contexts?.[contextKey]?.[target] ||
|
||||
(contextKey === "default" ? preferences.targets?.[target] : undefined)
|
||||
: preferences.targets?.[target];
|
||||
return {
|
||||
favorites: normalizeIds(raw?.favorites),
|
||||
recent: normalizeIds(raw?.recent),
|
||||
};
|
||||
}
|
||||
|
||||
function writeTargetState(preferences, target, contextKey) {
|
||||
if (!contextKey) {
|
||||
preferences.targets[target] = targetState(preferences, target);
|
||||
return preferences.targets[target];
|
||||
}
|
||||
preferences.contexts = preferences.contexts || {};
|
||||
preferences.contexts[contextKey] = preferences.contexts[contextKey] || {};
|
||||
preferences.contexts[contextKey][target] = targetState(preferences, target, contextKey);
|
||||
return preferences.contexts[contextKey][target];
|
||||
}
|
||||
|
||||
/** Rank catalog IDs with favorites first, then recent choices, then catalog order. */
|
||||
export function rankPreferredModels(
|
||||
target,
|
||||
modelIds,
|
||||
preferences = loadModelPreferences(),
|
||||
contextKey = ""
|
||||
) {
|
||||
const ids = normalizeIds(modelIds);
|
||||
const state = targetState(preferences, target, contextKey);
|
||||
const available = new Set(ids);
|
||||
const preferred = [...state.favorites, ...state.recent].filter((id) => available.has(id));
|
||||
return [...new Set([...preferred, ...ids])];
|
||||
}
|
||||
|
||||
/** Record a successful selection without storing server URLs or credentials. */
|
||||
export function recordModelPreference(target, modelId, options = {}) {
|
||||
if (!target || !modelId) return loadModelPreferences();
|
||||
const preferences = loadModelPreferences();
|
||||
const state = writeTargetState(preferences, target, options.context || "");
|
||||
state.recent = [modelId, ...state.recent.filter((id) => id !== modelId)].slice(0, MAX_RECENT);
|
||||
if (options.favorite) {
|
||||
state.favorites = [modelId, ...state.favorites.filter((id) => id !== modelId)].slice(
|
||||
0,
|
||||
MAX_FAVORITES
|
||||
);
|
||||
}
|
||||
if (options.unfavorite) state.favorites = state.favorites.filter((id) => id !== modelId);
|
||||
saveModelPreferences(preferences);
|
||||
return preferences;
|
||||
}
|
||||
|
||||
export function getModelPreferenceState(
|
||||
target,
|
||||
preferences = loadModelPreferences(),
|
||||
contextKey = ""
|
||||
) {
|
||||
return targetState(preferences, target, contextKey);
|
||||
}
|
||||
@@ -1,39 +1,22 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const BUILTIN_DEFAULT_SALT = "omniroute-cli-auth-v1";
|
||||
const SALT = "omniroute-cli-auth-v1";
|
||||
export const CLI_TOKEN_HEADER = "x-omniroute-cli-token";
|
||||
|
||||
let _cached = null;
|
||||
let _cachedSalt = null;
|
||||
|
||||
/** Mirrors getActiveSalt() in src/lib/machineToken.ts so a rotated
|
||||
* OMNIROUTE_CLI_SALT reaches the CLI too (docs/security/CLI_TOKEN.md). */
|
||||
function getActiveSalt() {
|
||||
return process.env.OMNIROUTE_CLI_SALT || BUILTIN_DEFAULT_SALT;
|
||||
}
|
||||
|
||||
export async function getCliToken() {
|
||||
const salt = getActiveSalt();
|
||||
if (_cached !== null && _cachedSalt === salt) return _cached;
|
||||
if (_cached !== null) return _cached;
|
||||
try {
|
||||
// node-machine-id is CommonJS: under `await import()` its exports land on
|
||||
// `.default`, so destructuring `machineIdSync` off the namespace yields
|
||||
// undefined and calling it throws — which the catch below turned into an
|
||||
// empty token, silently disabling CLI auth for every management request.
|
||||
// Same resolution order as src/lib/machineToken.ts.
|
||||
const mod = await import("node-machine-id");
|
||||
const machineIdSync = mod.machineIdSync ?? mod.default?.machineIdSync;
|
||||
if (typeof machineIdSync !== "function") throw new Error("machine-id API unavailable");
|
||||
// machineIdSync(true) returns the original unhashed hardware ID — mirrors
|
||||
// getMachineTokenSync() in src/lib/machineToken.ts (#10148 cliToken hardening).
|
||||
const mid = machineIdSync(true);
|
||||
_cached = crypto.createHmac("sha256", mid).update(salt).digest("hex");
|
||||
} catch (e) {
|
||||
// Swallowing here changes control flow (every management call goes out
|
||||
// unauthenticated and 401s), so leave a breadcrumb rather than failing mute.
|
||||
console.debug("[CLI_TOKEN] machine-id resolution failed, CLI auth disabled:", e);
|
||||
const { machineIdSync } = await import("node-machine-id");
|
||||
const mid = machineIdSync();
|
||||
_cached = crypto
|
||||
.createHash("sha256")
|
||||
.update(mid + SALT)
|
||||
.digest("hex")
|
||||
.substring(0, 32);
|
||||
} catch {
|
||||
_cached = "";
|
||||
}
|
||||
_cachedSalt = salt;
|
||||
return _cached;
|
||||
}
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import { printError, printInfo } from "../io.mjs";
|
||||
|
||||
/**
|
||||
* Container guard for CLI-tool config writes.
|
||||
*
|
||||
* `omniroute setup-*` writes to `~/.codex`, `~/.claude`, ... — paths that only
|
||||
* mean something on the operator's host. Run the same command inside the
|
||||
* OmniRoute container and the write "succeeds" into an ephemeral layer that no
|
||||
* host CLI ever reads and that disappears with the container. This guard turns
|
||||
* that silent no-op into an actionable refusal.
|
||||
*
|
||||
* Bind-mounted targets (the compose `host` profile) are allowed through: the
|
||||
* mount is the operator's explicit statement that the path reaches the host.
|
||||
*/
|
||||
|
||||
const TRUE_VALUES = new Set(["1", "true", "yes", "on"]);
|
||||
|
||||
/** Exit code for a refused write — matches the CLI's usage-error convention. */
|
||||
export const CONTAINER_WRITE_EXIT_CODE = 2;
|
||||
|
||||
function envAllowsContainerWrite(env = process.env) {
|
||||
return TRUE_VALUES.has(
|
||||
String(env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a pending config write.
|
||||
*
|
||||
* @param {string} targetPath Absolute path the command is about to write.
|
||||
* @param {{
|
||||
* toolLabel?: string,
|
||||
* hostCommand?: string,
|
||||
* allowContainerWrite?: boolean,
|
||||
* dryRun?: boolean,
|
||||
* env?: NodeJS.ProcessEnv,
|
||||
* deps?: object,
|
||||
* }} options
|
||||
* @returns {Promise<{ok: boolean, message?: string, warning?: string}>}
|
||||
*/
|
||||
export async function assertHostConfigTarget(targetPath, options = {}) {
|
||||
const {
|
||||
toolLabel,
|
||||
hostCommand,
|
||||
allowContainerWrite = false,
|
||||
dryRun = false,
|
||||
env = process.env,
|
||||
deps,
|
||||
} = options;
|
||||
|
||||
let describeContainerTarget;
|
||||
let buildContainerWriteRefusal;
|
||||
let CLI_OVERRIDE_HINT;
|
||||
try {
|
||||
// `.ts` extension is required so the published package (which ships only TS
|
||||
// source, resolved through tsx) can load these. See #2509.
|
||||
({ describeContainerTarget } = await import("../../../src/shared/utils/containerEnv.ts"));
|
||||
({ buildContainerWriteRefusal, CLI_OVERRIDE_HINT } =
|
||||
await import("../../../src/shared/utils/containerConfigGuard.ts"));
|
||||
} catch {
|
||||
// Fail open: a guard that cannot load must not block a legitimate host run.
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const info = describeContainerTarget(targetPath, deps);
|
||||
if (!info.ephemeral) return { ok: true };
|
||||
|
||||
if (dryRun) {
|
||||
return {
|
||||
ok: true,
|
||||
warning:
|
||||
`[dry-run] ${targetPath} is inside the container and is not mounted from the host — ` +
|
||||
`a real run would be refused. See --allow-container-write.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (allowContainerWrite || envAllowsContainerWrite(env)) {
|
||||
return {
|
||||
ok: true,
|
||||
warning:
|
||||
`Writing to ${targetPath} inside the container as requested — this file is lost when ` +
|
||||
`the container is recreated and host CLIs will not see it.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
message: buildContainerWriteRefusal(targetPath, {
|
||||
toolLabel,
|
||||
hostCommand,
|
||||
overrideHint: CLI_OVERRIDE_HINT,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Container check for commands that write nothing but still print host-oriented
|
||||
* instructions (setup-cursor). Fails closed to `false` so a broken import never
|
||||
* turns into a spurious warning.
|
||||
*/
|
||||
export async function isContainerRuntime(deps) {
|
||||
try {
|
||||
const { isRunningInContainer } = await import("../../../src/shared/utils/containerEnv.ts");
|
||||
return isRunningInContainer(deps);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard + report. Returns 0 to continue, or CONTAINER_WRITE_EXIT_CODE when the
|
||||
* caller should abort and return that code.
|
||||
*/
|
||||
export async function guardHostConfigTarget(targetPath, options = {}) {
|
||||
const result = await assertHostConfigTarget(targetPath, options);
|
||||
if (result.warning) printInfo(result.warning);
|
||||
if (result.ok) return 0;
|
||||
printError(result.message);
|
||||
return CONTAINER_WRITE_EXIT_CODE;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { hostname, platform } from "node:os";
|
||||
|
||||
/**
|
||||
* Resolve the bind host passed to the standalone Next.js server.
|
||||
*
|
||||
* HOSTNAME is a standard shell variable on Unix-like systems, so only the
|
||||
* dedicated OmniRoute variable is treated as configuration there. Windows
|
||||
* keeps the legacy HOSTNAME fallback for compatibility with existing .env
|
||||
* files, while still ignoring the OS-reported machine name.
|
||||
*
|
||||
* @param {NodeJS.ProcessEnv} [env]
|
||||
* @param {NodeJS.Platform} [runtimePlatform]
|
||||
* @param {string} [machineHostname]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function resolveServerHost(
|
||||
env = process.env,
|
||||
runtimePlatform = platform(),
|
||||
machineHostname = hostname()
|
||||
) {
|
||||
if (env.OMNIROUTE_SERVER_HOST) return env.OMNIROUTE_SERVER_HOST;
|
||||
if (runtimePlatform === "win32" && env.HOSTNAME && env.HOSTNAME !== machineHostname) {
|
||||
return env.HOSTNAME;
|
||||
}
|
||||
return "0.0.0.0";
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
- **feat(admission):** add lane-aware admission probes for combo/fusion/chaos fan-out (fail-open, queueing disabled), an env-wins `OMNIROUTE_CHAT_VIRTUAL_LANES` activation flag applied at boot, and adaptive-lane visibility in the `omniroute_get_health` MCP tool (related to #9654)
|
||||
- **docs(mcp):** complete the MCP server README tool reference so the `schemas/` catalog is fully covered (agent-skills, oneproxy, web, tool-search, combo/routing, pricing and DB-health tools were previously only discoverable via `omniroute_tool_search`)
|
||||
@@ -1 +0,0 @@
|
||||
- **feat(cli):** container-aware auto-config — `setup-*`, `omniroute configure`, `omniroute config set` and the CLI-tool config APIs now refuse to write into a containerised OmniRoute's ephemeral home (CLI exits `2`, API returns `422` with `containerEphemeralTarget`) and point at the host-CLI or bind-mount setup instead; `--allow-container-write` / `OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE=true` opt back in. Also fixes `CLI_CONFIG_HOME` so the Compose `host` profile's `/host-home` bind mounts are honoured instead of silently falling back to the container home. (#10057)
|
||||
@@ -1 +0,0 @@
|
||||
- feat(providers): add **Cloudflare AI Playground** as a No Auth provider (`cloudflare-playground`, alias `cfp`) — free anonymous chat over the reverse-engineered `cf_agent` WebSocket protocol (PartySocket transport, no account/API key/cookies) with GLM 5.2, Kimi K2.7 Code, DeepSeek V4 Pro, gpt-oss-120B, Llama 3.3 70B, Qwen2.5 Coder 32B and 14 more curated models. The executor drives a headless Chromium via Playwright (the WS upgrade is TLS-fingerprint-gated), translates the `cf_agent` frame stream into OpenAI SSE, and surfaces upstream rate limits (3021) as HTTP 429. Fixes #10389
|
||||
@@ -1,2 +0,0 @@
|
||||
- **feat(providers):** AI Horde accepts an optional registered API key and advertises only live image models that currently have workers ([#10542](https://github.com/diegosouzapw/OmniRoute/pull/10542))
|
||||
- **fix(providers):** AI Horde Check validates keys via `/v2/find_user` instead of the unauthenticated OpenAI models list ([#10542](https://github.com/diegosouzapw/OmniRoute/pull/10542))
|
||||
@@ -1 +0,0 @@
|
||||
- **feat(providers):** complete Jina AI as one credential pool — dashboard `jina-ai` / `jina-reader` share a token, `JINA_AI_API_KEY` is a real fallback, Test probes `GET https://api.jina.ai/v1/models` (embeddings fallback hits `jina-embeddings-v5-omni-small`), embed/rerank logs keep `connection_id`, catalog adds `jina-reranker-v3.5`, Omni v5 multimodal `{text}`/`{image}`/`{content}` docs pass through intact, and OmniRoute proxies classify / segment / `jina-search` (`s.jina.ai`). Reader stays a separate `r.jina.ai` card with an explicit label. Gemini Embedding 2 (`gemini/gemini-embedding-2`, alias `google/gemini-embedding-2`) uses dashboard `gemini` keys (or `GEMINI_API_KEY` / `GOOGLE_API_KEY` only when none exist), forwards native multimodal parts, and maps N OpenAI `input` items to N `:batchEmbedContents` vectors instead of one aggregated `:embedContent`. ([#10581](https://github.com/diegosouzapw/OmniRoute/pull/10581))
|
||||
@@ -1 +0,0 @@
|
||||
- **feat(settings):** add `autoDisableBannedScope` so permanent-ban auto-disable can target subscription/OAuth accounts only, leaving prepaid API keys in the routing pool ([#10617](https://github.com/diegosouzapw/OmniRoute/pull/10617))
|
||||
@@ -1 +0,0 @@
|
||||
- feat(modality-bridge): bridge Chat and Responses video parts through a strict trusted-loopback, quota-bounded FFmpeg broker; enforce HTTPS redirects/SSRF plus format, protocol, stream, pixel, frame, 50 MiB broker/remote, 36 MiB inline, and 120-second limits; propagate caller aborts; preserve the actual successful fallback model through cache/meta/headers; expose sampled latency and honest success telemetry; and ship the localized Video settings UI (#9760)
|
||||
@@ -1 +0,0 @@
|
||||
- **feat(api):** add `GET`/`POST` `/v1/multimodal-embeddings` as an alias of `/v1/embeddings` so Jina-compatible clients do not receive HTTP 404 `unknown_route` — thanks @RaviTharuma
|
||||
@@ -1 +0,0 @@
|
||||
- **feat(routing):** add client-, provider-, and model-neutral exclusive managed session connection leases with API-key-bound generation fencing, durable SQLite ownership, explicit allowlist policy, and bounded 429 capacity retry semantics.
|
||||
@@ -1 +0,0 @@
|
||||
- **Passthrough streaming:** stop leaking upstream SSE control lines (`id:`/`event:`/`retry:`/`:` comments) to plain OpenAI Chat-Completions-format clients, while preserving `event:` framing for OpenAI Responses API and Claude Messages API passthrough ([#10017](https://github.com/diegosouzapw/OmniRoute/issues/10017)).
|
||||
@@ -1,2 +0,0 @@
|
||||
- Fix: wire AgentRouter's existing console balance fetcher into the Dashboard Quota UI (visibility gate + provider-limits data path + background sync) so its wallet balance renders instead of falling back to "Usage API not implemented" (#10078)
|
||||
- Fix: AgentRouter's dollar balance now renders as a currency-formatted "$X.XX" credits row in the Dashboard Quota UI instead of a bare percentage, and an exhausted wallet always shows exactly $0.00 (#10078)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(sse): bridge generic openai-compatible/anthropic-compatible provider type ids to their concrete uuid node id in credential lookup (#10085)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(dashboard): remap unified Kimi Code card API-key save to the admitted `kimi-coding-apikey` connection id, fixing 400 "Invalid provider" on Save (#10096)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(antigravity): strip trailing model turn for native Gemini requests too, not just Claude (#10104)
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(admission):** stop the adaptive latency-gradient collapse from permanently locking out ordinary requests — individually valid requests now make solo progress when the system is idle and normal pressure, and the collapsed limit actively recovers on sustained idle windows instead of being stuck; the critical-pressure fuse still wins over solo progress (#10111)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(sse): downgrade client-supplied `thinking:{type:"adaptive"}` to `enabled` and gate the `context-1m-2025-08-07` beta on model eligibility when a combo/fallback re-routes a request to a non-adaptive/non-1M model like claude-haiku-4-5 (avoids "adaptive thinking is not supported on this model" and "long context beta is not yet available" 400s, #10119)
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(logging):** move call-log artifact serialization and filesystem writes to a bounded singleton worker to keep request handling responsive (#10123)
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(oauth):** Claude connections created via `claude-auth/import` now send required CLI headers on the bootstrap identity call and persist a `cliUserID` device identity, fixing intermittent "Third-party apps now draw from your extra usage" 400s on otherwise valid imported subscription tokens ([#10144](https://github.com/diegosouzapw/OmniRoute/pull/10144), fixes [#10143](https://github.com/diegosouzapw/OmniRoute/issues/10143))
|
||||
@@ -1 +0,0 @@
|
||||
- fix(proxy-subscriptions): allow local/loopback proxy-subscription fetch URLs (local-first, cloud-metadata still blocked) (#10158)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(cli): guarantee a non-empty `[STARTUP] Fatal:` log line for any instrumentation-hook boot throw, not just DB-driver init failures (#10171)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(sse): gate structural chat admission shedding on real heap pressure instead of unconditional capacity, with a bounded headroom budget so a healthy heap can no longer bypass admission control indefinitely (#10183, #10268)
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(cursor):** Stop truncating pending tool calls on non-composer models when a KV checkpoint arrives after text but before the `exec_mcp` frame — the KV short-circuit is now gated to the composer family where it was verified ([#10215](https://github.com/diegosouzapw/OmniRoute/issues/10215)).
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(responses):** repair corrupted SSE deltas for non-ASCII streams by keeping a single stream-aware `TextDecoder` (`{ stream: true }`) across `transform()` calls instead of recreating it per chunk and decoding without the `stream` flag. When a multi-byte UTF-8 character (CJK/emoji) was split across two TCP chunks — common in Chinese streaming text — the per-chunk decoder truncated it to `U+FFFD`, corrupting every delta while the rebuilt `*.done` snapshot stayed internally identical ([#10223](https://github.com/diegosouzapw/OmniRoute/issues/10223))
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233))
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234))
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(open-sse): stop concurrent requests colliding on the same dedup hash for non-OpenAI target formats (#10249)
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(translator):** Text-format tool calls emitted inline by some models are now converted to proper `tool_use` blocks. Certain models (DeepSeek, Qwen) return tool invocations as `<tool_call>{"name":"Bash","arguments":{…}}</tool_call>` or `TOOL_CALL Read: {"file_path":"…"}` inside the text stream instead of the structured `tool_calls` field. Both formats leaked through the Claude translators as plain text, so Claude Code rendered the raw block and stalled instead of executing the tool. `extractXmlInvokeBlocks` (previously `<invoke>`-only) now scans for all three shapes in a single pass and emits `content_block_start`/`input_json_delta`/`content_block_stop` events, in both `openai-to-claude` and `gemini-to-claude` (Antigravity) paths ([#10251](https://github.com/diegosouzapw/OmniRoute/pull/10251))
|
||||
@@ -1 +0,0 @@
|
||||
- fix(dashboard): make provider card warning indicators expose the interaction they advertise (#10261)
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(providers):** preserve validator HTTP status codes in API-key and web connection-test results so callers can distinguish authentication, rate-limit, and upstream failures ([#10272](https://github.com/diegosouzapw/OmniRoute/pull/10272)) — thanks @Zartharas
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(sse):** tiny-budget reasoning probes (e.g. Claude Code's `/model` check sends `max_tokens: 1`) are answered with a valid truncated 200 instead of relaying the upstream 5xx "empty response content" — which previously also marked the connection unavailable and poisoned fallback/cooldown bookkeeping for a request that is only a probe ([#10281](https://github.com/diegosouzapw/OmniRoute/issues/10281)) — thanks @harkaranbrar7
|
||||
@@ -1 +0,0 @@
|
||||
- fix(sse): mark gemini-3.5-flash as thinking-capable so reasoning_effort is no longer rejected with a spurious 400 (#10286)
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(build):** stop Turbopack from dead-code-eliminating the Windows Tailscale branches of `src/lib/tailscaleTunnel.ts` in the published build (#10293). The release `dist` is bundled on a Linux runner, and the bundler constant-folds `process.platform`, pruning every non-Linux branch — the Windows installers shipped with no `where` lookup, an always-injected `--socket`, and a lost `net start Tailscale`/windows-default-binary path. The module now reads the platform at runtime via `os.platform()` (a function call a bundler cannot fold), so the Windows branches survive on any build machine; a vitest regression test mocking `os.platform()` → `win32` guards the anti-fold invariant (RED before, GREEN after).
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(ops):** Docker HEALTHCHECK defaults to the lightweight `/healthz` lifecycle probe instead of the heavy `/api/monitoring/health` path, with an `OMNIROUTE_HEALTHCHECK_PATH` opt-in override ([#10311](https://github.com/diegosouzapw/OmniRoute/pull/10311))
|
||||
@@ -1 +0,0 @@
|
||||
- fix(resilience): keep combo quality and auth failure reasons separate and redact connection labels in terminal errors (#10314)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(dashboard): send periodic WS heartbeat pings so live dashboard connections stop dropping every ~35s (#10319)
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(chat-body-admission):** restore a single process-wide admission budget — heavyweight leases and queued bytes are now bounded once for the whole process instead of per session, so one session can no longer mint extra capacity or starve others; per-session fairness is preserved via round-robin dispatch ([#10110](https://github.com/diegosouzapw/OmniRoute/issues/10110))
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(providers):** validate Z.ai web Local Storage sessions against the authenticated user-settings endpoint and preserve exact upstream status codes ([#10329](https://github.com/diegosouzapw/OmniRoute/pull/10329)) — thanks @Zartharas
|
||||
@@ -1 +0,0 @@
|
||||
- fix(backend): redact client IPs and account prefixes from default proxy logs (#10348)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(providers): GitLab Duo falls back to the public Code Suggestions endpoint when direct_access returns 401 (#10365)
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(db):** `getSettings()` defaults `debugMode` to `false` — fresh installs no longer run in debug mode (persisted `debugMode: true` is preserved) ([#10372](https://github.com/diegosouzapw/OmniRoute/pull/10372) — thanks @lamchun1110)
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(translator):** Consolidate tool-name casing normalization into a single `restoreClaudeToolName` helper reused across every response path (`openai-to-claude`, `gemini-to-claude`, `stream` passthrough, xAI and Antigravity handlers), replacing six hand-copied 7-entry casing maps. The shared helper resolves via the request-side `toolNameMap` first (preserving declared PascalCase and MCP/alias names), then the complete `TOOL_RENAME_MAP` (which already covers `glob`/`grep`/`task`/`todowrite`/`skill`/`askuserquestion`/etc.), then the `#7926` TitleCase→lowercase fallback for map-less clients. This closes the coverage gap that left `TodoWrite` and other tools failing with `Error: No such tool available: todowrite`, fixes a `ReferenceError` in `remapToolNamesInResponse`, and preserves the Gemini thought-signature persistence (`#8979`) and OpenAI→Claude `toolNameMap` restoration that must not regress ([#10374](https://github.com/diegosouzapw/OmniRoute/issues/10374))
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(responses):** preserve native tool definitions for custom OpenAI-compatible providers when using the Responses API (`/v1/responses`). When `apiType` is set to `"responses"` (or `_omnirouteForceResponsesUpstream` is enabled), OmniRoute passes native tool shapes (`custom` with lark grammars, `namespace`, `local_shell`) directly upstream without running a lossy Responses→Chat→Responses conversion ([#10374](https://github.com/diegosouzapw/OmniRoute/issues/10374))
|
||||
@@ -1 +0,0 @@
|
||||
- fix(dashboard): Free Tier 'used this month' now includes live usage_history rows, not just the rolled-up daily summary (#10381)
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(executors):** OpencodeExecutor and MimocodeExecutor now rotate to the next account on network exceptions (timeout, connection refused/reset) when the failed account has a dedicated proxy, not only on 429 — a throw on one account no longer fails the whole request when other accounts remain. Accounts sharing the default egress (no proxy) fail fast instead of retrying the same outage against every account. The shared rotation mechanics (`pickAccount`/`markCooldown`/`markSuccess`) are now extracted into `accountRotation.ts`, fixing an identical unconditional-cooldown gap that pre-dated this PR in MimocodeExecutor ([#10393](https://github.com/diegosouzapw/OmniRoute/pull/10393))
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(sse):** the header-budget drop warning fires once per unique dropped-header set instead of on every SSE response (warn-storm fix) ([#10397](https://github.com/diegosouzapw/OmniRoute/pull/10397) — thanks @lamchun1110)
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(guardrails):** Vision Bridge now reroutes whole requests for named combos whose targets have zero vision-capable models (previously such image requests died with `capability_mismatch` when the describe path could not run), and when the fallback describe path also fails for every image the request degrades to explicit `(unavailable)` stub text instead of preserving images the combo cannot consume ([#10415](https://github.com/diegosouzapw/OmniRoute/pull/10415)) — thanks @rqzbeh
|
||||
@@ -1,2 +0,0 @@
|
||||
- **fix(antigravity):** geo-blocked egress (Google "User location is not supported") is now classified (scoped to the Google AI surfaces that emit it: Cloud Code/Gemini Code Assist, Gemini API, Vertex), cached as a 24h per-account exclusion so routing continues with other accounts, and surfaced with an actionable message; the dashboard connection test now probes the real `streamGenerateContent` model surface instead of the non-geo-restricted OAuth userinfo endpoint ([#10420](https://github.com/diegosouzapw/OmniRoute/pull/10420)) — thanks @rqzbeh
|
||||
- **fix(antigravity):** strip competing-agent identity sentences from system prompts (e.g. "You are a Claude agent, built on Anthropic's Claude Agent SDK.") that Antigravity flags and answers with 429 RESOURCE_EXHAUSTED (port of decolua/9router b566b20) ([#10420](https://github.com/diegosouzapw/OmniRoute/pull/10420)) — thanks @rqzbeh
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user