Compare commits

..

1 Commits

Author SHA1 Message Date
diegosouzapw
92fc61374a fix(oauth): align codebuddy-cn OAuth User-Agent with chat/usage (#12702)
OAuth device-code auth/poll and token-refresh for codebuddy-cn presented
CLI/2.63.2 CodeBuddy/2.63.2 while chat-completion and usage/quota requests
presented CLI/2.108.1 CodeBuddy/2.108.1 for the same account. A 45-minor-
version-apart client fingerprint across auth vs. chat calls is exactly the
kind of internally-inconsistent signal an anti-abuse WAF flags as anomalous
(Tencent gateway code 11128 'request illegal').

Centralize the version string into CODEBUDDY_CN_USER_AGENT (exported from
src/lib/oauth/constants/oauth.ts) and reference it from the chat registry
entry and the usage handler so all three surfaces can never drift apart
again. Adds a permanent regression test asserting the OAuth, chat and
usage User-Agent headers all match.
2026-09-10 15:22:15 -03:00
12854 changed files with 408956 additions and 4040094 deletions

View File

@@ -73,11 +73,6 @@ docs/i18n/**
# so without this rule these land in /app/docs and become readable through the
# dashboard's Docs viewer at runtime.
docs/superpowers/**
# Operator-internal security writeups: git only, not the image or /docs catalog.
docs/security/STEALTH_GUIDE.md
docs/security/SOCKET_DEV_FINDINGS.md
docs/security/MITM-TPROXY-DECRYPT.md
docs/security/PUBLIC_CREDS.md
docs/diagrams/**/*.png
docs/diagrams/**/*.jpg
docs/diagrams/**/*.jpeg

View File

@@ -112,9 +112,8 @@ DISABLE_SQLITE_AUTO_BACKUP=false
# Example: redis://localhost:6379 (or redis://redis:6379 in Docker)
# REDIS_URL=redis://localhost:6379
# Namespace prefix for ALL OmniRoute Redis keys (rate limiter + auth cache +
# quota store + warmup circuit breaker). Prevents key collisions when OmniRoute
# shares a Redis instance with other apps (e.g. on 127.0.0.1:6379). Default when
# unset: omniroute:
# quota store). Prevents key collisions when OmniRoute shares a Redis instance
# with other apps (e.g. on 127.0.0.1:6379). Default when unset: omniroute:
# REDIS_KEY_PREFIX=omniroute:
# Host interface docker-compose publishes the Redis sidecar on.
# Default: 127.0.0.1 (loopback only). The compose Redis runs WITHOUT
@@ -125,21 +124,6 @@ DISABLE_SQLITE_AUTO_BACKUP=false
# Host port for the compose Redis sidecar. Default: 6379.
# REDIS_PORT=6379
# Host interface docker-compose publishes the app's own ports (dashboard,
# API, live-WS) on for the base/web/cli/host profiles and docker-compose.prod.yml.
# Default: 127.0.0.1 (loopback only). Combined with REQUIRE_API_KEY=false
# (the default below), an unqualified publish spec would expose the anonymous
# /v1 LLM proxy to your whole LAN/WAN. Only set this to 0.0.0.0 once you've
# confirmed REQUIRE_API_KEY=true, or that a reverse proxy in front of this
# instance already enforces its own authentication. (#12568)
# APP_BIND_HOST=127.0.0.1
# Host interface docker-compose publishes the Qdrant memory sidecar on.
# Default: 127.0.0.1 (loopback only). Same LAN-exposure reasoning as Redis.
# QDRANT_BIND_HOST=127.0.0.1
# Host interface docker-compose publishes the Bifrost router sidecar on.
# Default: 127.0.0.1 (loopback only). Same LAN-exposure reasoning as Redis.
# BIFROST_BIND_HOST=127.0.0.1
# ═══════════════════════════════════════════════════════════════════════════════
# 3. NETWORK & PORTS
# ═══════════════════════════════════════════════════════════════════════════════
@@ -173,11 +157,6 @@ PORT=20128
# stay consistent without relying on window.location.origin alone:
# NEXT_PUBLIC_BASE_URL=https://host/omniroute
#
# Client-side fallback port for display URLs when no origin is known (SSR/tests):
# read before PORT so a browser bundle built with a different public port still
# renders the right http://localhost:<port> links (src/shared/hooks/useDisplayBaseUrl.ts).
# NEXT_PUBLIC_PORT=20128
#
# Explicit path probed by the container health check. Unset, the probe derives it
# from OMNIROUTE_BASE_PATH; setting it opts back into the deep monitoring endpoint.
# Used by: scripts/dev/healthcheck.mjs
@@ -293,9 +272,9 @@ OMNIROUTE_USE_TURBOPACK=1
# OMNIROUTE_SKIP_DB_HEALTHCHECK=1
# Interval (ms) for the background credential health check scheduler.
# Default: 3600000 (60 minutes). Minimum: 10000 (10 seconds).
# Default: 300000 (5 minutes). Minimum: 10000 (10 seconds).
# Used by: open-sse/config/constants.ts, src/lib/credentialHealth/scheduler.ts
# CREDENTIAL_HEALTH_CHECK_INTERVAL=3600000
# CREDENTIAL_HEALTH_CHECK_INTERVAL=300000
# TTL (ms) for cached credential health status.
# Default: 300000 (5 minutes).
@@ -394,8 +373,6 @@ AUTH_COOKIE_SECURE=false
# Require an API key for all /v1/* proxy endpoints.
# Used by: API middleware — rejects unauthenticated requests to the proxy API.
# Default: false | Set true for multi-user/public deployments.
# Leaving this false is only safe when the app is reachable on loopback only
# (see APP_BIND_HOST above) or sits behind a reverse proxy doing its own auth.
REQUIRE_API_KEY=false
# Allow revealing full API key values in the Dashboard UI.
@@ -546,48 +523,6 @@ ALLOW_API_KEY_REVEAL=false
# When unset, OmniRoute uses the per-feature defaults. Set to "false"/"0" to disable.
# OUTBOUND_SSRF_GUARD_ENABLED=true
# ── Self-hosted unified OpenAI-compatible entry (RIC-738, D4) ────────────────────
# When set, /v1/chat/completions diverts to the self-hosted provider adapters
# (open-sse/services/selfHostedEntry.ts) instead of the cloud pipeline. YAML inline
# (example) — or point OMNIROUTE_SELF_HOSTED_PROVIDERS_FILE at a YAML file. Secrets
# are runtime-only, never logged. While ANY of these is set, the entry is active;
# config present but unparseable returns a 500 (never silently falls through).
# OMNIROUTE_SELF_HOSTED_PROVIDERS='
# providers:
# - id: local
# kind: openai
# baseUrl: http://127.0.0.1:11434/v1
# model: llama3
# - id: claude
# kind: anthropic
# baseUrl: http://127.0.0.1:8080
# model: claude-sonnet
# '
# OMNIROUTE_SELF_HOSTED_PROVIDERS_FILE=/etc/omniroute/providers.yaml
# Optional shared API key for the unified entry (D5 reserved). When set, require
# `Authorization: Bearer <key>`; empty = open loopback/trusted-network route.
# OMNIROUTE_SELF_HOSTED_API_KEY=
# ── Deterministic routing strategies (M2 / RIC-740, D3 可审计路由) ─────────────
# Optional `strategy:` block — either inline in the providers document above, or a
# standalone document via these env vars. One rule per line; every decision is
# explainable via the `x-omniroute-route-decision` response header. No ML/predict.
# Malformed strategy config returns a 500 (never silently becomes a no-op).
# Example (inline, same shape as `strategy:` inside the providers YAML):
# OMNIROUTE_SELF_HOSTED_STRATEGY='
# blacklist: []
# whitelist: [cheap, fast, premium]
# costPriority: true
# latencyAware:
# enabled: true
# cooldown:
# consecutiveFailures: 2
# cooldownMs: 30000
# fallbackChain: [cheap, fast, premium]
# '
# OMNIROUTE_SELF_HOSTED_STRATEGY_FILE=/etc/omniroute/strategy.yaml
# See docs/routing/DETERMINISTIC_ROUTING.md for the full strategy surface.
# ═══════════════════════════════════════════════════════════════════════════════
# 5. INPUT SANITIZATION & PII PROTECTION (FASE-01)
# ═══════════════════════════════════════════════════════════════════════════════
@@ -669,15 +604,6 @@ ALLOW_API_KEY_REVEAL=false
# Validated to >= 1, clamped to <= 32. | Default: 3
# COMBO_CONCURRENCY_PER_MODEL=3
# Disable conversation-history tracking (#13150).
# Used by: open-sse/services/conversationTracker.ts. resolveConversationId()
# returns an untracked result before it reads SQLite or parses message history,
# and the switch also covers client-supplied session IDs. Routing sessions are
# unaffected and existing records are not deleted. Use it when the dashboard's
# conversation view is unused and the turn table has grown large.
# Set to 1 to disable. | Default: unset (tracking enabled)
# OMNIROUTE_DISABLE_CONVERSATION_TRACKING=1
# ═══════════════════════════════════════════════════════════════════════════════
# 7. URLS & CLOUD SYNC
# ═══════════════════════════════════════════════════════════════════════════════
@@ -768,26 +694,14 @@ NEXT_PUBLIC_CLOUD_URL=
# OpenCode Go/Zen VPS egress (#5997): on a datacenter VPS, Cloudflare in front of
# opencode.ai/zen/go 403s chat requests that lack OpenCode CLI identity headers.
# When your clients don't already send them, set this to synthesize the CLI headers
# (User-Agent, x-opencode-client, x-opencode-project, canonical request/session ids) on
# absent keys. ON by default — a client value always wins, these only fill gaps.
# (User-Agent, x-opencode-client, x-opencode-project, fresh request/session UUIDs) on
# absent keys. OFF by default — forward-only is safer when clients already send them.
# Values are overridable via OPENCODE_GO_USER_AGENT / OPENCODE_USER_AGENT / OPENCODE_CLIENT /
# OPENCODE_PROJECT (defaults: opencode/1.18.31 / desktop / global).
# OPENCODE_PROJECT (defaults: opencode-cli/1.0.0 / cli / default).
#OPENCODE_SYNTHESIZE_CLI_HEADERS=true
#OPENCODE_USER_AGENT=opencode/1.18.31
#OPENCODE_CLIENT=desktop
#OPENCODE_PROJECT=global
# Keyless OpenCode models are answered only when the request declares a non-empty tool
# list, and the upstream inspects which names it carries. OmniRoute reuses the list a
# request of the same conversation was last seen getting through, so a request that
# carries none — a title or a summary — goes out with the list its own client already
# declared. Set to off to stop adjusting request bodies entirely; headers are unaffected.
#OPENCODE_FREE_TIER_REQUEST_CONTRACT=off
# Tool names to declare when nothing has been observed yet for a model, comma-separated.
# Empty falls back to a single placeholder the model is told not to call. Only useful on
# an install where no client sends tools, since there is then nothing to learn from.
#OPENCODE_FREE_TIER_PLACEHOLDER_TOOLS=glob,grep,read
#OPENCODE_USER_AGENT=opencode-cli/1.0.0
#OPENCODE_CLIENT=cli
#OPENCODE_PROJECT=default
# Ollama Cloud quota scraping. Prefer configuring this per connection in
# Dashboard → Providers → Ollama Cloud. The cookie is sensitive.
@@ -806,12 +720,6 @@ NEXT_PUBLIC_CLOUD_URL=
ENABLE_SOCKS5_PROXY=true
NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# Opt-in feature flag (default off; a dashboard DB override wins over this value): proxy pools
# and per-account rotation stop re-serving a member that just failed (TCP probe refused, or a
# 429 received through it) for a period that doubles on each repeat, up to a cap. No proxy
# status is written. "true" (or 1, yes) enables it; unset keeps plain selection.
# PROXY_SKIP_RECENTLY_FAILED=false
# Standard proxy variables (lowercase variants also supported).
# HTTP_PROXY=http://127.0.0.1:7890
# HTTPS_PROXY=http://127.0.0.1:7890
@@ -1109,12 +1017,6 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
# Used by: src/lib/jobs/reasoningCacheCleanupJob.ts.
#OMNIROUTE_REASONING_CACHE_CLEANUP_INTERVAL_MS=1800000
# Opt-in minimum output budget (tokens) for reasoning models (#10281 follow-up).
# When set, a caller max_tokens in [256, floor) on a thinking-capable model is
# raised to the floor so reasoning tokens cannot consume the whole budget
# (zero-content finish_reason=length turns). Unset = never enlarge client budgets (#9507).
#OMNIROUTE_REASONING_MIN_BUDGET=4096
# Spend write batcher cadence (ms) and buffer size before forced flush.
# Used by: src/lib/spend/batchWriter.ts. Defaults: 60000 ms / 1000 entries.
#OMNIROUTE_SPEND_FLUSH_INTERVAL_MS=60000
@@ -1161,11 +1063,6 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
# Used by: open-sse/services/compression/engines/rtk/filterLoader.ts. Default: 0.
#OMNIROUTE_RTK_TRUST_PROJECT_FILTERS=0
# Character cap for Lite proactive tool-result truncation when lite.maxToolLength
# is unset. Range 256-1000000. Dashboard setting wins over this env.
# Used by: open-sse/services/compression/lite.ts. Default: 2000.
#OMNIROUTE_LITE_MAX_TOOL_LENGTH=2000
# Maximum concurrent synchronous compression workers. Excess jobs wait FIFO.
# Used by: open-sse/services/compression/compressionWorkerPool.ts. Default: 2.
#OMNI_COMPRESSION_WORKERS=2
@@ -1222,38 +1119,10 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
# Used by: src/lib/db/core.ts::getDbHealthCheckIntervalMs().
#OMNIROUTE_DB_HEALTHCHECK_INTERVAL_MS=21600000
# Removed: periodic live wal_checkpoint(TRUNCATE) could SIGBUS the process (issue
# #13973). The variable is inert: a positive value logs a one-time deprecation warning,
# while 0 or unset stays silent. The WAL is kept small
# by the PASSIVE scheduler below and truncated by the shutdown checkpoint.
# WAL truncate cadence override (ms). Set to 0 to disable. Default: 21600000 (6h).
# Used by: src/lib/db/core.ts::getWalTruncateIntervalMs().
#OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS=21600000
# Frequent wal_checkpoint(PASSIVE) cadence (ms). Set to 0 to disable. Default: 300000 (5m).
# Used by: src/lib/db/walMaintenance.ts.
#OMNIROUTE_WAL_PASSIVE_INTERVAL_MS=300000
# WAL size (MB) above which a PASSIVE tick runs wal_checkpoint(RESTART) so the
# WAL starts over without rewriting the mapped wal-index. Default: 256.
# Used by: src/lib/db/walMaintenance.ts.
#OMNIROUTE_WAL_GUARD_MAX_MB=256
# Explicit path to sql-wasm.wasm for the sql.js fallback adapter. Default: auto-detect.
# Used by: src/lib/db/adapters/sqljsAdapter.ts.
#OMNIROUTE_SQLJS_WASM_PATH=
# Days a terminal (completed/failed/cancelled/expired) Batch API job's checkpoints,
# referenced files, and row are kept by the automatic cleanup sweep. Default: 30
# (matches OpenAI's own Batch API output retention window). Only takes effect once
# BATCH_AND_FILE_AUTO_CLEANUP_ENABLED is turned on.
# Used by: src/lib/db/cleanup.ts::getBatchRetentionDays().
#OMNIROUTE_BATCH_RETENTION_DAYS=30
# Let the automatic cleanup sweep delete terminal Batch API jobs (and their
# checkpoints) past OMNIROUTE_BATCH_RETENTION_DAYS, and clear the content of
# uploaded files past their own expires_at. Off by default: every existing
# install keeps this data exactly as before until an operator opts in.
# Used by: src/lib/db/cleanup.ts (feature flag; see docs/reference/FEATURE_FLAGS.md).
#BATCH_AND_FILE_AUTO_CLEANUP_ENABLED=false
# Skip the Redis-backed auth cache used by API key lookups (forces DB reads).
# Used by: src/lib/db/apiKeys.ts. Set to 1 to disable. Default: enabled.
#OMNIROUTE_DISABLE_REDIS_AUTH_CACHE=0
@@ -1306,11 +1175,6 @@ CODEX_OAUTH_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann
# Trae OAuth token override. Used by: open-sse/executors/trae.ts.
# TRAE_TOKEN=
# Trae web client Origin/Referer override (fleet-wide bump if Trae moves hosts
# again without a code change). Default: https://work.trae.ai.
# Used by: open-sse/executors/trae.ts.
# TRAE_WEB_ORIGIN=https://work.trae.ai
# ── Gemini / Antigravity (Google-based) ──
# These providers ship public OAuth client_id/secret values embedded in their
# public CLIs. Defaults are baked into the code via
@@ -1326,11 +1190,6 @@ CODEX_OAUTH_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann
# ── Kimi Coding (Moonshot) ──
KIMI_CODING_OAUTH_CLIENT_ID=17e5f671-d194-4dfb-9706-5516cb48c098
# ── Muse Code (Meta) ──
# Public device-flow client id is baked into open-sse/utils/publicCreds.ts (muse_id).
# Set this only to override the Muse CLI client. Do not put the public id here.
# MUSE_CODE_OAUTH_CLIENT_ID=
# ── GitHub Copilot ──
GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
@@ -1414,10 +1273,6 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
# VISION_BRIDGE_BASE_URL=
# VISION_BRIDGE_API_KEY=
# How long a "no usable vision candidate" outcome is remembered, in ms.
# Invalid or negative values fall back to the default; 0 disables the negative cache.
# OMNIROUTE_VISION_BRIDGE_NEGATIVE_CACHE_MS=30000
# ─────────────────────────────────────────────────────────────────────────────
# ⚠️ GOOGLE OAUTH (Antigravity) & OTHER PROVIDERS — REMOTE SERVERS
# ─────────────────────────────────────────────────────────────────────────────
@@ -1456,8 +1311,7 @@ CLAUDE_USER_AGENT="claude-cli/2.1.258 (external, cli)"
# stream with a misleading 400 out-of-extra-usage placeholder. Set to true to
# forward the original names verbatim (debugging only).
# CLAUDE_DISABLE_TOOL_NAME_CLOAK=false
# Optional override; leave unset to follow the shared Codex client version.
# CODEX_USER_AGENT="codex-cli/0.155.0 (Windows 10.0.26200; x64)"
CODEX_USER_AGENT="codex-cli/0.144.1 (Windows 10.0.26200; x64)"
GITHUB_USER_AGENT="GitHubCopilotChat/0.54.0"
ANTIGRAVITY_USER_AGENT="antigravity/2.0.1 linux/arm64 google-api-nodejs-client/10.3.0"
KIRO_USER_AGENT="AWS-SDK-JS/3.0.0 kiro-ide/1.0.0"
@@ -1477,7 +1331,7 @@ CURSOR_USER_AGENT="Cursor/3.4"
# Override Codex client version sent in headers independently of the
# CODEX_USER_AGENT string. Used by: open-sse/config/codexClient.ts.
# CODEX_CLIENT_VERSION=0.155.0
# CODEX_CLIENT_VERSION=0.144.1
#
# Override the advertised Claude Code client version independently of
# CLAUDE_USER_AGENT. Anthropic gates some models (Fable 5.1) on this
@@ -1488,13 +1342,6 @@ CURSOR_USER_AGENT="Cursor/3.4"
# Override the advertised GitHub Copilot CLI version independently of
# GITHUB_USER_AGENT. Used by: open-sse/config/providerHeaderProfiles.ts.
# GITHUB_COPILOT_CLI_VERSION=1.0.82
#
# Pin the `copilot-integration-id` header sent to standard GitHub Copilot,
# overriding the default copilot-developer-cli identity (and disabling the
# automatic 403-identity fallback to copilot-chat). Set this only if your
# Copilot account/org requires a specific integration id. Used by:
# open-sse/config/providerHeaderProfiles.ts, open-sse/executors/copilotIdentityFallback.ts.
# COPILOT_INTEGRATION_ID=copilot-chat
# Kill-switch to strip non-standard `codex.*` SSE events (e.g. codex.rate_limits)
# from the Codex Responses stream. These frames break the OpenAI SDK's
@@ -1602,7 +1449,7 @@ CURSOR_USER_AGENT="Cursor/3.4"
#
# Hierarchy: REQUEST_TIMEOUT_MS acts as a global override.
# If set, it becomes the default for FETCH_TIMEOUT_MS, STREAM_IDLE_TIMEOUT_MS,
# and STREAM_READINESS_TIMEOUT_MS. STREAM_ACTIVE_TIMEOUT_MS is independent.
# and STREAM_READINESS_TIMEOUT_MS.
# The fine-grained variables below override their respective defaults only when set.
# ── Global shortcut ──
@@ -1623,18 +1470,6 @@ CURSOR_USER_AGENT="Cursor/3.4"
# # caller's deadline; on expiry the request retries
# # once on a fresh no-keep-alive socket. 0 disables
# # the bound (default: 30000 = 30s).
# OMNIROUTE_DIRECT_RESPONSE_RETRY_TIMEOUT_MS=600000 # Ceiling (ms) for the fresh-socket
# # RETRY attempt above (#13703). Only applies when
# # the caller already attached its own deadline
# # signal (the resolved connection/model/provider/
# # FETCH_TIMEOUT_MS cascade) — that signal is the
# # real bound and fires first in the intended path,
# # so this is a generous backstop rather than a flat
# # cap: without it the retry reused the same short
# # OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS window as the
# # pooled attempt and 504'd healthy slow-TTFB
# # reasoning models. Never allowed below the flat
# # floor above (default: 600000 = 10 min).
# Default timeout (ms) for src/shared/utils/fetchTimeout.ts. Acts as the
# fallback when FETCH_TIMEOUT_MS is unset. Default: 120000 (2 min).
@@ -1796,8 +1631,6 @@ CURSOR_USER_AGENT="Cursor/3.4"
# ── Stream idle detection ──
# STREAM_IDLE_TIMEOUT_MS=600000 # Max silence between SSE chunks (default: 600000)
# # Extended-thinking models rarely pause >90s.
# STREAM_ACTIVE_TIMEOUT_MS=1260000 # Max total active SSE lifetime (default: 21 min = the largest registered model timeoutMs + 1 min; 0 disables)
# # Independent of REQUEST_TIMEOUT_MS and byte activity.
# STREAM_READINESS_TIMEOUT_MS=80000 # Time to receive the first non-ping SSE event
# STREAM_READINESS_MAX_TIMEOUT_MS=180000 # Cap for adaptive first-event extensions
# # (large/tool-heavy/high-reasoning requests).
@@ -1813,12 +1646,6 @@ CURSOR_USER_AGENT="Cursor/3.4"
# ── TLS client (wreq-js fingerprint proxy) ──
# TLS_CLIENT_TIMEOUT_MS=600000 # Inherits from FETCH_TIMEOUT_MS by default
# TLS_FIRST_BYTE_WATCHDOG_MS=10000 # #12656: bounds time-to-first-byte on the wreq body (0 disables)
# OPENCODE_RESPONSES_STALL_ROTATION=false # #13484 feature flag (Settings → Feature Flags wins): rotate once when a streamed Responses reply stalls before its first byte
# OPENCODE_PARK_AND_RESUME=false # #13924 feature flag (Settings → Feature Flags wins): park the request with a heartbeat after repeated transient 429s, then replay one capped leg of up to 3 accounts
#OPENCODE_POOL_STRAIN_MARKER_PATH=/tmp/opencode-pool-strain.json # #13924: pool-strain marker path (JSON {since, reason, ttl_s}); fresh marker parks without recounting
# RESPONSES_FIRST_BYTE_TIMEOUT_MS=15000 # #13484: OpenCode Responses first-byte window, only used when the OPENCODE_RESPONSES_STALL_ROTATION flag is on (0 disables)
# FLUSH_EMPTY_RETRY_ENABLED=false # #14213 feature flag (Settings → Feature Flags wins): retry empty translated streaming turns through the normal credential path (up to STREAM_RECOVERY.EMPTY_TURN_RETRY_MAX retries)
# ── API Bridge (/v1 proxy server) ──
# API_BRIDGE_PROXY_TIMEOUT_MS=600000 # Proxy hop timeout (default: 10min)
@@ -1907,8 +1734,8 @@ APP_LOG_TO_FILE=true
# bodies is retained in the database.
# Used by: open-sse/handlers/chatCore.ts — cloneBoundedChatLogPayload()
# CHAT_LOG_TEXT_LIMIT=65536 # Max string length before truncation (default: 64 KB)
# CHAT_LOG_ARRAY_TAIL_ITEMS=1000 # Number of array items retained from tail (default: 1000)
# CHAT_LOG_MAX_DEPTH=20 # Max nesting depth before truncation (default: 20)
# CHAT_LOG_ARRAY_TAIL_ITEMS=128 # Number of array items retained from tail (default: 128)
# CHAT_LOG_MAX_DEPTH=6 # Max nesting depth before truncation (default: 6)
# CHAT_LOG_MAX_OBJECT_KEYS=80 # Max object keys retained (default: 80, 0 = no limit)
# CHAT_LOG_MAX_BODY_KB=1024 # Whole request/response body size before it's replaced by a bare
# {_truncated, messageCount, ...} summary instead of the full clone
@@ -1949,13 +1776,6 @@ APP_LOG_TO_FILE=true
# Override only to hand-tune for a known workload.
# HEAP_PRESSURE_THRESHOLD_MB=
# Exit the process after critical resource pressure persists, so a supervisor
# (systemd Restart=always, Docker restart policy) brings back a clean process.
# Accepts 1/true/yes/on. Default: false. Used by: open-sse/utils/resourcePressure.ts.
# OMNIROUTE_PRESSURE_SELF_RESTART=false
# How long (ms) critical pressure must persist before that exit fires. Default: 120000 (2m).
# OMNIROUTE_PRESSURE_SELF_RESTART_AFTER_MS=120000
# ── CLI helpers (bin/cli/) ──
# Override UI language for CLI output. Accepts BCP-47 locale (e.g. en, pt-BR).
# Falls back to LC_ALL / LC_MESSAGES / LANG / en if unset.
@@ -1973,10 +1793,6 @@ APP_LOG_TO_FILE=true
# Per-attempt HTTP timeout for CLI → server calls (milliseconds). Default: 30000.
# OMNIROUTE_HTTP_TIMEOUT_MS=30000
# How long `omniroute serve` waits for the health endpoint before printing the
# readiness-timeout warning (milliseconds). Also --ready-timeout. Default: 60000.
# OMNIROUTE_READY_TIMEOUT_MS=60000
# Set to 1 to print retry/backoff details to stderr during CLI commands.
# OMNIROUTE_VERBOSE=0
@@ -2122,12 +1938,6 @@ APP_LOG_TO_FILE=true
# Default: 8000 (8 seconds). On timeout, a last-good 200 is served when available.
# CATALOG_BUILD_TIMEOUT_MS=8000
# Age after which a connection's synced model list stops being authoritative for routing (#12849).
# A stale (or never-timestamped) synced catalog fails open to the provider registry.
# Used by: src/lib/db/models/activeSyncedCatalog.ts
# Default: 2592000000 (30 days)
# OMNIROUTE_SYNCED_CATALOG_STALE_AFTER_MS=2592000000
# ── NanoBanana (Image Generation) ──
# Polling config for async image generation jobs.
# Used by: open-sse/handlers/imageGeneration.ts
@@ -2267,13 +2077,6 @@ APP_LOG_TO_FILE=true
# Management key for an externally managed instance. Embedded instances use
# OmniRoute's encrypted service key.
# CLIPROXYAPI_MANAGEMENT_KEY=
# Host interface docker-compose publishes the cliproxyapi sidecar on (the
# --profile cliproxyapi Docker service, port 8317). Default: 127.0.0.1
# (loopback only) — its data volume holds provider OAuth/API credentials, and
# the pinned image has no env-based data-plane api-keys override (only a
# mounted config.yaml), so an unqualified publish spec would put a
# credential-bearing service on your whole LAN. (#12578)
# CLIPROXY_BIND_HOST=127.0.0.1
# ── Mux embedded service ──
# Override the port where the embedded Mux (coder/mux) agent-orchestration
@@ -2282,13 +2085,6 @@ APP_LOG_TO_FILE=true
# Used by: src/lib/services/bootstrap.ts, src/app/api/services/mux/_lib.ts
# MUX_SERVICE_PORT=8322
# ── open-wa embedded service ──
# Override the port where the embedded open-wa (WhatsApp Web automation)
# daemon listens. Always bound to 127.0.0.1 — never configurable to 0.0.0.0.
# Rarely needed — defaults to 8323.
# Used by: src/lib/services/bootstrap.ts
# OPENWA_SERVICE_PORT=8323
# ── Dario embedded service ──
# Override the host/port the embedded Dario (Claude Code subscription proxy)
# daemon binds to and is reached at. Always bound to 127.0.0.1 — never
@@ -2342,11 +2138,6 @@ APP_LOG_TO_FILE=true
# PROXY_HEALTH_ENABLED=true
# Sweep interval in ms (minimum 60000). Default: 600000 (10min).
# PROXY_HEALTH_INTERVAL_MS=600000
# Background recovery-pass interval in ms: how often the scheduler re-probes proxies it
# previously marked unhealthy, so a proxy that comes back is picked up without a restart.
# Values below 60000 fall back to the default.
# PROXY_HEALTH_RECOVERY_INTERVAL_MS=600000
# Reachability probe target for the scheduler and the auto-test endpoint.
# Point it at an internal/self-hosted URL to avoid the public default.
# PROXY_HEALTH_TEST_URL=https://httpbin.org/ip
@@ -2376,20 +2167,15 @@ APP_LOG_TO_FILE=true
# proxy — only the operator sets active/inactive (a flaky probe must not strand an
# assigned proxy; #6246). Set "true" to restore the legacy test-and-set behaviour.
# PROXY_HEALTH_AUTO_DEACTIVATE=false
# Opt-in feature flag (default off; a dashboard DB override wins over this value): show,
# under a proxy pool in the dashboard, how many observed egress IPs served its members over
# the last 24 h and how many connections used them (read-only, computed from the proxy log,
# never used for routing). "true" (or 1, yes) enables it.
# PROXY_POOL_EGRESS_OBSERVATION=false
# Allow OAuth and provider validation flows to bypass a pinned proxy and connect
# directly when proxy reachability pre-checks fail. Default: false.
# Also configurable from Dashboard > Settings > Feature Flags.
# OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK=false
# Rate limit maximum wait time before failing a request (ms). Default: 30000 (30s)
# Rate limit maximum wait time before failing a request (ms). Default: 15000 (15s)
# Used by: open-sse/services/rateLimitManager.ts
# RATE_LIMIT_MAX_WAIT_MS=30000
# RATE_LIMIT_MAX_WAIT_MS=15000
# Limiter-managed execution backstop (Bottleneck `expiration`): bounds a job's
# post-dispatch execution, never queue wait. Must stay ABOVE upstream
@@ -2494,10 +2280,6 @@ APP_LOG_TO_FILE=true
# Cursor stream idle timeout (ms). Default: 300000 (5 min).
# Used by: open-sse/executors/cursor.ts.
# CURSOR_STREAM_TIMEOUT_MS=300000
# Grace window (ms) after a composer kv_after_text soft terminator when bytes remain
# buffered — gives a trailing exec_mcp tool call time to complete its frame. 2s covers
# every exec_mcp-behind-kv ordering observed live.
# CURSOR_KV_GRACE_MS=2000
# Cursor tool-commit directive toggle. Default-on: when a request declares
# tools, a directive is prepended so composer-2.5 reliably issues tool calls
@@ -2505,22 +2287,6 @@ APP_LOG_TO_FILE=true
# Used by: open-sse/executors/cursor.ts.
# CURSOR_TOOL_DIRECTIVE=1
# Operator-defined system prompt text appended to the system message AFTER
# translation (post-translation injection), so it reaches codex/Responses and
# /v1/messages paths. Also used as the directive prefix stripped from echoed
# system preamble blocks. Leave unset to disable.
# Used by: open-sse/translator/request/claude-to-openai.ts, open-sse/translator/response/openai-to-claude.ts.
# OMNIROUTE_SYSTEM_INSTRUCTION_APPEND=
# Set to "1" to also strip echoed system-prompt PREAMBLE blocks
# (<analysis>/<system-reminder>/<summary> blocks, prose reproductions of the skill
# section) from the start of an openai->claude stream. OFF by default: it recognises
# constructs by English-prose heuristics and DOES mutate the response payload, so a
# reply that genuinely opens with such a section would lose it. Turn it on only when
# you actually hit the system-echo leak.
# Used by: open-sse/translator/response/openai-to-claude.ts, open-sse/utils/directivePreambleStripper.ts.
# OMNIROUTE_STRIP_SYSTEM_PREAMBLE=0
# Per-image fetch timeout (ms) for remote image_url vision input. Default: 15000.
# Used by: open-sse/utils/cursorImages.ts.
# CURSOR_IMAGE_FETCH_TIMEOUT_MS=15000
@@ -2717,16 +2483,6 @@ APP_LOG_TO_FILE=true
# for root-less / user-namespaced deployments (e.g. rootless Docker/Podman)
# where the operator trusts the CA manually (e.g. via Node's extra-CA-certs mechanism).
# OMNIROUTE_NO_SUDO=0
# ── Antigravity MITM bridge (bin/antigravity-bridge.mjs) ──
# Local HTTPS listener that fronts the Antigravity IDE and forwards to the router.
# BRIDGE_PORT: port the bridge listens on. Defaults to 20129.
# ROUTER_URL: where it forwards /v1/antigravity traffic. Defaults to the local router.
# CERT_DIR: directory holding server.key/server.crt for the bridge's TLS listener.
# Defaults to ~/.omniroute/mitm (the MITM CA directory).
# BRIDGE_PORT=20129
# ROUTER_URL=http://127.0.0.1:20128/v1/antigravity
# CERT_DIR=~/.omniroute/mitm
# Explicit opt-out: skip provisioning /etc/hosts DNS entries for the Antigravity
# proxy hostnames entirely (containers with no sudo/root available).
# Used by: src/mitm/dns/provision.ts.
@@ -2763,16 +2519,6 @@ APP_LOG_TO_FILE=true
# When enabled, the node authenticates with the API key stored on its connection.
# AUDIO_REMOTE_PROVIDER_NODES=false
# Used by: src/app/api/v1/_shared/rerankProviderNodes.ts — lets POST /v1/rerank (and
# the memory engine's loopback rerank step) use an OpenAI-compatible provider node
# hosted outside localhost, e.g. a LAN box or Tailscale peer running TEI/Infinity/vLLM.
# OFF by default: routing to a remote host changes egress identity, so it must be an
# explicit operator decision. Loopback/private nodes (localhost, 127.0.0.1,
# 172.16-31.x) are always allowed and unaffected by this flag. Remote nodes must also
# pass the provider outbound URL policy (see OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS);
# cloud-metadata hosts are never routed to.
# RERANK_REMOTE_PROVIDER_NODES=false
# ── Free Proxy Pool (auto-sync scheduler) ──
# Background refresh of the free-proxy pool. Opt-in, OFF by default (parallels
# Hard Rule #20's default-off posture for data-mutating background features).
@@ -2925,13 +2671,6 @@ APP_LOG_TO_FILE=true
# tokens (accessToken / refreshToken / providerSpecificData). Default OFF —
# only non-credential metadata is synced. See docs/security/SOCKET_DEV_FINDINGS.md §5.
# OMNIROUTE_CLOUD_SYNC_SECRETS=false
#
# Set to "true" to reject an UNSIGNED Cloud sync response when no local secret
# is configured (#13679). Default OFF keeps v3.8.x back-compat for peers that
# have not rotated in a shared secret yet; v3.9 flips the default to enforced.
# A signature that IS present is always verified, and always rejected when
# OMNIROUTE_CLOUD_SYNC_SECRET is unset, regardless of this flag.
# OMNIROUTE_CLOUD_SYNC_ENFORCE_SIGNATURE=false
# ─── Zed import legacy compat (v3.8.6) ──────────────────────────────────────
# Set to "true" to fall back to the v3.8.5 one-step "import everything from
@@ -2982,10 +2721,6 @@ PLAYGROUND_COMPARE_MAX_COLUMNS=4
# MEMORY_VEC_TOP_K=20 # default top-K for vector search
# MEMORY_RRF_K=60 # RRF k constant (sqlite-vec hybrid recipe)
# HF_HUB_ENDPOINT=https://huggingface.co # override Hugging Face Hub base URL for static potion downloads
# Test/diagnostic seam (src/lib/memory/vectorStore.ts) — forces getVectorStore() to
# return null (simulates a cloud/WASM environment without sqlite-vec), degrading
# memory retrieval to FTS5 keyword search. Default off; leave unset in production.
# VECTOR_STORE_DISABLE_VEC=false
# TV6 typed memory decay (OPT-IN, default off — the sweep DELETES decayed memories)
# MEMORY_TYPED_DECAY_ENABLED=false # master switch for the destructive sweep (default off)
# MEMORY_TYPED_DECAY_EPISODIC_DAYS=30 # episodic TTL in days; 0 = episodic immune too
@@ -3227,13 +2962,6 @@ QUOTA_STORE_DRIVER=sqlite
# CHATGPT_WEB_CODEX_CHROME_PATH=/usr/bin/chromium
# CHROME_PATH=/usr/bin/chromium
# CHATGPT_WEB_CODEX_CDP_URL=http://chatgpt-web-codex-browser:9223
# CDP_PROXY_TOKEN required by docker/chatgpt-web-codex-browser/cdp-proxy.mjs (#13679):
# when set, every request to the CDP proxy sidecar must present it as an
# `X-Omni-Cdp-Token` header. Left unset, the proxy keeps forwarding requests
# unauthenticated (network isolation via docker-compose.yml's dedicated
# `chatgpt-web-codex-net` is the default mitigation). Generate with:
# `openssl rand -hex 32`
# CDP_PROXY_TOKEN=
# CHATGPT_WEB_CODEX_TUNNEL_ID=tunnel_0123456789abcdef0123456789abcdef
# CHATGPT_WEB_CODEX_RUNTIME_KEY=
# CHATGPT_WEB_CODEX_CONNECTOR_NAME=OmniRoute Codex v2
@@ -3262,7 +2990,6 @@ QUOTA_STORE_DRIVER=sqlite
# OMNIROUTE_VNC_READY_MS=45000
# OMNIROUTE_VNC_HARVEST_MS=20000
# OMNIROUTE_VNC_CHROMIUM_ARGS=--remote-debugging-port=9222 --no-first-run --no-default-browser-check
# OMNIROUTE_VNC_NETWORK=omniroute-vnc-browser-login
# ─────────────────────────────────────────────────────────────────────────────
# Data-dir alias (optional — open-sse/services/notionThreadSessions.ts)
@@ -3343,11 +3070,6 @@ QUOTA_STORE_DRIVER=sqlite
# Telegram Mini App bridge. The update endpoint remains disabled while the bot
# token is unset. Used by: src/lib/telegram/* and src/app/api/telegram/update/route.ts.
# TELEGRAM_BOT_TOKEN=
# Shared secret registered with setWebhook and echoed back by Telegram as the
# X-Telegram-Bot-Api-Secret-Token header. REQUIRED for the webhook path: without
# it the webhook is rejected with 503, because an unauthenticated update lets any
# caller mint API keys and spend upstream quota. The Mini App path does not use it.
# TELEGRAM_WEBHOOK_SECRET=
# TELEGRAM_DEFAULT_MODEL=auto/chat
# TELEGRAM_BOT_API_BASE=https://api.telegram.org
# TELEGRAM_WEBHOOK_TIMEOUT_MS=60000

View File

@@ -1,41 +0,0 @@
# ──────────────────────────────────────────────────────────────────────
# OmniRoute — Self-Host env (minimal, zero-fee self-host)
# ──────────────────────────────────────────────────────────────────────
# cp .env.selfhost.example .env
# Edit only the two lines marked `# EDIT ME`. Everything else has a sane
# default. No secrets are baked in — OmniRoute never ships credentials.
#
# Full variable reference: docs/guides/DOCKER_GUIDE.md and .env.example
# ──────────────────────────────────────────────────────────────────────
# ── Ports (host-side) ──────────────────────────────────────────────────
# Dashboard + API + Live-WS. Already match the image defaults.
DASHBOARD_PORT=20128
API_PORT=20129
LIVE_WS_PORT=20132
# ── Bind address ───────────────────────────────────────────────────────
# 127.0.0.1 = loopback only (safe with REQUIRE_API_KEY=false, the default).
# Set to 0.0.0.0 ONLY when REQUIRE_API_KEY=true OR a reverse proxy
# enforces auth upstream. Exposing an unauthenticated /v1 proxy on the
# LAN/WAN lets anyone burn your provider quotas. # EDIT ME if you must.
APP_BIND_HOST=127.0.0.1
# ── Auth ──────────────────────────────────────────────────────────────
# false = the dashboard and /v1 proxy are open to APP_BIND_HOST's network.
# true = every request needs an API key / dashboard login. The dashboard
# auto-creates INITIAL_PASSWORD on first boot (read it from the logs:
# `docker logs omniroute | grep -i password`). # EDIT ME — set true.
REQUIRE_API_KEY=false
# INITIAL_PASSWORD= # uncomment to pre-seed the dashboard password
# ── Memory ceiling (V8 old-space) ──────────────────────────────────────
# 1024 = dashboard + light chat. Coding agents (long POST /v1/responses
# bodies) need more — see SELF_HOST_GUIDE.md "sizing". 2048 is a safe
# default for a single user who runs Claude Code / Codex through it.
OMNIROUTE_MEMORY_MB=2048
# ── Browser-facing origin (optional) ───────────────────────────────────
# Set ONLY if you expose OmniRoute behind a domain via a reverse proxy.
# NEXT_PUBLIC_BASE_URL=https://your-domain.example.com
# BASE_URL=http://omniroute:20128

View File

@@ -1,6 +1,6 @@
name: Bug Report
description: Report a bug or unexpected behavior in OmniRoute
title: "fix(): "
title: "[BUG] "
labels: ["bug"]
body:
- type: markdown
@@ -8,8 +8,6 @@ body:
value: |
Thanks for taking the time to report a bug. Please fill out the sections below so we can reproduce and fix the issue.
The title is prefilled as `fix(): ` to match the [Conventional Commits](https://github.com/diegosouzapw/OmniRoute/blob/main/CONTRIBUTING.md#commit-messages) convention — pick a scope from the list documented there (e.g. `providers`, `resilience`, `dashboard`, `api`).
- type: input
id: version
attributes:

View File

@@ -1,6 +1,6 @@
name: Feature Request
description: Suggest a new feature or improvement for OmniRoute
title: "feat(): "
title: "[Feature] "
labels: ["enhancement"]
body:
- type: markdown
@@ -8,8 +8,6 @@ body:
value: |
Thanks for suggesting a feature! Please describe the problem you're trying to solve and how you'd like it to work.
The title is prefilled as `feat(): ` to match the [Conventional Commits](https://github.com/diegosouzapw/OmniRoute/blob/main/CONTRIBUTING.md#commit-messages) convention — pick a scope from the list documented there (e.g. `providers`, `resilience`, `dashboard`, `api`).
- type: textarea
id: problem
attributes:

View File

@@ -54,10 +54,9 @@ runs:
# --no-audit: `audit:deps` is its own gate; the inline audit only adds latency.
if npm ci --no-audit --no-fund; then
exit 0
else
exit_code=$?
fi
exit_code=$?
if [ "$attempt" -eq "$max_attempts" ]; then
exit "$exit_code"
fi

View File

@@ -144,22 +144,6 @@ jobs:
- run: npm run check:test-discovery
- run: npm run check:radar-sentinels
- run: npm run check:tracked-artifacts
- name: AI attribution in commit / PR metadata (Hard Rule #16)
if: github.event_name == 'pull_request'
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
printf '%s' "$PR_BODY" > "$RUNNER_TEMP/pr-body.md"
npm run check:ai-attribution -- --range "$PR_BASE_SHA..$PR_HEAD_SHA" --pr-title "$PR_TITLE" --pr-body-file "$RUNNER_TEMP/pr-body.md"
# A test parked in vitest.config.ts's exclude list does not run, and looks like
# coverage to whoever reads the tree. 62 files accumulated behind a comment pointing
# at #8618 — closed in August while the list grew to 62; 51 of them passed when
# finally measured (#13204). This gate requires every exclusion to name a tracker and
# to appear in config/quality/vitest-exclusions.json, so the debt stays reviewable.
- run: npm run check:vitest-exclusions
# (gap 30) Also lives in quality.yml's PR-only "Merge integrity" job — because the
# CHANGELOG half of that job needs a base to diff against. This half does NOT: the
# generator either reproduces the committed SKILL.md files or it does not.
@@ -463,11 +447,8 @@ jobs:
# One FS inventory of src/app/api for both anti-hallucination directions.
- name: API docs refs (openapi + prose → routes)
run: npm run check:api-docs-refs
# Blocking since the 2026-09 docs re-sync: a core doc edited without `npm run i18n:run
# --files=<doc>` (or `--adopt` for a mechanical edit) leaves 65 stale mirrors behind;
# the run only retranslates the `## ` sections whose text changed, so it is cheap.
- name: i18n docs drift (sources changed since their translation)
run: node scripts/i18n/check-translation-drift.mjs
- name: i18n translation drift (warn)
run: node scripts/i18n/check-translation-drift.mjs --warn
docs-lint:
name: Docs Lint (prose — advisory)
@@ -524,11 +505,9 @@ jobs:
- run: node scripts/i18n/check-ui-keys-coverage.mjs --threshold=65
# Real-translation ratchet: a leaf copied verbatim from en.json passes key
# parity above but is still English to the user (es shipped 55% English).
# Blocking since PR-4 retranslated the verbatim-English backlog: the share of
# untranslated leaves per locale may only fall (ratchet baseline in
# config/quality/i18n-translation-baseline.json; `npm run i18n:check-ratio:update`).
- name: i18n real-translation ratio
run: node scripts/i18n/check-translation-ratio.mjs
# Advisory in PR-0; flipped to blocking once the backlog is retranslated (PR-4).
- name: i18n real-translation ratio (advisory)
run: node scripts/i18n/check-translation-ratio.mjs --warn
# #8463: a rewritten English value used to leave its 39 translations behind
# silently (googleOAuthWarning shipped wrong copy in 39 locales for months).
# Key parity above cannot see it — a stale translation counts as covered.
@@ -536,30 +515,6 @@ jobs:
env:
BASE_REF: ${{ github.base_ref && format('origin/{0}', github.base_ref) || '' }}
run: node scripts/i18n/check-ui-value-drift.mjs
# Sibling of the drift gate above. That one catches an English value that was
# REWRITTEN; this one catches an English key that was ADDED while some locales never
# got it. The coverage gate at the top of this job cannot: it is a percentage per
# locale, and 11 absent keys out of ~13,000 leaves coverage at 99.9%. Incident: the
# Phase 3 canvas keys were translated across the 42 locales that existed, then the EU
# batch (#13044) took the repo to 51 and the nine newcomers shipped untranslated.
- name: i18n new-key coverage (a new key must reach every locale)
env:
BASE_REF: ${{ github.base_ref && format('origin/{0}', github.base_ref) || '' }}
run: node scripts/i18n/check-new-key-coverage.mjs
# Absolute complement of the two gates above: every locale must carry exactly the key
# set of en.json, whatever the age of the key. A locale batch is generated from the
# en.json of the day the branch is cut and translates for days while the base keeps
# adding keys — the batch PR adds no key itself, so the new-key gate stays silent and
# 43 absent keys out of ~13,000 still read 99.7 % coverage. Incident 2026-09-15:
# batch 1 (#13044) landed 43 keys short in nine locales, batch 2 (#13660) 10 keys short
# in eight. Fix is `sync-ui-keys --locale=<codes> --translate-markers`.
- name: i18n key completeness (every locale carries every en.json key)
run: node scripts/i18n/check-key-completeness.mjs
# Same gate for the CLI catalogs (bin/cli/locales). check:cli-i18n only compares
# pt-BR / zh-CN / zh-TW; 38 locales shipped with 124 of 830 keys for months
# (audit 2026-09-16) and the CLI silently fell back to English for them.
- name: i18n key completeness (CLI catalogs)
run: node scripts/i18n/check-key-completeness.mjs --catalog=cli
# #8038: cheap glossary/protected-terms consistency gate —
# complements i18n-ui-coverage (key parity) and the ICU `i18n` job below
@@ -1123,7 +1078,7 @@ jobs:
# stalled upload can neither eat the job's budget nor turn a green job cancelled.
timeout-minutes: 5
continue-on-error: true
uses: codecov/codecov-action@0b35c9ecc4f0529d0eb674914510c22f85b196b4 # v7.1.0
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
files: coverage/lcov.info
token: ${{ secrets.CODECOV_TOKEN }}

View File

@@ -22,10 +22,10 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
- uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
languages: javascript-typescript
queries: security-extended
- uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
- uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
category: "/language:javascript-typescript"

View File

@@ -193,7 +193,6 @@ jobs:
platforms: ${{ matrix.platform }}
build-args: |
OMNIROUTE_USE_TURBOPACK=0
OMNIROUTE_BUILD_MEMORY_MB=12288
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
tags: |
${{ env.IMAGE_NAME }}
@@ -213,7 +212,6 @@ jobs:
platforms: ${{ matrix.platform }}
build-args: |
OMNIROUTE_USE_TURBOPACK=0
OMNIROUTE_BUILD_MEMORY_MB=12288
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
tags: |
${{ env.IMAGE_NAME }}
@@ -241,7 +239,6 @@ jobs:
platforms: ${{ matrix.platform }}
build-args: |
OMNIROUTE_USE_TURBOPACK=0
OMNIROUTE_BUILD_MEMORY_MB=12288
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
tags: |
${{ env.IMAGE_NAME }}
@@ -269,7 +266,6 @@ jobs:
platforms: ${{ matrix.platform }}
build-args: |
OMNIROUTE_USE_TURBOPACK=0
OMNIROUTE_BUILD_MEMORY_MB=12288
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
tags: |
${{ env.IMAGE_NAME }}
@@ -415,12 +411,16 @@ jobs:
path: /tmp/digests/bun-web
merge-multiple: true
- name: Create Docker Hub version manifests
- name: Create Docker Hub manifest
run: |
set -euo pipefail
create_manifest() {
local image="$1" suffix="$2" dir="$3" optional="${4:-}"
local tags=(-t "${image}:${VERSION}${suffix}")
if [ "$PROMOTE_LATEST" = "true" ]; then
tags+=(-t "${image}:latest${suffix}")
fi
local refs=()
while IFS= read -r digest_file; do
refs+=("${image}@sha256:$(basename "$digest_file")")
@@ -433,7 +433,7 @@ jobs:
echo "No image digests in $dir" >&2
exit 1
fi
docker buildx imagetools create -t "${image}:${VERSION}${suffix}" "${refs[@]}"
docker buildx imagetools create "${tags[@]}" "${refs[@]}"
}
create_manifest "${IMAGE_NAME}" "" /tmp/digests/base
@@ -441,12 +441,16 @@ jobs:
create_manifest "${IMAGE_NAME}" "-bun" /tmp/digests/bun-base optional
create_manifest "${IMAGE_NAME}" "-web-bun" /tmp/digests/bun-web optional
- name: Create GHCR version manifests
- name: Create GHCR manifest
run: |
set -euo pipefail
create_manifest() {
local image="$1" suffix="$2" dir="$3" optional="${4:-}"
local tags=(-t "${image}:${VERSION}${suffix}")
if [ "$PROMOTE_LATEST" = "true" ]; then
tags+=(-t "${image}:latest${suffix}")
fi
local refs=()
while IFS= read -r digest_file; do
refs+=("${image}@sha256:$(basename "$digest_file")")
@@ -459,7 +463,7 @@ jobs:
echo "No image digests in $dir" >&2
exit 1
fi
docker buildx imagetools create -t "${image}:${VERSION}${suffix}" "${refs[@]}"
docker buildx imagetools create "${tags[@]}" "${refs[@]}"
}
create_manifest "${GHCR_IMAGE_NAME}" "" /tmp/digests/base
@@ -467,59 +471,6 @@ jobs:
create_manifest "${GHCR_IMAGE_NAME}" "-bun" /tmp/digests/bun-base optional
create_manifest "${GHCR_IMAGE_NAME}" "-web-bun" /tmp/digests/bun-web optional
- name: Smoke-test published Docker image
if: needs.prepare.outputs.version != 'main'
run: |
set -euo pipefail
container="omniroute-smoke-${VERSION//[^a-zA-Z0-9_.-]/-}"
trap 'docker rm -f "$container" >/dev/null 2>&1 || true' EXIT
docker run --detach --name "$container" "${IMAGE_NAME}:${VERSION}"
for attempt in $(seq 1 30); do
status="$(docker inspect --format '{{.State.Health.Status}}' "$container")"
if [ "$status" = "healthy" ]; then
exit 0
fi
if [ "$status" = "unhealthy" ] || [ "$(docker inspect --format '{{.State.Status}}' "$container")" = "exited" ]; then
docker logs "$container"
exit 1
fi
sleep 2
done
docker logs "$container"
exit 1
- name: Promote Docker Hub latest tags
if: needs.prepare.outputs.promote_latest == 'true'
run: |
set -euo pipefail
promote_tag() {
local suffix="$1"
docker buildx imagetools create -t "${IMAGE_NAME}:latest${suffix}" "${IMAGE_NAME}:${VERSION}${suffix}"
}
promote_tag ""
promote_tag "-web"
for suffix in -bun -web-bun; do
if docker buildx imagetools inspect "${IMAGE_NAME}:${VERSION}${suffix}" >/dev/null 2>&1; then
promote_tag "$suffix"
fi
done
- name: Promote GHCR latest tags
if: needs.prepare.outputs.promote_latest == 'true'
run: |
set -euo pipefail
promote_tag() {
local suffix="$1"
docker buildx imagetools create -t "${GHCR_IMAGE_NAME}:latest${suffix}" "${GHCR_IMAGE_NAME}:${VERSION}${suffix}"
}
promote_tag ""
promote_tag "-web"
for suffix in -bun -web-bun; do
if docker buildx imagetools inspect "${GHCR_IMAGE_NAME}:${VERSION}${suffix}" >/dev/null 2>&1; then
promote_tag "$suffix"
fi
done
- name: Inspect image
if: needs.prepare.outputs.version != 'main'
run: |
@@ -584,7 +535,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.38.0
uses: github/codeql-action/upload-sarif@v4.37.9
with:
sarif_file: trivy-results.sarif
category: trivy-image

View File

@@ -293,9 +293,6 @@ jobs:
# #8781: open-sse workspace typecheck gate — the workspace imports @/ which
# escapes to src/ via undeclared path aliases. See check-open-sse-typecheck.mjs.
open-sse-typecheck
# Hard Rule #16 — AI/bot attribution in PR commits, title or body (#14436). Reads the PR
# from GITHUB_EVENT_PATH; no-op on non-PR events. ci.yml only runs on PRs to main.
ai-attribution
)
ratchet_gates=(
secrets vuln-ratchet workflows openapi-breaking

View File

@@ -10,11 +10,7 @@ name: Radar Export
on:
workflow_dispatch: # o operador pode publicar sob demanda (de qualquer ref)
push:
# `main` e a release ativa (default branch) publicam no mesmo asset estável: o
# radar-server só consome o asset, então um merge de catálogo na release que ficasse
# à espera do cron semanal deixava o feed até 7 dias atrás do README (2026-09-14: a
# linha da Together removida em d6e62ae só saiu do feed com dispatch manual).
branches: [main, "release/**"]
branches: [main] # produção: só o catálogo do main clobra o asset estável
paths:
- open-sse/config/freeModelCatalog.data.ts
- open-sse/config/freeModelCatalog.ts
@@ -23,9 +19,7 @@ on:
- scripts/release/radar-export.mjs
- .github/workflows/radar-export.yml
schedule:
# Diário 03:17 UTC — antes do `radar-feed.timer` do servidor (04:23 UTC), para o ciclo
# do dia já enxergar o export do dia; também mantém geradoEm/proveniência frescos.
- cron: "17 3 * * *"
- cron: "17 6 * * 1" # semanal (segunda 06:17 UTC): mantém geradoEm/proveniência frescos
permissions:
contents: read

View File

@@ -1,42 +0,0 @@
name: Release acceptance
on:
push:
branches: ["release/v*"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: release-acceptance-${{ github.ref }}
cancel-in-progress: false
jobs:
acceptance:
name: Release acceptance
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: "22"
cache: npm
- run: npm ci
- name: Emit shadow acceptance report
run: |
node scripts/quality/validate-release-acceptance.mjs \
--plan tests/fixtures/release-acceptance/plan-lint.json \
--manifests tests/fixtures/release-acceptance/shadow-manifests \
--out release-acceptance-report.json
continue-on-error: true
- uses: actions/upload-artifact@v4
if: always()
with:
name: release-acceptance-report
path: release-acceptance-report.json
if-no-files-found: ignore
retention-days: 30

2
.gitignore vendored
View File

@@ -74,7 +74,6 @@ yarn-error.log*
# Local gitleaks artifacts (do not commit)
gitleaks-local.json
!.env.example
!.env.selfhost.example
!.env.homolog.example
!.env.devin-bridge.example
# Provider API keys (never commit)
@@ -219,7 +218,6 @@ scripts/i18n/_pending-keys.json
# PR Reviews and local feedback files
pr_reviews*.json
/review/
#hidden local data directories (never commit)
.local-data/

View File

@@ -97,11 +97,4 @@
# credential; the generic-api-key rule flags the long hyphenated string.
'''omniroute-cheaperinference-sponsor-banner-dismissed-v\d+''',
'''SunbreakWebUI1''',
# Uzbek dashboard catalog (#13727, src/i18n/messages/uz.json `outputTokenDesc`):
# "Yakunlash/javob tokenlari" = "completion/response tokens". The rule reads the
# `...TokenDesc` key as a token assignment and the translated words as its value.
'''Yakunlash/javob''',
# Feature-flag id from #13439 (src/shared/constants/featureFlagDefinitions.ts):
# `key: "PROTECTED_PRIORITY_INFRA_502_ENABLED"` is a flag name, not a credential.
'''PROTECTED_PRIORITY_INFRA_502_ENABLED''',
]

View File

@@ -1,4 +0,0 @@
#!/usr/bin/env sh
# Hard Rule #16 — no AI/bot Co-Authored-By trailers or AI-generation footers in commit metadata.
# Human co-authors stay. Incident record: #14436.
node scripts/check/check-ai-attribution.mjs --message-file "$1"

View File

@@ -1,17 +1,14 @@
#!/usr/bin/env sh
set -eu
if ! command -v npx >/dev/null 2>&1; then
echo "npx not found in PATH — install the project's Node/npm toolchain before committing." >&2
exit 1
echo "⚠️ npx not found in PATH — skipping pre-commit hooks"
echo " Run 'npm run lint && npm run check:any-budget:t11' manually before pushing."
exit 0
fi
# Cheap, deterministic local gates (re-enabled). Slower checks (i18n drift,
# openapi coverage/security-tiers, env-doc sync) run in CI to keep commits fast.
sh scripts/check/check-git-identity.sh
# The stash is shared by every worktree. Never let a local hook stash another
# session's changes, and never download a missing tool during a commit.
npx --no-install lint-staged --no-stash
npx lint-staged
node scripts/check/check-docs-sync.mjs
npm run check:any-budget:t11
node scripts/check/check-tracked-artifacts.mjs

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,3 @@
# wasm-bindgen glue + embedded WASM_BASE64. Prettier rewrites the generated JS
# (quotes, wrapping) on any touch of this file; format tinycmsDomMocks.ts instead.
open-sse/executors/tinycmsSigner.ts
# Long reference tables are manually aligned; formatting the whole file causes noisy diffs.
docs/reference/ENVIRONMENT.md

View File

@@ -26,34 +26,6 @@ npm install @omniroute/opencode-plugin-v2
}
```
### Local `file://` install
OpenCode resolves a local plugin **directory** by probing the subpaths
`server.*` / `index.*` (then `tui`, `rpc`) at the package root — it never
reads `package.json` `main`/`exports`. A folder exposing only `dist/` is
therefore silently skipped (no `loading plugin`, no error).
This package ships a root `server.js` re-exporting `./dist/index.js` for
exactly that probe, so pointing OpenCode at a local checkout works:
```json
{
"plugins": [
{
"package": "file:///path/to/OmniRoute/@omniroute/opencode-plugin-v2",
"options": {
"providerId": "omniroute",
"baseURL": "http://localhost:20128"
}
}
]
}
```
Prerequisites when targeting a folder: run `npm run build` first (the root
`server.js` re-exports `./dist/index.js`), and keep the folder's root
`server.js``dist/` alone is not resolvable by the host.
## Credentials
The plugin needs a gateway key to read the catalog, and looks for one in this
@@ -84,14 +56,8 @@ explicitly:
}
```
The token can also come from the `OMNIROUTE_MANAGEMENT_API_KEY` environment
variable (the option wins when both are set). Resolution order:
`managementReadToken` option, then `OMNIROUTE_MANAGEMENT_API_KEY`, then the
`apiKey` fallback.
Left unset, `managementReadToken` falls back to `apiKey` for backwards
compatibility, and the plugin warns once at startup that the fallback is
active. When a gateway rejects that fallback, the catalog still
compatibility. When a gateway rejects that fallback, the catalog still
publishes — but with raw model ids instead of display names, no canonical
alias dedupe, no pricing and no combos. The plugin warns once per endpoint
when this happens, naming the endpoint and the consequence, so the degraded
@@ -99,25 +65,25 @@ catalog is never a mystery.
## Options
| Key | Default | Notes |
| -------------------------------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `providerId` | `"omniroute"` | Provider id and integration id; models publish under `<providerId>/…` |
| `baseURL` | required | OmniRoute gateway root (no `/v1` suffix needed) |
| `apiKey` | connected credential, then `OMNIROUTE_API_KEY` | Chat key for `/v1/*` — see [Credentials](#credentials) |
| `managementReadToken` | option, then `OMNIROUTE_MANAGEMENT_API_KEY`, then `apiKey` | Management key for `/api/*` (combos, providers, enrichment) — usually **not** the same key |
| `displayName` | `"OmniRoute"` | Provider display name |
| `timeoutMs` | `10000` | Per-endpoint fetch timeout (auto-combos use 5s) |
| `modelCacheTtlMs` | `300000` | Catalog cache TTL; disk snapshot warms cold starts |
| `timeouts` | per-endpoint override | `{ models, combos, autoCombos, enrichment }` in ms; falls back to `timeoutMs` |
| `enrichment` | `true` | Fetch names + pricing (`/api/pricing*`, `/api/free-tier/summary`) |
| `providerTag` | `true` | Prefix a display name with the upstream provider it routes to |
| `geminiSanitization` | `true` | Strip `$schema`/`additionalProperties` from tool schemas sent to Gemini models (`$ref` tools are forwarded untouched) |
| `usableOnly` | `false` | Filter to healthy provisioned providers (`/api/providers`) |
| `visibleModels` / `hiddenModels` | `[]` | Exact-or-suffix allowlists, deny wins |
| `apiFormat.allowAnthropic` | `false` | Route allowlisted ids to the Anthropic API block |
| `apiFormat.anthropicModels` | `[]` | Full model ids routed to Anthropic |
| `apiFormat.anthropicPrefixes` | v1 defaults | Deprecated, warns once — prefer `anthropicModels` |
| `logLevel` / `startupDebug` | `warn` / `false` | Logger verbosity |
| Key | Default | Notes |
| -------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `providerId` | `"omniroute"` | Provider id and integration id; models publish under `<providerId>/…` |
| `baseURL` | required | OmniRoute gateway root (no `/v1` suffix needed) |
| `apiKey` | connected credential, then `OMNIROUTE_API_KEY` | Chat key for `/v1/*` — see [Credentials](#credentials) |
| `managementReadToken` | falls back to `apiKey` | Management key for `/api/*` (combos, providers, enrichment) — usually **not** the same key |
| `displayName` | `"OmniRoute"` | Provider display name |
| `timeoutMs` | `10000` | Per-endpoint fetch timeout (auto-combos use 5s) |
| `modelCacheTtlMs` | `300000` | Catalog cache TTL; disk snapshot warms cold starts |
| `timeouts` | per-endpoint override | `{ models, combos, autoCombos, enrichment }` in ms; falls back to `timeoutMs` |
| `enrichment` | `true` | Fetch names + pricing (`/api/pricing*`, `/api/free-tier/summary`) |
| `providerTag` | `true` | Prefix a display name with the upstream provider it routes to |
| `geminiSanitization` | `true` | Strip `$schema`/`additionalProperties` from tool schemas sent to Gemini models (`$ref` tools are forwarded untouched) |
| `usableOnly` | `false` | Filter to healthy provisioned providers (`/api/providers`) |
| `visibleModels` / `hiddenModels` | `[]` | Exact-or-suffix allowlists, deny wins |
| `apiFormat.allowAnthropic` | `false` | Route allowlisted ids to the Anthropic API block |
| `apiFormat.anthropicModels` | `[]` | Full model ids routed to Anthropic |
| `apiFormat.anthropicPrefixes` | v1 defaults | Deprecated, warns once — prefer `anthropicModels` |
| `logLevel` / `startupDebug` | `warn` / `false` | Logger verbosity |
## Tool calling on Gemini models

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,6 @@
},
"files": [
"dist",
"server.js",
"README.md",
"LICENSE"
],
@@ -27,7 +26,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@opencode/plugin": "2.0.12",
"@opencode-ai/plugin": "1.18.29",
"@types/node": "^22.19.19",
"tsup": "^8.5.1",
"tsx": "^4.22.3",
@@ -63,9 +62,6 @@
"access": "public"
},
"peerDependencies": {
"@opencode/plugin": ">=2.0.12 <3"
},
"overrides": {
"esbuild": "^0.28.1"
"@opencode-ai/plugin": ">=1.18.29 <2"
}
}

View File

@@ -1,10 +0,0 @@
// Root entrypoint for OpenCode host plugin loading.
//
// OpenCode 2.x local installs (`file://` directory in `opencode.json`) never
// read package.json `main`/`exports`: the config scan resolves only the
// subpaths ["server", ""] then ["tui"], ["rpc"] from the package directory.
// With only `dist/index.js` present the scan yields `{}` and the plugin is
// silently dropped (no `loading plugin`, no error). This stable re-export
// keeps `dist/` as the only build output while exposing the module the host
// actually probes for.
export { default } from "./dist/index.js";

View File

@@ -1,6 +1,6 @@
import { createHash } from "node:crypto";
import { homedir } from "node:os";
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import type {
OmniRouteEnrichmentEntry,
@@ -10,7 +10,6 @@ import type {
OmniRouteRawCombo,
OmniRouteRawModelEntry,
} from "./shared/index.js";
import { isHttpUrl } from "./shared/index.js";
export const DEFAULT_MODEL_CACHE_TTL_MS = 300_000 as const;
@@ -35,9 +34,8 @@ export const SNAPSHOT_FORMAT_VERSION = 2 as const;
/**
* A raw snapshot entry is stale when it cannot be mapped to a publishable
* model: no string `id` (unroutable), or a pre-mapped `api` block missing a
* valid `npm` package (the runner would reject it as `Unsupported package`)
* or a usable `url` (the host would reach the AI SDK with no baseURL).
* model: no string `id` (unroutable) or a pre-mapped `api` block without a
* valid `npm` package (the runner would reject it as `Unsupported package`).
* Plain `/v1/models` entries carry no `api` block -- it is synthesized at
* publish time -- so only a present-but-invalid block drops the entry.
*/
@@ -49,11 +47,7 @@ export function isStaleSnapshotModel(entry: unknown): boolean {
if (api === undefined) return false;
if (!api || typeof api !== "object") return true;
const npm = (api as { npm?: unknown }).npm;
if (typeof npm !== "string" || npm.length === 0) return true;
// Same requirement as `npm`, and the same predicate the options schema
// applies to `baseURL`: a pre-mapped block without a callable `url` publishes
// a model the host cannot route -- see `legacyApiToInfoApi`.
return !isHttpUrl((api as { url?: unknown }).url);
return typeof npm !== "string" || npm.length === 0;
}
interface DiskSnapshotV2 {
@@ -81,12 +75,6 @@ interface DiskSnapshotV2 {
*/
const MAX_SNAPSHOT_BYTES = 32 * 1024 * 1024;
// Suffix for the temp file each write publishes via rename. Monotone per
// process: two writes for one provider (for example across a credential
// rotation) must not share a temp name. Built after the empty-models and
// size-cap guards, so only real attempts consume a value.
let snapshotWriteCounter = 0;
function trimTrailingSlashes(value: string): string {
let i = value.length;
while (i > 0 && value.charCodeAt(i - 1) === 0x2f) i -= 1;
@@ -139,7 +127,7 @@ export async function readDiskSnapshot(
if (
!parsed ||
typeof parsed.v !== "number" ||
parsed.v !== SNAPSHOT_FORMAT_VERSION ||
parsed.v < SNAPSHOT_FORMAT_VERSION ||
typeof parsed.identityFingerprint !== "string" ||
parsed.identityFingerprint !== identityFingerprint
) {
@@ -157,7 +145,7 @@ export async function readDiskSnapshot(
(entry) => !isStaleSnapshotModel(entry)
);
if (stale > 0) {
logger?.warn(`[omniroute-v2] dropping ${stale} stale snapshot entries with an unusable api block`);
logger?.warn(`[omniroute-v2] dropping ${stale} stale snapshot entries without api block`);
}
if (models.length === 0) return undefined;
return {
@@ -185,14 +173,8 @@ export async function readDiskSnapshot(
export async function writeDiskSnapshot(
providerId: string,
snapshot: CatalogSnapshot,
identityFingerprint: string,
logger?: { warn: (message: string) => void }
identityFingerprint: string
): Promise<void> {
// Monotone per-process suffix: two writes for one provider (for example
// across a credential rotation) must not share a temp name. Declared here
// so the catch below can clean it up; assigned after the guards so only
// real attempts consume a counter value.
let tmp = "";
try {
if (snapshot.models.length === 0) return;
const file = diskSnapshotPath(providerId);
@@ -208,33 +190,14 @@ export async function writeDiskSnapshot(
writtenAt: Date.now(),
};
let payload = JSON.stringify(envelope);
if (
Buffer.byteLength(payload, "utf8") > MAX_SNAPSHOT_BYTES &&
envelope.enrichment !== undefined
) {
if (payload.length > MAX_SNAPSHOT_BYTES && envelope.enrichment !== undefined) {
delete envelope.enrichment;
payload = JSON.stringify(envelope);
}
if (Buffer.byteLength(payload, "utf8") > MAX_SNAPSHOT_BYTES) {
logger?.warn(
`[omniroute-v2] snapshot for ${providerId} exceeds the size cap, skipping disk write`
);
return;
}
tmp = `${file}.${process.pid}.${snapshotWriteCounter++}`;
await writeFile(tmp, payload, { encoding: "utf8", mode: 0o600 });
await rename(tmp, file);
} catch (err) {
if (payload.length > MAX_SNAPSHOT_BYTES) return;
await writeFile(file, payload, { encoding: "utf8", mode: 0o600 });
} catch {
// Best-effort: callers already hold the in-memory entry.
logger?.warn(
`[omniroute-v2] snapshot write failed for ${providerId}: ` +
`${err instanceof Error ? err.message : String(err)}, keeping the in-memory entry`
);
try {
await unlink(tmp);
} catch {
// Ignore: the temp file may not exist (mkdir failed first).
}
}
}

View File

@@ -1,7 +1,8 @@
import type { Model, Provider } from "@opencode/plugin";
import type { LegacyModel } from "./legacy-model.js";
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import { type HostContract, detectHostContract, emitsLegacyFields } from "./compat.js";
import type { Model as LegacyModelV2 } from "@opencode-ai/sdk/v2";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
import {
isHttpUrl,
type ApiFormatV2,
type LogLevel,
type Logger,
@@ -90,135 +91,67 @@ export interface CatalogFetchers {
onSourceError?: (endpoint: string, reason: string) => void;
}
export type StableModelInfo = Model.Info;
export type StableProviderInfo = Provider.Info;
// The shared mappers speak the legacy (`Provider.models[id]`) `Model` shape
// (imported from `@opencode-ai/sdk/v2`, also re-exported by the plugin root
// as `ModelV2`); the real v2 `CatalogDraft` carries `ModelV2Info` instead.
// Convert the fields 1:1 at the draft boundary -- NEVER `as unknown as` the
// whole model.
//
// Binary-compat note: the prod binary (beta-17823) reads a top-level
// `package` field on both Model and Provider structs (`package:a.Package`,
// gated by `isAISDK = startsWith("aisdk:")`), with a model-to-provider
// fallback (`package: u.package ?? s.package`). The pinned SDK types
// (1.18.29) only know the `api` block, so the binary field is published via
// the typed extensions below (spread/Object.assign, never `any`).
export const BINARY_AISDK_PREFIX = "aisdk:";
/**
* Structural mirror of the stable `ctx.provider.transform` editor, used as
* the parameter type where the payload is handed to the host (and in tests
* that fake the editor). Kept as documentation of the contract surface even
* where only `add` is exercised.
*/
export interface StableProviderEditor {
add(input: { info: StableProviderInfo; models: readonly StableModelInfo[] }): void;
get(providerID: string): { provider: StableProviderInfo } | undefined;
list(): readonly { provider: StableProviderInfo }[];
update(providerID: string, update: (provider: StableProviderInfo) => void): void;
remove(providerID: string): void;
readonly models: {
set(providerID: string, models: readonly StableModelInfo[]): void;
update(providerID: string, modelID: string, update: (model: StableModelInfo) => void): void;
remove(providerID: string, modelID: string): void;
};
}
/**
* Project the legacy catalog entry the shared mappers produce onto the
* stable `Model.Info` shape. The mapper layer stays untouched; only this
* boundary knows both shapes. Extra legacy-only keys (`api`, `options`,
* string `release_date`) are dropped, never cast across.
*/
export function legacyToStable(
providerID: string,
modelID: string,
m: LegacyModel,
apiKey: string,
baseURL: string
): StableModelInfo {
if (!m.api || typeof m.api.npm !== "string" || m.api.npm.length === 0) {
throw new Error(
"[omniroute-v2] refusing to publish a model without an api block (missing api.npm)"
);
}
if (!isHttpUrl(m.api.url)) {
throw new Error(
"[omniroute-v2] refusing to publish a model whose api block carries no http(s) url"
);
}
const stablePackage =
m.api.npm === "@ai-sdk/anthropic" ? "@opencode/ai/providers/anthropic" : NPM_OPENAI_COMPAT;
const input: string[] = [];
if (m.capabilities.input.text) input.push("text");
if (m.capabilities.input.audio) input.push("audio");
if (m.capabilities.input.image) input.push("image");
if (m.capabilities.input.video) input.push("video");
if (m.capabilities.input.pdf) input.push("pdf");
const output: string[] = [];
if (m.capabilities.output.text) output.push("text");
if (m.capabilities.output.audio) output.push("audio");
if (m.capabilities.output.image) output.push("image");
if (m.capabilities.output.video) output.push("video");
if (m.capabilities.output.pdf) output.push("pdf");
const variants = Object.entries(m.variants ?? {}).map(([id, body]) => ({
id,
settings: { ...(body as Record<string, unknown>) },
headers: {},
body: { ...(body as Record<string, unknown>) },
}));
const parsed = Date.parse(m.release_date);
const info = {
id: modelID,
modelID,
providerID,
...(m.family !== undefined ? { family: m.family } : {}),
name: m.name,
package: stablePackage,
settings: { baseURL: ensureV1Suffix(baseURL), apiKey },
headers: { ...m.headers },
...(Object.keys(m.options).length > 0 ? { body: { ...m.options } } : {}),
capabilities: { tools: m.capabilities.toolcall, input, output },
variants,
time: { released: Number.isNaN(parsed) ? 0 : parsed },
cost: [
{ input: m.cost.input, output: m.cost.output, cache: { ...m.cost.cache } },
],
status: m.status,
enabled: true,
limit: { ...m.limit },
} as unknown;
return info as StableModelInfo;
}
const NPM_OPENAI_COMPAT = "@opencode/ai/providers/openai-compatible";
/**
* Fail-fast guard for a pre-mapped `api` block: the snapshot filter and the
* stale-entry suite assert on it, and the beta-replay adapter relies on the
* same refusal for entries that bypass the mapper. New mapper output always
* carries a valid block via `resolveApiBlockV2`, so this fires only on stale
* snapshots or hand-built entries.
*/
export function legacyApiToInfoApi(api: LegacyModel["api"]): {
id: string;
type: "aisdk";
/** Top-level `package` as the legacy contract expects it (`aisdk:<npm>`). */
export interface BinaryCompatPackage {
package: string;
url: string;
} {
}
/**
* The legacy contract keeps on the model/provider itself what the `api` block
* carries in the pinned types: the aisdk package, the endpoint (as
* `settings.baseURL`) and the per-request headers. None of these keys collide
* with a key of `ModelV2Info`/`ProviderV2Info`, so both field sets can be
* published on the same object.
*/
export interface BinaryCompatFields extends BinaryCompatPackage {
settings: Record<string, unknown>;
headers: Record<string, string>;
}
/** Legacy variants read their options from `settings`, not `headers`/`body`. */
export type BinaryCompatVariant = ModelV2Info["variants"][number] & {
settings: Record<string, unknown>;
};
export type BinaryCompatModel = ModelV2Info & BinaryCompatFields;
export type BinaryCompatProvider = ProviderV2Info &
BinaryCompatPackage & {
settings: Record<string, unknown>;
};
export function toBinaryPackage(npm: string): string {
return npm.startsWith(BINARY_AISDK_PREFIX) ? npm : `${BINARY_AISDK_PREFIX}${npm}`;
}
export function legacyApiToInfoApi(api: LegacyModelV2["api"]): ModelV2Info["api"] {
if (!api || typeof api.npm !== "string" || api.npm.length === 0) {
throw new Error(
"[omniroute-v2] refusing to publish a model without an api block (missing api.npm)"
);
}
// The host reads `api.url` in `prepareOptions` and never falls back to the
// provider's own, so a model published without one reaches the AI SDK with no
// baseURL and fails at call time with a bare `Invalid URL` — no request on the
// wire, nothing in the gateway logs, no model named.
if (!isHttpUrl(api.url)) {
throw new Error(
"[omniroute-v2] refusing to publish a model whose api block carries no http(s) url"
);
}
return { id: api.id, type: "aisdk", package: api.npm, url: api.url };
}
function legacyCostToInfoCost(cost: LegacyModel["cost"]): StableModelInfo["cost"] {
const c = [{ input: cost.input, output: cost.output, cache: cost.cache }];
return c as unknown as StableModelInfo["cost"];
function legacyCostToInfoCost(cost: LegacyModelV2["cost"]): ModelV2Info["cost"] {
return [{ input: cost.input, output: cost.output, cache: cost.cache }];
}
function legacyCapabilitiesToInfoCapabilities(
caps: LegacyModel["capabilities"]
): StableModelInfo["capabilities"] {
caps: LegacyModelV2["capabilities"]
): ModelV2Info["capabilities"] {
const input: string[] = [];
if (caps.input.text) input.push("text");
if (caps.input.audio) input.push("audio");
@@ -234,21 +167,21 @@ function legacyCapabilitiesToInfoCapabilities(
return { tools: caps.toolcall, input, output };
}
function legacyToInfo(providerID: string, modelID: string, m: LegacyModel): StableModelInfo {
function legacyToInfo(providerID: string, modelID: string, m: LegacyModelV2): ModelV2Info {
const variants = Object.entries(m.variants ?? {}).map(([id, body]) => ({
id,
headers: {},
body: body as Record<string, unknown>,
}));
const parsed = Date.parse(m.release_date);
const out = {
return {
id: modelID,
modelID,
providerID,
...(m.family !== undefined ? { family: m.family } : {}),
name: m.name,
api: legacyApiToInfoApi(m.api),
capabilities: legacyCapabilitiesToInfoCapabilities(m.capabilities),
headers: { ...m.headers },
request: { headers: { ...m.headers }, body: { ...m.options } },
variants,
time: { released: Number.isNaN(parsed) ? 0 : parsed },
cost: legacyCostToInfoCost(m.cost),
@@ -256,7 +189,6 @@ function legacyToInfo(providerID: string, modelID: string, m: LegacyModel): Stab
enabled: true,
limit: { ...m.limit },
};
return out as unknown as StableModelInfo;
}
export interface PublishCounts {
@@ -323,39 +255,74 @@ export function passesComboAllowlist(combo: OmniRouteRawCombo, visible?: ModelLi
}
/**
* Copy the converted legacy fields onto a stable `Model.Info` target.
* Kept for the beta-replay adapter below (`publishCatalog`), which reuses it
* per entry; new code calls `legacyToStable` via `buildProviderPayload`.
* Project the `api` block onto the legacy top-level fields. Only the `aisdk`
* variant of `ModelApi`/`ProviderApi` carries a package, so the caller narrows
* before calling; a `native` api has no legacy equivalent and publishes
* nothing (the legacy contract has no native models).
*/
export function assignModelFields(
target: StableModelInfo,
source: LegacyModel,
apiKey: string,
baseURL: string
): void {
const info = legacyToStable(
(target.providerID as string) || source.providerID,
(target.id as string) || source.id,
source,
apiKey,
baseURL
);
Object.assign(target, info);
function legacyModelFields(info: ModelV2Info): BinaryCompatFields | undefined {
if (info.api.type !== "aisdk") return undefined;
const settings: Record<string, unknown> = {
...(info.api.settings ?? {}),
...info.request.body,
};
if (info.api.url !== undefined) settings.baseURL = info.api.url;
return {
package: toBinaryPackage(info.api.package),
settings,
headers: { ...info.request.headers },
};
}
/**
* Copy the provider identity fields onto a stable `Provider.Info` target.
* Kept for the beta-replay adapter below (`publishCatalog` writes `name` /
* `integrationID` through it before adding stable fields); new code builds
* the provider object inline in `buildProviderPayload`.
*/
export function assignProviderFields(
target: StableProviderInfo,
source: { name: string; integrationID: string },
_contract?: unknown
/** `{id, headers, body}` (pinned types) plus `{settings}` (legacy contract). */
function legacyVariants(variants: ModelV2Info["variants"]): BinaryCompatVariant[] {
return variants.map((variant) => ({ ...variant, settings: { ...variant.body } }));
}
function assignModelFields(
target: ModelV2Info,
source: LegacyModelV2,
contract: HostContract
): void {
(target as { name: string }).name = source.name;
(target as { integrationID: string }).integrationID = source.integrationID;
const info = legacyToInfo(target.providerID || source.providerID, target.id || source.id, source);
target.name = info.name;
target.api = info.api;
target.capabilities = info.capabilities;
target.request = info.request;
target.variants = info.variants;
target.time = info.time;
target.cost = info.cost;
target.status = info.status;
target.enabled = info.enabled;
target.limit = info.limit;
if (info.family !== undefined) {
target.family = info.family;
}
if (!emitsLegacyFields(contract)) return;
const legacy = legacyModelFields(info);
if (legacy !== undefined) {
Object.assign(target, legacy);
target.variants = legacyVariants(info.variants);
}
}
function assignProviderFields(
target: ProviderV2Info,
source: { name: string; api: ProviderV2Info["api"]; integrationID: string },
contract: HostContract
): void {
target.name = source.name;
target.api = source.api;
target.integrationID = source.integrationID;
if (!emitsLegacyFields(contract)) return;
// The legacy contract defaults `Provider.Info.package` to `""` and model
// resolution falls back to it (`package: model.package ?? provider.package`),
// so the provider carries the same `aisdk:<npm>` value as its models, and
// the endpoint as `settings.baseURL`.
if (source.api.type !== "aisdk") return;
const settings: Record<string, unknown> = { ...(source.api.settings ?? {}) };
if (source.api.url !== undefined) settings.baseURL = source.api.url;
Object.assign(target, { package: toBinaryPackage(source.api.package), settings });
}
/** A widened capability flag (`boolean | { field }`) read back as a plain flag. */
@@ -436,14 +403,15 @@ async function resolveUsableAliases(
return rawConnections.length > 0 ? usableProviderAliasSet(rawConnections, enrichment) : undefined;
}
/** Everything the combo collection pass reads, passed as one value. */
/** Everything the combo publishing pass reads, passed as one value. */
interface PublishContext {
draft: CatalogDraft;
opts: ResolvedOptions;
log: Logger;
providerId: string;
hostContract: HostContract;
enrichment: OmniRouteEnrichmentMap;
rawModelById: Map<string, OmniRouteRawModelEntry>;
collected: Map<string, LegacyModel>;
publishedKeys: Set<string>;
publishedModelIds: Map<string, string>;
visibleFilter: ReturnType<typeof compileModelListFilter>;
@@ -469,12 +437,13 @@ interface PublishContext {
*/
async function publishCombos(ctx: PublishContext): Promise<number | undefined> {
const {
draft,
opts,
log,
providerId: X,
hostContract,
enrichment,
rawModelById,
collected,
publishedKeys,
publishedModelIds,
visibleFilter,
@@ -514,7 +483,7 @@ async function publishCombos(ctx: PublishContext): Promise<number | undefined> {
if (hiddenFilter && passesComboAllowlist(combo, hiddenFilter)) return false;
return true;
});
const resolvedByName = new Map<string, LegacyModel>();
const resolvedByName = new Map<string, LegacyModelV2>();
let unresolved: typeof pending = [];
for (let pass = 0; pass < MAX_COMBO_PASSES && pending.length > 0; pass++) {
@@ -574,7 +543,9 @@ async function publishCombos(ctx: PublishContext): Promise<number | undefined> {
}
}
}
collected.set(key, mapped);
draft.model.update(X, mid, (m) => {
assignModelFields(m, mapped, hostContract);
});
publishedKeys.add(key);
publishedModelIds.set(key, mapped.id);
comboCount += 1;
@@ -607,7 +578,7 @@ async function publishCombos(ctx: PublishContext): Promise<number | undefined> {
* output, modalities, capabilities) instead of only direct raw members.
* v1 parity (combo member synthesis at nested resolution time).
*/
function synthesizeNestedMember(name: string, nested: LegacyModel): OmniRouteRawModelEntry {
function synthesizeNestedMember(name: string, nested: LegacyModelV2): OmniRouteRawModelEntry {
const inputModalities: string[] = [];
if (nested.capabilities.input.text) inputModalities.push("text");
if (nested.capabilities.input.audio) inputModalities.push("audio");
@@ -642,22 +613,11 @@ function synthesizeNestedMember(name: string, nested: LegacyModel): OmniRouteRaw
};
}
/**
* Collect the full catalog (models + combos + auto-combos) as legacy entries
* keyed `providerId/bareId`, then project them onto the stable contract in
* `buildProviderPayload`. Collect-then-project keeps every fetch/filter/LCD
* behavior identical to the beta path while the only host touchpoint is the
* single `editor.add` in the payload builder.
*/
export interface CollectedCatalog {
entries: Map<string, LegacyModel>;
counts: PublishCounts;
}
export async function collectCatalog(
export async function publishCatalog(
draft: CatalogDraft,
opts: ResolvedOptions,
fetchers?: CatalogFetchers
): Promise<CollectedCatalog> {
): Promise<PublishCounts> {
const X = opts.providerId;
const log = opts.logger ?? createLogger(opts.startupDebug ? "debug" : (opts.logLevel ?? "warn"));
const modelsTimeout = opts.timeouts?.models ?? opts.timeoutMs;
@@ -666,16 +626,35 @@ export async function collectCatalog(
// set (P2 resolves it in index.ts; direct publishCatalog callers may only
// pass timeoutMs).
const autoCombosTimeout = opts.timeouts?.autoCombos ?? 5_000;
// The contract is discovered from the object the host seeds into the
// provider draft, which the host fills before any model is published. The
// verdict is then reused for every model: the model seed carries no
// discriminating key, and a single provider/model pair always speaks one
// contract.
let hostContract: HostContract = "unknown";
draft.provider.update(X, (p) => {
hostContract = detectHostContract(p);
assignProviderFields(
p,
{
name: opts.displayName ?? "OmniRoute",
api: {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: ensureV1Suffix(opts.baseURL),
},
integrationID: X,
},
hostContract
);
});
log.debug(`[omniroute-v2] host catalog contract detected: ${hostContract}`);
const modelsFetcher = fetchers?.fetcher ?? fetchers?.models;
const combosFetcher = fetchers?.combosFetcher ?? fetchers?.combos;
const autoCombosFetcher = fetchers?.autoCombosFetcher ?? fetchers?.autoCombos;
const providersFetcher = fetchers?.providersFetcher ?? fetchers?.providers;
const empty: CollectedCatalog = {
entries: new Map(),
counts: { models: 0, combos: 0, autoCombos: 0 },
};
let rawModels: OmniRouteRawModelEntry[];
try {
rawModels = modelsFetcher ? await modelsFetcher(opts.baseURL, opts.apiKey, modelsTimeout) : [];
@@ -683,7 +662,7 @@ export async function collectCatalog(
log.warn(
`[omniroute-v2] models fetch failed, publishing empty catalog: ${err instanceof Error ? err.message : String(err)}`
);
return empty;
return { models: 0, combos: 0, autoCombos: 0 };
}
const visibleFilter = compileModelListFilter(opts.visibleModels);
@@ -712,7 +691,6 @@ export async function collectCatalog(
// v1's `models[comboKey]` lookup so the intentional-dedup check sees the
// overwritten entry's id, not just key presence.
const publishedModelIds = new Map<string, string>();
const collected = new Map<string, LegacyModel>();
let modelCount = 0;
for (const entry of rawModels) {
if (!entry.id) continue;
@@ -728,22 +706,24 @@ export async function collectCatalog(
providerTag: opts.providerTag !== false,
});
const mid = mapped.id.startsWith(X + "/") ? mapped.id.slice(X.length + 1) : mapped.id;
const key = X + "/" + mid;
collected.set(key, mapped);
publishedKeys.add(key);
publishedModelIds.set(key, mapped.id);
draft.model.update(X, mid, (m) => {
assignModelFields(m, mapped, hostContract);
});
publishedKeys.add(X + "/" + mid);
publishedModelIds.set(X + "/" + mid, mapped.id);
modelCount += 1;
}
const warnedCombos = opts.collisionWarned ?? new Set<string>();
const cacheKey = `${opts.baseURL}::${opts.providerId}`;
const comboCount = await publishCombos({
draft,
opts,
log,
providerId: X,
hostContract,
enrichment,
rawModelById,
collected,
publishedKeys,
publishedModelIds,
visibleFilter,
@@ -755,8 +735,7 @@ export async function collectCatalog(
warnedCombos,
cacheKey,
});
if (comboCount === undefined)
return { entries: collected, counts: { models: modelCount, combos: 0, autoCombos: 0 } };
if (comboCount === undefined) return { models: modelCount, combos: 0, autoCombos: 0 };
// Migration: v1 published opencode-X; v2 publishes X bare. Sessions pinned
// opencode-X resolve ModelUnavailableError -- see RELEASE.md migration note.
@@ -780,7 +759,7 @@ export async function collectCatalog(
log.warn(
`[omniroute-v2] auto combos fetch failed, falling back to models+combos catalog: ${err instanceof Error ? err.message : String(err)}`
);
return { entries: collected, counts: { models: modelCount, combos: comboCount, autoCombos: 0 } };
return { models: modelCount, combos: comboCount, autoCombos: 0 };
}
let autoComboCount = 0;
@@ -807,93 +786,13 @@ export async function collectCatalog(
);
}
}
collected.set(key, mapped);
draft.model.update(X, mapped.id, (m) => {
assignModelFields(m, mapped, hostContract);
});
publishedKeys.add(key);
publishedModelIds.set(key, mapped.id);
autoComboCount += 1;
}
return { entries: collected, counts: { models: modelCount, combos: comboCount, autoCombos: autoComboCount } };
}
/**
* Project a collected catalog onto the stable contract: one provider `info`
* plus one `Model.Info` per entry. The provider carries the endpoint and the
* inference key (`settings.baseURL` + `settings.apiKey`, verified live
* against 2.0.12) so inference authenticates; each model repeats them because
* the host merges model settings over provider settings at request time.
*/
export function buildProviderPayload(
collected: CollectedCatalog,
opts: ResolvedOptions
): { info: StableProviderInfo; models: StableModelInfo[] } {
const X = opts.providerId;
const info = {
id: X,
name: opts.displayName ?? "OmniRoute",
activation: "enabled",
package: NPM_OPENAI_COMPAT,
settings: { baseURL: ensureV1Suffix(opts.baseURL), apiKey: opts.apiKey },
integrationID: X,
} as unknown as StableProviderInfo;
const models: StableModelInfo[] = [];
for (const [key, legacy] of collected.entries) {
const slash = key.indexOf("/");
const bareId = slash > 0 ? key.slice(slash + 1) : legacy.id;
models.push(legacyToStable(X, bareId, legacy, opts.apiKey, opts.baseURL));
}
return { info, models };
}
/**
* Beta-draft publish path: replays a collected catalog into a beta
* `CatalogDraft`-shaped editor. The 19 legacy suite files drive it with
* injected fetchers and read back `api`/`request` aliases plus counts, so
* removing it means rewriting those files to `collectCatalog` +
* `buildProviderPayload` (done for host-contract/api-package/smoke; the rest
* keep the adapter). New product code uses `collectCatalog` +
* `buildProviderPayload` directly; `src/index.ts` never calls this.
*/
export async function publishCatalog(
draft: {
provider: { update: (id: string, fn: (p: Record<string, unknown>) => void) => void };
model: {
update: (pid: string, mid: string, fn: (m: Record<string, unknown>) => void) => void;
};
},
opts: ResolvedOptions,
fetchers?: CatalogFetchers
): Promise<PublishCounts> {
const collected = await collectCatalog(opts, fetchers);
const payload = buildProviderPayload(collected, opts);
const X = opts.providerId;
draft.provider.update(X, (p) => {
const info = payload.info as unknown as Record<string, unknown>;
for (const [k, v] of Object.entries(info)) p[k] = v;
// Beta-shaped aliases the legacy suite reads: `api` block plus
// `request` (headers/body). The stable payload carries the same data as
// top-level `package`/`settings`/`headers`/`body`.
const settings = (info.settings ?? {}) as Record<string, unknown>;
const npm = String(info.package ?? "").replace("@opencode/ai/providers/", "@ai-sdk/");
p["api"] = { type: "aisdk", package: npm, url: settings["baseURL"] };
p["request"] = { headers: (info.headers ?? {}) as Record<string, string>, body: (info.body ?? {}) as Record<string, unknown> };
});
for (const m of collected.entries.keys()) {
const slash = m.indexOf("/");
const mid = slash > 0 ? m.slice(slash + 1) : m;
const stable = payload.models.find(
(s) => (s.id as string) === mid || `${X}/${s.id as string}` === m
);
if (!stable) continue;
draft.model.update(X, mid, (target) => {
for (const [k, v] of Object.entries(stable as unknown as Record<string, unknown>))
target[k] = v;
// Beta-shaped aliases, same projection as the provider above.
const s = stable as unknown as Record<string, any>;
const npm = String(s.package ?? "").replace("@opencode/ai/providers/", "@ai-sdk/");
target["api"] = { type: "aisdk", package: npm, url: s.settings?.baseURL };
target["request"] = { headers: s.headers ?? {}, body: s.body ?? {} };
});
}
return collected.counts;
return { models: modelCount, combos: comboCount, autoCombos: autoComboCount };
}

View File

@@ -1,24 +1,3 @@
/**
* The stable contract has no `ctx.catalog`: providers publish through
* `ctx.provider.transform` (`editor.add` with `info` + `models`) and narrow
* through `ctx.model.transform`. This guard therefore requires the provider
* and model transforms plus an options object, and nothing else.
*/
export function assertContext(ctx: unknown): void {
if (!isObject(ctx)) {
throw new Error("[omniroute-v2] contract breach: ctx must be an object");
}
if (!isTransformHolder(ctx.provider) || typeof ctx.provider.transform !== "function") {
throw new Error("[omniroute-v2] contract breach: ctx.provider.transform must be a function");
}
if (!isTransformHolder(ctx.model) || typeof ctx.model.transform !== "function") {
throw new Error("[omniroute-v2] contract breach: ctx.model.transform must be a function");
}
if (!isObject(ctx.options)) {
throw new Error("[omniroute-v2] contract breach: ctx.options must be an object");
}
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
@@ -26,3 +5,62 @@ function isObject(value: unknown): value is Record<string, unknown> {
function isTransformHolder(value: unknown): value is { transform: unknown } {
return isObject(value) && "transform" in value;
}
/**
* The catalog domain is the one this plugin cannot work without. The
* integration domain carries the credential flow and the `aisdk` domain the
* tool-schema cleaning: a host missing either still gets its catalog, so
* neither is asserted here — each is probed where it is used.
*/
export function assertContext(ctx: unknown): void {
if (!isObject(ctx)) {
throw new Error("[omniroute-v2] contract breach: ctx must be an object");
}
if (!isTransformHolder(ctx.catalog) || typeof ctx.catalog.transform !== "function") {
throw new Error("[omniroute-v2] contract breach: ctx.catalog.transform must be a function");
}
if (!isObject(ctx.options)) {
throw new Error("[omniroute-v2] contract breach: ctx.options must be an object");
}
}
/**
* Catalog contract spoken by the running host.
*
* opencode v2 is a moving target: the catalog contract changed between the
* binary that ships today and the SDK types this package pins. Rather than
* keying off a version list (which goes stale on the next release), the
* contract is discovered at runtime from the object the host seeds into the
* draft.
*
* - `legacy-package` — the seed carries a top-level `package` and no `api`
* block. Observed on `@opencode-ai/cli` 0.0.0-beta-17823, whose
* `Provider.Info.empty` is `{id, name, activation, package}`.
* - `sdk-api` — the seed carries an `api` block. This is the contract of the
* pinned `@opencode-ai/plugin`/`@opencode-ai/sdk` types.
* - `unknown` — neither or both. The caller publishes the superset.
*/
export type HostContract = "legacy-package" | "sdk-api" | "unknown";
export function detectHostContract(seed: unknown): HostContract {
if (!isObject(seed)) return "unknown";
const hasApi = "api" in seed;
const hasPackage = "package" in seed;
if (hasApi && !hasPackage) return "sdk-api";
if (hasPackage && !hasApi) return "legacy-package";
return "unknown";
}
/**
* Whether to publish the legacy top-level fields (`package`, `settings`,
* `headers`, `variants[].settings`) next to the `api`-block fields.
*
* A host proven to speak the legacy contract gets them because it needs them;
* an unrecognised host gets them because the superset is the safer default
* (both field sets have been observed to survive an unknown-key write). A host
* that speaks the `api` contract does not, so a future strict schema cannot
* reject the write on an excess property.
*/
export function emitsLegacyFields(contract: HostContract): boolean {
return contract !== "sdk-api";
}

View File

@@ -1,17 +1,6 @@
import type { PluginContext } from "@opencode-ai/plugin/v2/promise";
import type { Logger } from "./shared/index.js";
/** Minimal stable context surface this module reads (provider transforms stay untyped here). */
export interface StableCredentialContext {
integration: {
connection?: {
active?: (integrationID: string) => Promise<unknown>;
resolve?: (connection: unknown) => Promise<unknown>;
};
};
}
type StableContext = StableCredentialContext;
/** Where a resolved key came from, so the failure message can name the fix. */
export type ApiKeyOrigin = "connection" | "option" | "env" | "missing";
@@ -23,16 +12,12 @@ export interface ResolvedApiKey {
const ENV_VAR = "OMNIROUTE_API_KEY";
/**
* `ctx.integration.connection` carries the stored credential. Probing the
* shape keeps the plugin loadable on a host that exposes `integration`
* without it.
* `ctx.integration.connection` is newer than the `key`/`env` methods this
* plugin registers, so a host that predates it exposes `integration` without
* it. Probing the shape keeps the plugin loadable on both.
*/
function connectionApi(
ctx: StableContext
): { active: (id: string) => Promise<unknown>; resolve: (c: unknown) => Promise<unknown> } | undefined {
const connection = (ctx.integration as unknown as Record<string, unknown>).connection as
| { active?: unknown; resolve?: unknown }
| undefined;
function connectionApi(ctx: PluginContext): PluginContext["integration"]["connection"] | undefined {
const connection = (ctx.integration as Partial<PluginContext["integration"]>).connection;
if (
connection === undefined ||
typeof connection.active !== "function" ||
@@ -40,10 +25,7 @@ function connectionApi(
) {
return undefined;
}
return connection as {
active: (id: string) => Promise<unknown>;
resolve: (c: unknown) => Promise<unknown>;
};
return connection;
}
/**
@@ -54,33 +36,30 @@ function connectionApi(
* feed inference and the catalog fetches would still need a key pasted into
* the config file.
*
* Returns `undefined` (never throws) when there is no connection or when the
* stored credential is an OAuth grant — this plugin authenticates the gateway
* with a bearer key, and an access token from an unrelated grant is not one.
* Returns `undefined` (never throws) when there is no connection, when the
* host is too old to expose one, or when the stored credential is an OAuth
* grant — this plugin authenticates the gateway with a bearer key, and an
* access token from an unrelated grant is not one.
*/
async function keyFromConnection(
ctx: unknown,
ctx: PluginContext,
integrationID: string,
log: Logger
): Promise<string | undefined> {
const connection = connectionApi(ctx as StableCredentialContext);
const connection = connectionApi(ctx);
if (connection === undefined) return undefined;
try {
const active = await connection.active(integrationID);
if (active === undefined) return undefined;
const credential = (await connection.resolve(active)) as
| { type?: unknown; key?: unknown }
| undefined;
const credential = await connection.resolve(active);
if (credential === undefined) return undefined;
if (credential.type !== "key") {
log.warn(
`[omniroute-v2] ignoring the stored ${String(credential.type)} credential: this plugin authenticates with an API key`
`[omniroute-v2] ignoring the stored ${credential.type} credential: this plugin authenticates with an API key`
);
return undefined;
}
return typeof credential.key === "string" && credential.key.length > 0
? credential.key
: undefined;
return credential.key.length > 0 ? credential.key : undefined;
} catch (err) {
log.warn(
`[omniroute-v2] could not read the stored credential: ${err instanceof Error ? err.message : String(err)}`
@@ -95,7 +74,7 @@ async function keyFromConnection(
* so an explicit per-project override keeps working.
*/
export async function resolveApiKey(
ctx: unknown,
ctx: PluginContext,
integrationID: string,
optionKey: string | undefined,
log: Logger

View File

@@ -1,4 +1,4 @@
import { Plugin } from "@opencode/plugin";
import { define, type PluginContext } from "@opencode-ai/plugin/v2/promise";
import {
optionalTierFingerprint,
catalogContentFingerprint,
@@ -17,7 +17,7 @@ import type {
OmniRouteRawModelEntry,
} from "./shared/index.js";
import type { ResolvedOptions } from "./catalog.js";
import { buildProviderPayload, collectCatalog } from "./catalog.js";
import { publishCatalog } from "./catalog.js";
import {
DEFAULT_MODEL_CACHE_TTL_MS,
UNREACHABLE_COOLDOWN_MS,
@@ -31,14 +31,7 @@ import { assertContext } from "./compat.js";
import { type ApiKeyOrigin, resolveApiKey, warnIfMissing } from "./credentials.js";
import { createSourceErrorReporter } from "./enrichment-report.js";
import { sanitizeToolSchemasFor } from "./gemini-language.js";
import {
MANAGEMENT_TOKEN_ENV_VAR,
PLUGIN_ID,
parsePluginOptions,
resolveManagementReadToken,
resolveTimeouts,
type PluginOptions,
} from "./options.js";
import { PLUGIN_ID, parsePluginOptions, resolveTimeouts, type PluginOptions } from "./options.js";
/**
* A fetch result that says whether it succeeded. Returning a bare `[]` on
@@ -68,7 +61,7 @@ function toResolvedOptions(parsed: PluginOptions): ResolvedOptions {
providerId: parsed.providerId,
baseURL: parsed.baseURL,
apiKey: parsed.apiKey ?? process.env.OMNIROUTE_API_KEY ?? "",
managementReadToken: resolveManagementReadToken(parsed.managementReadToken),
managementReadToken: parsed.managementReadToken,
timeoutMs: parsed.timeoutMs,
timeouts: parsed.timeouts,
logLevel: parsed.logLevel,
@@ -87,9 +80,9 @@ function toResolvedOptions(parsed: PluginOptions): ResolvedOptions {
};
}
export default Plugin.define({
export default define({
id: PLUGIN_ID,
setup: async (ctx) => {
setup: async (ctx: PluginContext) => {
assertContext(ctx);
const parsed = parsePluginOptions(ctx.options);
const X = parsed.providerId;
@@ -100,16 +93,6 @@ export default Plugin.define({
resolved.logLevel = parsed.logLevel;
resolved.startupDebug = parsed.startupDebug;
log.info(`[omniroute-v2] init providerId=${X}`);
// The inference key stands in below when no management token is set, and
// gateways usually reject that stand-in with 401/403. Say so once here,
// before any fetch, instead of letting the refusal surface per endpoint.
if (resolved.managementReadToken === undefined) {
log.warn(
`[omniroute-v2] no management token configured: management endpoints (/api/*) will reuse the inference key, ` +
`which gateways usually reject with 401/403. Set "managementReadToken" in the plugin options ` +
`or export ${MANAGEMENT_TOKEN_ENV_VAR}.`
);
}
// v1 parity port: in-memory TTL + disk snapshot. The memory key
// `baseURL::sha256(creds)` isolates credential tuples (prod vs
@@ -314,7 +297,7 @@ export default Plugin.define({
};
if (models.length > 0) {
state.entries.set(cacheKey, snapshot);
await writeDiskSnapshot(X, snapshot, identityFingerprint, log);
await writeDiskSnapshot(X, snapshot, identityFingerprint);
}
void optional.then(
(parts) => upgradeWithOptional(snapshot, parts),
@@ -361,7 +344,7 @@ export default Plugin.define({
if (unchanged) return;
state.entries.set(cacheKey, upgraded);
if (upgraded.models.length > 0) {
await writeDiskSnapshot(X, upgraded, identityFingerprint, log);
await writeDiskSnapshot(X, upgraded, identityFingerprint);
}
// Reload only when the optional tier actually moved: the catalog
// fingerprint covers ids alone, so without this the host would rebuild
@@ -374,12 +357,12 @@ export default Plugin.define({
);
const optionalChanged = state.optionalFingerprint !== optionalFingerprint;
state.optionalFingerprint = optionalFingerprint;
if (optionalChanged) {
if (optionalChanged && typeof ctx.catalog.reload === "function") {
try {
await ctx.provider.reload();
await ctx.catalog.reload();
} catch (err) {
log.warn(
`[omniroute-v2] provider reload after late sources failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}`
`[omniroute-v2] catalog reload after late sources failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}`
);
}
}
@@ -430,16 +413,8 @@ export default Plugin.define({
// the memory entry on failure, so `entries` stays the last-known-good
// source — including cross-setup via the disk snapshot.
// Fail-open one level down, in the wrappers (never reject) and the
// `collectCatalog` catches — so no try/catch here.
//
// The stable host replays the registered transform to rebuild its
// registry, so the callback only reads the latest collected snapshot;
// the refresh below keeps that snapshot current and reloads the host.
// The transform callback is synchronous, so it cannot await the fetch:
// setup publishes first, then the host replays the callback (during
// registration and on every reload) and reads the published snapshot.
let latest: { info: unknown; models: unknown[] } | undefined;
const refreshAndPublish = async (): Promise<void> => {
// `publishCatalog` catches — so no try/catch here.
const catalogRegistration = ctx.catalog.transform(async (draft) => {
await ensureCredential();
await ensureWarmSnapshot();
const snapshot = await loadSnapshot();
@@ -458,9 +433,9 @@ export default Plugin.define({
combos: number;
autoCombos: number;
}> => {
// fetcher-level fail-open covers fetches; this guard covers mapper throws.
// fetcher-level fail-open covers fetches; this guard covers mapper/draft throws.
try {
const collected = await collectCatalog(resolved, {
return await publishCatalog(draft, resolved, {
onSourceError: reportSourceError,
models: async () => effective.models,
combos: async () => effective.combos,
@@ -468,9 +443,6 @@ export default Plugin.define({
providers: async () => effective.providers ?? [],
enrichment: async () => effective.enrichment ?? new Map(),
});
const payload = buildProviderPayload(collected, resolved);
latest = payload as unknown as { info: unknown; models: unknown[] };
return collected.counts;
} catch (err) {
log.warn(
`[omniroute-v2] catalog publish failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}`
@@ -486,34 +458,18 @@ export default Plugin.define({
);
const changed = state.fingerprint !== undefined && state.fingerprint !== fingerprint;
state.fingerprint = fingerprint;
if (changed) {
if (changed && typeof ctx.catalog.reload === "function") {
await Promise.resolve();
try {
await ctx.provider.reload();
await ctx.catalog.reload();
} catch (err) {
log.warn(
`[omniroute-v2] provider reload failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}`
);
}
}
};
// Publish before returning so the first transform replay already has
// data; without a key this degrades to an empty provider, not a crash.
// A host throw in `editor.add` must not reject setup: the catalog is
// the job, and a failed publish keeps the previous one.
await refreshAndPublish();
const providerRegistration = ctx.provider.transform((editor) => {
if (latest !== undefined) {
try {
editor.add(latest as never);
} catch (err) {
log.warn(
`[omniroute-v2] catalog publish failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}`
`[omniroute-v2] catalog reload failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}`
);
}
}
});
const integrationHook = (ctx.integration as unknown as { transform?: unknown } | undefined)
const integrationHook = (ctx.integration as Partial<PluginContext["integration"]> | undefined)
?.transform;
// A host that exposes the hook but throws while registering it must cost
// the plugin nothing but the connect action: the throw happens OUTSIDE
@@ -522,14 +478,7 @@ export default Plugin.define({
let integrationRegistration: unknown;
if (typeof integrationHook === "function") {
try {
integrationRegistration = (
integrationHook as (
cb: (draft: {
update: (id: string, fn: (i: { name: string }) => void) => void;
method: { update: (input: unknown) => void };
}) => void
) => unknown
)((draft) => {
integrationRegistration = integrationHook((draft) => {
draft.update(X, (integration) => {
integration.name = parsed.displayName ?? "OmniRoute";
});
@@ -547,28 +496,18 @@ export default Plugin.define({
}
}
/**
* `aisdk.hook("language")` cleans Gemini tool schemas where the model is
* still structured data. A host without the domain stays loadable,
* minus the sanitising.
* `aisdk.language` is newer than the catalog domain, so a host may not
* expose it; the plugin must stay loadable there, minus the sanitising.
*/
const languageHook = (ctx.aisdk as unknown as { hook?: unknown } | undefined)?.hook;
const languageHook = (ctx.aisdk as Partial<PluginContext["aisdk"]> | undefined)?.language;
// A host that rejects this registration must cost the catalog nothing: the
// plugin is a catalog first, and tool-schema cleaning is an extra.
let languageRegistration: Promise<{ dispose: () => Promise<void> }> | undefined;
if (parsed.geminiSanitization !== false && typeof languageHook === "function") {
try {
languageRegistration = (
languageHook as (
name: string,
cb: (input: { model: { providerID: string; id: string }; language?: unknown }) => void
) => Promise<{ dispose: () => Promise<void> }>
)("language", (input) => {
languageRegistration = languageHook((input) => {
if (input.model.providerID !== X) return;
input.language = sanitizeToolSchemasFor(
input.language as never,
input.model.id,
log
) as unknown as undefined;
input.language = sanitizeToolSchemasFor(input.language, input.model.id, log);
});
} catch (err) {
log.warn(
@@ -577,41 +516,7 @@ export default Plugin.define({
}
}
/**
* `aisdk.hook("sdk")` carries inference-telemetry options. It is the same
* entry point the `"language"` hook above goes through, so a host that
* exposes no `aisdk` domain — or refuses this particular name — must still
* load the catalog. Strict fallback (no proven options-only marking):
* register the hook and record the observation in `options` only — never
* wrap fetch, never assign `sdk`. Gated on the opt-in `telemetry` flag
* (off by default).
*/
const sdkHook = (ctx.aisdk as unknown as { hook?: unknown } | undefined)?.hook;
let sdkRegistration: Promise<{ dispose: () => Promise<void> }> | undefined;
if (parsed.telemetry === true && typeof sdkHook === "function") {
try {
sdkRegistration = (
sdkHook as (
name: string,
cb: (input: {
model: { providerID: string; id: string };
package: string;
options: Record<string, unknown>;
}) => void
) => Promise<{ dispose: () => Promise<void> }>
)("sdk", (input) => {
if (input.model.providerID !== X) return;
if (!input.package.includes("@ai-sdk/openai-compatible")) return;
input.options.telemetry = true;
});
} catch (err) {
log.warn(
`[omniroute-v2] host refused the sdk hook, inference telemetry will not be marked: ${err instanceof Error ? err.message : String(err)}`
);
}
}
await providerRegistration;
await catalogRegistration;
if (integrationRegistration !== undefined) {
try {
await integrationRegistration;
@@ -630,14 +535,5 @@ export default Plugin.define({
);
}
}
if (sdkRegistration !== undefined) {
try {
await sdkRegistration;
} catch (err) {
log.warn(
`[omniroute-v2] sdk hook registration failed, inference telemetry will not be marked: ${err instanceof Error ? err.message : String(err)}`
);
}
}
},
});

View File

@@ -1,81 +0,0 @@
/**
* Vendored legacy catalog shape (beta `Model`), kept dependency-free.
*
* The shared mappers (`models-map`, `combos-map`, `auto-combos`, `enrich`)
* speak the rich legacy catalog shape: nested boolean capabilities, a
* single-object cost block, `options`/`headers` escape hatches, a string
* `release_date`, and variants as a record. It was previously imported from
* `@opencode-ai/sdk/v2`; vendoring it removes the beta SDK dependency while
* keeping the mapper layer untouched. The stable-contract boundary lives in
* `catalog.ts` (`legacyToStable`), which projects this shape onto the
* `Model.Info`/`Provider.Info` types from `@opencode/plugin`.
*/
export interface LegacyModelCapabilities {
temperature: boolean;
reasoning: boolean;
attachment: boolean;
toolcall: boolean;
input: {
text: boolean;
audio: boolean;
image: boolean;
video: boolean;
pdf: boolean;
};
output: {
text: boolean;
audio: boolean;
image: boolean;
video: boolean;
pdf: boolean;
};
interleaved:
| boolean
| {
field: "reasoning" | "reasoning_content" | "reasoning_text" | string;
};
}
export interface LegacyModelCost {
input: number;
output: number;
cache: {
read: number;
write: number;
};
}
export interface LegacyModel {
id: string;
providerID: string;
api: {
id: string;
url: string;
npm: string;
};
name: string;
family?: string;
capabilities: LegacyModelCapabilities;
cost: LegacyModelCost;
limit: {
context: number;
input?: number;
output: number;
};
status: "alpha" | "beta" | "deprecated" | "active";
options: {
[key: string]: unknown;
};
headers: {
[key: string]: string;
};
release_date: string;
variants?: {
[key: string]: {
[key: string]: unknown;
};
};
}
/** Namespace alias so existing `Model as X` imports keep working. */
export type Model = LegacyModel;

View File

@@ -1,7 +1,5 @@
import { z } from "zod";
import { isHttpUrl } from "./shared/models-map.js";
const apiFormatSchema = z
.object({
allowAnthropic: z.boolean().optional(),
@@ -30,10 +28,7 @@ const pluginOptionsSchema = z
.regex(/^[A-Za-z0-9._-]+$/, "providerId may only contain letters, digits, '.', '_' and '-'")
.refine((v) => v !== "." && v !== "..", "providerId cannot be a path segment")
.default("omniroute"),
baseURL: z
.string()
.trim()
.refine(isHttpUrl, "baseURL must be an http(s) URL, for example http://localhost:20128"),
baseURL: z.string().url(),
apiKey: z.string().optional(),
displayName: z.string().optional(),
managementReadToken: z.string().optional(),
@@ -55,30 +50,12 @@ const pluginOptionsSchema = z
// routes to, so the same model sold through two connections is
// distinguishable in the picker.
providerTag: z.boolean().default(true),
// Inference telemetry is off by default: the host must opt in before the
// plugin touches the sdk domain at all.
telemetry: z.boolean().default(false),
apiFormat: apiFormatSchema.optional(),
})
.strict();
export type PluginOptions = z.infer<typeof pluginOptionsSchema>;
/** Environment source for the management token (option wins over this). */
export const MANAGEMENT_TOKEN_ENV_VAR = "OMNIROUTE_MANAGEMENT_API_KEY";
/**
* Resolve the management token: a non-empty option wins, then a non-empty
* environment value, else absent. Empty counts as absent on both inputs, the
* same rule the inference key follows; no trimming, the token is opaque.
*/
export function resolveManagementReadToken(optionValue: string | undefined): string | undefined {
if (optionValue !== undefined && optionValue.length > 0) return optionValue;
const fromEnv = process.env[MANAGEMENT_TOKEN_ENV_VAR];
if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv;
return undefined;
}
/** Per-endpoint timeout defaults (v1 parity). `timeoutMs` is the global fallback. */
export const DEFAULT_TIMEOUT_MS = 10_000 as const;
/** Auto-combos keep the v1 5s budget; the field is resolved now for the P3 port. */

View File

@@ -1,4 +1,4 @@
import type { LegacyModel as ModelV2 } from "../legacy-model.js";
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
import type { ApiFormatV2 } from "./models-map.js";
import { resolveApiBlockV2 } from "./models-map.js";
import { autoComboModelId, formatAutoComboName, type AutoVariant } from "./naming.js";

View File

@@ -1,4 +1,4 @@
import type { LegacyModel } from "../legacy-model.js";
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
import { type ApiFormatV2, type OmniRouteRawModelEntry, resolveApiBlockV2 } from "./models-map.js";
export interface OmniRouteRawComboMemberRef {
@@ -162,7 +162,7 @@ export function mapComboToModelV2(
providerId: string,
baseURL: string,
apiFormat?: ApiFormatV2
): LegacyModel {
): ModelV2 {
// `every` over an empty array returns true (would lie about an empty
// combo's capabilities) — short-circuit to all-false when no members.
const hasMembers = members.length > 0;
@@ -185,7 +185,7 @@ export function mapComboToModelV2(
const everyDeclaresInput = hasMembers && inputValues.length === members.length;
const capabilities: LegacyModel["capabilities"] = {
const capabilities: ModelV2["capabilities"] = {
temperature:
hasMembers && members.every((m) => (m.capabilities?.temperature ?? true) !== false),
reasoning:

View File

@@ -1,4 +1,4 @@
import type { LegacyModel as ModelV2 } from "../legacy-model.js";
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
import { buildModelDisplayName } from "./naming.js";
import type { FreeModelFreeType } from "./naming.js";

View File

@@ -1,4 +1,4 @@
import type { LegacyModel as ModelV2 } from "../legacy-model.js";
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
import { normaliseFreeLabel } from "./naming.js";
export interface OmniRouteRawModelEntry {
@@ -111,22 +111,6 @@ function trimTrailingSlashes(value: string): string {
* (it appends `/v1/messages` automatically), so callers should branch on
* format first.
*/
/**
* A url the AI SDK can actually call. `new URL()` alone is not enough: it
* parses `localhost:20128` as the scheme `localhost:` and `ftp://host` as ftp,
* both of which reach `fetch` and fail there. Mirrors the `isHttpUrl` guard the
* settings schema applies to `headroomUrl`.
*/
export function isHttpUrl(value: unknown): boolean {
if (typeof value !== "string") return false;
try {
const { protocol } = new URL(value);
return protocol === "http:" || protocol === "https:";
} catch {
return false;
}
}
export function ensureV1Suffix(url: string): string {
const trimmed = trimTrailingSlashes(url);
return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`;

View File

@@ -1,32 +1,20 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
import { publishCatalog } from "../src/catalog.js";
type BetaDraft = {
provider: {
list?: () => unknown[];
get?: (id: string) => unknown;
update: (id: string, fn: (p: Record<string, any>) => void) => void;
remove?: () => void;
};
model: {
get?: (...a: string[]) => unknown;
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
remove?: () => void;
default?: { get: () => undefined; set: () => void };
};
};
const SUPPORTED_PACKAGES = new Set(["@ai-sdk/openai-compatible", "@ai-sdk/anthropic"]);
function fakeDraft(): { models: Map<string, Record<string, any>>; draft: BetaDraft } {
const providers = new Map<string, Record<string, any>>();
const models = new Map<string, Record<string, any>>();
function fakeDraft(): { models: Map<string, ModelV2Info>; draft: CatalogDraft } {
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
const draft = {
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: Record<string, any>) => void) => {
const p = (providers.get(id) ?? { id }) as Record<string, any>;
update: (id: string, fn: (p: ProviderV2Info) => void) => {
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
fn(p);
providers.set(id, p);
},
@@ -34,16 +22,16 @@ function fakeDraft(): { models: Map<string, Record<string, any>>; draft: BetaDra
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
fn(m);
models.set(k, m);
},
remove: () => {},
default: { get: () => undefined, set: () => {} },
},
};
} as CatalogDraft;
return { models, draft };
}
@@ -56,7 +44,7 @@ const baseOpts = {
usableOnly: false,
};
function apiPackageOf(m: Record<string, any> | undefined): string {
function apiPackageOf(m: ModelV2Info | undefined): string {
assert.ok(m, "model must be published");
assert.equal(m?.api.type, "aisdk");
if (m?.api.type !== "aisdk") throw new Error("model api must be aisdk");

View File

@@ -1,29 +1,17 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
import { publishCatalog } from "../src/catalog.js";
type BetaDraft = {
provider: {
list?: () => unknown[];
get?: (id: string) => unknown;
update: (id: string, fn: (p: Record<string, any>) => void) => void;
remove?: () => void;
};
model: {
get?: (...a: string[]) => unknown;
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
remove?: () => void;
default?: { get: () => undefined; set: () => void };
};
};
function fakeDraft(): {
models: Map<string, Record<string, any>>;
draft: BetaDraft;
models: Map<string, ModelV2Info>;
draft: CatalogDraft;
warns: string[];
restore: () => void;
} {
const providers = new Map<string, Record<string, any>>();
const models = new Map<string, Record<string, any>>();
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
const warns: string[] = [];
const origWarn = console.warn;
console.warn = (...args: unknown[]) => {
@@ -33,8 +21,8 @@ function fakeDraft(): {
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: Record<string, any>) => void) => {
const p = (providers.get(id) ?? { id }) as Record<string, any>;
update: (id: string, fn: (p: ProviderV2Info) => void) => {
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
fn(p);
providers.set(id, p);
},
@@ -42,16 +30,16 @@ function fakeDraft(): {
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
fn(m);
models.set(k, m);
},
remove: () => {},
default: { get: () => undefined, set: () => {} },
},
};
} as CatalogDraft;
return {
models,
draft,

View File

@@ -44,28 +44,20 @@ function stubFetch(
async function setupPlugin(opts: CtxOpts): Promise<{
callbacks: Array<(draft: unknown) => Promise<void>>;
reloads: { count: number };
added: unknown[];
}> {
const callbacks: Array<(draft: unknown) => Promise<void>> = [];
const reloads = { count: 0 };
const added: unknown[] = [];
const ctx = {
options: { ...opts },
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
callbacks.push(async () => {
cb({ add: (input: unknown) => added.push(input) });
});
cb({ add: (input: unknown) => added.push(input) });
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
callbacks.push(cb);
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {
reloads.count += 1;
},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
};
const logs: string[] = [];
@@ -84,15 +76,7 @@ async function setupPlugin(opts: CtxOpts): Promise<{
console.log = origLog;
console.warn = origWarn;
}
return { callbacks, reloads, added };
}
function publishedOf(added: unknown[]): Map<string, Record<string, unknown>> {
const published = new Map<string, Record<string, unknown>>();
for (const entry of added as Array<{ info: { id: string }; models: Array<Record<string, unknown>> }>) {
for (const m of entry.models) published.set(entry.info.id + "/" + String(m.id), m);
}
return published;
return { callbacks, reloads };
}
function stubDraft(): { draft: unknown; published: Map<string, Record<string, unknown>> } {
@@ -136,16 +120,21 @@ describe("plugin-v2 P1 parity: TTL 300s + disk snapshot", () => {
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch(counter, ["m1"]);
try {
const { added } = await setupPlugin({
const { callbacks } = await setupPlugin({
providerId: "ttl-hit",
baseURL: "https://gw.example.com",
apiKey: "k-ttl",
});
const published = publishedOf(added);
const { draft, published } = stubDraft();
await callbacks[0](draft);
assert.equal(counter.models, 1);
assert.equal(counter.combos, 1);
assert.equal(counter.autoCombos, 1);
assert.ok(published.has("ttl-hit/m1"));
await callbacks[0](draft);
assert.equal(counter.models, 1);
assert.equal(counter.combos, 1);
assert.equal(counter.autoCombos, 1);
} finally {
globalThis.fetch = origFetch;
disk.restore();
@@ -161,19 +150,21 @@ describe("plugin-v2 P1 parity: TTL 300s + disk snapshot", () => {
let now = 1_000_000;
Date.now = () => now;
try {
// TTL expiry is exercised through collectCatalog-level caching in
// setup: a first setup fetches, a second setup with a fresh disk
// replays the snapshot path. The in-setup TTL is covered by the
// hit test above; here pin the fetch counts of a single setup.
const { added: _addedE } = await setupPlugin({
const { callbacks } = await setupPlugin({
providerId: "ttl-expire",
baseURL: "https://gw.example.com",
apiKey: "k-expire",
modelCacheTtlMs: 1000,
});
void _addedE;
const { draft } = stubDraft();
await callbacks[0](draft);
assert.equal(counter.models, 1);
now += 1500;
now += 500;
await callbacks[0](draft);
assert.equal(counter.models, 1);
now += 1000;
await callbacks[0](draft);
assert.equal(counter.models, 2);
} finally {
Date.now = origNow;
globalThis.fetch = origFetch;
@@ -218,13 +209,16 @@ describe("plugin-v2 P1 parity: TTL 300s + disk snapshot", () => {
console.log = () => {};
console.warn = () => {};
try {
const pending = setupPlugin({
const { callbacks } = await setupPlugin({
providerId: "singleflight",
baseURL: "https://gw.example.com",
apiKey: "k-sf",
});
const { draft } = stubDraft();
const a = callbacks[0](draft);
const b = callbacks[0](draft);
release();
await pending;
await Promise.all([a, b]);
assert.equal(counter.models, 1);
} finally {
console.log = origLog;
@@ -244,12 +238,13 @@ describe("plugin-v2 P1 parity: TTL 300s + disk snapshot", () => {
console.log = () => {};
console.warn = () => {};
try {
const { added } = await setupPlugin({
const { callbacks } = await setupPlugin({
providerId: "warm",
baseURL: "https://gw.example.com",
apiKey: "k-warm",
});
assert.ok(publishedOf(added).has("warm/mw"));
const { draft } = stubDraft();
await callbacks[0](draft);
assert.ok(statSync(diskSnapshotPath("warm")).isFile());
} finally {
console.log = origLog;
@@ -285,12 +280,13 @@ describe("plugin-v2 P1 parity: TTL 300s + disk snapshot", () => {
};
console.log = () => {};
try {
const { added } = await setupPlugin({
const { callbacks } = await setupPlugin({
providerId: "warm",
baseURL: "https://gw.example.com",
apiKey: "k-warm",
});
const published = publishedOf(added);
const { draft, published } = stubDraft();
await callbacks[0](draft);
assert.ok(
published.has("warm/mw"),
`warm snapshot must publish mw, got: ${JSON.stringify([...published.keys()])}`
@@ -321,7 +317,8 @@ describe("plugin-v2 P1 parity: TTL 300s + disk snapshot", () => {
apiKey: "k-inval",
modelCacheTtlMs: 1,
});
void first;
const { draft } = stubDraft();
await first.callbacks[0](draft);
assert.equal(counter.models, 1);
// Fresh setup = empty memory (setup closure): the stale disk warm entry
// expires + the refetch starts, no reuse of the previous cache.
@@ -333,7 +330,7 @@ describe("plugin-v2 P1 parity: TTL 300s + disk snapshot", () => {
apiKey: "k-inval",
modelCacheTtlMs: 1,
});
void second;
await second.callbacks[0](draft);
assert.equal(counter.models, 2);
} finally {
console.log = origLog;

View File

@@ -1,32 +1,20 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
import { publishCatalog } from "../src/catalog.js";
type BetaDraft = {
provider: {
list?: () => unknown[];
get?: (id: string) => unknown;
update: (id: string, fn: (p: Record<string, any>) => void) => void;
remove?: () => void;
};
model: {
get?: (...a: string[]) => unknown;
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
remove?: () => void;
default?: { get: () => undefined; set: () => void };
};
};
interface FakeDraft {
providers: Map<string, Record<string, any>>;
models: Map<string, Record<string, any>>;
providers: Map<string, ProviderV2Info>;
models: Map<string, ModelV2Info>;
warns: string[];
provider: BetaDraft["provider"];
model: BetaDraft["model"];
provider: CatalogDraft["provider"];
model: CatalogDraft["model"];
}
function fakeDraft(): FakeDraft {
const providers = new Map<string, Record<string, any>>();
const models = new Map<string, Record<string, any>>();
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
return {
providers,
models,
@@ -34,8 +22,8 @@ function fakeDraft(): FakeDraft {
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: Record<string, any>) => void) => {
const p = (providers.get(id) ?? { id }) as Record<string, any>;
update: (id: string, fn: (p: ProviderV2Info) => void) => {
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
fn(p);
providers.set(id, p);
},
@@ -43,9 +31,9 @@ function fakeDraft(): FakeDraft {
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
fn(m);
models.set(k, m);
},

View File

@@ -5,8 +5,7 @@ import { assertContext } from "../src/compat.js";
function validContext() {
return {
options: { baseURL: "https://gw.example.com" },
provider: { transform: async () => {}, reload: async () => {} },
model: { transform: async () => {}, reload: async () => {} },
catalog: { transform: async () => {} },
integration: { transform: async () => {} },
};
}
@@ -15,19 +14,15 @@ describe("assertContext", () => {
it("throws on non-object ctx", () => {
assert.throws(() => assertContext(null), /\[omniroute-v2\] contract breach/);
});
it("throws when provider.transform is missing", () => {
const ctx = { ...validContext(), provider: {} };
assert.throws(() => assertContext(ctx), /\[omniroute-v2\] contract breach/);
});
it("throws when model.transform is missing", () => {
const ctx = { ...validContext(), model: {} };
it("throws when catalog.transform is missing", () => {
const ctx = { ...validContext(), catalog: {} };
assert.throws(() => assertContext(ctx), /\[omniroute-v2\] contract breach/);
});
it("serves a catalog on a host that has no integration domain", () => {
// The integration domain carries the credential flow, not the catalog.
// Refusing to load without it would deny the whole plugin to a host that
// simply does not implement that surface yet.
assert.doesNotThrow(() => assertContext({ provider: { transform: () => {} }, model: { transform: () => {} }, options: {} }));
assert.doesNotThrow(() => assertContext({ catalog: { transform: () => {} }, options: {} }));
});
it("throws when options is not an object", () => {
const ctx = { ...validContext(), options: undefined };

View File

@@ -1,6 +1,6 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
type PluginContext = { options?: unknown; provider?: unknown; model?: unknown; integration?: unknown; aisdk?: unknown };
import type { PluginContext } from "@opencode-ai/plugin/v2/promise";
import type { Logger } from "../src/shared/index.js";
import { resolveApiKey, warnIfMissing } from "../src/credentials.js";

View File

@@ -1,251 +0,0 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import {
diskSnapshotPath,
readDiskSnapshot,
writeDiskSnapshot,
type CatalogSnapshot,
} from "../src/cache.js";
function isolateDisk(): { dir: string; restore: () => void } {
const dir = mkdtempSync(join(tmpdir(), "omniroute-disk-atomic-"));
const prev = process.env.OPENCODE_DATA_DIR;
process.env.OPENCODE_DATA_DIR = dir;
return {
dir,
restore: () => {
if (prev === undefined) delete process.env.OPENCODE_DATA_DIR;
else process.env.OPENCODE_DATA_DIR = prev;
},
};
}
function makeSnapshot(models: string[] = ["m-a"]): CatalogSnapshot {
return {
models: models.map((id) => ({ id })),
combos: [],
autoCombos: [],
providers: [],
fetchedAt: Date.now(),
} as unknown as CatalogSnapshot;
}
function makeLogger() {
const messages: string[] = [];
return {
messages,
logger: { warn: (message: string) => void messages.push(message) },
};
}
// Entries next to the destination other than the destination itself: any
// leftover temp file after a successful write shows up here.
function strayEntries(file: string): string[] {
let entries: string[];
try {
entries = readdirSync(dirname(file));
} catch {
return [];
}
return entries.filter((entry) => entry !== file.split("/").pop());
}
// The writer names its temp file `${file}.${pid}.${counter}` with a
// module-monotone counter starting at 0, built after the empty-models and
// size-cap guards (an over-cap call consumes no counter value). Tests in this
// file run sequentially in one process, so the attempt table below predicts
// every temp path exactly:
// over-cap: no counter use | failed write A: 0, failed write B: 1 |
// interrupted overwrite A: 2, interrupted overwrite B: 3 | mkdir failure: 4 |
// truncated read: 5 | success: 6 | permissions: 7 | round-trip: 8, 9.
function predictedTmp(file: string, counter: number): string {
return `${file}.${process.pid}.${counter}`;
}
describe("disk snapshot atomic write, strict version, traced give-ups", () => {
it("ignores a newer snapshot version without throwing", async () => {
const disk = isolateDisk();
try {
const file = diskSnapshotPath("t1-future");
mkdirSync(dirname(file), { recursive: true });
// A writer from the future persists version 3; this reader must
// treat it as "no snapshot" instead of trusting unknown data.
writeFileSync(
file,
JSON.stringify({
v: 3,
identityFingerprint: "fp-1",
models: [{ id: "m-future" }],
combos: [],
writtenAt: Date.now(),
})
);
const back = await readDiskSnapshot("t1-future", "fp-1");
assert.equal(back, undefined);
} finally {
disk.restore();
rmSync(disk.dir, { recursive: true, force: true });
}
});
it("traces an over-cap write and leaves no destination behind", async () => {
const disk = isolateDisk();
try {
const { messages, logger } = makeLogger();
const bigId = `huge-${"x".repeat(33 * 1024 * 1024)}`;
await writeDiskSnapshot("t2-cap", makeSnapshot([bigId]), "fp-1", logger);
const file = diskSnapshotPath("t2-cap");
assert.equal(existsSync(file), false);
assert.deepEqual(strayEntries(file), []);
assert.match(messages.join("\n"), /exceeds|too large|size cap/i);
} finally {
disk.restore();
rmSync(disk.dir, { recursive: true, force: true });
}
});
it("a failed write leaves no destination behind and is traced", async () => {
const disk = isolateDisk();
const file = diskSnapshotPath("t3a-fail");
const blocker = predictedTmp(file, 1);
try {
const { messages, logger } = makeLogger();
await writeDiskSnapshot("t3a-fail", makeSnapshot(["m-before"]), "fp-1", logger);
// Plant a directory at the next temp path: the write fails with
// EISDIR before any rename, deterministically, on every platform.
mkdirSync(dirname(file), { recursive: true });
mkdirSync(blocker, { recursive: true });
await writeDiskSnapshot("t3a-fail", makeSnapshot(["m-after"]), "fp-1", logger);
assert.equal(existsSync(file), true);
const back = await readDiskSnapshot("t3a-fail", "fp-1");
assert.deepEqual(
(back?.models ?? []).map((entry) => entry.id),
["m-before"]
);
assert.match(messages.join("\n"), /failed|EISDIR|error/i);
} finally {
rmSync(blocker, { recursive: true, force: true });
disk.restore();
rmSync(disk.dir, { recursive: true, force: true });
}
});
it("an interrupted overwrite keeps the previous snapshot", async () => {
const disk = isolateDisk();
const file = diskSnapshotPath("t3b-keep");
const blocker = predictedTmp(file, 3);
try {
const { messages, logger } = makeLogger();
await writeDiskSnapshot("t3b-keep", makeSnapshot(["m-before"]), "fp-1", logger);
const before = readFileSync(file, "utf8");
mkdirSync(blocker, { recursive: true });
await writeDiskSnapshot("t3b-keep", makeSnapshot(["m-after"]), "fp-1", logger);
assert.equal(readFileSync(file, "utf8"), before);
const back = await readDiskSnapshot("t3b-keep", "fp-1");
assert.deepEqual(
(back?.models ?? []).map((entry) => entry.id),
["m-before"]
);
assert.match(messages.join("\n"), /failed|EISDIR|error/i);
} finally {
rmSync(blocker, { recursive: true, force: true });
disk.restore();
rmSync(disk.dir, { recursive: true, force: true });
}
});
it("a mkdir failure is traced and writes nothing", async () => {
const disk = isolateDisk();
try {
const { messages, logger } = makeLogger();
// A file planted at the plugins path makes mkdir fail
// deterministically (EEXIST on mkdir, ENOTDIR on direct writeFile).
writeFileSync(join(disk.dir, "plugins"), "blocker");
await writeDiskSnapshot("t3b-bis", makeSnapshot(["m-a"]), "fp-1", logger);
assert.equal(existsSync(diskSnapshotPath("t3b-bis")), false);
assert.match(messages.join("\n"), /failed|EEXIST|ENOTDIR|error/i);
} finally {
disk.restore();
rmSync(disk.dir, { recursive: true, force: true });
}
});
it("a truncated file reads as no snapshot without throwing", async () => {
const disk = isolateDisk();
try {
const { logger } = makeLogger();
await writeDiskSnapshot("t4-truncated", makeSnapshot(["m-a"]), "fp-1", logger);
const file = diskSnapshotPath("t4-truncated");
const full = readFileSync(file, "utf8");
writeFileSync(file, full.slice(0, Math.floor(full.length / 2)));
const back = await readDiskSnapshot("t4-truncated", "fp-1");
assert.equal(back, undefined);
} finally {
disk.restore();
rmSync(disk.dir, { recursive: true, force: true });
}
});
it("a successful write leaves no entry but the destination", async () => {
const disk = isolateDisk();
try {
await writeDiskSnapshot("t5-clean", makeSnapshot(["m-a"]), "fp-1");
const file = diskSnapshotPath("t5-clean");
assert.deepEqual(strayEntries(file), []);
} finally {
disk.restore();
rmSync(disk.dir, { recursive: true, force: true });
}
});
it("the replaced snapshot stays owner-only", async (t) => {
if (process.platform === "win32") {
t.skip("file mode semantics are POSIX-only");
return;
}
const disk = isolateDisk();
try {
await writeDiskSnapshot("t6-mode", makeSnapshot(["m-a"]), "fp-1");
const file = diskSnapshotPath("t6-mode");
assert.equal((statSync(file).mode & 0o077) === 0, true);
} finally {
disk.restore();
rmSync(disk.dir, { recursive: true, force: true });
}
});
it("round-trips a valid snapshot with and without a logger", async () => {
const disk = isolateDisk();
try {
const { logger } = makeLogger();
const snapshot = makeSnapshot(["m-a"]);
await writeDiskSnapshot("t7-roundtrip", snapshot, "fp-1");
const plain = await readDiskSnapshot("t7-roundtrip", "fp-1");
assert.deepEqual(
(plain?.models ?? []).map((entry) => entry.id),
["m-a"]
);
await writeDiskSnapshot("t7-roundtrip", snapshot, "fp-1", logger);
const logged = await readDiskSnapshot("t7-roundtrip", "fp-1", logger);
assert.deepEqual(
(logged?.models ?? []).map((entry) => entry.id),
["m-a"]
);
} finally {
disk.restore();
rmSync(disk.dir, { recursive: true, force: true });
}
});
});

View File

@@ -1,7 +1,7 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { applyEnrichment } from "../src/shared/enrich.js";
import type { LegacyModel as ModelV2 } from "../src/legacy-model.js";
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
function model(id: string, name = id): ModelV2 {
return {

View File

@@ -1,31 +1,19 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
import type { OmniRouteEnrichmentMap } from "../src/shared/index.js";
import { publishCatalog } from "../src/catalog.js";
type BetaDraft = {
provider: {
list?: () => unknown[];
get?: (id: string) => unknown;
update: (id: string, fn: (p: Record<string, any>) => void) => void;
remove?: () => void;
};
model: {
get?: (...a: string[]) => unknown;
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
remove?: () => void;
default?: { get: () => undefined; set: () => void };
};
};
function fakeDraft(): { models: Map<string, Record<string, any>>; draft: BetaDraft } {
const providers = new Map<string, Record<string, any>>();
const models = new Map<string, Record<string, any>>();
function fakeDraft(): { models: Map<string, ModelV2Info>; draft: CatalogDraft } {
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
const draft = {
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: Record<string, any>) => void) => {
const p = (providers.get(id) ?? { id }) as Record<string, any>;
update: (id: string, fn: (p: ProviderV2Info) => void) => {
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
fn(p);
providers.set(id, p);
},
@@ -33,16 +21,16 @@ function fakeDraft(): { models: Map<string, Record<string, any>>; draft: BetaDra
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
fn(m);
models.set(k, m);
},
remove: () => {},
default: { get: () => undefined, set: () => {} },
},
};
} as CatalogDraft;
return { models, draft };
}

View File

@@ -148,17 +148,16 @@ describe("Gemini sanitising is wired into the host, and only where it belongs",
options["geminiSanitization"] = opts.geminiSanitization;
const ctx: Record<string, unknown> = {
options,
provider: { transform: () => registration, reload: async () => {} },
model: { transform: () => registration },
catalog: { transform: () => registration, reload: async () => {} },
integration: { transform: () => registration },
};
if (opts.withAisdk !== false) {
ctx["aisdk"] = {
hook: (name: string, cb: (input: LanguageInput) => void | Promise<void>) => {
assert.equal(name, "language");
language: (cb: (input: LanguageInput) => void | Promise<void>) => {
languageCallbacks.push(cb);
return registration;
},
sdk: () => registration,
};
}
return { ctx, languageCallbacks };
@@ -212,13 +211,13 @@ describe("Gemini sanitising is wired into the host, and only where it belongs",
const registration = Promise.resolve({ dispose: async () => {} });
const ctx: Record<string, unknown> = {
options: { baseURL: "https://gw.example.com", providerId: "omni", apiKey: "k" },
provider: { transform: () => registration, reload: async () => {} },
model: { transform: () => registration },
catalog: { transform: () => registration, reload: async () => {} },
integration: { transform: () => registration },
aisdk: {
hook: () => {
language: () => {
throw new Error("host says no");
},
sdk: () => registration,
},
};
// Must not reject: tool-schema cleaning is an extra, the catalog is the job.

View File

@@ -1,6 +1,64 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { buildProviderPayload, collectCatalog } from "../src/catalog.js";
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
import {
publishCatalog,
type BinaryCompatModel,
type BinaryCompatProvider,
type BinaryCompatVariant,
} from "../src/catalog.js";
import { detectHostContract, emitsLegacyFields } from "../src/compat.js";
/**
* A host seed shape. `legacy` mirrors `Provider.Info.empty` as observed on
* `@opencode-ai/cli` 0.0.0-beta-17823; `sdk` mirrors the pinned SDK contract;
* `bare` is a host that discloses neither.
*/
type SeedKind = "legacy" | "sdk" | "bare";
function providerSeed(id: string, kind: SeedKind): ProviderV2Info {
if (kind === "legacy") {
return { id, name: id, activation: "auto", package: "" } as unknown as ProviderV2Info;
}
if (kind === "sdk") {
return { id, name: id, api: { type: "aisdk", package: "", url: "" } } as ProviderV2Info;
}
return { id } as ProviderV2Info;
}
function fakeDraft(kind: SeedKind): {
draft: CatalogDraft;
providers: Map<string, ProviderV2Info>;
models: Map<string, ModelV2Info>;
} {
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
const draft = {
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: ProviderV2Info) => void) => {
const p = providers.get(id) ?? providerSeed(id, kind);
fn(p);
providers.set(id, p);
},
remove: () => {},
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
fn(m);
models.set(k, m);
},
remove: () => {},
default: { get: () => undefined, set: () => {} },
},
} as CatalogDraft;
return { draft, providers, models };
}
const baseOpts = {
providerId: "omniroute",
@@ -16,49 +74,91 @@ const rawModel = {
capabilities: { effort_tiers: ["low", "high"] },
};
async function publish() {
const collected = await collectCatalog(baseOpts, {
async function publish(kind: SeedKind) {
const { draft, providers, models } = fakeDraft(kind);
await publishCatalog(draft, baseOpts, {
fetcher: async () => [rawModel],
combosFetcher: async () => [],
});
const payload = buildProviderPayload(collected, baseOpts);
assert.equal(collected.counts.models, 1);
const model = payload.models[0] as unknown as Record<string, any>;
const provider = providers.get("omniroute");
const model = models.get("omniroute/af/chat-latest");
assert.ok(provider, "provider must be published");
assert.ok(model, "model must be published");
return { info: payload.info as unknown as Record<string, any>, model };
return { provider: provider as BinaryCompatProvider, model: model as BinaryCompatModel };
}
describe("stable payload", () => {
it("publishes one provider info with endpoint plus key", async () => {
const { info } = await publish();
assert.equal(info.id, "omniroute");
assert.equal(info.package, "@opencode/ai/providers/openai-compatible");
assert.equal((info.settings as Record<string, unknown>).baseURL, "https://gw.example.com/v1");
assert.equal((info.settings as Record<string, unknown>).apiKey, "k");
describe("host contract detection", () => {
it("reads the contract off the seeded object, not off a version", () => {
assert.equal(detectHostContract({ id: "x", package: "" }), "legacy-package");
assert.equal(detectHostContract({ id: "x", api: { type: "aisdk" } }), "sdk-api");
assert.equal(detectHostContract({ id: "x" }), "unknown");
assert.equal(detectHostContract({ id: "x", api: {}, package: "" }), "unknown");
assert.equal(detectHostContract(undefined), "unknown");
assert.equal(detectHostContract("nope"), "unknown");
});
it("publishes each model with package, endpoint, variants", async () => {
const { model } = await publish();
assert.equal(model.package, "@opencode/ai/providers/openai-compatible");
assert.equal(
(model.settings as Record<string, unknown>).baseURL,
"https://gw.example.com/v1"
);
assert.ok(model.headers !== undefined);
const variants = model.variants as Array<{
id: string;
settings: unknown;
body: unknown;
headers: unknown;
}>;
it("publishes the legacy fields for every contract but the sdk one", () => {
assert.equal(emitsLegacyFields("legacy-package"), true);
assert.equal(emitsLegacyFields("unknown"), true);
assert.equal(emitsLegacyFields("sdk-api"), false);
});
});
describe("legacy-package host (cli 0.0.0-beta-17823)", () => {
it("publishes package and settings.baseURL on the provider", async () => {
const { provider } = await publish("legacy");
assert.equal(provider.api.type, "aisdk");
assert.equal(provider.package, "aisdk:@ai-sdk/openai-compatible");
assert.equal(provider.settings.baseURL, "https://gw.example.com/v1");
});
it("publishes package, settings.baseURL and headers on the model", async () => {
const { model } = await publish("legacy");
if (model.api.type !== "aisdk") throw new Error("model api must be aisdk");
assert.equal(model.package, `aisdk:${model.api.package}`);
assert.equal(model.package, "aisdk:@ai-sdk/openai-compatible");
assert.equal(model.settings.baseURL, model.api.url);
assert.deepEqual(model.headers, model.request.headers);
});
it("publishes each variant in both shapes", async () => {
const { model } = await publish("legacy");
const variants = model.variants as BinaryCompatVariant[];
assert.deepEqual(
variants.map((v) => v.id),
["low", "high"]
);
for (const variant of variants) {
assert.deepEqual(variant.settings, { reasoningEffort: variant.id });
// The pinned-contract shape stays intact next to the legacy one.
assert.deepEqual(variant.body, { reasoningEffort: variant.id });
assert.deepEqual(variant.headers, {});
}
});
});
describe("sdk-api host", () => {
it("publishes the api block only, with no legacy field", async () => {
const { provider, model } = await publish("sdk");
assert.equal(provider.api.type, "aisdk");
assert.equal("package" in provider, false);
assert.equal("settings" in provider, false);
assert.equal("package" in model, false);
assert.equal("settings" in model, false);
assert.equal("headers" in model, false);
for (const variant of model.variants) {
assert.equal("settings" in variant, false);
assert.deepEqual(variant.body, { reasoningEffort: variant.id });
}
});
});
describe("undisclosed host contract", () => {
it("falls back to the superset so an unknown host still routes", async () => {
const { provider, model } = await publish("bare");
assert.equal(provider.package, "aisdk:@ai-sdk/openai-compatible");
assert.equal(model.package, "aisdk:@ai-sdk/openai-compatible");
assert.ok(model.settings.baseURL);
assert.ok(model.api);
});
});

View File

@@ -3,44 +3,13 @@ import assert from "node:assert/strict";
import plugin from "../src/index.js";
interface CapturedCall {
kind: "provider" | "integration";
}
/**
* Wait until `read()` stops changing, then return the settled value.
*
* The plugin's optional tier lands asynchronously after a publish. Waiting for
* it with a fixed `sleep(5)` raced the work: under load the tier arrived after
* the sleep, so the *next* assertion counted its reload and read 2 where it
* expected 1. Polling until the value holds steady for a few consecutive turns
* ties the wait to the work instead of to the clock.
*/
async function settle<T>(read: () => T, quietTurns = 3, timeoutMs = 5000): Promise<T> {
const { setTimeout: sleep } = await import("node:timers/promises");
const deadline = Date.now() + timeoutMs;
let last = read();
let stable = 0;
while (stable < quietTurns && Date.now() < deadline) {
await sleep(5);
const current = read();
if (current === last) {
stable += 1;
} else {
last = current;
stable = 0;
}
}
return last;
kind: "catalog" | "integration";
}
interface FakeCtx {
options: Record<string, unknown>;
provider: {
transform: (cb: (editor: unknown) => unknown) => Promise<{ dispose: () => Promise<void> }>;
reload: () => Promise<void>;
};
model: {
transform: (cb: (editor: unknown) => unknown) => Promise<{ dispose: () => Promise<void> }>;
catalog: {
transform: (cb: (draft: unknown) => unknown) => Promise<{ dispose: () => Promise<void> }>;
};
integration: {
transform: (cb: (draft: unknown) => unknown) => Promise<{ dispose: () => Promise<void> }>;
@@ -50,16 +19,9 @@ interface FakeCtx {
function fakeCtx(options: Record<string, unknown>, seen: CapturedCall[]): FakeCtx {
return {
options,
provider: {
transform: (cb: (editor: unknown) => unknown) => {
seen.push({ kind: "provider" });
assert.equal(typeof cb, "function");
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {},
},
model: {
transform: (cb: (editor: unknown) => unknown) => {
catalog: {
transform: (cb: (draft: unknown) => unknown) => {
seen.push({ kind: "catalog" });
assert.equal(typeof cb, "function");
return Promise.resolve({ dispose: async () => {} });
},
@@ -95,7 +57,7 @@ describe("plugin-v2 entrypoint", () => {
);
assert.deepEqual(
seen.map((s) => s.kind),
["provider", "integration"]
["catalog", "integration"]
);
const seen2: CapturedCall[] = [];
const warns2: string[] = [];
@@ -118,66 +80,29 @@ describe("plugin-v2 entrypoint", () => {
);
});
it("setup publishes the provider payload through editor.add", async () => {
const origFetch = globalThis.fetch;
globalThis.fetch = (async (url: unknown) => {
const href = String(url);
if (href.includes("/v1/models")) {
return {
ok: true,
status: 200,
statusText: "OK",
json: async () => ({ data: [{ id: "m1" }] }),
};
}
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
}) as typeof fetch;
const added: Array<{ info: Record<string, unknown>; models: unknown[] }> = [];
const ctx = {
options: { baseURL: "https://gw.example.com", providerId: "payload-add", apiKey: "k" },
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
cb({
add: (input: unknown) => {
added.push(input as { info: Record<string, unknown>; models: unknown[] });
},
});
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
};
try {
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
} finally {
globalThis.fetch = origFetch;
}
assert.equal(added.length, 1);
assert.equal(added[0]?.info.id, "payload-add");
it("registers transforms synchronously: captures exist without awaiting fetch", async () => {
const seen: CapturedCall[] = [];
const ctx = fakeCtx({ baseURL: "https://gw.example.com" }, seen);
const pending = (plugin as unknown as { setup: (ctx: FakeCtx) => Promise<void> }).setup(ctx);
assert.deepEqual(
seen.map((s) => s.kind),
["catalog", "integration"]
);
await pending;
});
it("declares key plus env methods and no oauth in the integration transform", async () => {
const seen: CapturedCall[] = [];
const integrationCallbacks: Array<(draft: unknown) => unknown> = [];
const providerCallbacks: Array<(editor: unknown) => unknown> = [];
const catalogCallbacks: Array<(draft: unknown) => unknown> = [];
const ctx: FakeCtx = {
options: { baseURL: "https://gw.example.com", providerId: "omniroute" },
provider: {
transform: (cb: (editor: unknown) => unknown) => {
seen.push({ kind: "provider" });
providerCallbacks.push(cb);
catalog: {
transform: (cb: (draft: unknown) => unknown) => {
seen.push({ kind: "catalog" });
catalogCallbacks.push(cb);
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: {
transform: (cb: (draft: unknown) => unknown) => {
@@ -238,6 +163,7 @@ describe("plugin-v2 entrypoint", () => {
const { mkdtempSync } = await import("node:fs");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const { setTimeout: sleep } = await import("node:timers/promises");
const dir = mkdtempSync(join(tmpdir(), "omniroute-lazy-"));
const prevDataDir = process.env.OPENCODE_DATA_DIR;
process.env.OPENCODE_DATA_DIR = dir;
@@ -253,6 +179,7 @@ describe("plugin-v2 entrypoint", () => {
return { ok: true, status: 200, statusText: "OK", json: async () => ({ data: ids }) };
}) as typeof fetch;
try {
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
let reloads = 0;
const ctx = {
options: {
@@ -261,18 +188,15 @@ describe("plugin-v2 entrypoint", () => {
apiKey: "k-lazy",
modelCacheTtlMs: 1,
},
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
cb({ add: () => {} });
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
catalogCallbacks.push(cb);
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {
reloads += 1;
},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
@@ -287,13 +211,30 @@ describe("plugin-v2 entrypoint", () => {
} finally {
console.log = origLog;
}
assert.equal(catalogCallbacks.length, 1);
const cb = catalogCallbacks[0] as (draft: unknown) => Promise<void>;
const draft = {
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
model: {
update: (_pid: string, _mid: string, fn: (m: Record<string, unknown>) => void) => fn({}),
},
};
await cb(draft);
assert.equal(reloads, 0, "the first publish sets the baseline, it does not reload");
assert.equal(modelsCall, 1);
await sleep(5);
// The optional tier lands after that first publish and brings combos and
// the overlay with it — one reload, so the picker shows them without
// waiting for the next refresh.
const afterFirstUpgrade = await settle(() => reloads);
const afterFirstUpgrade = reloads;
assert.ok(afterFirstUpgrade <= 1, `at most one reload for the first upgrade, got ${reloads}`);
await cb(draft);
assert.equal(reloads, afterFirstUpgrade + 1, "a new model id reloads once");
assert.equal(modelsCall, 2);
await sleep(5);
await cb(draft);
assert.equal(reloads, afterFirstUpgrade + 1, "an identical run never reloads");
assert.equal(modelsCall, 3);
if (prevDataDir === undefined) delete process.env.OPENCODE_DATA_DIR;
else process.env.OPENCODE_DATA_DIR = prevDataDir;
} finally {

View File

@@ -1,388 +0,0 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import plugin from "../src/index.js";
import { publishCatalog } from "../src/catalog.js";
type BetaDraft = {
provider: {
list?: () => unknown[];
get?: (id: string) => unknown;
update: (id: string, fn: (p: Record<string, any>) => void) => void;
remove?: () => void;
};
model: {
get?: (...a: string[]) => unknown;
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
remove?: () => void;
default?: { get: () => undefined; set: () => void };
};
};
const MODELS_URL = "https://gw.example.com/v1/models";
const COMBOS_URL = "https://gw.example.com/api/combos";
const PRICING_MODELS_URL = "https://gw.example.com/api/pricing/models";
const MGMT_ENV_VAR = "OMNIROUTE_MANAGEMENT_API_KEY";
const INFERENCE_ENV_VAR = "OMNIROUTE_API_KEY";
function okJson(body: unknown) {
return { ok: true, status: 200, statusText: "OK", json: async () => body };
}
interface Harness {
seen: Map<string, string>;
warns: string[];
restore: () => void;
}
function installHarness(combos: unknown[]): Harness {
const seen = new Map<string, string>();
const warns: string[] = [];
const origFetch = globalThis.fetch;
const origWarn = console.warn;
const origLog = console.log;
const origError = console.error;
console.warn = (...args: unknown[]) => {
warns.push(String(args[0]));
};
console.log = () => {};
console.error = (...args: unknown[]) => {
warns.push(String(args[0]));
};
globalThis.fetch = (async (url: unknown, init?: { headers?: Record<string, string> }) => {
const href = String(url);
seen.set(href, String(init?.headers?.Authorization ?? ""));
if (href.includes("/api/combos/auto")) return okJson({ combos: [] });
if (href.includes("/api/pricing/models")) {
return okJson({
providers: {
demo: {
id: "demo",
name: "Demo",
models: [{ id: "team-combo", name: "Team Combo" }],
},
},
});
}
if (href.includes("/api/pricing")) return okJson({});
if (href.includes("/api/free-tier/summary")) return okJson({ perModel: [] });
if (href.includes("/api/combos")) return okJson({ combos });
return okJson({ data: [{ id: "m1" }] });
}) as typeof fetch;
return {
seen,
warns,
restore() {
globalThis.fetch = origFetch;
console.warn = origWarn;
console.log = origLog;
console.error = origError;
},
};
}
async function withIsolatedEnv<T>(
mgmt: string | undefined,
inference: string | undefined,
fn: () => Promise<T>
): Promise<T> {
const prevMgmt = process.env[MGMT_ENV_VAR];
const prevInference = process.env[INFERENCE_ENV_VAR];
// Like tests/management-token.test.ts:176-180: a fresh OPENCODE_DATA_DIR
// per case keeps the real disk snapshot out of the run, so a filtered 'it'
// never gets a warm snapshot served without fetch.
const prevDataDir = process.env.OPENCODE_DATA_DIR;
process.env.OPENCODE_DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-mgmt-env-"));
if (mgmt === undefined) delete process.env[MGMT_ENV_VAR];
else process.env[MGMT_ENV_VAR] = mgmt;
if (inference === undefined) delete process.env[INFERENCE_ENV_VAR];
else process.env[INFERENCE_ENV_VAR] = inference;
try {
return await fn();
} finally {
if (prevDataDir === undefined) delete process.env.OPENCODE_DATA_DIR;
else process.env.OPENCODE_DATA_DIR = prevDataDir;
if (prevMgmt === undefined) delete process.env[MGMT_ENV_VAR];
else process.env[MGMT_ENV_VAR] = prevMgmt;
if (prevInference === undefined) delete process.env[INFERENCE_ENV_VAR];
else process.env[INFERENCE_ENV_VAR] = prevInference;
}
}
function setupHarness(options: Record<string, unknown>) {
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
const ctx = {
options,
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
catalogCallbacks.push(async () => {
cb({ add: () => {} });
});
return Promise.resolve({ dispose: async () => {} });
},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
};
return { catalogCallbacks, ctx };
}
function stubDraft() {
const published = new Map<string, Record<string, unknown>>();
const draft = {
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
model: {
update: (pid: string, mid: string, fn: (m: Record<string, unknown>) => void) => {
const key = pid + "/" + mid;
let entry = published.get(key);
if (entry === undefined) {
entry = { id: mid, providerID: pid };
published.set(key, entry);
}
fn(entry);
},
},
};
return { draft, published };
}
function fallbackWarns(warns: string[]): string[] {
return warns.filter((w) => w.includes("managementReadToken"));
}
async function runSetup(ctx: unknown): Promise<void> {
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
}
describe("plugin-v2 management token environment source", () => {
it("uses the managementReadToken option for /api/* while models keep apiKey", async () => {
await withIsolatedEnv(undefined, undefined, async () => {
const h = installHarness([]);
try {
const { catalogCallbacks, ctx } = setupHarness({
baseURL: "https://gw.example.com",
providerId: "omniroute",
apiKey: "chat-key",
managementReadToken: "mgmt-option-token",
});
await runSetup(ctx);
assert.deepEqual(fallbackWarns(h.warns), []);
const { draft } = stubDraft();
await catalogCallbacks[0](draft);
assert.equal(h.seen.get(COMBOS_URL), "Bearer mgmt-option-token");
assert.equal(h.seen.get(MODELS_URL), "Bearer chat-key");
} finally {
h.restore();
}
});
});
it("reads the management token from the environment when the option is absent", async () => {
await withIsolatedEnv("mgmt-env-token", undefined, async () => {
const h = installHarness([]);
try {
const { catalogCallbacks, ctx } = setupHarness({
baseURL: "https://gw.example.com",
providerId: "omniroute",
apiKey: "chat-key",
});
await runSetup(ctx);
assert.deepEqual(fallbackWarns(h.warns), []);
const { draft } = stubDraft();
await catalogCallbacks[0](draft);
assert.equal(h.seen.get(COMBOS_URL), "Bearer mgmt-env-token");
assert.equal(h.seen.get(MODELS_URL), "Bearer chat-key");
} finally {
h.restore();
}
});
});
it("prefers the option over the environment", async () => {
await withIsolatedEnv("mgmt-env-token", undefined, async () => {
const h = installHarness([]);
try {
const { catalogCallbacks, ctx } = setupHarness({
baseURL: "https://gw.example.com",
providerId: "omniroute",
apiKey: "chat-key",
managementReadToken: "mgmt-option-token",
});
await runSetup(ctx);
assert.deepEqual(fallbackWarns(h.warns), []);
const { draft } = stubDraft();
await catalogCallbacks[0](draft);
assert.equal(h.seen.get(COMBOS_URL), "Bearer mgmt-option-token");
} finally {
h.restore();
}
});
});
it("falls back to the inference key with a single early warning when neither is set", async () => {
await withIsolatedEnv(undefined, undefined, async () => {
const h = installHarness([]);
try {
const { catalogCallbacks, ctx } = setupHarness({
baseURL: "https://gw.example.com",
providerId: "omniroute",
apiKey: "chat-key",
});
await runSetup(ctx);
const atSetup = fallbackWarns(h.warns);
assert.equal(
atSetup.length,
1,
`expected exactly one early fallback warning, got: ${JSON.stringify(h.warns)}`
);
assert.match(atSetup[0] ?? "", /managementReadToken/);
assert.match(atSetup[0] ?? "", new RegExp(MGMT_ENV_VAR));
assert.ok(!(atSetup[0] ?? "").includes("chat-key"), "warning must not leak the key");
const { draft } = stubDraft();
await catalogCallbacks[0](draft);
assert.equal(h.seen.get(COMBOS_URL), "Bearer chat-key");
assert.equal(
fallbackWarns(h.warns).length,
1,
"the fallback warning stays a single setup-time notice"
);
} finally {
h.restore();
}
});
});
it("treats an empty option as absent so the environment wins", async () => {
await withIsolatedEnv("mgmt-env-token", undefined, async () => {
const h = installHarness([]);
try {
const { catalogCallbacks, ctx } = setupHarness({
baseURL: "https://gw.example.com",
providerId: "omniroute",
apiKey: "chat-key",
managementReadToken: "",
});
await runSetup(ctx);
assert.deepEqual(fallbackWarns(h.warns), []);
const { draft } = stubDraft();
await catalogCallbacks[0](draft);
assert.equal(h.seen.get(COMBOS_URL), "Bearer mgmt-env-token");
} finally {
h.restore();
}
});
});
it("treats an empty environment value as absent so the option wins", async () => {
await withIsolatedEnv("", undefined, async () => {
const h = installHarness([]);
try {
const { catalogCallbacks, ctx } = setupHarness({
baseURL: "https://gw.example.com",
providerId: "omniroute",
apiKey: "chat-key",
managementReadToken: "mgmt-option-token",
});
await runSetup(ctx);
assert.deepEqual(fallbackWarns(h.warns), []);
const { draft } = stubDraft();
await catalogCallbacks[0](draft);
assert.equal(h.seen.get(COMBOS_URL), "Bearer mgmt-option-token");
} finally {
h.restore();
}
});
});
it("falls back with a warning when both the option and the environment are empty", async () => {
await withIsolatedEnv("", undefined, async () => {
const h = installHarness([]);
try {
const { catalogCallbacks, ctx } = setupHarness({
baseURL: "https://gw.example.com",
providerId: "omniroute",
apiKey: "chat-key",
managementReadToken: "",
});
await runSetup(ctx);
assert.equal(fallbackWarns(h.warns).length, 1);
const { draft } = stubDraft();
await catalogCallbacks[0](draft);
assert.equal(h.seen.get(COMBOS_URL), "Bearer chat-key");
} finally {
h.restore();
}
});
});
it("enriches the catalog from the environment token alone", async () => {
const providers = new Map<string, Record<string, any>>();
const models = new Map<string, Record<string, any>>();
const draft = {
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: Record<string, any>) => void) => {
const p = (providers.get(id) ?? { id }) as Record<string, any>;
fn(p);
providers.set(id, p);
},
remove: () => {},
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
fn(m);
models.set(k, m);
},
remove: () => {},
default: { get: () => undefined, set: () => {} },
},
} as unknown as BetaDraft;
let seenCombos = "";
let seenPricing = "";
const res = await withIsolatedEnv("mgmt-env-token", undefined, async () =>
publishCatalog(
draft,
{
providerId: "omniroute",
baseURL: "https://gw.example.com",
apiKey: "chat-key",
managementReadToken: process.env[MGMT_ENV_VAR],
timeoutMs: 1000,
modelCacheTtlMs: 300000,
usableOnly: false,
},
{
fetcher: async () => [{ id: "m1" }],
combosFetcher: async (_base, token) => {
seenCombos = token;
return [{ id: "team-combo", models: [{ kind: "model", model: "m1" }] }];
},
enrichmentFetcher: async (_base, token) => {
seenPricing = token;
// The process env is the source under test: the resolver output
// flows in through the option above, so report success only when
// the flow under test actually carried it.
if (token !== "mgmt-env-token") return new Map();
return new Map([["team-combo", { name: "Team Combo" }]]);
},
}
)
);
assert.deepEqual(res, { models: 1, combos: 1, autoCombos: 0 });
assert.equal(seenCombos, "mgmt-env-token");
assert.equal(seenPricing, "mgmt-env-token");
const entry = models.get("omniroute/team-combo");
assert.ok(entry, "expected the combo entry in the published catalog");
assert.equal(entry?.name, "Team Combo");
});
});

View File

@@ -1,7 +1,7 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import plugin from "../src/index.js";
import { collectCatalog } from "../src/catalog.js";
import { publishCatalog } from "../src/catalog.js";
const MODELS_URL = "https://gw.example.com/v1/models";
const COMBOS_URL = "https://gw.example.com/api/combos";
@@ -24,24 +24,21 @@ function silence() {
}
function setup(options: Record<string, unknown>, reload?: () => Promise<void>) {
const added: unknown[] = [];
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
const ctx = {
options,
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
cb({ add: (input: unknown) => added.push(input) });
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
catalogCallbacks.push(cb);
return Promise.resolve({ dispose: async () => {} });
},
...(reload ? { reload } : {}),
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
};
return { added, ctx };
return { catalogCallbacks, ctx };
}
function stubDraft() {
@@ -81,15 +78,16 @@ describe("plugin-v2 managementReadToken wiring (F1)", () => {
}) as typeof fetch;
const guard = silence();
try {
const { added, ctx } = setup({
const { catalogCallbacks, ctx } = setup({
baseURL: "https://gw.example.com",
providerId: "omniroute",
apiKey: "chat-key",
managementReadToken: "mgmt-key",
});
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
const ids = (added as Array<{ models: Array<{ id: string }> }>).flatMap((a) => a.models.map((m) => m.id));
assert.ok(ids.includes("m1"));
const { draft, published } = stubDraft();
await catalogCallbacks[0](draft);
assert.ok(published.has("omniroute/m1"));
assert.equal(seen.get(COMBOS_URL), "Bearer mgmt-key");
assert.equal(seen.get(MODELS_URL), "Bearer chat-key");
} finally {
@@ -119,12 +117,14 @@ describe("plugin-v2 managementReadToken wiring (F1)", () => {
}) as typeof fetch;
const guard = silence();
try {
const { ctx } = setup({
const { catalogCallbacks, ctx } = setup({
baseURL: "https://gw.example.com",
providerId: "omniroute",
apiKey: "chat-key",
});
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
const { draft } = stubDraft();
await catalogCallbacks[0](draft);
assert.equal(seen.get(COMBOS_URL), "Bearer chat-key");
} finally {
globalThis.fetch = origFetch;
@@ -132,9 +132,16 @@ describe("plugin-v2 managementReadToken wiring (F1)", () => {
}
});
it("collectCatalog routes combosFetcher to managementReadToken, models to apiKey", async () => {
it("publishCatalog routes combosFetcher to managementReadToken, models to apiKey", async () => {
const calls: Array<[string, string]> = [];
const collected = await collectCatalog(
const draft = {
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
model: {
update: (_p: string, _m: string, fn: (m: Record<string, unknown>) => void) => fn({}),
},
};
const res = await publishCatalog(
draft as never,
{
providerId: "omniroute",
baseURL: "https://gw.example.com",
@@ -155,7 +162,6 @@ describe("plugin-v2 managementReadToken wiring (F1)", () => {
},
}
);
const res = collected.counts;
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
assert.deepEqual(calls, [
["models", "chat-key"],
@@ -195,52 +201,26 @@ describe("plugin-v2 fail-closed models (F2)", () => {
}) as typeof fetch;
const guard = silence();
try {
const firstAdded: unknown[] = [];
const firstCtx = {
options: {
baseURL: "https://gw.example.com",
providerId: "f2-keep",
apiKey: "k-f2",
modelCacheTtlMs: 1,
},
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
cb({ add: (input: unknown) => firstAdded.push(input) });
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {},
},
model: { transform: () => Promise.resolve({ dispose: async () => {} }) },
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
};
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(firstCtx);
const firstIds = (firstAdded as Array<{ models: Array<{ id: string }> }>).flatMap((a) => a.models.map((m) => m.id));
assert.ok(firstIds.includes("m1"), "first refresh must publish m1");
const { catalogCallbacks, ctx } = setup({
baseURL: "https://gw.example.com",
providerId: "f2-keep",
apiKey: "k-f2",
modelCacheTtlMs: 1,
});
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
const first = stubDraft();
await catalogCallbacks[0](first.draft);
assert.ok(first.published.has("f2-keep/m1"), "first refresh must publish m1");
const { setTimeout: sleep } = await import("node:timers/promises");
await sleep(5);
const secondAdded: unknown[] = [];
const secondCtx = {
options: {
baseURL: "https://gw.example.com",
providerId: "f2-keep",
apiKey: "k-f2",
modelCacheTtlMs: 1,
},
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
cb({ add: (input: unknown) => secondAdded.push(input) });
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {},
},
model: { transform: () => Promise.resolve({ dispose: async () => {} }) },
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
};
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(secondCtx);
const second = stubDraft();
await catalogCallbacks[0](second.draft);
if (prevDataDir === undefined) delete process.env.OPENCODE_DATA_DIR;
else process.env.OPENCODE_DATA_DIR = prevDataDir;
const secondIds = (secondAdded as Array<{ models: Array<{ id: string }> }>).flatMap((a) => a.models.map((m) => m.id));
assert.ok(secondIds.includes("m1"), "empty models fetch must reuse last-known catalog");
assert.ok(
second.published.has("f2-keep/m1"),
"empty models fetch must reuse last-known catalog"
);
assert.ok(
guard.warns.some((w) => w.includes("keeping last-known catalog")),
`expected keep-last-known warn, got: ${JSON.stringify(guard.warns)}`

View File

@@ -1,31 +1,19 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
import { publishCatalog } from "../src/catalog.js";
type BetaDraft = {
provider: {
list?: () => unknown[];
get?: (id: string) => unknown;
update: (id: string, fn: (p: Record<string, any>) => void) => void;
remove?: () => void;
};
model: {
get?: (...a: string[]) => unknown;
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
remove?: () => void;
default?: { get: () => undefined; set: () => void };
};
};
interface Captured {
models: Map<string, Record<string, any>>;
draft: BetaDraft;
models: Map<string, ModelV2Info>;
draft: CatalogDraft;
warns: string[];
restore: () => void;
}
function fakeDraft(): Captured {
const providers = new Map<string, Record<string, any>>();
const models = new Map<string, Record<string, any>>();
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
const warns: string[] = [];
const origWarn = console.warn;
console.warn = (...args: unknown[]) => {
@@ -35,8 +23,8 @@ function fakeDraft(): Captured {
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: Record<string, any>) => void) => {
const p = (providers.get(id) ?? { id }) as Record<string, any>;
update: (id: string, fn: (p: ProviderV2Info) => void) => {
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
fn(p);
providers.set(id, p);
},
@@ -44,16 +32,16 @@ function fakeDraft(): Captured {
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
fn(m);
models.set(k, m);
},
remove: () => {},
default: { get: () => undefined, set: () => {} },
},
};
} as CatalogDraft;
return {
models,
draft,

View File

@@ -29,38 +29,6 @@ describe("parsePluginOptions", () => {
it("requires baseURL", () => {
assert.throws(() => parsePluginOptions({}), /baseURL/);
});
it("rejects a baseURL that is not an http(s) URL", () => {
// `new URL()` reads "localhost:20128" as the scheme "localhost:" followed
// by a path, so a gateway address typed without "http://" parses. Every
// model would then be published with "localhost:20128/v1" as its api url
// and every call would fail in the client on an unknown scheme, with no
// request on the wire and nothing in the gateway logs.
for (const baseURL of [
"localhost:20128",
"localhost:20128/v1",
"ftp://gw.example.com/v1",
"gw.example.com/v1",
]) {
assert.throws(
() => parsePluginOptions({ baseURL }),
/baseURL must be an http\(s\) URL/,
`expected ${baseURL} to be rejected`
);
}
});
it("accepts http and https baseURLs, with or without a port or path", () => {
for (const baseURL of [
"http://localhost:20128/v1",
"http://localhost:20128",
"https://gw.example.com/v1",
"https://gw.example.com/omniroute/v1",
]) {
assert.equal(parsePluginOptions({ baseURL }).baseURL, baseURL);
// Padding a copied address is trimmed rather than rejected, matching the
// treatment `headroomUrl` already gets in the settings schema.
assert.equal(parsePluginOptions({ baseURL: ` ${baseURL} ` }).baseURL, baseURL);
}
});
it("rejects unknown top-level keys (strict)", () => {
assert.throws(() => parsePluginOptions({ baseURL: "https://gw.example.com", bogus: 1 }));
});

View File

@@ -1,6 +1,8 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createReadStream } from "node:fs";
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
import { publishCatalog } from "../src/catalog.js";
import {
mapComboToModelV2 as sharedMapCombo,
@@ -10,20 +12,6 @@ import {
type OmniRouteRawCombo,
type OmniRouteRawModelEntry,
} from "../src/shared/index.js";
type BetaDraft = {
provider: {
list?: () => unknown[];
get?: (id: string) => unknown;
update: (id: string, fn: (p: Record<string, any>) => void) => void;
remove?: () => void;
};
model: {
get?: (...a: string[]) => unknown;
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
remove?: () => void;
default?: { get: () => undefined; set: () => void };
};
};
interface Fixture {
models: OmniRouteRawModelEntry[];
@@ -68,16 +56,16 @@ async function loadV1Parity(): Promise<V1Parity> {
type ApiAuth = { type: "api"; key: string };
function fakeDraft() {
const providers = new Map<string, Record<string, any>>();
const models = new Map<string, Record<string, any>>();
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
return {
providers,
models,
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: Record<string, any>) => void) => {
const p = (providers.get(id) ?? { id }) as Record<string, any>;
update: (id: string, fn: (p: ProviderV2Info) => void) => {
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
fn(p);
providers.set(id, p);
},
@@ -85,9 +73,9 @@ function fakeDraft() {
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
fn(m);
models.set(k, m);
},
@@ -128,7 +116,7 @@ describe("v1-vs-v2 catalog parity", () => {
assert.equal(counts.combos, 2);
assert.equal(counts.autoCombos, 0);
// Final converted Record<string, any> shape (legacy→info boundary in
// Final converted ModelV2Info shape (legacy→info boundary in
// src/catalog.ts assignModelFields): api resolves to the
// openai-compatible AISDK block, capabilities fold tool_calling into
// tools, cost is zeroed (pricing lives server-side).
@@ -233,4 +221,4 @@ describe("v1-vs-v2 catalog parity", () => {
});
void (0 as unknown as ApiAuth);
void (0 as unknown as BetaDraft);
void (0 as unknown as CatalogDraft);

View File

@@ -5,10 +5,11 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import plugin from "../src/index.js";
// Guard around the provider publish: fetcher-level fail-open covers fetch
// rejections, but a mapper throw or a host throw in `editor.add` would reject
// the setup (unhandled rejection). The guard must warn + resolve instead.
describe("plugin-v2 publish guard (mapper/host throws)", () => {
// Guard around `publishCatalog` in the catalog transform: fetcher-level
// fail-open covers fetch rejections, but a mapper throw or a host throw in
// `draft.update` would reject the transform callback (unhandled rejection).
// The guard must warn + resolve instead.
describe("plugin-v2 publish guard (mapper/draft throws)", () => {
function isolateDisk(): () => void {
const dir = mkdtempSync(join(tmpdir(), "omniroute-guard-"));
const prev = process.env.OPENCODE_DATA_DIR;
@@ -18,26 +19,24 @@ describe("plugin-v2 publish guard (mapper/host throws)", () => {
else process.env.OPENCODE_DATA_DIR = prev;
};
}
function setupCtx(add: (input: unknown) => void): {
function setupCtx(): {
catalogCallbacks: Array<(draft: unknown) => Promise<void>>;
ctx: Record<string, unknown>;
} {
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
const ctx = {
options: { baseURL: "https://gw.example.com", providerId: "omniroute", apiKey: "k" },
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
cb({ add });
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
catalogCallbacks.push(cb);
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
};
return { ctx };
return { catalogCallbacks, ctx };
}
function stubFetch(): typeof fetch {
@@ -75,17 +74,25 @@ describe("plugin-v2 publish guard (mapper/host throws)", () => {
}
}
it("host throw in editor.add: setup resolves + warns, never rejects", async () => {
it("host throw in draft.model.update: callback resolves + warn, never rejects", async () => {
const restoreDisk = isolateDisk();
const { ctx } = setupCtx(() => {
throw new Error("host boom");
});
const { catalogCallbacks, ctx } = setupCtx();
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch();
try {
const { warns } = await silenceConsole(async () => {
// MUST resolve — without the guard this rejects with "host boom".
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
assert.equal(catalogCallbacks.length, 1);
const draft = {
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
model: {
update: () => {
throw new Error("host boom");
},
},
};
// MUST resolve — without the guard this rejects with "host boom".
await catalogCallbacks[0](draft);
});
assert.ok(
warns.some((w) => w.includes("catalog publish failed") && w.includes("host boom")),
@@ -96,4 +103,35 @@ describe("plugin-v2 publish guard (mapper/host throws)", () => {
restoreDisk();
}
});
it("host throw in draft.provider.update: callback resolves + warn, never rejects", async () => {
const restoreDisk = isolateDisk();
const { catalogCallbacks, ctx } = setupCtx();
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch();
try {
const { warns } = await silenceConsole(async () => {
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
const draft = {
provider: {
update: () => {
throw new Error("provider host boom");
},
},
model: {
update: (_pid: string, _mid: string, fn: (m: Record<string, unknown>) => void) =>
fn({}),
},
};
await catalogCallbacks[0](draft);
});
assert.ok(
warns.some((w) => w.includes("catalog publish failed")),
`expected a publish-guard warn, got: ${JSON.stringify(warns)}`
);
} finally {
globalThis.fetch = origFetch;
restoreDisk();
}
});
});

View File

@@ -2,8 +2,12 @@ import { describe, it } from "node:test";
import assert from "node:assert/strict";
import plugin from "../src/index.js";
// Fail-open refresh: a combos 403/500/abort must not escape setup. Setup
// resolves with a models-only provider payload plus a combos warn.
// RED: reproduces the PROD unhandled rejection — combos 403 must not escape
// the catalog transform. Today `loadSnapshot()` awaits
// `Promise.all([models, combos])` with no catch, so a 403 combos fetch
// rejects the snapshot promise and the rejection propagates out of the
// `ctx.catalog.transform` callback (fail-open in `publishCatalog` is
// bypassed because injected fetchers return the already-rejected data).
describe("plugin-v2 fail-open refresh (PROD 403 combos)", () => {
let diskSeq = 0;
async function isolateDisk(): Promise<() => void> {
@@ -23,33 +27,31 @@ describe("plugin-v2 fail-open refresh (PROD 403 combos)", () => {
combosStatus: number;
modelsStatus?: number;
reloads: { count: number };
added: unknown[];
}): {
catalogCallbacks: Array<(draft: unknown) => Promise<void>>;
ctx: Record<string, unknown>;
} {
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
const ctx = {
options: {
baseURL: "https://gw.example.com",
providerId: "fo-" + String(opts.combosStatus) + "-" + String(opts.modelsStatus ?? 200),
apiKey: "k-fo-" + String(opts.combosStatus),
},
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
cb({ add: (input: unknown) => opts.added.push(input) });
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
catalogCallbacks.push(cb);
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {
opts.reloads.count += 1;
},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
};
return { ctx };
return { catalogCallbacks, ctx };
}
function stubFetch(opts: { combosStatus: number; modelsStatus?: number }): typeof fetch {
@@ -76,6 +78,24 @@ describe("plugin-v2 fail-open refresh (PROD 403 combos)", () => {
}) as typeof fetch;
}
function stubDraft(): {
draft: unknown;
published: Map<string, Record<string, unknown>>;
} {
const published = new Map<string, Record<string, unknown>>();
const draft = {
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
model: {
update: (pid: string, mid: string, fn: (m: Record<string, unknown>) => void) => {
const entry: Record<string, unknown> = { id: mid, providerID: pid };
fn(entry);
published.set(pid + "/" + mid, entry);
},
},
};
return { draft, published };
}
async function silenceConsole<T>(fn: () => Promise<T>): Promise<{ result: T; warns: string[] }> {
const warns: string[] = [];
const origWarn = console.warn;
@@ -93,29 +113,23 @@ describe("plugin-v2 fail-open refresh (PROD 403 combos)", () => {
}
}
function modelIds(added: unknown[]): string[] {
const out: string[] = [];
for (const entry of added) {
const models = (entry as { models?: Array<{ id?: unknown }> }).models ?? [];
for (const m of models) out.push(String(m.id));
}
return out;
}
it("combos 403: setup resolves (models-only + warn), never rejects", async () => {
it("combos 403: catalog callback resolves (models-only + warn), never rejects", async () => {
const restoreDisk = await isolateDisk();
const reloads = { count: 0 };
const added: unknown[] = [];
const { ctx } = setupCtx({ combosStatus: 403, reloads, added });
const { catalogCallbacks, ctx } = setupCtx({ combosStatus: 403, reloads });
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch({ combosStatus: 403 });
try {
const { warns } = await silenceConsole(async () => {
// MUST resolve — today it rejects with the 403 error.
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
assert.equal(catalogCallbacks.length, 1);
const { draft, published } = stubDraft();
// MUST resolve — today it rejects with the 403 error.
await catalogCallbacks[0](draft);
const key = [...published.keys()].find((k) => k.endsWith("/m1"));
assert.ok(
modelIds(added).includes("m1"),
`models-only fallback must publish m1, got: ${JSON.stringify(modelIds(added))}`
key,
`models-only fallback must publish m1, got: ${JSON.stringify([...published.keys()])}`
);
});
assert.ok(
@@ -128,19 +142,21 @@ describe("plugin-v2 fail-open refresh (PROD 403 combos)", () => {
}
});
it("combos 500: setup resolves (models-only + warn), never rejects", async () => {
it("combos 500: catalog callback resolves (models-only + warn), never rejects", async () => {
const restoreDisk = await isolateDisk();
const reloads = { count: 0 };
const added: unknown[] = [];
const { ctx } = setupCtx({ combosStatus: 500, reloads, added });
const { catalogCallbacks, ctx } = setupCtx({ combosStatus: 500, reloads });
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch({ combosStatus: 500 });
try {
const { warns } = await silenceConsole(async () => {
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
const { draft, published } = stubDraft();
await catalogCallbacks[0](draft);
const key = [...published.keys()].find((k) => k.endsWith("/m1"));
assert.ok(
modelIds(added).includes("m1"),
`models-only fallback must publish m1, got: ${JSON.stringify(modelIds(added))}`
key,
`models-only fallback must publish m1, got: ${JSON.stringify([...published.keys()])}`
);
});
assert.ok(
@@ -153,11 +169,10 @@ describe("plugin-v2 fail-open refresh (PROD 403 combos)", () => {
}
});
it("combos timeout (abort): setup resolves, never rejects", async () => {
it("combos timeout (abort): catalog callback resolves, never rejects", async () => {
const restoreDisk = await isolateDisk();
const reloads = { count: 0 };
const added: unknown[] = [];
const { ctx } = setupCtx({ combosStatus: 200, reloads, added });
const { catalogCallbacks, ctx } = setupCtx({ combosStatus: 200, reloads });
const origFetch = globalThis.fetch;
globalThis.fetch = (async (url: unknown) => {
const href = String(url);
@@ -179,9 +194,12 @@ describe("plugin-v2 fail-open refresh (PROD 403 combos)", () => {
try {
const { warns } = await silenceConsole(async () => {
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
const { draft, published } = stubDraft();
await catalogCallbacks[0](draft);
const key = [...published.keys()].find((k) => k.endsWith("/m1"));
assert.ok(
modelIds(added).includes("m1"),
`models-only fallback must publish m1, got: ${JSON.stringify(modelIds(added))}`
key,
`models-only fallback must publish m1, got: ${JSON.stringify([...published.keys()])}`
);
});
assert.ok(

View File

@@ -1,69 +0,0 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { PLUGIN_ID } from "../src/options.js";
// OpenCode 2.0.12 local installs (`file://` directory) never read
// package.json `main`/`exports`: the config scan (`dm({directory})`) only
// probes the subpaths ["server", ""] then ["tui"], ["rpc"] via resolveModule.
// With only `dist/index.js` present the scan yields `{}` and the plugin is
// silently dropped — no `loading plugin`, no error. The package root must
// therefore expose a `server.*` entrypoint re-exporting the built plugin.
const testsDir = dirname(fileURLToPath(import.meta.url));
const pkgDir = resolve(testsDir, "..");
const serverEntry = join(pkgDir, "server.js");
const distEntry = join(pkgDir, "dist", "index.js");
const require = createRequire(import.meta.url);
describe("root server entrypoint (opencode file:// installs)", () => {
it("ships a root server.js re-exporting the built plugin", () => {
assert.ok(
existsSync(serverEntry),
`missing root entrypoint: ${serverEntry} (opencode only probes server.*/index.* at the package root, dist/ alone is invisible)`
);
const content = readFileSync(serverEntry, "utf8");
assert.ok(content.includes("./dist/index.js"), "server.js must re-export ./dist/index.js");
assert.ok(content.includes("export"), "server.js must re-export the plugin");
});
it("package.json files ships the root entrypoint", () => {
const pkg = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8")) as {
files?: string[];
};
assert.ok(
Array.isArray(pkg.files) && pkg.files.includes("server.js"),
`package.json "files" must include "server.js", got: ${JSON.stringify(pkg.files)}`
);
});
it("host-style probe require.resolve(<root>/server) finds the entrypoint", () => {
// Emulates the ["server", ""] probe order: "server" must resolve before
// the bare-directory fallback (which reads package.json main).
let resolved: string;
try {
resolved = require.resolve(join(pkgDir, "server"));
} catch {
assert.fail(`host probe for "server" found nothing under ${pkgDir}`);
}
assert.ok(
resolved === serverEntry || resolved.endsWith(join("opencode-plugin-v2", "server.js")),
`probe must resolve to the root server.js, got: ${resolved}`
);
});
it(
"root entrypoint exposes the plugin (id + setup)",
{ skip: !existsSync(distEntry) ? "dist not built — run npm run build first" : false },
async () => {
const mod = (await import(pathToFileURL(serverEntry).href)) as {
default?: { id?: unknown; setup?: unknown };
};
assert.ok(mod.default, "server.js must have a default export");
assert.equal(mod.default?.id, PLUGIN_ID);
assert.equal(typeof mod.default?.setup, "function");
}
);
});

View File

@@ -3,20 +3,8 @@ import assert from "node:assert/strict";
import { mapRawModelToModelV2, resolveApiBlockV2 } from "../src/shared/models-map.js";
import { parsePluginOptions } from "../src/options.js";
import { publishCatalog } from "../src/catalog.js";
type BetaDraft = {
provider: {
list?: () => unknown[];
get?: (id: string) => unknown;
update: (id: string, fn: (p: Record<string, any>) => void) => void;
remove?: () => void;
};
model: {
get?: (...a: string[]) => unknown;
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
remove?: () => void;
default?: { get: () => undefined; set: () => void };
};
};
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
const GW = "https://gw.example.com";
const PREFIXES = ["cc", "claude", "anthropic", "kiro", "kr"];
@@ -112,8 +100,8 @@ describe("deprecated anthropicPrefixes", () => {
});
it("copied v1 config routes anthropic and warns deprecation through publishCatalog", async () => {
const providers = new Map<string, Record<string, any>>();
const models = new Map<string, Record<string, any>>();
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
const warns: string[] = [];
const origWarn = console.warn;
console.warn = (...args: unknown[]) => {
@@ -128,8 +116,8 @@ describe("deprecated anthropicPrefixes", () => {
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: Record<string, any>) => void) => {
const p = (providers.get(id) ?? { id }) as Record<string, any>;
update: (id: string, fn: (p: ProviderV2Info) => void) => {
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
fn(p);
providers.set(id, p);
},
@@ -137,18 +125,18 @@ describe("deprecated anthropicPrefixes", () => {
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
fn(m);
models.set(k, m);
},
remove: () => {},
default: { get: () => undefined, set: () => {} },
},
};
const { collectCatalog, buildProviderPayload } = await import("../src/catalog.js");
const collected = await collectCatalog(
} as CatalogDraft;
const res = await publishCatalog(
draft,
{
providerId: "omniroute",
baseURL: GW,
@@ -164,18 +152,11 @@ describe("deprecated anthropicPrefixes", () => {
enrichmentFetcher: async () => new Map(),
}
);
assert.deepEqual(collected.counts, { models: 1, combos: 0, autoCombos: 0 });
const payload = buildProviderPayload(collected, {
providerId: "omniroute",
baseURL: GW,
apiKey: "k",
timeoutMs: 1000,
modelCacheTtlMs: 300000,
usableOnly: false,
});
const m = payload.models.find((x) => String((x as unknown as { id: string }).id) === "cc/claude-x") as unknown as Record<string, any>;
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
const m = models.get("omniroute/cc/claude-x");
assert.ok(m);
assert.equal(m?.package, "@opencode/ai/providers/anthropic");
if (m?.api.type !== "aisdk") throw new Error("model api must be aisdk");
assert.equal(m?.api.id, "anthropic");
} finally {
console.warn = origWarn;
}

View File

@@ -1,26 +1,21 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import type { CatalogDraft, PluginContext } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
import plugin from "../src/index.js";
describe("stable contract smoke", () => {
describe("v2 contract smoke", () => {
it("default export has string id and function setup", () => {
assert.equal(typeof (plugin as { id: unknown }).id, "string");
assert.equal(typeof (plugin as { setup: unknown }).setup, "function");
});
it("setup registers transforms against a stable ctx", async () => {
it("setup registers transforms against a structurally-real ctx", async () => {
const seen: string[] = [];
const ctx = {
options: { baseURL: "https://gw.example.com", providerId: "omniroute" },
provider: {
catalog: {
transform: async () => {
seen.push("provider.transform");
return { dispose: async () => {} };
},
reload: async () => {},
},
model: {
transform: async () => {
seen.push("model.transform");
seen.push("catalog.transform");
return { dispose: async () => {} };
},
reload: async () => {},
@@ -33,16 +28,51 @@ describe("stable contract smoke", () => {
reload: async () => {},
connection: { active: async () => undefined, resolve: async () => undefined },
},
agent: { transform: async () => ({ dispose: async () => {} }), reload: async () => {} },
command: { transform: async () => ({ dispose: async () => {} }), reload: async () => {} },
reference: { transform: async () => ({ dispose: async () => {} }), reload: async () => {} },
skill: { transform: async () => ({ dispose: async () => {} }), reload: async () => {} },
aisdk: {
hook: async () => ({ dispose: async () => {} }),
sdk: async () => ({ dispose: async () => {} }),
language: async () => ({ dispose: async () => {} }),
},
plugin: { add: async () => {}, remove: async () => {} },
} satisfies PluginContext;
await (plugin as { setup: (c: PluginContext) => Promise<void> }).setup(ctx);
assert.deepEqual(seen, ["catalog.transform", "integration.transform"]);
});
it("publishCatalog writes into a real CatalogDraft without proxy breakage", async () => {
const { publishCatalog } = await import("../src/catalog.js");
const written: { provider?: string; models: string[] } = { models: [] };
const draft: CatalogDraft = {
provider: {
list: () => [],
get: () => undefined,
update: (id: string, fn: (p: ProviderV2Info) => void) => {
written.provider = id;
const p = {
id,
name: "",
api: { type: "aisdk", package: "" },
request: { headers: {}, body: {} },
} as ProviderV2Info;
fn(p);
},
remove: () => {},
},
model: {
get: () => undefined,
update: (providerID: string, modelID: string, fn: (d: ModelV2Info) => void) => {
written.models.push(providerID + "/" + modelID);
const d = { id: modelID, providerID } as ModelV2Info;
fn(d);
},
remove: () => {},
default: { get: () => undefined, set: () => {} },
},
};
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
assert.deepEqual(seen, ["provider.transform", "integration.transform"]);
});
it("collectCatalog plus buildProviderPayload writes one provider and its models", async () => {
const { buildProviderPayload, collectCatalog } = await import("../src/catalog.js");
const collected = await collectCatalog(
const res = await publishCatalog(
draft,
{
providerId: "omniroute",
baseURL: "https://gw.example.com",
@@ -56,19 +86,8 @@ describe("stable contract smoke", () => {
combos: async () => [],
}
);
assert.equal(collected.counts.models, 1);
const payload = buildProviderPayload(collected, {
providerId: "omniroute",
baseURL: "https://gw.example.com",
apiKey: "k",
timeoutMs: 1000,
modelCacheTtlMs: 300000,
usableOnly: false,
});
assert.equal((payload.info as unknown as { id: string }).id, "omniroute");
assert.deepEqual(
payload.models.map((m) => String((m as unknown as { id: string }).id)),
["m1"]
);
assert.equal(res.models, 1);
assert.equal(written.provider, "omniroute");
assert.deepEqual(written.models, ["omniroute/m1"]);
});
});

View File

@@ -5,11 +5,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { createHash } from "node:crypto";
import plugin from "../src/index.js";
import {
diskSnapshotPath,
isStaleSnapshotModel,
snapshotIdentityFingerprint,
} from "../src/cache.js";
import { diskSnapshotPath, snapshotIdentityFingerprint } from "../src/cache.js";
import { legacyApiToInfoApi } from "../src/catalog.js";
function isolateDisk(): { dir: string; restore: () => void } {
@@ -26,37 +22,26 @@ function isolateDisk(): { dir: string; restore: () => void } {
}
function setupCtx(providerId: string): {
added: unknown[];
callbacks: Array<(draft: unknown) => Promise<void>>;
ctx: Record<string, unknown>;
} {
const added: unknown[] = [];
const callbacks: Array<(draft: unknown) => Promise<void>> = [];
const ctx = {
options: {
providerId,
baseURL: "https://gw.example.com",
apiKey: "k-snapfix",
},
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
cb({ add: (input: unknown) => added.push(input) });
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
callbacks.push(cb);
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
};
return { added, ctx };
}
function publishedOf(added: unknown[]): Map<string, Record<string, unknown>> {
const published = new Map<string, Record<string, unknown>>();
for (const entry of added as Array<{ info: { id: string }; models: Array<Record<string, unknown>> }>) {
for (const m of entry.models) published.set(entry.info.id + "/" + String(m.id), m);
}
return published;
return { callbacks, ctx };
}
function stubDraft(): { draft: unknown; published: Map<string, Record<string, unknown>> } {
@@ -112,7 +97,7 @@ function downFetch(): typeof fetch {
const fingerprint = snapshotIdentityFingerprint("https://gw.example.com", "k-snapfix", "k-snapfix");
describe("plugin-v2 snapshot stale-entry filter", () => {
it("snapshot with 3 unusable pre-mapped entries + 1 valid: only the valid one is published + warn emitted", async () => {
it("snapshot with 2 entries without api block + 1 valid: only the valid one is published + warn emitted", async () => {
const disk = isolateDisk();
const providerId = "snapfix-mixed";
mkdirSync(join(disk.dir, "plugins"), { recursive: true });
@@ -121,15 +106,11 @@ describe("plugin-v2 snapshot stale-entry filter", () => {
JSON.stringify({
v: 2,
identityFingerprint: fingerprint,
// Three pre-mapped entries with an unusable api block missing npm,
// empty npm, and a well-formed npm with no url (the shape a snapshot
// written by an older build carries, and the one that reaches the host
// as a bare `Invalid URL`) — plus one plain raw entry, which has no api
// block at all and gets one synthesized at publish time.
// Two pre-mapped entries with a broken api block (missing npm) plus
// one plain raw entry (no api block: synthesized at publish time).
models: [
{ id: "stale-a", api: {} },
{ id: "stale-b", api: { npm: "" } },
{ id: "stale-c", api: { id: "openai-compatible", npm: "@ai-sdk/openai-compatible" } },
{ id: "good-1", context_length: 128000 },
],
combos: [],
@@ -141,10 +122,11 @@ describe("plugin-v2 snapshot stale-entry filter", () => {
const origFetch = globalThis.fetch;
globalThis.fetch = downFetch();
try {
const { added, ctx } = setupCtx(providerId);
const { callbacks, ctx } = setupCtx(providerId);
const { warns } = await silenceConsole(async () => {
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
const published = publishedOf(added);
const { draft, published } = stubDraft();
await callbacks[0](draft);
assert.ok(
published.has(`${providerId}/good-1`),
`valid entry must be published, got: ${JSON.stringify([...published.keys()])}`
@@ -155,7 +137,7 @@ describe("plugin-v2 snapshot stale-entry filter", () => {
);
});
assert.ok(
warns.some((w) => w.includes("dropping 3 stale snapshot entries with an unusable api block")),
warns.some((w) => w.includes("dropping 2 stale snapshot entries without api block")),
`expected stale-drop warn, got: ${JSON.stringify(warns)}`
);
} finally {
@@ -197,10 +179,11 @@ describe("plugin-v2 snapshot stale-entry filter", () => {
};
}) as typeof fetch;
try {
const { added, ctx } = setupCtx(providerId);
const { callbacks, ctx } = setupCtx(providerId);
await silenceConsole(async () => {
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
const published = publishedOf(added);
const { draft, published } = stubDraft();
await callbacks[0](draft);
assert.ok(
published.has(`${providerId}/fresh-1`),
`fresh fetch must win over unversioned snapshot, got: ${JSON.stringify([...published.keys()])}`
@@ -233,56 +216,4 @@ describe("plugin-v2 snapshot stale-entry filter", () => {
// Sanity: sha256 helper used above matches the plugin identity scheme.
assert.equal(createHash("sha256").update("x").digest("hex").length, 64);
});
it("legacyApiToInfoApi throws unless api.url is an http(s) url", () => {
const npm = "@ai-sdk/openai-compatible";
for (const api of [
{ id: "openai-compatible", npm },
{ id: "openai-compatible", npm, url: "" },
{ id: "openai-compatible", npm, url: " " },
// Non-empty but uncallable: the AI SDK reaches `fetch` and fails there.
{ id: "openai-compatible", npm, url: "/v1" },
{ id: "openai-compatible", npm, url: "gw.example.com/v1" },
{ id: "openai-compatible", npm, url: "ftp://gw.example.com/v1" },
]) {
assert.throws(
() => legacyApiToInfoApi(api as unknown as { id: string; npm: string; url: string }),
/api block carries no http\(s\) url/,
`expected a publish-time refusal for ${JSON.stringify(api)}`
);
}
// A complete block still publishes unchanged.
assert.deepEqual(
legacyApiToInfoApi({
id: "openai-compatible",
npm: "@ai-sdk/openai-compatible",
url: "https://gw.example.com/v1",
}),
{
id: "openai-compatible",
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://gw.example.com/v1",
}
);
});
it("isStaleSnapshotModel drops a pre-mapped entry whose api.url is unusable", () => {
const npm = "@ai-sdk/openai-compatible";
// Present-but-unusable url: stale, for the same reason a missing npm is.
for (const url of [undefined, "", " ", "/v1", "gw.example.com/v1", "ftp://gw/v1"]) {
assert.equal(
isStaleSnapshotModel({ id: "a/b", api: { id: "x", npm, ...(url === undefined ? {} : { url }) } }),
true,
`expected ${JSON.stringify(url)} to be treated as stale`
);
}
// Complete block: publishable.
assert.equal(
isStaleSnapshotModel({ id: "a/b", api: { id: "x", npm, url: "https://gw/v1" } }),
false
);
// No api block at all stays publishable: it is synthesized at publish time.
assert.equal(isStaleSnapshotModel({ id: "a/b" }), false);
});
});

View File

@@ -1,47 +0,0 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import plugin from "../src/index.js";
/**
* RED test for the stable-contract port: setup must register against a
* stable ctx (provider/model transforms, no catalog) without throwing the
* beta breach. Runs against the built shape: id + setup, graceful without
* a key.
*/
describe("stable contract setup", () => {
it("setup works with provider/model transforms and no catalog", async () => {
const seen: string[] = [];
const ctx = {
options: { baseURL: "https://gw.example.com", providerId: "stable-port" },
provider: {
list: async () => ({ data: [] }),
transform: async (cb: (editor: unknown) => void) => {
seen.push("provider.transform");
assert.equal(typeof cb, "function");
return { dispose: async () => {} };
},
reload: async () => {
seen.push("provider.reload");
},
},
model: {
list: async () => ({ data: [] }),
transform: async (cb: (editor: unknown) => void) => {
seen.push("model.transform");
assert.equal(typeof cb, "function");
return { dispose: async () => {} };
},
reload: async () => {},
},
integration: {
transform: async () => ({ dispose: async () => {} }),
connection: { active: async () => undefined, resolve: async () => undefined },
},
};
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
assert.ok(
seen.includes("provider.transform"),
`provider.transform must be registered, got: ${JSON.stringify(seen)}`
);
});
});

View File

@@ -29,35 +29,39 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
providerId: string,
reloads: { count: number }
): {
added: unknown[];
catalogCallbacks: Array<(draft: unknown) => Promise<void>>;
ctx: Record<string, unknown>;
} {
const added: unknown[] = [];
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
const ctx = {
options: { baseURL: "https://gw.example.com", providerId, apiKey: "k-" + providerId },
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
cb({ add: (input: unknown) => added.push(input) });
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
catalogCallbacks.push(cb);
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {
reloads.count += 1;
},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
};
return { added, ctx };
return { catalogCallbacks, ctx };
}
function publishedOf(added: unknown[]): Map<string, Record<string, unknown>> {
function stubDraft(): { draft: unknown; published: Map<string, Record<string, unknown>> } {
const published = new Map<string, Record<string, unknown>>();
for (const entry of added as Array<{ info: { id: string }; models: Array<Record<string, unknown>> }>) {
for (const m of entry.models) published.set(entry.info.id + "/" + String(m.id), m);
}
return published;
const draft = {
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
model: {
update: (pid: string, mid: string, fn: (m: Record<string, unknown>) => void) => {
const entry: Record<string, unknown> = { id: mid, providerID: pid };
fn(entry);
published.set(pid + "/" + mid, entry);
},
},
};
return { draft, published };
}
/**
@@ -124,10 +128,12 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch({ autoCombosHangs: true });
const reloads = { count: 0 };
const { added, ctx } = setupCtx("staged-hang", reloads);
const { catalogCallbacks, ctx } = setupCtx("staged-hang", reloads);
try {
await withSilentConsole(async () => {
const done = (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
const { draft, published } = stubDraft();
const done = catalogCallbacks[0]!(draft);
const raced = await Promise.race([
done.then(() => "published" as const),
new Promise<"timeout">((r) => setTimeout(() => r("timeout"), 1500)),
@@ -137,7 +143,7 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
"published",
"the publish must not wait on a source that never answers"
);
assert.ok([...publishedOf(added).keys()].some((k) => k.endsWith("/m1")));
assert.ok([...published.keys()].some((k) => k.endsWith("/m1")));
});
} finally {
globalThis.fetch = origFetch;
@@ -150,18 +156,19 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch({ autoCombosHangs: false, enrichmentDelayMs: 120 });
const reloads = { count: 0 };
const { added, ctx } = setupCtx("staged-late", reloads);
const { catalogCallbacks, ctx } = setupCtx("staged-late", reloads);
try {
await withSilentConsole(async () => {
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
const early = [...publishedOf(added).values()].find((m) => m["id"] === "m1");
const first = stubDraft();
await catalogCallbacks[0]!(first.draft);
const early = [...first.published.values()].find((m) => m["id"] === "m1");
assert.ok(early, "models publish before the slow enrichment");
await new Promise((r) => setTimeout(r, 300));
// Re-setup refreshes the snapshot; the late enrichment lands on reload.
added.length = 0;
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
const late = [...publishedOf(added).values()].find((m) => m["id"] === "m1");
const second = stubDraft();
await catalogCallbacks[0]!(second.draft);
const late = [...second.published.values()].find((m) => m["id"] === "m1");
// The overlay is rendered, not just stored: the provider label the
// gateway ships alongside the display name reaches the picker.
assert.equal(
@@ -181,12 +188,13 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch({ autoCombosHangs: false });
const reloads = { count: 0 };
const { ctx } = setupCtx("staged-stable", reloads);
const { catalogCallbacks, ctx } = setupCtx("staged-stable", reloads);
try {
await withSilentConsole(async () => {
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
for (let i = 0; i < 3; i++) {
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
const d = stubDraft();
await catalogCallbacks[0]!(d.draft);
await new Promise((r) => setTimeout(r, 60));
}
});
@@ -235,15 +243,16 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
return ok({ data: [{ id: "m1" }] });
}) as unknown as typeof fetch;
const reloads = { count: 0 };
const { added, ctx } = setupCtx("staged-ttl", reloads);
const { catalogCallbacks, ctx } = setupCtx("staged-ttl", reloads);
(ctx["options"] as Record<string, unknown>)["modelCacheTtlMs"] = 1;
try {
await withSilentConsole(async () => {
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
await catalogCallbacks[0]!(stubDraft().draft);
await new Promise((r) => setTimeout(r, 250));
added.length = 0;
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
const m1 = [...publishedOf(added).values()].find((m) => m["id"] === "m1");
const second = stubDraft();
await catalogCallbacks[0]!(second.draft);
const m1 = [...second.published.values()].find((m) => m["id"] === "m1");
assert.equal(
m1?.["name"],
"Omni - Model One",
@@ -264,10 +273,12 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch({ autoCombosHangs: false, combosHangs: true });
const reloads = { count: 0 };
const { added, ctx } = setupCtx("staged-combos-hang", reloads);
const { catalogCallbacks, ctx } = setupCtx("staged-combos-hang", reloads);
try {
await withSilentConsole(async () => {
const done = (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
const { draft, published } = stubDraft();
const done = catalogCallbacks[0]!(draft);
const raced = await Promise.race([
done.then(() => "published" as const),
new Promise<"timeout">((r) => setTimeout(() => r("timeout"), 1500)),
@@ -277,14 +288,14 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
"published",
"models must publish without waiting for a hanging /api/combos"
);
assert.ok([...publishedOf(added).keys()].some((k) => k.endsWith("/m1")));
assert.ok([...published.keys()].some((k) => k.endsWith("/m1")));
// "staged-combos-hang" contains "combo" as a substring — filter on the
// model id suffix instead: no published model id may start with a
// combo prefix.
assert.equal(
[...publishedOf(added).keys()].filter((k) => /\/combo/i.test(k)).length,
[...published.keys()].filter((k) => /\/combo/i.test(k)).length,
0,
`no combos known yet — models-only on the first publish is correct, got ${JSON.stringify([...publishedOf(added).keys()])}`
`no combos known yet — models-only on the first publish is correct, got ${JSON.stringify([...published.keys()])}`
);
});
} finally {
@@ -331,17 +342,18 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
return ok({ data: [{ id: "m1" }] });
}) as unknown as typeof fetch;
const reloads = { count: 0 };
const { added, ctx } = setupCtx("staged-enrich-down", reloads);
const { catalogCallbacks, ctx } = setupCtx("staged-enrich-down", reloads);
(ctx["options"] as Record<string, unknown>)["modelCacheTtlMs"] = 1;
try {
await withSilentConsole(async () => {
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
await catalogCallbacks[0]!(stubDraft().draft);
await new Promise((r) => setTimeout(r, 250));
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
await catalogCallbacks[0]!(stubDraft().draft);
await new Promise((r) => setTimeout(r, 250));
added.length = 0;
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
const m1 = [...publishedOf(added).values()].find((m) => m["id"] === "m1");
const third = stubDraft();
await catalogCallbacks[0]!(third.draft);
const m1 = [...third.published.values()].find((m) => m["id"] === "m1");
assert.equal(
m1?.["name"],
"Omni - Model One",
@@ -381,20 +393,23 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
return ok({ data: [{ id: "m1" }] });
}) as unknown as typeof fetch;
const reloads = { count: 0 };
const { added: _addedU, ctx } = setupCtx("staged-unreachable", reloads);
const { catalogCallbacks, ctx } = setupCtx("staged-unreachable", reloads);
(ctx["options"] as Record<string, unknown>)["modelCacheTtlMs"] = 1;
try {
await withSilentConsole(async () => {
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
// First transform: gateway healthy, entry stored.
await catalogCallbacks[0]!(stubDraft().draft);
await new Promise((r) => setTimeout(r, 250));
// Gateway goes down only now: the next transform fails totally while
// a prior entry exists, arming the cooldown.
down = true;
await new Promise((r) => setTimeout(r, 10));
// A fresh setup replays the same failing gateway through a new
// closure, so it refetches once and arms its own cooldown; the
// count assertion pins that single arming fetch.
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
await catalogCallbacks[0]!(stubDraft().draft);
const afterArming = modelCalls;
assert.ok(afterArming >= 2, "the failing transform tries the network once");
await catalogCallbacks[0]!(stubDraft().draft);
assert.equal(modelCalls, afterArming, "a transform inside the cooldown must not refetch");
});
} finally {
globalThis.fetch = origFetch;
@@ -412,17 +427,12 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
const ctx = {
options: { baseURL: "https://gw.example.com", providerId: "staged-integ", apiKey: "k" },
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
catalogCallbacks.push(async () => {
cb({ add: () => {} });
});
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
catalogCallbacks.push(cb);
return Promise.resolve({ dispose: async () => {} });
},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
},
integration: {
transform: () => {
throw new Error("host says no");

View File

@@ -1,133 +0,0 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import plugin from "../src/index.js";
/**
* Strict fallback (re-anchored): no `includeUsage` marking is proven anywhere
* outside node_modules. The repo-verifiable facts are:
* (a) the host mock in `gemini-language.test.ts` mounts a callable `aisdk`
* domain the plugin reaches through `hook(name, cb)` — the domain exists
* on the host and routes by name;
* (b) `@opencode/plugin 2.0.12` is the pinned contract reference
* (`package.json`), its `sdk`-event option shape is UNKNOWN here.
* Consequence: no options-only marking is proven → strict fallback: register
* the hook (domain exists, probed at runtime) and record the observation in
* `options` only; the pure telemetry core stays unported and unimported.
*
* Host shape: the stable `@opencode/plugin` contract publishes the catalog
* through `ctx.provider.transform` (`assertContext` requires the provider and
* model transforms) and exposes one named `ctx.aisdk.hook(name, cb)` entry
* point instead of a callable domain per event. The fake below mirrors that;
* the five properties it asserts are unchanged.
*/
interface SdkInput {
model: { id: string; providerID: string };
package: string;
options: Record<string, unknown>;
}
function hostCtx(opts: {
telemetry?: boolean;
withAisdk?: boolean;
sdkImpl?: (
cb: (input: SdkInput) => void | Promise<void>
) => Promise<{ dispose: () => Promise<void> }>;
}): {
ctx: Record<string, unknown>;
sdkCallbacks: Array<(input: SdkInput) => void | Promise<void>>;
} {
const sdkCallbacks: Array<(input: SdkInput) => void | Promise<void>> = [];
const registration = Promise.resolve({ dispose: async () => {} });
const options: Record<string, unknown> = {
baseURL: "https://gw.example.com",
providerId: "omni",
apiKey: "k",
};
if (opts.telemetry !== undefined) options["telemetry"] = opts.telemetry;
const ctx: Record<string, unknown> = {
options,
provider: { transform: () => registration, reload: async () => {} },
model: { transform: () => registration },
integration: { transform: () => registration },
};
if (opts.withAisdk !== false) {
ctx["aisdk"] = {
hook: (name: string, cb: (input: SdkInput) => void | Promise<void>) => {
// The stable host routes every aisdk event through one entry point;
// "language" is the Gemini sanitiser, only "sdk" is this test's subject.
if (name !== "sdk") return registration;
if (opts.sdkImpl !== undefined) return opts.sdkImpl(cb);
sdkCallbacks.push(cb);
return registration;
},
};
}
return { ctx, sdkCallbacks };
}
async function setupQuiet(ctx: Record<string, unknown>): Promise<void> {
const warn = console.warn;
const log = console.log;
console.warn = () => {};
console.log = () => {};
try {
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
} finally {
console.warn = warn;
console.log = log;
}
}
describe("aisdk.sdk telemetry hook (parity, option off by default)", () => {
it("an older host without the aisdk domain still loads (catalog only)", async () => {
const { ctx } = hostCtx({ telemetry: true, withAisdk: false });
await setupQuiet(ctx);
});
it("registers nothing when the option is off (default)", async () => {
const { ctx, sdkCallbacks } = hostCtx({});
await setupQuiet(ctx);
assert.equal(sdkCallbacks.length, 0, "telemetry off must not touch aisdk.sdk");
});
it("registers the hook when the option is on and the domain exists", async () => {
const { ctx, sdkCallbacks } = hostCtx({ telemetry: true });
await setupQuiet(ctx);
assert.equal(sdkCallbacks.length, 1, "telemetry on must register aisdk.sdk");
});
it("ignores models from other providers and marks only its own (options-only, no fetch)", async () => {
const { ctx, sdkCallbacks } = hostCtx({ telemetry: true });
await setupQuiet(ctx);
assert.equal(sdkCallbacks.length, 1);
const foreign: SdkInput = {
model: { id: "m", providerID: "some-other-provider" },
package: "@ai-sdk/openai-compatible",
options: {},
};
await sdkCallbacks[0]!(foreign);
assert.deepEqual(foreign.options, {}, "another provider's options are untouched");
const own: SdkInput = {
model: { id: "m", providerID: "omni" },
package: "@ai-sdk/openai-compatible",
options: {},
};
await sdkCallbacks[0]!(own);
assert.equal(
own.options["telemetry"],
true,
"own models carry an options-only telemetry mark, never a wrapped fetch"
);
});
it("a host that refuses the sdk hook still keeps its catalog", async () => {
const { ctx } = hostCtx({
telemetry: true,
sdkImpl: () => {
throw new Error("refused");
},
});
await setupQuiet(ctx);
});
});

View File

@@ -2,30 +2,18 @@ import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { parsePluginOptions, resolveTimeouts } from "../src/options.js";
import { publishCatalog } from "../src/catalog.js";
type BetaDraft = {
provider: {
list?: () => unknown[];
get?: (id: string) => unknown;
update: (id: string, fn: (p: Record<string, any>) => void) => void;
remove?: () => void;
};
model: {
get?: (...a: string[]) => unknown;
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
remove?: () => void;
default?: { get: () => undefined; set: () => void };
};
};
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
function fakeDraft(): BetaDraft {
const providers = new Map<string, Record<string, any>>();
const models = new Map<string, Record<string, any>>();
function fakeDraft(): CatalogDraft {
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
return {
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: Record<string, any>) => void) => {
const p = (providers.get(id) ?? { id }) as Record<string, any>;
update: (id: string, fn: (p: ProviderV2Info) => void) => {
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
fn(p);
providers.set(id, p);
},
@@ -33,16 +21,16 @@ function fakeDraft(): BetaDraft {
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
fn(m);
models.set(k, m);
},
remove: () => {},
default: { get: () => undefined, set: () => {} },
},
};
} as CatalogDraft;
}
const BER = "https://gw.example.com";
@@ -136,11 +124,7 @@ describe("plugin-v2 P2 parity: per-endpoint timeouts", () => {
};
const ctx = {
options,
provider: {
transform: () => Promise.resolve({ dispose: async () => {} }),
reload: async () => {},
},
model: {
catalog: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: {

View File

@@ -1,31 +1,19 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
import { publishCatalog } from "../src/catalog.js";
import { parsePluginOptions } from "../src/options.js";
type BetaDraft = {
provider: {
list?: () => unknown[];
get?: (id: string) => unknown;
update: (id: string, fn: (p: Record<string, any>) => void) => void;
remove?: () => void;
};
model: {
get?: (...a: string[]) => unknown;
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
remove?: () => void;
default?: { get: () => undefined; set: () => void };
};
};
function fakeDraft(): { models: Map<string, Record<string, any>>; draft: BetaDraft } {
const providers = new Map<string, Record<string, any>>();
const models = new Map<string, Record<string, any>>();
function fakeDraft(): { models: Map<string, ModelV2Info>; draft: CatalogDraft } {
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
const draft = {
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: Record<string, any>) => void) => {
const p = (providers.get(id) ?? { id }) as Record<string, any>;
update: (id: string, fn: (p: ProviderV2Info) => void) => {
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
fn(p);
providers.set(id, p);
},
@@ -33,16 +21,16 @@ function fakeDraft(): { models: Map<string, Record<string, any>>; draft: BetaDra
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
fn(m);
models.set(k, m);
},
remove: () => {},
default: { get: () => undefined, set: () => {} },
},
};
} as CatalogDraft;
return { models, draft };
}
@@ -202,17 +190,12 @@ describe("catalog usableOnly gating", () => {
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
await plugin.setup({
options: { baseURL: "https://gw.example.com", providerId: "usable-gate" },
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
catalogCallbacks.push(async () => {
cb({ add: () => {} });
});
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
catalogCallbacks.push(cb);
return Promise.resolve({ dispose: async () => {} });
},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
},
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
});
const { mkdtempSync } = await import("node:fs");

View File

@@ -41,20 +41,18 @@ describe("warm snapshot is read under the credential actually in use", () => {
snapshotIdentityFingerprint(baseURL, hostKey, hostKey)
);
const added: unknown[] = [];
const published = new Map<string, Record<string, unknown>>();
const callbacks: Array<(draft: unknown) => Promise<void>> = [];
const registration = Promise.resolve({ dispose: async () => {} });
const ctx = {
options: { baseURL, providerId: "warmid", apiKey: "key-written-in-the-config" },
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
cb({ add: (input: unknown) => added.push(input) });
catalog: {
transform: (cb: (d: unknown) => Promise<void>) => {
callbacks.push(cb);
return registration;
},
reload: async () => {},
},
model: {
transform: () => registration,
},
integration: {
transform: () => registration,
connection: {
@@ -64,10 +62,17 @@ describe("warm snapshot is read under the credential actually in use", () => {
},
};
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
const published = new Map<string, Record<string, unknown>>();
for (const entry of added as Array<{ info: { id: string }; models: Array<Record<string, unknown>> }>) {
for (const m of entry.models) published.set(`${entry.info.id}/${String(m.id)}`, m);
}
const draft = {
provider: { update: (_i: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
model: {
update: (pid: string, mid: string, fn: (m: Record<string, unknown>) => void) => {
const e: Record<string, unknown> = { id: mid, providerID: pid };
fn(e);
published.set(`${pid}/${mid}`, e);
},
},
};
await callbacks[0]!(draft);
assert.ok(
[...published.keys()].some((k) => k.endsWith("/m-snap")),
`the snapshot must survive the credential switch, published: ${JSON.stringify([...published.keys()])}`

View File

@@ -230,7 +230,6 @@ Every field is optional. Defaults mirror v0.1.0 behaviour so existing `opencode.
| `visibleModels` | `string[]` | _unset_ | Allowlist — when set and non-empty, only models whose raw `/v1/models` ID matches are emitted. Bare IDs (no slash, e.g. `claude-opus-4-7`) match any `{prefix}/claude-opus-4-7`; full IDs (e.g. `cc/claude-opus-4-7`) match exactly. Composes with `usableOnly` and `hiddenModels` (all filters AND together). Unset or empty = no filter. |
| `hiddenModels` | `string[]` | _unset_ | Blocklist — models whose raw ID matches are dropped. Same matching rules as `visibleModels`. When a model is in both `visibleModels` and `hiddenModels`, the blocklist wins (deny takes precedence). Composes with `usableOnly` and `visibleModels` (all filters AND together). Unset or empty = no filter. |
| `diskCache` | `boolean` | `true` | Persist the last successful `/v1/models` + `/api/combos` + enrichment + connections + compression snapshot to `${OPENCODE_DATA_DIR ?? ~/.local/share/opencode}/plugins/omniroute-<providerId>.json`. On a subsequent cold start where `/v1/models` throws (network down / IP whitelist drop / 5xx) the static block hydrates from the snapshot so OC's model picker survives offline. Soft-fail on read/write — never blocks publishing. |
| `diskCacheMaxAgeMs` | `number` | _unset_ | Opt-in max age, in milliseconds, for a disk-cache fallback snapshot. Unset or `0` keeps the snapshot unbounded. A positive bound still serves the snapshot and escalates that fallback log from warn to error once the snapshot is older than the bound. |
| `geminiSanitization` | `boolean` | `true` | Strip `$schema`/`$ref`/`additionalProperties` from tool params when the model id matches `gemini` |
| `mcpAutoEmit` | `boolean` | `false` | Auto-write an `mcp.<providerId>` remote entry into the OC config pointing at `<baseURL>/api/mcp/stream` with the resolved Bearer token |
| `mcpToken` | `string` | _unset_ | Optional separate Bearer for the auto-emitted MCP entry. Falls back to the provider's `apiKey` (from `auth.json`) when unset |

View File

@@ -23,7 +23,7 @@
"scripts": {
"build": "tsup",
"clean": "rm -rf dist",
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/telemetry.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/feature-defaults.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts tests/model-allowlist.test.ts tests/log-level.test.ts tests/effort-tier-variants.test.ts tests/naming.test.ts tests/free-budget-magnitude.test.ts tests/models-fetcher.test.ts tests/issue-13000-cold-start-combo-limit.test.ts",
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/telemetry.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/feature-defaults.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts tests/model-allowlist.test.ts tests/log-level.test.ts tests/effort-tier-variants.test.ts tests/naming.test.ts tests/free-budget-magnitude.test.ts tests/models-fetcher.test.ts",
"prepublishOnly": "npm run clean && npm run build && npm test"
},
"keywords": [
@@ -68,7 +68,6 @@
"typescript": "^5.9.3"
},
"overrides": {
"esbuild": "^0.28.1",
"toml": "^4.1.2"
"esbuild": "^0.28.1"
}
}

View File

@@ -197,13 +197,6 @@ const featuresSchema = z
visibleModels: z.array(z.string().min(1)).optional(),
hiddenModels: z.array(z.string().min(1)).optional(),
diskCache: z.boolean().optional(),
/**
* Opt-in max age for a disk-cache fallback snapshot, in milliseconds.
* Unset or `0` keeps the historical unbounded default: a stale snapshot
* is still served. A positive bound does not refuse the snapshot; the
* fallback log escalates from warn to error once the snapshot is older.
*/
diskCacheMaxAgeMs: z.number().nonnegative().optional(),
providerTag: z.boolean().optional(),
debugLog: z.boolean().optional(),
startupDebug: z.boolean().optional(),
@@ -227,11 +220,7 @@ const optionsSchema = z
* to 60000. Default when unset: 300000.
*/
autoSyncIntervalMs: z.number().int().nonnegative().optional(),
baseURL: z
.string()
.trim()
.refine(isHttpUrl, "baseURL must be an http(s) URL, for example http://localhost:20128")
.optional(),
baseURL: z.string().url().optional(),
managementReadToken: z.string().min(1).optional(),
features: featuresSchema.optional(),
})
@@ -487,49 +476,12 @@ function coercePluginOptions(opts?: PluginOptions): OmniRoutePluginOptions {
*/
export const DEFAULT_ANTHROPIC_PREFIXES = ["cc", "claude", "anthropic", "kiro", "kr"];
/**
* First-class OmniRoute catalog suffixes (`GET /v1/models`). The Anthropic
* Messages translator looks these up as `claude-<model>` on provider
* `claude` and 404s. Keep them on openai-compatible `/v1` so the full
* catalog id (`cc/claude-haiku-4-5-20251001-low`) is sent unchanged.
*/
export const OPENAI_COMPAT_EFFORT_TIER_SUFFIXES = [
"-low",
"-medium",
"-high",
"-xhigh",
"-thinking",
"-minimal",
"-max",
] as const;
function hasOpenAiCompatEffortTierSuffix(modelId: string): boolean {
const lower = modelId.toLowerCase();
return OPENAI_COMPAT_EFFORT_TIER_SUFFIXES.some((suffix) => lower.endsWith(suffix));
}
/**
* Ensure a baseURL ends with `/v1` so the OpenAI-compat SDK constructs
* `/v1/chat/completions` correctly. The Anthropic SDK does NOT want `/v1`
* (it appends `/v1/messages` automatically), so callers should branch on
* format first.
*/
/**
* A url the AI SDK can actually call. `new URL()` alone is not enough: it
* parses `localhost:20128` as the scheme `localhost:` and `ftp://host` as ftp,
* both of which reach `fetch` and fail there. Mirrors the `isHttpUrl` guard the
* settings schema applies to `headroomUrl`.
*/
export function isHttpUrl(value: unknown): boolean {
if (typeof value !== "string") return false;
try {
const { protocol } = new URL(value);
return protocol === "http:" || protocol === "https:";
} catch {
return false;
}
}
export function ensureV1Suffix(url: string): string {
const trimmed = trimTrailingSlashes(url);
return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`;
@@ -539,12 +491,7 @@ export function ensureV1Suffix(url: string): string {
* Resolve the API block (id + url + npm package) for a given model id.
*
* Decision matrix:
* - If the model id ends with a first-class OmniRoute effort-tier suffix
* (`-low` / `-medium` / `-high` / `-xhigh` / `-thinking` / `-minimal` /
* `-max`), return the OpenAI-compat block even when the prefix is
* Anthropic. Those ids exist only in `GET /v1/models`; the Anthropic
* Messages path 404s them as `claude-<name>` on provider `claude`.
* - Else if the model id's prefix (the substring before the first `/`) is in
* - If the model id's prefix (the substring before the first `/`) is in
* `apiFormat.anthropicPrefixes` (or the default list), return the
* Anthropic SDK block: `id: "anthropic"`, `url: baseURL` (no `/v1`),
* `npm: "@ai-sdk/anthropic"`.
@@ -563,7 +510,7 @@ export function resolveApiBlock(
const prefixes = apiFormat?.anthropicPrefixes ?? DEFAULT_ANTHROPIC_PREFIXES;
const slash = modelId.indexOf("/");
const prefix = slash === -1 ? modelId : modelId.slice(0, slash);
const isAnthropic = prefixes.includes(prefix) && !hasOpenAiCompatEffortTierSuffix(modelId);
const isAnthropic = prefixes.includes(prefix);
return isAnthropic
? {
id: "anthropic",
@@ -4664,21 +4611,9 @@ export function buildStaticProviderEntry(
.map((m) => m.max_output_tokens)
.filter((v): v is number => typeof v === "number" && v > 0);
// Prefer the server-computed aggregate (accounts for explicit
// context_length overrides and members outside memberEntries, e.g.
// not yet resolved in /v1/models) over the raw Math.min(member)
// lower bound. Mirrors mapComboToModelV2's limit.context logic
// (#13000) so the static catalog and the dynamic hook agree.
const preferredContext =
typeof combo.computed_context_length === "number" && combo.computed_context_length > 0
? combo.computed_context_length
: contextValues.length > 0
? Math.min(...contextValues)
: undefined;
if (preferredContext !== undefined && outputValues.length > 0) {
if (contextValues.length > 0 && outputValues.length > 0) {
entry.limit = {
context: preferredContext,
context: Math.min(...contextValues),
output: Math.min(...outputValues),
};
}
@@ -5329,7 +5264,6 @@ export function createOmniRouteConfigHook(
sink.call(logger, message);
};
const features = resolved.features ?? {};
const wantCombos = features.combos !== false;
const wantAutoCombos = features.autoCombos !== false;
const wantEnrichment = features.enrichment !== false;
const wantCompressionMeta = features.compressionMetadata === true;
@@ -5482,7 +5416,6 @@ export function createOmniRouteConfigHook(
};
const doCombos = async (): Promise<void> => {
if (!wantCombos) return;
try {
localRawCombos = await combosFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
@@ -5558,32 +5491,6 @@ export function createOmniRouteConfigHook(
const modelsFetchOk = !modelsFetchThrew && localRawModels.length > 0;
// Snapshot backfill for computed_context_length: a live /api/combos
// response can come back without this field (server hasn't finished
// recomputing it yet, e.g. just after a restart) even though the
// combo's members and identity are otherwise unchanged. When that
// happens, prefer the last-known-good value from the warm disk
// snapshot over the Math.min(member) fallback in
// mapComboToModelV2() — never overwrite any other combo field
// (models/name/etc.) with stale data, only this one derived number.
if (warmSnapshot) {
const snapshotComboById = new Map(warmSnapshot.rawCombos.map((c) => [c.id, c]));
for (const combo of localRawCombos) {
const hasLive =
typeof combo.computed_context_length === "number" &&
combo.computed_context_length > 0;
if (hasLive) continue;
const stale = snapshotComboById.get(combo.id);
if (
stale &&
typeof stale.computed_context_length === "number" &&
stale.computed_context_length > 0
) {
combo.computed_context_length = stale.computed_context_length;
}
}
}
// Disk-cache fallback (cold first run, no warm snapshot): when the
// live fetch returned no models AND features.diskCache !== false,
// hydrate from the last-known-good snapshot so OC still surfaces a
@@ -5591,24 +5498,9 @@ export function createOmniRouteConfigHook(
if (modelsFetchThrew && wantDiskCache && !warmSnapshot) {
const snapshot = await diskSnapshotReader(resolved.providerId, snapshotFingerprint);
if (snapshot && snapshot.rawModels.length > 0) {
// Report snapshot age like the warm-startup path already does:
// "stale" alone reads as a transient blip, so a week-old catalog
// is indistinguishable from a five-minute-old one.
const snapshotAge = snapshot.writtenAt;
const ageMs = typeof snapshotAge === "number" ? now() - snapshotAge : undefined;
const snapshotAgeLabel =
typeof ageMs === "number" ? `${Math.round(ageMs / 3_600_000)}h` : "unknown";
const maxAgeMs = features.diskCacheMaxAgeMs;
const pastMaxAge =
typeof maxAgeMs === "number" &&
maxAgeMs > 0 &&
typeof ageMs === "number" &&
ageMs > maxAgeMs;
logAt(
pastMaxAge ? "error" : "warn",
`config shim: /v1/models unreachable; using stale disk cache (${snapshot.rawModels.length} models, age ${snapshotAgeLabel}${
pastMaxAge ? `, past diskCacheMaxAgeMs=${maxAgeMs}` : ""
})`
"warn",
`config shim: /v1/models unreachable; using stale disk cache (${snapshot.rawModels.length} models)`
);
localRawModels = snapshot.rawModels;
localRawCombos = snapshot.rawCombos;

View File

@@ -461,31 +461,6 @@ test("config: fetchers throw → warn + emit stub entry with models: {}", async
// 6. Combos fetcher throws → models-only catalog (no combos in models block)
// ────────────────────────────────────────────────────────────────────────────
test("config: features.combos=false skips /api/combos fetch", async () => {
const readAuthJson = stubReadAuthJson({
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
});
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
const logger = captureWarn();
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute", features: { combos: false } },
{ readAuthJson, fetcher, combosFetcher, logger }
);
const input = makeInput();
await hook(input);
assert.equal(fetcher.callCount(), 1, "models fetch still runs");
assert.equal(combosFetcher.callCount(), 0, "combos fetch suppressed by feature flag");
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
assert.ok(entry);
assert.equal(entry.models["claude-tier"], undefined, "no combo entry when combos are off");
assert.ok(entry.models["claude-sonnet-4-6"]);
});
test("config: combos fetcher throws → emit models-only catalog (no combos in models block)", async () => {
const readAuthJson = stubReadAuthJson({
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
@@ -506,7 +481,10 @@ test("config: combos fetcher throws → emit models-only catalog (no combos in m
];
assert.ok(entry);
const ids = Object.keys(entry.models).sort();
assert.deepEqual(ids, ["claude-sonnet-4-6", "gemini-3-flash"]);
assert.deepEqual(ids, [
"claude-sonnet-4-6",
"gemini-3-flash",
]);
assert.equal(entry.models["claude-tier"], undefined, "no combo entry");
assert.ok(
logger.entries.some((e) => String(e[0]).includes("/api/combos fetch failed")),
@@ -1063,7 +1041,11 @@ test("config: features.enrichment=false skips enrichment fetch + keeps raw-id na
];
assert.ok(entry);
assert.equal(enrichmentFetcher.callCount(), 0, "enrichment fetch suppressed by feature flag");
assert.equal(entry.models["claude-sonnet-4-6"].name, "claude-sonnet-4-6", "raw id retained");
assert.equal(
entry.models["claude-sonnet-4-6"].name,
"claude-sonnet-4-6",
"raw id retained"
);
});
test("config: enrichment fetcher throws → soft-fail (warn + raw-id static catalog)", async () => {
@@ -1086,7 +1068,11 @@ test("config: enrichment fetcher throws → soft-fail (warn + raw-id static cata
"opencode-omniroute"
];
assert.ok(entry, "static block still published on enrichment failure");
assert.equal(entry.models["claude-sonnet-4-6"].name, "claude-sonnet-4-6", "raw id retained");
assert.equal(
entry.models["claude-sonnet-4-6"].name,
"claude-sonnet-4-6",
"raw id retained"
);
assert.equal(enrichmentFetcher.callCount(), 1);
assert.ok(
logger.entries.some((e) => String(e[0]).includes("/api/pricing/models fetch failed")),
@@ -1284,7 +1270,10 @@ test("config: diskCache hydrates stale snapshot when /v1/models throws", async (
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
assert.ok(entry.models["claude-sonnet-4-6"], "stale snapshot hydrated into static block");
assert.ok(
entry.models["claude-sonnet-4-6"],
"stale snapshot hydrated into static block"
);
assert.equal(
entry.models["claude-sonnet-4-6"].name,
"Claude Sonnet 4.6 (cached)",
@@ -1292,152 +1281,14 @@ test("config: diskCache hydrates stale snapshot when /v1/models throws", async (
);
assert.equal(writes, 0, "disk write skipped when live fetch failed");
assert.ok(
logger.entries.some(
(e) =>
String(e[0]).includes("using stale disk cache") ||
String(e[0]).includes("warm startup from disk snapshot")
logger.entries.some((e) =>
String(e[0]).includes("using stale disk cache") ||
String(e[0]).includes("warm startup from disk snapshot")
),
"disk-cache hydration breadcrumb emitted"
);
});
// The stale-fallback branch (`modelsFetchThrew && wantDiskCache && !warmSnapshot`)
// only runs when the warm-startup read found nothing — a snapshot can appear on
// disk between that first read and the live fetch failing (e.g. another OC
// process instance wrote one concurrently). A stateful reader simulates that:
// empty on the warm-startup read, populated by the time the fallback re-reads.
function emptyThenSnapshotReader(
snapshot: Omit<
Awaited<ReturnType<typeof import("../src/index.js").defaultDiskSnapshotReader>> & object,
never
>
): typeof import("../src/index.js").defaultDiskSnapshotReader {
let calls = 0;
return (async () => {
calls++;
return calls === 1 ? undefined : snapshot;
}) as typeof import("../src/index.js").defaultDiskSnapshotReader;
}
test("config: stale-fallback warning reports the disk snapshot age in hours", async () => {
const readAuthJson = stubReadAuthJson({
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
});
const fetcher = throwingModelsFetcher();
const combosFetcher = stubCombosFetcher([]);
const logger = captureWarn();
const writtenAt = Date.now() - 2 * 3_600_000; // 2h old
const diskSnapshotReader = emptyThenSnapshotReader({
rawModels: [MODEL_CLAUDE],
rawCombos: [],
rawEnrichment: new Map(),
rawCompressionCombos: [],
rawConnections: [],
writtenAt,
});
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute", features: { diskCache: true } },
{ readAuthJson, fetcher, combosFetcher, diskSnapshotReader, logger }
);
await hook(makeInput());
assert.ok(
logger.entries.some((e) => String(e[0]).includes("using stale disk cache (1 models, age 2h)")),
"stale-fallback warning includes the computed snapshot age"
);
});
test('config: stale-fallback warning falls back to "unknown" age without writtenAt', async () => {
const readAuthJson = stubReadAuthJson({
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
});
const fetcher = throwingModelsFetcher();
const combosFetcher = stubCombosFetcher([]);
const logger = captureWarn();
const diskSnapshotReader = emptyThenSnapshotReader({
rawModels: [MODEL_CLAUDE],
rawCombos: [],
rawEnrichment: new Map(),
rawCompressionCombos: [],
rawConnections: [],
});
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute", features: { diskCache: true } },
{ readAuthJson, fetcher, combosFetcher, diskSnapshotReader, logger }
);
await hook(makeInput());
assert.ok(
logger.entries.some((e) =>
String(e[0]).includes("using stale disk cache (1 models, age unknown)")
),
'stale-fallback warning falls back to "unknown" when writtenAt is absent'
);
});
test("config: diskCacheMaxAgeMs escalates the fallback log but still serves the snapshot", async () => {
const readAuthJson = stubReadAuthJson({
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
});
const fetcher = throwingModelsFetcher();
const combosFetcher = stubCombosFetcher([]);
const levels: string[] = [];
const logger = {
warn: (message: string) => {
levels.push(`warn:${message}`);
},
error: (message: string) => {
levels.push(`error:${message}`);
},
};
const writtenAt = 1_700_000_000_000;
const maxAgeMs = 24 * 3_600_000;
const diskSnapshotReader = emptyThenSnapshotReader({
rawModels: [MODEL_CLAUDE],
rawCombos: [],
rawEnrichment: new Map([["claude-sonnet-4-6", { name: "Claude Sonnet 4.6 (cached)" }]]),
rawCompressionCombos: [],
rawConnections: [],
writtenAt,
});
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute", features: { diskCache: true, diskCacheMaxAgeMs: maxAgeMs } },
{
readAuthJson,
fetcher,
combosFetcher,
diskSnapshotReader,
logger,
now: () => writtenAt + 48 * 3_600_000,
}
);
const input = makeInput();
await hook(input);
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
assert.ok(entry.models["claude-sonnet-4-6"], "snapshot past the bound is still served");
assert.ok(
levels.some(
(line) => line.startsWith("error:") && line.includes(`past diskCacheMaxAgeMs=${maxAgeMs}`)
),
"past-bound fallback escalates to error"
);
assert.equal(
levels.some((line) => line.startsWith("warn:") && line.includes("using stale disk cache")),
false,
"past-bound fallback is not only a warning"
);
});
test("config: cached rawEnrichment from earlier provider hook is reused (no refetch)", async () => {
const readAuthJson = stubReadAuthJson({
"opencode-omniroute": { type: "api", key: "sk-shared", baseURL: "https://or.example/v1" },
@@ -1525,7 +1376,10 @@ test("config: providerTag (default-on) prepends '<provider> - ' to enriched raw-
"opencode-omniroute"
];
assert.ok(entry);
assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude - Claude Sonnet 4.6");
assert.equal(
entry.models["claude-sonnet-4-6"].name,
"Claude - Claude Sonnet 4.6"
);
assert.equal(entry.models["gemini-3-flash"].name, "Gemini - Gemini 3 Flash");
// Combos stay untouched — `Combo: ` prefix already conveys multi-upstream.
assert.equal(entry.models["claude-tier"].name, "Claude Tier");
@@ -1641,7 +1495,10 @@ test("config: providerTag is idempotent — second hook call doesn't double-suff
const entryA = (inputA as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
assert.equal(entryA.models["claude-sonnet-4-6"].name, "Claude - Claude Sonnet 4.6");
assert.equal(
entryA.models["claude-sonnet-4-6"].name,
"Claude - Claude Sonnet 4.6"
);
// Second invocation (cache hit) — name must still be single-suffixed.
const inputB = makeInput();
@@ -1649,7 +1506,10 @@ test("config: providerTag is idempotent — second hook call doesn't double-suff
const entryB = (inputB as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
assert.equal(entryB.models["claude-sonnet-4-6"].name, "Claude - Claude Sonnet 4.6");
assert.equal(
entryB.models["claude-sonnet-4-6"].name,
"Claude - Claude Sonnet 4.6"
);
});
// ────────────────────────────────────────────────────────────────────────────

View File

@@ -19,7 +19,6 @@ import {
normaliseFreeLabel,
resolveApiBlock,
DEFAULT_ANTHROPIC_PREFIXES,
OPENAI_COMPAT_EFFORT_TIER_SUFFIXES,
ensureV1Suffix,
debugLogEnabled,
debugLogSetEnabled,
@@ -37,7 +36,10 @@ test("normaliseFreeLabel: '(Free)' suffix becomes [Free] prefix", () => {
});
test("normaliseFreeLabel: trailing ' Free' word becomes [Free] prefix", () => {
assert.equal(normaliseFreeLabel("DeepSeek V4 Flash Free"), "[Free] DeepSeek V4 Flash");
assert.equal(
normaliseFreeLabel("DeepSeek V4 Flash Free"),
"[Free] DeepSeek V4 Flash"
);
});
test("normaliseFreeLabel: trailing '-free' (hyphen) becomes [Free] prefix", () => {
@@ -57,7 +59,10 @@ test("normaliseFreeLabel: names without 'free' pass through unchanged", () => {
test("normaliseFreeLabel: 'free' in the middle of a name is NOT rewritten", () => {
// Only trailing/standalone "free" markers count; embedded "freedom" stays
assert.equal(normaliseFreeLabel("Freedom Model"), "Freedom Model");
assert.equal(
normaliseFreeLabel("Freedom Model"),
"Freedom Model"
);
});
test("normaliseFreeLabel: empty / whitespace-only inputs are handled", () => {
@@ -75,22 +80,6 @@ test("resolveApiBlock: cc/* models get the Anthropic SDK block (no /v1)", () =>
assert.equal(block.url, "https://api.example.com"); // NO /v1 suffix
});
test("resolveApiBlock: cc/* effort-tier catalog ids stay on openai-compatible /v1", () => {
assert.ok(OPENAI_COMPAT_EFFORT_TIER_SUFFIXES.includes("-low"));
for (const id of [
"cc/claude-haiku-4-5-20251001-low",
"cc/claude-opus-5-medium",
"cc/claude-opus-5-high",
"cc/claude-opus-5-xhigh",
"cc/claude-opus-4-6-thinking",
]) {
const block = resolveApiBlock(id, "https://api.example.com");
assert.equal(block.id, "openai-compatible", `${id} must not use Anthropic Messages`);
assert.equal(block.npm, "@ai-sdk/openai-compatible");
assert.equal(block.url, "https://api.example.com/v1");
}
});
test("resolveApiBlock: claude/*, anthropic/*, kiro/*, kr/* all route to Anthropic", () => {
for (const id of [
"claude/claude-opus-4-7",
@@ -139,7 +128,13 @@ test("resolveApiBlock: model id without '/' uses the id as prefix", () => {
});
test("DEFAULT_ANTHROPIC_PREFIXES: contains the canonical Anthropic aliases", () => {
assert.deepEqual(DEFAULT_ANTHROPIC_PREFIXES, ["cc", "claude", "anthropic", "kiro", "kr"]);
assert.deepEqual(DEFAULT_ANTHROPIC_PREFIXES, [
"cc",
"claude",
"anthropic",
"kiro",
"kr",
]);
});
test("ensureV1Suffix: idempotent for URLs that already end in /v1", () => {
@@ -250,7 +245,8 @@ test("createDebugLoggingFetch: records error without crashing the wrapped fetch"
test("createDebugLoggingFetch: URL instance input is captured (not 'undefined')", async () => {
const providerId = "test-provider-url-input";
debugLogClear(providerId);
const inner: typeof fetch = async () => new Response("ok", { status: 200 });
const inner: typeof fetch = async () =>
new Response("ok", { status: 200 });
const wrapped = createDebugLoggingFetch(inner, providerId, true);
await wrapped(new URL("https://api.example.com/v1/chat"));
const entries = debugLogRead(providerId);
@@ -262,7 +258,8 @@ test("createDebugLoggingFetch: URL instance input is captured (not 'undefined')"
test("createDebugLoggingFetch: Request object input captures URL and headers", async () => {
const providerId = "test-provider-request-input";
debugLogClear(providerId);
const inner: typeof fetch = async () => new Response("ok", { status: 200 });
const inner: typeof fetch = async () =>
new Response("ok", { status: 200 });
const wrapped = createDebugLoggingFetch(inner, providerId, true);
const req = new Request("https://api.example.com/v1/chat", {
method: "POST",

View File

@@ -1,221 +0,0 @@
/**
* Repro for #13000: combo context limits fall back to Math.min(member)
* instead of using computed_context_length after cold start — no disk
* snapshot fallback.
*
* Scenario (mirrors the report): a warm disk snapshot holds the combo with
* its correct server-computed `computed_context_length` (245000, from all 6
* members). After a restart, the live refresh's combos fetch returns the
* SAME combo but without `computed_context_length` (e.g. the value hasn't
* propagated yet), and the live models fetch only resolves 2 of the 6
* members (the rest not yet in /v1/models). The background refresh then
* republishes the provider block built from this degraded live data,
* downgrading a previously-known-good 245000 limit to Math.min(163840,
* 1_000_000) = 163840 — exactly the member-minimum described in the issue.
*/
import test from "node:test";
import assert from "node:assert/strict";
import type { Config } from "@opencode-ai/plugin";
import {
createOmniRouteConfigHook,
_resetInflightRefresh,
type OmniRouteAutoCombosFetcher,
type OmniRouteCombosFetcher,
type OmniRouteCompressionMetaFetcher,
type OmniRouteEnrichmentFetcher,
type OmniRouteFetchCache,
type OmniRouteModelsFetcher,
type OmniRouteProvidersFetcher,
type OmniRouteRawCombo,
type OmniRouteRawModelEntry,
type OmniRouteReadAuthJson,
type OmniRouteStaticProviderEntry,
type OmniRouteDiskSnapshotReader,
type OmniRouteDiskSnapshotWriter,
} from "../src/index.js";
test.beforeEach(() => {
_resetInflightRefresh();
});
function stubReadAuthJson(value: Record<string, unknown>): OmniRouteReadAuthJson {
return async () => value as never;
}
function authStub() {
return stubReadAuthJson({
"opencode-omniroute": {
type: "api",
key: "sk-test",
baseURL: "https://or.example.com/v1",
},
});
}
function makeInput(): Config {
return { provider: {} } as unknown as Config;
}
// The two members resolvable in the degraded live /v1/models response.
const MEMBER_DEEPSEEK: OmniRouteRawModelEntry = {
id: "deepseek-v4-pro",
capabilities: { tool_calling: true, reasoning: true, vision: false, thinking: false },
context_length: 163_840,
max_output_tokens: 64_000,
input_modalities: ["text"],
output_modalities: ["text"],
};
const MEMBER_GLM: OmniRouteRawModelEntry = {
id: "glm-5.2",
capabilities: { tool_calling: true, reasoning: true, vision: false, thinking: false },
context_length: 1_000_000,
max_output_tokens: 16_384,
input_modalities: ["text"],
output_modalities: ["text"],
};
// The other member that IS present once the server is fully warm.
const MEMBER_GLM_53_HIGH: OmniRouteRawModelEntry = {
id: "GLM-5.3-high",
capabilities: { tool_calling: true, reasoning: true, vision: false, thinking: false },
context_length: 245_000,
max_output_tokens: 128_000,
input_modalities: ["text"],
output_modalities: ["text"],
};
const COMBO_MODELS: OmniRouteRawCombo["models"] = [
{ kind: "model", model: "deepseek-v4-pro", weight: 25 },
{ kind: "model", model: "glm-5.2", weight: 25 },
{ kind: "model", model: "GLM-5.3-high", weight: 50 },
];
test("issue #13000: warm combo limit (245000) survives a degraded post-restart refresh instead of downgrading to Math.min(member)", async () => {
const warmSnapshot: Omit<import("../src/index.js").OmniRouteFetchCacheEntry, "expiresAt"> = {
rawModels: [MEMBER_DEEPSEEK, MEMBER_GLM, MEMBER_GLM_53_HIGH],
rawCombos: [
{
id: "orchestrator",
name: "orchestrator",
models: COMBO_MODELS,
computed_context_length: 245_000,
},
],
rawAutoCombos: [],
rawEnrichment: new Map(),
rawCompressionCombos: [],
rawConnections: [],
};
const fetcher: OmniRouteModelsFetcher = async () => [MEMBER_DEEPSEEK, MEMBER_GLM];
const combosFetcher: OmniRouteCombosFetcher = async () => [
{
id: "orchestrator",
name: "orchestrator",
models: COMBO_MODELS,
// computed_context_length intentionally omitted.
},
];
const autoCombosFetcher: OmniRouteAutoCombosFetcher = async () => [];
const enrichmentFetcher: OmniRouteEnrichmentFetcher = async () => new Map();
const compressionMetaFetcher: OmniRouteCompressionMetaFetcher = async () => [];
const providersFetcher: OmniRouteProvidersFetcher = async () => [];
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => warmSnapshot;
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
const sharedCache: OmniRouteFetchCache = new Map();
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute", modelCacheTtl: 60_000 },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
autoCombosFetcher,
enrichmentFetcher,
compressionMetaFetcher,
providersFetcher,
diskSnapshotReader,
diskSnapshotWriter,
cache: sharedCache,
}
);
const input = makeInput();
await hook(input);
// Let the detached background refresh (degraded live data) complete and
// republish the block.
await new Promise((r) => setTimeout(r, 100));
const entryAfter = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
const comboModelAfter = entryAfter.models["orchestrator"];
assert.ok(comboModelAfter, "combo model still published after refresh");
assert.equal(
comboModelAfter.limit.context,
245_000,
`expected the combo limit to stay at the known-good 245000, but got ${comboModelAfter.limit.context} ` +
`(Math.min(member) fallback — the exact bug described in #13000)`
);
});
test("issue #13000 (control): no warm snapshot exists — Math.min(member) fallback is still used (expected, documented behavior)", async () => {
const fetcher: OmniRouteModelsFetcher = async () => [MEMBER_DEEPSEEK, MEMBER_GLM];
const combosFetcher: OmniRouteCombosFetcher = async () => [
{
id: "orchestrator",
name: "orchestrator",
models: COMBO_MODELS,
// computed_context_length intentionally omitted.
},
];
const autoCombosFetcher: OmniRouteAutoCombosFetcher = async () => [];
const enrichmentFetcher: OmniRouteEnrichmentFetcher = async () => new Map();
const compressionMetaFetcher: OmniRouteCompressionMetaFetcher = async () => [];
const providersFetcher: OmniRouteProvidersFetcher = async () => [];
// No prior snapshot on disk.
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
const sharedCache: OmniRouteFetchCache = new Map();
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute", modelCacheTtl: 60_000 },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
autoCombosFetcher,
enrichmentFetcher,
compressionMetaFetcher,
providersFetcher,
diskSnapshotReader,
diskSnapshotWriter,
cache: sharedCache,
}
);
const input = makeInput();
await hook(input);
const entryAfter = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
const comboModelAfter = entryAfter.models["orchestrator"];
assert.ok(comboModelAfter, "combo model published on cold first run");
// No snapshot to backfill from — Math.min(163840, 1_000_000) = 163840.
assert.equal(
comboModelAfter.limit.context,
163_840,
"pure cold start with no snapshot must keep using the Math.min(member) fallback"
);
});

View File

@@ -59,26 +59,6 @@ test("parseOmniRoutePluginOptions: invalid baseURL (not a URL) → throws", () =
assert.throws(() => parseOmniRoutePluginOptions({ baseURL: "not-a-url" }), /baseURL/i);
});
test("parseOmniRoutePluginOptions: baseURL without an http(s) scheme → throws", () => {
// `new URL()` reads "localhost:20128" as the scheme "localhost:" followed by
// a path, so the address parses and the models are published with an api url
// no client can call.
for (const baseURL of ["localhost:20128", "localhost:20128/v1", "ftp://or.example.com", "or.example.com"]) {
assert.throws(
() => parseOmniRoutePluginOptions({ baseURL }),
/baseURL must be an http\(s\) URL/,
`expected ${baseURL} to be rejected`
);
}
});
test("parseOmniRoutePluginOptions: http and https baseURLs are accepted, padding trimmed", () => {
for (const baseURL of ["http://localhost:20128", "https://or.example.com/v1"]) {
assert.equal(parseOmniRoutePluginOptions({ baseURL }).baseURL, baseURL);
assert.equal(parseOmniRoutePluginOptions({ baseURL: ` ${baseURL} ` }).baseURL, baseURL);
}
});
test("parseOmniRoutePluginOptions: unknown key → throws (strict mode catches typos)", () => {
assert.throws(
() =>

View File

@@ -12,7 +12,7 @@
> // opencode.json
> {
> "$schema": "https://opencode.ai/config.json",
> "plugin": ["@omniroute/opencode-plugin"],
> "plugin": ["@omniroute/opencode-plugin"]
> }
> ```
>
@@ -100,7 +100,7 @@ Returns the value to place under `provider.omniroute` inside `opencode.json`.
| `baseURL` | `string` | Yes | OmniRoute base URL. Accepts `http://host:port` **or** `http://host:port/v1`. Trailing slashes are tolerated. |
| `apiKey` | `string` | Yes | OmniRoute API key. Use `sk_omniroute` for local installs that have `REQUIRE_API_KEY=false`. |
| `displayName` | `string` | No | Custom name shown in the OpenCode UI. Default: `"OmniRoute"`. |
| `models` | `string[]` | No | Override the surfaced model catalog. Default: 8 curated models — see `OMNIROUTE_DEFAULT_OPENCODE_MODELS`. |
| `models` | `string[]` | No | Override the surfaced model catalog. Default: 4 curated models — see `OMNIROUTE_DEFAULT_OPENCODE_MODELS`. |
| `modelLabels` | `Record<string,string>` | No | Human-readable labels keyed by model id. |
Throws on empty/invalid input — `baseURL` must be a real URL, `apiKey` must be a non-empty string.
@@ -143,7 +143,7 @@ Duplicates and empty strings are dropped automatically, and order is preserved.
- **Requests 404 with `/v1/v1/...`** — you're on an old version (≤1.0.0). Update to `≥0.1.0` of this re-released package. The new build normalises `baseURL` automatically.
- **`401 Invalid API key`** — your OmniRoute instance has `REQUIRE_API_KEY=true` but the key you supplied doesn't exist there. Create one via the dashboard or set `REQUIRE_API_KEY=false` and use `sk_omniroute`.
- **OpenCode complains the provider has no models** — supply an explicit `models` list; the default 8 may be hidden by your provider visibility settings.
- **OpenCode complains the provider has no models** — supply an explicit `models` list; the default 4 may be hidden by your provider visibility settings.
## Related

View File

@@ -46,7 +46,7 @@ Repository map and Reference Documentation sections below.
## Project at a Glance
**OmniRoute** — unified AI proxy/router. One endpoint, 359 LLM providers, auto-fallback.
**OmniRoute** — unified AI proxy/router. One endpoint, 356 LLM providers, auto-fallback.
| Layer | Location | Purpose |
| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -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 (182 migrations) |
| Database | `src/lib/db/` | SQLite domain modules (172 migrations) |
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
| MCP Server | `open-sse/mcp-server/` | 110 tools (45 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 |
@@ -542,7 +542,6 @@ git push -u origin feat/your-feature
**Husky hooks**:
- **pre-commit**: lint-staged + `check-docs-sync` + `check:any-budget:t11` + `check:tracked-artifacts`
- **commit-msg**: `check:ai-attribution` — rejects AI/bot `Co-Authored-By` trailers and AI-generation footers in the message (Hard Rule #16; human co-authors allowed; also in the `quality.yml` fast-gates loop (PR→`release/**`) and a PR-only `ci.yml` lint step (PR→`main`) — #14436)
- **pre-push**: intentionally light (PATH/npm sanity only). `any-budget` + `tracked-artifacts`
already run on pre-commit; re-running them on every push was pure double-pay. CI still
enforces both. (Was Fase 6A.12 full pre-push gate; folded into pre-commit in #6716.)
@@ -579,31 +578,14 @@ own dedicated branch, and you MUST confirm the base branch with the operator bef
# HARD LINKS (`cp -al`), never a symlink: ~5s for the whole tree and near-zero extra
# disk (the inodes are shared), and unlike a symlink it does not break the dev server.
cp -al "$(git -C <main_checkout> rev-parse --show-toplevel)/node_modules" node_modules
# `.husky/_` is gitignored, so a fresh worktree does NOT have it and
# `core.hooksPath=.husky/_` then points at a directory that does not exist —
# every pre-commit gate goes silently mute. Copy it too.
cp -a "$(git -C <main_checkout> rev-parse --show-toplevel)/.husky/_" .husky/_
```
`scripts/dev/new-worktree.sh <branch> [base]` does all of the above (canonical path,
hard-linked `node_modules`, `.husky/_`) and then **verifies** the hook is actually
executable, so prefer it over running the steps by hand.
**Never `ln -s` node_modules.** Turbopack rejects a symlink that resolves outside the
project root, so `npm run dev` dies with a FATAL panic (`Symlink [project]/node_modules
is invalid, it points out of the filesystem root`) while typecheck, lint and the test
runners all keep passing — the error names "filesystem root", not the worktree, so it
reads like a Next/build bug and costs real time to trace (incident 2026-07-31, #9043).
**A worktree without `.husky/_` runs NO pre-commit gate — and says nothing.** `git`
resolves `core.hooksPath` relative to the worktree top; when the directory is missing it
simply finds no hook and commits. Nothing is printed, the commit succeeds, and the
identity/lint/docs gates never ran. This is how 59 commits carrying a stale identity
override (name of a contributor + the maintainer's e-mail) got past
`scripts/check/check-git-identity.sh` between 2026-08-29 and 09-02 — they were all made in
`cp -al` worktrees. Verify with `ls .husky/_/pre-commit` inside a new worktree, or just use
`scripts/dev/new-worktree.sh`, which fails loudly when the hook is not executable.
3. **Work, commit, push, open the PR — all from inside the worktree.** Never `git checkout` a
different branch inside a worktree another session might share.
4. **Tear down only your own** worktree + branch when done, from the main checkout:
@@ -665,7 +647,7 @@ focused checks, and use a Conventional Commit message (for example, `docs: slim
## Environment
- **Runtime**: Node.js ≥22.22.2 <23 || ≥24.0.0 <27, ES Modules. This is the **only supported** runtime for the published `omniroute` CLI, the server, and the test suites (`node:test` + vitest) — `engines.node` is authoritative and end users never need Bun. A **best-effort `bun:sqlite` compatibility path** exists so a global Bun install (`bun install -g omniroute`) can start without `better-sqlite3` (driver adapter + Bun-aware process spawning); it is **not** a supported runtime — no support guarantees — and every Bun-specific runtime change MUST preserve the Node driver/fallback chain and ship a Bun test (`test:bun:db`) or an explicit reason why the path is Node-only.
- **Bun (build/dev script runner + compatibility smoke only)**: Bun `1.4.2` is pinned as an **exact devDependency** (provisioned through the existing `npm ci` via the lockfile's `@oven/bun-*` platform binaries — no `setup-bun`/ad-hoc install). It is used **only** to execute a small, allow-listed set of TypeScript **gate/generator scripts** (replacing `node --import tsx` for startup speed): the CI checks `check:provider-consistency`, `check:compression-budget`, `check:known-symbols`, and the non-CI `gen:provider-reference`, `bench:compression` — plus the focused `test:bun:db` compatibility smoke suite for the best-effort `bun:sqlite` path. **Do NOT** widen Bun to `npm install`, the build (`build:cli*`), `check:pack-artifact`, the supported published runtime, or the main test runners — those stay on Node. Any new Bun-invoking gate/generator script must be validated byte-identical against its `node --import tsx` output first. After pulling the lockfile change, run `npm install` so `bun` resolves locally (a stale `node_modules` will fail those scripts with `bun: not found`).
- **Bun (build/dev script runner + compatibility smoke only)**: Bun `1.4.0` is pinned as an **exact devDependency** (provisioned through the existing `npm ci` via the lockfile's `@oven/bun-*` platform binaries — no `setup-bun`/ad-hoc install). It is used **only** to execute a small, allow-listed set of TypeScript **gate/generator scripts** (replacing `node --import tsx` for startup speed): the CI checks `check:provider-consistency`, `check:compression-budget`, `check:known-symbols`, and the non-CI `gen:provider-reference`, `bench:compression` — plus the focused `test:bun:db` compatibility smoke suite for the best-effort `bun:sqlite` path. **Do NOT** widen Bun to `npm install`, the build (`build:cli*`), `check:pack-artifact`, the supported published runtime, or the main test runners — those stay on Node. Any new Bun-invoking gate/generator script must be validated byte-identical against its `node --import tsx` output first. After pulling the lockfile change, run `npm install` so `bun` resolves locally (a stale `node_modules` will fail those scripts with `bun: not found`).
- **TypeScript**: 6.0+, target ES2022, module esnext, resolution bundler
- **Path aliases**: `@/*` → `src/`, `@omniroute/open-sse` → `open-sse/`, `@omniroute/open-sse/*` → `open-sse/*`
- **Default port**: 20128 (API + dashboard on same port)

File diff suppressed because it is too large Load Diff

View File

@@ -106,8 +106,7 @@ RUN test -f package-lock.json \
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-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 --force_build=1) \
&& test -f node_modules/better-sqlite3/build/Release/better_sqlite3.node \
&& node /usr/local/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js rebuild) \
&& node -e "require('better-sqlite3')(':memory:').close()" \
&& node -e "const wreq=require('wreq-js'); if(typeof wreq.createTransport!=='function') process.exit(1)"
@@ -226,19 +225,7 @@ ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_MEMORY_MB}"
# Data directory inside Docker — must match the volume mount in docker-compose.yml
ENV DATA_DIR=/app/data
RUN mkdir -p /app/data && chown node:node /app /app/data
# #13679: default the PUBLISHED image to requiring an API key. A bare
# `docker run -p 20128:20128 … diegosouzapw/omniroute` (README/QUICK-START
# one-liners) does not pass `--env-file .env`, so without this default the
# anonymous /v1 LLM proxy would be both keyless AND world-reachable on the
# published container. This does NOT change the npm/CLI local-dev default
# (`REQUIRE_API_KEY` stays `"false"` in featureFlagDefinitions.ts) — only the
# shipped deployment artifact's posture. docker-compose.yml is unaffected: it
# loads the operator's own `.env` (env_file:) which overrides this ENV, and
# already binds loopback-only by default (#12568). Override with
# `-e REQUIRE_API_KEY=false` for an intentionally keyless deployment.
ENV REQUIRE_API_KEY=true
RUN mkdir -p /app/data
# `npm run build` (build-next-isolated → assembleStandalone) bundles ALL runtime
# files into .build/next/standalone/ — .next, node_modules, migrations, scripts,
@@ -248,24 +235,23 @@ ENV REQUIRE_API_KEY=true
# The old per-module overrides were therefore pure duplication and were removed
# (build-output-isolation cleanup). See scripts/build/assembleStandalone.mjs
# (EXTRA_MODULE_ENTRIES) for the single source of truth.
COPY --chown=node:node --from=builder /app/.build/next/standalone ./
COPY --from=builder /app/.build/next/standalone ./
# better-sqlite3 is the one exception still copied explicitly: assembleStandalone
# only syncs its native build/ dir; the JS wrapper (lib/, package.json) is left to
# Next.js tracing. bootstrap-env requires SQLite BEFORE the standalone server
# starts, so guarantee the complete package independent of trace behaviour.
COPY --chown=node:node --from=builder /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3
RUN test -f /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node
COPY --from=builder /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3
# migrations land at <standalone>/migrations via assembleStandalone; point the runtime at them.
ENV OMNIROUTE_MIGRATIONS_DIR=/app/migrations
# Docker healthcheck script — not traced by Next.js standalone output, so copy
# it explicitly. The HEALTHCHECK CMD references it as `node healthcheck.mjs`.
COPY --chown=node:node --from=builder /app/scripts/dev/healthcheck.mjs ./healthcheck.mjs
COPY --from=builder /app/scripts/dev/healthcheck.mjs ./healthcheck.mjs
# Every COPY above hands its files to the baked-in `node` non-root user
# (UID/GID 1000) at copy time. Do NOT add a `RUN chown -R node:node /app`
# afterwards: in the overlay filesystem changing ownership rewrites every file
# into a new layer, which stored the ~2 GB standalone build twice (#13990).
# Hand /app over to the baked-in `node` non-root user (UID/GID 1000) so the
# runtime process never holds root privileges. The chown happens after all
# COPYs so it covers files originally owned by root in the builder stage.
RUN chown -R node:node /app
EXPOSE 20128
@@ -354,7 +340,7 @@ RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,targe
# build, not the floating `@latest`.
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-npm-cache,target=/root/.npm \
npm install -g --no-audit --no-fund \
@openai/codex@0.155.0 \
@openai/codex@0.153.2 \
@anthropic-ai/claude-code@2.1.260 \
droid@0.212.0 \
openclaw@2026.9.1

View File

@@ -1,5 +1,5 @@
# ── Multi-stage Dockerfile for Native Bun Runtime (web-latest-bun) ───────────
FROM oven/bun:1.4.2-slim AS base
FROM oven/bun:1.4.0-slim AS base
WORKDIR /app
RUN apt-get update \
@@ -56,7 +56,7 @@ ENV NODE_ENV=production
RUN bun run --quiet build
# ── Runner Base stage (100% Bun Native Production Runtime) ──────────────────
FROM oven/bun:1.4.2-slim AS runner-base
FROM oven/bun:1.4.0-slim AS runner-base
LABEL org.opencontainers.image.title="omniroute" \
org.opencontainers.image.description="Unified AI proxy — route any LLM through one endpoint (Bun Native)" \

View File

@@ -7,19 +7,19 @@
# 🚀 OmniRoute — The Free AI Gateway
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 359 providers — 150+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 1595% tokens (~89% avg) — never hit limits. 359 AI providers · 150+ free tiers · ~1.62B free tokens/mo · 19 routing strategies · $0 to start."/>
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 356 providers — 150+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 1595% tokens (~89% avg) — never hit limits. 356 AI providers · 150+ free tiers · ~1.47B free tokens/mo · 19 routing strategies · $0 to start."/>
</div>
<div align="center">
## 💰 ~1.62B Free Tokens / Month
## 💰 ~1.47B Free Tokens / Month
</div>
> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute catalogs **489 free-tier entries across 35 recurring pool keys** and computes the token headline from the **17 pools with a published positive monthly budget plus five per-model Groq caps**, deduplicated by shared pool. Quotas that only open after a regional identity check (today: ModelScope) are shown apart, +~6M behind regional identity verification, and never summed into the headline. The result stays visible on the dashboard (`/dashboard/free-tiers`).
> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute catalogs **444 free-tier entries across 34 recurring pool keys** and computes the token headline from the **16 pools with a published positive monthly budget plus five per-model Groq caps**, deduplicated by shared pool. Quotas that only open after a regional identity check (today: ModelScope) are shown apart, +~6M behind regional identity verification, and never summed into the headline. The result stays visible on the dashboard (`/dashboard/free-tiers`).
<img src="./docs/diagrams/free-tier-budget.svg" width="100%" alt="OmniRoute free-tier budget card: ~1.62B free tokens per month steady, up to ~2.22B in the first month with signup credits, from 35 documented recurring pool keys covering 489 cataloged free-tier entries behind one endpoint. Honest pool-deduped math — each shared pool counted once, including 17 recurring pools with a published positive monthly token budget plus five per-model Groq caps; 13 providers are marked avoid in the terms-risk catalog so you decide. Budget bar includes Mistral 1B, Nara 210M, LLM7 150M, xKiro 150M, Groq 30M (five per-model caps) and smaller pools, plus first-month signup credits and permanently-free no-token-cap providers surfaced separately so they never inflate the headline. Live used/remaining on /dashboard/free-tiers."/>
<img src="./docs/diagrams/free-tier-budget.svg" width="100%" alt="OmniRoute free-tier budget card: ~1.47B free tokens per month steady, up to ~2.10B in the first month with signup credits, from 34 documented recurring pool keys covering 444 cataloged free-tier entries behind one endpoint. Honest pool-deduped math — each shared pool counted once, including 16 recurring pools with a published positive monthly token budget plus five per-model Groq caps; 13 providers are marked avoid in the terms-risk catalog so you decide. Budget bar includes Mistral 1B, Nara 210M, LLM7 150M, Groq 30M (five per-model caps) and smaller pools, plus first-month signup credits and permanently-free no-token-cap providers surfaced separately so they never inflate the headline. Live used/remaining on /dashboard/free-tiers."/>
> Animated summary of the live `/dashboard/free-tiers` page. Full methodology (pool dedupe, credit tiers, provider terms): **[docs/reference/FREE_TIERS.md](docs/reference/FREE_TIERS.md)**.
>
@@ -63,7 +63,7 @@
| | v3.8.49 | **v3.8.50** | `v3.8.51+` |
| ------------------------- | :-----: | :-----------------------: | :---------: |
| 🌐 Providers | 290 | **357** | more queued |
| 🌐 Providers | 290 | **352** | more queued |
| 🧠 Unique chat model IDs | 1185 | **1312** | — |
| 🖼️ Modality Bridge | — | 🆕 vision + audio + video | — |
| 📡 Radar free catalog | — | 🆕 opt-in | — |
@@ -101,7 +101,7 @@
<tr>
<td align="right"><b>⚙️ Features</b></td>
<td align="center"><a href="#-combos--the-flagship">🎯 Combos</a></td>
<td align="center"><a href="#-357-ai-providers--152-catalog-marked-free">🌐 Providers</a></td>
<td align="center"><a href="#-352-ai-providers--154-catalog-marked-free">🌐 Providers</a></td>
<td align="center"><a href="#-full-cli--a2a--mcp">🔌 CLI &amp; MCP</a></td>
</tr>
<tr>
@@ -133,7 +133,7 @@
</div>
<div align="center">
<b>🌐 In 67 languages</b>
<b>🌐 In 51 languages</b>
<br/><br/>
<a href="README.md"><img src="docs/assets/flags/us.svg" width="30" alt="English (en)" title="English (en)"></a>
<a href="docs/i18n/pt-BR/README.md"><img src="docs/assets/flags/br.svg" width="30" alt="Português — Brasil (pt-BR)" title="Português — Brasil (pt-BR)"></a>
@@ -186,22 +186,6 @@
<a href="docs/i18n/sl/README.md"><img src="docs/assets/flags/si.svg" width="30" alt="Slovenščina (sl)" title="Slovenščina (sl)"></a>
<a href="docs/i18n/mt/README.md"><img src="docs/assets/flags/mt.svg" width="30" alt="Malti (mt)" title="Malti (mt)"></a>
<a href="docs/i18n/ga/README.md"><img src="docs/assets/flags/ie.svg" width="30" alt="Gaeilge (ga)" title="Gaeilge (ga)"></a>
<a href="docs/i18n/kn/README.md"><img src="docs/assets/flags/in.svg" width="30" alt="ಕನ್ನಡ (kn)" title="ಕನ್ನಡ (kn)"></a>
<a href="docs/i18n/ml/README.md"><img src="docs/assets/flags/in.svg" width="30" alt="മലയാളം (ml)" title="മലയാളം (ml)"></a>
<a href="docs/i18n/or/README.md"><img src="docs/assets/flags/in.svg" width="30" alt="ଓଡ଼ିଆ (or)" title="ଓଡ଼ିଆ (or)"></a>
<a href="docs/i18n/pa/README.md"><img src="docs/assets/flags/in.svg" width="30" alt="ਪੰਜਾਬੀ (pa)" title="ਪੰਜਾਬੀ (pa)"></a>
<a href="docs/i18n/ne/README.md"><img src="docs/assets/flags/np.svg" width="30" alt="नेपाली (ne)" title="नेपाली (ne)"></a>
<a href="docs/i18n/si/README.md"><img src="docs/assets/flags/lk.svg" width="30" alt="සිංහල (si)" title="සිංහල (si)"></a>
<a href="docs/i18n/my/README.md"><img src="docs/assets/flags/mm.svg" width="30" alt="မြန်မာ (my)" title="မြန်မာ (my)"></a>
<a href="docs/i18n/km/README.md"><img src="docs/assets/flags/kh.svg" width="30" alt="ខ្មែរ (km)" title="ខ្មែរ (km)"></a>
<a href="docs/i18n/ha/README.md"><img src="docs/assets/flags/ng.svg" width="30" alt="Hausa (ha)" title="Hausa (ha)"></a>
<a href="docs/i18n/yo/README.md"><img src="docs/assets/flags/ng.svg" width="30" alt="Yorùbá (yo)" title="Yorùbá (yo)"></a>
<a href="docs/i18n/ig/README.md"><img src="docs/assets/flags/ng.svg" width="30" alt="Igbo (ig)" title="Igbo (ig)"></a>
<a href="docs/i18n/am/README.md"><img src="docs/assets/flags/et.svg" width="30" alt="አማርኛ (am)" title="አማርኛ (am)"></a>
<a href="docs/i18n/uz/README.md"><img src="docs/assets/flags/uz.svg" width="30" alt="Oʻzbekcha (uz)" title="Oʻzbekcha (uz)"></a>
<a href="docs/i18n/ka/README.md"><img src="docs/assets/flags/ge.svg" width="30" alt="ქართული (ka)" title="ქართული (ka)"></a>
<a href="docs/i18n/hy/README.md"><img src="docs/assets/flags/am.svg" width="30" alt="Հայերեն (hy)" title="Հայերեն (hy)"></a>
<a href="docs/i18n/bs/README.md"><img src="docs/assets/flags/ba.svg" width="30" alt="Bosanski (bs)" title="Bosanski (bs)"></a>
</div>
<br/>
@@ -234,7 +218,7 @@ curl http://localhost:20128/v1/chat/completions \
</div>
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint and 359 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 359 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 54 recurring/keyless free-forever providers · 36 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals and 39,000+ static test declarations across 5,100+ tracked test files."/>
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint and 356 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 356 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 52 recurring/keyless free-forever providers · 36 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals and 39,000+ static test declarations across 5,100+ tracked test files."/>
<br/>
<br/>
@@ -284,7 +268,7 @@ curl http://localhost:20128/v1/chat/completions \
<td>
Thanks to <b>Kimi (Moonshot AI)</b>, our founding Open Source Friend, for backing this project! Kimi is the AI lab behind the open-weight K2 and K3 model families — <b>Kimi K3</b> delivers a 1M-token context window, native vision and frontier-level coding at a fraction of closed-model prices, and works out of the box with Claude Code, Codex and every coding tool OmniRoute serves.
<br/><br/>
<b>What Kimi's support powers:</b> Kimi's API credits power OmniRoute's AI-validated release pipeline — the <i>merge validation powered by Kimi K3</i> stage that reviews every pull request before it ships — plus day-to-day feature development. First-class Kimi support ships on both rails: the direct <a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute">Kimi API</a> (<code>kimi-k3</code>) and the <a href="https://www.kimi.ai/code?aff=omniroute">Kimi Code coding plan</a> (OAuth and API key). OmniRoute is also the first Brazilian open-source project in Kimi's support program. <a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute"><b>Get a Kimi API key with 15% extra credits →</b></a>
<b>What Kimi's support powers:</b> Kimi's API credits power OmniRoute's AI-validated release pipeline — the <i>merge validation powered by Kimi K3</i> stage that reviews every pull request before it ships — plus day-to-day feature development. First-class Kimi support ships on both rails: the direct <a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute">Kimi API</a> (<code>kimi-k3</code>) and the <a href="https://www.kimi.com/code?aff=omniroute">Kimi Code coding plan</a> (OAuth and API key). OmniRoute is also the first Brazilian open-source project in Kimi's support program. <a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute"><b>Get a Kimi API key with 15% extra credits →</b></a>
</td>
</tr>
<tr>
@@ -487,7 +471,7 @@ All **19** strategies — mix & match per combo step:
</div>
<img src="./docs/diagrams/comparison-table.svg" width="100%" alt="What sets OmniRoute apart — a dated feature snapshot vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 359 providers, 150+ free tiers built in, 19 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA and 42 i18n UI locales. OmniRoute is MIT-licensed and self-hostable. Competitor capabilities and counts may change; see the linked methodology."/>
<img src="./docs/diagrams/comparison-table.svg" width="100%" alt="What sets OmniRoute apart — a dated feature snapshot vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 356 providers, 150+ free tiers built in, 19 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA and 42 i18n UI locales. OmniRoute is MIT-licensed and self-hostable. Competitor capabilities and counts may change; see the linked methodology."/>
<sub>📊 Full methodology &amp; per-feature detail vs 9router, OpenRouter, CLIProxyAPI &amp; LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md)</sub>
@@ -543,9 +527,9 @@ Pix copia-e-cola:
## 📡 OmniRoute Radar
The main free-tier headline remains **~1.62B tokens/month** from the documented,
The main free-tier headline remains **~1.47B tokens/month** from the documented,
pool-deduplicated catalog above. Temporary provider signup credits can separately lift the first
month to **~2.22B**. Radar is an optional, signed catalog overlay for people who want fresher
month to **~2.10B**. Radar is an optional, signed catalog overlay for people who want fresher
free-model availability between OmniRoute releases; the community catalog and every existing free
feature remain free.
@@ -630,13 +614,13 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
<td align="center" width="76"><picture><source media="(prefers-color-scheme:dark)" srcset="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-png@1.91.0/dark/goose.png"/><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/goose.svg" width="40" alt="Goose"/></picture><br/><sub><b>Goose</b></sub><br/><sub>                           </sub></td>
<td align="center" width="76"><img src="./public/providers/cli-generic.svg" width="40" alt="Open Interpreter"/><br/><sub><b>Open Interpreter</b></sub><br/><sub>                           </sub></td>
<td align="center" width="76"><img src="./public/providers/cli-generic.svg" width="40" alt="Warp AI"/><br/><sub><b>Warp AI</b></sub><br/><sub>                           </sub></td>
<td align="center" width="76"><a href="https://deyin.ai"><img src="./public/deyin.svg" width="40" alt="deyin.ai"/><br/><sub><b>deyin.ai</b></sub><br/><sub>                           </sub></a></td>
<td align="center" width="76"><img src="./public/providers/cli-generic.svg" width="40" alt="Agent Deck"/><br/><sub><b>Agent Deck</b></sub><br/><sub>                           </sub></td>
</tr>
</table>
</div>
<div align="center">
<b> also works with</b> · Agent Deck · Kiro · Command Code · Antigravity · Windsurf · AMP · <b>any OpenAI-compatible tool</b>
<b> also works with</b> · Kiro · Command Code · Antigravity · Windsurf · AMP · <b>any OpenAI-compatible tool</b>
</div>
<sub>📖 Per-tool setup for all 36 tools (26 CLI Code's + 10 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>
@@ -669,11 +653,11 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
<div align="center">
## 🌐 357 AI Providers — 152 Catalog-Marked Free
## 🌐 352 AI Providers — 152 Catalog-Marked Free
</div>
> **357 registered providers** across the canonical chat, media, search, local, cloud-agent and system collections, including **152 carrying `hasFree: true` discovery metadata**. The chat model registry covers **229 providers / 2,554 distinct provider-model pairs / 1,283 raw model IDs**; the separate free-budget catalog has **491 per-model rows**, **35 recurring pools** and **54 recurring/keyless free-forever providers**. These are different denominators by design; definitions and pool-deduped calculations live in the [Provider Reference](docs/reference/PROVIDER_REFERENCE.md) and [Free Tiers](docs/reference/FREE_TIERS.md).
> **352 registered providers** across the canonical chat, media, search, local, cloud-agent and system collections, including **152 carrying `hasFree: true` discovery metadata**. The chat model registry covers **229 providers / 2,554 distinct provider-model pairs / 1,283 raw model IDs**; the separate free-budget catalog has **444 per-model rows**, **34 recurring pools** and **52 recurring/keyless free-forever providers**. These are different denominators by design; definitions and pool-deduped calculations live in the [Provider Reference](docs/reference/PROVIDER_REFERENCE.md) and [Free Tiers](docs/reference/FREE_TIERS.md).
<div align="center">
@@ -863,7 +847,7 @@ with a scoped access token; every command then targets the remote.
```bash
omniroute connect 192.168.0.15 # password → scoped token, saved as a context
omniroute models # ← runs against the REMOTE server
omniroute models list # ← runs against the REMOTE server
omniroute configure codex # ← picks a remote model, writes a local Codex profile
omniroute tokens create --name ci --scope read # mint narrower tokens for other machines
omniroute contexts use default # ← switch back to the local server
@@ -1007,10 +991,6 @@ omniroute
```
> 💡 See `npm warn ERESOLVE` or peer-dep warnings? [They're harmless](docs/guides/TROUBLESHOOTING.md#npm-install-warnings-eresolve--peer--deprecated).
> **Using Gemini Web or another web-cookie provider?** The npm package includes
> Playwright but not its Chromium binary. See the
> [Playwright Chromium setup](docs/guides/TROUBLESHOOTING.md#gemini-web-and-playwright-chromium)
> note before making the first web-provider request.
Dashboard at `http://localhost:20128` · API at `http://localhost:20128/v1`.
@@ -1153,9 +1133,7 @@ install never blocks on compiling from source: it uses a prebuilt binary when on
your platform/Node, and otherwise falls back transparently to a pure-JS engine
(`node:sqlite` on Node 22+, else the bundled `sql.js` WASM) — no build tools required.
To skip the post-install **native warm-up** entirely (CI, headless, or slow machines).
Note: this only skips the native SQLite warm-up step (`scripts/postinstall.mjs`); the
binary-copy/repair hook (`scripts/build/postinstall.mjs`) still runs normally:
To skip the post-install native warm-up entirely (CI, headless, or slow machines):
```bash
OMNIROUTE_SKIP_POSTINSTALL=1 npm install -g omniroute # CI=1 also skips it
@@ -1275,7 +1253,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
<tr><td nowrap><b>Runtime</b></td><td>Node.js 22.x / 24.x LTS — <code>&gt;=22.22.2 &lt;23 || &gt;=24.0.0 &lt;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) — 122 domain modules, 182 migrations</td></tr>
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 122 domain modules, 172 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>
@@ -1338,7 +1316,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
<tr><td nowrap><b><a href="docs/architecture/RESILIENCE_GUIDE.md">Resilience Guide</a></b></td><td>Circuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing</td></tr>
<tr><td nowrap><b><a href="docs/routing/AUTO-COMBO.md">Auto-Combo Engine</a></b></td><td>16-factor scoring, mode packs, self-healing</td></tr>
<tr><td nowrap><b><a href="docs/ops/PROXY_GUIDE.md">Proxy Guide</a></b></td><td>3-level proxy system, 1proxy marketplace, registry CRUD</td></tr>
<tr><td nowrap><b><a href="docs/reference/FREE_TIERS.md">Free Tiers</a></b></td><td>Consolidated directory: 35 documented recurring pools / 489 cataloged free-tier entries</td></tr>
<tr><td nowrap><b><a href="docs/reference/FREE_TIERS.md">Free Tiers</a></b></td><td>Consolidated directory: 34 documented recurring pools / 444 cataloged free-tier entries</td></tr>
<tr><td nowrap><b><a href="docs/guides/FEATURES.md">Features Gallery</a></b></td><td>Visual dashboard tour with screenshots</td></tr>
<tr><td nowrap><b><a href="docs/architecture/CODEBASE_DOCUMENTATION.md">Codebase Documentation</a></b></td><td>Beginner-friendly codebase walkthrough</td></tr>
</table>

View File

@@ -218,8 +218,6 @@ These rules are enforced by tooling and reviewers:
## Supply-chain scanner findings (Socket.dev / Snyk / similar)
> **Scope note:** `socket.yml` at the repository root only shapes `projectIgnorePaths` for Socket.dev's registry-side post-publish scan of the published npm artifact — it is not an enforced CI/PR merge gate. No workflow in `.github/workflows`, no `package.json` script, and no `Makefile` target invokes Socket.dev.
The published `omniroute` npm artifact bundles the Next.js `output: "standalone"`
build, which means every route handler — including documented privileged
features (MITM, Zed import, Cloud Sync, embedded service supervisor) — ends

View File

@@ -1,582 +0,0 @@
#!/usr/bin/env node
/**
* OmniRoute Antigravity Bridge Proxy
*
* Intercepts Antigravity CLI and IDE requests:
* - Directs Gemini 3.8 models directly to Google backend (100% native, untouched).
* - Directs other models (Claude Sonnet 4.5/4.6, Opus, Gemini 3.7, GPT-OSS, etc.) to OmniRoute /v1/antigravity.
* - Passes all non-model Google requests (auth, onboarding, telemetry) directly to Google backend.
* - Transparently forwards all other non-target internet traffic.
*/
import net from "node:net";
import http from "node:http";
import https from "node:https";
import tls from "node:tls";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const PORT = parseInt(process.env.BRIDGE_PORT || "20129", 10);
const ROUTER_URL = process.env.ROUTER_URL || "http://127.0.0.1:20128/v1/antigravity";
const ROUTER_API_KEY =
process.env.ROUTER_API_KEY || process.env.OMNIROUTE_API_KEY || "sk-omniroute-bridge-local";
// Connection pool agents with TCP keep-alive
const httpAgent = new http.Agent({
keepAlive: true,
keepAliveMsecs: 60000,
maxSockets: 64,
maxFreeSockets: 16,
timeout: 120000,
});
const httpsAgent = new https.Agent({
keepAlive: true,
keepAliveMsecs: 60000,
maxSockets: 64,
maxFreeSockets: 16,
timeout: 120000,
});
let cachedSslOptions = null;
function getSslOptions() {
if (cachedSslOptions) return cachedSslOptions;
const certDir =
process.env.CERT_DIR || path.join(process.env.HOME || process.cwd(), ".omniroute", "mitm");
const serverKey = path.join(certDir, "server.key");
const serverCrt = path.join(certDir, "server.crt");
if (!fs.existsSync(serverKey) || !fs.existsSync(serverCrt)) {
console.error("❌ Certificate files not found in", certDir);
process.exit(1);
}
cachedSslOptions = {
key: fs.readFileSync(serverKey),
cert: fs.readFileSync(serverCrt),
};
return cachedSslOptions;
}
const TARGET_HOSTS = new Set([
"cloudcode-pa.googleapis.com",
"daily-cloudcode-pa.googleapis.com",
"daily-cloudcode-pa.sandbox.googleapis.com",
"autopush-cloudcode-pa.sandbox.googleapis.com",
"preprod-daily-cloudcode-pa.sandbox.googleapis.com",
"antigravity-unleash.goog",
]);
function isGenerationRequest(url) {
if (!url) return false;
return (
url.includes(":generateContent") ||
url.includes(":streamGenerateContent") ||
url.includes("/GenerateChat") ||
url.includes("/StreamGenerateChat") ||
url.includes("/GenerateCode") ||
url.includes("/CompleteCode")
);
}
function extractModel(body, url) {
if (body && typeof body === "object") {
if (typeof body.model === "string" && body.model) return body.model;
if (body.request && typeof body.request.model === "string" && body.request.model) {
return body.request.model;
}
}
if (url) {
try {
const parsed = new URL(url, "https://cloudcode-pa.googleapis.com");
const m = parsed.searchParams.get("model");
if (m) return m;
} catch {}
}
return null;
}
const MODEL_ROUTING_MAP = {
// Official OmniRoute Auto Groups
"auto/best-fast": "groq/openai/gpt-oss-120b",
"auto/best-coding": "mistral/codestral-latest",
"auto/best-reasoning": "nvidia/nvidia/nemotron-3-super-120b-a12b",
"auto/best-free": "groq/qwen/qwen3.8-27b",
"auto/best-vision": "nvidia/meta/llama-3.2-90b-vision-instruct",
"auto/coding:pro": "mistral/codestral-latest",
"auto/coding:fast": "groq/openai/gpt-oss-120b",
"auto/coding:free": "groq/qwen/qwen3.8-27b",
"auto/coding:reliable": "mistral/codestral-latest",
"auto/reasoning:pro": "nvidia/nvidia/nemotron-3-super-120b-a12b",
"auto/smart": "nvidia/nvidia/nemotron-3-super-120b-a12b",
"auto/claude-sonnet": "mistral/codestral-latest",
"auto/claude-opus": "nvidia/nvidia/nemotron-3-super-120b-a12b",
"auto/gemini": "gemini/gemini-2.5-flash",
"auto/llama": "groq/openai/gpt-oss-120b",
"auto/gemma": "groq/qwen/qwen3.8-27b",
// Human-readable Display Names (in case CLI sends displayName in envelope)
"Auto: Best Fast (OmniRoute)": "groq/openai/gpt-oss-120b",
"Auto: Best Coding (OmniRoute)": "mistral/codestral-latest",
"Auto: Best Reasoning (OmniRoute)": "nvidia/nvidia/nemotron-3-super-120b-a12b",
"Auto: Best Free (OmniRoute)": "groq/qwen/qwen3.8-27b",
"Auto: Best Vision (OmniRoute)": "nvidia/meta/llama-3.2-90b-vision-instruct",
"Auto: Coding Pro (OmniRoute)": "mistral/codestral-latest",
"Auto: Coding Fast (OmniRoute)": "groq/openai/gpt-oss-120b",
"Auto: Coding Free (OmniRoute)": "groq/qwen/qwen3.8-27b",
"Auto: Coding Reliable (OmniRoute)": "mistral/codestral-latest",
"Auto: Reasoning Pro (OmniRoute)": "nvidia/nvidia/nemotron-3-super-120b-a12b",
"Auto: Smart (OmniRoute)": "nvidia/nvidia/nemotron-3-super-120b-a12b",
"Auto: Claude Sonnet (OmniRoute)": "mistral/codestral-latest",
"Auto: Claude Opus (OmniRoute)": "nvidia/nvidia/nemotron-3-super-120b-a12b",
"Auto: Gemini (OmniRoute)": "gemini/gemini-2.5-flash",
"Auto: Llama (OmniRoute)": "groq/openai/gpt-oss-120b",
"Auto: Gemma (OmniRoute)": "groq/qwen/qwen3.8-27b",
// Fail-safe self-healing for dead/retired models
"nvidia/deepseek-ai/deepseek-v4-pro-0813": "groq/openai/gpt-oss-120b",
"deepseek-ai/deepseek-v4-pro-0813": "groq/openai/gpt-oss-120b",
"NVIDIA: DeepSeek V4 Pro": "groq/openai/gpt-oss-120b",
"nvidia/openai/gpt-oss-120b": "groq/openai/gpt-oss-120b",
"openai/gpt-oss-120b": "groq/openai/gpt-oss-120b",
"groq/llama-3.3-70b-versatile": "groq/openai/gpt-oss-120b",
"llama-3.3-70b-versatile": "groq/openai/gpt-oss-120b",
};
function resolveTargetModel(model) {
if (!model) return "groq/openai/gpt-oss-120b";
if (MODEL_ROUTING_MAP[model]) return MODEL_ROUTING_MAP[model];
const clean = model.replace(/^models\//, "").trim();
if (MODEL_ROUTING_MAP[clean]) return MODEL_ROUTING_MAP[clean];
for (const [k, v] of Object.entries(MODEL_ROUTING_MAP)) {
if (k.toLowerCase() === model.toLowerCase() || k.toLowerCase() === clean.toLowerCase()) {
return v;
}
}
if (
clean.includes("deepseek-v4-pro") ||
(clean.startsWith("nvidia") && clean.includes("gpt-oss-120b")) ||
clean.includes("llama-3.3-70b-versatile")
) {
return "groq/openai/gpt-oss-120b";
}
return clean;
}
const OMNIROUTE_BUILTIN_GROUPS = [
{
id: "auto/best-coding",
displayName: "Auto: Best Coding (OmniRoute)",
descriptionText:
"OmniRoute dynamic routing to the highest benchmark coding model available (Mistral Codestral)",
},
{
id: "auto/best-reasoning",
displayName: "Auto: Best Reasoning (OmniRoute)",
descriptionText:
"OmniRoute dynamic routing to the highest benchmark reasoning model available (Nemotron 3 Super 120B)",
},
{
id: "auto/best-fast",
displayName: "Auto: Best Fast (OmniRoute)",
descriptionText: "OmniRoute sub-second lowest latency high-throughput model (Groq LPUs)",
},
{
id: "auto/best-vision",
displayName: "Auto: Best Vision (OmniRoute)",
descriptionText: "OmniRoute multimodal & computer vision routing",
},
{
id: "auto/best-free",
displayName: "Auto: Best Free (OmniRoute)",
descriptionText: "OmniRoute 100% unmetered free tier model routing (Qwen 3.8 27B)",
},
{
id: "auto/coding:pro",
displayName: "Auto: Coding Pro (OmniRoute)",
descriptionText: "OmniRoute frontier pro-tier coding model (Codestral)",
},
{
id: "auto/coding:fast",
displayName: "Auto: Coding Fast (OmniRoute)",
descriptionText: "OmniRoute fast sub-second daily coding model (Groq 120B)",
},
{
id: "auto/coding:free",
displayName: "Auto: Coding Free (OmniRoute)",
descriptionText: "OmniRoute zero-cost free coding model",
},
{
id: "auto/coding:reliable",
displayName: "Auto: Coding Reliable (OmniRoute)",
descriptionText: "OmniRoute maximum uptime and reliability coding model",
},
{
id: "auto/reasoning:pro",
displayName: "Auto: Reasoning Pro (OmniRoute)",
descriptionText: "OmniRoute deep reasoning frontier model",
},
{
id: "auto/smart",
displayName: "Auto: Smart (OmniRoute)",
descriptionText: "OmniRoute highest intelligence general-purpose model",
},
{
id: "auto/claude-sonnet",
displayName: "Auto: Claude Sonnet (OmniRoute)",
descriptionText: "OmniRoute automated routing across Claude Sonnet providers",
},
{
id: "auto/claude-opus",
displayName: "Auto: Claude Opus (OmniRoute)",
descriptionText: "OmniRoute automated routing across Claude Opus providers",
},
{
id: "auto/gemini",
displayName: "Auto: Gemini (OmniRoute)",
descriptionText: "OmniRoute automated routing across Gemini providers",
},
{
id: "auto/llama",
displayName: "Auto: Llama (OmniRoute)",
descriptionText: "OmniRoute automated routing across Llama providers",
},
{
id: "auto/gemma",
displayName: "Auto: Gemma (OmniRoute)",
descriptionText: "OmniRoute automated routing across Gemma providers",
},
// Active, verified provider models
{
id: "groq/openai/gpt-oss-120b",
displayName: "Groq: GPT-OSS 120B (Ultra-Fast 0.02s)",
descriptionText: "Ultra-fast inference on Groq LPUs at sub-second speeds",
},
{
id: "groq/qwen/qwen3.8-27b",
displayName: "Groq: Qwen 3.8 27B",
descriptionText: "High-speed Qwen 3.8 27B model on Groq",
},
{
id: "mistral/codestral-latest",
displayName: "Mistral: Codestral Latest",
descriptionText: "Mistral flagship frontier code reasoning model",
},
{
id: "nvidia/nvidia/nemotron-3-super-120b-a12b",
displayName: "NVIDIA: Nemotron 3 Super 120B",
descriptionText: "Nemotron 3 Super 120B Deep Reasoning model on NVIDIA NIM",
},
{
id: "gemini/gemini-2.5-flash",
displayName: "Gemini: Gemini 2.5 Flash (AI Studio)",
descriptionText: "Google AI Studio direct Gemini 2.5 Flash route",
},
{
id: "gemini/gemini-2.5-pro",
displayName: "Gemini: Gemini 2.5 Pro (AI Studio)",
descriptionText: "Google AI Studio direct Gemini 2.5 Pro route",
},
];
const OMNIROUTE_CUSTOM_MODELS = new Set([
...OMNIROUTE_BUILTIN_GROUPS.map((g) => g.id),
...Object.keys(MODEL_ROUTING_MAP),
]);
function shouldInterceptToOmniRoute(model, url) {
if (!model) return false;
// Never intercept non-streaming unary RPCs (Antigravity expects raw JSON/Protobuf, not SSE)
const isStreaming =
url.includes("streamGenerateContent") ||
url.includes("StreamGenerateChat") ||
url.includes("alt=sse");
if (!isStreaming) return false;
// Never intercept native Google/Gemini models (used by Antigravity core, subagents, websearch, grounding)
if (model.startsWith("gemini-") || model.startsWith("models/gemini-")) {
return false;
}
// Never intercept native Google CloudCode PA hosted models
if (
model === "claude-sonnet-4-6" ||
model === "claude-opus-4-6" ||
model === "gpt-oss-120b-medium"
) {
return false;
}
// Intercept any OmniRoute auto group, provider model, or mapped alias
const clean = model.replace(/^models\//, "").trim();
if (
clean.startsWith("auto/") ||
clean.toLowerCase().includes("omniroute") ||
clean.includes("/") ||
OMNIROUTE_CUSTOM_MODELS.has(model) ||
OMNIROUTE_CUSTOM_MODELS.has(clean) ||
Boolean(MODEL_ROUTING_MAP[model]) ||
Boolean(MODEL_ROUTING_MAP[clean])
) {
return true;
}
return false;
}
const internalApp = http.createServer(async (req, res) => {
const host = (req.headers.host || "cloudcode-pa.googleapis.com").split(":")[0];
const url = req.url || "/";
// Collect request body
const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
}
const bodyBuffer = Buffer.concat(chunks);
let bodyJson = null;
if (bodyBuffer.length > 0) {
try {
bodyJson = JSON.parse(bodyBuffer.toString("utf-8"));
} catch {}
}
const model = extractModel(bodyJson, url);
const shouldIntercept = shouldInterceptToOmniRoute(model, url);
if (shouldIntercept) {
const resolvedModel = resolveTargetModel(model);
console.log(
`[Bridge] 🔀 INTERCEPTING -> OmniRoute: "${model || "default"}" => "${resolvedModel}" (${url})`
);
let outgoingBuffer = bodyBuffer;
if (bodyJson) {
const cloned = JSON.parse(JSON.stringify(bodyJson));
cloned.model = resolvedModel;
if (cloned.request && typeof cloned.request === "object") {
cloned.request.model = resolvedModel;
}
outgoingBuffer = Buffer.from(JSON.stringify(cloned), "utf-8");
}
// Forward to OmniRoute /v1/antigravity
try {
const forwardHeaders = {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(outgoingBuffer),
Authorization: `Bearer ${ROUTER_API_KEY}`,
"x-omniroute-source": "agent-bridge",
"x-omniroute-agent": "antigravity",
"x-omniroute-skip-usage": "true", // Skip usage tracking for default models
};
const upstreamReq = http.request(
ROUTER_URL,
{
method: "POST",
headers: forwardHeaders,
agent: httpAgent,
},
(upstreamRes) => {
res.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers);
upstreamRes.pipe(res);
}
);
upstreamReq.setNoDelay(true);
upstreamReq.on("error", (err) => {
console.error(`[Bridge] ❌ Error forwarding to OmniRoute: ${err.message}`);
if (!res.headersSent) {
res.writeHead(502, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: { message: `OmniRoute bridge error: ${err.message}` } }));
}
});
upstreamReq.write(outgoingBuffer);
upstreamReq.end();
return;
} catch (err) {
console.error(`[Bridge] ❌ Failed to invoke OmniRoute: ${err.message}`);
}
}
// Otherwise: Passthrough directly to Google upstream
console.log(`[Bridge] ⏩ PASSTHROUGH -> Google: ${model || "non-model"} (${url})`);
const upstreamHeaders = { ...req.headers };
delete upstreamHeaders["host"]; // Let https.request set the correct Host
upstreamHeaders["host"] = host;
if (url.includes("fetchAvailableModels")) {
delete upstreamHeaders["accept-encoding"];
}
const googleReq = https.request(
{
hostname: host,
port: 443,
path: url,
method: req.method,
headers: upstreamHeaders,
agent: httpsAgent,
},
(googleRes) => {
if (url.includes("fetchAvailableModels")) {
const respChunks = [];
googleRes.on("data", (chunk) => respChunks.push(chunk));
googleRes.on("end", () => {
const respBuffer = Buffer.concat(respChunks);
let finalBuffer = respBuffer;
try {
const data = JSON.parse(respBuffer.toString("utf-8"));
if (data && data.models) {
// Inject OmniRoute built-in auto groups and models
const baseTemplate =
data.models["claude-sonnet-4-6"] ||
data.models["gpt-oss-120b-medium"] ||
Object.values(data.models)[0] ||
{};
const injectedIds = [];
for (const group of OMNIROUTE_BUILTIN_GROUPS) {
data.models[group.id] = {
...baseTemplate,
id: group.id,
name: group.id,
displayName: group.displayName,
descriptionText: group.descriptionText,
};
injectedIds.push(group.id);
}
// Prepend OmniRoute groups to agentModelSorts recommended group
if (
Array.isArray(data.agentModelSorts) &&
data.agentModelSorts[0]?.groups?.[0]?.modelIds
) {
const existing = data.agentModelSorts[0].groups[0].modelIds;
data.agentModelSorts[0].groups[0].modelIds = [
...injectedIds,
...existing.filter((id) => !injectedIds.includes(id)),
];
}
finalBuffer = Buffer.from(JSON.stringify(data), "utf-8");
console.log(
`[Bridge] 🌟 Injected custom models into fetchAvailableModels (${finalBuffer.length} bytes)`
);
}
} catch (err) {
console.error(`[Bridge] ⚠️ Error modifying fetchAvailableModels: ${err.message}`);
}
const headers = { ...googleRes.headers };
delete headers["content-length"];
delete headers["content-encoding"];
headers["content-length"] = String(finalBuffer.length);
res.writeHead(googleRes.statusCode || 200, headers);
res.end(finalBuffer);
});
return;
}
res.writeHead(googleRes.statusCode || 200, googleRes.headers);
googleRes.pipe(res);
}
);
googleReq.setNoDelay(true);
googleReq.on("error", (err) => {
console.error(`[Bridge] ❌ Google upstream error: ${err.message}`);
if (!res.headersSent) {
res.writeHead(502, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: { message: `Google upstream error: ${err.message}` } }));
}
});
if (bodyBuffer.length > 0) {
googleReq.write(bodyBuffer);
}
googleReq.end();
});
internalApp.keepAliveTimeout = 65000;
internalApp.headersTimeout = 66000;
// Proxy server listening on HTTP port
const proxyServer = http.createServer((req, res) => {
// Plain HTTP request (non-CONNECT)
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("OmniRoute Antigravity Bridge Proxy Active\n");
});
proxyServer.keepAliveTimeout = 65000;
proxyServer.headersTimeout = 66000;
proxyServer.on("connect", (req, clientSocket, head) => {
clientSocket.setNoDelay(true);
const [targetHost, targetPortStr] = (req.url || "").split(":");
const targetPort = parseInt(targetPortStr || "443", 10);
if (TARGET_HOSTS.has(targetHost)) {
// Target host: Terminate TLS locally and route via internalApp
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
const ssl = getSslOptions();
const tlsSocket = new tls.TLSSocket(clientSocket, {
isServer: true,
key: ssl.key,
cert: ssl.cert,
});
tlsSocket.setNoDelay(true);
tlsSocket.on("error", (err) => {
// Client closed or TLS error
clientSocket.destroy();
});
internalApp.emit("connection", tlsSocket);
} else {
// Non-target host: Transparent raw TCP tunnel
const upstreamSocket = net.connect(targetPort, targetHost, () => {
upstreamSocket.setNoDelay(true);
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
if (head && head.length > 0) {
upstreamSocket.write(head);
}
upstreamSocket.pipe(clientSocket);
clientSocket.pipe(upstreamSocket);
});
const cleanup = () => {
clientSocket.destroy();
upstreamSocket.destroy();
};
upstreamSocket.on("error", cleanup);
clientSocket.on("error", cleanup);
}
});
export {
resolveTargetModel,
MODEL_ROUTING_MAP,
shouldInterceptToOmniRoute,
extractModel,
OMNIROUTE_BUILTIN_GROUPS,
proxyServer,
internalApp,
};
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
proxyServer.listen(PORT, "127.0.0.1", () => {
console.log(`🚀 OmniRoute Antigravity Bridge listening on 127.0.0.1:${PORT}`);
console.log(` Routing non-Gemini 3.8 model traffic -> ${ROUTER_URL}`);
console.log(` Preserving Gemini 3.8 native traffic -> Google`);
});
}

View File

@@ -79,8 +79,7 @@ export async function runChatCommand(promptArg, opts, cmd) {
const data = await response.json();
const text = extractText(data, opts.responsesApi);
// Commander stores `--no-history` as `history === false`, never as `noHistory`.
if (opts.history !== false && opts.noHistory !== true) {
if (!opts.noHistory) {
appendHistory({ prompt, model: opts.model, latencyMs, usage: data.usage, response: text });
}

View File

@@ -5,7 +5,6 @@ import { t } from "../i18n.mjs";
import { apiFetch } from "../api.mjs";
import { resolveDataDir } from "../data-dir.mjs";
import { listManifestTargets } from "../cli-manifest.mjs";
import { loadModelCatalog, ModelCommandError } from "./model-api.mjs";
// Target lists shared with `omniroute run` / `omniroute configure` — always
// derived from the canonical manifest so the completion scripts cannot drift.
@@ -31,14 +30,14 @@ function readCache() {
}
async function refreshCache(opts = {}) {
// Fail before replacing the cache when the selected catalog is unavailable.
const models = (await loadModelCatalog(opts)).map((model) => model.id);
let combos = [],
providers = [];
providers = [],
models = [];
try {
const [cr, pr] = await Promise.allSettled([
const [cr, pr, mr] = await Promise.allSettled([
apiFetch("/api/combos", opts),
apiFetch("/api/providers", opts),
apiFetch("/api/models", opts),
]);
if (cr.status === "fulfilled" && cr.value.ok) {
const j = await cr.value.json();
@@ -48,6 +47,10 @@ async function refreshCache(opts = {}) {
const j = await pr.value.json();
providers = (j.providers || j.items || []).map((p) => p.id || p.name).filter(Boolean);
}
if (mr.status === "fulfilled" && mr.value.ok) {
const j = await mr.value.json();
models = (Array.isArray(j) ? j : j.data || []).map((m) => m.id).filter(Boolean);
}
} catch (err) {
if (process.env.OMNIROUTE_DEBUG_COMPLETION) {
console.error("[omniroute completion] refreshCache failed:", err?.message ?? err);
@@ -79,12 +82,7 @@ function installPath(shell) {
return join(home, ".bash_completion.d", "omniroute");
}
function modelSubcommandWords(program) {
const models = program?.commands.find((command) => command.name() === "models");
return models?.commands.map((command) => command.name()).join(" ") || "";
}
function generateZshScript(modelCommands) {
function generateZshScript() {
return `#compdef omniroute
# OmniRoute zsh completion (dynamic)
@@ -181,7 +179,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)' ;;
models) _arguments '1:subcommand:(${modelCommands})' ;;
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})' ;;
@@ -207,7 +204,7 @@ compdef _omniroute omniroute
`;
}
function generateBashScript(modelCommands) {
function generateBashScript() {
return `#!/bin/bash
# OmniRoute CLI bash completion (dynamic)
@@ -238,7 +235,6 @@ _omniroute() {
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 ;;
config) COMPREPLY=($(compgen -W "list get set validate contexts" -- "\${cur}")); return 0 ;;
models) COMPREPLY=($(compgen -W "${modelCommands}" -- "\${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 ;;
@@ -266,7 +262,7 @@ complete -F _omniroute omniroute
`;
}
function generateFishScript(modelCommands) {
function generateFishScript() {
return `# OmniRoute CLI fish completion (dynamic)
complete -c omniroute -f
@@ -281,7 +277,6 @@ complete -c omniroute -n '__fish_seen_subcommand_from combo' -a 'list switch cre
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 config' -a 'list get set validate contexts'
complete -c omniroute -n '__fish_seen_subcommand_from models' -a '${modelCommands}'
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'
@@ -320,17 +315,17 @@ export function registerCompletion(program) {
comp
.command("zsh")
.description(t("completion.zsh") || "Print zsh completion script")
.action(async () => process.stdout.write(generateZshScript(modelSubcommandWords(program))));
.action(async () => process.stdout.write(generateZshScript()));
comp
.command("bash")
.description(t("completion.bash") || "Print bash completion script")
.action(async () => process.stdout.write(generateBashScript(modelSubcommandWords(program))));
.action(async () => process.stdout.write(generateBashScript()));
comp
.command("fish")
.description(t("completion.fish") || "Print fish completion script")
.action(async () => process.stdout.write(generateFishScript(modelSubcommandWords(program))));
.action(async () => process.stdout.write(generateFishScript()));
comp
.command("install [shell]")
@@ -344,7 +339,7 @@ export function registerCompletion(program) {
}
const dest = installPath(target);
mkdirSync(dirname(dest), { recursive: true });
writeFileSync(dest, gen(modelSubcommandWords(program)));
writeFileSync(dest, gen());
process.stdout.write(
`Installed ${target} completion at ${dest}\nRestart your shell or source the file.\n`
);
@@ -356,18 +351,7 @@ export function registerCompletion(program) {
.option("--quiet", "Suppress output")
.action(async (opts, cmd) => {
const globalOpts = cmd.optsWithGlobals();
let data;
try {
data = await refreshCache(globalOpts);
} catch (error) {
console.error(
error instanceof ModelCommandError
? error.message
: "Unable to refresh model completions."
);
process.exitCode = error.exitCode || 1;
return;
}
const data = await refreshCache(globalOpts);
if (!opts.quiet && !globalOpts.quiet) {
process.stdout.write(
`Cached: ${data.combos.length} combos, ${data.providers.length} providers, ${data.models.length} models\n`
@@ -386,17 +370,17 @@ export function registerCompletion(program) {
process.stderr.write(`Unknown shell: ${shell}. Valid: bash, zsh, fish\n`);
process.exit(1);
}
process.stdout.write(gen(modelSubcommandWords(program)));
process.stdout.write(gen());
});
}
// Legacy export for backward compatibility
export async function runCompletionCommand(shell, program) {
export async function runCompletionCommand(shell) {
const gen = generators[shell];
if (!gen) {
process.stderr.write(`Unknown shell: ${shell}. Valid: bash, zsh, fish\n`);
return 1;
}
process.stdout.write(gen(modelSubcommandWords(program)));
process.stdout.write(gen());
return 0;
}

View File

@@ -1,9 +1,7 @@
import { t } from "../i18n.mjs";
import { emit } from "../output.mjs";
import { writePrivateFileAtomic } from "../private-file.mjs";
import {
loadContexts,
loadContextsForExport,
saveContextsSecure,
deleteContextCredential,
migrateContextCredentials,
@@ -247,15 +245,14 @@ export function registerContexts(program) {
.command("export")
.description("Export contexts to JSON")
.option("--out <path>", "Output file path (default: stdout)")
.option("--no-secrets", "Omit credentials from export (safe default)")
.option("--include-secrets", "Explicitly include plaintext credentials in the export")
.option("--no-secrets", "Omit API keys from export")
.action(async (opts, cmd) => {
const includeSecrets = opts.includeSecrets === true && opts.secrets !== false;
const cfg = await loadContextsForExport({ includeSecrets });
const out = includeSecrets ? cfg : redactContextSecrets(cfg);
const cfg = loadContexts();
const out = opts.noSecrets ? redactContextSecrets(cfg) : JSON.parse(JSON.stringify(cfg));
const json = JSON.stringify(out, null, 2);
if (opts.out) {
writePrivateFileAtomic(opts.out, json);
const { writeFileSync } = await import("node:fs");
writeFileSync(opts.out, json);
process.stdout.write(`Exported to ${opts.out}\n`);
} else {
process.stdout.write(json + "\n");

View File

@@ -10,7 +10,6 @@ import { getCliToken, CLI_TOKEN_HEADER } from "../utils/cliToken.mjs";
import { printHeading } from "../io.mjs";
import { t } from "../i18n.mjs";
import { readDatabaseHealth, readEncryptedCredentialSamples } from "../sqlite.mjs";
import { getCrashLogPath } from "../runtime/processSupervisor.mjs";
const STATIC_SALT = "omniroute-field-encryption-v1";
const KEY_LENGTH = 32;
@@ -381,33 +380,6 @@ function checkMemory() {
});
}
// #13538: surfaces the supervisor's give-up crash record (persisted by
// ServerSupervisor.persistCrashLog(), bin/cli/runtime/processSupervisor.mjs)
// so a user whose `--tray` worker died silently (detached, stdio:"ignore")
// has something concrete `doctor` can point at without needing `--log`.
function checkCrashLog() {
const crashLogPath = getCrashLogPath();
if (!fs.existsSync(crashLogPath)) {
return ok("Crash log", "No supervisor crash record found", { crashLogPath });
}
try {
const stat = fs.statSync(crashLogPath);
const contents = fs.readFileSync(crashLogPath, "utf8");
const lastEntry = contents.split("\n").filter(Boolean).slice(-6).join("\n");
return warn(
"Crash log",
`Supervisor recorded a give-up crash at ${crashLogPath} (last modified ${stat.mtime.toISOString()})`,
{ crashLogPath, modifiedAt: stat.mtime.toISOString(), tail: lastEntry }
);
} catch (error) {
return warn("Crash log", `Crash record exists at ${crashLogPath} but could not be read`, {
crashLogPath,
error: error instanceof Error ? error.message : String(error),
});
}
}
async function fetchWithTimeout(url, options = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS);
@@ -607,7 +579,6 @@ export async function collectDoctorChecks(context = {}, options = {}) {
checks.push(await checkNodeRuntime(rootDir));
checks.push(await checkNativeBinary(rootDir));
checks.push(checkMemory());
checks.push(checkCrashLog());
if (!options.skipLiveness) {
checks.push(await checkServerLiveness(options));

View File

@@ -13,7 +13,6 @@ const OMNIROUTE_ENV_VARS = [
"OMNIROUTE_API_KEY",
"OMNIROUTE_BASE_URL",
"OMNIROUTE_HTTP_TIMEOUT_MS",
"OMNIROUTE_READY_TIMEOUT_MS",
];
const ENV_DEFAULTS = {

View File

@@ -352,24 +352,10 @@ export async function runKeysRegenerateCommand(id, opts = {}) {
return 1;
}
try {
const encodedId = encodeURIComponent(id);
let res = await apiFetch(`/api/v1/registered-keys/${encodedId}/regenerate`, {
const res = await apiFetch(`/api/v1/registered-keys/${encodeURIComponent(id)}/regenerate`, {
method: "POST",
retry: false,
acceptNotOk: true,
});
// `keys` predates the split between registered keys and the dashboard's
// ordinary API keys. IDs shown by `keys list`/the dashboard belong to
// `/api/keys`, while deployment/registered-key IDs belong to
// `/api/v1/registered-keys`. Try the ordinary-key route when the ID is not
// present in the registered-key store so the command works with either ID.
if (isRouteUnavailableStatus(res.status)) {
res = await apiFetch(`/api/keys/${encodedId}/regenerate`, {
method: "POST",
retry: false,
acceptNotOk: true,
});
}
if (!res.ok) {
console.error(t("common.error", { message: `HTTP ${res.status}` }));
return 1;
@@ -424,17 +410,9 @@ export async function runKeysRevealCommand(id, opts = {}) {
return 1;
}
try {
const encodedId = encodeURIComponent(id);
let res = await apiFetch(`/api/v1/registered-keys/${encodedId}/reveal`, {
const res = await apiFetch(`/api/v1/registered-keys/${encodeURIComponent(id)}/reveal`, {
retry: false,
acceptNotOk: true,
});
if (isRouteUnavailableStatus(res.status)) {
res = await apiFetch(`/api/keys/${encodedId}/reveal`, {
retry: false,
acceptNotOk: true,
});
}
if (!res.ok) {
console.error(t("common.error", { message: `HTTP ${res.status}` }));
return 1;

View File

@@ -9,8 +9,6 @@ function truncate(v, len = 60) {
return s.length > len ? s.slice(0, len - 1) + "…" : s;
}
const VALID_MCP_TRANSPORTS = ["stdio", "sse", "streamable-http"];
const mcpToolSchema = [
{ key: "name", header: "Tool", width: 36 },
{
@@ -45,25 +43,6 @@ export function registerMcp(program) {
if (exitCode !== 0) process.exit(exitCode);
});
mcp
.command("enable")
.description(t("mcp.enable.description"))
.option("--transport <transport>", t("mcp.enable.transport"))
.action(async (opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals();
const exitCode = await runMcpEnableCommand({ ...opts, output: globalOpts.output });
if (exitCode !== 0) process.exit(exitCode);
});
mcp
.command("disable")
.description(t("mcp.disable.description"))
.action(async (opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals();
const exitCode = await runMcpDisableCommand({ ...opts, output: globalOpts.output });
if (exitCode !== 0) process.exit(exitCode);
});
// 5.1 — mcp call + mcp scopes
mcp
.command("call <tool> [argsJson]")
@@ -82,15 +61,10 @@ export function registerMcp(program) {
? JSON.parse(argsPositional)
: {};
const exitCode = await runMcpCallCommand(
tool,
args,
{
...opts,
stream: opts.stream,
},
globalOpts
);
const exitCode = await runMcpCallCommand(tool, args, {
...opts,
stream: opts.stream,
}, globalOpts);
if (exitCode !== 0) process.exit(exitCode);
});
@@ -153,9 +127,7 @@ async function mcpJsonRpcCall(tool, args, { stream = false, globalOpts = {} } =
if (!initRes.ok) {
const text = await initRes.text().catch(() => "");
process.stderr.write(
`MCP initialize failed: HTTP ${initRes.status}${text ? `${text}` : ""}\n`
);
process.stderr.write(`MCP initialize failed: HTTP ${initRes.status}${text ? `${text}` : ""}\n`);
return 1;
}
@@ -255,7 +227,6 @@ export async function runMcpStatusCommand(opts = {}) {
});
if (!res.ok) {
console.log(t("mcp.stopped"));
console.log(t("mcp.stoppedHint"));
return 0;
}
@@ -269,9 +240,6 @@ export async function runMcpStatusCommand(opts = {}) {
const transport = status.transport || "stdio";
const online = status.online ?? status.running;
console.log(online ? t("mcp.running", { transport }) : t("mcp.stopped"));
if (!online && status.enabled === false) {
console.log(t("mcp.stoppedHint"));
}
if (status.toolsCount !== undefined) console.log(` Tools: ${status.toolsCount}`);
if (status.scopes?.length) {
console.log(" Scopes:");
@@ -302,76 +270,10 @@ export async function runMcpRestartCommand(opts = {}) {
console.log(t("mcp.restarted"));
return 0;
}
const body = await res.json().catch(() => null);
const message = body?.error || `HTTP ${res.status}`;
console.error(t("common.error", { message }));
console.error(t("common.error", { message: `HTTP ${res.status}` }));
return 1;
} catch (err) {
console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) }));
return 1;
}
}
export async function runMcpEnableCommand(opts = {}) {
const serverUp = await isServerUp();
if (!serverUp) {
console.error(t("common.serverOffline"));
return 1;
}
if (opts.transport && !VALID_MCP_TRANSPORTS.includes(opts.transport)) {
console.error(
t("common.error", {
message: `Invalid transport '${opts.transport}'. Valid: ${VALID_MCP_TRANSPORTS.join(", ")}`,
})
);
return 1;
}
try {
const body = { mcpEnabled: true };
if (opts.transport) body.mcpTransport = opts.transport;
const res = await apiFetch("/api/settings", {
method: "PATCH",
body,
retry: false,
acceptNotOk: true,
});
if (!res.ok) {
console.error(t("common.error", { message: `HTTP ${res.status}` }));
return 1;
}
console.log(t("mcp.enabled"));
return 0;
} catch (err) {
console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) }));
return 1;
}
}
export async function runMcpDisableCommand(opts = {}) {
const serverUp = await isServerUp();
if (!serverUp) {
console.error(t("common.serverOffline"));
return 1;
}
try {
const res = await apiFetch("/api/settings", {
method: "PATCH",
body: { mcpEnabled: false },
retry: false,
acceptNotOk: true,
});
if (!res.ok) {
console.error(t("common.error", { message: `HTTP ${res.status}` }));
return 1;
}
console.log(t("mcp.disabled"));
return 0;
} catch (err) {
console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) }));
return 1;
}
}

View File

@@ -1,131 +0,0 @@
import { apiFetch, statusToExitCode } from "../api.mjs";
export class ModelCommandError extends Error {
constructor(message, exitCode = 2) {
super(message);
this.exitCode = exitCode;
}
}
export async function modelRequest(path, opts, init = {}) {
let response;
try {
response = await apiFetch(path, {
...opts,
...init,
retry: false,
timeout: opts.timeout ?? 30000,
redirect: "error",
acceptNotOk: true,
});
} catch (error) {
throw new ModelCommandError(
"Unable to reach the selected OmniRoute server.",
error.exitCode === 124 ? 124 : 1
);
}
if (!response.ok) {
const error = new ModelCommandError(
`Model request failed (HTTP ${response.status}).`,
statusToExitCode(response.status)
);
error.status = response.status;
throw error;
}
try {
return await response.json();
} catch {
throw new ModelCommandError("The server returned an invalid model response.", 1);
}
}
export async function loadModelCatalog(opts = {}) {
let data;
try {
data = await modelRequest("/api/v1/models", opts);
} catch (error) {
if (![404, 405, 501].includes(error.status)) throw error;
data = await modelRequest("/api/models", opts);
}
const models = Array.isArray(data) ? data : (data?.data ?? data?.models);
if (!Array.isArray(models)) throw new ModelCommandError("Invalid model catalog.", 1);
return models.filter((model) => model && typeof model === "object").map(publicModel);
}
// Public model metadata only; never forward credentials or compatibility headers.
const PUBLIC_FIELDS = [
"id",
"name",
"object",
"created",
"owned_by",
"provider",
"description",
"source",
"context_length",
"contextWindow",
"contextWindowOverride",
"contextWindowOverrideSource",
"max_input_tokens",
"max_output_tokens",
"inputTokenLimit",
"outputTokenLimit",
"apiFormat",
"supportedEndpoints",
"targetFormat",
"supportsVision",
"supports_vision",
"supports_tools",
"supports_reasoning",
"modelType",
"isFree",
"dimensions",
"root",
"parent",
"type",
"free",
"custom",
"api_format",
"supported_endpoints",
"input_modalities",
"output_modalities",
"supported_parameters",
"supportedInputTypes",
];
export function publicModel(model) {
const result = {};
for (const field of PUBLIC_FIELDS) {
const value = model?.[field];
if (value === null || ["string", "number", "boolean"].includes(typeof value))
result[field] = value;
else if (Array.isArray(value) && value.every((item) => typeof item === "string"))
result[field] = value;
}
if (model?.capabilities && typeof model.capabilities === "object") {
result.capabilities = Object.fromEntries(
Object.entries(model.capabilities).filter(
([key, value]) =>
[
"vision",
"reasoning",
"tool_calling",
"structured_output",
"streaming",
"audio",
"video",
].includes(key) && typeof value === "boolean"
)
);
}
result.id = String(model?.id || model?.name || "unknown");
result.provider = String(model?.provider || model?.owned_by || "unknown");
result.contextWindow =
result.contextWindowOverride ??
result.context_length ??
result.max_input_tokens ??
result.inputTokenLimit ??
result.contextWindow ??
"-";
return result;
}

View File

@@ -1,174 +0,0 @@
import { z } from "zod";
import { emit } from "../output.mjs";
import { ModelCommandError, modelRequest, publicModel } from "./model-api.mjs";
// Runtime CLI subset of providerModelMutationSchema. The API validates again.
const identity = z.object({
provider: z.string().trim().min(1).max(120),
modelId: z.string().trim().min(1).max(240),
});
const patchSchema = z.object({
modelName: z.string().trim().min(1).max(240).optional(),
apiFormat: z
.enum([
"chat-completions",
"responses",
"embeddings",
"rerank",
"audio-transcriptions",
"audio-speech",
"images-generations",
"video",
])
.optional(),
max_input_tokens: z.number().int().positive().safe().optional(),
max_output_tokens: z.number().int().positive().safe().optional(),
contextWindowOverride: z.number().int().positive().safe().nullable().optional(),
});
const FORMAT_ENDPOINT = {
"chat-completions": "chat",
responses: "chat",
embeddings: "embeddings",
rerank: "rerank",
"audio-transcriptions": "audio-transcriptions",
"audio-speech": "audio-speech",
"images-generations": "images",
video: "videos",
};
function payloadFor(action, provider, modelId, opts) {
const id = identity.safeParse({ provider, modelId });
const patch = {};
if (opts.name !== undefined) patch.modelName = opts.name;
if (opts.apiFormat !== undefined) patch.apiFormat = opts.apiFormat;
if (opts.contextWindow !== undefined) {
patch[action === "add" ? "max_input_tokens" : "contextWindowOverride"] = Number(
opts.contextWindow
);
}
if (opts.clearContextWindow) {
if (opts.contextWindow !== undefined || action !== "edit")
throw new ModelCommandError("Invalid context-window options.");
patch.contextWindowOverride = null;
}
if (opts.maxOutputTokens !== undefined) patch.max_output_tokens = Number(opts.maxOutputTokens);
const parsed = patchSchema.safeParse(patch);
if (!id.success || !parsed.success)
throw new ModelCommandError("Invalid model identifier or metadata.");
if (action === "edit" && Object.keys(patch).length === 0)
throw new ModelCommandError("Provide at least one metadata change.");
return {
...id.data,
...parsed.data,
...(parsed.data.apiFormat
? { supportedEndpoints: [FORMAT_ENDPOINT[parsed.data.apiFormat]] }
: {}),
...(action === "add" ? { source: "manual" } : {}),
};
}
export async function listManualModels(provider, opts = {}) {
const parsed = identity.shape.provider.safeParse(provider);
if (!parsed.success) throw new ModelCommandError("Invalid provider identifier.");
const data = await modelRequest(
`/api/provider-models?${new URLSearchParams({ provider: parsed.data })}`,
opts
);
if (!Array.isArray(data?.models)) throw new ModelCommandError("Invalid manual model catalog.", 1);
return data.models;
}
export async function modifyManualModel(action, provider, modelId, opts = {}) {
if (!["add", "edit", "remove"].includes(action))
throw new ModelCommandError("Invalid model operation.");
if (action === "remove" && !opts.yes && !opts.dryRun)
throw new ModelCommandError("Removal requires --yes (or --dry-run).");
const body = payloadFor(action, provider, modelId, opts);
const before = (await listManualModels(body.provider, opts)).find(
(model) => model.id === body.modelId
);
if (action === "add" && before)
throw new ModelCommandError("The custom model already exists; use edit.");
if (action !== "add" && (!before || (before.source && before.source !== "manual"))) {
throw new ModelCommandError("The selected model is not an existing manual model.");
}
if (opts.dryRun)
return { action, dryRun: true, provider: body.provider, modelId: body.modelId, changes: body };
const query = new URLSearchParams({
provider: body.provider,
model: body.modelId,
resetOverride: "true",
});
await modelRequest(
action === "remove" ? `/api/provider-models?${query}` : "/api/provider-models",
opts,
{
method: { add: "POST", edit: "PUT", remove: "DELETE" }[action],
...(action === "remove" ? {} : { body }),
}
);
const after = (await listManualModels(body.provider, opts)).find(
(model) => model.id === body.modelId
);
const mapping = {
modelName: "name",
max_input_tokens: "inputTokenLimit",
max_output_tokens: "outputTokenLimit",
};
const mismatch =
action === "remove"
? Boolean(after)
: !after ||
Object.entries(body).some(([key, value]) => {
if (["provider", "modelId"].includes(key)) return false;
const actual = after[mapping[key] || key];
if (Array.isArray(value)) return JSON.stringify(actual) !== JSON.stringify(value);
return value === null ? actual != null : actual !== value;
});
if (mismatch)
throw new ModelCommandError(
"Model readback did not confirm the requested change; inspect the server before retrying.",
1
);
return {
action,
persistenceVerified: true,
inferenceValidation: "not-run",
provider: body.provider,
modelId: body.modelId,
...(after ? { model: publicModel({ ...after, provider: body.provider }) } : {}),
};
}
export function modelMutationAction(action) {
return async (provider, modelId, options, cmd) => {
try {
const opts = { ...cmd.optsWithGlobals(), ...options };
emit(await modifyManualModel(action, provider, modelId, opts), {
...opts,
output: opts.output || "json",
});
} catch (error) {
console.error(error instanceof ModelCommandError ? error.message : "Model operation failed.");
process.exitCode = error.exitCode || 1;
}
};
}
export async function manualListAction(provider, options, cmd) {
const opts = { ...cmd.optsWithGlobals(), ...options };
try {
emit(
(await listManualModels(provider, opts))
.filter((model) => !model.source || model.source === "manual")
.map((model) => publicModel({ ...model, provider })),
{ ...opts, output: opts.output || "json" }
);
} catch (error) {
console.error(
error instanceof ModelCommandError ? error.message : "Unable to list manual models."
);
process.exitCode = error.exitCode || 1;
}
}

View File

@@ -1,75 +1,92 @@
import { apiFetch, isServerUp } from "../api.mjs";
import { emit } from "../output.mjs";
import { modelListSchema } from "../schemas/output-schemas.mjs";
import { t } from "../i18n.mjs";
import { loadModelCatalog } from "./model-api.mjs";
import { manualListAction, modelMutationAction } from "./model-crud.mjs";
export function registerModels(program) {
const models = program
program
.command("models [provider]")
.description(t("models.description"))
.option("--search <query>", t("models.search"))
.option("--json", "Output as JSON")
.action(async (provider, opts, cmd) => {
process.exitCode = await runModelsCommand(provider, { ...cmd.optsWithGlobals(), ...opts });
const globalOpts = cmd.optsWithGlobals();
const exitCode = await runModelsCommand(provider, { ...opts, output: globalOpts.output });
if (exitCode !== 0) process.exit(exitCode);
});
models
.command("manual <provider>")
.description("List manual model metadata from the selected server")
.action(manualListAction);
models
.command("add <provider> <model-id>")
.description("Add an unverified manual model, then verify persistence")
.option("--name <name>", "Display name")
.option("--api-format <format>", "API format, e.g. chat-completions or responses")
.option("--context-window <tokens>", "Positive integer input/context limit")
.option("--max-output-tokens <tokens>", "Positive integer output limit")
.option("--dry-run", "Preview without writing or inference")
.action(modelMutationAction("add"));
models
.command("edit <provider> <model-id>")
.description("Edit manual model metadata, then verify persistence")
.option("--name <name>", "Display name")
.option("--api-format <format>", "API format, e.g. chat-completions or responses")
.option("--context-window <tokens>", "Positive integer context override")
.option("--clear-context-window", "Clear the manual context-window override")
.option("--dry-run", "Preview without writing or inference")
.action(modelMutationAction("edit"));
models
.command("remove <provider> <model-id>")
.description("Remove only a manual model override, then verify persistence")
.option("--yes", "Confirm removal of the manual override only")
.option("--dry-run", "Preview without writing or inference")
.action(modelMutationAction("remove"));
}
export async function runModelsCommand(provider, opts = {}) {
try {
let models = await loadModelCatalog(opts);
if (provider) {
const filter = provider.toLowerCase();
models = models.filter(
(model) =>
model.provider.toLowerCase().includes(filter) || model.id.toLowerCase().startsWith(filter)
);
}
if (opts.search) {
const search = opts.search.toLowerCase();
models = models.filter((model) =>
[model.id, model.name, model.provider, model.description].some((value) =>
String(value || "")
.toLowerCase()
.includes(search)
)
);
}
const table = opts.output === "table" || (!opts.output && !opts.json && process.stdout.isTTY);
emit(table ? models.slice(0, 50) : models, opts, modelListSchema);
if (table && models.length > 50)
console.log(`... and ${models.length - 50} more. Use --output json for the full list.`);
return 0;
} catch (error) {
console.error(error.exitCode ? error.message : "Unable to read the model catalog.");
return error.exitCode || 1;
const serverUp = await isServerUp();
if (!serverUp) {
console.error(t("models.noServer"));
return 1;
}
let models = [];
try {
const res = await apiFetch("/api/models", { retry: false, timeout: 5000, acceptNotOk: true });
if (res.ok) {
const data = await res.json();
models = Array.isArray(data) ? data : data.models || [];
}
} catch {}
if (models.length === 0) {
try {
const res = await apiFetch("/api/v1/models", {
retry: false,
timeout: 5000,
acceptNotOk: true,
});
if (res.ok) {
const data = await res.json();
models = Array.isArray(data) ? data : data.data || [];
}
} catch {}
}
if (provider) {
const filter = provider.toLowerCase();
models = models.filter(
(m) =>
(m.provider && m.provider.toLowerCase().includes(filter)) ||
(m.id && m.id.toLowerCase().startsWith(filter)) ||
(m.name && m.name.toLowerCase().includes(filter))
);
}
if (opts.search) {
const search = opts.search.toLowerCase();
models = models.filter(
(m) =>
(m.id && m.id.toLowerCase().includes(search)) ||
(m.name && m.name.toLowerCase().includes(search)) ||
(m.provider && m.provider.toLowerCase().includes(search)) ||
(m.description && m.description.toLowerCase().includes(search))
);
}
if (models.length === 0) {
console.log(t("models.noModels"));
return 0;
}
const normalized = models.map((m) => ({
id: m.id || m.name || "unknown",
provider: m.provider || "unknown",
contextWindow: String(m.context_length || m.max_tokens || m.contextWindow || "-"),
}));
const display = normalized.slice(0, 50);
emit(display, opts, modelListSchema);
if (models.length > 50) {
console.log(
`\x1b[2m ... and ${models.length - 50} more. Use --output json for full list.\x1b[0m`
);
}
return 0;
}

View File

@@ -23,13 +23,8 @@ const PROVIDERS_WITH_OAUTH = [
// the device-flow request to /api/providers/command-code/auth/start, which is
// gated by requireManagementAuth and returned 401 for a fresh CLI context
// (issue #9474). Map the alias to the real backend key instead.
//
// `copilot` has the same mismatch (#14298): the GitHub Copilot device flow is
// registered under the backend key `github`, so posting to
// /api/oauth/copilot/device-code failed for an unknown provider.
const BACKEND_OAUTH_KEY = {
"claude-code": "claude",
copilot: "github",
};
function resolveBackendKey(id) {
@@ -59,34 +54,6 @@ async function openBrowser(url) {
}
}
// Mirrors src/lib/oauth/providers.ts::isLoopbackHostname — used here to detect
// when the redirect_uri the server resolved (and the authorize URL now
// advertises) points at a loopback address the CLI never binds a listener on
// (issue #12413). Returns false on an unparseable URI rather than throwing.
function isLoopbackHost(uri) {
try {
return /^(localhost|127\.0\.0\.1|\[::1\]|::1)$/i.test(new URL(uri).hostname);
} catch {
return false;
}
}
function printLoopbackRedirectWarning(providerId, redirectUri) {
process.stdout.write(
`Note: the authorize URL below advertises ${redirectUri}, but this CLI does not\n` +
"listen on that port. Right after you approve, the browser is expected to\n" +
'show a connection error (e.g. "This site can\'t be reached" / \n' +
"ERR_CONNECTION_REFUSED) — that is normal, not a failure. Copy the full URL\n" +
"from the address bar anyway and paste it below.\n"
);
if (providerId === "antigravity") {
process.stdout.write(
"Tip: `omniroute login antigravity` captures the code automatically and\n" +
"avoids that error page entirely.\n"
);
}
}
function targetApiOptions(opts = {}) {
return {
baseUrl: opts.baseUrl,
@@ -143,10 +110,6 @@ async function runBrowserFlow(def, opts) {
const { codeVerifier, state, redirectUri: returnedRedirectUri } = start;
const finalRedirectUri = returnedRedirectUri || redirectUri;
if (finalRedirectUri && isLoopbackHost(finalRedirectUri)) {
printLoopbackRedirectWarning(def.id, finalRedirectUri);
}
process.stdout.write(`\nOpen this URL to authorize:\n ${url}\n\n`);
if (opts.browser !== false) await openBrowser(url);
process.stdout.write(
@@ -297,51 +260,27 @@ async function runDeviceFlow(def, opts) {
if (opts.browser !== false && verificationUri) await openBrowser(verificationUri);
process.stderr.write("Waiting for device authorization...\n");
// Poll the real device-flow route: POST /api/oauth/{key}/poll with the device
// code (#14298). The previous implementation polled
// GET /api/providers/{key}/auth/status?state=… and then POST …/auth/apply,
// but neither route exists on the server, and the device-code response has no
// `state` field at all — so the CLI looped until its timeout even after the
// user authorized. /api/oauth/{key}/poll is the same route the dashboard
// polls (src/shared/components/OAuthModal.tsx::pollDeviceCodeOnce) and it
// persists the connection server-side on success, so no separate apply step
// is needed.
const deviceCode = start.deviceCode ?? start.device_code ?? "";
if (!deviceCode) {
process.stderr.write("Server did not return a device code; cannot poll for authorization.\n");
process.exit(1);
}
const codeVerifier = start.codeVerifier ?? undefined;
const deadline = Date.now() + (opts.timeout ?? 300000);
let intervalMs = (start.intervalMs ?? start.interval ?? 5) * 1000;
const intervalMs = (start.intervalMs ?? start.interval ?? 5) * 1000;
while (Date.now() < deadline) {
await sleep(intervalMs);
const pollRes = await apiFetch(`/api/oauth/${providerKey}/poll`, {
...targetApiOptions(opts),
method: "POST",
body: { deviceCode, ...(codeVerifier ? { codeVerifier } : {}) },
});
if (!pollRes.ok) continue;
let poll;
try {
poll = await pollRes.json();
} catch {
continue;
}
if (poll.success) {
const conn = poll.connection ?? {};
process.stdout.write(
`Authorized: ${conn.email ?? conn.displayName ?? conn.id ?? "connected"}\n`
);
const statusRes = await apiFetch(
`/api/providers/${providerKey}/auth/status?state=${encodeURIComponent(start.state ?? "")}`,
targetApiOptions(opts)
);
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 },
});
process.stdout.write(`Authorized: ${status.account ?? status.email ?? "connected"}\n`);
return;
}
if (poll.error === "slow_down") {
// OAuth device-flow spec: back off by 5s on slow_down.
intervalMs += 5000;
continue;
}
if (poll.error && !poll.pending) {
process.stderr.write(`Device auth failed: ${poll.errorDescription ?? poll.error}\n`);
if (status.status === "error") {
process.stderr.write(`Device auth failed: ${status.error}\n`);
process.exit(1);
}
}

View File

@@ -15,17 +15,8 @@ function credentialShape(value) {
return { present: true, length: String(value).length };
}
const SENSITIVE_FIELD_SUFFIX_RE =
/(?:^|_)(?:api_?key|access_key|secret_access_key|access_?token|refresh_?token|id_?token|auth_token|token|password|passphrase|secret|secret_key|secret_value|client_?secret|credential|authorization|private_key)$/;
function isSensitiveFieldName(key) {
const normalized = String(key || "")
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
.replace(/[^A-Za-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.toLowerCase();
return SENSITIVE_FIELD_SUFFIX_RE.test(normalized);
}
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.
@@ -36,7 +27,7 @@ function isSensitiveFieldName(key) {
* for diagnostics; the value itself must never be printed.
*/
export function redactProviderResponse(value, key = "") {
if (isSensitiveFieldName(key)) {
if (SENSITIVE_FIELD_RE.test(key)) {
if (value === null || value === undefined || value === "") return null;
return typeof value === "string" ? credentialShape(value) : "[redacted]";
}
@@ -67,31 +58,17 @@ export function findConnectionFromResponse(body, selector) {
.trim()
.toLowerCase();
if (!needle) return null;
const selectUnique = (matches) => {
if (matches.length === 0) return null;
if (matches.length === 1) return matches[0];
const candidates = matches.map((row) => String(row?.id || "<missing-id>")).join(", ");
throw new Error(`Provider connection selector '${selector}' is ambiguous: ${candidates}`);
};
const exactId = selectUnique(
rows.filter((row) => String(row?.id || "").toLowerCase() === needle)
);
if (exactId) return exactId;
const idPrefix = selectUnique(
rows.filter((row) =>
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
);
if (idPrefix) return idPrefix;
const exactName = selectUnique(
rows.filter((row) => String(row?.name || "").toLowerCase() === needle)
);
if (exactName) return exactName;
return selectUnique(rows.filter((row) => String(row?.provider || "").toLowerCase() === needle));
}
/** Build the API body without accepting management auth as a provider secret. */
@@ -202,48 +179,6 @@ async function resolveRemoteConnection(selector, opts) {
return connection;
}
async function readRemoteConnectionById(id, opts) {
const response = await apiFetch(`/api/providers/${encodeURIComponent(id)}`, {
...targetOptions(opts),
acceptNotOk: true,
retry: false,
});
if (!response.ok)
throw new Error(`Provider mutation read-back failed: ${await readApiError(response)}`);
const body = await response.json().catch(() => ({}));
const connection = body?.connection;
if (!connection || String(connection.id || "") !== String(id)) {
throw new Error("Provider mutation read-back returned an unexpected connection.");
}
return connection;
}
async function confirmRemoteConnectionRemoved(id, opts) {
const response = await apiFetch(`/api/providers/${encodeURIComponent(id)}`, {
...targetOptions(opts),
acceptNotOk: true,
retry: false,
});
if (response.status === 404) return;
if (!response.ok) {
throw new Error(`Provider removal read-back failed: ${await readApiError(response)}`);
}
const body = await response.json().catch(() => ({}));
if (body?.connection) {
throw new Error("Provider removal read-back found the connection still present.");
}
throw new Error("Provider removal read-back returned an unexpected success response.");
}
function verifyConnectionFields(connection, expected) {
for (const [field, value] of Object.entries(expected)) {
if (value !== undefined && connection?.[field] !== value) {
throw new Error(`Provider mutation read-back did not persist field '${field}'.`);
}
}
return connection;
}
export async function runProviderAddCommand(provider, opts = {}) {
const normalized = String(provider || "").trim();
if (!normalized) {
@@ -306,18 +241,9 @@ export async function runProviderAddCommand(provider, opts = {}) {
return statusToExitCode(response.status);
}
const body = await response.json().catch(() => ({}));
const created = body?.connection;
if (!created?.id) throw new Error("Provider create response did not include a connection id.");
const verified = verifyConnectionFields(await readRemoteConnectionById(created.id, opts), {
provider: payload.provider,
name: payload.name,
defaultModel: payload.defaultModel,
priority: payload.priority,
});
if (!opts.silent) {
if (opts.json) {
console.log(JSON.stringify(redactProviderResponse({ connection: verified }), null, 2));
} else printSuccess(`Added provider connection '${verified.name || payload.name}'.`);
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) {
@@ -345,29 +271,6 @@ export async function runProviderImportCommand(file, opts = {}) {
printError("Provider import file contains no entries.");
return 2;
}
const existingByProviderAndName = new Map();
if (!opts.dryRun) {
try {
const response = await listRemoteConnections(opts);
if (!response.ok) {
printError(await readApiError(response));
return statusToExitCode(response.status);
}
const body = await response.json().catch(() => ({}));
const connections = Array.isArray(body?.connections) ? body.connections : [];
for (const connection of connections) {
const key = `${String(connection?.provider || "").toLowerCase()}\0${String(
connection?.name || connection?.provider || ""
).toLowerCase()}`;
if (!existingByProviderAndName.has(key)) {
existingByProviderAndName.set(key, connection);
}
}
} catch (error) {
printError(error instanceof Error ? error.message : String(error));
return 1;
}
}
const results = [];
for (const entry of entries) {
if (!entry || typeof entry !== "object" || !entry.provider) {
@@ -375,48 +278,16 @@ export async function runProviderImportCommand(file, opts = {}) {
if (!opts.continueOnError) break;
continue;
}
const provider = String(entry.provider).trim();
const name = String(entry.name || provider).trim();
const identityKey = `${provider.toLowerCase()}\0${name.toLowerCase()}`;
const existing = existingByProviderAndName.get(identityKey);
if (existing) {
const result = {
provider,
name,
ok: true,
status: "skipped_existing",
connectionId: existing.id,
};
results.push(result);
if (!opts.json) printInfo(`Skipped existing provider connection '${name}'.`);
continue;
}
// Import files contain provider data, never command/control-plane options.
// Keep the management target, context and authentication exclusively from
// the CLI invocation so an imported document cannot redirect credentials.
const importedProviderOptions = {
name: entry.name,
defaultModel: entry.defaultModel,
priority: entry.priority,
providerSpecificData: entry.providerSpecificData,
credential: entry.apiKey ?? entry.credential,
allowNoCredential: entry.allowNoCredential ?? opts.allowNoCredential,
};
const code = await runProviderAddCommand(provider, {
const code = await runProviderAddCommand(entry.provider, {
...opts,
...importedProviderOptions,
...entry,
credential: entry.apiKey ?? entry.credential,
dryRun: opts.dryRun,
yes: true,
silent: true,
allowNoCredential: entry.allowNoCredential ?? opts.allowNoCredential,
});
results.push({
provider,
name,
ok: code === 0,
code,
status: code === 0 ? "created" : "error",
});
if (code === 0) existingByProviderAndName.set(identityKey, { provider, name });
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));
@@ -469,7 +340,6 @@ export async function runProviderRemoveCommand(selector, opts = {}) {
printError(await readApiError(response));
return statusToExitCode(response.status);
}
await confirmRemoteConnectionRemoved(connection.id, opts);
if (opts.json)
console.log(JSON.stringify(redactProviderResponse({ removed: connection }), null, 2));
else printSuccess(`Removed provider connection '${connection.name || connection.id}'.`);
@@ -481,24 +351,12 @@ export async function runProviderRemoveCommand(selector, opts = {}) {
}
export async function runProviderEditCommand(selector, opts = {}) {
if (opts.active === true && opts.inactive === true) {
printError("--active and --inactive cannot be used together.");
return 2;
}
let parsedPriority;
if (opts.priority !== undefined) {
parsedPriority = Number(opts.priority);
if (!Number.isInteger(parsedPriority) || parsedPriority < 1) {
printError("--priority must be a positive integer.");
return 2;
}
}
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 (parsedPriority !== undefined) body.priority = parsedPriority;
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 });
@@ -530,16 +388,9 @@ export async function runProviderEditCommand(selector, opts = {}) {
printError(await readApiError(response));
return statusToExitCode(response.status);
}
await response.json().catch(() => ({}));
const expected = { ...body };
delete expected.apiKey;
const verified = verifyConnectionFields(
await readRemoteConnectionById(connection.id, opts),
expected
);
if (opts.json) {
console.log(JSON.stringify(redactProviderResponse({ connection: verified }), null, 2));
} else printSuccess(`Updated provider connection '${connection.name || connection.id}'.`);
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));

View File

@@ -6,8 +6,7 @@ export function registerRestart(program) {
program
.command("restart")
.description(t("restart.description"))
// No Commander default: runServe() falls back to PORT, then 20128 (#7049).
.option("--port <port>", t("serve.port"))
.option("--port <port>", t("serve.port"), "20128")
.action(async (opts) => {
const exitCode = await runRestartCommand(opts);
if (exitCode !== 0) process.exit(exitCode);

View File

@@ -4,13 +4,7 @@ import { join, dirname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { platform, totalmem } from "node:os";
import { t } from "../i18n.mjs";
import {
writePidFile,
cleanupPidFile,
waitForServer,
findListeningPids,
resolveReadyTimeoutMs,
} from "../utils/pid.mjs";
import { writePidFile, cleanupPidFile, waitForServer } from "../utils/pid.mjs";
import {
ServerSupervisor,
detectMitmCrash,
@@ -64,11 +58,6 @@ export function registerServe(program) {
.option("--max-restarts <n>", t("serve.max_restarts"), parseInt, 2)
.option("--tray", t("serve.tray") || "Start in the system tray (desktop only)")
.option("--no-tray", t("serve.no_tray") || "Disable system tray icon")
.option(
"--ready-timeout <ms>",
t("serve.ready_timeout") ||
"Readiness probe timeout in ms (also OMNIROUTE_READY_TIMEOUT_MS, default 60000)"
)
.option(
"--tls-cert <path>",
t("serve.tls_cert") ||
@@ -241,16 +230,6 @@ export async function runServe(opts = {}) {
process.exit(1);
}
// Refuse to start a second instance on a port something else already owns,
// BEFORE any pid file is written or any child is spawned. Otherwise the
// doomed child's EADDRINUSE arrives only after this process has rewritten
// the pid files of the healthy instance that actually owns the port.
const busyPids = await findListeningPids(dashboardPort);
if (busyPids.length > 0) {
reportPortInUse(dashboardPort, busyPids);
process.exit(1);
}
console.log(` \x1b[2m⏳ Starting server...\x1b[0m\n`);
// #5172/#5160/#5152: default the V8 heap to ~35% of physical RAM (clamped
@@ -297,8 +276,7 @@ export async function runServe(opts = {}) {
return runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort);
}
// Commander stores `--no-recovery` as `recovery === false`, never as `noRecovery`.
if (opts.recovery === false || opts.noRecovery === true) {
if (opts.noRecovery) {
return runWithoutRecovery(
serverJs,
env,
@@ -321,29 +299,10 @@ export async function runServe(opts = {}) {
opts.maxRestarts ?? 2,
startedAt,
useTray,
{
trayReadyPort: opts.trayReadyPort,
trayReadyToken: opts.trayReadyToken,
readyTimeoutMs: resolveReadyTimeoutMs({ timeoutMs: opts.readyTimeout }),
}
{ trayReadyPort: opts.trayReadyPort, trayReadyToken: opts.trayReadyToken }
);
}
/**
* Explain a port conflict in terms the operator can act on: who owns the port,
* and the two ways out. Exported for unit tests.
*/
export function reportPortInUse(port, pids = []) {
const owner = pids.length === 1 ? `PID ${pids[0]}` : `PIDs ${pids.join(", ")}`;
console.error(`\n\x1b[31m✖ Port ${port} is already in use by ${owner}.\x1b[0m`);
console.error(
` Another OmniRoute is most likely already serving there, so open` +
` ${urlScheme}://localhost:${port} before starting a second one.`
);
console.error(` To replace it: \x1b[36momniroute stop\x1b[0m, then start again`);
console.error(` To run alongside: \x1b[36momniroute serve --port <other-port>\x1b[0m\n`);
}
function runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort) {
// #5238: skip the explicit CLI --max-old-space-size when the user pinned the
// heap via NODE_OPTIONS (a CLI arg would shadow/override their value).
@@ -454,7 +413,7 @@ async function runWithSupervisor(
maxRestarts,
startedAt,
useTray = false,
{ trayReadyPort, trayReadyToken, readyTimeoutMs = resolveReadyTimeoutMs() } = {}
{ trayReadyPort, trayReadyToken } = {}
) {
if (showLog) process.env.OMNIROUTE_SHOW_LOG = "1";
writePidFile("supervisor", process.pid);
@@ -493,12 +452,7 @@ async function runWithSupervisor(
});
if (!showLog) {
let lastProbeOutcome = null;
waitForServer(dashboardPort, readyTimeoutMs, {
onOutcome: (outcome) => {
lastProbeOutcome = outcome;
},
}).then(async (up) => {
waitForServer(dashboardPort, 60000).then(async (up) => {
if (up) {
if (useTray) {
const trayReady = await maybeStartTray(dashboardPort, apiPort, supervisor);
@@ -522,7 +476,7 @@ async function runWithSupervisor(
}
onReady(dashboardPort, apiPort, noOpen, startedAt);
} else {
reportReadinessTimeout(dashboardPort, supervisor, lastProbeOutcome);
reportReadinessTimeout(dashboardPort, supervisor);
}
});
}
@@ -534,31 +488,11 @@ async function runWithSupervisor(
// stuck (issue reports show the server sometimes actually comes up later, or is
// reachable directly while the CLI still looks hung). Surface a clear diagnostic
// plus whatever stdout/stderr the child buffered instead of going silent.
export function reportReadinessTimeout(dashboardPort, supervisor, lastProbeOutcome = null) {
const readyTimeoutMs = resolveReadyTimeoutMs();
const seconds = Math.round(readyTimeoutMs / 1000);
export function reportReadinessTimeout(dashboardPort, supervisor) {
console.error(
`\n\x1b[33m⚠ Server did not respond within ${seconds}s.\x1b[0m It may still be starting, or may` +
`\n\x1b[33m⚠ Server did not respond within 60s.\x1b[0m It may still be starting, or may` +
` have failed silently.`
);
// The last probe classification separates a real boot failure (nothing ever
// bound the port, so the buffered output below is the reason) from a server
// that IS listening and merely did not answer the health route in time:
// very likely usable already, with only the readiness signal timed out.
if (lastProbeOutcome === "hanging" || lastProbeOutcome === "fast-reject") {
console.error(
` Port ${dashboardPort} IS accepting connections, so the server is probably up and` +
` still warming up. Check the dashboard before restarting it.`
);
} else if (lastProbeOutcome === "not-listening") {
console.error(
` Nothing is listening on port ${dashboardPort}, so the server never bound it and the` +
` output below is the reason.`
);
}
console.error(
` Tip: set OMNIROUTE_READY_TIMEOUT_MS=${readyTimeoutMs * 2} or --ready-timeout ${readyTimeoutMs * 2} for slower cold starts.`
);
console.error(` Try: curl -I http://localhost:${dashboardPort}/api/monitoring/health`);
console.error(` Or: rerun with \x1b[36m--log\x1b[0m to see live server output.\n`);

View File

@@ -35,24 +35,16 @@ export function resolveOpencodeTarget(opts = {}) {
baseUrl = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
}
// Precedence: explicit --api-key flag > OMNIROUTE_API_KEY env var > active
// context's management token. A context's accessToken/apiKey is a CLI
// management credential (oma_live_...) with no /v1/* inference scope — it
// must never silently outrank a real inference key the caller supplied
// either as a flag or via the ambient env var (mirrors the explicit >
// ambient-env > context precedence documented in bin/cli/api.mjs's
// buildHeaders()). Only fall back to the context token when neither an
// explicit flag nor the env var is set.
let apiKey = opts.apiKey ?? opts["api-key"];
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
if (!apiKey) {
try {
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
apiKey = c?.accessToken || c?.apiKey || "";
apiKey = c?.accessToken || c?.apiKey;
} catch {
/* no context auth */
}
}
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
return { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey };
}
@@ -185,17 +177,8 @@ export function registerSetupOpencode(program) {
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts, cmd) => {
// Commander parses the ancestor program's own global --api-key option
// (bin/cli/program.mjs, bound to .env("OMNIROUTE_API_KEY")) against any
// occurrence of the flag in argv, so it wins the value even when the
// user typed --api-key AFTER `setup-opencode` — this local option's own
// `opts.apiKey` never sees it. cmd.optsWithGlobals() resolves to the
// correct value either way ("globals overwrite locals" is exactly the
// outcome we want here, since the global option is where the value
// always actually lands).
const resolvedOpts = { ...opts, apiKey: cmd.optsWithGlobals().apiKey ?? opts.apiKey };
const code = await runSetupOpencodeCommand(resolvedOpts);
.action(async (opts) => {
const code = await runSetupOpencodeCommand(opts);
if (code !== 0) process.exit(code);
});
}

View File

@@ -187,9 +187,7 @@ export async function runUpdateCommand(opts = {}) {
}
if (dryRun) {
console.log(
"\n [DRY RUN] Would run: npm install -g omniroute@latest --include=optional --legacy-peer-deps"
);
console.log("\n [DRY RUN] Would run: npm install -g omniroute@latest --include=optional");
if (!skipBackup) console.log(" [DRY RUN] Would create backup in ~/.omniroute/backups/");
return 0;
}
@@ -223,9 +221,7 @@ export async function runUpdateCommand(opts = {}) {
const { execSync } = await import("child_process");
// --include=optional keeps the optionalDependencies (better-sqlite3, keytar,
// tls-client, llmlingua SLM stack) on update so an omit=optional config can't drop them.
execSync("npm install -g omniroute@latest --include=optional --legacy-peer-deps", {
stdio: "inherit",
});
execSync("npm install -g omniroute@latest --include=optional", { stdio: "inherit" });
// Trust-but-verify: `npm install -g` exits 0 even when a shadowing local install
// (e.g. ~/node_modules/omniroute ahead of the global prefix on PATH) means the
// binary the user actually runs was not touched. Re-read the running binary's

View File

@@ -1,7 +1,6 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
import { join, dirname } from "node:path";
import { resolveDataDir } from "./data-dir.mjs";
import { writePrivateFileAtomic } from "./private-file.mjs";
const CONFIG_VERSION = 1;
const KEYCHAIN_SERVICE = "omniroute-cli";
@@ -125,44 +124,17 @@ export function loadContexts() {
return readConfigFile();
}
/**
* Load contexts for an explicit export operation.
*
* The persisted file intentionally contains only `credentialRef` for
* keychain-backed contexts. Secret-bearing exports therefore have to hydrate
* every referenced credential first. Fail closed when any reference cannot be
* resolved so `--include-secrets` never produces a silently incomplete backup.
*/
export async function loadContextsForExport({ includeSecrets = false } = {}) {
const cfg = readConfigFile();
if (!includeSecrets) return cfg;
await hydrateCredentialCache(cfg);
const out = JSON.parse(JSON.stringify(cfg));
const contexts = out.contexts || out.profiles || {};
const unresolved = [];
for (const [name, context] of Object.entries(contexts)) {
if (!context || typeof context !== "object" || !context.credentialRef) continue;
const credential = credentialForContext(context);
if (!credential) {
unresolved.push(name);
continue;
}
Object.assign(context, credential);
}
if (unresolved.length > 0) {
throw new Error(`Cannot include keychain credentials for context(s): ${unresolved.join(", ")}`);
}
return out;
}
/**
* 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();
writePrivateFileAtomic(path, JSON.stringify(cfg, null, 2));
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(cfg, null, 2));
try {
chmodSync(path, 0o600);
} catch {}
}
/** Stable keychain reference; the reference itself is safe to persist in JSON. */

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More