Merge release/v3.8.51 into fix/warmup-cb-redis-key-prefix

Conflict in docs/reference/ENVIRONMENT.md was additive: the release tip
inserted APP_BIND_HOST/QDRANT_BIND_HOST/BIFROST_BIND_HOST rows just above
the REDIS_KEY_PREFIX row this branch edits. Kept both sides.
This commit is contained in:
diegosouzapw
2026-09-16 21:07:23 -03:00
3040 changed files with 925959 additions and 147193 deletions

View File

@@ -73,6 +73,11 @@ docs/i18n/**
# so without this rule these land in /app/docs and become readable through the # so without this rule these land in /app/docs and become readable through the
# dashboard's Docs viewer at runtime. # dashboard's Docs viewer at runtime.
docs/superpowers/** 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/**/*.png
docs/diagrams/**/*.jpg docs/diagrams/**/*.jpg
docs/diagrams/**/*.jpeg docs/diagrams/**/*.jpeg

View File

@@ -125,6 +125,21 @@ DISABLE_SQLITE_AUTO_BACKUP=false
# Host port for the compose Redis sidecar. Default: 6379. # Host port for the compose Redis sidecar. Default: 6379.
# REDIS_PORT=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 # 3. NETWORK & PORTS
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
@@ -374,6 +389,8 @@ AUTH_COOKIE_SECURE=false
# Require an API key for all /v1/* proxy endpoints. # Require an API key for all /v1/* proxy endpoints.
# Used by: API middleware — rejects unauthenticated requests to the proxy API. # Used by: API middleware — rejects unauthenticated requests to the proxy API.
# Default: false | Set true for multi-user/public deployments. # 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 REQUIRE_API_KEY=false
# Allow revealing full API key values in the Dashboard UI. # Allow revealing full API key values in the Dashboard UI.
@@ -721,6 +738,12 @@ NEXT_PUBLIC_CLOUD_URL=
ENABLE_SOCKS5_PROXY=true ENABLE_SOCKS5_PROXY=true
NEXT_PUBLIC_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). # Standard proxy variables (lowercase variants also supported).
# HTTP_PROXY=http://127.0.0.1:7890 # HTTP_PROXY=http://127.0.0.1:7890
# HTTPS_PROXY=http://127.0.0.1:7890 # HTTPS_PROXY=http://127.0.0.1:7890
@@ -1018,6 +1041,12 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
# Used by: src/lib/jobs/reasoningCacheCleanupJob.ts. # Used by: src/lib/jobs/reasoningCacheCleanupJob.ts.
#OMNIROUTE_REASONING_CACHE_CLEANUP_INTERVAL_MS=1800000 #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. # Spend write batcher cadence (ms) and buffer size before forced flush.
# Used by: src/lib/spend/batchWriter.ts. Defaults: 60000 ms / 1000 entries. # Used by: src/lib/spend/batchWriter.ts. Defaults: 60000 ms / 1000 entries.
#OMNIROUTE_SPEND_FLUSH_INTERVAL_MS=60000 #OMNIROUTE_SPEND_FLUSH_INTERVAL_MS=60000
@@ -1124,6 +1153,44 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
# Used by: src/lib/db/core.ts::getWalTruncateIntervalMs(). # Used by: src/lib/db/core.ts::getWalTruncateIntervalMs().
#OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS=21600000 #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 escalates to wal_checkpoint(TRUNCATE). Default: 256.
# Used by: src/lib/db/walMaintenance.ts.
#OMNIROUTE_WAL_GUARD_MAX_MB=256
# Minimum rows a cleanup must delete before the post-cleanup VACUUM runs. Default: 1000.
# 0 always vacuums when rows were freed. The post-cleanup VACUUM also runs when the
# reclaimable-space threshold below is met, whichever comes first (either signal fires it).
# Used by: src/lib/db/cleanup.ts::shouldVacuumAfterCleanup().
#OMNIROUTE_VACUUM_MIN_DELETED_ROWS=1000
# Minimum reclaimable space (MB) that alone justifies a full-database VACUUM after a
# cleanup, even when the row-count threshold above was not met (a handful of oversized
# blob rows can free far more space than thousands of tiny rows). VACUUM is synchronous
# and blocks the entire process. Default: 100. 0 always vacuums after any deletion.
# Used by: src/lib/db/cleanup.ts::getVacuumMinReclaimableBytes().
#OMNIROUTE_VACUUM_MIN_RECLAIMABLE_MB=100
# 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). # 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. # Used by: src/lib/db/apiKeys.ts. Set to 1 to disable. Default: enabled.
#OMNIROUTE_DISABLE_REDIS_AUTH_CACHE=0 #OMNIROUTE_DISABLE_REDIS_AUTH_CACHE=0
@@ -1176,6 +1243,11 @@ CODEX_OAUTH_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann
# Trae OAuth token override. Used by: open-sse/executors/trae.ts. # Trae OAuth token override. Used by: open-sse/executors/trae.ts.
# TRAE_TOKEN= # 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) ── # ── Gemini / Antigravity (Google-based) ──
# These providers ship public OAuth client_id/secret values embedded in their # These providers ship public OAuth client_id/secret values embedded in their
# public CLIs. Defaults are baked into the code via # public CLIs. Defaults are baked into the code via
@@ -1312,7 +1384,8 @@ CLAUDE_USER_AGENT="claude-cli/2.1.258 (external, cli)"
# stream with a misleading 400 out-of-extra-usage placeholder. Set to true to # stream with a misleading 400 out-of-extra-usage placeholder. Set to true to
# forward the original names verbatim (debugging only). # forward the original names verbatim (debugging only).
# CLAUDE_DISABLE_TOOL_NAME_CLOAK=false # CLAUDE_DISABLE_TOOL_NAME_CLOAK=false
CODEX_USER_AGENT="codex-cli/0.144.1 (Windows 10.0.26200; x64)" # Optional override; leave unset to follow the shared Codex client version.
# CODEX_USER_AGENT="codex-cli/0.153.4 (Windows 10.0.26200; x64)"
GITHUB_USER_AGENT="GitHubCopilotChat/0.54.0" GITHUB_USER_AGENT="GitHubCopilotChat/0.54.0"
ANTIGRAVITY_USER_AGENT="antigravity/2.0.1 linux/arm64 google-api-nodejs-client/10.3.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" KIRO_USER_AGENT="AWS-SDK-JS/3.0.0 kiro-ide/1.0.0"
@@ -1332,7 +1405,7 @@ CURSOR_USER_AGENT="Cursor/3.4"
# Override Codex client version sent in headers independently of the # Override Codex client version sent in headers independently of the
# CODEX_USER_AGENT string. Used by: open-sse/config/codexClient.ts. # CODEX_USER_AGENT string. Used by: open-sse/config/codexClient.ts.
# CODEX_CLIENT_VERSION=0.144.1 # CODEX_CLIENT_VERSION=0.153.4
# #
# Override the advertised Claude Code client version independently of # Override the advertised Claude Code client version independently of
# CLAUDE_USER_AGENT. Anthropic gates some models (Fable 5.1) on this # CLAUDE_USER_AGENT. Anthropic gates some models (Fable 5.1) on this
@@ -1647,6 +1720,9 @@ CURSOR_USER_AGENT="Cursor/3.4"
# ── TLS client (wreq-js fingerprint proxy) ── # ── TLS client (wreq-js fingerprint proxy) ──
# TLS_CLIENT_TIMEOUT_MS=600000 # Inherits from FETCH_TIMEOUT_MS by default # 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
# 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)
# ── API Bridge (/v1 proxy server) ── # ── API Bridge (/v1 proxy server) ──
# API_BRIDGE_PROXY_TIMEOUT_MS=600000 # Proxy hop timeout (default: 10min) # API_BRIDGE_PROXY_TIMEOUT_MS=600000 # Proxy hop timeout (default: 10min)
@@ -1777,6 +1853,13 @@ APP_LOG_TO_FILE=true
# Override only to hand-tune for a known workload. # Override only to hand-tune for a known workload.
# HEAP_PRESSURE_THRESHOLD_MB= # 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/) ── # ── CLI helpers (bin/cli/) ──
# Override UI language for CLI output. Accepts BCP-47 locale (e.g. en, pt-BR). # 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. # Falls back to LC_ALL / LC_MESSAGES / LANG / en if unset.
@@ -1794,6 +1877,10 @@ APP_LOG_TO_FILE=true
# Per-attempt HTTP timeout for CLI → server calls (milliseconds). Default: 30000. # Per-attempt HTTP timeout for CLI → server calls (milliseconds). Default: 30000.
# OMNIROUTE_HTTP_TIMEOUT_MS=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. # Set to 1 to print retry/backoff details to stderr during CLI commands.
# OMNIROUTE_VERBOSE=0 # OMNIROUTE_VERBOSE=0
@@ -1939,6 +2026,12 @@ APP_LOG_TO_FILE=true
# Default: 8000 (8 seconds). On timeout, a last-good 200 is served when available. # Default: 8000 (8 seconds). On timeout, a last-good 200 is served when available.
# CATALOG_BUILD_TIMEOUT_MS=8000 # 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) ── # ── NanoBanana (Image Generation) ──
# Polling config for async image generation jobs. # Polling config for async image generation jobs.
# Used by: open-sse/handlers/imageGeneration.ts # Used by: open-sse/handlers/imageGeneration.ts
@@ -2078,6 +2171,13 @@ APP_LOG_TO_FILE=true
# Management key for an externally managed instance. Embedded instances use # Management key for an externally managed instance. Embedded instances use
# OmniRoute's encrypted service key. # OmniRoute's encrypted service key.
# CLIPROXYAPI_MANAGEMENT_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 ── # ── Mux embedded service ──
# Override the port where the embedded Mux (coder/mux) agent-orchestration # Override the port where the embedded Mux (coder/mux) agent-orchestration
@@ -2168,15 +2268,20 @@ APP_LOG_TO_FILE=true
# proxy — only the operator sets active/inactive (a flaky probe must not strand an # 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. # assigned proxy; #6246). Set "true" to restore the legacy test-and-set behaviour.
# PROXY_HEALTH_AUTO_DEACTIVATE=false # 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 # Allow OAuth and provider validation flows to bypass a pinned proxy and connect
# directly when proxy reachability pre-checks fail. Default: false. # directly when proxy reachability pre-checks fail. Default: false.
# Also configurable from Dashboard > Settings > Feature Flags. # Also configurable from Dashboard > Settings > Feature Flags.
# OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK=false # OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK=false
# Rate limit maximum wait time before failing a request (ms). Default: 15000 (15s) # Rate limit maximum wait time before failing a request (ms). Default: 30000 (30s)
# Used by: open-sse/services/rateLimitManager.ts # Used by: open-sse/services/rateLimitManager.ts
# RATE_LIMIT_MAX_WAIT_MS=15000 # RATE_LIMIT_MAX_WAIT_MS=30000
# Limiter-managed execution backstop (Bottleneck `expiration`): bounds a job's # Limiter-managed execution backstop (Bottleneck `expiration`): bounds a job's
# post-dispatch execution, never queue wait. Must stay ABOVE upstream # post-dispatch execution, never queue wait. Must stay ABOVE upstream
@@ -2281,6 +2386,10 @@ APP_LOG_TO_FILE=true
# Cursor stream idle timeout (ms). Default: 300000 (5 min). # Cursor stream idle timeout (ms). Default: 300000 (5 min).
# Used by: open-sse/executors/cursor.ts. # Used by: open-sse/executors/cursor.ts.
# CURSOR_STREAM_TIMEOUT_MS=300000 # 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 # Cursor tool-commit directive toggle. Default-on: when a request declares
# tools, a directive is prepended so composer-2.5 reliably issues tool calls # tools, a directive is prepended so composer-2.5 reliably issues tool calls
@@ -2672,6 +2781,13 @@ APP_LOG_TO_FILE=true
# tokens (accessToken / refreshToken / providerSpecificData). Default OFF — # tokens (accessToken / refreshToken / providerSpecificData). Default OFF —
# only non-credential metadata is synced. See docs/security/SOCKET_DEV_FINDINGS.md §5. # only non-credential metadata is synced. See docs/security/SOCKET_DEV_FINDINGS.md §5.
# OMNIROUTE_CLOUD_SYNC_SECRETS=false # 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) ────────────────────────────────────── # ─── Zed import legacy compat (v3.8.6) ──────────────────────────────────────
# Set to "true" to fall back to the v3.8.5 one-step "import everything from # Set to "true" to fall back to the v3.8.5 one-step "import everything from
@@ -2722,6 +2838,10 @@ PLAYGROUND_COMPARE_MAX_COLUMNS=4
# MEMORY_VEC_TOP_K=20 # default top-K for vector search # MEMORY_VEC_TOP_K=20 # default top-K for vector search
# MEMORY_RRF_K=60 # RRF k constant (sqlite-vec hybrid recipe) # 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 # 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) # 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_ENABLED=false # master switch for the destructive sweep (default off)
# MEMORY_TYPED_DECAY_EPISODIC_DAYS=30 # episodic TTL in days; 0 = episodic immune too # MEMORY_TYPED_DECAY_EPISODIC_DAYS=30 # episodic TTL in days; 0 = episodic immune too
@@ -2963,6 +3083,13 @@ QUOTA_STORE_DRIVER=sqlite
# CHATGPT_WEB_CODEX_CHROME_PATH=/usr/bin/chromium # CHATGPT_WEB_CODEX_CHROME_PATH=/usr/bin/chromium
# CHROME_PATH=/usr/bin/chromium # CHROME_PATH=/usr/bin/chromium
# CHATGPT_WEB_CODEX_CDP_URL=http://chatgpt-web-codex-browser:9223 # 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_TUNNEL_ID=tunnel_0123456789abcdef0123456789abcdef
# CHATGPT_WEB_CODEX_RUNTIME_KEY= # CHATGPT_WEB_CODEX_RUNTIME_KEY=
# CHATGPT_WEB_CODEX_CONNECTOR_NAME=OmniRoute Codex v2 # CHATGPT_WEB_CODEX_CONNECTOR_NAME=OmniRoute Codex v2
@@ -2991,6 +3118,7 @@ QUOTA_STORE_DRIVER=sqlite
# OMNIROUTE_VNC_READY_MS=45000 # OMNIROUTE_VNC_READY_MS=45000
# OMNIROUTE_VNC_HARVEST_MS=20000 # OMNIROUTE_VNC_HARVEST_MS=20000
# OMNIROUTE_VNC_CHROMIUM_ARGS=--remote-debugging-port=9222 --no-first-run --no-default-browser-check # 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) # Data-dir alias (optional — open-sse/services/notionThreadSessions.ts)
@@ -3071,6 +3199,11 @@ QUOTA_STORE_DRIVER=sqlite
# Telegram Mini App bridge. The update endpoint remains disabled while the bot # 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. # token is unset. Used by: src/lib/telegram/* and src/app/api/telegram/update/route.ts.
# TELEGRAM_BOT_TOKEN= # 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_DEFAULT_MODEL=auto/chat
# TELEGRAM_BOT_API_BASE=https://api.telegram.org # TELEGRAM_BOT_API_BASE=https://api.telegram.org
# TELEGRAM_WEBHOOK_TIMEOUT_MS=60000 # TELEGRAM_WEBHOOK_TIMEOUT_MS=60000

View File

@@ -144,6 +144,12 @@ jobs:
- run: npm run check:test-discovery - run: npm run check:test-discovery
- run: npm run check:radar-sentinels - run: npm run check:radar-sentinels
- run: npm run check:tracked-artifacts - run: npm run check:tracked-artifacts
# 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 # (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 # 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. # generator either reproduces the committed SKILL.md files or it does not.
@@ -505,9 +511,11 @@ jobs:
- run: node scripts/i18n/check-ui-keys-coverage.mjs --threshold=65 - run: node scripts/i18n/check-ui-keys-coverage.mjs --threshold=65
# Real-translation ratchet: a leaf copied verbatim from en.json passes key # 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). # parity above but is still English to the user (es shipped 55% English).
# Advisory in PR-0; flipped to blocking once the backlog is retranslated (PR-4). # Blocking since PR-4 retranslated the verbatim-English backlog: the share of
- name: i18n real-translation ratio (advisory) # untranslated leaves per locale may only fall (ratchet baseline in
run: node scripts/i18n/check-translation-ratio.mjs --warn # 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
# #8463: a rewritten English value used to leave its 39 translations behind # #8463: a rewritten English value used to leave its 39 translations behind
# silently (googleOAuthWarning shipped wrong copy in 39 locales for months). # silently (googleOAuthWarning shipped wrong copy in 39 locales for months).
# Key parity above cannot see it — a stale translation counts as covered. # Key parity above cannot see it — a stale translation counts as covered.
@@ -515,6 +523,30 @@ jobs:
env: env:
BASE_REF: ${{ github.base_ref && format('origin/{0}', github.base_ref) || '' }} BASE_REF: ${{ github.base_ref && format('origin/{0}', github.base_ref) || '' }}
run: node scripts/i18n/check-ui-value-drift.mjs 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 — # #8038: cheap glossary/protected-terms consistency gate —
# complements i18n-ui-coverage (key parity) and the ICU `i18n` job below # complements i18n-ui-coverage (key parity) and the ICU `i18n` job below

View File

@@ -411,16 +411,12 @@ jobs:
path: /tmp/digests/bun-web path: /tmp/digests/bun-web
merge-multiple: true merge-multiple: true
- name: Create Docker Hub manifest - name: Create Docker Hub version manifests
run: | run: |
set -euo pipefail set -euo pipefail
create_manifest() { create_manifest() {
local image="$1" suffix="$2" dir="$3" optional="${4:-}" 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=() local refs=()
while IFS= read -r digest_file; do while IFS= read -r digest_file; do
refs+=("${image}@sha256:$(basename "$digest_file")") refs+=("${image}@sha256:$(basename "$digest_file")")
@@ -433,7 +429,7 @@ jobs:
echo "No image digests in $dir" >&2 echo "No image digests in $dir" >&2
exit 1 exit 1
fi fi
docker buildx imagetools create "${tags[@]}" "${refs[@]}" docker buildx imagetools create -t "${image}:${VERSION}${suffix}" "${refs[@]}"
} }
create_manifest "${IMAGE_NAME}" "" /tmp/digests/base create_manifest "${IMAGE_NAME}" "" /tmp/digests/base
@@ -441,16 +437,12 @@ jobs:
create_manifest "${IMAGE_NAME}" "-bun" /tmp/digests/bun-base optional create_manifest "${IMAGE_NAME}" "-bun" /tmp/digests/bun-base optional
create_manifest "${IMAGE_NAME}" "-web-bun" /tmp/digests/bun-web optional create_manifest "${IMAGE_NAME}" "-web-bun" /tmp/digests/bun-web optional
- name: Create GHCR manifest - name: Create GHCR version manifests
run: | run: |
set -euo pipefail set -euo pipefail
create_manifest() { create_manifest() {
local image="$1" suffix="$2" dir="$3" optional="${4:-}" 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=() local refs=()
while IFS= read -r digest_file; do while IFS= read -r digest_file; do
refs+=("${image}@sha256:$(basename "$digest_file")") refs+=("${image}@sha256:$(basename "$digest_file")")
@@ -463,7 +455,7 @@ jobs:
echo "No image digests in $dir" >&2 echo "No image digests in $dir" >&2
exit 1 exit 1
fi fi
docker buildx imagetools create "${tags[@]}" "${refs[@]}" docker buildx imagetools create -t "${image}:${VERSION}${suffix}" "${refs[@]}"
} }
create_manifest "${GHCR_IMAGE_NAME}" "" /tmp/digests/base create_manifest "${GHCR_IMAGE_NAME}" "" /tmp/digests/base
@@ -471,6 +463,59 @@ jobs:
create_manifest "${GHCR_IMAGE_NAME}" "-bun" /tmp/digests/bun-base optional create_manifest "${GHCR_IMAGE_NAME}" "-bun" /tmp/digests/bun-base optional
create_manifest "${GHCR_IMAGE_NAME}" "-web-bun" /tmp/digests/bun-web 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 - name: Inspect image
if: needs.prepare.outputs.version != 'main' if: needs.prepare.outputs.version != 'main'
run: | run: |

View File

@@ -10,7 +10,11 @@ name: Radar Export
on: on:
workflow_dispatch: # o operador pode publicar sob demanda (de qualquer ref) workflow_dispatch: # o operador pode publicar sob demanda (de qualquer ref)
push: push:
branches: [main] # produção: só o catálogo do main clobra o asset estável # `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/**"]
paths: paths:
- open-sse/config/freeModelCatalog.data.ts - open-sse/config/freeModelCatalog.data.ts
- open-sse/config/freeModelCatalog.ts - open-sse/config/freeModelCatalog.ts
@@ -19,7 +23,9 @@ on:
- scripts/release/radar-export.mjs - scripts/release/radar-export.mjs
- .github/workflows/radar-export.yml - .github/workflows/radar-export.yml
schedule: schedule:
- cron: "17 6 * * 1" # semanal (segunda 06:17 UTC): mantém geradoEm/proveniência frescos # 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 * * *"
permissions: permissions:
contents: read contents: read

View File

@@ -97,4 +97,11 @@
# credential; the generic-api-key rule flags the long hyphenated string. # credential; the generic-api-key rule flags the long hyphenated string.
'''omniroute-cheaperinference-sponsor-banner-dismissed-v\d+''', '''omniroute-cheaperinference-sponsor-banner-dismissed-v\d+''',
'''SunbreakWebUI1''', '''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''',
] ]

File diff suppressed because it is too large Load Diff

View File

@@ -56,8 +56,14 @@ 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 Left unset, `managementReadToken` falls back to `apiKey` for backwards
compatibility. When a gateway rejects that fallback, the catalog still compatibility, and the plugin warns once at startup that the fallback is
active. When a gateway rejects that fallback, the catalog still
publishes — but with raw model ids instead of display names, no canonical 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 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 when this happens, naming the endpoint and the consequence, so the degraded
@@ -65,25 +71,25 @@ catalog is never a mystery.
## Options ## Options
| Key | Default | Notes | | Key | Default | Notes |
| -------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | -------------------------------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `providerId` | `"omniroute"` | Provider id and integration id; models publish under `<providerId>/…` | | `providerId` | `"omniroute"` | Provider id and integration id; models publish under `<providerId>/…` |
| `baseURL` | required | OmniRoute gateway root (no `/v1` suffix needed) | | `baseURL` | required | OmniRoute gateway root (no `/v1` suffix needed) |
| `apiKey` | connected credential, then `OMNIROUTE_API_KEY` | Chat key for `/v1/*` — see [Credentials](#credentials) | | `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 | | `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 | | `displayName` | `"OmniRoute"` | Provider display name |
| `timeoutMs` | `10000` | Per-endpoint fetch timeout (auto-combos use 5s) | | `timeoutMs` | `10000` | Per-endpoint fetch timeout (auto-combos use 5s) |
| `modelCacheTtlMs` | `300000` | Catalog cache TTL; disk snapshot warms cold starts | | `modelCacheTtlMs` | `300000` | Catalog cache TTL; disk snapshot warms cold starts |
| `timeouts` | per-endpoint override | `{ models, combos, autoCombos, enrichment }` in ms; falls back to `timeoutMs` | | `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`) | | `enrichment` | `true` | Fetch names + pricing (`/api/pricing*`, `/api/free-tier/summary`) |
| `providerTag` | `true` | Prefix a display name with the upstream provider it routes to | | `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) | | `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`) | | `usableOnly` | `false` | Filter to healthy provisioned providers (`/api/providers`) |
| `visibleModels` / `hiddenModels` | `[]` | Exact-or-suffix allowlists, deny wins | | `visibleModels` / `hiddenModels` | `[]` | Exact-or-suffix allowlists, deny wins |
| `apiFormat.allowAnthropic` | `false` | Route allowlisted ids to the Anthropic API block | | `apiFormat.allowAnthropic` | `false` | Route allowlisted ids to the Anthropic API block |
| `apiFormat.anthropicModels` | `[]` | Full model ids routed to Anthropic | | `apiFormat.anthropicModels` | `[]` | Full model ids routed to Anthropic |
| `apiFormat.anthropicPrefixes` | v1 defaults | Deprecated, warns once — prefer `anthropicModels` | | `apiFormat.anthropicPrefixes` | v1 defaults | Deprecated, warns once — prefer `anthropicModels` |
| `logLevel` / `startupDebug` | `warn` / `false` | Logger verbosity | | `logLevel` / `startupDebug` | `warn` / `false` | Logger verbosity |
## Tool calling on Gemini models ## Tool calling on Gemini models

View File

@@ -1790,490 +1790,6 @@
} }
} }
}, },
"node_modules/tsup/node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsup/node_modules/@esbuild/android-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsup/node_modules/@esbuild/android-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsup/node_modules/@esbuild/android-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsup/node_modules/@esbuild/darwin-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsup/node_modules/@esbuild/darwin-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsup/node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsup/node_modules/@esbuild/freebsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsup/node_modules/@esbuild/linux-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsup/node_modules/@esbuild/linux-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsup/node_modules/@esbuild/linux-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsup/node_modules/@esbuild/linux-loong64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsup/node_modules/@esbuild/linux-mips64el": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsup/node_modules/@esbuild/linux-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsup/node_modules/@esbuild/linux-riscv64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsup/node_modules/@esbuild/linux-s390x": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsup/node_modules/@esbuild/linux-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsup/node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsup/node_modules/@esbuild/netbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsup/node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsup/node_modules/@esbuild/openbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsup/node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsup/node_modules/@esbuild/sunos-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsup/node_modules/@esbuild/win32-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsup/node_modules/@esbuild/win32-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsup/node_modules/@esbuild/win32-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsup/node_modules/esbuild": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.7",
"@esbuild/android-arm": "0.27.7",
"@esbuild/android-arm64": "0.27.7",
"@esbuild/android-x64": "0.27.7",
"@esbuild/darwin-arm64": "0.27.7",
"@esbuild/darwin-x64": "0.27.7",
"@esbuild/freebsd-arm64": "0.27.7",
"@esbuild/freebsd-x64": "0.27.7",
"@esbuild/linux-arm": "0.27.7",
"@esbuild/linux-arm64": "0.27.7",
"@esbuild/linux-ia32": "0.27.7",
"@esbuild/linux-loong64": "0.27.7",
"@esbuild/linux-mips64el": "0.27.7",
"@esbuild/linux-ppc64": "0.27.7",
"@esbuild/linux-riscv64": "0.27.7",
"@esbuild/linux-s390x": "0.27.7",
"@esbuild/linux-x64": "0.27.7",
"@esbuild/netbsd-arm64": "0.27.7",
"@esbuild/netbsd-x64": "0.27.7",
"@esbuild/openbsd-arm64": "0.27.7",
"@esbuild/openbsd-x64": "0.27.7",
"@esbuild/openharmony-arm64": "0.27.7",
"@esbuild/sunos-x64": "0.27.7",
"@esbuild/win32-arm64": "0.27.7",
"@esbuild/win32-ia32": "0.27.7",
"@esbuild/win32-x64": "0.27.7"
}
},
"node_modules/tsx": { "node_modules/tsx": {
"version": "4.22.3", "version": "4.22.3",
"dev": true, "dev": true,

View File

@@ -63,5 +63,8 @@
}, },
"peerDependencies": { "peerDependencies": {
"@opencode-ai/plugin": ">=1.18.29 <2" "@opencode-ai/plugin": ">=1.18.29 <2"
},
"overrides": {
"esbuild": "^0.28.1"
} }
} }

View File

@@ -1,6 +1,6 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import type { import type {
OmniRouteEnrichmentEntry, OmniRouteEnrichmentEntry,
@@ -10,6 +10,7 @@ import type {
OmniRouteRawCombo, OmniRouteRawCombo,
OmniRouteRawModelEntry, OmniRouteRawModelEntry,
} from "./shared/index.js"; } from "./shared/index.js";
import { isHttpUrl } from "./shared/index.js";
export const DEFAULT_MODEL_CACHE_TTL_MS = 300_000 as const; export const DEFAULT_MODEL_CACHE_TTL_MS = 300_000 as const;
@@ -34,8 +35,9 @@ export const SNAPSHOT_FORMAT_VERSION = 2 as const;
/** /**
* A raw snapshot entry is stale when it cannot be mapped to a publishable * 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 without a * model: no string `id` (unroutable), or a pre-mapped `api` block missing a
* valid `npm` package (the runner would reject it as `Unsupported package`). * 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).
* Plain `/v1/models` entries carry no `api` block -- it is synthesized at * Plain `/v1/models` entries carry no `api` block -- it is synthesized at
* publish time -- so only a present-but-invalid block drops the entry. * publish time -- so only a present-but-invalid block drops the entry.
*/ */
@@ -47,7 +49,11 @@ export function isStaleSnapshotModel(entry: unknown): boolean {
if (api === undefined) return false; if (api === undefined) return false;
if (!api || typeof api !== "object") return true; if (!api || typeof api !== "object") return true;
const npm = (api as { npm?: unknown }).npm; const npm = (api as { npm?: unknown }).npm;
return typeof npm !== "string" || npm.length === 0; 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);
} }
interface DiskSnapshotV2 { interface DiskSnapshotV2 {
@@ -75,6 +81,12 @@ interface DiskSnapshotV2 {
*/ */
const MAX_SNAPSHOT_BYTES = 32 * 1024 * 1024; 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 { function trimTrailingSlashes(value: string): string {
let i = value.length; let i = value.length;
while (i > 0 && value.charCodeAt(i - 1) === 0x2f) i -= 1; while (i > 0 && value.charCodeAt(i - 1) === 0x2f) i -= 1;
@@ -127,7 +139,7 @@ export async function readDiskSnapshot(
if ( if (
!parsed || !parsed ||
typeof parsed.v !== "number" || typeof parsed.v !== "number" ||
parsed.v < SNAPSHOT_FORMAT_VERSION || parsed.v !== SNAPSHOT_FORMAT_VERSION ||
typeof parsed.identityFingerprint !== "string" || typeof parsed.identityFingerprint !== "string" ||
parsed.identityFingerprint !== identityFingerprint parsed.identityFingerprint !== identityFingerprint
) { ) {
@@ -145,7 +157,7 @@ export async function readDiskSnapshot(
(entry) => !isStaleSnapshotModel(entry) (entry) => !isStaleSnapshotModel(entry)
); );
if (stale > 0) { if (stale > 0) {
logger?.warn(`[omniroute-v2] dropping ${stale} stale snapshot entries without api block`); logger?.warn(`[omniroute-v2] dropping ${stale} stale snapshot entries with an unusable api block`);
} }
if (models.length === 0) return undefined; if (models.length === 0) return undefined;
return { return {
@@ -173,8 +185,14 @@ export async function readDiskSnapshot(
export async function writeDiskSnapshot( export async function writeDiskSnapshot(
providerId: string, providerId: string,
snapshot: CatalogSnapshot, snapshot: CatalogSnapshot,
identityFingerprint: string identityFingerprint: string,
logger?: { warn: (message: string) => void }
): Promise<void> { ): 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 { try {
if (snapshot.models.length === 0) return; if (snapshot.models.length === 0) return;
const file = diskSnapshotPath(providerId); const file = diskSnapshotPath(providerId);
@@ -190,14 +208,33 @@ export async function writeDiskSnapshot(
writtenAt: Date.now(), writtenAt: Date.now(),
}; };
let payload = JSON.stringify(envelope); let payload = JSON.stringify(envelope);
if (payload.length > MAX_SNAPSHOT_BYTES && envelope.enrichment !== undefined) { if (
Buffer.byteLength(payload, "utf8") > MAX_SNAPSHOT_BYTES &&
envelope.enrichment !== undefined
) {
delete envelope.enrichment; delete envelope.enrichment;
payload = JSON.stringify(envelope); payload = JSON.stringify(envelope);
} }
if (payload.length > MAX_SNAPSHOT_BYTES) return; if (Buffer.byteLength(payload, "utf8") > MAX_SNAPSHOT_BYTES) {
await writeFile(file, payload, { encoding: "utf8", mode: 0o600 }); logger?.warn(
} catch { `[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) {
// Best-effort: callers already hold the in-memory entry. // 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

@@ -3,6 +3,7 @@ import { type HostContract, detectHostContract, emitsLegacyFields } from "./comp
import type { Model as LegacyModelV2 } from "@opencode-ai/sdk/v2"; import type { Model as LegacyModelV2 } from "@opencode-ai/sdk/v2";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types"; import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
import { import {
isHttpUrl,
type ApiFormatV2, type ApiFormatV2,
type LogLevel, type LogLevel,
type Logger, type Logger,
@@ -142,6 +143,15 @@ export function legacyApiToInfoApi(api: LegacyModelV2["api"]): ModelV2Info["api"
"[omniroute-v2] refusing to publish a model without an api block (missing api.npm)" "[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 }; return { id: api.id, type: "aisdk", package: api.npm, url: api.url };
} }

View File

@@ -31,7 +31,14 @@ import { assertContext } from "./compat.js";
import { type ApiKeyOrigin, resolveApiKey, warnIfMissing } from "./credentials.js"; import { type ApiKeyOrigin, resolveApiKey, warnIfMissing } from "./credentials.js";
import { createSourceErrorReporter } from "./enrichment-report.js"; import { createSourceErrorReporter } from "./enrichment-report.js";
import { sanitizeToolSchemasFor } from "./gemini-language.js"; import { sanitizeToolSchemasFor } from "./gemini-language.js";
import { PLUGIN_ID, parsePluginOptions, resolveTimeouts, type PluginOptions } from "./options.js"; import {
MANAGEMENT_TOKEN_ENV_VAR,
PLUGIN_ID,
parsePluginOptions,
resolveManagementReadToken,
resolveTimeouts,
type PluginOptions,
} from "./options.js";
/** /**
* A fetch result that says whether it succeeded. Returning a bare `[]` on * A fetch result that says whether it succeeded. Returning a bare `[]` on
@@ -61,7 +68,7 @@ function toResolvedOptions(parsed: PluginOptions): ResolvedOptions {
providerId: parsed.providerId, providerId: parsed.providerId,
baseURL: parsed.baseURL, baseURL: parsed.baseURL,
apiKey: parsed.apiKey ?? process.env.OMNIROUTE_API_KEY ?? "", apiKey: parsed.apiKey ?? process.env.OMNIROUTE_API_KEY ?? "",
managementReadToken: parsed.managementReadToken, managementReadToken: resolveManagementReadToken(parsed.managementReadToken),
timeoutMs: parsed.timeoutMs, timeoutMs: parsed.timeoutMs,
timeouts: parsed.timeouts, timeouts: parsed.timeouts,
logLevel: parsed.logLevel, logLevel: parsed.logLevel,
@@ -93,6 +100,16 @@ export default define({
resolved.logLevel = parsed.logLevel; resolved.logLevel = parsed.logLevel;
resolved.startupDebug = parsed.startupDebug; resolved.startupDebug = parsed.startupDebug;
log.info(`[omniroute-v2] init providerId=${X}`); 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 // v1 parity port: in-memory TTL + disk snapshot. The memory key
// `baseURL::sha256(creds)` isolates credential tuples (prod vs // `baseURL::sha256(creds)` isolates credential tuples (prod vs
@@ -297,7 +314,7 @@ export default define({
}; };
if (models.length > 0) { if (models.length > 0) {
state.entries.set(cacheKey, snapshot); state.entries.set(cacheKey, snapshot);
await writeDiskSnapshot(X, snapshot, identityFingerprint); await writeDiskSnapshot(X, snapshot, identityFingerprint, log);
} }
void optional.then( void optional.then(
(parts) => upgradeWithOptional(snapshot, parts), (parts) => upgradeWithOptional(snapshot, parts),
@@ -344,7 +361,7 @@ export default define({
if (unchanged) return; if (unchanged) return;
state.entries.set(cacheKey, upgraded); state.entries.set(cacheKey, upgraded);
if (upgraded.models.length > 0) { if (upgraded.models.length > 0) {
await writeDiskSnapshot(X, upgraded, identityFingerprint); await writeDiskSnapshot(X, upgraded, identityFingerprint, log);
} }
// Reload only when the optional tier actually moved: the catalog // Reload only when the optional tier actually moved: the catalog
// fingerprint covers ids alone, so without this the host would rebuild // fingerprint covers ids alone, so without this the host would rebuild

View File

@@ -1,5 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { isHttpUrl } from "./shared/models-map.js";
const apiFormatSchema = z const apiFormatSchema = z
.object({ .object({
allowAnthropic: z.boolean().optional(), allowAnthropic: z.boolean().optional(),
@@ -28,7 +30,10 @@ const pluginOptionsSchema = z
.regex(/^[A-Za-z0-9._-]+$/, "providerId may only contain letters, digits, '.', '_' and '-'") .regex(/^[A-Za-z0-9._-]+$/, "providerId may only contain letters, digits, '.', '_' and '-'")
.refine((v) => v !== "." && v !== "..", "providerId cannot be a path segment") .refine((v) => v !== "." && v !== "..", "providerId cannot be a path segment")
.default("omniroute"), .default("omniroute"),
baseURL: z.string().url(), baseURL: z
.string()
.trim()
.refine(isHttpUrl, "baseURL must be an http(s) URL, for example http://localhost:20128"),
apiKey: z.string().optional(), apiKey: z.string().optional(),
displayName: z.string().optional(), displayName: z.string().optional(),
managementReadToken: z.string().optional(), managementReadToken: z.string().optional(),
@@ -56,6 +61,21 @@ const pluginOptionsSchema = z
export type PluginOptions = z.infer<typeof pluginOptionsSchema>; 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. */ /** Per-endpoint timeout defaults (v1 parity). `timeoutMs` is the global fallback. */
export const DEFAULT_TIMEOUT_MS = 10_000 as const; 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. */ /** Auto-combos keep the v1 5s budget; the field is resolved now for the P3 port. */

View File

@@ -111,6 +111,22 @@ function trimTrailingSlashes(value: string): string {
* (it appends `/v1/messages` automatically), so callers should branch on * (it appends `/v1/messages` automatically), so callers should branch on
* format first. * 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 { export function ensureV1Suffix(url: string): string {
const trimmed = trimTrailingSlashes(url); const trimmed = trimTrailingSlashes(url);
return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`; return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`;

View File

@@ -0,0 +1,251 @@
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

@@ -6,6 +6,33 @@ interface CapturedCall {
kind: "catalog" | "integration"; kind: "catalog" | "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;
}
interface FakeCtx { interface FakeCtx {
options: Record<string, unknown>; options: Record<string, unknown>;
catalog: { catalog: {
@@ -163,7 +190,6 @@ describe("plugin-v2 entrypoint", () => {
const { mkdtempSync } = await import("node:fs"); const { mkdtempSync } = await import("node:fs");
const { tmpdir } = await import("node:os"); const { tmpdir } = await import("node:os");
const { join } = await import("node:path"); const { join } = await import("node:path");
const { setTimeout: sleep } = await import("node:timers/promises");
const dir = mkdtempSync(join(tmpdir(), "omniroute-lazy-")); const dir = mkdtempSync(join(tmpdir(), "omniroute-lazy-"));
const prevDataDir = process.env.OPENCODE_DATA_DIR; const prevDataDir = process.env.OPENCODE_DATA_DIR;
process.env.OPENCODE_DATA_DIR = dir; process.env.OPENCODE_DATA_DIR = dir;
@@ -222,16 +248,15 @@ describe("plugin-v2 entrypoint", () => {
await cb(draft); await cb(draft);
assert.equal(reloads, 0, "the first publish sets the baseline, it does not reload"); assert.equal(reloads, 0, "the first publish sets the baseline, it does not reload");
assert.equal(modelsCall, 1); assert.equal(modelsCall, 1);
await sleep(5);
// The optional tier lands after that first publish and brings combos and // The optional tier lands after that first publish and brings combos and
// the overlay with it — one reload, so the picker shows them without // the overlay with it — one reload, so the picker shows them without
// waiting for the next refresh. // waiting for the next refresh.
const afterFirstUpgrade = reloads; const afterFirstUpgrade = await settle(() => reloads);
assert.ok(afterFirstUpgrade <= 1, `at most one reload for the first upgrade, got ${reloads}`); assert.ok(afterFirstUpgrade <= 1, `at most one reload for the first upgrade, got ${reloads}`);
await cb(draft); await cb(draft);
assert.equal(reloads, afterFirstUpgrade + 1, "a new model id reloads once"); assert.equal(reloads, afterFirstUpgrade + 1, "a new model id reloads once");
assert.equal(modelsCall, 2); assert.equal(modelsCall, 2);
await sleep(5); await settle(() => reloads);
await cb(draft); await cb(draft);
assert.equal(reloads, afterFirstUpgrade + 1, "an identical run never reloads"); assert.equal(reloads, afterFirstUpgrade + 1, "an identical run never reloads");
assert.equal(modelsCall, 3); assert.equal(modelsCall, 3);

View File

@@ -0,0 +1,371 @@
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";
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
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,
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
catalogCallbacks.push(cb);
return 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, 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) ?? { id }) as ProviderV2Info;
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 unknown as CatalogDraft;
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

@@ -29,6 +29,38 @@ describe("parsePluginOptions", () => {
it("requires baseURL", () => { it("requires baseURL", () => {
assert.throws(() => parsePluginOptions({}), /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)", () => { it("rejects unknown top-level keys (strict)", () => {
assert.throws(() => parsePluginOptions({ baseURL: "https://gw.example.com", bogus: 1 })); assert.throws(() => parsePluginOptions({ baseURL: "https://gw.example.com", bogus: 1 }));
}); });

View File

@@ -5,7 +5,11 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import plugin from "../src/index.js"; import plugin from "../src/index.js";
import { diskSnapshotPath, snapshotIdentityFingerprint } from "../src/cache.js"; import {
diskSnapshotPath,
isStaleSnapshotModel,
snapshotIdentityFingerprint,
} from "../src/cache.js";
import { legacyApiToInfoApi } from "../src/catalog.js"; import { legacyApiToInfoApi } from "../src/catalog.js";
function isolateDisk(): { dir: string; restore: () => void } { function isolateDisk(): { dir: string; restore: () => void } {
@@ -97,7 +101,7 @@ function downFetch(): typeof fetch {
const fingerprint = snapshotIdentityFingerprint("https://gw.example.com", "k-snapfix", "k-snapfix"); const fingerprint = snapshotIdentityFingerprint("https://gw.example.com", "k-snapfix", "k-snapfix");
describe("plugin-v2 snapshot stale-entry filter", () => { describe("plugin-v2 snapshot stale-entry filter", () => {
it("snapshot with 2 entries without api block + 1 valid: only the valid one is published + warn emitted", async () => { it("snapshot with 3 unusable pre-mapped entries + 1 valid: only the valid one is published + warn emitted", async () => {
const disk = isolateDisk(); const disk = isolateDisk();
const providerId = "snapfix-mixed"; const providerId = "snapfix-mixed";
mkdirSync(join(disk.dir, "plugins"), { recursive: true }); mkdirSync(join(disk.dir, "plugins"), { recursive: true });
@@ -106,11 +110,15 @@ describe("plugin-v2 snapshot stale-entry filter", () => {
JSON.stringify({ JSON.stringify({
v: 2, v: 2,
identityFingerprint: fingerprint, identityFingerprint: fingerprint,
// Two pre-mapped entries with a broken api block (missing npm) plus // Three pre-mapped entries with an unusable api block missing npm,
// one plain raw entry (no api block: synthesized at publish time). // 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.
models: [ models: [
{ id: "stale-a", api: {} }, { id: "stale-a", api: {} },
{ id: "stale-b", api: { npm: "" } }, { id: "stale-b", api: { npm: "" } },
{ id: "stale-c", api: { id: "openai-compatible", npm: "@ai-sdk/openai-compatible" } },
{ id: "good-1", context_length: 128000 }, { id: "good-1", context_length: 128000 },
], ],
combos: [], combos: [],
@@ -137,7 +145,7 @@ describe("plugin-v2 snapshot stale-entry filter", () => {
); );
}); });
assert.ok( assert.ok(
warns.some((w) => w.includes("dropping 2 stale snapshot entries without api block")), warns.some((w) => w.includes("dropping 3 stale snapshot entries with an unusable api block")),
`expected stale-drop warn, got: ${JSON.stringify(warns)}` `expected stale-drop warn, got: ${JSON.stringify(warns)}`
); );
} finally { } finally {
@@ -216,4 +224,56 @@ describe("plugin-v2 snapshot stale-entry filter", () => {
// Sanity: sha256 helper used above matches the plugin identity scheme. // Sanity: sha256 helper used above matches the plugin identity scheme.
assert.equal(createHash("sha256").update("x").digest("hex").length, 64); 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

@@ -23,7 +23,7 @@
"scripts": { "scripts": {
"build": "tsup", "build": "tsup",
"clean": "rm -rf dist", "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", "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",
"prepublishOnly": "npm run clean && npm run build && npm test" "prepublishOnly": "npm run clean && npm run build && npm test"
}, },
"keywords": [ "keywords": [
@@ -68,6 +68,7 @@
"typescript": "^5.9.3" "typescript": "^5.9.3"
}, },
"overrides": { "overrides": {
"esbuild": "^0.28.1" "esbuild": "^0.28.1",
"toml": "^4.1.2"
} }
} }

View File

@@ -220,7 +220,11 @@ const optionsSchema = z
* to 60000. Default when unset: 300000. * to 60000. Default when unset: 300000.
*/ */
autoSyncIntervalMs: z.number().int().nonnegative().optional(), autoSyncIntervalMs: z.number().int().nonnegative().optional(),
baseURL: z.string().url().optional(), baseURL: z
.string()
.trim()
.refine(isHttpUrl, "baseURL must be an http(s) URL, for example http://localhost:20128")
.optional(),
managementReadToken: z.string().min(1).optional(), managementReadToken: z.string().min(1).optional(),
features: featuresSchema.optional(), features: featuresSchema.optional(),
}) })
@@ -482,6 +486,22 @@ export const DEFAULT_ANTHROPIC_PREFIXES = ["cc", "claude", "anthropic", "kiro",
* (it appends `/v1/messages` automatically), so callers should branch on * (it appends `/v1/messages` automatically), so callers should branch on
* format first. * 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 { export function ensureV1Suffix(url: string): string {
const trimmed = trimTrailingSlashes(url); const trimmed = trimTrailingSlashes(url);
return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`; return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`;
@@ -4611,9 +4631,21 @@ export function buildStaticProviderEntry(
.map((m) => m.max_output_tokens) .map((m) => m.max_output_tokens)
.filter((v): v is number => typeof v === "number" && v > 0); .filter((v): v is number => typeof v === "number" && v > 0);
if (contextValues.length > 0 && outputValues.length > 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) {
entry.limit = { entry.limit = {
context: Math.min(...contextValues), context: preferredContext,
output: Math.min(...outputValues), output: Math.min(...outputValues),
}; };
} }
@@ -5491,6 +5523,32 @@ export function createOmniRouteConfigHook(
const modelsFetchOk = !modelsFetchThrew && localRawModels.length > 0; 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 // Disk-cache fallback (cold first run, no warm snapshot): when the
// live fetch returned no models AND features.diskCache !== false, // live fetch returned no models AND features.diskCache !== false,
// hydrate from the last-known-good snapshot so OC still surfaces a // hydrate from the last-known-good snapshot so OC still surfaces a

View File

@@ -0,0 +1,221 @@
/**
* 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,6 +59,26 @@ test("parseOmniRoutePluginOptions: invalid baseURL (not a URL) → throws", () =
assert.throws(() => parseOmniRoutePluginOptions({ baseURL: "not-a-url" }), /baseURL/i); 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)", () => { test("parseOmniRoutePluginOptions: unknown key → throws (strict mode catches typos)", () => {
assert.throws( assert.throws(
() => () =>

View File

@@ -46,7 +46,7 @@ Repository map and Reference Documentation sections below.
## Project at a Glance ## Project at a Glance
**OmniRoute** — unified AI proxy/router. One endpoint, 356 LLM providers, auto-fallback. **OmniRoute** — unified AI proxy/router. One endpoint, 359 LLM providers, auto-fallback.
| Layer | Location | Purpose | | Layer | Location | Purpose |
| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -56,7 +56,7 @@ Repository map and Reference Documentation sections below.
| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) | | Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) |
| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions | | Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions |
| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc | | Services | `open-sse/services/` | Combo routing, rate limits, caching, etc |
| Database | `src/lib/db/` | SQLite domain modules (172 migrations) | | Database | `src/lib/db/` | SQLite domain modules (176 migrations) |
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic | | Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
| MCP Server | `open-sse/mcp-server/` | 110 tools (45 canonical + memory/skill/GitHub/pool/gamification/plugin/Notion/Obsidian/local-corpus/RTK modules), 3 transports (stdio / SSE / Streamable HTTP), 33 scopes | | MCP Server | `open-sse/mcp-server/` | 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 | | A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol |
@@ -578,14 +578,31 @@ 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 # 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. # 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 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 **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 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 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 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). 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 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. different branch inside a worktree another session might share.
4. **Tear down only your own** worktree + branch when done, from the main checkout: 4. **Tear down only your own** worktree + branch when done, from the main checkout:

View File

@@ -340,7 +340,7 @@ RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,targe
# build, not the floating `@latest`. # build, not the floating `@latest`.
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-npm-cache,target=/root/.npm \ RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-npm-cache,target=/root/.npm \
npm install -g --no-audit --no-fund \ npm install -g --no-audit --no-fund \
@openai/codex@0.153.2 \ @openai/codex@0.153.4 \
@anthropic-ai/claude-code@2.1.260 \ @anthropic-ai/claude-code@2.1.260 \
droid@0.212.0 \ droid@0.212.0 \
openclaw@2026.9.1 openclaw@2026.9.1

View File

@@ -7,7 +7,7 @@
# 🚀 OmniRoute — The Free AI Gateway # 🚀 OmniRoute — The Free AI Gateway
<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."/> <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.47B free tokens/mo · 19 routing strategies · $0 to start."/>
</div> </div>
@@ -17,9 +17,9 @@
</div> </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 **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`). > Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute catalogs **452 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.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."/> <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.07B in the first month with signup credits, from 34 documented recurring pool keys covering 452 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)**. > 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)**.
> >
@@ -133,7 +133,7 @@
</div> </div>
<div align="center"> <div align="center">
<b>🌐 In 51 languages</b> <b>🌐 In 66 languages</b>
<br/><br/> <br/><br/>
<a href="README.md"><img src="docs/assets/flags/us.svg" width="30" alt="English (en)" title="English (en)"></a> <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> <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,6 +186,21 @@
<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/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/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/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>
</div> </div>
<br/> <br/>
@@ -218,7 +233,7 @@ curl http://localhost:20128/v1/chat/completions \
</div> </div>
<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."/> <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 53 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/>
<br/> <br/>
@@ -471,7 +486,7 @@ All **19** strategies — mix & match per combo step:
</div> </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: 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."/> <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."/>
<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> <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>
@@ -529,7 +544,7 @@ Pix copia-e-cola:
The main free-tier headline remains **~1.47B 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 pool-deduplicated catalog above. Temporary provider signup credits can separately lift the first
month to **~2.10B**. Radar is an optional, signed catalog overlay for people who want fresher month to **~2.07B**. 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 free-model availability between OmniRoute releases; the community catalog and every existing free
feature remain free. feature remain free.
@@ -657,7 +672,7 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
</div> </div>
> **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). > **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 **443 per-model rows**, **34 recurring pools** and **53 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"> <div align="center">
@@ -1253,7 +1268,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>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>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>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, 172 migrations</td></tr> <tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 122 domain modules, 176 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>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>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> <tr><td nowrap><b>Protocols</b></td><td>MCP (stdio / HTTP / SSE) + A2A v0.3 (JSON-RPC 2.0 + SSE)</td></tr>
@@ -1316,7 +1331,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/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/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/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: 34 documented recurring pools / 444 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 / 452 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/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> <tr><td nowrap><b><a href="docs/architecture/CODEBASE_DOCUMENTATION.md">Codebase Documentation</a></b></td><td>Beginner-friendly codebase walkthrough</td></tr>
</table> </table>

View File

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

View File

@@ -248,7 +248,9 @@ export function registerContexts(program) {
.option("--no-secrets", "Omit API keys from export") .option("--no-secrets", "Omit API keys from export")
.action(async (opts, cmd) => { .action(async (opts, cmd) => {
const cfg = loadContexts(); const cfg = loadContexts();
const out = opts.noSecrets ? redactContextSecrets(cfg) : JSON.parse(JSON.stringify(cfg)); // Commander stores `--no-secrets` as `secrets === false`, never as `noSecrets`.
const redact = opts.secrets === false || opts.noSecrets === true;
const out = redact ? redactContextSecrets(cfg) : JSON.parse(JSON.stringify(cfg));
const json = JSON.stringify(out, null, 2); const json = JSON.stringify(out, null, 2);
if (opts.out) { if (opts.out) {
const { writeFileSync } = await import("node:fs"); const { writeFileSync } = await import("node:fs");

View File

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

View File

@@ -9,6 +9,8 @@ function truncate(v, len = 60) {
return s.length > len ? s.slice(0, len - 1) + "…" : s; return s.length > len ? s.slice(0, len - 1) + "…" : s;
} }
const VALID_MCP_TRANSPORTS = ["stdio", "sse", "streamable-http"];
const mcpToolSchema = [ const mcpToolSchema = [
{ key: "name", header: "Tool", width: 36 }, { key: "name", header: "Tool", width: 36 },
{ {
@@ -43,6 +45,25 @@ export function registerMcp(program) {
if (exitCode !== 0) process.exit(exitCode); 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 // 5.1 — mcp call + mcp scopes
mcp mcp
.command("call <tool> [argsJson]") .command("call <tool> [argsJson]")
@@ -61,10 +82,15 @@ export function registerMcp(program) {
? JSON.parse(argsPositional) ? JSON.parse(argsPositional)
: {}; : {};
const exitCode = await runMcpCallCommand(tool, args, { const exitCode = await runMcpCallCommand(
...opts, tool,
stream: opts.stream, args,
}, globalOpts); {
...opts,
stream: opts.stream,
},
globalOpts
);
if (exitCode !== 0) process.exit(exitCode); if (exitCode !== 0) process.exit(exitCode);
}); });
@@ -127,7 +153,9 @@ async function mcpJsonRpcCall(tool, args, { stream = false, globalOpts = {} } =
if (!initRes.ok) { if (!initRes.ok) {
const text = await initRes.text().catch(() => ""); 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; return 1;
} }
@@ -227,6 +255,7 @@ export async function runMcpStatusCommand(opts = {}) {
}); });
if (!res.ok) { if (!res.ok) {
console.log(t("mcp.stopped")); console.log(t("mcp.stopped"));
console.log(t("mcp.stoppedHint"));
return 0; return 0;
} }
@@ -240,6 +269,9 @@ export async function runMcpStatusCommand(opts = {}) {
const transport = status.transport || "stdio"; const transport = status.transport || "stdio";
const online = status.online ?? status.running; const online = status.online ?? status.running;
console.log(online ? t("mcp.running", { transport }) : t("mcp.stopped")); 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.toolsCount !== undefined) console.log(` Tools: ${status.toolsCount}`);
if (status.scopes?.length) { if (status.scopes?.length) {
console.log(" Scopes:"); console.log(" Scopes:");
@@ -270,10 +302,76 @@ export async function runMcpRestartCommand(opts = {}) {
console.log(t("mcp.restarted")); console.log(t("mcp.restarted"));
return 0; return 0;
} }
console.error(t("common.error", { message: `HTTP ${res.status}` })); const body = await res.json().catch(() => null);
const message = body?.error || `HTTP ${res.status}`;
console.error(t("common.error", { message }));
return 1; return 1;
} catch (err) { } catch (err) {
console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) })); console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) }));
return 1; 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

@@ -54,6 +54,34 @@ 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 = {}) { function targetApiOptions(opts = {}) {
return { return {
baseUrl: opts.baseUrl, baseUrl: opts.baseUrl,
@@ -110,6 +138,10 @@ async function runBrowserFlow(def, opts) {
const { codeVerifier, state, redirectUri: returnedRedirectUri } = start; const { codeVerifier, state, redirectUri: returnedRedirectUri } = start;
const finalRedirectUri = returnedRedirectUri || redirectUri; const finalRedirectUri = returnedRedirectUri || redirectUri;
if (finalRedirectUri && isLoopbackHost(finalRedirectUri)) {
printLoopbackRedirectWarning(def.id, finalRedirectUri);
}
process.stdout.write(`\nOpen this URL to authorize:\n ${url}\n\n`); process.stdout.write(`\nOpen this URL to authorize:\n ${url}\n\n`);
if (opts.browser !== false) await openBrowser(url); if (opts.browser !== false) await openBrowser(url);
process.stdout.write( process.stdout.write(

View File

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

View File

@@ -4,7 +4,7 @@ import { join, dirname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url"; import { fileURLToPath, pathToFileURL } from "node:url";
import { platform, totalmem } from "node:os"; import { platform, totalmem } from "node:os";
import { t } from "../i18n.mjs"; import { t } from "../i18n.mjs";
import { writePidFile, cleanupPidFile, waitForServer } from "../utils/pid.mjs"; import { writePidFile, cleanupPidFile, waitForServer, resolveReadyTimeoutMs } from "../utils/pid.mjs";
import { import {
ServerSupervisor, ServerSupervisor,
detectMitmCrash, detectMitmCrash,
@@ -58,6 +58,11 @@ export function registerServe(program) {
.option("--max-restarts <n>", t("serve.max_restarts"), parseInt, 2) .option("--max-restarts <n>", t("serve.max_restarts"), parseInt, 2)
.option("--tray", t("serve.tray") || "Start in the system tray (desktop only)") .option("--tray", t("serve.tray") || "Start in the system tray (desktop only)")
.option("--no-tray", t("serve.no_tray") || "Disable system tray icon") .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( .option(
"--tls-cert <path>", "--tls-cert <path>",
t("serve.tls_cert") || t("serve.tls_cert") ||
@@ -276,7 +281,8 @@ export async function runServe(opts = {}) {
return runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort); return runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort);
} }
if (opts.noRecovery) { // Commander stores `--no-recovery` as `recovery === false`, never as `noRecovery`.
if (opts.recovery === false || opts.noRecovery === true) {
return runWithoutRecovery( return runWithoutRecovery(
serverJs, serverJs,
env, env,
@@ -452,7 +458,8 @@ async function runWithSupervisor(
}); });
if (!showLog) { if (!showLog) {
waitForServer(dashboardPort, 60000).then(async (up) => { const readyTimeoutMs = resolveReadyTimeoutMs({ timeoutMs: opts.readyTimeout });
waitForServer(dashboardPort, readyTimeoutMs).then(async (up) => {
if (up) { if (up) {
if (useTray) { if (useTray) {
const trayReady = await maybeStartTray(dashboardPort, apiPort, supervisor); const trayReady = await maybeStartTray(dashboardPort, apiPort, supervisor);
@@ -489,10 +496,15 @@ async function runWithSupervisor(
// reachable directly while the CLI still looks hung). Surface a clear diagnostic // reachable directly while the CLI still looks hung). Surface a clear diagnostic
// plus whatever stdout/stderr the child buffered instead of going silent. // plus whatever stdout/stderr the child buffered instead of going silent.
export function reportReadinessTimeout(dashboardPort, supervisor) { export function reportReadinessTimeout(dashboardPort, supervisor) {
const readyTimeoutMs = resolveReadyTimeoutMs();
const seconds = Math.round(readyTimeoutMs / 1000);
console.error( console.error(
`\n\x1b[33m⚠ Server did not respond within 60s.\x1b[0m It may still be starting, or may` + `\n\x1b[33m⚠ Server did not respond within ${seconds}s.\x1b[0m It may still be starting, or may` +
` have failed silently.` ` have failed silently.`
); );
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(` 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`); console.error(` Or: rerun with \x1b[36m--log\x1b[0m to see live server output.\n`);

View File

@@ -35,16 +35,24 @@ export function resolveOpencodeTarget(opts = {}) {
baseUrl = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`; 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"]; let apiKey = opts.apiKey ?? opts["api-key"];
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
if (!apiKey) { if (!apiKey) {
try { try {
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT); const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
apiKey = c?.accessToken || c?.apiKey; apiKey = c?.accessToken || c?.apiKey || "";
} catch { } catch {
/* no context auth */ /* no context auth */
} }
} }
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
return { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey }; return { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey };
} }
@@ -177,8 +185,17 @@ export function registerSetupOpencode(program) {
"--allow-container-write", "--allow-container-write",
"Write even when the target is inside a container and not mounted from the host" "Write even when the target is inside a container and not mounted from the host"
) )
.action(async (opts) => { .action(async (opts, cmd) => {
const code = await runSetupOpencodeCommand(opts); // 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);
if (code !== 0) process.exit(code); if (code !== 0) process.exit(code);
}); });
} }

1340
bin/cli/locales/am.json Normal file

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -256,6 +256,7 @@
"max_restarts": "Μέγιστος αριθμός επανεκκινήσεων λόγω σφάλματος εντός 30 δευτερολέπτων πριν την οριστική διακοπή (προεπιλογή: 2)", "max_restarts": "Μέγιστος αριθμός επανεκκινήσεων λόγω σφάλματος εντός 30 δευτερολέπτων πριν την οριστική διακοπή (προεπιλογή: 2)",
"tray": "Εκκίνηση στην περιοχή ειδοποιήσεων (μόνο για επιτραπέζιους υπολογιστές, προαιρετικό)", "tray": "Εκκίνηση στην περιοχή ειδοποιήσεων (μόνο για επιτραπέζιους υπολογιστές, προαιρετικό)",
"no_tray": "Απενεργοποίηση εικονιδίου περιοχής ειδοποιήσεων", "no_tray": "Απενεργοποίηση εικονιδίου περιοχής ειδοποιήσεων",
"ready_timeout": "Χρονικό όριο λήξης του ελέγχου ετοιμότητας σε ms (επίσης OMNIROUTE_READY_TIMEOUT_MS, προεπιλογή 60000)",
"tls_cert": "Διαδρομή προς πιστοποιητικό TLS (PEM) για εξυπηρέτηση HTTPS (επίσης OMNIROUTE_TLS_CERT)", "tls_cert": "Διαδρομή προς πιστοποιητικό TLS (PEM) για εξυπηρέτηση HTTPS (επίσης OMNIROUTE_TLS_CERT)",
"tls_key": "Διαδρομή προς το ιδιωτικό κλειδί TLS (PEM) για εξυπηρέτηση HTTPS (επίσης OMNIROUTE_TLS_KEY)" "tls_key": "Διαδρομή προς το ιδιωτικό κλειδί TLS (PEM) για εξυπηρέτηση HTTPS (επίσης OMNIROUTE_TLS_KEY)"
}, },
@@ -347,6 +348,16 @@
"running": "Ο διακομιστής MCP εκτελείται ({transport})", "running": "Ο διακομιστής MCP εκτελείται ({transport})",
"stopped": "Ο διακομιστής MCP διακόπηκε.", "stopped": "Ο διακομιστής MCP διακόπηκε.",
"restarted": "Ο διακομιστής MCP επανεκκινήθηκε.", "restarted": "Ο διακομιστής MCP επανεκκινήθηκε.",
"stoppedHint": "Εκτελέστε `omniroute mcp enable` για να το ενεργοποιήσετε.",
"enabled": "Ο διακομιστής MCP ενεργοποιήθηκε.",
"disabled": "Ο διακομιστής MCP απενεργοποιήθηκε.",
"enable": {
"description": "Ενεργοποίηση του διακομιστή MCP",
"transport": "Μέθοδος μεταφοράς προς χρήση: stdio|sse|streamable-http"
},
"disable": {
"description": "Απενεργοποίηση του διακομιστή MCP"
},
"call": { "call": {
"description": "Άμεση κλήση εργαλείου MCP", "description": "Άμεση κλήση εργαλείου MCP",
"args": "Αντικείμενο ορισμάτων JSON (ενσωματωμένο)", "args": "Αντικείμενο ορισμάτων JSON (ενσωματωμένο)",
@@ -1093,7 +1104,7 @@
} }
}, },
"combo": { "combo": {
"title": "Combos", "title": "Συνδυασμοί",
"switched": "Ενεργό combo: {name}", "switched": "Ενεργό combo: {name}",
"created": "Δημιουργήθηκε combo: {name}", "created": "Δημιουργήθηκε combo: {name}",
"deleted": "Διαγράφηκε combo: {name}", "deleted": "Διαγράφηκε combo: {name}",

View File

@@ -256,6 +256,7 @@
"max_restarts": "Max crash restarts within 30s before giving up (default: 2)", "max_restarts": "Max crash restarts within 30s before giving up (default: 2)",
"tray": "Start in the system tray (desktop only, opt-in)", "tray": "Start in the system tray (desktop only, opt-in)",
"no_tray": "Disable system tray icon", "no_tray": "Disable system tray icon",
"ready_timeout": "Readiness probe timeout in ms (also OMNIROUTE_READY_TIMEOUT_MS, default 60000)",
"tls_cert": "Path to a TLS certificate (PEM) to serve HTTPS (also OMNIROUTE_TLS_CERT)", "tls_cert": "Path to a TLS certificate (PEM) to serve HTTPS (also OMNIROUTE_TLS_CERT)",
"tls_key": "Path to the TLS private key (PEM) to serve HTTPS (also OMNIROUTE_TLS_KEY)" "tls_key": "Path to the TLS private key (PEM) to serve HTTPS (also OMNIROUTE_TLS_KEY)"
}, },
@@ -347,6 +348,16 @@
"running": "MCP server running ({transport})", "running": "MCP server running ({transport})",
"stopped": "MCP server stopped.", "stopped": "MCP server stopped.",
"restarted": "MCP server restarted.", "restarted": "MCP server restarted.",
"stoppedHint": "Run `omniroute mcp enable` to turn it on.",
"enabled": "MCP server enabled.",
"disabled": "MCP server disabled.",
"enable": {
"description": "Enable the MCP server",
"transport": "Transport to use: stdio|sse|streamable-http"
},
"disable": {
"description": "Disable the MCP server"
},
"call": { "call": {
"description": "Invoke an MCP tool directly", "description": "Invoke an MCP tool directly",
"args": "JSON arguments object (inline)", "args": "JSON arguments object (inline)",

File diff suppressed because it is too large Load Diff

View File

@@ -30,7 +30,7 @@
"opencode": "Installi ja seadista OpenCode'i jaoks kaasas olev @omniroute/opencode-plugin" "opencode": "Installi ja seadista OpenCode'i jaoks kaasas olev @omniroute/opencode-plugin"
}, },
"doctor": { "doctor": {
"title": "OmniRoute Doctor", "title": "OmniRoute'i diagnostika",
"dbOk": "Andmebaas: korras ({path})", "dbOk": "Andmebaas: korras ({path})",
"dbMissing": "Andmebaas: pole lähtestatud — käivita `omniroute setup`", "dbMissing": "Andmebaas: pole lähtestatud — käivita `omniroute setup`",
"portOk": "Port {port}: saadaval", "portOk": "Port {port}: saadaval",
@@ -256,6 +256,7 @@
"max_restarts": "Maksimaalne krahhijärgsete taaskäivituste arv 30 sekundi jooksul enne alla andmist (vaikimisi: 2)", "max_restarts": "Maksimaalne krahhijärgsete taaskäivituste arv 30 sekundi jooksul enne alla andmist (vaikimisi: 2)",
"tray": "Käivita süsteemisalves (ainult töölaual, valikuline)", "tray": "Käivita süsteemisalves (ainult töölaual, valikuline)",
"no_tray": "Keela süsteemisalve ikoon", "no_tray": "Keela süsteemisalve ikoon",
"ready_timeout": "Valmisolekukontrolli ajalõpp millisekundites (ka OMNIROUTE_READY_TIMEOUT_MS, vaikimisi 60000)",
"tls_cert": "TLS-sertifikaadi (PEM) tee HTTPS-i teenindamiseks (ka OMNIROUTE_TLS_CERT)", "tls_cert": "TLS-sertifikaadi (PEM) tee HTTPS-i teenindamiseks (ka OMNIROUTE_TLS_CERT)",
"tls_key": "TLS privaatvõtme (PEM) tee HTTPS-i teenindamiseks (ka OMNIROUTE_TLS_KEY)" "tls_key": "TLS privaatvõtme (PEM) tee HTTPS-i teenindamiseks (ka OMNIROUTE_TLS_KEY)"
}, },
@@ -347,6 +348,16 @@
"running": "MCP server töötab ({transport})", "running": "MCP server töötab ({transport})",
"stopped": "MCP server peatatud.", "stopped": "MCP server peatatud.",
"restarted": "MCP server taaskäivitatud.", "restarted": "MCP server taaskäivitatud.",
"stoppedHint": "Selle sisselülitamiseks käivitage `omniroute mcp enable`.",
"enabled": "MCP-server on lubatud.",
"disabled": "MCP-server on keelatud.",
"enable": {
"description": "Luba MCP-server",
"transport": "Kasutatav transport: stdio|sse|streamable-http"
},
"disable": {
"description": "Keela MCP-server"
},
"call": { "call": {
"description": "Käivita MCP tööriist otse", "description": "Käivita MCP tööriist otse",
"args": "JSON argumentide objekt (otseselt sisestatud)", "args": "JSON argumentide objekt (otseselt sisestatud)",

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

View File

@@ -256,6 +256,7 @@
"max_restarts": "Uaslíon atosuithe titim laistigh de 30 sula bhfágann tú suas (réamhshocrú: 2)", "max_restarts": "Uaslíon atosuithe titim laistigh de 30 sula bhfágann tú suas (réamhshocrú: 2)",
"tray": "Tosaigh i mbosca córas (leicsitheoir amháin, roghnach)", "tray": "Tosaigh i mbosca córas (leicsitheoir amháin, roghnach)",
"no_tray": "Díchumasaigh deilbhín bosca córas", "no_tray": "Díchumasaigh deilbhín bosca córas",
"ready_timeout": "Teorainn ama an tseiceála ullmhachta i ms (OMNIROUTE_READY_TIMEOUT_MS freisin, réamhshocrú 60000)",
"tls_cert": "Cosán go dtí deimhniú TLS (PEM) chun HTTPS a sheirbhísiú (freisin OMNIROUTE_TLS_CERT)", "tls_cert": "Cosán go dtí deimhniú TLS (PEM) chun HTTPS a sheirbhísiú (freisin OMNIROUTE_TLS_CERT)",
"tls_key": "Cosán go dtí eochair phríobháideach TLS (PEM) chun HTTPS a sheirbhísiú (freisin OMNIROUTE_TLS_KEY)" "tls_key": "Cosán go dtí eochair phríobháideach TLS (PEM) chun HTTPS a sheirbhísiú (freisin OMNIROUTE_TLS_KEY)"
}, },
@@ -347,6 +348,16 @@
"running": "Tá freastalaí MCP ag rith ({transport})", "running": "Tá freastalaí MCP ag rith ({transport})",
"stopped": "Tá freastalaí MCP stoptha.", "stopped": "Tá freastalaí MCP stoptha.",
"restarted": "Tá freastalaí MCP atosaíte.", "restarted": "Tá freastalaí MCP atosaíte.",
"stoppedHint": "Rith `omniroute mcp enable` chun é a chur ar siúl.",
"enabled": "Freastalaí MCP cumasaithe.",
"disabled": "Freastalaí MCP díchumasaithe.",
"enable": {
"description": "Cumasaigh an freastalaí MCP",
"transport": "Iompar le húsáid: stdio|sse|streamable-http"
},
"disable": {
"description": "Díchumasaigh an freastalaí MCP"
},
"call": { "call": {
"description": "Glaoigh ar uirlis MCP go díreach", "description": "Glaoigh ar uirlis MCP go díreach",
"args": "Réimse argóintí JSON (inlíne)", "args": "Réimse argóintí JSON (inlíne)",

File diff suppressed because it is too large Load Diff

1340
bin/cli/locales/ha.json Normal file

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

View File

@@ -256,6 +256,7 @@
"max_restarts": "Maksimalni broj ponovnih pokretanja nakon pada unutar 30 s prije odustajanja (zadano: 2)", "max_restarts": "Maksimalni broj ponovnih pokretanja nakon pada unutar 30 s prije odustajanja (zadano: 2)",
"tray": "Pokretanje u programskoj traci (samo za stolna računala, po izboru)", "tray": "Pokretanje u programskoj traci (samo za stolna računala, po izboru)",
"no_tray": "Onemogući ikonu programske trake", "no_tray": "Onemogući ikonu programske trake",
"ready_timeout": "Vremensko ograničenje provjere spremnosti u ms (također OMNIROUTE_READY_TIMEOUT_MS, zadano 60000)",
"tls_cert": "Putanja do TLS certifikata (PEM) za posluživanje HTTPS-a (također OMNIROUTE_TLS_CERT)", "tls_cert": "Putanja do TLS certifikata (PEM) za posluživanje HTTPS-a (također OMNIROUTE_TLS_CERT)",
"tls_key": "Putanja do TLS privatnog ključa (PEM) za posluživanje HTTPS-a (također OMNIROUTE_TLS_KEY)" "tls_key": "Putanja do TLS privatnog ključa (PEM) za posluživanje HTTPS-a (također OMNIROUTE_TLS_KEY)"
}, },
@@ -347,6 +348,16 @@
"running": "MCP poslužitelj je pokrenut ({transport})", "running": "MCP poslužitelj je pokrenut ({transport})",
"stopped": "MCP poslužitelj je zaustavljen.", "stopped": "MCP poslužitelj je zaustavljen.",
"restarted": "MCP poslužitelj je ponovo pokrenut.", "restarted": "MCP poslužitelj je ponovo pokrenut.",
"stoppedHint": "Pokrenite `omniroute mcp enable` da biste ga uključili.",
"enabled": "MCP poslužitelj omogućen.",
"disabled": "MCP poslužitelj onemogućen.",
"enable": {
"description": "Omogući MCP poslužitelj",
"transport": "Prijenos koji će se koristiti: stdio|sse|streamable-http"
},
"disable": {
"description": "Onemogući MCP poslužitelj"
},
"call": { "call": {
"description": "Izravno pozovi MCP alat", "description": "Izravno pozovi MCP alat",
"args": "JSON objekt argumenata (unutarnji)", "args": "JSON objekt argumenata (unutarnji)",

File diff suppressed because it is too large Load Diff

1340
bin/cli/locales/hy.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1340
bin/cli/locales/ig.json Normal file

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

1340
bin/cli/locales/ka.json Normal file

File diff suppressed because it is too large Load Diff

1340
bin/cli/locales/km.json Normal file

File diff suppressed because it is too large Load Diff

1340
bin/cli/locales/kn.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -256,6 +256,7 @@
"max_restarts": "Didžiausias paleidimų iš naujo po strigties skaičius per 30 s prieš nutraukiant bandymus (numatyta: 2)", "max_restarts": "Didžiausias paleidimų iš naujo po strigties skaičius per 30 s prieš nutraukiant bandymus (numatyta: 2)",
"tray": "Paleisti sistemos dėkle (tik darbalaukio programoje, pasirenkama)", "tray": "Paleisti sistemos dėkle (tik darbalaukio programoje, pasirenkama)",
"no_tray": "Išjungti sistemos dėklo piktogramą", "no_tray": "Išjungti sistemos dėklo piktogramą",
"ready_timeout": "Parengties patikros skirtasis laikas ms (taip pat OMNIROUTE_READY_TIMEOUT_MS, numatytoji reikšmė 60000)",
"tls_cert": "Kelias į TLS sertifikatą (PEM), skirtą HTTPS teikti (taip pat OMNIROUTE_TLS_CERT)", "tls_cert": "Kelias į TLS sertifikatą (PEM), skirtą HTTPS teikti (taip pat OMNIROUTE_TLS_CERT)",
"tls_key": "Kelias į privatųjį TLS raktą (PEM), skirtą HTTPS teikti (taip pat OMNIROUTE_TLS_KEY)" "tls_key": "Kelias į privatųjį TLS raktą (PEM), skirtą HTTPS teikti (taip pat OMNIROUTE_TLS_KEY)"
}, },
@@ -347,6 +348,16 @@
"running": "MCP serveris veikia ({transport})", "running": "MCP serveris veikia ({transport})",
"stopped": "MCP serveris sustabdytas.", "stopped": "MCP serveris sustabdytas.",
"restarted": "MCP serveris paleistas iš naujo.", "restarted": "MCP serveris paleistas iš naujo.",
"stoppedHint": "Norėdami jį įjungti, paleiskite `omniroute mcp enable`.",
"enabled": "MCP serveris įjungtas.",
"disabled": "MCP serveris išjungtas.",
"enable": {
"description": "Įjungti MCP serverį",
"transport": "Naudotinas perdavimo būdas: stdio|sse|streamable-http"
},
"disable": {
"description": "Išjungti MCP serverį"
},
"call": { "call": {
"description": "Tiesiogiai iškviesti MCP įrankį", "description": "Tiesiogiai iškviesti MCP įrankį",
"args": "JSON argumentų objektas (įterptasis)", "args": "JSON argumentų objektas (įterptasis)",

View File

@@ -256,6 +256,7 @@
"max_restarts": "Maksimālās avāriju restartēšanas 30 sekunžu laikā pirms padoties (noklusējums: 2)", "max_restarts": "Maksimālās avāriju restartēšanas 30 sekunžu laikā pirms padoties (noklusējums: 2)",
"tray": "Sākt sistēmas tray (tikai darbvirsmas, izvēlēties)", "tray": "Sākt sistēmas tray (tikai darbvirsmas, izvēlēties)",
"no_tray": "Atspējot sistēmas tray ikonu", "no_tray": "Atspējot sistēmas tray ikonu",
"ready_timeout": "Gatavības pārbaudes taimauts milisekundēs (arī OMNIROUTE_READY_TIMEOUT_MS, noklusējuma vērtība 60000)",
"tls_cert": "Ceļš uz TLS sertifikātu (PEM) HTTPS apkalpošanai (arī OMNIROUTE_TLS_CERT)", "tls_cert": "Ceļš uz TLS sertifikātu (PEM) HTTPS apkalpošanai (arī OMNIROUTE_TLS_CERT)",
"tls_key": "Ceļš uz TLS privāto atslēgu (PEM) HTTPS apkalpošanai (arī OMNIROUTE_TLS_KEY)" "tls_key": "Ceļš uz TLS privāto atslēgu (PEM) HTTPS apkalpošanai (arī OMNIROUTE_TLS_KEY)"
}, },
@@ -347,6 +348,16 @@
"running": "MCP serveris darbojas ({transport})", "running": "MCP serveris darbojas ({transport})",
"stopped": "MCP serveris apturēts.", "stopped": "MCP serveris apturēts.",
"restarted": "MCP serveris restartēts.", "restarted": "MCP serveris restartēts.",
"stoppedHint": "Lai to ieslēgtu, palaidiet `omniroute mcp enable`.",
"enabled": "MCP serveris ir iespējots.",
"disabled": "MCP serveris ir atspējots.",
"enable": {
"description": "Iespējot MCP serveri",
"transport": "Izmantojamais transports: stdio|sse|streamable-http"
},
"disable": {
"description": "Atspējot MCP serveri"
},
"call": { "call": {
"description": "Izsaukt MCP rīku tieši", "description": "Izsaukt MCP rīku tieši",
"args": "JSON argumentu objekts (iekšējais)", "args": "JSON argumentu objekts (iekšējais)",

1340
bin/cli/locales/ml.json Normal file

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

View File

@@ -256,6 +256,7 @@
"max_restarts": "L-ogħla numru ta' restarts wara crash fi żmien 30s qabel ma jċedi (default: 2)", "max_restarts": "L-ogħla numru ta' restarts wara crash fi żmien 30s qabel ma jċedi (default: 2)",
"tray": "Bda' fil-caffettar tal-pajjiż (biss desktop, għażla)", "tray": "Bda' fil-caffettar tal-pajjiż (biss desktop, għażla)",
"no_tray": "Iddiżattiva l-ikona tal-caffettar tal-pajjiż", "no_tray": "Iddiżattiva l-ikona tal-caffettar tal-pajjiż",
"ready_timeout": "Limitu taż-żmien tal-verifika tat-tħejjija f'ms (ukoll OMNIROUTE_READY_TIMEOUT_MS, valur predefinit 60000)",
"tls_cert": "Triq għal ċertifikat TLS (PEM) biex isservi HTTPS (ukoll OMNIROUTE_TLS_CERT)", "tls_cert": "Triq għal ċertifikat TLS (PEM) biex isservi HTTPS (ukoll OMNIROUTE_TLS_CERT)",
"tls_key": "Triq għas-sieqa privata tal-TLS (PEM) biex isservi HTTPS (ukoll OMNIROUTE_TLS_KEY)" "tls_key": "Triq għas-sieqa privata tal-TLS (PEM) biex isservi HTTPS (ukoll OMNIROUTE_TLS_KEY)"
}, },
@@ -347,6 +348,16 @@
"running": "Servier MCP qed jaħdem ({transport})", "running": "Servier MCP qed jaħdem ({transport})",
"stopped": "Servier MCP waqaf.", "stopped": "Servier MCP waqaf.",
"restarted": "Servier MCP restartat.", "restarted": "Servier MCP restartat.",
"stoppedHint": "Ħaddem `omniroute mcp enable` biex tattivah.",
"enabled": "Is-server MCP huwa attivat.",
"disabled": "Is-server MCP huwa diżattivat.",
"enable": {
"description": "Attiva s-server MCP",
"transport": "Trasport li għandu jintuża: stdio|sse|streamable-http"
},
"disable": {
"description": "Iddiżattiva s-server MCP"
},
"call": { "call": {
"description": "Sejjaħ għodda MCP direttament", "description": "Sejjaħ għodda MCP direttament",
"args": "Oġġett argomenti JSON (internament)", "args": "Oġġett argomenti JSON (internament)",

1340
bin/cli/locales/my.json Normal file

File diff suppressed because it is too large Load Diff

1340
bin/cli/locales/ne.json Normal file

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

1340
bin/cli/locales/or.json Normal file

File diff suppressed because it is too large Load Diff

1340
bin/cli/locales/pa.json Normal file

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

View File

@@ -30,7 +30,7 @@
"opencode": "Instala e configura o plugin @omniroute/opencode-plugin incluído para o OpenCode" "opencode": "Instala e configura o plugin @omniroute/opencode-plugin incluído para o OpenCode"
}, },
"doctor": { "doctor": {
"title": "OmniRoute Doctor", "title": "Diagnóstico do OmniRoute",
"dbOk": "Banco de dados: OK ({path})", "dbOk": "Banco de dados: OK ({path})",
"dbMissing": "Banco de dados: não inicializado — execute `omniroute setup`", "dbMissing": "Banco de dados: não inicializado — execute `omniroute setup`",
"portOk": "Porta {port}: disponível", "portOk": "Porta {port}: disponível",
@@ -256,6 +256,7 @@
"max_restarts": "Máximo de reinícios em 30s antes de desistir (padrão: 2)", "max_restarts": "Máximo de reinícios em 30s antes de desistir (padrão: 2)",
"tray": "Mostrar ícone na bandeja do sistema (apenas desktop, opt-in)", "tray": "Mostrar ícone na bandeja do sistema (apenas desktop, opt-in)",
"no_tray": "Desabilitar ícone na bandeja do sistema", "no_tray": "Desabilitar ícone na bandeja do sistema",
"ready_timeout": "Tempo limite da verificação de prontidão em ms (também OMNIROUTE_READY_TIMEOUT_MS, padrão 60000)",
"tls_cert": "Caminho para um certificado TLS (PEM) para servir HTTPS (também OMNIROUTE_TLS_CERT)", "tls_cert": "Caminho para um certificado TLS (PEM) para servir HTTPS (também OMNIROUTE_TLS_CERT)",
"tls_key": "Caminho para a chave privada TLS (PEM) para servir HTTPS (também OMNIROUTE_TLS_KEY)" "tls_key": "Caminho para a chave privada TLS (PEM) para servir HTTPS (também OMNIROUTE_TLS_KEY)"
}, },
@@ -301,7 +302,7 @@
"noServer": "Servidor não está em execução. Inicie com: omniroute serve", "noServer": "Servidor não está em execução. Inicie com: omniroute serve",
"title": "Saúde", "title": "Saúde",
"status": "Status: {status}", "status": "Status: {status}",
"uptime": "Uptime: {uptime}", "uptime": "Tempo de atividade: {uptime}",
"requests": "Requisições (24h): {count}", "requests": "Requisições (24h): {count}",
"cost": "Custo (24h): ${cost}" "cost": "Custo (24h): ${cost}"
}, },
@@ -344,6 +345,19 @@
}, },
"mcp": { "mcp": {
"title": "Servidor MCP", "title": "Servidor MCP",
"running": "Servidor MCP em execução ({transport})",
"stopped": "Servidor MCP parado.",
"restarted": "Servidor MCP reiniciado.",
"stoppedHint": "Execute `omniroute mcp enable` para ativá-lo.",
"enabled": "Servidor MCP ativado.",
"disabled": "Servidor MCP desativado.",
"enable": {
"description": "Ativar o servidor MCP",
"transport": "Transporte a ser usado: stdio|sse|streamable-http"
},
"disable": {
"description": "Desativar o servidor MCP"
},
"call": { "call": {
"description": "Invocar uma ferramenta MCP diretamente", "description": "Invocar uma ferramenta MCP diretamente",
"args": "Objeto JSON de argumentos (inline)", "args": "Objeto JSON de argumentos (inline)",
@@ -371,10 +385,7 @@
}, },
"audit": { "audit": {
"description": "Log de auditoria MCP (alias para audit --source mcp)" "description": "Log de auditoria MCP (alias para audit --source mcp)"
}, }
"running": "Servidor MCP em execução ({transport})",
"stopped": "Servidor MCP parado.",
"restarted": "Servidor MCP reiniciado."
}, },
"a2a": { "a2a": {
"skills": { "skills": {
@@ -1093,7 +1104,7 @@
} }
}, },
"combo": { "combo": {
"title": "Combos", "title": "Combinações",
"switched": "Combo ativo: {name}", "switched": "Combo ativo: {name}",
"created": "Combo criado: {name}", "created": "Combo criado: {name}",
"deleted": "Combo removido: {name}", "deleted": "Combo removido: {name}",
@@ -1268,7 +1279,7 @@
"description": "REPL interativo multi-turn com LLM", "description": "REPL interativo multi-turn com LLM",
"model": "Modelo a usar (padrão: auto)", "model": "Modelo a usar (padrão: auto)",
"combo": "Nome do combo a usar", "combo": "Nome do combo a usar",
"system": "System prompt", "system": "Prompt do sistema",
"resume": "Retomar sessão salva pelo nome" "resume": "Retomar sessão salva pelo nome"
}, },
"plugin": { "plugin": {

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

1340
bin/cli/locales/si.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -256,6 +256,7 @@
"max_restarts": "Največje število ponovnih zagonov po sesutju v 30 s, preden se poskušanje opusti (privzeto: 2)", "max_restarts": "Največje število ponovnih zagonov po sesutju v 30 s, preden se poskušanje opusti (privzeto: 2)",
"tray": "Zaženi v sistemski vrstici (samo za namizne sisteme, po izbiri)", "tray": "Zaženi v sistemski vrstici (samo za namizne sisteme, po izbiri)",
"no_tray": "Onemogoči ikono sistemske vrstice", "no_tray": "Onemogoči ikono sistemske vrstice",
"ready_timeout": "Časovna omejitev preverjanja pripravljenosti v ms (tudi OMNIROUTE_READY_TIMEOUT_MS, privzeto 60000)",
"tls_cert": "Pot do potrdila TLS (PEM) za streženje prek HTTPS (tudi OMNIROUTE_TLS_CERT)", "tls_cert": "Pot do potrdila TLS (PEM) za streženje prek HTTPS (tudi OMNIROUTE_TLS_CERT)",
"tls_key": "Pot do zasebnega ključa TLS (PEM) za streženje prek HTTPS (tudi OMNIROUTE_TLS_KEY)" "tls_key": "Pot do zasebnega ključa TLS (PEM) za streženje prek HTTPS (tudi OMNIROUTE_TLS_KEY)"
}, },
@@ -347,6 +348,16 @@
"running": "Strežnik MCP deluje ({transport})", "running": "Strežnik MCP deluje ({transport})",
"stopped": "Strežnik MCP je ustavljen.", "stopped": "Strežnik MCP je ustavljen.",
"restarted": "Strežnik MCP je znova zagnan.", "restarted": "Strežnik MCP je znova zagnan.",
"stoppedHint": "Za vklop zaženite `omniroute mcp enable`.",
"enabled": "Strežnik MCP je omogočen.",
"disabled": "Strežnik MCP je onemogočen.",
"enable": {
"description": "Omogoči strežnik MCP",
"transport": "Prenos, ki naj se uporabi: stdio|sse|streamable-http"
},
"disable": {
"description": "Onemogoči strežnik MCP"
},
"call": { "call": {
"description": "Neposredno prikliči orodje MCP", "description": "Neposredno prikliči orodje MCP",
"args": "Objekt argumentov JSON (v vrstici)", "args": "Objekt argumentov JSON (v vrstici)",

View File

@@ -256,6 +256,7 @@
"max_restarts": "Maksimalan broj ponovnih pokretanja pri padu u toku 30s pre odustajanja (podrazumevano: 2)", "max_restarts": "Maksimalan broj ponovnih pokretanja pri padu u toku 30s pre odustajanja (podrazumevano: 2)",
"tray": "Покретање у системској траци (само десктоп, опционо)", "tray": "Покретање у системској траци (само десктоп, опционо)",
"no_tray": "Онемогући икону у системској траци", "no_tray": "Онемогући икону у системској траци",
"ready_timeout": "Временско ограничење провере спремности у ms (такође OMNIROUTE_READY_TIMEOUT_MS, подразумевано 60000)",
"tls_cert": "Путања до TLS сертификата (PEM) за HTTPS (такође OMNIROUTE_TLS_CERT)", "tls_cert": "Путања до TLS сертификата (PEM) за HTTPS (такође OMNIROUTE_TLS_CERT)",
"tls_key": "Путања до TLS приватног кључа (PEM) за HTTPS (такође OMNIROUTE_TLS_KEY)" "tls_key": "Путања до TLS приватног кључа (PEM) за HTTPS (такође OMNIROUTE_TLS_KEY)"
}, },
@@ -300,7 +301,7 @@
"description": "Провери здравље сервера и статус компоненти", "description": "Провери здравље сервера и статус компоненти",
"noServer": "Сервер није покренут. Покрените са: omniroute serve", "noServer": "Сервер није покренут. Покрените са: omniroute serve",
"title": "Здравље", "title": "Здравље",
"status": "Status: {status}", "status": "Статус: {status}",
"uptime": "Vreme rada: {uptime}", "uptime": "Vreme rada: {uptime}",
"requests": "Zahtevi (24h): {count}", "requests": "Zahtevi (24h): {count}",
"cost": "Trošak (24h): ${cost}" "cost": "Trošak (24h): ${cost}"
@@ -347,6 +348,16 @@
"running": "MCP server je pokrenut ({transport})", "running": "MCP server je pokrenut ({transport})",
"stopped": "MCP server je zaustavljen.", "stopped": "MCP server je zaustavljen.",
"restarted": "MCP server je ponovo pokrenut.", "restarted": "MCP server je ponovo pokrenut.",
"stoppedHint": "Покрените `omniroute mcp enable` да бисте га укључили.",
"enabled": "MCP сервер је омогућен.",
"disabled": "MCP сервер је онемогућен.",
"enable": {
"description": "Омогући MCP сервер",
"transport": "Протокол за пренос: stdio|sse|streamable-http"
},
"disable": {
"description": "Онемогући MCP сервер"
},
"call": { "call": {
"description": "Direktno pozovi MCP alat", "description": "Direktno pozovi MCP alat",
"args": "JSON objekat argumenata (inline)", "args": "JSON objekat argumenata (inline)",
@@ -810,7 +821,7 @@
} }
}, },
"program": { "program": {
"description": "OmniRoute — Smart AI Router with Auto Fallback", "description": "OmniRoute — паметни AI рутер са аутоматским пребацивањем на резервну опцију",
"version": "Прикажи верзију и изађи", "version": "Прикажи верзију и изађи",
"output": "Формат излаза (table, json, jsonl, csv)", "output": "Формат излаза (table, json, jsonl, csv)",
"quiet": "Сакрий небитан излаз", "quiet": "Сакрий небитан излаз",

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1340
bin/cli/locales/uz.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1340
bin/cli/locales/yo.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -14,18 +14,6 @@
"jsonOpt": "以 JSON 格式输出", "jsonOpt": "以 JSON 格式输出",
"yesOpt": "跳过确认" "yesOpt": "跳过确认"
}, },
"program": {
"description": "OmniRoute — 具有自动故障转移的智能 AI 路由器",
"version": "打印版本并退出",
"output": "输出格式table, json, jsonl, csv",
"quiet": "禁止非必要输出",
"no_color": "禁用彩色输出",
"timeout": "HTTP 请求超时(毫秒)",
"api_key": "OmniRoute 服务器的 API 密钥",
"base_url": "OmniRoute 服务器的基础 URL",
"context": "此命令使用的服务器上下文/配置文件",
"lang": "设置 CLI 显示语言(覆盖 OMNIROUTE_LANG"
},
"setup": { "setup": {
"title": "OmniRoute 设置", "title": "OmniRoute 设置",
"passwordPrompt": "管理员密码", "passwordPrompt": "管理员密码",
@@ -145,6 +133,20 @@
"listTitle": "{days} 天内过期的密钥:" "listTitle": "{days} 天内过期的密钥:"
} }
}, },
"authExport": {
"description": "导出已解密的提供者凭据(仅限本地,明文输出)",
"idOpt": "仅导出与此 id/名称/提供者匹配的连接",
"formatOpt": "输出格式json 或 env",
"outOpt": "将输出写入文件而非标准输出(以 0600 权限写入)",
"forceOpt": "确认你了解此操作会打印/写入明文密钥",
"warning": "⚠ 此操作会打印/写入已解密的明文 API 密钥和 OAuth 令牌。请确保你的屏幕、shell 历史记录以及任何输出文件保持私密。",
"confirmHeading": "⚠ 警告:此操作会以明文导出已解密的提供者凭据",
"confirmBody": "此命令会为所选连接解密并打印/写入 apiKey、accessToken、refreshToken 和\nidToken。请将输出视为机密。",
"confirmFooter": "如需确认,请运行:\n omniroute auth export --force",
"missingKey": "导出凭据需要 STORAGE_ENCRYPTION_KEY。",
"notFound": "未找到连接:{id}",
"invalidFormat": "无效格式:{format}。请使用 json 或 env。"
},
"stream": { "stream": {
"description": "使用 SSE 检查模式流式传输聊天响应", "description": "使用 SSE 检查模式流式传输聊天响应",
"file": "从文件读取提示", "file": "从文件读取提示",
@@ -254,6 +256,7 @@
"max_restarts": "30 秒内的最大崩溃重启次数默认2", "max_restarts": "30 秒内的最大崩溃重启次数默认2",
"tray": "显示系统托盘图标(仅桌面,选择加入)", "tray": "显示系统托盘图标(仅桌面,选择加入)",
"no_tray": "禁用系统托盘图标", "no_tray": "禁用系统托盘图标",
"ready_timeout": "就绪探测超时(毫秒)(也可用 OMNIROUTE_READY_TIMEOUT_MS默认 60000",
"tls_cert": "用于提供 HTTPS 服务的 TLS 证书PEM路径也可用 OMNIROUTE_TLS_CERT", "tls_cert": "用于提供 HTTPS 服务的 TLS 证书PEM路径也可用 OMNIROUTE_TLS_CERT",
"tls_key": "用于提供 HTTPS 服务的 TLS 私钥PEM路径也可用 OMNIROUTE_TLS_KEY" "tls_key": "用于提供 HTTPS 服务的 TLS 私钥PEM路径也可用 OMNIROUTE_TLS_KEY"
}, },
@@ -301,7 +304,7 @@
"status": "状态:{status}", "status": "状态:{status}",
"uptime": "运行时间:{uptime}", "uptime": "运行时间:{uptime}",
"requests": "请求数24h{count}", "requests": "请求数24h{count}",
"cost": "成本24h" "cost": "成本24h${cost}"
}, },
"quota": { "quota": {
"description": "显示提供者配额使用情况", "description": "显示提供者配额使用情况",
@@ -345,6 +348,16 @@
"running": "MCP 服务器正在运行({transport}", "running": "MCP 服务器正在运行({transport}",
"stopped": "MCP 服务器已停止。", "stopped": "MCP 服务器已停止。",
"restarted": "MCP 服务器已重启。", "restarted": "MCP 服务器已重启。",
"stoppedHint": "运行 `omniroute mcp enable` 以启用它。",
"enabled": "MCP 服务器已启用。",
"disabled": "MCP 服务器已禁用。",
"enable": {
"description": "启用 MCP 服务器",
"transport": "要使用的传输方式stdio|sse|streamable-http"
},
"disable": {
"description": "禁用 MCP 服务器"
},
"call": { "call": {
"description": "直接调用 MCP 工具", "description": "直接调用 MCP 工具",
"args": "JSON 参数对象(内联)", "args": "JSON 参数对象(内联)",
@@ -807,6 +820,18 @@
"event": "要模拟的事件类型默认request.completed" "event": "要模拟的事件类型默认request.completed"
} }
}, },
"program": {
"description": "OmniRoute — 具有自动故障转移的智能 AI 路由器",
"version": "打印版本并退出",
"output": "输出格式table, json, jsonl, csv",
"quiet": "禁止非必要输出",
"no_color": "禁用彩色输出",
"timeout": "HTTP 请求超时(毫秒)",
"api_key": "OmniRoute 服务器的 API 密钥",
"base_url": "OmniRoute 服务器的基础 URL",
"context": "此命令使用的服务器上下文/配置文件",
"lang": "设置 CLI 显示语言(覆盖 OMNIROUTE_LANG"
},
"files": { "files": {
"description": "管理文件(上传、列出、获取、下载、删除)", "description": "管理文件(上传、列出、获取、下载、删除)",
"list": { "list": {
@@ -907,6 +932,11 @@
"model": "按模型筛选" "model": "按模型筛选"
} }
}, },
"radar": {
"description": "检查并同步本地 Radar 目录订阅源",
"status": "显示本地 Radar 设置和订阅源缓存状态",
"sync": "通过本地服务器同步目录、推荐、优惠和 Intel"
},
"resilience": { "resilience": {
"description": "检查和管理弹性机制", "description": "检查和管理弹性机制",
"status": { "status": {
@@ -1234,8 +1264,8 @@
"description": "管理 OmniRoute 开机自启Linuxsystemd 用户服务)", "description": "管理 OmniRoute 开机自启Linuxsystemd 用户服务)",
"enable": "启用开机自启", "enable": "启用开机自启",
"disable": "禁用开机自启", "disable": "禁用开机自启",
"status": "显示自启状态", "toggle": "切换开机自启",
"toggle": "切换开机自启" "status": "显示自启状态"
}, },
"runtime": { "runtime": {
"description": "管理本地运行时依赖", "description": "管理本地运行时依赖",
@@ -1262,25 +1292,6 @@
"update": "更新已安装的插件", "update": "更新已安装的插件",
"scaffold": "搭建新的插件模板" "scaffold": "搭建新的插件模板"
}, },
"authExport": {
"description": "导出已解密的提供者凭据(仅限本地,明文输出)",
"idOpt": "仅导出与此 id/名称/提供者匹配的连接",
"formatOpt": "输出格式json 或 env",
"outOpt": "将输出写入文件而非标准输出(以 0600 权限写入)",
"forceOpt": "确认你了解此操作会打印/写入明文密钥",
"warning": "⚠ 此操作会打印/写入已解密的明文 API 密钥和 OAuth 令牌。请确保你的屏幕、shell 历史记录以及任何输出文件保持私密。",
"confirmHeading": "⚠ 警告:此操作会以明文导出已解密的提供者凭据",
"confirmBody": "此命令会为所选连接解密并打印/写入 apiKey、accessToken、refreshToken 和\nidToken。请将输出视为机密。",
"confirmFooter": "如需确认,请运行:\n omniroute auth export --force",
"missingKey": "导出凭据需要 STORAGE_ENCRYPTION_KEY。",
"notFound": "未找到连接:{id}",
"invalidFormat": "无效格式:{format}。请使用 json 或 env。"
},
"radar": {
"description": "检查并同步本地 Radar 目录订阅源",
"status": "显示本地 Radar 设置和订阅源缓存状态",
"sync": "通过本地服务器同步目录、推荐、优惠和 Intel"
},
"launch": { "launch": {
"description": "启动指向 OmniRoute 的 Claude Code本地或远程使用 --profile", "description": "启动指向 OmniRoute 的 Claude Code本地或远程使用 --profile",
"token": "Claude 客户端应发送的令牌ANTHROPIC_AUTH_TOKEN", "token": "Claude 客户端应发送的令牌ANTHROPIC_AUTH_TOKEN",

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