mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 09:42:15 +03:00
Compare commits
116 Commits
maint/cher
...
feat/modal
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4e321f1dd | ||
|
|
92499fc876 | ||
|
|
2d42c54a67 | ||
|
|
bbaa820efb | ||
|
|
964e321588 | ||
|
|
06a691f8fe | ||
|
|
40e1562850 | ||
|
|
63bf4b909d | ||
|
|
fade1cbab3 | ||
|
|
3aa0a7306f | ||
|
|
9d06dcfd6c | ||
|
|
9747123f00 | ||
|
|
ab8f3e83b7 | ||
|
|
8685815235 | ||
|
|
e53ca645f3 | ||
|
|
1828b6c14a | ||
|
|
cf0f416da3 | ||
|
|
2c8cb81fc0 | ||
|
|
1287a4f2ec | ||
|
|
471d39bc1f | ||
|
|
99781e000d | ||
|
|
159b90125c | ||
|
|
70fca36f9d | ||
|
|
94d386dbc5 | ||
|
|
3ff25a484f | ||
|
|
2bea34b0a6 | ||
|
|
f3ba480c08 | ||
|
|
8e812c53e5 | ||
|
|
f39daccfcb | ||
|
|
40f9a81b66 | ||
|
|
c8f4efa158 | ||
|
|
bfc846fae6 | ||
|
|
5f16478c7e | ||
|
|
493cae2ed9 | ||
|
|
a6f095c583 | ||
|
|
26daeabcf2 | ||
|
|
5560b90322 | ||
|
|
c6de098630 | ||
|
|
ee088198bf | ||
|
|
792626c3c4 | ||
|
|
dc66e3be29 | ||
|
|
7d4584db56 | ||
|
|
181828625b | ||
|
|
b7bad4006b | ||
|
|
c6195cec62 | ||
|
|
8c009f55e9 | ||
|
|
14b6183693 | ||
|
|
0bb3bc093c | ||
|
|
88b05fdb92 | ||
|
|
9f4186a2b2 | ||
|
|
afd5169b69 | ||
|
|
326a975084 | ||
|
|
0d36801f20 | ||
|
|
754ba0fa86 | ||
|
|
240b9b5bc4 | ||
|
|
2afaab52a1 | ||
|
|
04483ed211 | ||
|
|
3a87712b56 | ||
|
|
382449d593 | ||
|
|
807a0d2022 | ||
|
|
54bba33e2f | ||
|
|
a54c1f73af | ||
|
|
57fb90d734 | ||
|
|
5eba045175 | ||
|
|
06727f0e74 | ||
|
|
5f75abe4a2 | ||
|
|
58f0ff1b41 | ||
|
|
54ec0bf7a1 | ||
|
|
3eab125745 | ||
|
|
c34f69928c | ||
|
|
25e8a27045 | ||
|
|
e66feceb76 | ||
|
|
0298b82442 | ||
|
|
60c0f19289 | ||
|
|
44eb33a256 | ||
|
|
a83f85b765 | ||
|
|
a5dc8731de | ||
|
|
2c8093f73f | ||
|
|
b294c76719 | ||
|
|
439854ce65 | ||
|
|
5e4a684bad | ||
|
|
eb817932e4 | ||
|
|
788d56fa07 | ||
|
|
64c204f68c | ||
|
|
ee5f84c168 | ||
|
|
37ef7a7d9b | ||
|
|
f725dac4b2 | ||
|
|
904a54d602 | ||
|
|
1b9cd59740 | ||
|
|
b5c522604f | ||
|
|
1e2ef990e6 | ||
|
|
2dcb5bd422 | ||
|
|
390690dd0a | ||
|
|
15b9cb194f | ||
|
|
58ab721fe2 | ||
|
|
038035f937 | ||
|
|
da771f8d86 | ||
|
|
7a0515038b | ||
|
|
cf7e4148c5 | ||
|
|
153f453b0b | ||
|
|
9233a9483c | ||
|
|
3b411c7da7 | ||
|
|
3ea174d531 | ||
|
|
f1fda94047 | ||
|
|
034db3c3dd | ||
|
|
4dbbaeb746 | ||
|
|
2404556a62 | ||
|
|
3edb317889 | ||
|
|
247a00ce66 | ||
|
|
88b883a6c2 | ||
|
|
71e7e2bfd7 | ||
|
|
02dd5e723e | ||
|
|
ee94b0378d | ||
|
|
b263905984 | ||
|
|
848fca7eb0 | ||
|
|
ec09949e6d |
264
.env.example
264
.env.example
@@ -67,6 +67,14 @@ DISABLE_SQLITE_AUTO_BACKUP=false
|
||||
# Used by: src/shared/utils/rateLimiter.ts
|
||||
# Example: redis://localhost:6379 (or redis://redis:6379 in Docker)
|
||||
# REDIS_URL=redis://localhost:6379
|
||||
# Host interface docker-compose publishes the Redis sidecar on.
|
||||
# Default: 127.0.0.1 (loopback only). The compose Redis runs WITHOUT
|
||||
# `requirepass`, and app containers reach it over the compose network
|
||||
# (redis:6379) — the published port is only for host-side tooling. Setting this
|
||||
# to 0.0.0.0 exposes an unauthenticated Redis to your whole LAN.
|
||||
# REDIS_BIND_HOST=127.0.0.1
|
||||
# Host port for the compose Redis sidecar. Default: 6379.
|
||||
# REDIS_PORT=6379
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 3. NETWORK & PORTS
|
||||
@@ -337,14 +345,18 @@ ALLOW_API_KEY_REVEAL=false
|
||||
# OMNIROUTE_CHAT_HEAVY_TOOL_COUNT=64
|
||||
# Conservative string-size token estimate that classifies a request as heavyweight. Default 32000.
|
||||
# OMNIROUTE_CHAT_HEAVY_ESTIMATED_TOKENS=32000
|
||||
# Hard message-count cap; excess receives compact-required 413. Default 800.
|
||||
# OMNIROUTE_CHAT_HARD_MAX_MESSAGES=800
|
||||
# Optional opt-in hard message-count cap; excess receives compact-required 413 before
|
||||
# compression can run. Unset/0 (the default) means no history cap: heap growth is bounded
|
||||
# by OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT and the heap-pressure shed instead. Set a positive
|
||||
# value only on memory-constrained deployments that need a hard ceiling.
|
||||
# OMNIROUTE_CHAT_HARD_MAX_MESSAGES=0
|
||||
|
||||
# Hard cap (bytes) for a non-streaming upstream response buffered fully into memory
|
||||
# (#5152). Past this the upstream reader is cancelled and the request fails fast
|
||||
# instead of growing an unbounded string until the V8 heap is exhausted.
|
||||
# Used by: open-sse/handlers/chatCore/nonStreamingResponseBody.ts
|
||||
# Default: 67108864 (64 MB)
|
||||
# OMNIROUTE_FORWARDING_HEADER_BUDGET_BYTES=768
|
||||
# OMNIROUTE_MAX_NONSTREAMING_RESPONSE_BYTES=67108864
|
||||
|
||||
# CORS configuration — controls which cross-origin browser clients can call the API.
|
||||
@@ -445,6 +457,13 @@ ALLOW_API_KEY_REVEAL=false
|
||||
# Default: false
|
||||
# OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS=false
|
||||
|
||||
# Per-model concurrency cap for round-robin combos (#9100).
|
||||
# Used by: open-sse/services/comboConfig.ts — the round-robin combo semaphore
|
||||
# was hard-capped at 3 concurrent requests per model with no override, which
|
||||
# serialized higher-concurrency traffic behind that cap.
|
||||
# Validated to >= 1, clamped to <= 32. | Default: 3
|
||||
# COMBO_CONCURRENCY_PER_MODEL=3
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 7. URLS & CLOUD SYNC
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -520,16 +539,6 @@ NEXT_PUBLIC_BASE_URL=http://localhost:20128
|
||||
# cost of more upstream polling; raise to reduce request volume.
|
||||
# OMNIROUTE_CGPT_WEB_PRO_POLL_INTERVAL_MS=4000
|
||||
|
||||
# Timeout for the /api/jobs/:id/run-now endpoint, in milliseconds.
|
||||
# This bounds the CALL, not the job. runNow() dispatches the handler with
|
||||
# `void` and returns as soon as it has decided to start, so on the normal
|
||||
# path it resolves in milliseconds. It only matters when the job is already
|
||||
# running: runNow() then waits for the in-flight run before starting the
|
||||
# queued one, and this timeout prevents that wait from hanging forever.
|
||||
# Used by: src/app/api/jobs/[id]/run-now/route.ts
|
||||
# Default: 30000 (30 seconds)
|
||||
# OMNIROUTE_RUNNOW_TIMEOUT_MS=30000
|
||||
|
||||
# Public cloud URL — client-side mirror of CLOUD_URL.
|
||||
NEXT_PUBLIC_CLOUD_URL=
|
||||
|
||||
@@ -788,6 +797,16 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
|
||||
# Disable the proactive recovery scheduler entirely (default: false).
|
||||
# OMNIROUTE_DISABLE_CONNECTION_RECOVERY=false
|
||||
|
||||
# Proactive Claude warmup scheduler (#8848): fires a trivial request to opted-in
|
||||
# OAuth connections on a cron schedule (America/Los_Angeles) so accounts do not
|
||||
# hit the 5-hour sliding window cold. Off by default — set ENABLED=1 and flip
|
||||
# per-connection flags in settings.claudeWarmup.connections to activate.
|
||||
# Used by: src/lib/warmupScheduler.ts.
|
||||
# OMNIROUTE_WARMUP_ENABLED=false
|
||||
# OMNIROUTE_WARMUP_CRON="0 7 * * *"
|
||||
# OMNIROUTE_WARMUP_CONCURRENCY=3
|
||||
# OMNIROUTE_WARMUP_MODEL=
|
||||
|
||||
# Background job interval for budget reset checks (ms). Default: 600000 (10m).
|
||||
# Used by: src/lib/jobs/budgetResetJob.ts. Floor: 10000.
|
||||
#OMNIROUTE_BUDGET_RESET_JOB_INTERVAL_MS=600000
|
||||
@@ -852,6 +871,12 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
|
||||
# (>= 3 retrievals = never compressed). 1 disables the ramp (binary skip at the threshold only).
|
||||
# Used by: open-sse/services/compression/engines/ccr/index.ts. Default: 2.
|
||||
#COMPRESSION_CCR_RETRIEVAL_RAMP_FACTOR=2
|
||||
# CCR durable block store (#9061). The in-memory store loses blocks to LRU eviction, the TTL, a
|
||||
# restart, or a retrieve landing on another instance, while the model is told it can retrieve them
|
||||
# verbatim. Set to false to keep blocks in memory only, at the cost of that promise. Blocks over
|
||||
# 512KB and cloud runtimes are memory-only regardless.
|
||||
# Used by: open-sse/services/compression/engines/ccr/index.ts. Default: true.
|
||||
#COMPRESSION_CCR_DURABLE_STORE=true
|
||||
# T08/H5 — usage-observed prefix freeze (OPT-IN, default off). When enabled, a system prompt seen
|
||||
# >= THRESHOLD times is treated as a stable cacheable prefix and preserved from compression even
|
||||
# for providers the static cache-aware heuristic does not recognize (freeze = preserve, never
|
||||
@@ -1036,6 +1061,17 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
|
||||
# VISION_BRIDGE_BASE_URL=
|
||||
# VISION_BRIDGE_API_KEY=
|
||||
|
||||
# ── Raycast Pro (local auto-import) ──
|
||||
# Raycast Pro AI is a reverse-engineered, unofficial API — local/personal use
|
||||
# only (no OAuth client_id/secret; token is captured via macOS Auto-Import
|
||||
# from the Keychain + local Raycast SQLite DB, or pasted manually). These
|
||||
# vars are optional manual overrides used by open-sse/services/raycast.ts
|
||||
# and the direct-probe benchmark script scripts/raycast/usage-benchmark.mjs.
|
||||
# RAYCAST_BEARER_TOKEN=
|
||||
# RAYCAST_DEVICE_ID=
|
||||
# RAYCAST_AID=
|
||||
# RAYCAST_SIG_SECRET=
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ⚠️ GOOGLE OAUTH (Antigravity) & OTHER PROVIDERS — REMOTE SERVERS
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -1176,6 +1212,17 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# fallback when FETCH_TIMEOUT_MS is unset. Default: 120000 (2 min).
|
||||
# OMNIROUTE_DEFAULT_FETCH_TIMEOUT_MS=120000
|
||||
|
||||
# ── Proxy/relay fetch (connection pooling, #9158) ──
|
||||
# Used by: open-sse/utils/proxyFetch.ts.
|
||||
# A hung relay must fail BEFORE the client/agent timeout (typically 30s) so the
|
||||
# caller sees a relay-specific failure instead of a generic upstream timeout.
|
||||
# Capped at 29000ms so this timeout always fires first. Default: 25000 (25s).
|
||||
# OMNIROUTE_RELAY_FETCH_TIMEOUT_MS=25000
|
||||
|
||||
# Shared retry backoff (ms) for the direct/relay/proxy retry-once paths.
|
||||
# 0 = retry immediately. Default: 10.
|
||||
# OMNIROUTE_RETRY_BACKOFF_MS=10
|
||||
|
||||
# ── Firecrawl web-fetch executor ──
|
||||
# Point at a self-hosted Firecrawl instance (defaults to the public cloud API).
|
||||
# When set to a non-cloud base URL, the API key becomes optional.
|
||||
@@ -1237,6 +1284,14 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# OMNIROUTE_BROWSER_POOL=on
|
||||
# WEB_COOKIE_USE_BROWSER=0
|
||||
|
||||
# ── Adobe Firefly browser sign-in (system Chrome/Edge CDP) ──
|
||||
# Used by: open-sse/services/adobeFireflyBrowserLogin.ts. The Firefly login
|
||||
# flow drives a real, system-installed Chrome or Microsoft Edge via CDP so the
|
||||
# user can sign in interactively; the executable is auto-detected from common
|
||||
# install paths per OS. Set this to override that detection (e.g. a portable
|
||||
# install or a non-standard path) when auto-detection fails.
|
||||
# OMNIROUTE_LOGIN_BROWSER_PATH=
|
||||
|
||||
# ── Circuit breaker thresholds and reset windows ──
|
||||
# Used by: open-sse/config/constants.ts → src/lib/resilience/settings.ts.
|
||||
# Defaults match historical PROVIDER_PROFILES values (post-scaling for
|
||||
@@ -1348,6 +1403,10 @@ APP_LOG_TO_FILE=true
|
||||
# Default: 100000
|
||||
# CALL_LOGS_TABLE_MAX_ROWS=100000
|
||||
|
||||
# Force detailed request logging on or off, overriding the dashboard setting.
|
||||
# Values: true | false | Default: unset (follow dashboard setting)
|
||||
# ENABLE_REQUEST_LOGS=false
|
||||
|
||||
# Maximum age for orphaned active request log entries before the in-memory
|
||||
# pending-request reaper removes them. Accepts milliseconds.
|
||||
# Default: 3600000 (1 hour)
|
||||
@@ -1355,7 +1414,7 @@ APP_LOG_TO_FILE=true
|
||||
|
||||
# Whether call log pipeline capture stores stream chunks when enabled in settings.
|
||||
# Only applies when call_log_pipeline_enabled=true.
|
||||
# Default: true
|
||||
# Default: false (opt-in — saves disk: stream chunks are the biggest call-log artifact)
|
||||
# CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS=true
|
||||
|
||||
# Maximum call log artifact size for pipeline captures, in KB.
|
||||
@@ -1367,10 +1426,9 @@ APP_LOG_TO_FILE=true
|
||||
# bodies is retained in the database.
|
||||
# Used by: open-sse/handlers/chatCore.ts — cloneBoundedChatLogPayload()
|
||||
# CHAT_LOG_TEXT_LIMIT=65536 # Max string length before truncation (default: 64 KB)
|
||||
# CHAT_LOG_ARRAY_TAIL_ITEMS=128 # Number of array items retained from tail (default: 128)
|
||||
# CHAT_LOG_ARRAY_TAIL_ITEMS=128 # Number of array items retained from tail (default: 128)
|
||||
# CHAT_LOG_MAX_DEPTH=6 # Max nesting depth before truncation (default: 6)
|
||||
# CHAT_LOG_MAX_OBJECT_KEYS=80 # Max object keys retained (default: 80, 0 = no limit)
|
||||
# CHAT_LOG_MAX_BODY_KB=1024 # Max request/response body size before summarizing, in KB (default: 1024)
|
||||
|
||||
# Maximum rows in the proxy_logs SQLite table.
|
||||
# Default: 100000
|
||||
@@ -1425,10 +1483,6 @@ APP_LOG_TO_FILE=true
|
||||
# Default: ~/.omniroute/plugins/ Override in dev/CI to point at a local plugin tree.
|
||||
# OMNIROUTE_PLUGIN_PATH=
|
||||
|
||||
# Allow plugins to request the 'exec' permission (spawn child processes from the
|
||||
# plugin worker sandbox). Disabled by default; set to 1 to enable (local operator only).
|
||||
# OMNIROUTE_PLUGINS_ALLOW_EXEC=0
|
||||
|
||||
# ── Prompt cache (system prompt deduplication) ──
|
||||
# Used by: open-sse/services — caches identical system prompts across requests.
|
||||
# PROMPT_CACHE_MAX_SIZE=50 # Max cached entries (default: 50)
|
||||
@@ -1486,6 +1540,15 @@ APP_LOG_TO_FILE=true
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 19. MODEL SYNC (Dev)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Enable the models.dev capability sync. Default: false (opt-in only).
|
||||
# Also settable from Dashboard > Settings > AI. This variable wins over that
|
||||
# setting whenever it is set to anything non-empty, in either direction, so a
|
||||
# deployment can pin the sync on or off without depending on database state
|
||||
# surviving a rebuild. Leave it unset to let the dashboard toggle decide.
|
||||
# On: 1, true, yes or on (any casing). Any other value is off.
|
||||
# Used by: src/lib/modelsDevSync.ts
|
||||
# MODELS_DEV_SYNC_ENABLED=false
|
||||
|
||||
# Development-time model catalog sync interval in seconds.
|
||||
# Used by: src/lib/modelsDevSync.ts
|
||||
# Default: 86400 (24 hours)
|
||||
@@ -1508,6 +1571,14 @@ APP_LOG_TO_FILE=true
|
||||
# Default: 86400000 (24 hours)
|
||||
# OPENROUTER_CATALOG_TTL_MS=86400000
|
||||
|
||||
# Enrich the dashboard providers list with OpenRouter weekly ranking stats.
|
||||
# ON by default; set false to skip the background fetch entirely (#9324).
|
||||
# Used by: src/lib/catalog/openrouterProviderStats.ts
|
||||
# OPENROUTER_PROVIDER_STATS_ENABLED=true
|
||||
# Cache TTL for the OpenRouter provider stats snapshot, in ms.
|
||||
# Default: 86400000 (24 hours)
|
||||
# OPENROUTER_PROVIDER_STATS_TTL_MS=86400000
|
||||
|
||||
# ── Model catalog response shape ──
|
||||
# Include display-friendly name fields in /v1/models responses.
|
||||
# Disable for clients that expect model IDs only.
|
||||
@@ -1528,6 +1599,13 @@ APP_LOG_TO_FILE=true
|
||||
# DESIGNER_WEB_POLL_TIMEOUT_MS=60000 # Max wait for job completion (default: 60s)
|
||||
# DESIGNER_WEB_POLL_INTERVAL_MS=2000 # Poll frequency (default: 2s)
|
||||
|
||||
# ── Adobe Firefly (Image Upscale) ──
|
||||
# Base delay (ms) for the submit-retry exponential backoff when Adobe Firefly's
|
||||
# upscale job submission is rate-limited. Used by:
|
||||
# open-sse/services/adobeFireflyUpscale.ts::submitRetryDelayMs.
|
||||
# Default: 8000 (20 under NODE_ENV=test/VITEST/NODE_TEST_CONTEXT).
|
||||
# ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS=8000
|
||||
|
||||
# ── AWS Bedrock (Kiro / Audio) ──
|
||||
# Region used to construct AWS Bedrock endpoints. Used by:
|
||||
# src/lib/providers/validation.ts and open-sse/handlers/audioSpeech.ts.
|
||||
@@ -1622,6 +1700,26 @@ APP_LOG_TO_FILE=true
|
||||
# Used by: src/lib/services/bootstrap.ts, src/app/api/services/mux/_lib.ts
|
||||
# MUX_SERVICE_PORT=8322
|
||||
|
||||
# ── Dario embedded service ──
|
||||
# Override the host/port the embedded Dario (Claude Code subscription proxy)
|
||||
# daemon binds to and is reached at. Always bound to 127.0.0.1 — never
|
||||
# configurable to 0.0.0.0. Rarely needed — defaults to 127.0.0.1:3456.
|
||||
# Used by: src/lib/services/installers/dario.ts, src/lib/services/bootstrap.ts,
|
||||
# src/app/api/services/dario/_lib.ts, src/app/api/services/dario/admin/_lib.ts,
|
||||
# open-sse/executors/dario.ts
|
||||
# DARIO_HOST=127.0.0.1
|
||||
# DARIO_PORT=3456
|
||||
|
||||
# ── Dario embedded service ──
|
||||
# Override the host/port the embedded Dario (Claude Code subscription proxy)
|
||||
# daemon binds to and is reached at. Always bound to 127.0.0.1 — never
|
||||
# configurable to 0.0.0.0. Rarely needed — defaults to 127.0.0.1:3456.
|
||||
# Used by: src/lib/services/installers/dario.ts, src/lib/services/bootstrap.ts,
|
||||
# src/app/api/services/dario/_lib.ts, src/app/api/services/dario/admin/_lib.ts,
|
||||
# open-sse/executors/dario.ts
|
||||
# DARIO_HOST=127.0.0.1
|
||||
# DARIO_PORT=3456
|
||||
|
||||
# ── Local hostnames (Docker networking) ──
|
||||
# Comma-separated additional hostnames treated as "local" for provider routing.
|
||||
# Used by: open-sse/config/providerRegistry.ts — allows Docker service names.
|
||||
@@ -1795,7 +1893,7 @@ APP_LOG_TO_FILE=true
|
||||
|
||||
# Log request shape (content-type + content-length) for large chat payloads.
|
||||
# Used by: src/app/api/v1/chat/completions/route.ts. Set to "0" to silence.
|
||||
# Default: enabled.
|
||||
# Default: disabled (opt-in).
|
||||
# OMNIROUTE_LOG_REQUEST_SHAPE=1
|
||||
|
||||
# Write raw (untruncated) request/response JSON in call log artifacts.
|
||||
@@ -1854,6 +1952,18 @@ APP_LOG_TO_FILE=true
|
||||
# ── Devin CLI binary path ──
|
||||
# Used by: open-sse/executors/devin-cli.ts. Default: looked up via PATH.
|
||||
# CLI_DEVIN_BIN=devin
|
||||
# Agentic bridge-only binary override. The bridge still executes ACP stdio only.
|
||||
# CLI_DEVIN_AGENTIC_BIN=devin
|
||||
# Required isolated HOME for the agentic Devin child process.
|
||||
# DEVIN_AGENTIC_HOME=/home/bridge
|
||||
# Bounded ACP turn timeout in milliseconds. Default: 120000.
|
||||
# DEVIN_AGENTIC_ACP_TIMEOUT_MS=120000
|
||||
# Agentic bridge model aliases. Values must keep the devin-cli-agentic/ prefix.
|
||||
# DEVIN_BRIDGE_MODEL=devin-cli-agentic/swe-1-7
|
||||
# DEVIN_BRIDGE_SONNET_MODEL=devin-cli-agentic/swe-1-7
|
||||
# DEVIN_BRIDGE_OPUS_MODEL=devin-cli-agentic/swe-1-7
|
||||
# DEVIN_BRIDGE_HAIKU_MODEL=devin-cli-agentic/swe-1-7
|
||||
# DEVIN_BRIDGE_SUBAGENT_MODEL=devin-cli-agentic/swe-1-7
|
||||
|
||||
# ── Command Code (custom CLI) callback ──
|
||||
# Local port used for OAuth-style callbacks from the Command Code CLI helper.
|
||||
@@ -1908,6 +2018,15 @@ APP_LOG_TO_FILE=true
|
||||
# CHANGELOG_BASE_REF=origin/release/v0.0.0
|
||||
# ALLOW_CHANGELOG_REMOVALS=1
|
||||
|
||||
# ── Remote audio provider nodes ──
|
||||
# Used by: src/app/api/v1/_shared/audioProviderNodes.ts — lets the /v1/audio/*
|
||||
# routes use an OpenAI-compatible provider node hosted outside localhost.
|
||||
# OFF by default: routing audio to a remote host changes egress identity, so it
|
||||
# must be an explicit operator decision. Loopback/private nodes (localhost,
|
||||
# 127.0.0.1, 172.16-31.x) are always allowed and unaffected by this flag.
|
||||
# When enabled, the node authenticates with the API key stored on its connection.
|
||||
# AUDIO_REMOTE_PROVIDER_NODES=false
|
||||
|
||||
# ── 1Proxy egress pool ──
|
||||
# Used by: src/lib/oneproxySync.ts — fetches proxy nodes from the OmniRoute
|
||||
# CrofAI 1Proxy service. Disable, override URL, or tune the import quality.
|
||||
@@ -2115,6 +2234,11 @@ PLAYGROUND_COMPARE_MAX_COLUMNS=4
|
||||
# MEMORY_TYPED_DECAY_EPISODIC_DAYS=30 # episodic TTL in days; 0 = episodic immune too
|
||||
# MEMORY_TYPED_DECAY_ACCESS_IMMUNITY=3 # access_count >= N → immune; 0 disables access immunity
|
||||
# MEMORY_TYPED_DECAY_SWEEP_INTERVAL=0 # periodic sweep interval (seconds); 0 = no periodic sweep
|
||||
# ─── Memory Backend Connectors (Generic HTTP) ──────────────────────────────
|
||||
# NOTION_API_KEY=
|
||||
# NOTION_API_URL=
|
||||
# OBSIDIAN_API_KEY=
|
||||
# OBSIDIAN_API_URL=
|
||||
# AgentBridge + Traffic Inspector (Group A)
|
||||
|
||||
# AgentBridge
|
||||
@@ -2130,6 +2254,15 @@ INSPECTOR_MAX_BODY_KB=1024
|
||||
INSPECTOR_MASK_SECRETS=true
|
||||
INSPECTOR_LLM_HOSTS_EXTRA=
|
||||
INSPECTOR_INTERNAL_INGEST_TOKEN=
|
||||
# Shared secret for identity-preserving internal REST hops (#9260): when an
|
||||
# OmniRoute component calls another local OmniRoute route, this token (sent as
|
||||
# x-omniroute-internal-service-token) marks the request as internal so the
|
||||
# original caller identity is preserved. OPT-IN: unset disables the mechanism.
|
||||
# Used by: src/lib/api/internalServiceAuth.ts
|
||||
# OMNIROUTE_INTERNAL_SERVICE_TOKEN=
|
||||
# File-based variant (secret-file pattern; wins only when the inline var is
|
||||
# unset): path to a file whose trimmed content is the token.
|
||||
# OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE=
|
||||
# Quota Sharing (Group B — planos 16+22)
|
||||
QUOTA_STORE_DRIVER=sqlite # sqlite | redis
|
||||
# QUOTA_STORE_REDIS_URL= # ex.: redis://localhost:6379 (apenas quando driver=redis)
|
||||
@@ -2240,6 +2373,11 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
|
||||
# Host port for the 1-click Redis launcher. Default: 6379. Bump if the host
|
||||
# already binds 6379. The container's internal port stays 6379.
|
||||
# OMNIROUTE_REDIS_HOST_PORT=
|
||||
# Host interface the 1-click Redis launcher publishes on. Default: 127.0.0.1
|
||||
# (loopback only). The launcher starts Redis WITHOUT a password, so binding
|
||||
# 0.0.0.0 hands every host on your LAN an unauthenticated Redis — only widen
|
||||
# this if you also set a password on the instance yourself.
|
||||
# OMNIROUTE_REDIS_BIND_HOST=
|
||||
# Redis image used by the 1-click Redis launcher. Default: redis:7-alpine.
|
||||
# Override to redis:8-alpine or a private registry mirror as needed.
|
||||
# OMNIROUTE_REDIS_IMAGE=
|
||||
@@ -2342,20 +2480,80 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# VIBEPROXY_DATA_DIR=
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Telegram Mini App (inbound bot webhook + Mini App chat)
|
||||
# Used by: src/lib/telegram/*, src/app/api/telegram/update/route.ts
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Bot token from @BotFather (<numeric_id>:<secret>). Enables the inbound
|
||||
# update webhook and doubles as the HMAC secret for Mini App initData
|
||||
# verification. When unset, /api/telegram/update returns 503.
|
||||
# ── Internal service auth (management-plane service-to-service calls) ─────────
|
||||
# Inline token for internal service authentication; prefer the _FILE variant in
|
||||
# containerized deployments so the secret never lands in the environment table.
|
||||
# OMNIROUTE_INTERNAL_SERVICE_TOKEN=
|
||||
# Path to a file containing the internal service token (overrides the inline var).
|
||||
# OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE=
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 26. RADAR FEED (SELF-HOSTING)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Optional add-on (feature flag RADAR_ENABLED, default off — see feature flag
|
||||
# settings, not an env var) that overlays a signed, freshly-curated free-model
|
||||
# catalog on top of the release baseline. All four variables below are optional
|
||||
# and only needed to point the client at a self-hosted/forked feed or
|
||||
# supporter-key flow instead of the default OmniRoute Radar service. Used by:
|
||||
# src/lib/radar/sync.ts, src/lib/radar/pinnedKeys.ts, src/lib/radar/links.ts.
|
||||
|
||||
# Base URL of the Radar feed service. Overrides the built-in default so forks
|
||||
# and self-hosters can point at their own signed feed.
|
||||
# RADAR_FEED_URL=https://radar.omniroute.online
|
||||
|
||||
# Ed25519 public key (base64-DER SPKI or PEM) used to verify the feed
|
||||
# signature, replacing the pinned default key. Required when self-hosting a
|
||||
# feed signed with a different key pair.
|
||||
# RADAR_FEED_PUBKEY=
|
||||
|
||||
# URL the dashboard's "I'm a contributor" button opens (GitHub OAuth
|
||||
# supporter-key claim flow). No pricing/value lives in this repo — only the
|
||||
# link.
|
||||
# RADAR_CONTRIBUTOR_CLAIM_URL=https://radar.omniroute.online/auth/github
|
||||
|
||||
# URL the dashboard's "Support the project" button opens (payment/plans
|
||||
# page). No pricing/value lives in this repo — only the link.
|
||||
# RADAR_SUPPORTER_PLANS_URL=https://radar.omniroute.online/planos
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 27. RELEASE v3.8.50 ADDITIONS
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# Heavy chat admission queue wait before returning retryable 503. Set 0 for the
|
||||
# legacy immediate rejection. Used by: src/shared/middleware/chatBodyAdmission.ts.
|
||||
# Default: 5000 (5 seconds)
|
||||
# OMNIROUTE_CHAT_ADMISSION_QUEUE_MS=5000
|
||||
|
||||
# Timeout for /api/jobs/:id/run-now while it waits for an in-flight run.
|
||||
# Used by: src/app/api/jobs/[id]/run-now/route.ts. Default: 30000 (30 seconds)
|
||||
# OMNIROUTE_RUNNOW_TIMEOUT_MS=30000
|
||||
|
||||
# Maximum request/response body size before chat-log summarization, in KiB.
|
||||
# Used by: src/lib/chatLogTruncation.ts. Default: 1024
|
||||
# CHAT_LOG_MAX_BODY_KB=1024
|
||||
|
||||
# Adobe Firefly browser renewal and durable session cache (enabled by default).
|
||||
# Used by: open-sse/services/adobeFireflySession.ts.
|
||||
# ADOBE_FIREFLY_BROWSER_REFRESH=1
|
||||
# ADOBE_FIREFLY_SESSION_DISK=1
|
||||
# Minimum spacing between submissions and the extra pause after every third success.
|
||||
# ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS=12000
|
||||
# ADOBE_FIREFLY_BATCH_EXTRA_GAP_MS=15000
|
||||
# Chrome CDP runtime used by Adobe Firefly renewal. True headless is debug-only:
|
||||
# Adobe colligo normally rejects risk tokens minted without a headed browser.
|
||||
# ADOBE_FIREFLY_CHROME_CDP_PORT=9334
|
||||
# ADOBE_FIREFLY_CHROME_VISIBLE=0
|
||||
# ADOBE_FIREFLY_CHROME_HEADLESS=0
|
||||
# ADOBE_FIREFLY_CHROME_FORCE_RESTART=0
|
||||
# ADOBE_FIREFLY_CHROME_PING=auto
|
||||
# ADOBE_FIREFLY_LOGIN_WAIT_MS=0
|
||||
# ADOBE_FIREFLY_FORTER_WAIT_MS=45000
|
||||
# Optional absolute Chrome executable; auto-detected when unset.
|
||||
# CHROME_PATH=
|
||||
|
||||
# Telegram Mini App bridge. The update endpoint remains disabled while the bot
|
||||
# token is unset. Used by: src/lib/telegram/* and src/app/api/telegram/update/route.ts.
|
||||
# TELEGRAM_BOT_TOKEN=
|
||||
|
||||
# Model used for Telegram chat replies (default: auto/chat).
|
||||
# TELEGRAM_DEFAULT_MODEL=auto/chat
|
||||
|
||||
# Bot API base URL override (for proxies/self-hosted Bot API servers).
|
||||
# TELEGRAM_BOT_API_BASE=https://api.telegram.org
|
||||
|
||||
# Timeout (ms) for outbound Bot API calls (sendMessage/setWebhook).
|
||||
# TELEGRAM_WEBHOOK_TIMEOUT_MS=60000
|
||||
|
||||
16
.github/workflows/ci.yml
vendored
16
.github/workflows/ci.yml
vendored
@@ -811,7 +811,12 @@ jobs:
|
||||
|
||||
test-bun-sqlite:
|
||||
name: Bun SQLite Compatibility
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
fail-fast: false
|
||||
runs-on: ${{ matrix.os }}
|
||||
continue-on-error: ${{ matrix.os == 'windows-latest' }}
|
||||
timeout-minutes: 10
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
|
||||
@@ -824,6 +829,15 @@ jobs:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- name: Install Bun (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
powershell -c "iwr bun.sh/install.ps1 -useb | iex"
|
||||
echo "$env:USERPROFILE\.bun\bin" | Out-File -FilePath $env:GITHUB_PATH -Append
|
||||
- name: Install Bun (non-Windows)
|
||||
if: runner.os != 'Windows'
|
||||
run: npm install -g bun
|
||||
- run: npm run test:bun:db
|
||||
|
||||
test-vitest:
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -275,3 +275,6 @@ docker-compose.yml.bak
|
||||
# ignora um SYMLINK chamado _tasks; /_tasks (ancorado) cobre arquivo/symlink/dir na raiz
|
||||
# e impede que um git add -A recapture o symlink (incidente 2026-08-08).
|
||||
/_tasks
|
||||
|
||||
# CLI local cache/state
|
||||
.playwright-cli
|
||||
|
||||
1
changelog.d/features/5696-layer-a-capability-filter.md
Normal file
1
changelog.d/features/5696-layer-a-capability-filter.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(core):** add Layer A capability filter at router (#5696)
|
||||
1
changelog.d/features/8468-bun-windows-ci-coverage.md
Normal file
1
changelog.d/features/8468-bun-windows-ci-coverage.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(ci): add windows-latest leg to test-bun-sqlite job (#8468)
|
||||
@@ -0,0 +1 @@
|
||||
- **feat(gemini):** recursive type:object injection in schema normalizer + empty choices interceptor for streaming (#9268)
|
||||
1
changelog.d/features/9322-nanogpt-endpoint-surface.md
Normal file
1
changelog.d/features/9322-nanogpt-endpoint-surface.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(providers):** expanded the NanoGPT (`nano-gpt.com`) upstream provider from chat-only to the full OpenAI-compatible endpoint surface: audio transcriptions (`/api/v1/audio/transcriptions`), audio speech (`/api/v1/audio/speech`), video generation (`/api/v1/video/generations`), embeddings (`/v1/embeddings`), and the Responses API (`responsesBaseUrl` → `/api/v1/responses`) ([#9322](https://github.com/diegosouzapw/OmniRoute/issues/9322))
|
||||
@@ -1 +1 @@
|
||||
- **sse:** New-API / One-API / Sub2API aggregator balance detection for compatible nodes — with the "Aggregator Gateway" toggle on, OmniRoute queries the aggregator's `/api/user/self` to read the account balance, shows it as a dashboard badge and lets quota-preflight routing skip exhausted accounts. Gated by the `NEWAPI_AGGREGATOR_BALANCE` feature flag (default off), with a `quotaPerUnit` override for aggregators that do not use the default 500000 units/$1 rate ([#9415](https://github.com/diegosouzapw/OmniRoute/issues/9415))
|
||||
- **feat(sse):** New-API/One-API/Sub2API aggregator balance detection for compatible provider nodes — when the "Aggregator Gateway" toggle is enabled, OmniRoute queries the aggregator's `/api/user/self` endpoint to detect the account balance; the dashboard shows a balance badge and quota-preflight routing skips exhausted accounts. Gated by the `NEWAPI_AGGREGATOR_BALANCE` feature flag (default: off), with a custom `quotaPerUnit` override for aggregators that use a different rate than the default 500000 units/$1 ([#9415](https://github.com/diegosouzapw/OmniRoute/issues/9415))
|
||||
|
||||
1
changelog.d/features/9782-modality-bridge-settings.md
Normal file
1
changelog.d/features/9782-modality-bridge-settings.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(settings):** add a dedicated Modality Bridge settings page with Vision controls, runtime stats, and URL-addressable Audio and Video tabs ([#9782](https://github.com/diegosouzapw/OmniRoute/pull/9782))
|
||||
1
changelog.d/fixes/9496-kimi-k3-responses-replay.md
Normal file
1
changelog.d/fixes/9496-kimi-k3-responses-replay.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(translator): preserve authentic K3 Responses reasoning by model across providers, keep it on the matching assistant turn, and make Kimi Coding prefer client reasoning then cached replay before its empty-marker fallback (#9496)
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(providers):** new per-provider `noAuthFallbackDisabledProviders` setting lets operators disable the synthetic anonymous (no-auth) credential fallback for API-key providers whose static definition declares `anonymousFallback: true` (e.g. `opencode-go`, `opencode-zen`) — upstream endpoints now reject anonymous requests with `401 Missing API key`, so the fallback added latency and caused UI health/reconnect churn. Real keyed connections keep working and recover automatically once quota state clears; true no-auth providers (`opencode`, `mimocode`, …) are unaffected, with `blockedProviders` remaining their disable mechanism. Default behavior is unchanged ([#9675](https://github.com/diegosouzapw/OmniRoute/pull/9675))
|
||||
1
changelog.d/fixes/i18n-cc-alias-unclosed-tags.md
Normal file
1
changelog.d/fixes/i18n-cc-alias-unclosed-tags.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(i18n): re-escape CC discovery-alias `claude/<provider>/<model>` to HTML entities so next-intl stops logging INVALID_MESSAGE: UNCLOSED_TAG on provider detail pages (#8747 regression)
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(test):** reconcile test expectations that drifted from the code they guard on `release/v3.8.50` — auth/vision/provider schema snapshots, and three context-aware combo compatibility assertions that contradicted the same file's own stated contract (catalog-too-small targets stay available as runtime fallback rather than being dropped). The combo assertions were masked by an unresolved import that stopped `combo.ts` from loading at all, so they only become reachable once that import is repaired.
|
||||
@@ -0,0 +1 @@
|
||||
- Repair release-sweep regressions in locale and environment contracts, package metadata, scripts, dependency, size and dead-code ratchets, OpenAPI coverage, Telegram error sanitization, Openference public-credential handling, DB-module classification, resilience UI test assertions, strict CodeBuddy CN tests, Lite compression typing, and the job-registry migration number.
|
||||
@@ -0,0 +1 @@
|
||||
- Let the release-green validator finish the test-masking gate on loaded runners while preserving the existing timeout for every other full-CI gate, and report Node.js `ETIMEDOUT` errors as explicit timeout failures.
|
||||
@@ -115,6 +115,7 @@
|
||||
"recharts",
|
||||
"safe-regex",
|
||||
"selfsigned",
|
||||
"sharp",
|
||||
"size-limit",
|
||||
"smol-toml",
|
||||
"socks",
|
||||
|
||||
@@ -2036,11 +2036,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"tests/unit/codebuddy-cn-provider.test.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"tests/unit/codex-banked-reset-credits-5199.test.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 7
|
||||
|
||||
@@ -163,9 +163,11 @@
|
||||
"_rebaseline_2026_06_20_4389_thinking_toolchoice": "Re-baseline base.ts 1387->1399 (#4389): tool_choice-forced thinking guard at the existing Claude wire-image injection chokepoint (effThinking gate avoids the Anthropic 400 when tool_choice forces a tool). Cohesive guard; structural shrink tracked in #3501.",
|
||||
"_rebaseline_2026_07_18_6979_codex_test": "PR #6979 own growth: executor-codex.test.ts 1340->1347 (+7 = generalized ensureThinkingBudget assertion added to the existing codex thinking-budget cases). antigravity-test bump 942->977 REVERTED here: #7408's test split dropped that file to 888, so this PR's +35 fits under the original 942 frozen cap.",
|
||||
"_rebaseline_2026_07_24_8354_logs_timeline_sidebar": "PR #8354 (hartmark, feature/scrolling-log) own growth: src/shared/constants/sidebarVisibility/sections.ts 812->820 (+8, the single new logs-timeline SidebarItemDefinition entry added to LOGS_GROUP.items for the new /dashboard/logs/timeline scrolling request-timeline page). Irreducible data-literal wiring at the existing sidebar-sections chokepoint, same shape as every other item in the file; not extractable without an ad-hoc single-item exception to the file's otherwise-uniform multi-line item style.",
|
||||
"_rebaseline_2026_08_09_v3850_post_sweep_tip": "Release-captain reconciliation of absolute file-size drift on pure tip 382449d593 after the authorized cherry-pick wave. The affected production growth already belongs to merged, tested commits: Adobe Firefly CDP/session recovery (#9881), model capability serialization (#9296), Modality Bridge request wiring (#9759), disconnect-grace/reasoning-cache chatCore wiring (#9653/#9183), stacked Lite precedence, and Responses tool-call index/argument handling (#9843 plus the release translator fixes). This repair adds only the compact migration-146 retroactive guard, covered by db-job-registry-migration-renumber-139.test.ts. Values are the exact check:file-size split-newline measurements and remain shrink-only; structural decomposition remains tracked by the existing #3501 notes.",
|
||||
"cap": 1000,
|
||||
"testCap": 1000,
|
||||
"testFrozen": {
|
||||
"tests/unit/adobe-firefly.test.ts": 1136,
|
||||
"tests/unit/reasoning-cache.test.ts": 1035,
|
||||
"_rebaseline_2026_06_27_5193_antigravity_test": "#5193 own test growth: oauth-providers-config.test.ts 870->873 (+3: antigravity projectId assertion + 50ms tick for the now fire-and-forget onboarding, matching the no-PKCE/no-openid flow).",
|
||||
"_rebaseline_2026_07_02_5928_base_red": "web-cookie-providers-new.test.ts 845->850: #5928 (test(security) Kimi Web URL host parse, CodeQL #689) grew the file +5 lines and merged into release/v3.8.44 WITHOUT rebaselining, leaving a fast-gates base-red that blocked every subsequent PR->release. Test growth is legitimate (a security regression test); maintainer absorbs the drift here. Frozen at 850.",
|
||||
@@ -355,7 +357,7 @@
|
||||
"open-sse/executors/deepseek-web.ts": 1148,
|
||||
"open-sse/executors/grok-web.ts": 1044,
|
||||
"open-sse/executors/muse-spark-web.ts": 1405,
|
||||
"open-sse/handlers/chatCore.ts": 5042,
|
||||
"open-sse/handlers/chatCore.ts": 5061,
|
||||
"open-sse/handlers/imageGeneration.ts": 3101,
|
||||
"open-sse/handlers/responseSanitizer.ts": 1128,
|
||||
"open-sse/handlers/search.ts": 1536,
|
||||
@@ -364,12 +366,15 @@
|
||||
"open-sse/mcp-server/server.ts": 1448,
|
||||
"open-sse/mcp-server/tools/advancedTools.ts": 1120,
|
||||
"open-sse/services/accountFallback.ts": 1978,
|
||||
"open-sse/services/adobeFireflyClient.ts": 2385,
|
||||
"open-sse/services/adobeFireflyBrowserLogin.ts": 1362,
|
||||
"open-sse/services/adobeFireflyChromeRuntime.ts": 1201,
|
||||
"open-sse/services/adobeFireflyClient.ts": 2999,
|
||||
"open-sse/services/adobeFireflySession.ts": 1003,
|
||||
"open-sse/services/claudeCodeCompatible.ts": 1202,
|
||||
"open-sse/services/combo.ts": 3648,
|
||||
"open-sse/services/compression/strategySelector.ts": 1060,
|
||||
"open-sse/services/compression/strategySelector.ts": 1061,
|
||||
"open-sse/services/rateLimitManager.ts": 1167,
|
||||
"open-sse/translator/response/openai-responses.ts": 1224,
|
||||
"open-sse/translator/response/openai-responses.ts": 1271,
|
||||
"open-sse/utils/cursorAgentProtobuf.ts": 1505,
|
||||
"open-sse/utils/stream.ts": 2889,
|
||||
"src/app/(dashboard)/dashboard/HomePageClient.tsx": 1388,
|
||||
@@ -396,7 +401,7 @@
|
||||
"src/app/api/v1/models/catalog.ts": 1597,
|
||||
"src/lib/db/apiKeys.ts": 1529,
|
||||
"src/lib/db/core.ts": 1639,
|
||||
"src/lib/db/migrationRunner.ts": 1094,
|
||||
"src/lib/db/migrationRunner.ts": 1101,
|
||||
"src/lib/db/models.ts": 1097,
|
||||
"src/lib/db/providers.ts": 1034,
|
||||
"src/lib/memory/retrieval.ts": 1073,
|
||||
@@ -406,8 +411,8 @@
|
||||
"src/shared/components/RequestLoggerV2.tsx": 1629,
|
||||
"src/shared/components/analytics/charts.tsx": 1035,
|
||||
"src/shared/services/cliRuntime.ts": 1122,
|
||||
"src/sse/handlers/chat.ts": 1904,
|
||||
"src/sse/services/auth.ts": 2520,
|
||||
"src/sse/handlers/chat.ts": 1918,
|
||||
"src/sse/services/auth.ts": 2508,
|
||||
"tests/unit/account-fallback-service.test.ts": 1572,
|
||||
"tests/unit/provider-validation-specialty.test.ts": 2985,
|
||||
"open-sse/executors/hyperagent.ts": 1026,
|
||||
@@ -426,9 +431,6 @@
|
||||
"_rebaseline_2026_07_28_8863_firefly_detail_level": "PR #8863 (fix/adobe-firefly-gpt-detail-level-max) own growth: adobeFireflyClient.ts 2317->2322 (+5 = gpt-image detailLevel defaulting to maximal at the existing payload-build site). Covered by tests/unit/adobe-firefly.test.ts.",
|
||||
"_rebaseline_2026_07_29_8281_home_quickstart_prefetch": "Release v3.8.49 base-red fix (no PR — captain sweep): src/app/(dashboard)/dashboard/HomePageClient.tsx 1377->1381 (+4). #8292 added prefetch={false} to the sidebar but left /home's five quick-start Links prefetching, so first paint still fired 12 speculative RSC requests — caught by navigation.spec.ts only after the e2e helper bug (APP_ROUTE_PATTERN missing /home) was repaired in the same cycle. Growth is the five prefetch attributes; it was offset first by extracting the repeated className literals (INLINE_LINK x4, DOCS_LINK x1), which collapsed five wrapped <Link> blocks back to one line each — a naive fix measured 1391. Guard: tests/unit/sidebar-prefetch-policy-8281.test.ts.",
|
||||
"_rebaseline_2026_08_02_v3850_agentrouter_responses": "Release v3.8.50 AgentRouter/Codex compatibility reconciliation. open-sse/executors/base.ts 1562->1578: #9190 wires AgentRouter's selected Claude/OpenAI/Responses protocol through the existing executor URL, auth, identity-header and fingerprint chokepoints; the reusable alternate resolver remains outside base.ts. open-sse/utils/stream.ts 2887->2889: #9213 evaluates Responses ID and usage normalization independently so response.completed always receives finite usage.total_tokens instead of short-circuiting after an ID rewrite. tests/unit/chatcore-translation-paths.test.ts 2769->2776: #9191 updates the existing Claude-Code bridge assertions for the dynamic AgentRouter wire image. PR #9224 offsets its own chatCore growth by extracting the AgentRouter protocol decisions into chatCore/agentRouterProtocol.ts, leaving chatCore below its frozen ceiling. Covered by agentrouter executor/chatCore protocol tests, chatcore translation-path tests, and responses-commentary-passthrough tests.",
|
||||
"_rebaseline_2026_08_08_v3850_base_drift_batch_9757": "Base drift on release/v3.8.50, not own growth: the 08-06..08-08 merge batches grew 12 already-frozen (or newly-landed) files without carrying their rebaselines — the dedicated rebaseline PR #9616 was closed as 'superseded' but its file-size entries never actually reached the base, and later merges (#8894 combos page, #9539 EditConnectionModal, #8895 models route, #9294/#9293 catalog, #9541 db/core, #8970 tokenHealthCheck, #8925 mcp schemas+server, #8890 accountFallback, #9467 chat.ts, #8931 openai-to-kiro, ProxyRegistryManager) kept growing them. All 12 values re-measured on THIS branch's tree (= pure tip + this PR's 1-line chat.ts fix, which adds zero lines). This PR's own source changes (chat.ts identifier restore, stream.ts format carve-out) do not grow any frozen file past these values.",
|
||||
"_rebaseline_2026_08_08_migration_135_collision": "fix(db): resolve migration version 135 numbering collision — #9449's 135_connection_runtime_state.sql and #8908's 135_migrate_model_capability_max_token.sql both claimed version 135 (#9449 branched before #8908 merged and never got renumbered before landing on release/v3.8.50), which threw 'Migration version collision detected' the moment ANY code touched the database — a fresh install/deploy from this tip cannot even boot. Renumbered the later-landing file to 140 (next free slot) and added the matching isSchemaAlreadyApplied('140') retroactive guard, matching the established pattern already used for the prior 135/136 -> 137/138 renumber in the same file. Own growth: src/lib/db/migrationRunner.ts 1084->1094 (+10, the new case block) — irreducible, matches the existing per-case guard pattern exactly. Covered by tests/unit/migration-135-numbering-collision.test.ts (2/2), confirmed failing (reproducing the exact live crash) against the pre-fix colliding filenames, passing after.",
|
||||
"_rebaseline_2026_08_02_9259_rolling_rpm": "PR #9259 (issue #8733) own growth: open-sse/services/rateLimitManager.ts baseline 1060->1167 (+107; final source 1153). The existing withRateLimit chokepoint now composes process-local rolling RPM leases with Bottleneck admission, releases pre-dispatch leases on queue timeout/abort/connection disable, preserves caller abort reasons, and wires 429/header state into the extracted rollingRpmGate.ts. The remaining growth is irreducible lifecycle wiring at the dispatch boundary plus the real watchdog test hooks needed to verify queued-wedge recovery; moving it further would obscure lease ownership and Bottleneck cleanup. Covered by the focused rate-limit manager/sliding-window suite (33/33); distributed multi-instance coordination remains explicitly out of scope.",
|
||||
"_rebaseline_2026_07_25_dario_upstream_proxy_selector": "PR #8523 (Dario embedded service): upstream-proxy mode selector replaces the binary CLIProxyAPI toggle with Native/CLIProxyAPI/Dario/Fallback + a fallback-backend picker. ProviderDetailPageClient.tsx 798->804 (+6, new hook fields threaded through to ConnectionsListPanel), ConnectionRow.tsx 942->958 (+16, the mode <select> + conditional fallback-backend <select> replacing a single pill button), useProviderConnections.ts 954->986 (+32, upstreamProxyMode/upstreamProxyFallbackBackend state + handleSetUpstreamProxyMode, handleToggleCliproxyapiMode kept as a thin backward-compat wrapper for the existing hook-shape test). All additive UI/state for the new modes — no unrelated refactor.",
|
||||
"_rebaseline_2026_08_02_9242_token_health_transient": "PR #9242 (fix/refresh-circuit-transient): src/lib/tokenHealthCheck.ts 1021 (new file, above cap 1000). The file consolidates token-refresh health checking logic that was previously scattered across auth.ts and tokenRefresh.ts. Cohesive single-responsibility module for refresh circuit state management; not extractable without splitting the refresh state machine. Covered by tests/unit/tokenHealthCheck-transient.test.ts.",
|
||||
"_rebaseline_2026_07_28_8870_firefly_ref_cap_timeout": "PR #8870 (fix/adobe-firefly-gpt-ref-cap-timeout) own growth: adobeFireflyClient.ts 2322->2385 (+63 = gpt-image subject-ref hard cap at 2 + adaptive poll timeout budget (base 300s + 60s/ref, max 600s) + defensive .slice on referenceBlobs for gpt/nano/generic families). Fixes live 504s on multi-screenshot listing jobs (Featured Promo / Box Art) where 3–4+ subject refs stall colligo until the old 180s poll budget expires. Helpers adobeFireflyMaxImageRefs/adobeFireflyImageTimeoutMs live next to the existing payload/poll chokepoint (not extractable without splitting the wire recipe mid-PR). Covered by tests/unit/adobe-firefly.test.ts (ref-cap + timeout cases). Structural shrink tracked in #3501.",
|
||||
@@ -440,6 +442,7 @@
|
||||
"_rebaseline_2026_08_06b_v3850_sweepreds_drift": "Segunda reconciliacao de 2026-08-06 (/sweep-reds sobre o tip puro 2ddbbc61a6): 3 arquivos voltaram a passar do frozen apos os merges do mesmo dia, com atribuicao 1:1 por commit. (1) src/app/(dashboard)/dashboard/providers/page.tsx 1928->1944 e (2) open-sse/executors/base.ts 1635->1640, ambos do #9515 (feat(radar): flag-gated signed free-model catalog overlay, commit e7f6b1d130) — o overlay do Radar entra por wiring nos chokepoints ja existentes (a resolucao/verificacao do catalogo assinado mora fora destes dois arquivos); +16 e +5 linhas liquidas nao sao extraiveis sem inventar um leaf por callsite. (3) open-sse/services/accountFallback.ts 1966->1972 do #8704 (commit c4527f97bd), +6 linhas de dados em CREDITS_EXHAUSTED_SIGNALS ('has been exhausted', fixes #8631). src/sse/handlers/chat.ts 1880>1877 tambem estava violando e NAO entra aqui de proposito: e drenado por encolhimento na PR #9598, sem rebaseline. Crescimento proprio DESTA PR: src/lib/db/migrationRunner.ts 1077->1084 (+7) — o guard retroativo em isSchemaAlreadyApplied para os arquivos renumerados 137/138, exigido pela propria mensagem de erro de colisao do runner (ambas as migracoes sao ALTER TABLE ADD COLUMN puro, nao idempotente). Dois `case` + dois `return hasColumn(...)` + 3 linhas de comentario dentro do switch existente; nao extraivel.",
|
||||
"_rebaseline_2026_08_06c_v3850_sweepreds_pr2": "Segunda PR do /sweep-reds (fix/release-v3.8.50-basereds-0806b): tests/unit/provider-models-route.test.ts 1784->1787 (medido pelo gate, que conta split(\"\\n\").length) (+2 apos compressao de comentarios) — alinhamento de contrato forcado por dois merges do dia: #9106 tornou gemini-3.1-pro-high user-callable (a entry do alias entra na lista esperada do teste de discovery-retry, +1 linha de dado + 1 de comentario) e ff012ff420 adicionou onboardUser como bootstrap hop (exclusao no mock, ja comprimida a 1 linha). Nao ha o que encolher sem apagar o comentario que explica o porque.",
|
||||
"_rebaseline_2026_08_07_v3850_sweepreds_pr2_toolnamemap": "tests/unit/translator-openai-to-gemini.test.ts 1616->1619 (+3). O frozen estava EXATAMENTE no tamanho da base, entao qualquer linha nova viola. #9568 (c9a3361e5a) fez buildChangedToolNameMap emitir entradas IDENTIDADE (o Gemini minusculiza nomes de tool nas respostas, entao o tradutor de resposta precisa da chave para mapear de volta), o que passou a incluir `_toolNameMap` no envelope Antigravity de qualquer request com tools. As 3 linhas sao: a chave nova na lista esperada de Object.keys, 1 comentario explicando POR QUE ela aparece (sem ele o proximo leitor tenta remove-la de novo) e 1 assert do CONTEUDO do map — presenca de chave sozinha nao provaria a entrada identidade, que e justamente o comportamento novo. Nao ha o que extrair: e alinhamento de contrato dentro de um teste existente.",
|
||||
"_rebaseline_2026_08_08_9634_migration_139_guard": "PR #9634 (fix/release-v3850-basereds) own growth, re-measured on e0ce95c59 after rebase: src/lib/db/migrationRunner.ts 1094->1096 (+2, the isSchemaAlreadyApplied case-139 retroactive guard for the renumbered ccr migration). Irreducible, matches the per-case guard pattern exactly. Covered by tests/unit/migration-135-numbering-collision.test.ts.",
|
||||
"_rebaseline_2026_06_22_4644_deepseek_web_tools": "PR #4644 (BugsBag/robust deepseek-web tool-call parsing): open-sse/executors/deepseek-web.ts 1117->1125 (+8). The new agentic tool-call path emits surrounding text + reasoning before tool_calls and swaps to the dedicated deepseekWebTools.ts parser; the +8 lines are cohesive wiring at the existing transformSSE chokepoint (the parser itself lives in the new deepseekWebTools.ts file, already under cap). The PR's own fast-gate (PR->release) does not run check:file-size, so this surfaced only at release reconcile. Covered by tests/unit/deepseek-web-tools-variants.test.ts + deepseek-web-tools-execute.test.ts.",
|
||||
"_rebaseline_2026_06_23_4712_deepseek_web_tool_results": "PR for #4712 (deepseek-web drops role:tool): open-sse/executors/deepseek-web.ts 1125->1148 (+23). messagesToPrompt() now folds role:\\\"tool\\\" results into the single-prompt transcript (recovering the tool name from the preceding assistant tool_calls by tool_call_id) instead of silently dropping them; the lines are cohesive wiring inside the existing function. Covered by tests/unit/deepseek-web-tool-result-prompt-4712.test.ts.",
|
||||
"_rebaseline_2026_06_24_headroom_strategy": "Headroom-aware connection selection (dario technique): combo.ts 3168->3180 (+12 = a new `else if (strategy === \\\"headroom\\\")` dispatch branch in handleComboChat that delegates to orderTargetsByHeadroom + its log line, plus the import). The actual logic lives OUT of the god-file: the pure ranker rankByHeadroom/computeHeadroom is the new leaf open-sse/services/combo/headroomRanking.ts (91 LOC, <cap) and the async orderer orderTargetsByHeadroom is appended to the existing open-sse/services/combo/quotaStrategies.ts (<cap) next to its sibling reset-aware/reset-window orderers (reuses their connection-expansion machinery). headroom = 1 - max(util_5h, util_7d) from getSaturation (src/lib/quota/saturationSignals.ts), prefers the connection with the most free capacity. Only the dispatch wiring is irreducible at the existing combo strategy chokepoint (mirrors the reset-aware/reset-window/context-optimized branches); not extractable without hiding the call site. fill-first stays default; all existing strategies untouched. Covered by tests/unit/combo-headroom-ranking.test.ts (pure helper) + tests/unit/combo-headroom-strategy.test.ts (orderer, saturation injected). Structural shrink of combo.ts tracked in #3501.",
|
||||
@@ -520,7 +523,7 @@
|
||||
"open-sse/services/rateLimitManager.ts": "1167",
|
||||
"open-sse/translator/response/openai-responses.ts": "1204",
|
||||
"open-sse/utils/cursorAgentProtobuf.ts": "1505",
|
||||
"open-sse/utils/stream.ts": "2889",
|
||||
"open-sse/utils/stream.ts": 2915,
|
||||
"src/app/(dashboard)/dashboard/HomePageClient.tsx": "1388",
|
||||
"src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": "1031",
|
||||
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": "3117",
|
||||
@@ -546,7 +549,7 @@
|
||||
"src/lib/tokenHealthCheck.ts": "1053",
|
||||
"src/lib/db/apiKeys.ts": "1529",
|
||||
"src/lib/db/core.ts": "1639",
|
||||
"src/lib/db/migrationRunner.ts": "1094",
|
||||
"src/lib/db/migrationRunner.ts": "1096",
|
||||
"src/lib/db/models.ts": "1097",
|
||||
"src/lib/db/providers.ts": "1034",
|
||||
"src/lib/memory/retrieval.ts": "1073",
|
||||
@@ -567,5 +570,9 @@
|
||||
"open-sse/utils/sseHeartbeat.ts": "142",
|
||||
"_rebaseline_2026_08_04_9305_sse_comments": "#9305 fix: broadened sseCommentsEnabled()",
|
||||
"_rebaseline_2026_08_09_v3850_release_close": "Release v3.8.50 close reconciliation on e0ce95c592: src/sse/handlers/chat.ts 1904->1918 is the irreducible request-pipeline wiring from #9759 that invokes the Modality Bridge guardrail without moving its implementation into the handler; covered by the 17 Vision Bridge canaries plus the PR-1 focused suite. open-sse/translator/response/openai-responses.ts 1204->1215 is #9168's Responses tool-call argument delta buffering/normalization at the existing translator state-machine chokepoint; covered by its dedicated translator regression tests. Both values are measured by check:file-size (split-newline semantics), and the gate remains frozen at the new exact sizes.",
|
||||
"_rebaseline_2026_08_08_toolcall_message_index_collision": "fix(responses-api): tool call after a text message collided on the same output_index. own growth: open-sse/translator/response/openai-responses.ts 1204->1224 (+20, extracted toolCallOutputIndexBase() shared helper so emitToolCall/closeToolCall can no longer compute a tool call's output_index independently and collide with a text message emitted in the same turn). Live incident (2026-08-08, OpenClaw agent): a client that tracks response items by output_index saw the tool call's added/delta/done events land on an index it had already marked complete (the just-closed text message), and silently dropped them — the agent spoke its preamble and never executed the tool call, even though OmniRoute's own recorded responseBody had a complete, valid tool_calls entry. Covered by the new regression test in tests/unit/translator-resp-openai-responses.test.ts reproducing the exact live scenario."
|
||||
"_rebaseline_2026_08_08_toolcall_message_index_collision": "fix(responses-api): tool call after a text message collided on the same output_index. own growth: open-sse/translator/response/openai-responses.ts 1204->1224 (+20, extracted toolCallOutputIndexBase() shared helper so emitToolCall/closeToolCall can no longer compute a tool call's output_index independently and collide with a text message emitted in the same turn). Live incident (2026-08-08, OpenClaw agent): a client that tracks response items by output_index saw the tool call's added/delta/done events land on an index it had already marked complete (the just-closed text message), and silently dropped them — the agent spoke its preamble and never executed the tool call, even though OmniRoute's own recorded responseBody had a complete, valid tool_calls entry. Covered by the new regression test in tests/unit/translator-resp-openai-responses.test.ts reproducing the exact live scenario.",
|
||||
"_rebaseline_2026_08_03_9255_adobe_firefly_durable_sessions": "PR #9255 own cohesive growth: open-sse/services/adobeFireflyClient.ts 2322->2894 adds authenticated-vs-guest IMS classification, browser-risk ARP validation/rebuild, bounded 408 retry/recovery, sticky accepted-session handling, and matching image/video submit recovery at the existing Adobe upstream client chokepoints. This client was already explicitly frozen as a single self-contained upstream integration by #8006/#8510; splitting only the retry/auth helpers now would scatter one request state machine while structural shrink remains tracked in #3501. tests/unit/adobe-firefly.test.ts 871->1136 adds direct regression coverage for guest-token rejection, cookie/ARP rebuilding, 408 retries, sticky accepted ARP reuse, forced auth recovery, and cookie-to-IMS exchange. The obsolete 1179-line managed-Chrome fallback module was deleted rather than rebaselined after the packaged-safe pure-CDP path became authoritative. Focused Adobe suite: 61/61.",
|
||||
"_rebaseline_2026_08_07_9653_disconnect_grace_period": "Extracted fix(sse): grace period before finalizing a client disconnect as 499 (#9653) — a client that closes its connection right after reading a fully-completed SSE stream can race OmniRoute's own completion bookkeeping, getting persisted as a false 499/0-tokens even though it delivered the full response (live-confirmed: a real disconnect at 18236ms was corrected to 200/82814+1292 tokens). Own growth: open-sse/handlers/chatCore.ts 5030->5039 (+9, wiring createClientDisconnectGraceHandler at the existing onClientDisconnectFinalize call site) — irreducible call-site wiring, the actual grace-period logic lives in the new leaf createClientDisconnectGraceHandler (open-sse/utils/streamFailureFinalization.ts, not frozen). Re-measured to 5042 after rebasing onto a newer release/v3.8.50 tip: the file carries an unrelated +3 base drift from already-merged upstream commits between this PR's original branch point and the rebase target, not covered by this entry. Covered by tests/unit/stream-disconnect-grace-period-9653.test.ts (4/4, fake-timer driven). Other file-size gate violations present on this base tip are pre-existing/unrelated to this change (base-red #9679, re-verify current issue number at merge time).",
|
||||
"_rebaseline_2026_08_04_9268_gemini_schema_empty_choices": "Feature #9268 own growth: open-sse/utils/stream.ts 2889->2915 (+26 = irreducible call-site wiring for the empty-choices interceptor). The translate-mode flush now rejects a stream that completed without forwarding any valuable chunk (all-empty `choices: []`, no content/tool_calls/finish_reason) as a retryable 502 \"empty content\" instead of a clean empty 200 — the missing streaming counterpart of chatCore.ts's non-streaming isEmptyContentResponse. All rejection logic lives in the NEW leaf module open-sse/utils/streamEmptyChoices.ts (<cap, not frozen, unit-tested via tests/unit/stream-empty-choices-interceptor.test.ts); stream.ts only carries the `forwardedValuableChunk` boolean (declared at createSSEStream scope, set in emitTranslatedClientItem where the sole hasValuableContent check passes) plus the one flush-time rejectEmptyChoicesStream() call — the wait/orchestration at the chokepoint, not a movable block (mirrors the comboCooldownRetry.ts precedent). Schema-side twin fix: recursive type:\"object\" injection in open-sse/translator/helpers/geminiHelper.ts (not frozen, +33) for nested schemas with properties but no type (Gemini 400).",
|
||||
"_rebaseline_2026_08_09_5696_capability_filter": "PR #9424 own growth: open-sse/handlers/chatCore.ts 5050->5061 (+11). The Layer A capability gate is irreducible wiring at the existing pre-dispatch chokepoint: feature-flag check, capability derivation, compatibility decision, sanitized 400 response, pending-request cleanup, and warning telemetry. All matching and message logic lives outside the god-file in src/shared/constants/capabilities/capabilityFilter.ts; only orchestration remains here. Covered by tests/unit/capability-filter.test.ts (20 cases, including flag-off and sanitized error behavior). Structural shrink remains tracked separately."
|
||||
}
|
||||
|
||||
@@ -92,17 +92,19 @@
|
||||
"_rebaseline_2026_07_13_v3847_release": "39.3 -> 38.0 (-1.3, beyond the 0.5 eps). v3.8.47 cycle drift: the cycle merged ~45 PRs adding API routes (relay repair/free-pool #6909, backpressure #6590, combo context requirements #6907, services/usage endpoints) faster than openapi.yaml documentation; same class as the v3.8.34/v3.8.39 rebaselines. Documented follow-up: raise coverage next cycle via docs/openapi.yaml additions."
|
||||
},
|
||||
"i18nUiCoverage.pct": {
|
||||
"value": 99,
|
||||
"value": 100,
|
||||
"direction": "up",
|
||||
"eps": 0.5,
|
||||
"_tighten_2026_08_08_modality_bridge": "99 -> 100. Tighten required by the PR quality gate after the Modality Bridge UI keys were translated across all 42 non-English locales. CI collect-metrics on PR #9782 measured i18nUiCoverage.pct=100 with 0 ESLint warnings and 0 ESLint errors; locale dry-sync and UI coverage also report 100% with no missing keys or placeholders.",
|
||||
"_rebaseline_2026_07_04_v3844_release": "77.5 -> 76.8 (-0.7, beyond the 0.5 eps). v3.8.44 cycle drift surfaced only on the release PR (i18n-ui-coverage does NOT run on PR->release fast-gates). The cycle added ~1352 new UI keys to the en.json denominator (Discovery dashboard tab #5939, Bifrost/Mux embedded-service tabs #5817/#6034, proxy batch-ops #5918, fusion defaults #5598, tool-source toggle #5978, quota-row collapse #5977, CodeWhale/Crush CLI cards #5996/#5970, etc.) that the async i18n translation workflow has not yet back-filled (worst locales measure 76.8; __MISSING__ placeholders count as uncovered by design). Same shape and remedy as _rebaseline_2026_06_28_v3839_release. Recover via the i18n workflow next cycle; tighten with --require-tighten once translations land.",
|
||||
"_rebaseline_2026_06_28_v3839_release": "78.4 -> 77.5 (-0.9, beyond the 0.5 eps). v3.8.39 cycle drift surfaced ONLY on the release PR (i18n-ui-coverage does NOT run on PR->release fast-gates). The cycle added new UI strings (compression studio TOON A/B table, antigravity remote-login dashboard field, amber warning icon) to the en denominator faster than the 37 non-en locales were translated; those locales need `npm run i18n:run` with OMNIROUTE_TRANSLATION_API_KEY (unavailable locally) — same precedent as _rebaseline_2026_06_18_v3828_cycle_close + _quality_rebaseline_2026_06_20_ci_ratchet. Measured by CI collect-metrics (run 28317145160) = 77.5. My release-finalize tree changes no src/i18n/messages/*.json. Tightening is tracked as follow-up (run i18n:run with creds).",
|
||||
"_rebaseline_2026_07_13_v3847_release": "76.8 -> 75.5 (-1.3, beyond the 0.5 eps). v3.8.47 cycle drift: merged UI features added EN strings (relay repair UI #6909, combo builder #6907/#6991, capability override UI #6727) ahead of the 42-locale mirrors; same class as the v3.8.39/v3.8.44 rebaselines.",
|
||||
"_rebaseline_2026_07_28_v3849_release": "75.5 -> 99 (+23.5). Aperto EXIGIDO pelo modo --require-tighten do ratchet: a métrica melhorou de verdade no ciclo v3.8.49. A causa é o workflow assíncrono de tradução, que finalmente alcançou o denominador em EN — as rebaselines anteriores (v3.8.39/.44/.47) foram todas afrouxamentos registrando o atraso das traduções, e agora ele foi pago. O coletor SUBTRAI os placeholders (present - placeholder em scripts/quality/collect-metrics.mjs), então os 317 marcadores __MISSING__ que esta release introduziu para o drift de valor já estão descontados dos 99 — o número é honesto, não inflado por placeholder. Medido pelo collect-metrics do CI no run 30404226939."
|
||||
},
|
||||
"deadExports": {
|
||||
"value": 227,
|
||||
"value": 230,
|
||||
"direction": "down",
|
||||
"_rebaseline_2026_08_09_v3850_post_sweep": "227 -> 230. Measured by npm run check:dead-code on the unmodified release/v3.8.50 tip 382449d593 during the mandatory --full-ci pre-flight. The +3 is inherited cycle drift from the authorized merge sweep; this repair adds no production exports. Rebaseline records the actual tip so ci.yml quality-gate can run, while structural cleanup remains separate debt.",
|
||||
"_rebaseline_2026_07_01_v3843_release": "225->227 (+2). v3.8.43 cycle drift, surfaced in the Quality Ratchet job after eslintWarnings was rebaselined (check:dead-code runs there). 227 = measured by check:dead-code (knip) on the release tip 4635076eb. The 5 CI fixes add 0 dead exports: safeHttpHref in linkify.ts is module-local AND used (called by linkifyText); no new exports; test files are not scanned. Tighten via --update next cycle.",
|
||||
"dedicatedGate": true,
|
||||
"_rebaseline_2026_06_30_v3842_deadcode_wave": "310 -> 225. Measured by `node scripts/check/check-dead-code.mjs` on the v3.8.42 tip after the JxnLexn dead-code (#5463/#5464/#5466) + duplication (#5471..#5500) wave landed: DEAD_EXPORTS=133 + DEAD_FILES=92 = 225. The stale 310 was the v3.8.38 release snapshot never ratcheted on PR->release fast-gates (check:dead-code runs only on ci.yml PR->main, not quality.yml). Tightening to the true measured value; release-time captain rebaselines up if parallel cycle merges add dead exports.",
|
||||
|
||||
@@ -18,7 +18,7 @@ Unlike API-key providers, Web Cookie providers authenticate using the credential
|
||||
|
||||
Many authentication issues are caused by copying cookies from the wrong place.
|
||||
|
||||
## Do NOT copy from Cookie Storage
|
||||
## Do NOT copy from Cookie Storage
|
||||
|
||||
Most browsers expose stored cookies through:
|
||||
|
||||
@@ -36,7 +36,7 @@ Although these cookies look correct, they may be:
|
||||
|
||||
Using these values may cause authentication failures even if they appear valid.
|
||||
|
||||
## Copy from a Live Request
|
||||
## Copy from a Live Request
|
||||
|
||||
Instead, use the cookies from a successful request:
|
||||
|
||||
@@ -80,14 +80,14 @@ The exact credentials required depend on the provider.
|
||||
|
||||
Different websites store authentication differently. Some require only cookies, while others may require additional headers or tokens.
|
||||
|
||||
| Provider | Credential Format | Provider Guide |
|
||||
|----------|-------------------|----------------|
|
||||
| Claude Web | Full Cookie request header | `docs/providers/CLAUDE_WEB.md` |
|
||||
| ChatGPT Web | _(verify)_ | |
|
||||
| Gemini Web | _(verify)_ | |
|
||||
| Copilot Web | _(verify)_ | |
|
||||
| Grok Web | _(verify)_ | |
|
||||
| ... | ... | ... |
|
||||
| Provider | Credential Format | Provider Guide |
|
||||
| ----------- | -------------------------------------------------------------- | ------------------------------- |
|
||||
| Claude Web | Full Cookie request header | `docs/providers/CLAUDE_WEB.md` |
|
||||
| ChatGPT Web | Full Cookie header or `__Secure-next-auth.session-token` value | `docs/providers/CHATGPT_WEB.md` |
|
||||
| Gemini Web | _(verify)_ | |
|
||||
| Copilot Web | _(verify)_ | |
|
||||
| Grok Web | _(verify)_ | |
|
||||
| ... | ... | ... |
|
||||
|
||||
> Update this table as new Web Cookie providers are added or existing providers change their authentication requirements.
|
||||
|
||||
|
||||
@@ -5225,6 +5225,102 @@ paths:
|
||||
"200":
|
||||
description: Sync initialized
|
||||
|
||||
# ─── Background Jobs (local-only administration) ───────────────
|
||||
|
||||
/api/jobs:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: List registered background jobs
|
||||
description: Local-only runtime administration. Returns each registered job and its latest run.
|
||||
x-internal: true
|
||||
responses:
|
||||
"200":
|
||||
description: Registered jobs
|
||||
"500":
|
||||
description: Failed to list jobs
|
||||
|
||||
/api/jobs/{id}/enable:
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Enable a background job
|
||||
description: Local-only runtime administration. Enables the job and restarts its timer.
|
||||
x-internal: true
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Job enabled
|
||||
"404":
|
||||
description: Job not found
|
||||
"500":
|
||||
description: Failed to enable job
|
||||
|
||||
/api/jobs/{id}/disable:
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Disable a background job
|
||||
description: Local-only runtime administration. Disables the job and stops its timer.
|
||||
x-internal: true
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Job disabled
|
||||
"404":
|
||||
description: Job not found
|
||||
"500":
|
||||
description: Failed to disable job
|
||||
|
||||
/api/jobs/{id}/run-now:
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Trigger a background job
|
||||
description: >-
|
||||
Local-only runtime administration. Starts the job, or waits for an in-flight
|
||||
run before queueing the next one, subject to OMNIROUTE_RUNNOW_TIMEOUT_MS.
|
||||
x-internal: true
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Job trigger accepted
|
||||
"404":
|
||||
description: Job not found
|
||||
"500":
|
||||
description: Failed to trigger job
|
||||
|
||||
/api/jobs/{id}/runs:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Read background-job run history
|
||||
description: Local-only runtime administration. Returns newest-first run history for one job.
|
||||
x-internal: true
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Job run history
|
||||
"404":
|
||||
description: Job not found
|
||||
"500":
|
||||
description: Failed to load job runs
|
||||
|
||||
# ─── Resilience & Monitoring ────────────────────────────────────
|
||||
|
||||
/api/resilience:
|
||||
@@ -5247,6 +5343,70 @@ paths:
|
||||
"200":
|
||||
description: Updated resilience configuration
|
||||
|
||||
/api/resilience/connections:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Inspect connection resilience state
|
||||
description: >-
|
||||
Local-only operational view of per-connection cooldowns, provider circuit
|
||||
breakers, model lockouts, and recent breaker transitions. Credential columns
|
||||
are excluded by an explicit database whitelist.
|
||||
x-internal: true
|
||||
parameters:
|
||||
- name: windowMs
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 86400000
|
||||
default: 3600000
|
||||
- name: provider
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
responses:
|
||||
"200":
|
||||
description: Connection, breaker, lockout, window, and degradation metadata
|
||||
"400":
|
||||
description: Invalid query parameters
|
||||
"500":
|
||||
description: Failed to collect resilience state
|
||||
|
||||
/api/telegram/update:
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Receive Telegram updates or Mini App messages
|
||||
description: >-
|
||||
Public Telegram integration endpoint. Bot updates are acknowledged after
|
||||
reply dispatch is queued. Mini App requests must include Telegram-signed
|
||||
initData, which is verified with TELEGRAM_BOT_TOKEN before chat proxying.
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
properties:
|
||||
initData:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
update_id:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
description: Update acknowledged or Mini App reply returned
|
||||
"400":
|
||||
description: Invalid JSON, request shape, or missing Mini App message
|
||||
"401":
|
||||
description: Invalid Mini App initData signature
|
||||
"503":
|
||||
description: Telegram integration is not configured
|
||||
|
||||
/api/resilience/reset:
|
||||
post:
|
||||
tags: [System]
|
||||
|
||||
@@ -58,7 +58,7 @@ the natural shape is:
|
||||
- **Public HTTPS required.** Telegram only delivers updates to an HTTPS
|
||||
endpoint with a valid cert. Self-hosted OmniRoute behind Tailscale/ngrok
|
||||
needs a public tunnel or Cloudflare Tunnel for the webhook path
|
||||
(`TELEGRAM_WEBHOOK_URL`-style env). The dashboard can render the current
|
||||
(a future webhook-URL setting). The dashboard can render the current
|
||||
public origin (`OMNIROUTE_PUBLIC_BASE_URL`) but no webhook registration
|
||||
helper exists.
|
||||
- **Encryption gate.** `webhooks/route.ts:77` already refuses telegram
|
||||
@@ -102,7 +102,7 @@ the natural shape is:
|
||||
1. Add `grammy` or `telegraf` (or ~60 lines of hand-rolled HMAC + fetch).
|
||||
2. Implement `src/lib/telegram/initData.ts` — `verifyInitData(initData, botToken)`.
|
||||
3. Stand up a throwaway `POST /api/telegram/miniapp/webhook` route behind
|
||||
`TELEGRAM_WEBHOOK_SECRET`; register via `setWebhook` once, locally.
|
||||
a dedicated webhook secret; register via `setWebhook` once, locally.
|
||||
|
||||
### Phase 1 — Minimal chat slice (1–2 dev-days)
|
||||
|
||||
|
||||
125
docs/providers/CHATGPT_WEB.md
Normal file
125
docs/providers/CHATGPT_WEB.md
Normal file
@@ -0,0 +1,125 @@
|
||||
---
|
||||
title: "Providers — ChatGPT Web (session credentials via Cookie Editor)"
|
||||
version: 3.8.50
|
||||
lastUpdated: 2026-08-08
|
||||
---
|
||||
|
||||
# Providers — ChatGPT Web (Plus/Pro session credentials)
|
||||
|
||||
`chatgpt-web` (alias `cgpt-web`, display name **ChatGPT Web (Plus/Pro)**) sends OpenAI-format chat requests through an authenticated `chatgpt.com` browser session. It authenticates with the `__Secure-next-auth.session-token` cookie — **no API key required**.
|
||||
|
||||
> **New to Web Cookie providers?**
|
||||
>
|
||||
> Read **`docs/getting-started/WEB-COOKIE-GUIDE.md`** for the general setup process, limitations, and troubleshooting before following this provider-specific guide.
|
||||
|
||||
---
|
||||
|
||||
## 1. What credential does OmniRoute need?
|
||||
|
||||
Defined in `src/shared/constants/providers/web-cookie.ts` + `src/shared/providers/webSessionCredentials.ts`:
|
||||
|
||||
| Field | Value |
|
||||
| -------------------------- | ----------------------------------------------------------------------------- |
|
||||
| Provider id | `chatgpt-web` |
|
||||
| Credential name | `__Secure-next-auth.session-token` |
|
||||
| Accepts full Cookie header | ✅ yes |
|
||||
| Accepted storage keys | `cookie`, `sessionToken`, `session-token`, `__Secure-next-auth.session-token` |
|
||||
|
||||
Two paste formats both work:
|
||||
|
||||
- **Bare value** — just the token contents: `eyJhbGciOi...`
|
||||
- **Full Cookie header** — `__Secure-next-auth.session-token=eyJhbGciOi...; cf_clearance=...` (preferred — carries rotation/anti-bot cookies the executor needs)
|
||||
|
||||
---
|
||||
|
||||
## 2. Copy the cookie header with Cookie Editor
|
||||
|
||||
Cookie Editor can copy the cookies for the active `chatgpt.com` tab as an HTTP header string.
|
||||
Always compare the exported value with a live authenticated request as described in section 3.
|
||||
|
||||
### 2.1 Install and pin
|
||||
|
||||
1. Install **[Cookie-Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm)** (Moustachauve) in Chrome/Edge, or the Firefox equivalent.
|
||||
2. Pin it to the toolbar if you use it regularly.
|
||||
|
||||
### 2.2 Copy the credential
|
||||
|
||||
1. Go to **https://chatgpt.com** and make sure you're **signed in with the Plus/Pro account** you want OmniRoute to use.
|
||||
2. Open a conversation and send at least one message (forces the session token to be live/refreshed).
|
||||
3. Click the **Cookie Editor** icon to open its side panel for the active tab.
|
||||
4. Find `__Secure-next-auth.session-token`. If it's split into chunks (`__Secure-next-auth.session-token.0`, `.1`, …), select **all** of them — OmniRoute's `nextAuthCookie.ts` merges rotated chunk families.
|
||||
5. Click **Copy**, choose **Header string**, and copy the resulting `name=value; name=value` text.
|
||||
|
||||
> **If the token is missing:** confirm that you are signed in, send a message to refresh the session, and inspect the live request in section 3.
|
||||
|
||||
---
|
||||
|
||||
## 3. Verify the required data (before pasting)
|
||||
|
||||
The repo's `WEB-COOKIE-GUIDE.md` mandates a live-request check. Do it once per session:
|
||||
|
||||
1. With chatgpt.com open, press **F12** → **Network** tab.
|
||||
2. Refresh the page, then send a chat message.
|
||||
3. Click the conversation request (e.g. `/backend-api/conversation` or the SSE stream) → **Headers** → **Request Headers** → **Cookie**.
|
||||
4. Confirm it contains `__Secure-next-auth.session-token=...` — **not** just `cf_clearance` or `__cf_bm`.
|
||||
|
||||
The value you copied in step 2.3 must match what the live request sends. If they differ, re-copy from Cookie Editor.
|
||||
|
||||
---
|
||||
|
||||
## 4. Add / update the credential in OmniRoute
|
||||
|
||||
### Dashboard (typical user path)
|
||||
|
||||
1. Open the OmniRoute dashboard → **Providers** → **Add Provider**.
|
||||
2. Search **ChatGPT Web (Plus/Pro)** (id `chatgpt-web`).
|
||||
3. Paste the copied cookie header into the credential field.
|
||||
4. Click **Test Connection**.
|
||||
5. Save.
|
||||
|
||||
If requests later return 401 or 403, re-copy the header from a fresh live session. The executor merges `Set-Cookie` rotations while the connection is active, but it cannot recover a credential that is no longer accepted upstream.
|
||||
|
||||
### Bulk / session pools (many accounts)
|
||||
|
||||
For multiple ChatGPT sessions, use the bulk web-session import or session-pool endpoints:
|
||||
|
||||
- `POST /api/providers/bulk-web-session` — import many cookie credentials at once
|
||||
- `GET /api/session-pools` + `/api/session-pools/[provider]` — pool rotation across accounts
|
||||
|
||||
Each credential blob must carry the `__Secure-next-auth.session-token` value under one of the accepted storage keys (`cookie`, `sessionToken`, `session-token`, or the cookie's exact name).
|
||||
|
||||
### Renewing when the session expires
|
||||
|
||||
Web sessions can stop working after sign-out or server-side rotation. Re-run steps 2.2 through 4 whenever requests start failing with 401/403.
|
||||
|
||||
---
|
||||
|
||||
## 5. Contributing updates
|
||||
|
||||
If you changed the credential contract (new storage key, new cookie name, changed hint) or are filling the docs gap, contribute it:
|
||||
|
||||
1. Update `src/shared/providers/webSessionCredentials.ts` (credential name / placeholder / storage keys) or `src/shared/constants/providers/web-cookie.ts` (`authHint`).
|
||||
2. Update this guide (`docs/providers/CHATGPT_WEB.md`) and the provider table in `docs/getting-started/WEB-COOKIE-GUIDE.md`.
|
||||
3. Update `.env.example` + `docs/reference/ENVIRONMENT.md` if you touched env vars, then run:
|
||||
```bash
|
||||
node scripts/check/check-env-doc-sync.mjs # must pass
|
||||
```
|
||||
4. Run the provider/unit tests:
|
||||
```bash
|
||||
npm run test:unit
|
||||
# targeted: tests/unit/chatgpt-web.test.ts (stealth path)
|
||||
```
|
||||
5. Follow `CONTRIBUTING.md`, branch from the current active release tip, use a Conventional Commit message, and open the PR against that active release branch.
|
||||
|
||||
> ⚠️ **Never commit a real cookie value.** All examples above are placeholders. If a test fixture needs a token, use a fake `eyJhbGciOi...` string.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
| -------------------------------- | -------------------------------------------- | --------------------------------------------------------- |
|
||||
| Cookie not in Cookie Editor | Signed out / not HttpOnly-visible | Sign in; enable HttpOnly display in options |
|
||||
| Token missing from live request | Request is not authenticated | Sign in and send a chat message first |
|
||||
| 401 after Test Connection passed | Expired or rotated session | Re-copy from a fresh live request |
|
||||
| Chunked token fails | Only one chunk pasted | Select all `__Secure-next-auth.session-token.*` chunks |
|
||||
@@ -43,6 +43,7 @@ lastUpdated: 2026-06-28
|
||||
- [22. Debugging](#22-debugging)
|
||||
- [23. GitHub Integration](#23-github-integration)
|
||||
- [24. Skills Sandbox (v3.8.0+)](#24-skills-sandbox-v380)
|
||||
- [27. Radar Feed (Self-Hosting)](#27-radar-feed-self-hosting)
|
||||
- [Deployment Scenarios](#deployment-scenarios)
|
||||
- [Audit: Removed / Dead Variables](#audit-removed--dead-variables)
|
||||
|
||||
@@ -189,19 +190,20 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari
|
||||
| `MAX_BODY_SIZE_BYTES` | `10485760` (10 MB) | `src/shared/middleware/bodySizeGuard.ts` | Maximum allowed request body size. Rejects payloads exceeding this limit. |
|
||||
| `OMNIROUTE_CHAT_LARGE_BODY_BYTES` | `262144` (256 KB) | `src/shared/middleware/chatBodyAdmission.ts` | Actual request bodies at or above this threshold require an atomic process-local heavyweight admission lease before JSON parsing. |
|
||||
| `OMNIROUTE_CHAT_HARD_MAX_BODY_BYTES` | `52428800` (50 MB) | `src/shared/middleware/chatBodyAdmission.ts` | Chat-route hard cap enforced against bytes read during bounded ingestion, including requests with missing, invalid, or dishonest `Content-Length`; excess receives `413`. |
|
||||
| `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` | `1` | `src/shared/middleware/chatBodyAdmission.ts` | Maximum heavyweight chat requests admitted concurrently in one process. When capacity is unavailable, OmniRoute waits up to `OMNIROUTE_CHAT_ADMISSION_QUEUE_MS` for a slot, then returns retryable `503` with `Retry-After`. |
|
||||
| `OMNIROUTE_CHAT_ADMISSION_QUEUE_MS` | `5000` | `src/shared/middleware/chatBodyAdmission.ts` | How long a heavyweight chat request waits for an admission slot before the retryable `503`. A bounded wait serializes agent bursts (OpenCode, Claude Code, Cursor sub-requests) that would otherwise burn their client retry budget on immediate rejections; `0` restores the legacy immediate-reject behaviour. |
|
||||
| `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` | `1` | `src/shared/middleware/chatBodyAdmission.ts` | Maximum heavyweight chat requests admitted concurrently in one process. When capacity is unavailable, OmniRoute returns retryable `503` with `Retry-After`. |
|
||||
| `OMNIROUTE_CHAT_HEAVY_MESSAGE_COUNT` | `200` | `src/shared/middleware/chatBodyAdmission.ts` | Message count that classifies a chat request as heavyweight even when its body is below the byte threshold. |
|
||||
| `OMNIROUTE_CHAT_HEAVY_TOOL_COUNT` | `64` | `src/shared/middleware/chatBodyAdmission.ts` | Tool count that classifies a chat request as heavyweight even when its body is below the byte threshold. |
|
||||
| `OMNIROUTE_CHAT_HEAVY_ESTIMATED_TOKENS` | `32000` | `src/shared/middleware/chatBodyAdmission.ts` | Conservative string-size token estimate that classifies a request as heavyweight; this is an admission-cost proxy, not provider billing tokenization. |
|
||||
| `OMNIROUTE_CHAT_HARD_MAX_MESSAGES` | `800` | `src/shared/middleware/chatBodyAdmission.ts` | Hard chat history cap. Requests above it receive structured compact-required `413` before compression, translation, or provider dispatch. |
|
||||
| `OMNIROUTE_CHAT_HARD_MAX_MESSAGES` | `0` (disabled) | `src/shared/middleware/chatBodyAdmission.ts` | Optional opt-in chat history cap. Disabled by default: a message count is deployment policy, not a universal property of a request, and capping here rejects conversations with a terminal `413` before the compression pipeline can make them servable. Heap growth is bounded by `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` and the heap-pressure shed. Set a positive value on memory-constrained deployments that need a hard ceiling; excess then receives structured compact-required `413`. |
|
||||
| `OMNIROUTE_MAX_NONSTREAMING_RESPONSE_BYTES` | `67108864` (64 MB) | `open-sse/handlers/chatCore/nonStreamingResponseBody.ts` | Hard cap for a non-streaming upstream response buffered fully into memory. Past this the upstream reader is cancelled and the request fails fast instead of growing an unbounded string until the heap is exhausted. |
|
||||
| `OMNIROUTE_FORWARDING_HEADER_BUDGET_BYTES` | `768` | `open-sse/handlers/chatCore/responseHeaders.ts` | Max wire bytes forwarded from upstream response headers. When the budget is exceeded, lower-priority headers (e.g., custom `x-codex-*`, `x-oai-request-id`) are dropped to stay within common reverse-proxy header limits. Set higher to forward more upstream metadata at the cost of larger response header size. |
|
||||
| `CORS_ORIGIN` | _(unset)_ | `src/server/cors/origins.ts` | Legacy single-origin CORS allowlist. Prefer `CORS_ALLOWED_ORIGINS` for new deployments. CORS is only for cross-origin browser API clients; authenticated dashboard writes use same-origin requests plus session-bound CSRF protection instead. |
|
||||
| `CORS_ALLOWED_ORIGINS` | _(unset)_ | `src/server/cors/origins.ts` | Comma-separated CORS allowlist. No wildcard is sent unless `CORS_ALLOW_ALL=true` is explicitly configured. |
|
||||
| `CORS_ALLOW_ALL` | `false` | `src/server/cors/origins.ts` | Development-only escape hatch to echo any browser `Origin`. Do not enable on shared or production deployments. |
|
||||
| `OUTBOUND_SSRF_GUARD_ENABLED` | `true` | `src/shared/network/outboundUrlGuard.ts` | Block provider calls targeting private/loopback/link-local IP ranges. Disable only in isolated test envs. |
|
||||
| `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` | `false` | `src/shared/network/outboundUrlGuard.ts` | Allow provider URLs pointing to private/local networks (localhost, 192.168.x.x, 10.x.x.x, etc.). **REQUIRED for self-hosted providers** (LM Studio, Ollama, vLLM, Llamafile, Triton, SearXNG). When `false`, the dashboard rejects validation of local URLs. |
|
||||
| `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` | `true` | `src/shared/network/outboundUrlGuard.ts` | Allow adding/validating providers on local/private addresses (127.0.0.1, localhost, LAN, private ranges) — scoped to the provider validation path. **Default `true`** (local-first); set `false` to enforce strict public-only blocking. Cloud-metadata endpoints (169.254.169.254, metadata.google.internal) stay blocked regardless. (#5066) |
|
||||
| `AUDIO_REMOTE_PROVIDER_NODES` | `false` | `src/app/api/v1/_shared/audioProviderNodes.ts` | Let the `/v1/audio/*` routes (transcriptions, speech, translations) use an OpenAI-compatible provider node hosted outside localhost. Off by default — routing audio to a remote host changes egress identity and must be an explicit operator decision. Loopback/private nodes (localhost, 127.0.0.1, 172.16-31.x) are always allowed and unaffected. (#3963) |
|
||||
|
||||
### Hardening Checklist
|
||||
|
||||
@@ -265,6 +267,7 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp
|
||||
| `OMNIROUTE_PAYLOAD_RULES_PATH` | `./config/payloadRules.json` | `open-sse/services/payloadRules.ts` | Path to payload manipulation rules JSON file (per-model/protocol upstream tweaks). |
|
||||
| `OMNIROUTE_PAYLOAD_RULES_RELOAD_MS` | `5000` | `open-sse/services/payloadRules.ts` | Reload interval (ms) for hot-reloading the payload rules file. Minimum `1000`. |
|
||||
| `OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS` | `false` | `open-sse/services/model.ts` | Opt-in: route bare `claude-*` model IDs from Claude Code clients through the Claude Code OAuth account instead of requiring a provider prefix. Explicit provider prefixes still win. Also configurable via a dashboard toggle on the Claude provider page. |
|
||||
| `COMBO_CONCURRENCY_PER_MODEL` | `3` | `open-sse/services/comboConfig.ts` | Per-model concurrency cap for round-robin combos (#9100). The round-robin combo semaphore was hard-capped at 3 concurrent requests per model with no override, serializing higher-concurrency traffic behind that cap. Validated to `>= 1`, clamped to `<= 32`. |
|
||||
|
||||
---
|
||||
|
||||
@@ -378,6 +381,14 @@ Controls how OmniRoute discovers and launches CLI sidecars (Claude Code, Codex,
|
||||
| `CLI_QODER_BIN` | `qoder` | `src/shared/services/cliRuntime.ts` | Custom path to Qoder CLI binary. |
|
||||
| `CLI_QWEN_BIN` | `qwen` | `src/shared/services/cliRuntime.ts` | Custom path to the Qwen Code CLI binary. |
|
||||
| `CLI_DEVIN_BIN` | `devin` | `open-sse/executors/devin-cli.ts` | Custom path to the Devin CLI binary (v3.8.0). Used by the Windsurf/Devin executor. |
|
||||
| `CLI_DEVIN_AGENTIC_BIN` | `devin` | `open-sse/executors/devin-cli-agentic.ts` | Agentic bridge-only Devin CLI override. The executor accepts only the local ACP stdio upstream. |
|
||||
| `DEVIN_AGENTIC_HOME` | _(required)_ | `open-sse/executors/devin-cli-agentic.ts` | Absolute isolated home for the agentic Devin subprocess; accepted bridge paths are `/home/bridge` and task-local `.sandbox` paths. |
|
||||
| `DEVIN_AGENTIC_ACP_TIMEOUT_MS` | `120000` | `open-sse/executors/devin-cli-agentic.ts` | Maximum duration of one Devin ACP turn before the bridge terminates the child and returns an explicit timeout. |
|
||||
| `DEVIN_BRIDGE_MODEL` | `devin-cli-agentic/swe-1-7` | `docker/devin-bridge/compose.yml` | Main Claude Code model alias for the isolated bridge. The live harness replaces the example with a model returned by the current Devin account. |
|
||||
| `DEVIN_BRIDGE_SONNET_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used when Claude Code requests its Sonnet default. |
|
||||
| `DEVIN_BRIDGE_OPUS_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used when Claude Code requests its Opus default. |
|
||||
| `DEVIN_BRIDGE_HAIKU_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used when Claude Code requests its Haiku default. |
|
||||
| `DEVIN_BRIDGE_SUBAGENT_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used for Claude Code subagents. |
|
||||
| `AUGGIE_BIN` | `auggie` | `open-sse/executors/auggie.ts` | Absolute-path override for the Augment (Auggie) CLI binary used by the local `auggie` provider. Falls back to `CLI_AUGGIE_BIN`, then a PATH lookup. |
|
||||
| `CLI_AUGGIE_BIN` | `auggie` | `open-sse/executors/auggie.ts` | Alias override for the Augment (Auggie) CLI binary path (checked after `AUGGIE_BIN`). |
|
||||
| `HERMES_HOME` | `~/.hermes` | `src/lib/cli-helper/config-generator/hermesHome.ts` | Hermes Agent home directory where OmniRoute reads/writes the Hermes CLI config. Matches the env var the Hermes PowerShell installer sets on Windows (`%LOCALAPPDATA%\hermes`). |
|
||||
@@ -415,7 +426,6 @@ detection above).
|
||||
| `OMNIROUTE_HTTP_TIMEOUT_MS` | `30000` | `bin/cli/api.mjs` | Per-attempt HTTP timeout (ms) for CLI → server requests. |
|
||||
| `OMNIROUTE_VERBOSE` | `0` | `bin/cli/api.mjs` | Set to `1` to print retry/backoff diagnostics to stderr during CLI commands. |
|
||||
| `OMNIROUTE_PLUGIN_PATH` | _(unset)_ | `bin/cli/plugins.mjs` | Custom directory for CLI plugin discovery (`omniroute-cmd-*` packages). Defaults to `~/.omniroute/plugins/` when unset. |
|
||||
| `OMNIROUTE_PLUGINS_ALLOW_EXEC` | `0` | `src/lib/plugins/pluginWorker.ts` | Set to `1` to allow plugins to request the `exec` permission (spawn child processes from the worker sandbox). Local operator only. |
|
||||
|
||||
---
|
||||
|
||||
@@ -452,6 +462,7 @@ detection above).
|
||||
| `COMPRESSION_PIPELINE_BREAKER_THRESHOLD` | `3` | `open-sse/services/compression/pipelineEngineBreaker.ts` | Consecutive cross-request failures before an engine's breaker opens. |
|
||||
| `COMPRESSION_PIPELINE_BREAKER_COOLDOWN_MS` | `30000` | `open-sse/services/compression/pipelineEngineBreaker.ts` | Milliseconds an opened engine stays skipped before a half-open probe. |
|
||||
| `COMPRESSION_CCR_RETRIEVAL_RAMP_FACTOR` | `2` | `open-sse/services/compression/engines/ccr/index.ts` | T08/H8 CCR retrieval-feedback ramp: each prior retrieval of a stored block raises its effective `minChars` linearly (frequently-retrieved content compresses less; `>=3` retrievals = never compressed). `1` disables the ramp (binary skip at the threshold only). |
|
||||
| `COMPRESSION_CCR_DURABLE_STORE` | `true` | `open-sse/services/compression/engines/ccr/index.ts` | CCR durable block store (#9061). Backs the in-memory store with SQLite so a block survives LRU eviction, the TTL, a restart, or a retrieve landing on another instance. Set `false` to keep blocks in memory only. Blocks over 512KB and cloud runtimes stay memory-only regardless. |
|
||||
| `COMPRESSION_PREFIX_FREEZE_ENABLED` | `false` | `open-sse/services/compression/prefixFreeze.ts` | T08/H5 usage-observed prefix freeze master switch. **Opt-in (default off)** — when on, a system prompt observed `>=` the threshold is treated as a stable cacheable prefix and preserved from compression even for providers the static cache heuristic misses (freeze only *preserves*, never mutates). |
|
||||
| `COMPRESSION_PREFIX_FREEZE_THRESHOLD` | `3` | `open-sse/services/compression/prefixFreeze.ts` | Observations of a system prompt before it is treated as a frozen stable prefix. |
|
||||
| `OMNIROUTE_BOOTSTRAPPED` | `false` | `src/app/(dashboard)/dashboard/page.tsx` | Set `true` by bootstrap script after initial setup. Controls setup wizard visibility. |
|
||||
@@ -507,8 +518,12 @@ Built-in credentials for **localhost development**. For remote deployments, regi
|
||||
| `OMNIROUTE_QODER_WORKSPACE` | Qoder | Alias for `QODER_CLI_WORKSPACE`. |
|
||||
| `QODER_CLI_CONFIG_DIR` | Qoder | Override the Qoder CLI config dir (isolated PAT session, avoids clobbering a browser login). |
|
||||
| `BLACKBOX_WEB_VALIDATED_TOKEN` | Blackbox Web | Frontend `tk` token to send as `validated` on `/api/chat`. Required when Blackbox enforces token matching; otherwise OmniRoute falls back to a random UUID. See issue #2252. |
|
||||
| `VISION_BRIDGE_BASE_URL` | Vision Bridge guardrail | OpenAI-compatible base URL for non-Anthropic vision-bridge calls. Defaults to the legacy OpenAI URL env or api.openai.com. Point at OmniRoute's `/v1` self-loop or any OpenAI-compat endpoint (Gemini OpenAI-compat, OpenRouter). Issue #2232. |
|
||||
| `VISION_BRIDGE_BASE_URL` | Vision Bridge guardrail | OpenAI-compatible base URL for non-Anthropic vision-bridge calls. Defaults to the legacy OpenAI URL env or api.openai.com. Point at OmniRoute's `/v1` self-loop or any OpenAI-compat endpoint (Gemini OpenAI-compat, OpenRouter). Issue #2232. When the URL is OmniRoute's own `/v1`, the describe sub-request sends `x-omniroute-admission-bypass: internal` and authenticates with the resolved self-loop credential (`sk_omniroute` sentinel in local mode, or `OMNIROUTE_API_KEY` / `ROUTER_API_KEY` — #1350) so `REQUIRE_API_KEY=true` deployments work. |
|
||||
| `VISION_BRIDGE_API_KEY` | Vision Bridge guardrail | API key for the URL above. Overrides per-provider OpenAI / Google env vars for non-Anthropic vision-bridge calls. Anthropic models keep their dedicated Anthropic key path. Issue #2232. |
|
||||
| `RAYCAST_BEARER_TOKEN` | Raycast Pro | Optional manual override for the Raycast access token (normally captured via macOS Auto-Import). No OAuth client_id/secret — reverse-engineered, local/personal use only. |
|
||||
| `RAYCAST_DEVICE_ID` | Raycast Pro | Optional manual override for the Raycast device ID used to sign requests. |
|
||||
| `RAYCAST_AID` | Raycast Pro | Optional manual override for the Raycast account/app ID; falls back to the device ID when unset. |
|
||||
| `RAYCAST_SIG_SECRET` | Raycast Pro | Optional override for the request-signing HMAC secret. Defaults to a community-extracted value in `open-sse/services/raycast.ts`. |
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
@@ -656,12 +671,13 @@ REQUEST_TIMEOUT_MS (global override)
|
||||
| `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Raw socket timeout (0 = disabled). |
|
||||
| `SHUTDOWN_TIMEOUT_MS` | `30000` | Grace period on SIGTERM/SIGINT before force-exit. |
|
||||
| `OMNIROUTE_DEFAULT_FETCH_TIMEOUT_MS` | `120000` | Fallback used by `src/shared/utils/fetchTimeout.ts` when `FETCH_TIMEOUT_MS` is unset. |
|
||||
| `OMNIROUTE_RELAY_FETCH_TIMEOUT_MS` | `25000` | Relay-specific fetch timeout in `open-sse/utils/proxyFetch.ts` (#9158). A hung relay must fail before the client/agent timeout (~30s) so callers see a relay-specific failure instead of a generic upstream timeout. Capped at `29000` so it always fires first. |
|
||||
| `OMNIROUTE_RETRY_BACKOFF_MS` | `10` | Shared retry backoff for the direct/relay/proxy retry-once paths in `open-sse/utils/proxyFetch.ts` (#9158). `0` = retry immediately. |
|
||||
| `OMNIROUTE_CHATGPT_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`chatgptTlsClient.ts`). |
|
||||
| `OMNIROUTE_CHATGPT_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. |
|
||||
| `OMNIROUTE_CHATGPT_STREAM_FIRST_BYTE_TIMEOUT_MS` | `30000` | Max wait for the first streamed byte from the ChatGPT TLS sidecar. |
|
||||
| `OMNIROUTE_CLAUDE_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding. |
|
||||
| `OMNIROUTE_CLAUDE_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. |
|
||||
| `OMNIROUTE_RUNNOW_TIMEOUT_MS` | `30000` | Timeout for the `/api/jobs/:id/run-now` endpoint. Bounds how long a run-now call waits for an in-flight job to finish before starting the queued run. See `src/app/api/jobs/[id]/run-now/route.ts`. |
|
||||
| `OMNIROUTE_CHATGPT_STREAM_FIRST_BYTE_TIMEOUT_MS` | `30000` (30s) | Max wait for the first streamed byte from the ChatGPT TLS sidecar (`chatgptTlsClient.ts`) before aborting a dead stream. Raise if upstream cold-starts exceed the window. |
|
||||
| `OMNIROUTE_CLAUDE_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`claudeTlsClient.ts`). |
|
||||
| `OMNIROUTE_CLAUDE_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. |
|
||||
| `OMNIROUTE_PPLX_TLS_TIMEOUT_MS` | `30000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`perplexityTlsClient.ts`). |
|
||||
| `OMNIROUTE_PPLX_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. |
|
||||
| `OMNIROUTE_GROK_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`grokTlsClient.ts`). |
|
||||
@@ -670,6 +686,7 @@ REQUEST_TIMEOUT_MS (global override)
|
||||
| `OMNIROUTE_NOTION_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. |
|
||||
| `OMNIROUTE_BROWSER_POOL` | `on` | Shared Playwright browser pool for browser-backed web-cookie chat (`browserPool.ts`); set `off` to disable. |
|
||||
| `WEB_COOKIE_USE_BROWSER` | `0` | Opt a web-cookie chat request into the browser-backed path (`browserBackedChat.ts`); `1` to enable. |
|
||||
| `OMNIROUTE_LOGIN_BROWSER_PATH` | _(auto-detected)_ | Path to a system Chrome/Edge executable for the Adobe Firefly interactive browser sign-in (`adobeFireflyBrowserLogin.ts`); overrides per-OS auto-detection. |
|
||||
|
||||
Combo target attempts inherit the resolved upstream request timeout (`FETCH_TIMEOUT_MS`, or
|
||||
`REQUEST_TIMEOUT_MS` when it supplies the fetch default). Set `targetTimeoutMs` in a combo,
|
||||
@@ -717,8 +734,9 @@ The logging system writes to both stdout and rotated log files. All configuratio
|
||||
| `CALL_LOG_RETENTION_DAYS` | `7` | Days to keep request/call log entries in the database. |
|
||||
| `CALL_LOG_MAX_ENTRIES` | `10000` | Max call log entries in the in-memory buffer. |
|
||||
| `CALL_LOGS_TABLE_MAX_ROWS` | `100000` | Max rows in the `call_logs` SQLite table before pruning. |
|
||||
| `ENABLE_REQUEST_LOGS` | _(unset)_ | Force detailed request logging on or off, overriding the dashboard setting. |
|
||||
| `MAX_PENDING_REQUEST_AGE_MS` | `3600000` (1 hour) | Max age for orphaned active request log entries before in-memory cleanup. |
|
||||
| `CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS` | `true` | Store stream chunks in pipeline artifacts when `call_log_pipeline_enabled=true`. |
|
||||
| `CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS` | `false` | Store stream chunks in pipeline artifacts when `call_log_pipeline_enabled=true`. Opt-in (`true`) — off by default to save disk. |
|
||||
| `CALL_LOG_PIPELINE_MAX_SIZE_KB` | `512` | Max pipeline call log artifact size in KB when `call_log_pipeline_enabled=true`. |
|
||||
| `PROXY_LOGS_TABLE_MAX_ROWS` | `100000` | Max rows in the `proxy_logs` SQLite table before pruning. |
|
||||
| `APP_LOG_ROTATION_CHECK_INTERVAL_MS` | `60000` (1 min) | How often `src/lib/logRotation.ts` re-checks the active log file size. |
|
||||
@@ -726,7 +744,6 @@ The logging system writes to both stdout and rotated log files. All configuratio
|
||||
| `CHAT_LOG_ARRAY_TAIL_ITEMS` | `128` | Number of array items retained from the tail when truncating chat log payloads. |
|
||||
| `CHAT_LOG_MAX_DEPTH` | `6` | Max nesting depth before chat log payloads are truncated. |
|
||||
| `CHAT_LOG_MAX_OBJECT_KEYS` | `80` | Max object keys retained in chat log payloads (0 = unlimited). |
|
||||
| `CHAT_LOG_MAX_BODY_KB` | `1024` | Max request/response body size before `truncateForLog()` summarizes it, in KB. |
|
||||
| `CHAT_DEBUG_FILE` | `false` | When true, `serializeArtifactForStorage` skips size-based truncation. Debug only. |
|
||||
|
||||
---
|
||||
@@ -763,9 +780,13 @@ Embedding layer, vector store and reranking knobs for the persistent memory subs
|
||||
| `MEMORY_TRANSFORMERS_MODEL` | `Xenova/all-MiniLM-L6-v2` | HF repo id for the opt-in `@huggingface/transformers` local MiniLM pipeline (~23 MB int8, ~400 MB RAM). |
|
||||
| `MEMORY_STATIC_MODEL` | `minishlab/potion-base-8M` | HF repo id for the static potion/Model2Vec lookup-table embedder. Downloaded lazily into the cache dir. |
|
||||
| `MEMORY_STATIC_CACHE_DIR` | `<DATA_DIR>/embeddings` | Directory used to cache the static potion model files. Defaults under `DATA_DIR` when unset. |
|
||||
| `HF_HUB_ENDPOINT` | `https://huggingface.co` | Override Hugging Face Hub base URL used by `staticPotion.ts` (e.g. mirror endpoint for air-gapped setups). |
|
||||
| `MEMORY_VEC_TOP_K` | `20` | Default top-K used by the `sqlite-vec` brute-force vector search inside `src/lib/memory/vectorStore.ts`. |
|
||||
| `MEMORY_RRF_K` | `60` | Reciprocal Rank Fusion constant `k` for hybrid FTS5 + vector retrieval (sqlite-vec recipe). |
|
||||
| `HF_HUB_ENDPOINT` | `https://huggingface.co` | Override Hugging Face Hub base URL used by `staticPotion.ts` (e.g. mirror endpoint for air-gapped setups). |
|
||||
| `NOTION_API_KEY` | _(unset)_ | API key for Notion backend (used by `genericBackend.ts` known backend preset). |
|
||||
| `NOTION_API_URL` | `https://api.notion.com/v1`| Base URL for Notion API (can override for self-hosted Notion alternatives). |
|
||||
| `OBSIDIAN_API_KEY` | _(unset)_ | API key for Obsidian Vault backend (used by `genericBackend.ts` known backend preset). |
|
||||
| `OBSIDIAN_API_URL` | `http://localhost:27123` | Base URL for Obsidian Vault API (can override for remote vault). |
|
||||
| `MEMORY_TYPED_DECAY_ENABLED` | `false` | TV6 typed memory decay master switch. **Opt-in (default off)** — the sweep **deletes** decayed memories. With it off, `access_count`/`last_accessed_at` are pure telemetry and nothing is ever deleted. |
|
||||
| `MEMORY_TYPED_DECAY_EPISODIC_DAYS` | `30` | TTL (days) after which an unused `episodic` memory decays. `0` makes episodic immune too. Durable types (`factual`/`procedural`/`semantic`) are always immune. The decay clock re-bases on `last_accessed_at`. |
|
||||
| `MEMORY_TYPED_DECAY_ACCESS_IMMUNITY` | `3` | A memory injected `>=` this many times becomes immune to decay regardless of type. `0` disables access immunity. |
|
||||
@@ -832,6 +853,7 @@ Reverse-engineered session bridge for hyperagent.com (`src/shared/constants/prov
|
||||
|
||||
| Variable | Default | Source File | Description |
|
||||
| ----------------------------------- | ------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `MODELS_DEV_SYNC_ENABLED` | `false` | `src/lib/modelsDevSync.ts` | Opt-in switch for the models.dev capability sync. Set to anything non-empty it wins over the `modelsDevSyncEnabled` setting (Dashboard > Settings > AI) in either direction, so a deployment can pin the sync on or off without depending on database state surviving a rebuild; unset, it defers to that setting. On for `1`, `true`, `yes` or `on` in any casing; any other value is off. |
|
||||
| `MODELS_DEV_SYNC_INTERVAL` | `86400` (24h) | `src/lib/modelsDevSync.ts` | Development-time model catalog sync interval in seconds. |
|
||||
| `CONTEXT_WINDOW_RECONCILE_INTERVAL` | `86400` (24h) | `src/lib/contextWindowResolver.ts` | Interval (seconds) for the self-correcting context-window reconciler (5004): pins provider-declared windows from `/models` discovery as `auto:discovery` overrides when they diverge from the catalog. Set to `0` to disable. Reuses already-synced data (no new fetch); never overwrites `manual` overrides. |
|
||||
|
||||
@@ -847,6 +869,7 @@ Reverse-engineered session bridge for hyperagent.com (`src/shared/constants/prov
|
||||
| `NANOBANANA_POLL_INTERVAL_MS` | `2500` | `open-sse/handlers/imageGeneration.ts` | NanoBanana job polling frequency. |
|
||||
| `DESIGNER_WEB_POLL_TIMEOUT_MS` | `60000` | `open-sse/handlers/imageGeneration/providers/designerWeb.ts` | Max wait for microsoft-designer-web image generation jobs. |
|
||||
| `DESIGNER_WEB_POLL_INTERVAL_MS` | `2000` | `open-sse/handlers/imageGeneration/providers/designerWeb.ts` | microsoft-designer-web job polling frequency. |
|
||||
| `ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS` | `8000` | `open-sse/services/adobeFireflyUpscale.ts` | Base delay for the Adobe Firefly upscale submit-retry exponential backoff. |
|
||||
| `AWS_REGION` | _(unset)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | Region used to construct AWS Bedrock endpoints (Kiro, audio). |
|
||||
| `AWS_DEFAULT_REGION` | _(unset)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | Fallback when `AWS_REGION` is not set. |
|
||||
| `CLOUDFLARE_ACCOUNT_ID` | _(unset)_ | `open-sse/executors/cloudflare-ai.ts` | Account ID for Cloudflare Workers AI. |
|
||||
@@ -868,6 +891,10 @@ Reverse-engineered session bridge for hyperagent.com (`src/shared/constants/prov
|
||||
| `CLIPROXYAPI_PORT` | `5544` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI bridge port. |
|
||||
| `CLIPROXYAPI_CONFIG_DIR` | `~/.cli-proxy-api` | `src/lib/versionManager/processManager.ts` | CLIProxyAPI config directory. |
|
||||
| `MUX_SERVICE_PORT` | `8322` | `src/lib/services/bootstrap.ts` | Override the port where the embedded Mux (coder/mux) agent-orchestration daemon listens (always 127.0.0.1). |
|
||||
| `DARIO_HOST` | `127.0.0.1` | `open-sse/executors/dario.ts` | Dario embedded-service bind/connect host (loopback only by default). |
|
||||
| `DARIO_PORT` | `3456` | `open-sse/executors/dario.ts` | Dario embedded-service port. |
|
||||
| `DARIO_HOST` | `127.0.0.1` | `open-sse/executors/dario.ts` | Dario embedded-service bind/connect host (loopback only by default). |
|
||||
| `DARIO_PORT` | `3456` | `open-sse/executors/dario.ts` | Dario embedded-service port. |
|
||||
| `LOCAL_HOSTNAMES` | _(empty)_ | `open-sse/config/providerRegistry.ts` | Comma-separated additional hostnames treated as "local" (Docker service names, etc.). |
|
||||
|
||||
`ENABLE_CC_COMPATIBLE_PROVIDER` is only for third-party relays that accept Claude Code clients
|
||||
@@ -949,7 +976,7 @@ changing them requires a code edit, not an env var:
|
||||
| `CURSOR_AGENT_CLI_VERSION` | _(detect / pin)_ | `open-sse/utils/cursorAgentCliVersion.ts` | Agent CLI build id (`YYYY.MM.DD-<hash>`) for `x-cursor-client-version: cli-…` on Agent Run. |
|
||||
| `CURSOR_DATA_DIR` | _(probed)_ | `open-sse/utils/cursorAgentCliVersion.ts` | Override Cursor Agent CLI data dir (`…/versions/<id>`); same var the official agent uses. |
|
||||
| `CURSOR_TOKEN` | _(unset)_ | `scripts/ad-hoc/cursor-tap.cjs` | Direct Cursor bearer token used by developer tooling. |
|
||||
| `OMNIROUTE_LOG_REQUEST_SHAPE` | enabled (`!== "0"`) | `src/app/api/v1/chat/completions/route.ts` | Log content-type/length markers for large chat payloads. Set `"0"` to silence. |
|
||||
| `OMNIROUTE_LOG_REQUEST_SHAPE` | disabled (opt-in via `"1"`) | `src/app/api/v1/chat/completions/route.ts` | Log content-type/length markers for large chat payloads when `"1"` is set. Off by default to reduce log noise. |
|
||||
| `DEBUG_RESPONSES_SSE_TO_JSON` | _(unset)_ | `open-sse/handlers/responseTranslator.ts` | Set `true` to log Responses API SSE→JSON translation details. |
|
||||
| `NEXT_PUBLIC_OMNIROUTE_E2E_MODE` | _(unset)_ | E2E test harness | Set `true` to enable E2E test mode (relaxed auth, test hooks). |
|
||||
|
||||
@@ -1148,6 +1175,13 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy),
|
||||
| `OMNIROUTE_LOCAL_ENDPOINTS_TOKEN` | _(unset)_ | `src/lib/security/localEndpoints.ts` | Bearer token for `/api/local/*` callers that aren't on loopback (e.g. the desktop app). When set, requests from non-loopback IPs must carry `Authorization: Bearer <token>`. Required when `OMNIROUTE_LOCAL_ENDPOINTS_ENABLED=1` in non-loopback deployments. |
|
||||
| `OMNIROUTE_REDIS_CONTAINER_NAME` | `omniroute-redis` | `bin/cli/commands/redis.mjs` | Container name for the 1-click Redis launcher (`omniroute redis up`). Used by both the CLI and the `RedisLauncherPanel` GUI. |
|
||||
| `OMNIROUTE_REDIS_HOST_PORT` | `6379` | `bin/cli/commands/redis.mjs` | Host port for the 1-click Redis launcher. Bump if the host already binds 6379. The container's internal port stays 6379. |
|
||||
| `OMNIROUTE_REDIS_BIND_HOST` | `127.0.0.1` | `bin/cli/commands/redis.mjs` | Host interface the 1-click Redis launcher publishes on. The launcher starts Redis WITHOUT a password, so binding `0.0.0.0` hands every host on your LAN an unauthenticated Redis — only widen this if you also set a password on the instance yourself. |
|
||||
| `REDIS_BIND_HOST` | `127.0.0.1` | `docker-compose.yml` | Host interface docker-compose publishes the Redis sidecar on (#9286). The compose Redis runs without `requirepass`; app containers reach it over the compose network (`redis:6379`) — the published port exists only for host-side tooling. `0.0.0.0` exposes an unauthenticated Redis to the whole LAN. |
|
||||
| `REDIS_PORT` | `6379` | `docker-compose.yml` | Host port for the compose Redis sidecar. |
|
||||
| `OMNIROUTE_INTERNAL_SERVICE_TOKEN` | _(unset — mechanism disabled)_ | `src/lib/api/internalServiceAuth.ts` | Shared secret for identity-preserving internal REST hops (#9260): OmniRoute components calling other local OmniRoute routes send it as `x-omniroute-internal-service-token` so the original caller identity is preserved. Compared with `timingSafeEqual`. |
|
||||
| `OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE` | _(unset)_ | `src/lib/api/internalServiceAuth.ts` | Secret-file variant of the internal service token: path to a file whose trimmed content is the token. Only consulted when the inline var is unset. |
|
||||
| `OPENROUTER_PROVIDER_STATS_ENABLED` | `true` | `src/lib/catalog/openrouterProviderStats.ts` | Enrich the dashboard providers list with OpenRouter weekly ranking stats (#9324). On by default; set `false` to skip the background fetch entirely (non-blocking, never fatal). |
|
||||
| `OPENROUTER_PROVIDER_STATS_TTL_MS` | `86400000` (24h) | `src/lib/catalog/openrouterProviderStats.ts` | Cache TTL for the OpenRouter provider-stats snapshot, in milliseconds. |
|
||||
| `OMNIROUTE_REDIS_IMAGE` | `redis:7-alpine` | `bin/cli/commands/redis.mjs` | Redis image used by the 1-click Redis launcher. Override to `redis:8-alpine` or a private registry mirror as needed. |
|
||||
| `QDRANT_HOST` | `qdrant` | _(opt-in cluster profile)_ | Hostname of the Qdrant sidecar when `--profile memory` is active. Default points to the in-network qdrant service name; override for an external deployment. Only consumed when `qdrantEnabled` is `true` in code (`src/lib/memory/vectorStore.ts:108`). |
|
||||
| `QDRANT_PORT` | `6333` | _(opt-in cluster profile)_ | REST port of the Qdrant sidecar. |
|
||||
@@ -1173,6 +1207,17 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy),
|
||||
| `OMNIROUTE_ROTATE_400_THRESHOLD` | `1` | `open-sse/services/rotationConfig.ts` | Number of `400` errors within `OMNIROUTE_ROTATE_400_WINDOW_SECONDS` required before the account is rotated (only consulted when `OMNIROUTE_ROTATE_ON_400=true`). |
|
||||
| `OMNIROUTE_ROTATE_400_WINDOW_SECONDS` | `120` | `open-sse/services/rotationConfig.ts` | Sliding window (seconds) over which `400` errors are counted toward `OMNIROUTE_ROTATE_400_THRESHOLD`. |
|
||||
|
||||
### Claude Warmup Scheduler
|
||||
|
||||
Cron-driven warmup for opted-in Anthropic OAuth connections, so the 5-hour rate-limit window is opened by a trivial scheduled request instead of by the first real one (#8848). The scheduler is off unless `OMNIROUTE_WARMUP_ENABLED` is truthy **and** the connection is flagged in `settings.claudeWarmup.connections`; an empty connection list means nothing is warmed even with the env var on.
|
||||
|
||||
| Variable | Default | Source File | Description |
|
||||
| ----------------------------- | -------------------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `OMNIROUTE_WARMUP_ENABLED` | _(unset → off)_ | `src/lib/warmupScheduler.ts` | Master switch for the warmup scheduler. Accepts `1`/`true`/`yes`/`on` (case-insensitive, trimmed). Any other value, or unset, leaves the scheduler off. |
|
||||
| `OMNIROUTE_WARMUP_CRON` | `0 7 * * *` | `src/lib/warmupScheduler.ts` | Five-field cron expression for the warmup tick, evaluated in `America/Los_Angeles` (Anthropic's reset timezone) regardless of the host clock. |
|
||||
| `OMNIROUTE_WARMUP_CONCURRENCY` | `3` | `src/lib/warmupScheduler.ts` | How many connections are warmed in parallel per tick. Clamped to `1`-`10`; a non-numeric value falls back to `3`. |
|
||||
| `OMNIROUTE_WARMUP_MODEL` | `claude-3-5-haiku-20241022` | `src/lib/warmupScheduler.ts` | Model used for the warmup request. Override only if the default is unavailable on your plan; pick the cheapest model that still opens the window. |
|
||||
|
||||
### Browser-Login VNC Sessions & Data-Dir Alias
|
||||
|
||||
Containerized Chromium+VNC used for interactive browser-login credential capture (`/api/vnc-session`), plus a legacy `DATA_DIR` alias. All optional — the VNC defaults target the bundled `omniroute-vnc-chromium:local` image and are only overridden for a custom container image, ports, or lifecycle tuning.
|
||||
@@ -1237,6 +1282,25 @@ that should be able to run the docs translator.
|
||||
|
||||
---
|
||||
|
||||
## 27. Radar Feed (Self-Hosting)
|
||||
|
||||
Optional add-on gated by the RADAR_ENABLED feature flag (default off — a feature
|
||||
flag toggled via Settings/DB, not an env var; see
|
||||
[docs/frameworks/RADAR.md](../frameworks/RADAR.md#flag-radar_enabled-default-off)).
|
||||
The four variables below are optional overrides used only to point the client at a
|
||||
self-hosted or forked feed / supporter-key flow instead of the default OmniRoute
|
||||
Radar service. See [docs/frameworks/RADAR.md](../frameworks/RADAR.md) for the full
|
||||
module doc.
|
||||
|
||||
| Variable | Default | Source File | Description |
|
||||
| -------------------------------- | --------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------ |
|
||||
| `RADAR_FEED_URL` | `https://radar.omniroute.online` | `src/lib/radar/sync.ts` | Base URL of the Radar feed service. Override to point at a self-hosted or forked feed. |
|
||||
| `RADAR_FEED_PUBKEY` | _(pinned default key)_ | `src/lib/radar/pinnedKeys.ts` | Ed25519 public key (base64-DER SPKI or PEM) used to verify feed signatures from a custom feed. |
|
||||
| `RADAR_CONTRIBUTOR_CLAIM_URL` | `https://radar.omniroute.online/auth/github` | `src/lib/radar/links.ts` | URL the "I'm a contributor" dashboard button opens (GitHub OAuth supporter-key claim flow). |
|
||||
| `RADAR_SUPPORTER_PLANS_URL` | `https://radar.omniroute.online/planos` | `src/lib/radar/links.ts` | URL the "Support the project" dashboard button opens (payment/plans page). |
|
||||
|
||||
---
|
||||
|
||||
## Audit: Removed / Dead Variables
|
||||
|
||||
The following variables appeared in previous versions of `.env.example` but have **no runtime references** in the current codebase. They have been removed:
|
||||
@@ -1304,13 +1368,52 @@ Used by `src/lib/vncSession/manifest.ts` to configure Docker-based headless Chro
|
||||
| `OMNIROUTE_VNC_HARVEST_MS` | `20000` | `src/lib/vncSession/manifest.ts` | Harvest/cleanup timeout (ms). |
|
||||
| `VIBEPROXY_DATA_DIR` | _(unset)_ | `open-sse/services/notionThreadSessions.ts` | Directory for Notion thread session persistence. |
|
||||
|
||||
### Telegram Mini App
|
||||
### Internal service auth
|
||||
|
||||
Used by `src/lib/telegram/*` and `src/app/api/telegram/update/route.ts` for the inbound bot webhook and Mini App chat proxy. All optional — the endpoint returns 503 when `TELEGRAM_BOT_TOKEN` is unset.
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `OMNIROUTE_INTERNAL_SERVICE_TOKEN` | – | Inline token for management-plane service-to-service authentication. |
|
||||
| `OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE` | – | Path to a file containing the internal service token (preferred in containers; overrides the inline variable). |
|
||||
|
||||
| Variable | Default | Source File | Description |
|
||||
| ------------------------------ | -------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| `TELEGRAM_BOT_TOKEN` | _(unset)_ | `src/lib/telegram/config.ts` | Bot token from @BotFather (`<numeric_id>:<secret>`). Enables the inbound webhook; doubles as the HMAC secret for Mini App `initData` verification. |
|
||||
| `TELEGRAM_DEFAULT_MODEL` | `auto/chat` | `src/lib/telegram/chatProxy.ts` | Model used for Telegram chat replies. |
|
||||
| `TELEGRAM_BOT_API_BASE` | `https://api.telegram.org` | `src/lib/telegram/config.ts` | Bot API base URL override (proxies / self-hosted Bot API servers). |
|
||||
| `TELEGRAM_WEBHOOK_TIMEOUT_MS` | `60000` | `src/lib/telegram/config.ts` | Timeout (ms) for outbound Bot API calls (`sendMessage`/`setWebhook`). |
|
||||
### OpenRouter provider stats
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `OPENROUTER_PROVIDER_STATS_ENABLED` | `true` | Set to `false` to skip fetching OpenRouter per-provider stats for catalog enrichment. |
|
||||
| `OPENROUTER_PROVIDER_STATS_TTL_MS` | `3600000` | Cache TTL (ms) for the fetched OpenRouter provider stats. |
|
||||
|
||||
### Embedded Redis binding
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `REDIS_BIND_HOST` | `127.0.0.1` | Bind address for the embedded Redis service. |
|
||||
| `REDIS_PORT` | `6379` | Port for the embedded Redis service. |
|
||||
| `OMNIROUTE_REDIS_BIND_HOST` | – | OmniRoute-scoped override for the embedded Redis bind address. |
|
||||
|
||||
---
|
||||
|
||||
## 24. Release v3.8.50 additions
|
||||
|
||||
These settings were introduced after the previous environment-contract snapshot.
|
||||
|
||||
| Variable | Default | Source File | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `OMNIROUTE_CHAT_ADMISSION_QUEUE_MS` | `5000` | `src/shared/middleware/chatBodyAdmission.ts` | Maximum wait for a heavyweight chat admission slot before a retryable `503`; `0` restores immediate rejection. |
|
||||
| `OMNIROUTE_RUNNOW_TIMEOUT_MS` | `30000` | `src/app/api/jobs/[id]/run-now/route.ts` | Bounds how long a run-now call waits for an in-flight job before starting the queued run. |
|
||||
| `CHAT_LOG_MAX_BODY_KB` | `1024` | `src/lib/logEnv.ts` | Maximum request or response body size before log summarization, in KiB. |
|
||||
| `ADOBE_FIREFLY_BROWSER_REFRESH` | enabled | `open-sse/services/adobeFireflySession.ts` | Keeps IMS and browser-risk state fresh through account-scoped Chrome CDP sessions; set `0` to disable. |
|
||||
| `ADOBE_FIREFLY_SESSION_DISK` | enabled | `open-sse/services/adobeFireflySession.ts` | Persists repaired Adobe sessions under `DATA_DIR`; set `0` for memory-only state. |
|
||||
| `ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS` | `12000` | `open-sse/services/adobeFireflySession.ts` | Minimum spacing between Adobe Firefly generate submissions. |
|
||||
| `ADOBE_FIREFLY_BATCH_EXTRA_GAP_MS` | `15000` | `open-sse/services/adobeFireflySession.ts` | Extra quiet period after every third successful Adobe submission. |
|
||||
| `ADOBE_FIREFLY_CHROME_CDP_PORT` | `9334` | `open-sse/services/adobeFireflyChromeRuntime.ts` | CDP port for the account-scoped Chrome runtime. |
|
||||
| `ADOBE_FIREFLY_CHROME_VISIBLE` | `0` | `open-sse/services/adobeFireflyChromeRuntime.ts` | Set `1` to keep the Adobe renewal browser visible; the default parks a headed window off-screen. |
|
||||
| `ADOBE_FIREFLY_CHROME_HEADLESS` | `0` | `open-sse/services/adobeFireflyChromeRuntime.ts` | Debug-only true-headless mode; Adobe colligo normally rejects the resulting risk session. |
|
||||
| `ADOBE_FIREFLY_CHROME_FORCE_RESTART` | `0` | `open-sse/services/adobeFireflyChromeRuntime.ts` | Set `1` to restart the account-scoped Chrome runtime before renewal. |
|
||||
| `ADOBE_FIREFLY_CHROME_PING` | automatic | `open-sse/services/adobeFireflyChromeRuntime.ts` | `1` forces, and `0` disables, the in-page generate probe used to prove the renewed ARP session. |
|
||||
| `ADOBE_FIREFLY_LOGIN_WAIT_MS` | context-dependent | `open-sse/services/adobeFireflyChromeRuntime.ts` | Interactive-login wait budget: `0` on background renewal and `300000` on the explicit login flow unless overridden. |
|
||||
| `ADOBE_FIREFLY_FORTER_WAIT_MS` | `45000` | `open-sse/services/adobeFireflyChromeRuntime.ts` | Maximum wait for a fresh Forter token during session renewal. |
|
||||
| `CHROME_PATH` | auto-detect | `open-sse/services/adobeFireflyChromeRuntime.ts` | Optional absolute Chrome executable used when platform auto-detection is insufficient. |
|
||||
| `TELEGRAM_BOT_TOKEN` | _(unset)_ | `src/lib/telegram/config.ts` | BotFather token that enables the inbound webhook and signs Mini App `initData`. |
|
||||
| `TELEGRAM_DEFAULT_MODEL` | `auto/chat` | `src/lib/telegram/chatProxy.ts` | Model used for Telegram chat replies. |
|
||||
| `TELEGRAM_BOT_API_BASE` | `https://api.telegram.org` | `src/lib/telegram/config.ts` | Bot API base URL override for proxies or self-hosted Bot API servers. |
|
||||
| `TELEGRAM_WEBHOOK_TIMEOUT_MS` | `60000` | `src/lib/telegram/config.ts` | Timeout in milliseconds for outbound Bot API calls. |
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
title: "Guardrails"
|
||||
version: 3.8.50
|
||||
lastUpdated: 2026-08-07
|
||||
lastUpdated: 2026-08-08
|
||||
---
|
||||
|
||||
# Guardrails
|
||||
|
||||
> **Source of truth:** `src/lib/guardrails/`
|
||||
> **Last updated:** 2026-08-07 — v3.8.50 (Modality Bridge PR-1: mode selector, task-aware prompt, describe cache, transparency header + stats)
|
||||
> **Last updated:** 2026-08-08 — v3.8.50 (Modality Bridge PR-2: dashboard settings page, live stats, test action, and media-provider shortcuts)
|
||||
|
||||
Guardrails enforce safety, policy, and content transformations at the boundary
|
||||
between OmniRoute and upstream providers. Each guardrail can inspect (and
|
||||
@@ -134,6 +134,22 @@ swap is already visible in the response body's `model` field.
|
||||
PR-3-reserved `audio`). Counters reset on process restart by design
|
||||
(telemetry, not accounting).
|
||||
|
||||
#### Dashboard configuration
|
||||
|
||||
The dedicated dashboard page is
|
||||
`/dashboard/settings/modality-bridge`. Its URL-addressable `Vision`, `Audio`,
|
||||
and `Video` tabs preserve query parameters while switching the `tab` value.
|
||||
The Vision tab is live: it exposes enablement, mode, model selection (including
|
||||
the automatic default), task-aware prompting, advanced timeout/image/cache
|
||||
limits, runtime counters, and a guarded sample request. Audio and Video are
|
||||
explicit placeholders: Audio is reserved for PR-3, while Video remains tracked
|
||||
in issue `#9760`.
|
||||
|
||||
The former Vision Bridge card under AI settings is a compatibility link to the
|
||||
new page; it no longer owns a second copy of the form. Media Providers also
|
||||
links Image-to-Text and Speech-to-Text workflows to the corresponding Modality
|
||||
Bridge tabs without removing the existing Speech-to-Text playground.
|
||||
|
||||
**Self-loop admission bypass:** when the describe call routes through OmniRoute's
|
||||
own `/v1` self-loop (non-standard provider model), the sub-request sends
|
||||
`x-omniroute-admission-bypass: internal` and is authenticated with the resolved
|
||||
@@ -355,9 +371,16 @@ Environment variables read by the built-in guardrails:
|
||||
| `PII_RESPONSE_SANITIZATION` / `_MODE` | `pii-masker` (downstream) | Controls response-side masker behavior. |
|
||||
|
||||
The Vision Bridge reads runtime config from the DB-backed settings store
|
||||
(`getSettings()`), not env vars: `visionBridgeEnabled`, `visionBridgeModel`,
|
||||
`visionBridgePrompt`, `visionBridgeTimeout`, `visionBridgeMaxImages`. Defaults
|
||||
live in `src/shared/constants/visionBridgeDefaults.ts`.
|
||||
(`getSettings()`), not env vars. The primary keys are
|
||||
`modalityBridgeVisionEnabled`, `modalityBridgeVisionMode`,
|
||||
`modalityBridgeVisionModel`, `modalityBridgeVisionTaskAware`,
|
||||
`modalityBridgeVisionPrompt`, `modalityBridgeVisionTimeout`,
|
||||
`modalityBridgeVisionMaxImages`, `modalityBridgeCacheEnabled`,
|
||||
`modalityBridgeCacheTtlMinutes`, and `modalityBridgeCacheMaxEntries`. The legacy
|
||||
`visionBridge*` keys are accepted only as the documented one-cycle read
|
||||
fallback; dashboard writes use the primary keys. Defaults and the fallback
|
||||
resolver live in `src/shared/constants/modalityBridgeDefaults.ts`, with legacy
|
||||
constants retained in `src/shared/constants/visionBridgeDefaults.ts`.
|
||||
|
||||
## Custom Guardrails
|
||||
|
||||
|
||||
@@ -247,6 +247,17 @@ export const AUDIO_TRANSCRIPTION_PROVIDERS: Record<string, AudioProvider> = {
|
||||
format: "speechmatics",
|
||||
models: [{ id: "enhanced", name: "Enhanced" }],
|
||||
},
|
||||
|
||||
nanogpt: {
|
||||
id: "nanogpt",
|
||||
baseUrl: "https://nano-gpt.com/api/v1/audio/transcriptions",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [
|
||||
{ id: "whisper-1", name: "Whisper 1" },
|
||||
{ id: "gpt-4o-transcription", name: "GPT-4o Transcription" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -570,6 +581,17 @@ export const AUDIO_SPEECH_PROVIDERS: Record<string, AudioProvider> = {
|
||||
{ id: "mimo-v2.5-tts-voiceclone", name: "MiMo V2.5 Voice Clone" },
|
||||
],
|
||||
},
|
||||
|
||||
nanogpt: {
|
||||
id: "nanogpt",
|
||||
baseUrl: "https://nano-gpt.com/api/v1/audio/speech",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [
|
||||
{ id: "tts-1-hd", name: "TTS 1 HD" },
|
||||
{ id: "tts-1", name: "TTS 1" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getUpstreamTimeoutConfig } from "@/shared/utils/runtimeTimeouts";
|
||||
import { resolvePublicCred } from "../utils/publicCreds.ts";
|
||||
import type { LegacyProvider } from "./providerRegistry.ts";
|
||||
import { loadProviderCredentials } from "./credentialLoader.ts";
|
||||
import { generateLegacyProviders } from "./providerRegistry.ts";
|
||||
@@ -18,6 +19,15 @@ export const FETCH_TIMEOUT_MS = upstreamTimeouts.fetchTimeoutMs;
|
||||
// idle for this duration. Override with STREAM_IDLE_TIMEOUT_MS env var.
|
||||
export const STREAM_IDLE_TIMEOUT_MS = upstreamTimeouts.streamIdleTimeoutMs;
|
||||
|
||||
// Grace period (ms) a client-disconnect finalization waits for the stream's own
|
||||
// completion bookkeeping to land before persisting a 499. See #9653 — a client
|
||||
// that closes right after reading a fully-completed SSE stream can otherwise
|
||||
// race OmniRoute's own completion callback, resulting in a false 499 with zero
|
||||
// token usage for a request that actually delivered its full response. Set
|
||||
// STREAM_DISCONNECT_GRACE_PERIOD_MS=0 to disable and restore the old
|
||||
// immediate-fail behavior.
|
||||
export const STREAM_DISCONNECT_GRACE_PERIOD_MS = upstreamTimeouts.streamDisconnectGracePeriodMs;
|
||||
|
||||
// Timeout for the first non-ping SSE event. Inherits REQUEST_TIMEOUT_MS when
|
||||
// set, unless STREAM_READINESS_TIMEOUT_MS is specified directly. This must stay
|
||||
// conservative for large prompts and slow first-byte reasoning providers.
|
||||
@@ -127,7 +137,7 @@ export const OAUTH_ENDPOINTS = {
|
||||
openference: {
|
||||
token: "https://openference.com/oauth/token",
|
||||
auth: "https://openference.com/app/oauth/authorize",
|
||||
clientId: "omniroute",
|
||||
clientId: resolvePublicCred("openference_id"),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -409,6 +409,25 @@ export const EMBEDDING_PROVIDERS: Record<string, EmbeddingProvider> = {
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
nanogpt: {
|
||||
id: "nanogpt",
|
||||
baseUrl: "https://nano-gpt.com/v1/embeddings",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [
|
||||
{
|
||||
id: "text-embedding-3-small",
|
||||
name: "Text Embedding 3 Small",
|
||||
dimensions: 1536,
|
||||
},
|
||||
{
|
||||
id: "text-embedding-3-large",
|
||||
name: "Text Embedding 3 Large",
|
||||
dimensions: 3072,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const EMBEDDING_PROVIDER_ALIASES: Record<string, string> = {
|
||||
|
||||
@@ -8,6 +8,7 @@ export const nanogptProvider: RegistryEntry = {
|
||||
executor: "default",
|
||||
baseUrl: "https://nano-gpt.com/api/v1/chat/completions",
|
||||
modelsUrl: "https://nano-gpt.com/api/v1/models",
|
||||
responsesBaseUrl: "https://nano-gpt.com/api/v1/responses",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: CHAT_OPENAI_COMPAT_MODELS.nanogpt,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import { resolvePublicCred, type RegistryEntry } from "../../shared.ts";
|
||||
|
||||
/**
|
||||
* Openference — OpenAI-compatible AI inference gateway (https://openference.com/).
|
||||
@@ -18,7 +18,7 @@ export const openferenceProvider: RegistryEntry = {
|
||||
authHeader: "bearer",
|
||||
passthroughModels: true,
|
||||
oauth: {
|
||||
clientIdDefault: "omniroute",
|
||||
clientIdDefault: resolvePublicCred("openference_id"),
|
||||
tokenUrl: "https://openference.com/oauth/token",
|
||||
},
|
||||
models: [{ id: "GLM-5.2", name: "GLM 5.2", contextLength: 850000 }],
|
||||
|
||||
@@ -339,6 +339,15 @@ export const VIDEO_PROVIDERS: Record<string, VideoProvider> = {
|
||||
format: "adobe-firefly-video",
|
||||
models: toRegistryVideoModels(),
|
||||
},
|
||||
|
||||
nanogpt: {
|
||||
id: "nanogpt",
|
||||
baseUrl: "https://nano-gpt.com/api/v1/video/generations",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
format: "openai",
|
||||
models: [{ id: "default", name: "NanoGPT Video" }],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -2822,8 +2822,7 @@ export class ChatGptWebExecutor extends BaseExecutor {
|
||||
const modelSlug = resolvedModel.slug;
|
||||
const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages(
|
||||
(body || {}) as Record<string, unknown>,
|
||||
messages as Array<{ role: string; content: unknown }>,
|
||||
{ hardened: isThinkingCapableModel(model, modelSlug) }
|
||||
messages as Array<{ role: string; content: unknown }>
|
||||
);
|
||||
|
||||
if (!credentials.apiKey) {
|
||||
|
||||
@@ -142,6 +142,8 @@ import {
|
||||
getExplicitModelOutputCap,
|
||||
resolveInputTokenCapForGate,
|
||||
} from "@/lib/modelCapabilities.ts";
|
||||
import { checkRequestCapabilityFit, deriveRequestCapabilityRequirements, buildCapabilityMismatchMessage } from "@/shared/constants/capabilities/capabilityFilter.ts";
|
||||
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags.ts";
|
||||
import { toPositiveInteger } from "../services/reasoningTokenBuffer.ts";
|
||||
import { normalizeThinkingForModel } from "@/shared/constants/modelSpecs.ts";
|
||||
import {
|
||||
@@ -171,6 +173,7 @@ import {
|
||||
ANTIGRAVITY_PRE_RESPONSE_TIMEOUT_CODE,
|
||||
STREAM_RECOVERY,
|
||||
DEFAULT_MAX_TOKENS,
|
||||
STREAM_DISCONNECT_GRACE_PERIOD_MS,
|
||||
} from "../config/constants.ts";
|
||||
import { createRecoverableStream, makeContinuationBody } from "../services/streamRecovery.ts";
|
||||
import {
|
||||
@@ -2637,7 +2640,16 @@ export async function handleChatCore({
|
||||
}
|
||||
}
|
||||
// === /Quota Share enforcement PRE-hook ===
|
||||
|
||||
if (isFeatureFlagEnabled("CAPABILITY_FILTER_ENABLED")) {
|
||||
const fit = checkRequestCapabilityFit(getResolvedModelCapabilities({ provider, model: effectiveModel }),
|
||||
deriveRequestCapabilityRequirements(body as Record<string, unknown>), provider);
|
||||
if (!fit.compatible) {
|
||||
const msg = buildCapabilityMismatchMessage(fit.terminalReason!, provider, effectiveModel);
|
||||
log?.warn?.("CAPABILITY", msg);
|
||||
trackPendingRequest(model, provider, connectionId, false);
|
||||
return createErrorResult(400, msg, null, fit.terminalReason, "invalid_request_error");
|
||||
}
|
||||
}
|
||||
// Get executor for this provider (with optional upstream proxy routing)
|
||||
const executor = await resolveExecutorWithProxy(provider);
|
||||
const getExecutionCredentials = () =>
|
||||
@@ -4917,13 +4929,20 @@ export async function handleChatCore({
|
||||
});
|
||||
const handleStreamFailure = streamFailureFinalizers.handleStreamFailure;
|
||||
onPipelineStreamError = streamFailureFinalizers.onPipelineStreamError;
|
||||
onClientDisconnectFinalize = (event) =>
|
||||
handleStreamFailure({
|
||||
status: 499,
|
||||
message: `Client disconnected: ${event.reason}`,
|
||||
code: "client_disconnected",
|
||||
type: "client_disconnected",
|
||||
});
|
||||
// #9653: gives a genuine, race-delayed completion a chance to land (see
|
||||
// createClientDisconnectGraceHandler's doc comment) before persisting a false
|
||||
// 499/0-tokens for a request that actually delivered its full response.
|
||||
onClientDisconnectFinalize = streamFailure.createClientDisconnectGraceHandler({
|
||||
isStreamCompletionRecorded: () => streamCompletionRecorded,
|
||||
gracePeriodMs: STREAM_DISCONNECT_GRACE_PERIOD_MS,
|
||||
finalize: (event) =>
|
||||
handleStreamFailure({
|
||||
status: 499,
|
||||
message: `Client disconnected: ${event.reason}`,
|
||||
code: "client_disconnected",
|
||||
type: "client_disconnected",
|
||||
}),
|
||||
});
|
||||
|
||||
// For providers using Responses API format, translate stream back to openai (Chat Completions) format
|
||||
// UNLESS client is Droid CLI which expects openai-responses format back
|
||||
|
||||
@@ -1524,7 +1524,7 @@ async function handleFalAIImageGeneration({
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const images = await normalizeProviderImagePayload(payload, body, log);
|
||||
const images = await normalizeProviderImagePayload(payload, body, log, "b64_json");
|
||||
return saveImageSuccessResult({
|
||||
provider,
|
||||
model,
|
||||
@@ -1714,7 +1714,7 @@ async function handleStabilityAIImageGeneration({
|
||||
payload = { image: buffer.toString("base64") };
|
||||
}
|
||||
|
||||
const images = await normalizeProviderImagePayload(payload, body, log);
|
||||
const images = await normalizeProviderImagePayload(payload, body, log, "b64_json");
|
||||
return saveImageSuccessResult({
|
||||
provider,
|
||||
model,
|
||||
@@ -1833,7 +1833,7 @@ async function handleBlackForestLabsImageGeneration({
|
||||
})
|
||||
: initialPayload;
|
||||
|
||||
const images = await normalizeProviderImagePayload(finalPayload, body, log);
|
||||
const images = await normalizeProviderImagePayload(finalPayload, body, log, "url");
|
||||
return saveImageSuccessResult({
|
||||
provider,
|
||||
model,
|
||||
@@ -1908,7 +1908,7 @@ async function handleRecraftImageGeneration({
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const images = await normalizeProviderImagePayload(payload, body, log);
|
||||
const images = await normalizeProviderImagePayload(payload, body, log, "url");
|
||||
return saveImageSuccessResult({
|
||||
provider,
|
||||
model,
|
||||
@@ -2200,7 +2200,7 @@ function shouldIncludeStabilityMask(model) {
|
||||
]).has(model);
|
||||
}
|
||||
|
||||
async function normalizeProviderImagePayload(payload, body, log) {
|
||||
async function normalizeProviderImagePayload(payload, body, log, defaultFormat) {
|
||||
const candidates = [];
|
||||
|
||||
const pushCandidate = (value) => {
|
||||
@@ -2226,7 +2226,7 @@ async function normalizeProviderImagePayload(payload, body, log) {
|
||||
|
||||
const normalized = [];
|
||||
for (const candidate of candidates) {
|
||||
const item = await normalizeProviderImageCandidate(candidate, body);
|
||||
const item = await normalizeProviderImageCandidate(candidate, body, defaultFormat);
|
||||
if (item) normalized.push(item);
|
||||
}
|
||||
|
||||
@@ -2240,8 +2240,8 @@ async function normalizeProviderImagePayload(payload, body, log) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function normalizeProviderImageCandidate(candidate, body) {
|
||||
const wantsBase64 = body?.response_format === "b64_json";
|
||||
async function normalizeProviderImageCandidate(candidate, body, defaultFormat) {
|
||||
const wantsBase64 = body?.response_format === "b64_json" || defaultFormat === "b64_json";
|
||||
let url = null;
|
||||
let b64 = null;
|
||||
|
||||
|
||||
@@ -15,14 +15,15 @@ import { saveImageErrorResult, saveImageSuccessResult } from "../../imageGenerat
|
||||
import {
|
||||
AdobeFireflyError,
|
||||
adobeFireflyGenerateImage,
|
||||
adobeFireflyImageTimeoutMs,
|
||||
resolveAdobeAccessToken,
|
||||
resolveAdobeSourceImageReferences,
|
||||
resolveAdobeSourceImageIds,
|
||||
resolveAdobeImageModel,
|
||||
} from "../../../services/adobeFireflyClient.ts";
|
||||
import { getAdobeReferenceUploadLimit } from "../../../services/adobeFireflyModels.ts";
|
||||
import { isAdobeFireflyUpscaleModel } from "../../../services/adobeFireflyUpscale.ts";
|
||||
import { handleAdobeFireflyImageUpscale } from "../../imageUpscale/adobeFirefly.ts";
|
||||
import { ensureAdobeFireflySession } from "../../../services/adobeFireflySession.ts";
|
||||
|
||||
function normalizePositiveNumber(value: unknown, fallback: number): number {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && n > 0 ? n : fallback;
|
||||
}
|
||||
|
||||
export async function handleAdobeFireflyImageGeneration({
|
||||
model,
|
||||
@@ -50,25 +51,22 @@ export async function handleAdobeFireflyImageGeneration({
|
||||
images?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
credentials: { apiKey?: string; accessToken?: string };
|
||||
credentials: {
|
||||
apiKey?: string;
|
||||
accessToken?: string;
|
||||
connectionId?: string;
|
||||
providerSpecificData?: {
|
||||
cookie?: unknown;
|
||||
access_token?: unknown;
|
||||
accessToken?: unknown;
|
||||
browserSessionKey?: unknown;
|
||||
} | null;
|
||||
};
|
||||
log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void };
|
||||
fetchImpl?: typeof fetch;
|
||||
}) {
|
||||
const startTime = Date.now();
|
||||
const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
|
||||
|
||||
// Topaz upscalers share adobe-firefly but use /v2/3p-images/upsample (no prompt).
|
||||
if (isAdobeFireflyUpscaleModel(model)) {
|
||||
return handleAdobeFireflyImageUpscale({
|
||||
model,
|
||||
provider,
|
||||
body: body as Record<string, unknown>,
|
||||
credentials,
|
||||
log,
|
||||
fetchImpl,
|
||||
});
|
||||
}
|
||||
|
||||
if (!prompt) {
|
||||
return saveImageErrorResult({
|
||||
provider,
|
||||
@@ -80,7 +78,17 @@ export async function handleAdobeFireflyImageGeneration({
|
||||
}
|
||||
|
||||
try {
|
||||
const accessToken = await resolveAdobeAccessToken(credentials, fetchImpl);
|
||||
// Durable session: JWT + Cookie once → auto-rebuild ARP from forter/arkose,
|
||||
// cache, optional Playwright warm-up. Submit path rotates ARP on 408.
|
||||
const session = await ensureAdobeFireflySession({
|
||||
credentials,
|
||||
fetchImpl,
|
||||
log,
|
||||
});
|
||||
const accessToken = session.accessToken;
|
||||
const sessionCookie = session.cookie || undefined;
|
||||
const arpSessionId = session.arpSessionId;
|
||||
const timeoutMs = normalizePositiveNumber(body.timeout_ms, 180_000);
|
||||
const seed =
|
||||
typeof body.seed === "number"
|
||||
? body.seed
|
||||
@@ -88,44 +96,26 @@ export async function handleAdobeFireflyImageGeneration({
|
||||
? Number(body.seed)
|
||||
: undefined;
|
||||
|
||||
// Keep the raw credential blob for Cookie + sherlockToken (x-arp-session-id).
|
||||
// JWT may be embedded in the same paste as cookies (HAR / multi-line).
|
||||
const psd = (credentials as { providerSpecificData?: { cookie?: string } })
|
||||
?.providerSpecificData;
|
||||
const sessionCookie =
|
||||
(typeof psd?.cookie === "string" && psd.cookie.trim()) ||
|
||||
(typeof credentials?.apiKey === "string" && credentials.apiKey.trim()) ||
|
||||
(typeof credentials?.accessToken === "string" && credentials.accessToken.includes(";")
|
||||
? credentials.accessToken
|
||||
: undefined);
|
||||
// Cap uploads by model family (matches MediaViewModel GetSourceImageLimit).
|
||||
const { id: resolvedId } = resolveAdobeImageModel(model);
|
||||
const maxRefs = resolvedId.includes("nano-banana") || resolvedId.includes("gpt-image") ? 4 : 2;
|
||||
|
||||
const { spec } = resolveAdobeImageModel(model);
|
||||
const references = await resolveAdobeSourceImageReferences({
|
||||
const sourceImageIds = await resolveAdobeSourceImageIds({
|
||||
accessToken,
|
||||
body,
|
||||
max: getAdobeReferenceUploadLimit(spec, "image"),
|
||||
max: maxRefs,
|
||||
sessionCookie,
|
||||
arpSessionId,
|
||||
prompt,
|
||||
fetchImpl,
|
||||
log,
|
||||
});
|
||||
|
||||
const explicitTimeout =
|
||||
typeof body.timeout_ms === "number"
|
||||
? body.timeout_ms
|
||||
: typeof body.timeout_ms === "string" && body.timeout_ms.trim()
|
||||
? Number(body.timeout_ms)
|
||||
: undefined;
|
||||
const timeoutMs = adobeFireflyImageTimeoutMs({
|
||||
timeoutMs: explicitTimeout,
|
||||
refCount: references.length,
|
||||
});
|
||||
|
||||
log?.info?.(
|
||||
"IMAGE",
|
||||
`${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` +
|
||||
(references.length ? ` | refs: ${references.length}` : "") +
|
||||
` | pollTimeoutMs=${timeoutMs}`
|
||||
(sourceImageIds.length ? ` | refs: ${sourceImageIds.length}` : "") +
|
||||
` | session=${session.source}`
|
||||
);
|
||||
|
||||
const result = await adobeFireflyGenerateImage({
|
||||
@@ -137,8 +127,11 @@ export async function handleAdobeFireflyImageGeneration({
|
||||
quality: body.quality,
|
||||
seed: Number.isFinite(seed as number) ? (seed as number) : undefined,
|
||||
negativePrompt: typeof body.negative_prompt === "string" ? body.negative_prompt : undefined,
|
||||
references: references.length ? references : undefined,
|
||||
sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined,
|
||||
sessionCookie,
|
||||
arpSessionId,
|
||||
sessionFingerprint: session.fingerprint,
|
||||
sessionBrowserKey: session.browserSessionKey,
|
||||
timeoutMs,
|
||||
fetchImpl,
|
||||
log,
|
||||
|
||||
@@ -40,7 +40,12 @@ export async function handleResponsesCore({
|
||||
const customToolNames = collectResponsesCustomToolNames(body?.tools, inputItems);
|
||||
|
||||
// Convert Responses API format to Chat Completions format
|
||||
const convertedBody = convertResponsesApiFormat(body, credentials, modelInfo?.provider);
|
||||
const convertedBody = convertResponsesApiFormat(
|
||||
body,
|
||||
credentials,
|
||||
modelInfo?.provider,
|
||||
modelInfo?.model
|
||||
);
|
||||
|
||||
// Ensure stream is enabled
|
||||
convertedBody.stream = true;
|
||||
|
||||
@@ -9,11 +9,10 @@ import { sanitizeErrorMessage } from "../../utils/error.ts";
|
||||
import {
|
||||
AdobeFireflyError,
|
||||
adobeFireflyGenerateVideo,
|
||||
resolveAdobeAccessToken,
|
||||
resolveAdobeSourceImageReferences,
|
||||
resolveAdobeSourceImageIds,
|
||||
resolveAdobeVideoModel,
|
||||
} from "../../services/adobeFireflyClient.ts";
|
||||
import { getAdobeReferenceUploadLimit } from "../../services/adobeFireflyModels.ts";
|
||||
import { ensureAdobeFireflySession } from "../../services/adobeFireflySession.ts";
|
||||
|
||||
function normalizePositiveNumber(value: unknown, fallback: number): number {
|
||||
const n = Number(value);
|
||||
@@ -32,7 +31,17 @@ export async function handleAdobeFireflyVideoGeneration({
|
||||
provider: string;
|
||||
providerConfig?: { baseUrl?: string };
|
||||
body: Record<string, unknown>;
|
||||
credentials?: { apiKey?: string; accessToken?: string } | null;
|
||||
credentials?: {
|
||||
apiKey?: string;
|
||||
accessToken?: string;
|
||||
connectionId?: string;
|
||||
providerSpecificData?: {
|
||||
cookie?: unknown;
|
||||
access_token?: unknown;
|
||||
accessToken?: unknown;
|
||||
browserSessionKey?: unknown;
|
||||
} | null;
|
||||
} | null;
|
||||
log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void };
|
||||
fetchImpl?: typeof fetch;
|
||||
}) {
|
||||
@@ -47,7 +56,14 @@ export async function handleAdobeFireflyVideoGeneration({
|
||||
}
|
||||
|
||||
try {
|
||||
const accessToken = await resolveAdobeAccessToken(credentials, fetchImpl);
|
||||
const session = await ensureAdobeFireflySession({
|
||||
credentials,
|
||||
fetchImpl,
|
||||
log,
|
||||
});
|
||||
const accessToken = session.accessToken;
|
||||
const sessionCookie = session.cookie || undefined;
|
||||
const arpSessionId = session.arpSessionId;
|
||||
const timeoutMs = normalizePositiveNumber(body.timeout_ms, 300_000);
|
||||
const seed =
|
||||
typeof body.seed === "number"
|
||||
@@ -55,22 +71,16 @@ export async function handleAdobeFireflyVideoGeneration({
|
||||
: typeof body.seed === "string" && String(body.seed).trim()
|
||||
? Number(body.seed)
|
||||
: undefined;
|
||||
// Keep raw paste for Cookie + sherlockToken (x-arp-session-id).
|
||||
const psd = (credentials as { providerSpecificData?: { cookie?: string } })
|
||||
?.providerSpecificData;
|
||||
const sessionCookie =
|
||||
(typeof psd?.cookie === "string" && psd.cookie.trim()) ||
|
||||
(typeof credentials?.apiKey === "string" && credentials.apiKey.trim()) ||
|
||||
(typeof credentials?.accessToken === "string" && credentials.accessToken.includes(";")
|
||||
? credentials.accessToken
|
||||
: undefined);
|
||||
|
||||
const { spec } = resolveAdobeVideoModel(String(model));
|
||||
const references = await resolveAdobeSourceImageReferences({
|
||||
// Kling i2v / Veo ref / Sora frame: upload reference images first.
|
||||
const { id: videoModelId } = resolveAdobeVideoModel(String(model));
|
||||
const maxFrames = videoModelId.includes("kling") || videoModelId.includes("sora") ? 2 : 3;
|
||||
const sourceImageIds = await resolveAdobeSourceImageIds({
|
||||
accessToken,
|
||||
body,
|
||||
max: getAdobeReferenceUploadLimit(spec, "image"),
|
||||
max: maxFrames,
|
||||
sessionCookie,
|
||||
arpSessionId,
|
||||
prompt,
|
||||
fetchImpl,
|
||||
log,
|
||||
@@ -79,7 +89,8 @@ export async function handleAdobeFireflyVideoGeneration({
|
||||
log?.info?.(
|
||||
"VIDEO",
|
||||
`${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` +
|
||||
(references.length ? ` | refs: ${references.length}` : "")
|
||||
(sourceImageIds.length ? ` | frames: ${sourceImageIds.length}` : "") +
|
||||
` | session=${session.source}`
|
||||
);
|
||||
|
||||
const result = await adobeFireflyGenerateVideo({
|
||||
@@ -99,8 +110,11 @@ export async function handleAdobeFireflyVideoGeneration({
|
||||
? body.negativePrompt
|
||||
: undefined,
|
||||
generateAudio: body.generate_audio !== false && body.generateAudio !== false,
|
||||
references: references.length ? references : undefined,
|
||||
sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined,
|
||||
sessionCookie,
|
||||
arpSessionId,
|
||||
sessionFingerprint: session.fingerprint,
|
||||
sessionBrowserKey: session.browserSessionKey,
|
||||
timeoutMs,
|
||||
fetchImpl,
|
||||
log,
|
||||
|
||||
@@ -254,7 +254,7 @@ export async function handleVideoJobGeneration({
|
||||
body: JSON.stringify(bodyForPreset),
|
||||
log,
|
||||
});
|
||||
if (!submitResult.ok) {
|
||||
if (submitResult.ok === false) {
|
||||
return { success: false, status: submitResult.status, error: submitResult.error };
|
||||
}
|
||||
|
||||
@@ -279,7 +279,7 @@ export async function handleVideoJobGeneration({
|
||||
headers: buildJobHeaders(preset, credentials),
|
||||
log,
|
||||
});
|
||||
if (!pollResult.ok) {
|
||||
if (pollResult.ok === false) {
|
||||
return { success: false, status: pollResult.status, error: pollResult.error };
|
||||
}
|
||||
|
||||
|
||||
@@ -54,21 +54,8 @@ export function buildAccountSemaphoreKey({
|
||||
return `${String(provider)}:${String(accountKey)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective positive cap, or null when the semaphore is bypassed (unset/<=0).
|
||||
*
|
||||
* Narrowing companion of {@link isBypassed}: that one returns a plain boolean, so
|
||||
* TypeScript cannot narrow `number | null` to `number` in its else-branch (a
|
||||
* `x is null | undefined` predicate would be unsound — 0 bypasses too). Callers
|
||||
* that need the VALUE after the guard go through here instead of casting.
|
||||
*/
|
||||
function resolveActiveCap(maxConcurrency?: number | null): number | null {
|
||||
if (maxConcurrency == null || maxConcurrency <= 0) return null;
|
||||
return maxConcurrency;
|
||||
}
|
||||
|
||||
function isBypassed(maxConcurrency?: number | null): boolean {
|
||||
return resolveActiveCap(maxConcurrency) === null;
|
||||
return maxConcurrency == null || maxConcurrency <= 0;
|
||||
}
|
||||
|
||||
function createNoopReleaseFn(): () => void {
|
||||
@@ -205,8 +192,7 @@ export function acquire(
|
||||
maxQueueSize = DEFAULT_MAX_QUEUE_SIZE,
|
||||
}: AcquireAccountSemaphoreOptions = {}
|
||||
): Promise<() => void> {
|
||||
const activeCap = resolveActiveCap(maxConcurrency);
|
||||
if (activeCap === null) {
|
||||
if (isBypassed(maxConcurrency)) {
|
||||
return Promise.resolve(createNoopReleaseFn());
|
||||
}
|
||||
|
||||
@@ -214,7 +200,9 @@ export function acquire(
|
||||
return Promise.reject(makeAbortError(signal));
|
||||
}
|
||||
|
||||
const gate = ensureGate(semaphoreKey, activeCap);
|
||||
// isBypassed() above already excluded null/<=0 — ensureGate requires a plain
|
||||
// number, but a boolean-returning helper isn't a type predicate TS can narrow on.
|
||||
const gate = ensureGate(semaphoreKey, maxConcurrency as number);
|
||||
clearCleanupTimer(gate);
|
||||
|
||||
if (gate.running < gate.maxConcurrency && !isBlocked(gate)) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1200
open-sse/services/adobeFireflyChromeRuntime.ts
Normal file
1200
open-sse/services/adobeFireflyChromeRuntime.ts
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
97
open-sse/services/adobeFireflyReferences.ts
Normal file
97
open-sse/services/adobeFireflyReferences.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { AdobeFireflyError } from "./adobeFireflyClient.ts";
|
||||
import type { AdobeFireflyVideoModelSpec } from "./adobeFireflyClient.ts";
|
||||
|
||||
export interface AdobeSourceImageReference {
|
||||
source: string;
|
||||
usage?: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export function normalizeAdobeReferenceBlobs(
|
||||
modelSpec: AdobeFireflyVideoModelSpec,
|
||||
references: unknown
|
||||
): Array<{ id: string; usage: string; order?: number }> {
|
||||
if (!Array.isArray(references)) return [];
|
||||
|
||||
const maxReferences = modelSpec.referenceMode === "image" ? 3 : 2;
|
||||
if (references.length > maxReferences) {
|
||||
throw new AdobeFireflyError(
|
||||
`Adobe Firefly model accepts at most ${maxReferences} ${
|
||||
modelSpec.referenceMode === "image" ? "asset" : "frame"
|
||||
} image references`,
|
||||
400,
|
||||
"bad_image"
|
||||
);
|
||||
}
|
||||
|
||||
return references.map((reference, index) => {
|
||||
if (!reference || typeof reference !== "object") {
|
||||
throw new AdobeFireflyError("Invalid Adobe Firefly reference image", 400, "bad_image");
|
||||
}
|
||||
const value = reference as Record<string, unknown>;
|
||||
const id = typeof value.id === "string" ? value.id.trim() : "";
|
||||
if (!id) {
|
||||
throw new AdobeFireflyError("Adobe Firefly reference image id is required", 400, "bad_image");
|
||||
}
|
||||
|
||||
const expectedUsage = modelSpec.referenceMode === "image" ? "asset" : "frame";
|
||||
const usage = typeof value.usage === "string" ? value.usage.trim() : expectedUsage;
|
||||
if (usage !== expectedUsage) {
|
||||
throw new AdobeFireflyError(
|
||||
`Adobe Firefly model does not support image references with usage '${usage}'`,
|
||||
400,
|
||||
"bad_image"
|
||||
);
|
||||
}
|
||||
|
||||
return expectedUsage === "frame" ? { id, usage, order: index + 1 } : { id, usage };
|
||||
});
|
||||
}
|
||||
|
||||
export function extractAdobeSourceImageReferences(
|
||||
body: unknown,
|
||||
max = 4
|
||||
): AdobeSourceImageReference[] {
|
||||
if (!body || typeof body !== "object") return [];
|
||||
const inputs = (body as Record<string, unknown>).adobe_reference_inputs;
|
||||
if (!Array.isArray(inputs)) return [];
|
||||
|
||||
const references: AdobeSourceImageReference[] = [];
|
||||
for (const input of inputs) {
|
||||
if (!input || typeof input !== "object") continue;
|
||||
const value = input as Record<string, unknown>;
|
||||
if (
|
||||
value.type !== undefined &&
|
||||
value.type !== "input_image" &&
|
||||
value.type !== "image" &&
|
||||
value.type !== "image_url"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const imageUrl = value.image_url;
|
||||
const source =
|
||||
typeof value.source === "string"
|
||||
? value.source.trim()
|
||||
: typeof imageUrl === "string"
|
||||
? imageUrl.trim()
|
||||
: imageUrl &&
|
||||
typeof imageUrl === "object" &&
|
||||
typeof (imageUrl as Record<string, unknown>).url === "string"
|
||||
? String((imageUrl as Record<string, unknown>).url).trim()
|
||||
: typeof value.url === "string"
|
||||
? value.url.trim()
|
||||
: "";
|
||||
if (!source || (!source.startsWith("data:image/") && !/^https?:\/\//i.test(source))) continue;
|
||||
|
||||
const usage =
|
||||
typeof value.usage === "string" && value.usage.trim() ? value.usage.trim() : undefined;
|
||||
const order =
|
||||
typeof value.order === "number" && Number.isInteger(value.order) && value.order > 0
|
||||
? value.order
|
||||
: undefined;
|
||||
references.push({ source, ...(usage ? { usage } : {}), ...(order ? { order } : {}) });
|
||||
if (references.length >= max) break;
|
||||
}
|
||||
return references;
|
||||
}
|
||||
54
open-sse/services/adobeFireflySecurity.ts
Normal file
54
open-sse/services/adobeFireflySecurity.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
const ADOBE_JWT_IN_TEXT_REGEX =
|
||||
/eyJ[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}/;
|
||||
const ADOBE_JWT_IN_TEXT_GLOBAL_REGEX =
|
||||
/eyJ[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}/g;
|
||||
const ADOBE_JWT_EXACT_REGEX =
|
||||
/^eyJ[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}$/;
|
||||
const FIREFLY_3P_HOST_SUFFIX = "firefly-3p.ff.adobe.io";
|
||||
|
||||
export function decodeAdobeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
let raw = String(token || "")
|
||||
.trim()
|
||||
.replace(/^bearer\s+/i, "")
|
||||
.trim();
|
||||
const match = raw.match(ADOBE_JWT_IN_TEXT_REGEX);
|
||||
if (match) raw = match[0];
|
||||
const part = raw.split(".")[1];
|
||||
if (!part) return null;
|
||||
const json = Buffer.from(part.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
|
||||
const value: unknown = JSON.parse(json);
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function findAllAdobeJwts(value: string): string[] {
|
||||
return value.match(ADOBE_JWT_IN_TEXT_GLOBAL_REGEX) ?? [];
|
||||
}
|
||||
|
||||
export function isExactAdobeJwt(value: string): boolean {
|
||||
return ADOBE_JWT_EXACT_REGEX.test(value);
|
||||
}
|
||||
|
||||
export function stripAdobeJwts(value: string, replacement = ""): string {
|
||||
return value.replace(ADOBE_JWT_IN_TEXT_GLOBAL_REGEX, replacement);
|
||||
}
|
||||
|
||||
function hostnameMatches(hostname: string, expected: string): boolean {
|
||||
const normalized = hostname.toLowerCase().replace(/\.$/, "");
|
||||
return normalized === expected || normalized.endsWith(`.${expected}`);
|
||||
}
|
||||
|
||||
export function isAdobeFireflyApiUrl(rawUrl: string): boolean {
|
||||
try {
|
||||
return hostnameMatches(new URL(rawUrl).hostname, FIREFLY_3P_HOST_SUFFIX);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isAdobeLoginCookieDomain(domain: string): boolean {
|
||||
return hostnameMatches(domain.replace(/^\./, ""), "adobelogin.com");
|
||||
}
|
||||
1002
open-sse/services/adobeFireflySession.ts
Normal file
1002
open-sse/services/adobeFireflySession.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -156,9 +156,10 @@ export function resolveAdobeCreativityLevel(opts: {
|
||||
return clampLevel(normalizeExplicitCreativity(Number(explicit)));
|
||||
}
|
||||
|
||||
const percent = typeof opts.creativityPercent === "number" && Number.isFinite(opts.creativityPercent)
|
||||
? Math.max(0, Math.min(100, opts.creativityPercent))
|
||||
: 0;
|
||||
const percent =
|
||||
typeof opts.creativityPercent === "number" && Number.isFinite(opts.creativityPercent)
|
||||
? Math.max(0, Math.min(100, opts.creativityPercent))
|
||||
: 0;
|
||||
return clampLevel(percent / 100);
|
||||
}
|
||||
|
||||
@@ -265,11 +266,7 @@ export async function adobeFireflyUpscaleImage(opts: {
|
||||
|
||||
const blobId = String(opts.blobId || "").trim();
|
||||
if (!blobId) {
|
||||
throw new AdobeFireflyError(
|
||||
"Adobe Firefly upscale requires a source image",
|
||||
400,
|
||||
"bad_image"
|
||||
);
|
||||
throw new AdobeFireflyError("Adobe Firefly upscale requires a source image", 400, "bad_image");
|
||||
}
|
||||
|
||||
const factor = normalizeFactor(opts.upsamplerFactor, spec.factors);
|
||||
|
||||
@@ -39,7 +39,7 @@ export function preferAntigravityConnectionsWithStoredProject<T extends Record<s
|
||||
): T[] {
|
||||
if (!Array.isArray(connections) || connections.length === 0) return connections;
|
||||
const hasStoredProject = (connection: T): boolean => {
|
||||
if (typeof connection.projectId === "string" && connection.projectId) return true;
|
||||
if (typeof connection.projectId === "string" && connection.projectId.trim()) return true;
|
||||
let psd = connection.providerSpecificData;
|
||||
if (typeof psd === "string") {
|
||||
try {
|
||||
@@ -48,12 +48,9 @@ export function preferAntigravityConnectionsWithStoredProject<T extends Record<s
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return Boolean(
|
||||
psd &&
|
||||
typeof psd === "object" &&
|
||||
typeof (psd as Record<string, unknown>).projectId === "string" &&
|
||||
(psd as Record<string, unknown>).projectId
|
||||
);
|
||||
if (!psd || typeof psd !== "object") return false;
|
||||
const projectId = (psd as Record<string, unknown>).projectId;
|
||||
return typeof projectId === "string" && projectId.trim().length > 0;
|
||||
};
|
||||
const withStoredProject = connections.filter(hasStoredProject);
|
||||
return withStoredProject.length > 0 ? withStoredProject : connections;
|
||||
|
||||
@@ -1,13 +1,39 @@
|
||||
/**
|
||||
* Re-export from `antigravityProjectPersist.ts` plus a connection-preference helper.
|
||||
*
|
||||
* The persistence layer for a runtime-discovered Antigravity projectId lives in
|
||||
* the sibling file `antigravityProjectPersist.ts` (named by its core function).
|
||||
* This module adds `preferAntigravityConnectionsWithStoredProject()`, used by the
|
||||
* quota-strategy engine to give priority to connections whose projectId has
|
||||
* already been discovered and persisted.
|
||||
*/
|
||||
|
||||
import { persistDiscoveredAntigravityProjectId } from "./antigravityProjectPersist.ts";
|
||||
|
||||
export { persistDiscoveredAntigravityProjectId };
|
||||
|
||||
export function preferAntigravityConnectionsWithStoredProject(
|
||||
connections: Array<Record<string, unknown>>
|
||||
): Array<Record<string, unknown>> {
|
||||
return connections.filter(
|
||||
(conn) => conn != null && typeof conn.projectId === "string" && conn.projectId.trim().length > 0
|
||||
);
|
||||
/**
|
||||
* Prefer Antigravity connections with a discovered/stored `projectId` for
|
||||
* reset-aware quota routing.
|
||||
*
|
||||
* This is a preference, not a hard requirement: when no candidate has a stored
|
||||
* projectId, retain the full pool rather than making freshly-added accounts unusable.
|
||||
*/
|
||||
function hasStoredProjectId(connection: Record<string, unknown>): boolean {
|
||||
if (typeof connection.projectId === "string" && connection.projectId.trim().length > 0) {
|
||||
return true;
|
||||
}
|
||||
const providerSpecificData = connection.providerSpecificData;
|
||||
if (providerSpecificData && typeof providerSpecificData === "object") {
|
||||
const nested = (providerSpecificData as Record<string, unknown>).projectId;
|
||||
if (typeof nested === "string" && nested.trim().length > 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function preferAntigravityConnectionsWithStoredProject<T extends Record<string, unknown>>(
|
||||
connections: T[]
|
||||
): T[] {
|
||||
const withStoredProject = connections.filter(hasStoredProjectId);
|
||||
return withStoredProject.length > 0 ? withStoredProject : connections;
|
||||
}
|
||||
|
||||
@@ -138,9 +138,7 @@ function normalizeRuntimeStep(
|
||||
: {}),
|
||||
weight,
|
||||
label,
|
||||
// `prompt` is a per-step pipeline input and only exists on a model step —
|
||||
// #8894 widened the union with ComboProviderWildcardStep, which has no prompt.
|
||||
prompt: (step.kind === "model" ? step.prompt : null) || null,
|
||||
prompt: step.kind === "model" ? step.prompt || null : null,
|
||||
} satisfies ResolvedComboTarget;
|
||||
}
|
||||
|
||||
@@ -485,13 +483,6 @@ function estimateRequestInputTokens(body: Record<string, unknown>): number {
|
||||
}
|
||||
|
||||
function valueContainsImagePart(value: unknown): boolean {
|
||||
// Delegates to the unified media detector (open-sse/utils/mediaParts.ts) —
|
||||
// single source of truth shared with the vision-bridge guardrail. The
|
||||
// detector keeps this filter's legacy permissive matches (image-ish `type`
|
||||
// in any casing, bare `image_url`/`input_image` keys, source.media_type
|
||||
// image/*, bare data:image strings, recursion capped at depth 8) via
|
||||
// "image_indicator" parts. containsMediaKind short-circuits on the first
|
||||
// hit — this runs on every request, so no full-part collection here.
|
||||
return containsMediaKind([{ content: [value] }], "image");
|
||||
}
|
||||
|
||||
@@ -615,6 +606,10 @@ export type CompatFilterOptions = {
|
||||
failOpen?: boolean;
|
||||
};
|
||||
|
||||
export function hasHardCapabilityFailure(reasons: string[]): boolean {
|
||||
return reasons.some((reason) => HARD_COMPAT_REASONS.has(reason));
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize a capability-filter exhaustion for a 400-class combo error (#8488).
|
||||
* Returns null when the empty pool is not attributable to hard requirements.
|
||||
@@ -718,9 +713,7 @@ export function filterTargetsByRequestCompatibility(
|
||||
|
||||
if (compatible.length === targets.length) return targets;
|
||||
if (compatible.length === 0) {
|
||||
const hardRejected = rejected.some((entry) =>
|
||||
entry.reasons.some((r) => HARD_COMPAT_REASONS.has(r))
|
||||
);
|
||||
const hardRejected = rejected.some((entry) => hasHardCapabilityFailure(entry.reasons));
|
||||
const failOpen = options?.failOpen === true;
|
||||
|
||||
log.debug?.(
|
||||
|
||||
@@ -51,9 +51,9 @@ export function extractFusionPanelSpec(
|
||||
panel.push(step.comboName);
|
||||
return;
|
||||
}
|
||||
// #8894 widened ComboStep with ComboProviderWildcardStep, which carries a
|
||||
// modelPattern instead of a model. getComboModelString() already resolves any
|
||||
// step shape (and returns null for the ones with no concrete model id).
|
||||
// Provider-wildcard steps have no concrete model to dispatch — fusion is a
|
||||
// fixed-size panel of literal models/combo-refs, not a wildcard-expanding
|
||||
// strategy (see file header). Skip rather than push an undefined model.
|
||||
const modelStr = getComboModelString(step);
|
||||
if (modelStr) panel.push(modelStr);
|
||||
});
|
||||
|
||||
@@ -89,9 +89,7 @@ async function getQuotaAwareConnectionsForTarget(
|
||||
? (connections as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
if (provider === "antigravity" || provider === "agy") {
|
||||
activeConnections = preferAntigravityConnectionsWithStoredProject(
|
||||
activeConnections
|
||||
) as Array<Record<string, unknown>>;
|
||||
activeConnections = preferAntigravityConnectionsWithStoredProject(activeConnections);
|
||||
}
|
||||
if (
|
||||
!resetAwareConnectionCache.has(provider) &&
|
||||
|
||||
@@ -221,6 +221,14 @@ const LITE_SCHEMA: EngineConfigField[] = [
|
||||
label: "Preserve system prompt",
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
key: "compressToolResults",
|
||||
type: "boolean",
|
||||
label: "Proactively truncate long tool results",
|
||||
description:
|
||||
"Truncates tool results over 2,000 characters during Lite compression. Emergency overflow protection may still trim content when the context exceeds the model budget.",
|
||||
defaultValue: true,
|
||||
},
|
||||
];
|
||||
|
||||
function validateLiteConfig(config: Record<string, unknown>): EngineValidationResult {
|
||||
@@ -231,6 +239,7 @@ function validateLiteConfig(config: Record<string, unknown>): EngineValidationRe
|
||||
) {
|
||||
errors.push("preserveSystemPrompt must be a boolean");
|
||||
}
|
||||
validateBoolean(config, "compressToolResults", errors);
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
@@ -253,9 +262,17 @@ export const liteEngine: CompressionEngine = {
|
||||
},
|
||||
apply(body, options) {
|
||||
const adapter = adaptBodyForCompression(body);
|
||||
const stepCompressToolResults = options?.stepConfig?.compressToolResults;
|
||||
const result = applyLiteCompression(adapter.body, {
|
||||
...options,
|
||||
preserveSystemPrompt: options?.config?.preserveSystemPrompt !== false,
|
||||
// buildStepOptions() already merges global config.lite with explicit step.config
|
||||
// (step wins) into stepConfig, so consume that single effective value instead of
|
||||
// AND-ing root and step values — an explicit step `true` must override a global `false`.
|
||||
compressToolResults:
|
||||
typeof stepCompressToolResults === "boolean"
|
||||
? stepCompressToolResults
|
||||
: (options?.config?.lite?.compressToolResults ?? true),
|
||||
});
|
||||
return adapter.adapted ? { ...result, body: adapter.restore(result.body) } : result;
|
||||
},
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
* - Only replace blocks ≥ minChars (default 600).
|
||||
* - `stackable: true`, `stackPriority: 4` (runs just after session-dedup(3)).
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
deleteAllCcrBlocks,
|
||||
@@ -292,8 +291,10 @@ function rehydrateEntry(hash: string, principalId: string, now: number): CcrEntr
|
||||
|
||||
// Re-admit through the same budgets a fresh store would face. If the block no longer
|
||||
// fits, it stays on disk and is served straight from the row instead of being cached.
|
||||
const { principalId: owner, bytes } = entry;
|
||||
if (enforcePrincipalBudget(owner, bytes) && enforceGlobalBudget(owner, bytes)) {
|
||||
if (
|
||||
enforcePrincipalBudget(entry.principalId, entry.bytes) &&
|
||||
enforceGlobalBudget(entry.principalId, entry.bytes)
|
||||
) {
|
||||
const key = buildStoreKey(hash, principalId === ANON ? undefined : principalId);
|
||||
ccrStore.set(key, entry);
|
||||
ccrTotalBytes += entry.bytes;
|
||||
|
||||
@@ -17,6 +17,7 @@ interface LiteCompressionOptions {
|
||||
model?: string;
|
||||
supportsVision?: boolean | null;
|
||||
preserveSystemPrompt?: boolean;
|
||||
compressToolResults?: boolean;
|
||||
}
|
||||
|
||||
function trimTrailingHorizontalWhitespace(line: string): string {
|
||||
@@ -253,9 +254,11 @@ export function applyLiteCompression(
|
||||
current = r2.body;
|
||||
if (r2.applied) techniquesApplied.push("system-dedup");
|
||||
|
||||
const r3 = compressToolResults(current);
|
||||
current = r3.body;
|
||||
if (r3.applied) techniquesApplied.push("tool-compress");
|
||||
if (options?.compressToolResults !== false) {
|
||||
const r3 = compressToolResults(current);
|
||||
current = r3.body;
|
||||
if (r3.applied) techniquesApplied.push("tool-compress");
|
||||
}
|
||||
|
||||
const r4 = removeRedundantContent(current, options);
|
||||
current = r4.body;
|
||||
|
||||
@@ -14,6 +14,8 @@ export function resolveStepDetailConfig(
|
||||
config: CompressionConfig | undefined
|
||||
) {
|
||||
switch (engine) {
|
||||
case "lite":
|
||||
return config?.lite ?? {};
|
||||
case "headroom":
|
||||
return config?.headroom ?? {};
|
||||
case "session-dedup":
|
||||
|
||||
@@ -349,6 +349,7 @@ function runCompression(
|
||||
const result = applyLiteCompression(compressionBody, {
|
||||
...options,
|
||||
preserveSystemPrompt: options?.config?.preserveSystemPrompt !== false,
|
||||
...options?.config?.lite,
|
||||
});
|
||||
return adapter.adapted ? { ...result, body: adapter.restore(result.body) } : result;
|
||||
}
|
||||
|
||||
@@ -157,6 +157,12 @@ export interface LiveZoneConfig {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** Lite detail settings for proactive request-time transformations. */
|
||||
export interface LiteConfig {
|
||||
/** Truncate tool-result strings over 2,000 characters before provider dispatch. */
|
||||
compressToolResults: boolean;
|
||||
}
|
||||
|
||||
export interface CompressionPipelineStep {
|
||||
engine: CompressionEngineId;
|
||||
intensity?: CavemanIntensity | RtkIntensity;
|
||||
@@ -218,6 +224,8 @@ export interface CompressionConfig {
|
||||
languageConfig?: CompressionLanguageConfig;
|
||||
aggressive?: AggressiveConfig;
|
||||
ultra?: UltraConfig;
|
||||
/** Lite proactive transformation detail settings. */
|
||||
lite?: LiteConfig;
|
||||
/** Headroom SmartCrusher detail settings (minRows gate). */
|
||||
headroom?: HeadroomConfig;
|
||||
/** Session Dedup detail settings (minBlockChars / fuzzy, #8388). */
|
||||
@@ -395,6 +403,7 @@ export const DEFAULT_COMPRESSION_CONFIG: CompressionConfig = {
|
||||
ultraEngine: "heuristic",
|
||||
ultraSlmPrewarm: false,
|
||||
liveZone: { enabled: false },
|
||||
lite: { compressToolResults: true },
|
||||
codexResponsesConfig: { ...DEFAULT_CODEX_RESPONSES_CONFIG },
|
||||
};
|
||||
|
||||
|
||||
@@ -121,10 +121,6 @@ export function getFirecrawlBaseUrl(connection?: Record<string, unknown>): strin
|
||||
export async function fetchFirecrawlQuota(
|
||||
connectionId: string,
|
||||
connection?: Record<string, unknown>
|
||||
// FirecrawlQuota, not the base QuotaInfo: every return here is a full credit
|
||||
// breakdown (remainingCredits / planCredits / extraCreditsInferred / overPlan),
|
||||
// and the narrower annotation made the custom-base literal below an excess-
|
||||
// property error. FirecrawlQuota extends QuotaInfo, so callers are unaffected.
|
||||
): Promise<FirecrawlQuota | null> {
|
||||
const cached = quotaCache.get(connectionId);
|
||||
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
||||
|
||||
@@ -64,6 +64,8 @@ const REASONING_REPLAY_MODEL_PATTERNS = [
|
||||
];
|
||||
|
||||
const DEEPSEEK_V4_MODEL_PATTERN = /deepseek[-/]v4[-.](flash|pro)/i;
|
||||
const K3_REASONING_REPLAY_MODEL_PATTERN = /(?:^|\/)(?:kimi-)?k3(?:$|-)/i;
|
||||
const NATIVE_K27_REASONING_REPLAY_MODEL_PATTERN = /(?:^|\/)kimi-k2\.7-code(?:$|-)/i;
|
||||
|
||||
export function isDeepSeekReasoningModel(params: {
|
||||
provider: string;
|
||||
@@ -94,6 +96,14 @@ export function requiresReasoningReplay(params: {
|
||||
if (normalizedInterleavedField === "reasoning_content") return true;
|
||||
if (normalizedInterleavedField === "reasoning_details") return false;
|
||||
|
||||
if (K3_REASONING_REPLAY_MODEL_PATTERN.test(normalizedModel)) return true;
|
||||
if (
|
||||
(normalizedProvider === "moonshot" || normalizedProvider === "kimi") &&
|
||||
NATIVE_K27_REASONING_REPLAY_MODEL_PATTERN.test(normalizedModel)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// DeepSeek legacy reasoner family has an inverse contract: do not replay.
|
||||
if (/deepseek-reasoner/i.test(normalizedModel) || /deepseek-r1/i.test(normalizedModel)) {
|
||||
return false;
|
||||
|
||||
@@ -84,6 +84,10 @@ export function hasValidContent(msg: ClaudeMessage): boolean {
|
||||
return msg.content.some(
|
||||
(block) =>
|
||||
(block.type === "text" && block.text?.trim()) ||
|
||||
(block.type === "thinking" && block.thinking?.trim()) ||
|
||||
(block.type === "redacted_thinking" &&
|
||||
typeof block.data === "string" &&
|
||||
block.data.trim()) ||
|
||||
block.type === "tool_use" ||
|
||||
block.type === "tool_result" ||
|
||||
// #7777: media-only user turns are real content — dropping them
|
||||
|
||||
@@ -686,5 +686,38 @@ export function cleanJSONSchemaForAntigravity(schema: unknown): unknown {
|
||||
|
||||
addPlaceholders(cleaned);
|
||||
|
||||
// Phase 7: Recursive type:"object" injection for nested schemas (#9268).
|
||||
// Gemini/Vertex requires every node with properties/required to have an explicit
|
||||
// `type: "object"`. Some clients (e.g. Composio-exported tools) emit nested
|
||||
// schemas with `properties` but no `type`, causing a Gemini 400. Follow the
|
||||
// `removeUnsupportedKeywords()`/`addPlaceholders()` visitor pattern.
|
||||
function injectObjectType(obj: unknown): void {
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
for (const item of obj) {
|
||||
injectObjectType(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const record = obj as JsonRecord;
|
||||
if (
|
||||
!record.type &&
|
||||
(record.properties !== undefined || record.required !== undefined)
|
||||
) {
|
||||
record.type = "object";
|
||||
}
|
||||
|
||||
// Recurse into remaining values.
|
||||
for (const value of Object.values(record)) {
|
||||
if (value && typeof value === "object") {
|
||||
injectObjectType(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
injectObjectType(cleaned);
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
@@ -2,10 +2,16 @@
|
||||
* Convert OpenAI Responses API format to standard chat completions format.
|
||||
* Delegates to the canonical translator to avoid logic duplication.
|
||||
*/
|
||||
import { requiresReasoningReplay } from "../../services/reasoningCache.ts";
|
||||
import { openaiResponsesToOpenAIRequest } from "../request/openai-responses.ts";
|
||||
import { toRecord } from "../request/openai-responses/helpers.ts";
|
||||
|
||||
export function convertResponsesApiFormat(body, credentials = null, provider = null) {
|
||||
export function convertResponsesApiFormat(
|
||||
body: Record<string, unknown>,
|
||||
credentials: unknown = null,
|
||||
provider: unknown = null,
|
||||
model: unknown = null
|
||||
): Record<string, unknown> {
|
||||
const bodyModel = toRecord(body).model;
|
||||
const requestedModel =
|
||||
typeof bodyModel === "string" && bodyModel.trim().length > 0
|
||||
@@ -13,5 +19,25 @@ export function convertResponsesApiFormat(body, credentials = null, provider = n
|
||||
? bodyModel
|
||||
: `${provider}/${bodyModel}`
|
||||
: provider;
|
||||
return openaiResponsesToOpenAIRequest(requestedModel, body, null, credentials);
|
||||
const credentialRecord =
|
||||
credentials && typeof credentials === "object" && !Array.isArray(credentials)
|
||||
? (credentials as Record<string, unknown>)
|
||||
: {};
|
||||
const translationCredentials = requiresReasoningReplay({
|
||||
provider: String(provider ?? ""),
|
||||
model: String(model ?? ""),
|
||||
allowLegacyFallback: false,
|
||||
})
|
||||
? { ...credentialRecord, _preserveReasoningContent: true }
|
||||
: credentials;
|
||||
const converted = openaiResponsesToOpenAIRequest(
|
||||
requestedModel,
|
||||
body,
|
||||
null,
|
||||
translationCredentials
|
||||
);
|
||||
if (!converted || typeof converted !== "object" || Array.isArray(converted)) {
|
||||
throw new TypeError("Responses request conversion must produce an object");
|
||||
}
|
||||
return converted as Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
providerHonorsOpenAIFormatCacheControl,
|
||||
resolveConnectionCacheOverride,
|
||||
} from "../utils/cacheControlPolicy.ts";
|
||||
import { requiresAuthenticReasoningContent } from "../utils/reasoningContentInjector.ts";
|
||||
import { isInternalReasoningPlaceholder } from "../utils/reasoningPlaceholder.ts";
|
||||
import {
|
||||
coerceToolSchemas,
|
||||
@@ -162,7 +161,11 @@ function isReasoningOnlyReplayTarget(provider: unknown, model: unknown): boolean
|
||||
/(^|\/)deepseek/i.test(normalizedModel) ||
|
||||
normalizedProvider === "xiaomi-mimo" ||
|
||||
/(^|\/)mimo/i.test(normalizedModel) ||
|
||||
requiresAuthenticReasoningContent(normalizedProvider, normalizedModel)
|
||||
requiresReasoningReplay({
|
||||
provider: normalizedProvider,
|
||||
model: normalizedModel,
|
||||
allowLegacyFallback: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -233,6 +236,17 @@ export function translateRequest(
|
||||
const connectionCacheOverride = resolveConnectionCacheOverride(
|
||||
(credentials as { providerSpecificData?: unknown } | null)?.providerSpecificData
|
||||
);
|
||||
const normalizedProvider = String(provider ?? "");
|
||||
const normalizedModel = String(model ?? "");
|
||||
const isKimiCoding =
|
||||
normalizedProvider === "kimi-coding" || normalizedProvider === "kimi-coding-apikey";
|
||||
const requiresExplicitReasoningReplay = requiresReasoningReplay({
|
||||
provider: normalizedProvider,
|
||||
model: normalizedModel,
|
||||
allowLegacyFallback: false,
|
||||
});
|
||||
const preserveResponsesReasoning =
|
||||
sourceFormat === FORMATS.OPENAI_RESPONSES && requiresExplicitReasoningReplay;
|
||||
|
||||
// Phase 2: Apply thinking budget control before normalization
|
||||
result = applyThinkingBudget(result);
|
||||
@@ -318,12 +332,16 @@ export function translateRequest(
|
||||
options?.preserveCacheControl === true &&
|
||||
providerHonorsOpenAIFormatCacheControl(provider, connectionCacheOverride);
|
||||
const step1Credentials =
|
||||
options?.copilotClient || hasTargetHint || preserveCacheControl
|
||||
options?.copilotClient ||
|
||||
hasTargetHint ||
|
||||
preserveCacheControl ||
|
||||
preserveResponsesReasoning
|
||||
? {
|
||||
...(credentials && typeof credentials === "object" ? credentials : {}),
|
||||
...(options?.copilotClient ? { _copilotClient: true } : {}),
|
||||
...(hasTargetHint ? { _targetFormat: targetFormat } : {}),
|
||||
...(preserveCacheControl ? { _preserveCacheControl: true } : {}),
|
||||
...(preserveResponsesReasoning ? { _preserveReasoningContent: true } : {}),
|
||||
}
|
||||
: credentials;
|
||||
result = toOpenAI(model, result, stream, step1Credentials);
|
||||
@@ -381,14 +399,6 @@ export function translateRequest(
|
||||
// Resolve reasoning-replay status up-front: it gates both the reasoning_content
|
||||
// strip in filterToOpenAIFormat below (#4849 must NOT strip client reasoning for
|
||||
// replay providers) and the cache re-injection further down.
|
||||
const normalizedProvider = String(provider ?? "");
|
||||
const normalizedModel = String(model ?? "");
|
||||
const isKimiCoding =
|
||||
normalizedProvider === "kimi-coding" || normalizedProvider === "kimi-coding-apikey";
|
||||
const requiresAuthenticReasoning = requiresAuthenticReasoningContent(
|
||||
normalizedProvider,
|
||||
normalizedModel
|
||||
);
|
||||
const resolvedCapabilities = getResolvedModelCapabilities({
|
||||
provider: normalizedProvider,
|
||||
model: normalizedModel,
|
||||
@@ -397,7 +407,10 @@ export function translateRequest(
|
||||
provider: normalizedProvider,
|
||||
model: normalizedModel,
|
||||
thinkingEnabled: hasThinkingConfig(result),
|
||||
supportsReasoning: supportsReasoning({ provider: normalizedProvider, model: normalizedModel }),
|
||||
supportsReasoning: supportsReasoning({
|
||||
provider: normalizedProvider,
|
||||
model: normalizedModel,
|
||||
}),
|
||||
interleavedField: resolvedCapabilities?.interleavedField ?? null,
|
||||
});
|
||||
|
||||
@@ -470,7 +483,7 @@ export function translateRequest(
|
||||
|
||||
if (
|
||||
targetFormat === FORMATS.OPENAI &&
|
||||
!requiresAuthenticReasoning &&
|
||||
!requiresExplicitReasoningReplay &&
|
||||
result.messages &&
|
||||
Array.isArray(result.messages)
|
||||
) {
|
||||
@@ -495,7 +508,7 @@ export function translateRequest(
|
||||
// isReasoner / normalizedProvider / normalizedModel / resolvedCapabilities were
|
||||
// resolved up-front (before the OpenAI-format filter) so the #4849 reasoning strip
|
||||
// could honor reasoning-replay providers.
|
||||
if (isReasoner && !isKimiCoding && result.messages && Array.isArray(result.messages)) {
|
||||
if (isReasoner && result.messages && Array.isArray(result.messages)) {
|
||||
const canReplayReasoningOnly = isReasoningOnlyReplayTarget(normalizedProvider, normalizedModel);
|
||||
|
||||
for (const [messageIndex, msg] of result.messages.entries()) {
|
||||
@@ -544,29 +557,51 @@ export function translateRequest(
|
||||
// Has tool_use blocks but no thinking block yet.
|
||||
// Reasoning models (Kimi K2, etc.) require a thinking block before tool_use
|
||||
// on multi-turn or they regenerate the same tool call infinitely.
|
||||
const hasThinkingBlock = msg.content.some(
|
||||
const thinkingBlock = msg.content.find(
|
||||
(b) => b?.type === "thinking" || b?.type === "redacted_thinking"
|
||||
);
|
||||
if (hasThinkingBlock) continue;
|
||||
const hasNonEmptyClientThinking =
|
||||
thinkingBlock?.type === "thinking" &&
|
||||
typeof thinkingBlock.thinking === "string" &&
|
||||
thinkingBlock.thinking.trim().length > 0;
|
||||
if (thinkingBlock && (!isKimiCoding || hasNonEmptyClientThinking)) continue;
|
||||
|
||||
const toolUseBlocks = msg.content.filter((b) => b?.type === "tool_use");
|
||||
const firstToolUseId = toolUseBlocks[0]?.id;
|
||||
const firstToolUseIdx = msg.content.findIndex((b) => b?.type === "tool_use");
|
||||
|
||||
// Try reasoning cache first
|
||||
// Client reasoning wins above. Otherwise try authentic replay before
|
||||
// retaining Kimi Code's empty protocol marker as the final fallback.
|
||||
if (firstToolUseId) {
|
||||
const cached = lookupReasoning(firstToolUseId);
|
||||
if (cached) {
|
||||
msg.content.splice(firstToolUseIdx, 0, {
|
||||
type: "thinking",
|
||||
thinking: cached,
|
||||
});
|
||||
if (thinkingBlock) {
|
||||
thinkingBlock.type = "thinking";
|
||||
thinkingBlock.thinking = cached;
|
||||
delete thinkingBlock.data;
|
||||
delete thinkingBlock.signature;
|
||||
} else {
|
||||
msg.content.splice(firstToolUseIdx, 0, {
|
||||
type: "thinking",
|
||||
thinking: cached,
|
||||
});
|
||||
}
|
||||
recordReplay();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (requiresAuthenticReasoning) continue;
|
||||
// Fallback: inject placeholder (must be non-empty for kimi-coding)
|
||||
if (isKimiCoding) {
|
||||
if (thinkingBlock) {
|
||||
thinkingBlock.type = "thinking";
|
||||
thinkingBlock.thinking = "";
|
||||
delete thinkingBlock.data;
|
||||
delete thinkingBlock.signature;
|
||||
} else {
|
||||
msg.content.splice(firstToolUseIdx, 0, { type: "thinking", thinking: "" });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (requiresExplicitReasoningReplay) continue;
|
||||
msg.content.splice(firstToolUseIdx, 0, {
|
||||
type: "thinking",
|
||||
thinking: NON_ANTHROPIC_THINKING_PLACEHOLDER,
|
||||
@@ -603,7 +638,7 @@ export function translateRequest(
|
||||
// Native Moonshot K3/K2.7 accepts only the real prior reasoning. If it
|
||||
// was not supplied and the cache missed, leave it absent so upstream can
|
||||
// enforce its contract instead of corrupting history with a placeholder.
|
||||
if (requiresAuthenticReasoning) {
|
||||
if (requiresExplicitReasoningReplay) {
|
||||
if (msg.reasoning_content === "") delete msg.reasoning_content;
|
||||
continue;
|
||||
}
|
||||
@@ -616,7 +651,7 @@ export function translateRequest(
|
||||
// deepseek-v4-flash accepts an ABSENT reasoning_content field (the 400 is
|
||||
// specific to empty-string, and even that is endpoint-dependent). Omit
|
||||
// the field instead; providers that genuinely enforce the contract
|
||||
// (kimi-coding, moonshot authentic-reasoning) have their own paths above.
|
||||
// (kimi-coding, moonshot reasoning replay) have their own paths above.
|
||||
if ((hasToolCalls || shouldReplayReasoningOnly) && !msg.reasoning_content) {
|
||||
if (requiresReasoningContentPresence(normalizedProvider, normalizedModel)) {
|
||||
msg.reasoning_content = NON_ANTHROPIC_THINKING_PLACEHOLDER;
|
||||
@@ -755,6 +790,7 @@ export function initState(sourceFormat) {
|
||||
inThinking: false,
|
||||
parseTextualReasoningTags: false,
|
||||
funcArgsBuf: {},
|
||||
funcArgsEscapeState: {},
|
||||
funcNames: {},
|
||||
funcCallIds: {},
|
||||
funcArgsDone: {},
|
||||
|
||||
@@ -73,6 +73,19 @@ function toolOutputContentToString(output: unknown): string {
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function getReasoningSummaryText(item: JsonRecord): string {
|
||||
if (!Array.isArray(item.summary)) return "";
|
||||
return item.summary
|
||||
.map((part) => toString(toRecord(part).text))
|
||||
.filter((text) => text.length > 0)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function appendReasoningContent(current: unknown, next: string): string {
|
||||
const existing = typeof current === "string" ? current : "";
|
||||
return existing ? `${existing}\n\n${next}` : next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert OpenAI Responses API request to OpenAI Chat Completions format
|
||||
*/
|
||||
@@ -83,13 +96,13 @@ export function openaiResponsesToOpenAIRequest(
|
||||
credentials: unknown
|
||||
): unknown {
|
||||
void stream;
|
||||
void credentials;
|
||||
const collapseToPlainString = requiresPlainStringContent(extractProviderHint(model));
|
||||
|
||||
const root = toRecord(body);
|
||||
if (root.input === undefined) return body;
|
||||
const credentialRecord = toRecord(credentials);
|
||||
const storeEnabled = isOpenAIResponsesStoreEnabled(credentialRecord.providerSpecificData);
|
||||
const preserveReasoningContent = credentialRecord._preserveReasoningContent === true;
|
||||
const rawInputItems = normalizeResponsesInputForChat(root.input);
|
||||
|
||||
// Tools may be declared at the Responses top level or in one or more
|
||||
@@ -204,6 +217,7 @@ export function openaiResponsesToOpenAIRequest(
|
||||
// Group items by conversation turn
|
||||
let currentAssistantMsg: JsonRecord | null = null;
|
||||
let pendingToolResults: JsonRecord[] = [];
|
||||
let pendingReasoningContent = "";
|
||||
|
||||
// Upstream providers reject messages:[] with "400: at least one message is required".
|
||||
// When the client sends input:[] (empty), inject a placeholder user message — mirrors
|
||||
@@ -220,11 +234,20 @@ export function openaiResponsesToOpenAIRequest(
|
||||
const itemType = toString(item.type) || (item.role ? "message" : "");
|
||||
|
||||
if (itemType === "message") {
|
||||
const role = toString(item.role);
|
||||
// Flush pending assistant message with tool calls
|
||||
if (currentAssistantMsg) {
|
||||
messages.push(currentAssistantMsg);
|
||||
currentAssistantMsg = null;
|
||||
}
|
||||
if (role !== "assistant" && pendingReasoningContent) {
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: null,
|
||||
reasoning_content: pendingReasoningContent,
|
||||
});
|
||||
pendingReasoningContent = "";
|
||||
}
|
||||
|
||||
// Flush pending tool results
|
||||
if (pendingToolResults.length > 0) {
|
||||
@@ -269,7 +292,12 @@ export function openaiResponsesToOpenAIRequest(
|
||||
})
|
||||
: item.content;
|
||||
|
||||
messages.push({ role: toString(item.role), content });
|
||||
const message: JsonRecord = { role, content };
|
||||
if (role === "assistant" && pendingReasoningContent) {
|
||||
message.reasoning_content = pendingReasoningContent;
|
||||
pendingReasoningContent = "";
|
||||
}
|
||||
messages.push(message);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -294,6 +322,10 @@ export function openaiResponsesToOpenAIRequest(
|
||||
content: null,
|
||||
tool_calls: [],
|
||||
};
|
||||
if (pendingReasoningContent) {
|
||||
currentAssistantMsg.reasoning_content = pendingReasoningContent;
|
||||
pendingReasoningContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
const toolCalls = Array.isArray(currentAssistantMsg.tool_calls)
|
||||
@@ -353,6 +385,10 @@ export function openaiResponsesToOpenAIRequest(
|
||||
content: null,
|
||||
tool_calls: [],
|
||||
};
|
||||
if (pendingReasoningContent) {
|
||||
currentAssistantMsg.reasoning_content = pendingReasoningContent;
|
||||
pendingReasoningContent = "";
|
||||
}
|
||||
}
|
||||
const toolCalls = Array.isArray(currentAssistantMsg.tool_calls)
|
||||
? currentAssistantMsg.tool_calls
|
||||
@@ -401,7 +437,21 @@ export function openaiResponsesToOpenAIRequest(
|
||||
}
|
||||
|
||||
if (itemType === "reasoning") {
|
||||
// Skip reasoning items - they are display-only metadata
|
||||
// Responses reasoning summaries are normally display metadata. Preserve them only
|
||||
// when the routed upstream explicitly requires prior reasoning to continue a turn.
|
||||
if (preserveReasoningContent) {
|
||||
const reasoning = getReasoningSummaryText(item);
|
||||
if (reasoning) {
|
||||
if (currentAssistantMsg) {
|
||||
currentAssistantMsg.reasoning_content = appendReasoningContent(
|
||||
currentAssistantMsg.reasoning_content,
|
||||
reasoning
|
||||
);
|
||||
} else {
|
||||
pendingReasoningContent = appendReasoningContent(pendingReasoningContent, reasoning);
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -430,6 +480,13 @@ export function openaiResponsesToOpenAIRequest(
|
||||
if (currentAssistantMsg) {
|
||||
messages.push(currentAssistantMsg);
|
||||
}
|
||||
if (pendingReasoningContent) {
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: null,
|
||||
reasoning_content: pendingReasoningContent,
|
||||
});
|
||||
}
|
||||
if (pendingToolResults.length > 0) {
|
||||
for (const toolResult of pendingToolResults) {
|
||||
messages.push(toolResult);
|
||||
|
||||
@@ -31,6 +31,19 @@ import {
|
||||
// normalizeUpstreamFailure is re-exported for external importers (tests).
|
||||
export { normalizeUpstreamFailure } from "./openai-responses/pureHelpers.ts";
|
||||
|
||||
/** Carries escapeJsonStringValues's scan state (whether we're inside a JSON
|
||||
* string, and whether the fragment ended mid-escape-sequence) across calls
|
||||
* for the SAME tool call — see escapeJsonStringValues's own doc comment for
|
||||
* why this must persist across chunks rather than reset per call. */
|
||||
interface JsonStringEscapeState {
|
||||
inString: boolean;
|
||||
pendingEscape: boolean;
|
||||
}
|
||||
|
||||
function createJsonStringEscapeState(): JsonStringEscapeState {
|
||||
return { inString: false, pendingEscape: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape control characters (newlines, tabs, carriage returns) that appear
|
||||
* inside JSON string values, ensuring the resulting string is valid JSON.
|
||||
@@ -38,18 +51,42 @@ export { normalizeUpstreamFailure } from "./openai-responses/pureHelpers.ts";
|
||||
* newlines (0x0A) instead of \n escapes inside tool call argument JSON.
|
||||
* Only escapes characters inside string contexts to avoid double-escaping
|
||||
* already-proper JSON or corrupting structural newlines.
|
||||
*
|
||||
* `arguments` deltas arrive as arbitrary fragments of one continuous JSON
|
||||
* string (OpenAI's Chat Completions streaming contract only guarantees each
|
||||
* `tool_calls[].function.arguments` delta is the next slice, not that it
|
||||
* starts/ends on a quote or escape boundary) — a large multi-line argument
|
||||
* value routinely gets split mid-string. `escapeState` must therefore be the
|
||||
* SAME object passed in on every call for a given tool call index, not a
|
||||
* fresh `{inString: false}` each time: resetting per call made the
|
||||
* in-string/out-of-string decision (and therefore whether a raw newline
|
||||
* gets escaped) depend on where a chunk boundary happened to fall, which
|
||||
* produced a real, reported bug — a single reassembled arguments string
|
||||
* with a mix of real newlines and literal two-character `\n` sequences,
|
||||
* breaking generated code (e.g. Python) that embeds multi-line content.
|
||||
*/
|
||||
function escapeJsonStringValues(json: string): string {
|
||||
function escapeJsonStringValues(json: string, escapeState: JsonStringEscapeState): string {
|
||||
let result = "";
|
||||
let inString = false;
|
||||
let { inString, pendingEscape } = escapeState;
|
||||
|
||||
for (let i = 0; i < json.length; i++) {
|
||||
const ch = json[i];
|
||||
|
||||
// Inside a string, skip over escape sequences
|
||||
// This char is the one immediately following a backslash from a
|
||||
// previous iteration (possibly in a prior fragment) — it's already
|
||||
// "consumed" by that escape sequence, pass it through untouched.
|
||||
if (pendingEscape) {
|
||||
result += ch;
|
||||
pendingEscape = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Inside a string, an unescaped backslash starts an escape sequence —
|
||||
// the char AFTER it (next iteration, possibly in the next fragment)
|
||||
// must not be reinterpreted as a quote/control-char in its own right.
|
||||
if (inString && ch === "\\") {
|
||||
result += ch + (json[i + 1] ?? "");
|
||||
i++;
|
||||
result += ch;
|
||||
pendingEscape = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -69,6 +106,8 @@ function escapeJsonStringValues(json: string): string {
|
||||
result += ch;
|
||||
}
|
||||
|
||||
escapeState.inString = inString;
|
||||
escapeState.pendingEscape = pendingEscape;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -482,6 +521,7 @@ function emitToolCall(state, emit, tc) {
|
||||
delete state.funcArgsDone[tcIdx];
|
||||
delete state.funcItemAdded[tcIdx];
|
||||
delete state.funcItemDone[tcIdx];
|
||||
delete state.funcArgsEscapeState?.[tcIdx];
|
||||
}
|
||||
|
||||
if (funcName) state.funcNames[tcIdx] = funcName;
|
||||
@@ -528,7 +568,14 @@ function emitToolCall(state, emit, tc) {
|
||||
if (tc.function?.arguments) {
|
||||
const refCallId = state.funcCallIds[tcIdx] || newCallId;
|
||||
const existingArgs = state.funcArgsBuf[tcIdx] || "";
|
||||
const sanitized = escapeJsonStringValues(tc.function.arguments);
|
||||
if (!state.funcArgsEscapeState) state.funcArgsEscapeState = {};
|
||||
if (!state.funcArgsEscapeState[tcIdx]) {
|
||||
state.funcArgsEscapeState[tcIdx] = createJsonStringEscapeState();
|
||||
}
|
||||
const sanitized = escapeJsonStringValues(
|
||||
tc.function.arguments,
|
||||
state.funcArgsEscapeState[tcIdx]
|
||||
);
|
||||
const nextArgs = appendToolCallArgumentDelta(existingArgs, sanitized);
|
||||
const emittedDelta = nextArgs.slice(existingArgs.length);
|
||||
state.funcArgsBuf[tcIdx] = nextArgs;
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
import crypto from "node:crypto";
|
||||
import dns from "node:dns";
|
||||
import { isIP } from "node:net";
|
||||
import sharp from "sharp";
|
||||
import {
|
||||
parseAndValidatePublicUrl,
|
||||
isPrivateHost,
|
||||
@@ -31,6 +30,15 @@ import {
|
||||
} from "@/shared/network/outboundUrlGuard";
|
||||
import type { EncodedImage } from "./cursorAgentProtobuf.ts";
|
||||
|
||||
type SharpFactory = (typeof import("sharp"))["default"];
|
||||
|
||||
let sharpFactoryPromise: Promise<SharpFactory> | undefined;
|
||||
|
||||
function loadSharp(): Promise<SharpFactory> {
|
||||
sharpFactoryPromise ??= import("sharp").then((module) => module.default);
|
||||
return sharpFactoryPromise;
|
||||
}
|
||||
|
||||
/** Final per-image byte cap after prep (composer-api / wire bound). */
|
||||
export const MAX_CURSOR_IMAGE_BYTES = 1024 * 1024;
|
||||
|
||||
@@ -503,6 +511,7 @@ export async function prepareCursorImageForWire(input: {
|
||||
mimeType: string;
|
||||
detail?: string;
|
||||
}): Promise<PreparedImage> {
|
||||
const sharp = await loadSharp();
|
||||
const mime = input.mimeType.toLowerCase();
|
||||
const softMax = softMaxBytesForDetail(input.detail);
|
||||
const qualities = jpegQualitiesForDetail(input.detail);
|
||||
|
||||
@@ -177,6 +177,9 @@ const EMBEDDED_DEFAULTS = {
|
||||
13, 92, 15, 89, 66, 91, 76, 70, 72, 29, 71, 70, 3, 65, 93, 84, 72, 23, 28, 87, 92, 88, 15, 95,
|
||||
91, 22, 71, 87, 20, 66, 67, 86, 13, 81, 81, 21,
|
||||
],
|
||||
// Openference OAuth — public PKCE client id. The plaintext equals the first
|
||||
// nine bytes of MASK, so its XOR-masked representation is nine zero bytes.
|
||||
openference_id: [0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
// Trae Cloud IDE — public oauth client id
|
||||
trae_id: [10, 3, 95, 6, 10, 22, 66, 3, 11, 90, 72, 31, 91, 2],
|
||||
// Microsoft Designer web app — public ClientId header sent by the
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
* that proxy to thinking-mode models.
|
||||
*/
|
||||
|
||||
import { requiresReasoningReplay } from "../services/reasoningCache.ts";
|
||||
|
||||
const PLACEHOLDER = " ";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
@@ -30,24 +32,6 @@ const THINKING_MODEL_PATTERNS: RegExp[] = [
|
||||
/\bmimo\b/i, // xiaomi-tokenplan mimo family (e.g. xiaomi-tokenplan/mimo-v2.5-pro)
|
||||
];
|
||||
|
||||
const AUTHENTIC_REASONING_MODEL_PATTERN = /(?:^|\/)kimi-k(?:3|2\.7-code)(?:$|-)/i;
|
||||
|
||||
/**
|
||||
* Native Moonshot K3/K2.7 replay must use the original reasoning content.
|
||||
* A fabricated placeholder changes preserved-thinking history and is not a
|
||||
* valid substitute when the client and reasoning cache both lack the field.
|
||||
*/
|
||||
export function requiresAuthenticReasoningContent(provider: unknown, model: unknown): boolean {
|
||||
const normalizedProvider = String(provider ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const normalizedModel = String(model ?? "").trim();
|
||||
return (
|
||||
(normalizedProvider === "moonshot" || normalizedProvider === "kimi") &&
|
||||
AUTHENTIC_REASONING_MODEL_PATTERN.test(normalizedModel)
|
||||
);
|
||||
}
|
||||
|
||||
export function isThinkingMessageModel(model: string | undefined | null): boolean {
|
||||
if (!model || typeof model !== "string") return false;
|
||||
return THINKING_MODEL_PATTERNS.some((re) => re.test(model));
|
||||
@@ -62,7 +46,11 @@ export function shouldInjectReasoningContentPlaceholder(
|
||||
.toLowerCase();
|
||||
return (
|
||||
(normalizedProvider === "moonshot" || normalizedProvider === "kimi") &&
|
||||
!requiresAuthenticReasoningContent(normalizedProvider, model) &&
|
||||
!requiresReasoningReplay({
|
||||
provider: normalizedProvider,
|
||||
model: String(model ?? ""),
|
||||
allowLegacyFallback: false,
|
||||
}) &&
|
||||
isThinkingMessageModel(model)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
hasActiveDeltaValue,
|
||||
injectThinkingSignature,
|
||||
} from "./streamHelpers.ts";
|
||||
import { rejectEmptyChoicesStream, buildEmptyChoicesStreamError } from "./streamEmptyChoices.ts";
|
||||
import { calculateCost } from "@/lib/usage/costCalculator";
|
||||
import { buildOmniRouteSseMetadataComment } from "@/domain/omnirouteResponseMeta";
|
||||
import { sseCommentsEnabled } from "./sseHeartbeat.ts";
|
||||
@@ -725,6 +726,9 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
}
|
||||
: null;
|
||||
|
||||
// Tracks whether any valuable chunk was forwarded; empty at flush => retryable 502 (#9268)
|
||||
let forwardedValuableChunk = false;
|
||||
|
||||
// Track content length for usage estimation (both modes)
|
||||
let totalContentLength = 0;
|
||||
// Passthrough: accumulate content and reasoning separately for call log response body
|
||||
@@ -995,6 +999,7 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
const output = formatSSE(itemSanitized, sourceFormat);
|
||||
clientPayloadCollector.push(itemSanitized);
|
||||
reqLogger?.appendConvertedChunk?.(output);
|
||||
forwardedValuableChunk = true;
|
||||
controller.enqueue(encoder.encode(output));
|
||||
};
|
||||
|
||||
@@ -2619,6 +2624,27 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
// #9268: reject a translate-mode stream that forwarded no valuable chunk
|
||||
// (all-empty `choices: []`) instead of completing with an empty 200.
|
||||
if (
|
||||
mode === STREAM_MODE.TRANSLATE &&
|
||||
rejectEmptyChoicesStream({
|
||||
forwardedValuableChunk,
|
||||
hasValidUsage: hasValidUsage(state?.usage),
|
||||
providerPayloadCollector,
|
||||
clientPayloadCollector,
|
||||
targetFormat,
|
||||
model,
|
||||
usage: state?.usage,
|
||||
onFailure,
|
||||
onComplete,
|
||||
clearPendingRequestFromStream,
|
||||
})
|
||||
) {
|
||||
controller.error(markPendingRequestCleared(buildEmptyChoicesStreamError()));
|
||||
return;
|
||||
}
|
||||
|
||||
// Flush remaining events (only once at stream end)
|
||||
const flushed = translateResponse(targetFormat, sourceFormat, null, state);
|
||||
|
||||
|
||||
123
open-sse/utils/streamEmptyChoices.ts
Normal file
123
open-sse/utils/streamEmptyChoices.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Empty-stream rejection for the SSE transform (#9268).
|
||||
*
|
||||
* A streaming provider can complete a turn having forwarded nothing usable —
|
||||
* every chunk carried an empty `choices: []` (no content, no tool_calls, no
|
||||
* finish_reason, e.g. a Gemini turn where the model emitted nothing). The SSE
|
||||
* transform drops those chunks silently, so without a guard the stream would
|
||||
* terminate with a clean empty 200, which clients treat as a valid empty turn
|
||||
* and retry to their cap with no error to stop on.
|
||||
*
|
||||
* The transform is the only place that knows a chunk was actually forwarded, so
|
||||
* `createSSEStream` threads a `forwardedValuableChunk` boolean and the
|
||||
* flush-time callbacks. All rejection logic lives here so the frozen
|
||||
* `open-sse/utils/stream.ts` only carries the minimal call-site wiring.
|
||||
*
|
||||
* Mirrors the non-streaming `isEmptyContentResponse` behavior in
|
||||
* `open-sse/handlers/chatCore.ts` (empty content → retryable 502), and the
|
||||
* #8649 disconnect-aware wrapper's "Provider returned empty content" outcome.
|
||||
*/
|
||||
import { buildErrorBody } from "./error.ts";
|
||||
import { buildStreamSummaryFromEvents } from "./streamPayloadCollector.ts";
|
||||
|
||||
type StructuredSSEEventLike = {
|
||||
index: number;
|
||||
timestamp?: string;
|
||||
event?: string;
|
||||
data: unknown;
|
||||
};
|
||||
|
||||
type StructuredSSECollectorLike = {
|
||||
getEvents: () => StructuredSSEEventLike[];
|
||||
build: (summary?: unknown, opts?: { includeEvents?: boolean }) => unknown;
|
||||
};
|
||||
|
||||
type EmptyChoicesRejectContext = {
|
||||
/** True when any chunk with content/tool_calls/finish_reason was forwarded. */
|
||||
forwardedValuableChunk: boolean;
|
||||
/** Valid usage accumulated on the stream state (usage-only streams are fine). */
|
||||
hasValidUsage: boolean;
|
||||
/** Provider-side event collector (for the onComplete providerPayload summary). */
|
||||
providerPayloadCollector: StructuredSSECollectorLike;
|
||||
/** Client-side payload collector (for the onComplete clientPayload). */
|
||||
clientPayloadCollector: StructuredSSECollectorLike;
|
||||
targetFormat?: string;
|
||||
model?: string | null;
|
||||
usage?: unknown;
|
||||
onFailure?: ((payload: {
|
||||
status: number;
|
||||
message: string;
|
||||
code?: string;
|
||||
type?: string;
|
||||
}) => boolean | void | Promise<void>) | null;
|
||||
onComplete?: ((payload: {
|
||||
status: number;
|
||||
usage: unknown;
|
||||
responseBody?: unknown;
|
||||
providerPayload?: unknown;
|
||||
clientPayload?: unknown;
|
||||
error?: string | null;
|
||||
errorCode?: string | null;
|
||||
}) => void) | null;
|
||||
clearPendingRequestFromStream?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns `true` when the empty-stream condition was detected and the caller
|
||||
* must abort the stream (controller.error + early return); `false` when the
|
||||
* stream legitimately forwarded content/usage and should complete normally.
|
||||
*/
|
||||
export function rejectEmptyChoicesStream(ctx: EmptyChoicesRejectContext): boolean {
|
||||
if (ctx.forwardedValuableChunk || ctx.hasValidUsage) return false;
|
||||
|
||||
const error = new Error(
|
||||
"Provider returned empty content — stream forwarded no valuable chunks"
|
||||
) as Error & { statusCode: number; code: string };
|
||||
error.statusCode = 502;
|
||||
error.code = "empty_content";
|
||||
|
||||
if (ctx.onFailure) {
|
||||
try {
|
||||
ctx.onFailure({ status: 502, message: error.message, code: "empty_content" });
|
||||
} catch {
|
||||
// best-effort — must never break the stream error path
|
||||
}
|
||||
}
|
||||
|
||||
const errorBody = buildErrorBody(502, error.message);
|
||||
if (ctx.onComplete) {
|
||||
try {
|
||||
ctx.onComplete({
|
||||
status: 502,
|
||||
usage: ctx.usage,
|
||||
responseBody: errorBody,
|
||||
error: error.message,
|
||||
errorCode: "empty_content",
|
||||
providerPayload: ctx.providerPayloadCollector.build(
|
||||
buildStreamSummaryFromEvents(
|
||||
ctx.providerPayloadCollector.getEvents(),
|
||||
ctx.targetFormat,
|
||||
ctx.model
|
||||
),
|
||||
{ includeEvents: false }
|
||||
),
|
||||
clientPayload: ctx.clientPayloadCollector.build(errorBody, { includeEvents: false }),
|
||||
});
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
ctx.clearPendingRequestFromStream?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The retryable error the caller should surface via controller.error. */
|
||||
export function buildEmptyChoicesStreamError(): Error & { statusCode: number; code: string } {
|
||||
const error = new Error(
|
||||
"Provider returned empty content — stream forwarded no valuable chunks"
|
||||
) as Error & { statusCode: number; code: string };
|
||||
error.statusCode = 502;
|
||||
error.code = "empty_content";
|
||||
return error;
|
||||
}
|
||||
@@ -29,6 +29,59 @@ export type PipelineStreamErrorHandler = (event: {
|
||||
statusCode: number;
|
||||
}) => boolean;
|
||||
|
||||
export type ClientDisconnectEvent = { reason: string; duration: number };
|
||||
|
||||
/**
|
||||
* #9653: a client that closes its connection right after reading a fully-completed
|
||||
* SSE stream can race the stream's own completion bookkeeping — the bytes already
|
||||
* reached the client, but the transform stream's completion callback (which flips
|
||||
* `isStreamCompletionRecorded()` to true) hasn't finished bubbling up yet when the
|
||||
* disconnect handler fires. Persisting immediately in that case records a false
|
||||
* 499 with zero token usage for a request that actually delivered its full response.
|
||||
*
|
||||
* This wraps a disconnect finalizer with a grace period: instead of finalizing
|
||||
* immediately, poll `isStreamCompletionRecorded()` until it flips true (a real
|
||||
* completion landed — nothing more to do) or the deadline passes (genuinely gone —
|
||||
* finalize as a 499 same as before). Pass `gracePeriodMs <= 0` to disable and
|
||||
* finalize immediately, matching the pre-#9653 behavior.
|
||||
*/
|
||||
export function createClientDisconnectGraceHandler({
|
||||
isStreamCompletionRecorded,
|
||||
gracePeriodMs,
|
||||
finalize,
|
||||
pollIntervalMs = 250,
|
||||
setTimeoutFn = setTimeout,
|
||||
}: {
|
||||
isStreamCompletionRecorded: () => boolean;
|
||||
gracePeriodMs: number;
|
||||
finalize: (event: ClientDisconnectEvent) => unknown;
|
||||
pollIntervalMs?: number;
|
||||
setTimeoutFn?: (callback: () => void, ms: number) => unknown;
|
||||
}): (event: ClientDisconnectEvent) => boolean {
|
||||
return (event) => {
|
||||
if (isStreamCompletionRecorded()) return true;
|
||||
if (gracePeriodMs <= 0) {
|
||||
finalize(event);
|
||||
return true;
|
||||
}
|
||||
|
||||
const deadline = Date.now() + gracePeriodMs;
|
||||
const poll = () => {
|
||||
if (isStreamCompletionRecorded()) return;
|
||||
if (Date.now() >= deadline) {
|
||||
finalize(event);
|
||||
return;
|
||||
}
|
||||
setTimeoutFn(poll, pollIntervalMs);
|
||||
};
|
||||
setTimeoutFn(poll, pollIntervalMs);
|
||||
|
||||
// Claim "handled" immediately so the caller's own immediate-finalize fallback
|
||||
// doesn't fire while the grace-period poll is still pending.
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
export function finalizeStreamRequestLog({
|
||||
pendingRequestId,
|
||||
model,
|
||||
@@ -107,9 +160,7 @@ export function createStreamFailureFinalizers({
|
||||
const message = failure.message || "Upstream stream error";
|
||||
const code = failure.code || failure.type || String(status);
|
||||
const classification =
|
||||
failure.code || failure.type
|
||||
? { code: failure.code, type: failure.type }
|
||||
: undefined;
|
||||
failure.code || failure.type ? { code: failure.code, type: failure.type } : undefined;
|
||||
|
||||
if (!isFailureCompletionRecorded()) {
|
||||
const errorBody = buildErrorBody(status, message, undefined, classification);
|
||||
|
||||
228
package-lock.json
generated
228
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "3.8.49",
|
||||
"version": "3.8.50",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "3.8.49",
|
||||
"version": "3.8.50",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -81,7 +81,7 @@
|
||||
"sqlite-vec": "^0.1.9",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tsx": "^4.23.0",
|
||||
"undici": "^8.3.0",
|
||||
"undici": "^8.10.0",
|
||||
"update-notifier": "^7.3.1",
|
||||
"uuid": "^14.0.0",
|
||||
"ws": "^8.18.0",
|
||||
@@ -134,6 +134,7 @@
|
||||
"lint-staged": "^17.0.8",
|
||||
"lockfile-lint": "^5.0.0",
|
||||
"node-loader": "^2.1.0",
|
||||
"opencode-ai": "1.18.8",
|
||||
"playwright-ctrf-json-reporter": "^0.0.29",
|
||||
"prettier": "^3.8.3",
|
||||
"promptfoo": "^0.121.18",
|
||||
@@ -153,7 +154,7 @@
|
||||
"@atjsh/llmlingua-2": "2.0.3",
|
||||
"@huggingface/transformers": "3.5.2",
|
||||
"@tensorflow/tfjs": "4.22.0",
|
||||
"better-sqlite3": "^13.0.1",
|
||||
"better-sqlite3": "^13.0.2",
|
||||
"js-tiktoken": "^1.0.20",
|
||||
"keytar": "^7.9.0",
|
||||
"tls-client-node": "^0.2.0",
|
||||
@@ -13757,10 +13758,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "13.0.1",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.1.tgz",
|
||||
"integrity": "sha512-LYpmOXdkpQYf4wmlxkdzW01XGlOXNIbjLg45yNkh0FQ4814VbK9PdOFmhZpYbej+EZtR/i3FDdhEG98HqZdgnA==",
|
||||
"hasInstallScript": true,
|
||||
"version": "13.0.3",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
|
||||
"integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -28809,6 +28809,205 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/opencode-ai": {
|
||||
"version": "1.18.8",
|
||||
"resolved": "https://registry.npmjs.org/opencode-ai/-/opencode-ai-1.18.8.tgz",
|
||||
"integrity": "sha512-eZvYK0rIc/NUDQ+s3LsO9gyUU3MswsbNOLZz06iPwVhbg/2jF6bkTaroBgiIdFWKwUn5sj+kSMc4TBYxFkMrNQ==",
|
||||
"cpu": [
|
||||
"arm64",
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"os": [
|
||||
"darwin",
|
||||
"linux",
|
||||
"win32"
|
||||
],
|
||||
"bin": {
|
||||
"opencode": "bin/opencode.exe"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"opencode-darwin-arm64": "1.18.8",
|
||||
"opencode-darwin-x64": "1.18.8",
|
||||
"opencode-darwin-x64-baseline": "1.18.8",
|
||||
"opencode-linux-arm64": "1.18.8",
|
||||
"opencode-linux-arm64-musl": "1.18.8",
|
||||
"opencode-linux-x64": "1.18.8",
|
||||
"opencode-linux-x64-baseline": "1.18.8",
|
||||
"opencode-linux-x64-baseline-musl": "1.18.8",
|
||||
"opencode-linux-x64-musl": "1.18.8",
|
||||
"opencode-windows-arm64": "1.18.8",
|
||||
"opencode-windows-x64": "1.18.8",
|
||||
"opencode-windows-x64-baseline": "1.18.8"
|
||||
}
|
||||
},
|
||||
"node_modules/opencode-darwin-arm64": {
|
||||
"version": "1.18.8",
|
||||
"resolved": "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.8.tgz",
|
||||
"integrity": "sha512-ZZCIEgTvHxOHk52Aeqhq59t/R0aqs29bPIgu45XE4rkgjmn/XCkTWalCPtyzJHipdcEbq/g0lqsE1OlJV0oNbA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/opencode-darwin-x64": {
|
||||
"version": "1.18.8",
|
||||
"resolved": "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.8.tgz",
|
||||
"integrity": "sha512-2EXRMJbRKnFPWI9oDU9tb7jDGmKiPmfjCLtwJMe3EF57h5wfcdEH9sP25bR3Og5NbE2M+PtMcJm0jMeHn2XoLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/opencode-darwin-x64-baseline": {
|
||||
"version": "1.18.8",
|
||||
"resolved": "https://registry.npmjs.org/opencode-darwin-x64-baseline/-/opencode-darwin-x64-baseline-1.18.8.tgz",
|
||||
"integrity": "sha512-eLXa2tK9LRuZ5e20QG2k4dmWAA5xnLgJ1afRTSD0/ybE6CAeK02i8vFCnFFDaxuBo+gnq+yqO8AkqvN1m64V/Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/opencode-linux-arm64": {
|
||||
"version": "1.18.8",
|
||||
"resolved": "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.8.tgz",
|
||||
"integrity": "sha512-7kj3c9JEdryHgK+o8zE/N9KzTOdbiDn6KpY8dl+hM9n5Cnmxezx4IAlgJeC9QxpIx8Omop6CYuZ+17KfrKdKLw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/opencode-linux-arm64-musl": {
|
||||
"version": "1.18.8",
|
||||
"resolved": "https://registry.npmjs.org/opencode-linux-arm64-musl/-/opencode-linux-arm64-musl-1.18.8.tgz",
|
||||
"integrity": "sha512-tww5TF/LIOv/GoTNyzGYgqDRhbJrhoMu8R+p5yD/SpnXPg3rcfYREw2wRy9yikyPU9sAQksuIIteTsyGerPjlA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/opencode-linux-x64": {
|
||||
"version": "1.18.8",
|
||||
"resolved": "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.8.tgz",
|
||||
"integrity": "sha512-Sm4fbQ9BdLI6hgN6FYYX8Nql+Sqe/2EKHJu3iWg0UYs93AXN4ROi0rvOmRbMk+ycYgOchb0hL6Ti2opxLx17sg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/opencode-linux-x64-baseline": {
|
||||
"version": "1.18.8",
|
||||
"resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline/-/opencode-linux-x64-baseline-1.18.8.tgz",
|
||||
"integrity": "sha512-egeEF4tk1rK9flIQjjeSVB9cR/X3zUti0pNAHW6ROJkNkj72z2C2FmjK1hZbfjtteCueMXPLptS23JROHGWL1w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/opencode-linux-x64-baseline-musl": {
|
||||
"version": "1.18.8",
|
||||
"resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline-musl/-/opencode-linux-x64-baseline-musl-1.18.8.tgz",
|
||||
"integrity": "sha512-S+438BXs48gLeXX/ya4TSNytDy9mliU3sOAf6j9rfFjzGiF/S08LedemSAnHkr0riBtamik1aRPSmTjhQ0dOBg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/opencode-linux-x64-musl": {
|
||||
"version": "1.18.8",
|
||||
"resolved": "https://registry.npmjs.org/opencode-linux-x64-musl/-/opencode-linux-x64-musl-1.18.8.tgz",
|
||||
"integrity": "sha512-c+E4Zsp0DYVcuqcDtgxw/4YcFLrVYWdGBR8x4CzpW48ga3RshaH+BlmUiy+GY0yr1x6UR+e2V3w4uzvzm/L9UQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/opencode-windows-arm64": {
|
||||
"version": "1.18.8",
|
||||
"resolved": "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.8.tgz",
|
||||
"integrity": "sha512-7NjdtEIiX28kmsKD9jHbFG4bbwBB5T4dAe2UwdnOqCBb2cl+ETV5eO6kbdC/xWxrgOghgZM1Wtw791T5pQPyag==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/opencode-windows-x64": {
|
||||
"version": "1.18.8",
|
||||
"resolved": "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.8.tgz",
|
||||
"integrity": "sha512-G+NEgEMvu/dEYshH5IaqHVTmsHVuGdORBvVmgphFiknT7q/NXPuoZCMtMIdfNlEFbu54BlzRDdJCR3Mqe98gUw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/opencode-windows-x64-baseline": {
|
||||
"version": "1.18.8",
|
||||
"resolved": "https://registry.npmjs.org/opencode-windows-x64-baseline/-/opencode-windows-x64-baseline-1.18.8.tgz",
|
||||
"integrity": "sha512-IGbjFyWoSN9rdGUJX7TWkQ1Yl673Q3dDna54b5NtqeRcZ839p+Z47zzM5m883HKAxrdKCC6Z22HYuDcXLV0laA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/opener": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
|
||||
@@ -35019,9 +35218,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "8.9.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz",
|
||||
"integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==",
|
||||
"version": "8.10.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz",
|
||||
"integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
@@ -36812,12 +37011,7 @@
|
||||
},
|
||||
"open-sse": {
|
||||
"name": "@omniroute/open-sse",
|
||||
"version": "3.8.49",
|
||||
"dependencies": {
|
||||
"@toon-format/toon": "^4.1.0",
|
||||
"safe-regex": "^2.1.1",
|
||||
"smol-toml": "1.7.1"
|
||||
}
|
||||
"version": "3.8.50"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
33
package.json
33
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "3.8.49",
|
||||
"description": "Unified AI router with 160+ providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.",
|
||||
"version": "3.8.50",
|
||||
"description": "Unified AI router with 291 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"omniroute": "bin/omniroute.mjs",
|
||||
@@ -23,6 +23,7 @@
|
||||
".env.example",
|
||||
"scripts/build/postinstall.mjs",
|
||||
"scripts/build/fixTlsClientNodeBinary.mjs",
|
||||
"scripts/build/fixPlaywrightAndroid.mjs",
|
||||
"bin/cli/runtime/",
|
||||
"scripts/postinstall.mjs",
|
||||
"scripts/build/postinstallSupport.mjs",
|
||||
@@ -33,11 +34,15 @@
|
||||
"scripts/dev/tls-options.mjs",
|
||||
"scripts/check/check-supported-node-runtime.ts",
|
||||
"scripts/dev/sync-env.mjs",
|
||||
"scripts/build/assembleStandalone.mjs",
|
||||
"scripts/build/backendOnlyPages.mjs",
|
||||
"scripts/build/build-tproxy-native.mjs",
|
||||
"scripts/build/native-binary-compat.mjs",
|
||||
"scripts/build/build-next-isolated.mjs",
|
||||
"scripts/build/runtime-env.mjs",
|
||||
"README.md",
|
||||
"LICENSE",
|
||||
"!**/node_modules/**",
|
||||
"!**/__tests__/**",
|
||||
"!**/*.test.ts",
|
||||
"!**/*.test.tsx",
|
||||
@@ -110,6 +115,8 @@
|
||||
"test:unit:ci": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial",
|
||||
"test:unit:ci:shard": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=4096 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=$TEST_SHARD tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=4096 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=$TEST_SHARD \"tests/unit/dashboard/**/*.test.ts\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=4096 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=$TEST_SHARD \"tests/unit/serial/**/*.test.ts\"",
|
||||
"test:unit:fast": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-isolation=none tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-isolation=none \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial",
|
||||
"test:scoped": "bash scripts/quality/test-scoped.sh",
|
||||
"test:scoped:staged": "bash scripts/quality/test-scoped.sh --staged",
|
||||
"test:unit:shard": "concurrently --kill-others-on-fail -n s1,s2 \"npm:test:unit:shard:1\" \"npm:test:unit:shard:2\"",
|
||||
"test:unit:shard:1": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=1/2 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=1/2 \"tests/unit/dashboard/**/*.test.ts\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=1/2 \"tests/unit/serial/**/*.test.ts\"",
|
||||
"test:unit:shard:2": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=2/2 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=2/2 \"tests/unit/dashboard/**/*.test.ts\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=2/2 \"tests/unit/serial/**/*.test.ts\"",
|
||||
@@ -143,6 +150,7 @@
|
||||
"check:node-runtime": "node --import tsx scripts/check/check-supported-node-runtime.ts",
|
||||
"check:pack-artifact": "node --import tsx scripts/build/validate-pack-artifact.ts",
|
||||
"check:pack-boot": "node scripts/check/check-pack-boot.mjs",
|
||||
"check:install-upgrade": "node scripts/check/check-install-upgrade.mjs",
|
||||
"check:pack-policy": "node --import tsx scripts/build/validate-pack-artifact.ts --policy-only",
|
||||
"check:cli-i18n": "node scripts/check/check-cli-i18n.mjs",
|
||||
"check:openapi-coverage": "node scripts/check/check-openapi-coverage.mjs",
|
||||
@@ -161,6 +169,7 @@
|
||||
"check:test-masking": "node scripts/check/check-test-masking.mjs",
|
||||
"check:test-runner-api": "node scripts/check/check-test-runner-api.mjs",
|
||||
"check:changelog-integrity": "node scripts/check/check-changelog-integrity.mjs",
|
||||
"sweep:stale-fragments": "node scripts/release/sweep-stale-fragments.mjs",
|
||||
"changelog:aggregate": "node scripts/release/aggregate-changelog.mjs",
|
||||
"check:agent-skills-sync": "node --import tsx/esm scripts/skills/generate-agent-skills.mjs",
|
||||
"check:build-scope": "node scripts/check/check-build-scope.mjs",
|
||||
@@ -184,6 +193,7 @@
|
||||
"check:bundle-size": "node scripts/check/check-bundle-size.mjs",
|
||||
"check:circular-deps": "node scripts/check/check-circular-deps.mjs",
|
||||
"check:mutation-ratchet": "node scripts/check/check-mutation-ratchet.mjs",
|
||||
"check:rtl-ratchet": "node scripts/check/check-rtl-ratchet.mjs",
|
||||
"check:licenses": "node scripts/check/check-licenses.mjs",
|
||||
"check:pr-evidence": "node scripts/check/check-pr-evidence.mjs",
|
||||
"check:vuln-ratchet": "node scripts/check/check-vuln-ratchet.mjs",
|
||||
@@ -200,9 +210,11 @@
|
||||
"typecheck:core": "tsc --pretty false -p tsconfig.typecheck-core.json",
|
||||
"typecheck:noimplicit:core": "tsc --pretty false -p tsconfig.typecheck-noimplicit-core.json",
|
||||
"check:dashboard-typecheck": "node scripts/check/check-dashboard-typecheck.mjs",
|
||||
"check:open-sse-typecheck": "node scripts/check/check-open-sse-typecheck.mjs",
|
||||
"backfill-aggregation": "node --import tsx src/scripts/backfillAggregation.ts",
|
||||
"env:sync": "node scripts/dev/sync-env.mjs",
|
||||
"test:integration": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 tests/integration/*.test.ts \"tests/integration/combo-matrix/*.test.ts\"",
|
||||
"test:integration:ci": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=$TEST_SHARD tests/integration/*.test.ts \"tests/integration/combo-matrix/*.test.ts\"",
|
||||
"test:combo:matrix": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 \"tests/integration/combo-matrix/*.test.ts\"",
|
||||
"test:combo:live": "cross-env RUN_COMBO_LIVE=1 DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 \"tests/integration/combo-live/*.live.test.ts\"",
|
||||
"test:combo:live:vps": "node scripts/test/combo-live-vps.mjs",
|
||||
@@ -232,6 +244,7 @@
|
||||
"prepare": "husky",
|
||||
"system-info": "node scripts/dev/system-info.mjs",
|
||||
"build:cli-api": "node --import tsx/esm scripts/cli/generate-api-commands.mjs",
|
||||
"postbuild": "node scripts/build/colocate-standalone.mjs",
|
||||
"release:contributors": "node scripts/release/gen-contributors.mjs",
|
||||
"release:uncovered": "node scripts/release/list-uncovered-commits.mjs",
|
||||
"test:coverage:runner": "node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true NODE_OPTIONS=--max-old-space-size=8192 c8 --merge-async --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 60 --lines 60 --functions 60 --branches 60 node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial",
|
||||
@@ -306,7 +319,7 @@
|
||||
"sqlite-vec": "^0.1.9",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tsx": "^4.23.0",
|
||||
"undici": "^8.3.0",
|
||||
"undici": "^8.10.0",
|
||||
"update-notifier": "^7.3.1",
|
||||
"uuid": "^14.0.0",
|
||||
"ws": "^8.18.0",
|
||||
@@ -319,7 +332,7 @@
|
||||
"@atjsh/llmlingua-2": "2.0.3",
|
||||
"@huggingface/transformers": "3.5.2",
|
||||
"@tensorflow/tfjs": "4.22.0",
|
||||
"better-sqlite3": "^13.0.1",
|
||||
"better-sqlite3": "^13.0.2",
|
||||
"js-tiktoken": "^1.0.20",
|
||||
"keytar": "^7.9.0",
|
||||
"tls-client-node": "^0.2.0",
|
||||
@@ -365,6 +378,7 @@
|
||||
"lint-staged": "^17.0.8",
|
||||
"lockfile-lint": "^5.0.0",
|
||||
"node-loader": "^2.1.0",
|
||||
"opencode-ai": "1.18.8",
|
||||
"playwright-ctrf-json-reporter": "^0.0.29",
|
||||
"prettier": "^3.8.3",
|
||||
"promptfoo": "^0.121.18",
|
||||
@@ -396,9 +410,18 @@
|
||||
"sharp"
|
||||
]
|
||||
},
|
||||
"allowScripts": {
|
||||
"better-sqlite3": true,
|
||||
"esbuild": true,
|
||||
"@swc/core": true,
|
||||
"@parcel/watcher": true,
|
||||
"keytar": true,
|
||||
"protobufjs": true,
|
||||
"unrs-resolver": true
|
||||
},
|
||||
"overrides": {
|
||||
"fast-xml-parser": "^5.10.1",
|
||||
"sharp": "^0.35.0",
|
||||
"sharp": "^0.35.3",
|
||||
"postcss": "^8.5.18",
|
||||
"ip-address": "^10.3.1",
|
||||
"qs": "^6.15.2",
|
||||
|
||||
@@ -86,6 +86,11 @@ export const NATIVE_ASSET_ENTRIES = [
|
||||
src: ["node_modules", "better-sqlite3", "build"],
|
||||
dest: ["node_modules", "better-sqlite3", "build"],
|
||||
},
|
||||
{
|
||||
label: "better-sqlite3 prebuilt native binaries",
|
||||
src: ["node_modules", "better-sqlite3", "prebuilds"],
|
||||
dest: ["node_modules", "better-sqlite3", "prebuilds"],
|
||||
},
|
||||
{
|
||||
// onnxruntime-node's dist/binding.js dlopen()s a platform-specific
|
||||
// libonnxruntime.so.1 shipped under bin/napi-v3/<platform>/<arch>/ — a
|
||||
|
||||
@@ -49,6 +49,7 @@ export const INTENTIONALLY_INTERNAL = new Set([
|
||||
"commandCodeAuth", // intentionally-internal: 5 API routes em /api/providers/command-code/auth/*
|
||||
"compression", // intentionally-internal: 2 API routes (settings/compression, context/rtk/config)
|
||||
"compressionDetailNormalizers", // db-internal: importado só por db/compression.ts (normalizeSessionDedupConfig/normalizeCcrConfig/buildDetailConfigDefaults/applyDetailConfigUpdate — normalizadores do detail-config split do compression.ts, #8404)
|
||||
"connectionRuntimeState", // intentionally-internal: warmupScheduler sqlite/redis stores importam diretamente de @/lib/db/connectionRuntimeState (Rule #2)
|
||||
"vacuumScheduler", // intentionally-internal: src/instrumentation-node.ts (dynamic import, lifecycle wiring per Rule #2)
|
||||
"detailedLogs", // intentionally-internal: 3 callers (callLogs.ts, logs/detail route, embeddings handler)
|
||||
"discovery", // DEAD?: 0 importers na auditoria de 2026-06-11; lib/discovery/index.ts não usa db/discovery
|
||||
|
||||
@@ -42,12 +42,14 @@ export const KNOWN_DUPLICATE_VERSIONS = new Set([
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ALLOWLIST 2 — gaps de sequência CONHECIDOS.
|
||||
// Fonte: auditoria do disco (src/lib/db/migrations/) — a sequência pula 026 e 055.
|
||||
// Estes números nunca tiveram arquivo físico (slots legados que viraram outros
|
||||
// números via RENAMED_MIGRATION_COMPATIBILITY em migrationRunner.ts). Congelados
|
||||
// para que o gate bloqueie apenas NOVOS buracos inexplicados na sequência.
|
||||
// Fonte: auditoria do disco (src/lib/db/migrations/). Além dos slots legados,
|
||||
// 143–145 estão reservados pelas migrations Radar que já existem na série
|
||||
// empilhada. O job registry foi promovido de 139 para 146 pela tabela
|
||||
// RENAMED_MIGRATION_COMPATIBILITY para não ocupar esses slots em trânsito.
|
||||
// O stale-enforcement remove automaticamente cada reserva quando o arquivo
|
||||
// correspondente aterrissar na release.
|
||||
// ---------------------------------------------------------------------------
|
||||
export const KNOWN_GAPS = new Set(["026", "055", "121"]); // 121: número queimado no ciclo v3.8.47 — 122 (#6909) mergeou antes e 121 nunca aterrissou (validação e2e 2026-07-12)
|
||||
export const KNOWN_GAPS = new Set(["026", "055", "121", "143", "144", "145"]); // 121: número queimado no ciclo v3.8.47 — 122 (#6909) mergeou antes e 121 nunca aterrissou (validação e2e 2026-07-12)
|
||||
|
||||
function pad3(n) {
|
||||
return String(n).padStart(3, "0");
|
||||
|
||||
@@ -221,6 +221,17 @@ export const FULL_CI_SKIP = new Set(["check:pr-evidence", "check:codeql-ratchet"
|
||||
// Gates that need a specific env to behave like CI (else they compare against the wrong base).
|
||||
export const FULL_CI_ENV = { "check:test-masking": { GITHUB_BASE_REF: "main" } };
|
||||
|
||||
const FULL_CI_DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const FULL_CI_TIMEOUT_OVERRIDES_MS = {
|
||||
// Measured at 19m38s on the loaded release-v3.8.50 devbox. The former generic
|
||||
// 10m ceiling killed a green scan before it could report its result.
|
||||
"check:test-masking": 30 * 60 * 1000,
|
||||
};
|
||||
|
||||
export function fullCiTimeoutFor(gateId) {
|
||||
return FULL_CI_TIMEOUT_OVERRIDES_MS[gateId] ?? FULL_CI_DEFAULT_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a ci.yml text and return the ordered, de-duplicated list of gate commands to run.
|
||||
* Each entry: { id, job, args:["run", <script>, ...("--" + args)], env }.
|
||||
@@ -272,7 +283,8 @@ export function extractCiGates(
|
||||
* never an infinite block that the release captain mistakes for a hang and kills the pre-flight.
|
||||
*/
|
||||
export function classifyRunError(err, timeoutMs) {
|
||||
if (err && err.killed && timeoutMs) {
|
||||
const timedOut = err?.killed === true || err?.code === "ETIMEDOUT";
|
||||
if (timedOut && timeoutMs) {
|
||||
return {
|
||||
code: 124,
|
||||
out: `gate exceeded its ${Math.round(timeoutMs / 1000)}s ceiling and was killed — treat as a hung/failed gate (e.g. an unreleased DB handle in the unit suite); does NOT pass`,
|
||||
@@ -696,7 +708,10 @@ async function main() {
|
||||
for (const g of gates) {
|
||||
// Skip a gate the curated pass already ran with the same id (avoid double-running lint).
|
||||
if (already.has(g.id)) continue;
|
||||
const { code, out } = run(npmCmd, g.args, { env: g.env, timeout: 10 * 60 * 1000 });
|
||||
const { code, out } = run(npmCmd, g.args, {
|
||||
env: g.env,
|
||||
timeout: fullCiTimeoutFor(g.id),
|
||||
});
|
||||
saveGateLog(`fullci-${g.id.replace(/[^a-z0-9]+/gi, "-")}`, out);
|
||||
record({
|
||||
id: g.id,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/shared/components";
|
||||
import MediaProviderHeader from "../../components/MediaProviderHeader";
|
||||
@@ -39,7 +40,10 @@ interface MediaProviderPageClientProps {
|
||||
function renderPlayground(
|
||||
kind: MediaKind,
|
||||
providerId: string,
|
||||
imageToTextCopy: { title: string; description: React.ReactNode }
|
||||
bridgeCopy: {
|
||||
imageToText: { title: string; description: React.ReactNode; cta: string };
|
||||
sttCta: string;
|
||||
}
|
||||
) {
|
||||
switch (kind) {
|
||||
case "embedding":
|
||||
@@ -49,7 +53,17 @@ function renderPlayground(
|
||||
case "tts":
|
||||
return <TtsExampleCard providerId={providerId} />;
|
||||
case "stt":
|
||||
return <SttExampleCard providerId={providerId} />;
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<SttExampleCard providerId={providerId} />
|
||||
<Link
|
||||
href="/dashboard/settings/modality-bridge?tab=audio"
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{bridgeCopy.sttCta}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
case "webSearch":
|
||||
return <WebSearchExampleCard providerId={providerId} />;
|
||||
case "webFetch":
|
||||
@@ -66,9 +80,15 @@ function renderPlayground(
|
||||
<div className="flex flex-col gap-2 border border-dashed border-border rounded-xl p-6">
|
||||
<div className="flex items-center gap-2 text-text-muted">
|
||||
<span className="material-symbols-outlined text-[20px]">image_search</span>
|
||||
<h3 className="text-sm font-medium">{imageToTextCopy.title}</h3>
|
||||
<h3 className="text-sm font-medium">{bridgeCopy.imageToText.title}</h3>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">{imageToTextCopy.description}</p>
|
||||
<p className="text-xs text-text-muted">{bridgeCopy.imageToText.description}</p>
|
||||
<Link
|
||||
href="/dashboard/settings/modality-bridge?tab=vision"
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{bridgeCopy.imageToText.cta}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
@@ -197,10 +217,14 @@ export default function MediaProviderPageClient({
|
||||
|
||||
{/* Playground */}
|
||||
{renderPlayground(activeKind, providerId, {
|
||||
title: t("imageToText"),
|
||||
description: t.rich("imageToTextComingSoon", {
|
||||
code: (chunks) => <code className="font-mono bg-bg-subtle px-1 rounded">{chunks}</code>,
|
||||
}),
|
||||
imageToText: {
|
||||
title: t("imageToText"),
|
||||
description: t.rich("imageToTextBridgeAvailable", {
|
||||
code: (chunks) => <code className="rounded bg-bg-subtle px-1 font-mono">{chunks}</code>,
|
||||
}),
|
||||
cta: t("imageToTextBridgeCta"),
|
||||
},
|
||||
sttCta: t("sttBridgeCta"),
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -59,6 +59,7 @@ import EmptyConnectionsPlaceholder from "./components/EmptyConnectionsPlaceholde
|
||||
import UpstreamProxyCard from "./components/UpstreamProxyCard";
|
||||
import SearchProviderCard from "./components/SearchProviderCard";
|
||||
import NoAuthProviderControls from "./components/NoAuthProviderControls";
|
||||
import AnonymousFallbackToggle from "./components/AnonymousFallbackToggle";
|
||||
// providerText used by UpstreamProxyCard (Phase 1t.7)
|
||||
|
||||
export default function ProviderDetailPageClient() {
|
||||
@@ -538,6 +539,12 @@ export default function ProviderDetailPageClient() {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!isUpstreamProxyProvider && !isFreeNoAuth && (
|
||||
<AnonymousFallbackToggle
|
||||
providerId={providerId}
|
||||
providerName={providerInfo?.name || providerId}
|
||||
/>
|
||||
)}
|
||||
{!isUpstreamProxyProvider && !isFreeNoAuth && (
|
||||
<Card>
|
||||
<ProviderAccountRoutingCard
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
computeNoAuthFallbackDisabledProviders,
|
||||
isNoAuthFallbackEnabled,
|
||||
} from "../components/AnonymousFallbackToggle";
|
||||
|
||||
describe("AnonymousFallbackToggle list-update helpers", () => {
|
||||
it("disabling adds the providerId exactly once and dedupes existing entries", () => {
|
||||
const next = computeNoAuthFallbackDisabledProviders(
|
||||
["openai", "openai", "opencode-go"],
|
||||
"opencode-go",
|
||||
"opencode",
|
||||
true
|
||||
);
|
||||
expect(next).toEqual(["openai", "opencode-go"]);
|
||||
expect(next.filter((id) => id === "opencode-go")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("enabling removes both the providerId and its alias", () => {
|
||||
const next = computeNoAuthFallbackDisabledProviders(
|
||||
["openai", "opencode-go", "opencode"],
|
||||
"opencode-go",
|
||||
"opencode",
|
||||
false
|
||||
);
|
||||
expect(next).toEqual(["openai"]);
|
||||
});
|
||||
|
||||
it("enabling with only the alias present also removes it", () => {
|
||||
const next = computeNoAuthFallbackDisabledProviders(
|
||||
["opencode"],
|
||||
"opencode-go",
|
||||
"opencode",
|
||||
false
|
||||
);
|
||||
expect(next).toEqual([]);
|
||||
});
|
||||
|
||||
it("is enabled by default when the disabled list is absent", () => {
|
||||
expect(isNoAuthFallbackEnabled("opencode-go", "opencode", undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("is disabled when the providerId is in the list", () => {
|
||||
expect(isNoAuthFallbackEnabled("opencode-go", "opencode", ["opencode-go"])).toBe(false);
|
||||
});
|
||||
|
||||
it("is disabled when only the alias is in the list", () => {
|
||||
expect(isNoAuthFallbackEnabled("opencode-go", "opencode", ["opencode"])).toBe(false);
|
||||
});
|
||||
|
||||
it("is enabled when the list is present but does not contain the provider", () => {
|
||||
expect(isNoAuthFallbackEnabled("opencode-go", "opencode", ["openai"])).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
// Issue #8935 — per-provider opt-out for the synthetic anonymous (no-auth)
|
||||
// credential fallback on API-key providers whose static definition declares
|
||||
// anonymousFallback: true (opencode-go, opencode-zen, pollinations, kilocode).
|
||||
// Default ON (fallback enabled) when the setting is absent, so existing
|
||||
// behavior is preserved for everyone who does not opt out. True no-auth
|
||||
// providers (NOAUTH_PROVIDERS / WEB_COOKIE_PROVIDERS) never see this control —
|
||||
// their synthetic credential is the only credential path and is governed by
|
||||
// blockedProviders instead.
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/shared/components";
|
||||
import { getProviderAlias, getProviderById } from "@/shared/constants/providers";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import { providerText } from "../providerPageHelpers";
|
||||
|
||||
export function computeNoAuthFallbackDisabledProviders(
|
||||
current: string[],
|
||||
providerId: string,
|
||||
providerAlias: string | undefined,
|
||||
disabling: boolean
|
||||
): string[] {
|
||||
const keysToRemove = new Set([providerId, providerAlias].filter(Boolean));
|
||||
if (!disabling) {
|
||||
return current.filter((item) => !keysToRemove.has(item));
|
||||
}
|
||||
return Array.from(new Set([...current.filter((item) => !keysToRemove.has(item)), providerId]));
|
||||
}
|
||||
|
||||
export function isNoAuthFallbackEnabled(
|
||||
providerId: string,
|
||||
providerAlias: string | undefined,
|
||||
disabledProviders: string[] | undefined
|
||||
): boolean {
|
||||
if (!Array.isArray(disabledProviders)) return true;
|
||||
return (
|
||||
!disabledProviders.includes(providerId) &&
|
||||
!(typeof providerAlias === "string" && disabledProviders.includes(providerAlias))
|
||||
);
|
||||
}
|
||||
|
||||
interface AnonymousFallbackToggleProps {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
}
|
||||
|
||||
export default function AnonymousFallbackToggle({
|
||||
providerId,
|
||||
providerName,
|
||||
}: AnonymousFallbackToggleProps) {
|
||||
const t = useTranslations("providers");
|
||||
const notify = useNotificationStore();
|
||||
const [disabledProviders, setDisabledProviders] = useState<string[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const providerDef = getProviderById(providerId) as { anonymousFallback?: boolean } | undefined;
|
||||
const providerAlias = getProviderAlias(providerId);
|
||||
const fallbackEnabled = isNoAuthFallbackEnabled(providerId, providerAlias, disabledProviders);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function fetchDisabledProviders() {
|
||||
try {
|
||||
const response = await fetch("/api/settings", { cache: "no-store" });
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
if (!cancelled && Array.isArray(data.noAuthFallbackDisabledProviders)) {
|
||||
setDisabledProviders(data.noAuthFallbackDisabledProviders);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch provider settings:", error);
|
||||
}
|
||||
}
|
||||
|
||||
void fetchDisabledProviders();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
async (nextEnabled: boolean) => {
|
||||
const previous = disabledProviders;
|
||||
const next = computeNoAuthFallbackDisabledProviders(
|
||||
previous,
|
||||
providerId,
|
||||
providerAlias,
|
||||
!nextEnabled
|
||||
);
|
||||
setDisabledProviders(next);
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ noAuthFallbackDisabledProviders: next }),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
data?.error?.message ||
|
||||
data?.error ||
|
||||
providerText(
|
||||
t,
|
||||
"anonymousFallbackUpdateFailed",
|
||||
"Failed to update anonymous fallback setting"
|
||||
)
|
||||
);
|
||||
}
|
||||
setDisabledProviders(
|
||||
Array.isArray(data.noAuthFallbackDisabledProviders)
|
||||
? data.noAuthFallbackDisabledProviders
|
||||
: next
|
||||
);
|
||||
notify.success(
|
||||
nextEnabled
|
||||
? providerText(
|
||||
t,
|
||||
"anonymousFallbackEnabled",
|
||||
"Anonymous fallback enabled for {provider}",
|
||||
{
|
||||
provider: providerName,
|
||||
}
|
||||
)
|
||||
: providerText(
|
||||
t,
|
||||
"anonymousFallbackDisabled",
|
||||
"Anonymous fallback disabled for {provider} — exhausted connections will skip this provider",
|
||||
{ provider: providerName }
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
setDisabledProviders(previous);
|
||||
notify.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: providerText(
|
||||
t,
|
||||
"anonymousFallbackUpdateFailed",
|
||||
"Failed to update anonymous fallback setting"
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[disabledProviders, notify, providerAlias, providerId, providerName, t]
|
||||
);
|
||||
|
||||
// Only API-key providers whose static definition opts into the anonymous
|
||||
// fallback get this control; everything else self-hides.
|
||||
if (providerDef?.anonymousFallback !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = providerText(t, "anonymousFallbackTitle", "Anonymous fallback");
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="inline-flex shrink-0 items-center justify-center w-10 h-10 rounded-full bg-sky-500/10 text-sky-500">
|
||||
<span className="material-symbols-outlined text-[20px]">key_off</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
<p className="text-sm text-text-muted">
|
||||
{providerText(
|
||||
t,
|
||||
"anonymousFallbackDesc",
|
||||
"When all configured connections are exhausted (quota, credits, or expiry), temporarily use this provider's keyless tier. Turn off to skip this provider instead of sending anonymous requests — recommended when the keyless tier rejects them (401)."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={fallbackEnabled}
|
||||
aria-label={title}
|
||||
disabled={saving}
|
||||
onClick={() => handleToggle(!fallbackEnabled)}
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${
|
||||
fallbackEnabled ? "bg-sky-500" : "bg-black/[0.12] dark:bg-white/[0.15]"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
|
||||
fallbackEnabled ? "translate-x-[26px]" : "translate-x-[3px]"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -111,16 +111,16 @@ export default function WebSessionCredentialGuide({
|
||||
<li>
|
||||
{providerText(
|
||||
t,
|
||||
"webSessionGuideStep2",
|
||||
"Open the browser developer tools and inspect a request made by the web app."
|
||||
"webSessionGuideStep2Fast",
|
||||
"Fast path: install the Cookie Editor extension (chromewebstore.google.com → Cookie Editor), open it on the {provider} tab, find {credential} (select all numbered chunks if split), and click Export → Copy with the export format set to “Cookie header”.",
|
||||
{ provider: providerName, credential: requirement.credentialName }
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{providerText(
|
||||
t,
|
||||
"webSessionGuideStep3",
|
||||
"Copy the required credential from the provider's own domain. For cookies, copy only the Cookie header value and omit Cookie:.",
|
||||
{ credential: requirement.credentialName }
|
||||
"webSessionGuideStep3Manual",
|
||||
"Manual path: open the browser developer tools (F12 → Network), refresh the page, open an authenticated request, and copy the Cookie header value from Request Headers — omit the Cookie: prefix."
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Badge from "@/shared/components/Badge";
|
||||
import type { BreakerWithHistory } from "@/types/resilience";
|
||||
|
||||
interface BreakerTimelineProps {
|
||||
breakers: BreakerWithHistory[];
|
||||
onWindowChange: (ms: number) => void;
|
||||
}
|
||||
|
||||
const WINDOWS = [
|
||||
{ label: "1h", ms: 3600000 },
|
||||
{ label: "6h", ms: 21600000 },
|
||||
{ label: "24h", ms: 86400000 },
|
||||
] as const;
|
||||
|
||||
export default function BreakerTimeline({ breakers, onWindowChange }: BreakerTimelineProps) {
|
||||
const t = useTranslations("resilienceConnections");
|
||||
const [selectedWindowMs, setSelectedWindowMs] = useState(3600000);
|
||||
|
||||
const stateLabel = (state: string) => {
|
||||
const key = `timeline.state.${state.toLowerCase()}`;
|
||||
const label = t(key);
|
||||
return label === key ? state : label; // Fallback for unknown states
|
||||
};
|
||||
const reasonLabel = (reason?: string) => {
|
||||
if (!reason) return "";
|
||||
// Map known reasons; use prefix matching for parameterized reasons (e.g., "probe-failed (cycle 3)")
|
||||
const knownReasons = [
|
||||
"timeout-elapsed",
|
||||
"success-recovery",
|
||||
"manual-reset",
|
||||
"probe-success",
|
||||
"recovery",
|
||||
"probe-failed",
|
||||
];
|
||||
const matched = knownReasons.find((r) => reason.startsWith(r));
|
||||
if (matched) {
|
||||
return t(`timeline.reason.${matched}`);
|
||||
}
|
||||
return reason; // Fallback: display raw reason string
|
||||
};
|
||||
|
||||
// API already filters transitionHistory by windowMs; no client-side re-filter needed
|
||||
const filtered = breakers.filter((b) => b.transitionHistory.length > 0);
|
||||
// Current state: breakers that are OPEN/HALF_OPEN/DEGRADED even with no window transitions
|
||||
const currentBreakers = breakers.filter(
|
||||
(b) => b.state === "OPEN" || b.state === "HALF_OPEN" || b.state === "DEGRADED"
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
|
||||
{currentBreakers.length > 0 && (
|
||||
<div>
|
||||
<h3>{t("timeline.currentState")}</h3>
|
||||
{currentBreakers.map((b) => (
|
||||
<span key={b.name} style={{ display: "inline-block", marginRight: "8px" }}>
|
||||
<Badge
|
||||
variant={
|
||||
b.state === "OPEN"
|
||||
? "error"
|
||||
: b.state === "HALF_OPEN" || b.state === "DEGRADED"
|
||||
? "warning"
|
||||
: "success"
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{b.name} ({b.state})
|
||||
</Badge>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "16px" }}>
|
||||
<span style={{ fontWeight: 600 }}>{t("timeline.window")}</span>
|
||||
{WINDOWS.map((w) => (
|
||||
<button
|
||||
key={w.ms}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedWindowMs(w.ms);
|
||||
onWindowChange(w.ms);
|
||||
}}
|
||||
style={{ fontWeight: selectedWindowMs === w.ms ? 700 : 400 }}
|
||||
>
|
||||
{t(`timeline.window${w.label}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{filtered.length === 0 ? (
|
||||
<p style={{ color: "var(--color-text-muted)" }}>{t("timeline.empty")}</p>
|
||||
) : (
|
||||
filtered.map((b) => (
|
||||
<div key={b.name}>
|
||||
<Badge
|
||||
variant={
|
||||
b.state === "OPEN"
|
||||
? "error"
|
||||
: b.state === "HALF_OPEN"
|
||||
? "warning"
|
||||
: b.state === "DEGRADED"
|
||||
? "warning"
|
||||
: "success"
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{b.name} ({stateLabel(b.state)})
|
||||
</Badge>
|
||||
<ul style={{ marginTop: "8px", paddingLeft: "20px" }}>
|
||||
{b.transitionHistory.map((tr, i) => (
|
||||
<li key={`${tr.timestamp}-${tr.from}-${tr.to}-${i}`}>
|
||||
{new Date(tr.timestamp).toLocaleString()}: {stateLabel(tr.from)} {"->"}{" "}
|
||||
{stateLabel(tr.to)}
|
||||
{tr.reason ? ` (${reasonLabel(tr.reason)})` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Badge from "@/shared/components/Badge";
|
||||
import type { ConnectionState } from "@/types/resilience";
|
||||
import { formatRemaining } from "@/shared/utils/formatRemaining";
|
||||
|
||||
interface ConnectionDetailProps {
|
||||
connection: ConnectionState | undefined; // undefined when connection deleted
|
||||
receivedAt: number; // client fetch receive time (immune to clock skew)
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ConnectionDetail({
|
||||
connection,
|
||||
receivedAt,
|
||||
onClose,
|
||||
}: ConnectionDetailProps) {
|
||||
const t = useTranslations("resilienceConnections");
|
||||
const [tick, setTick] = useState(0); // force re-render for live countdown
|
||||
useEffect(() => {
|
||||
if (!connection?.isCoolingDown) return;
|
||||
// Reset tick baseline when the connection changes so the countdown restarts from
|
||||
// the fresh cooldownRemainingMs. setTick(0) is a re-sync, not a cascading render.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setTick(0);
|
||||
const interval = setInterval(() => setTick((n) => n + 1), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [connection?.isCoolingDown, connection?.id]);
|
||||
|
||||
if (!connection) return null; // Guard: connection deleted while panel open
|
||||
const elapsedMs = tick * 1000;
|
||||
const adjustedCooldown = Math.max(0, connection.cooldownRemainingMs - elapsedMs);
|
||||
return (
|
||||
<div style={{ padding: "16px", borderRadius: "8px", border: "1px solid var(--color-border)" }}>
|
||||
<h2>{t("detail.title")}</h2>
|
||||
<div>
|
||||
{t("detail.provider")}: {connection.provider}
|
||||
</div>
|
||||
<div>
|
||||
{t("detail.id")}: {connection.id}
|
||||
</div>
|
||||
<div>
|
||||
{t("detail.authType")}: {connection.authType}
|
||||
</div>
|
||||
<div>
|
||||
{t("detail.priority")}: {connection.priority}
|
||||
</div>
|
||||
<div>
|
||||
{t("detail.isActive")}: {connection.isActive ? t("detail.yes") : t("detail.no")}
|
||||
</div>
|
||||
<div>
|
||||
{t("detail.errorCode")}: {connection.errorCode ?? t("detail.never")}
|
||||
</div>
|
||||
<div>
|
||||
{t("detail.lastErrorAt")}: {connection.lastErrorAt ?? t("detail.never")}
|
||||
</div>
|
||||
<hr />
|
||||
<h3>{t("detail.cooldown")}</h3>
|
||||
<div>
|
||||
{t("detail.rateLimitedUntil")}: {connection.rateLimitedUntil ?? t("detail.never")}
|
||||
</div>
|
||||
<div>
|
||||
{t("detail.backoffLevel")}: {connection.backoffLevel}
|
||||
</div>
|
||||
<div>
|
||||
{t("detail.remaining")}:{" "}
|
||||
{connection.isCoolingDown ? formatRemaining(adjustedCooldown) : t("detail.never")}
|
||||
</div>
|
||||
<hr />
|
||||
<h3>{t("detail.breaker")}</h3>
|
||||
{connection.breaker ? (
|
||||
<div>
|
||||
<Badge
|
||||
variant={
|
||||
connection.breaker.state === "OPEN"
|
||||
? "error"
|
||||
: connection.breaker.state === "HALF_OPEN"
|
||||
? "warning"
|
||||
: connection.breaker.state === "DEGRADED"
|
||||
? "warning"
|
||||
: "success"
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{connection.breaker.state}
|
||||
</Badge>
|
||||
<div>
|
||||
{t("detail.failureCount")}: {connection.breaker.failureCount}
|
||||
</div>
|
||||
<div>
|
||||
{t("detail.retryAfterMs")}: {connection.breaker.retryAfterMs}
|
||||
</div>
|
||||
<div>
|
||||
{t("detail.lastFailureKind")}: {connection.breaker.lastFailureKind ?? t("detail.never")}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>{t("detail.never")}</div>
|
||||
)}
|
||||
<hr />
|
||||
<h3>{t("detail.lockouts")}</h3>
|
||||
{connection.lockouts.length > 0 ? (
|
||||
<ul>
|
||||
{connection.lockouts.map((l, i) => (
|
||||
<li key={i}>
|
||||
{l.model}: {l.reason} ({formatRemaining(Math.max(0, l.remainingMs - elapsedMs))})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div>{t("detail.noLockouts")}</div>
|
||||
)}
|
||||
<button type="button" onClick={onClose}>
|
||||
{t("detail.close")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, memo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Badge from "@/shared/components/Badge";
|
||||
import DataTable from "@/shared/components/DataTable";
|
||||
import type { DataTableColumn, DataTableRow } from "@/shared/components/DataTable";
|
||||
import type { ConnectionState } from "@/types/resilience";
|
||||
import { formatRemaining } from "@/shared/utils/formatRemaining";
|
||||
import ConnectionDetail from "./ConnectionDetail";
|
||||
|
||||
interface ConnectionsTableProps {
|
||||
connections: ConnectionState[];
|
||||
receivedAt: number; // client fetch receive time (immune to clock skew)
|
||||
degraded: string[]; // meta.degraded from API (to show "Unknown" when breaker data absent)
|
||||
}
|
||||
|
||||
// Module-scoped memoized countdown cell: hoisted to avoid remount on every poll
|
||||
// (useMemo with receivedAt dependency would create new type each poll -> unmount/remount)
|
||||
// Ponytail: elapsed derived from tick count (pure -- no Date.now() in render), self-corrects
|
||||
// on each poll when receivedAt changes and the effect resets the tick baseline.
|
||||
const CountdownCell = memo(function CountdownCell({
|
||||
connection,
|
||||
receivedAt,
|
||||
}: {
|
||||
connection: ConnectionState;
|
||||
receivedAt: number;
|
||||
}) {
|
||||
const [tick, setTick] = useState(0); // force re-render for live countdown
|
||||
useEffect(() => {
|
||||
if (!connection.isCoolingDown) return;
|
||||
// Reset tick baseline when new data arrives so the countdown restarts from the
|
||||
// fresh cooldownRemainingMs. setTick(0) is a re-sync, not a cascading render.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setTick(0);
|
||||
const interval = setInterval(() => setTick((n) => n + 1), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [connection.isCoolingDown, receivedAt]);
|
||||
if (!connection.isCoolingDown) return <span>-</span>;
|
||||
const elapsedMs = tick * 1000;
|
||||
return <span>{formatRemaining(Math.max(0, connection.cooldownRemainingMs - elapsedMs))}</span>;
|
||||
});
|
||||
|
||||
export default function ConnectionsTable({
|
||||
connections,
|
||||
receivedAt,
|
||||
degraded,
|
||||
}: ConnectionsTableProps) {
|
||||
const t = useTranslations("resilienceConnections");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
// Derive the effective selected id during render (closes detail when connection disappears)
|
||||
const effectiveSelectedId =
|
||||
selectedId && connections.some((c) => c.id === selectedId) ? selectedId : null;
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: "status", label: t("table.status") },
|
||||
{ key: "provider", label: t("table.provider") },
|
||||
{ key: "id", label: t("table.connectionId") },
|
||||
{ key: "authType", label: t("table.authType") },
|
||||
{ key: "backoffLevel", label: t("table.backoffLevel") },
|
||||
{ key: "cooldown", label: t("table.cooldown") },
|
||||
{ key: "lastError", label: t("table.lastError") },
|
||||
{ key: "lockouts", label: t("table.lockouts") },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={connections as unknown as DataTableRow[]}
|
||||
selectedId={effectiveSelectedId ?? undefined}
|
||||
onRowClick={(row) => setSelectedId(String(row.id))}
|
||||
renderCell={(row: DataTableRow, col: DataTableColumn) => {
|
||||
const r = row as unknown as ConnectionState;
|
||||
switch (col.key) {
|
||||
case "status":
|
||||
switch (r.connectionStatus) {
|
||||
case "cooling_down":
|
||||
return (
|
||||
<Badge variant="warning" size="sm">
|
||||
{t("table.coolingDown")}
|
||||
</Badge>
|
||||
);
|
||||
case "circuit_open":
|
||||
return (
|
||||
<Badge variant="error" size="sm">
|
||||
{t("table.circuitOpen")}
|
||||
</Badge>
|
||||
);
|
||||
case "terminal":
|
||||
return (
|
||||
<Badge variant="error" size="sm">
|
||||
{t("table.terminal")}
|
||||
</Badge>
|
||||
);
|
||||
case "healthy":
|
||||
// When breaker data is absent (degraded source), show "Unknown" not "Healthy"
|
||||
if (degraded.includes("circuitBreaker") && !r.breaker) {
|
||||
return (
|
||||
<Badge variant="info" size="sm">
|
||||
{t("table.unknown")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return r.breaker?.state === "HALF_OPEN" ? (
|
||||
<Badge variant="warning" size="sm">
|
||||
{t("table.recovering")}
|
||||
</Badge>
|
||||
) : r.breaker?.state === "DEGRADED" ? (
|
||||
<Badge variant="warning" size="sm">
|
||||
{t("table.degraded")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="success" size="sm">
|
||||
{t("table.healthy")}
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Badge variant="info" size="sm">
|
||||
{r.connectionStatus}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
case "id":
|
||||
return <span>{r.id.length > 8 ? `${r.id.slice(0, 8)}...` : r.id}</span>;
|
||||
case "cooldown":
|
||||
return <CountdownCell connection={r} receivedAt={receivedAt} />;
|
||||
{
|
||||
/* memoized: isolates 1s tick to single cell */
|
||||
}
|
||||
case "lastError":
|
||||
return <span>{r.lastErrorType ?? t("table.never")}</span>;
|
||||
case "lockouts":
|
||||
return <span>{r.lockouts.length}</span>;
|
||||
case "provider":
|
||||
return <span>{r.provider}</span>;
|
||||
case "authType":
|
||||
return <span>{r.authType}</span>;
|
||||
case "backoffLevel":
|
||||
return <span>{r.backoffLevel}</span>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{effectiveSelectedId && (
|
||||
<ConnectionDetail
|
||||
connection={connections.find((c) => c.id === effectiveSelectedId) ?? undefined}
|
||||
receivedAt={receivedAt}
|
||||
onClose={() => setSelectedId(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { ResilienceConnectionsResponse } from "@/types/resilience";
|
||||
import EmptyState from "@/shared/components/EmptyState";
|
||||
import ConnectionsTable from "./ConnectionsTable";
|
||||
import BreakerTimeline from "./BreakerTimeline";
|
||||
|
||||
const POLL_INTERVAL_MS = 30000;
|
||||
|
||||
export default function ResilienceConnectionsClient() {
|
||||
const t = useTranslations("resilienceConnections");
|
||||
const [data, setData] = useState<ResilienceConnectionsResponse | null>(null);
|
||||
const [windowMs, setWindowMs] = useState(3600000);
|
||||
const [pollError, setPollError] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [stopReason, setStopReason] = useState<"none" | "local_only" | "not_found">("none");
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const stoppedRef = useRef(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchData = useCallback(async (ws: number) => {
|
||||
if (stoppedRef.current) return; // permanent stop after 404/403
|
||||
if (document.hidden) return; // pause on tab hidden
|
||||
// Abort any in-flight request before starting a new one
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
// Timeout: prevent hanging request from killing the polling chain
|
||||
let timedOut = false;
|
||||
const timeoutId = setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, 25000); // < 30s poll interval
|
||||
try {
|
||||
const res = await fetch(`/api/resilience/connections?windowMs=${ws}`, {
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (controller.signal.aborted) return; // guard: window switch cancelled this request
|
||||
if (res.status === 404 || res.status === 403) {
|
||||
stoppedRef.current = true;
|
||||
setStopReason(res.status === 403 ? "local_only" : "not_found"); // explicit reason for UI
|
||||
setPollError(true);
|
||||
setLoading(false); // exit loading so pollError banner is visible
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
setPollError(true); // surface 5xx as error (not "No connections")
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const json = await res.json();
|
||||
if (controller.signal.aborted) return; // guard against stale response
|
||||
// Capture receive time for client-side countdown (immune to clock skew).
|
||||
// Spread to avoid mutating the parsed JSON object.
|
||||
setData({ ...json, receivedAt: Date.now() } as ResilienceConnectionsResponse);
|
||||
setLoading(false);
|
||||
setPollError(false); // clear error on successful fetch
|
||||
} catch (err) {
|
||||
if (err?.name === "AbortError") {
|
||||
// works across browsers, undici, and test mocks
|
||||
if (timedOut) {
|
||||
// Timeout: surface as error (not user-initiated cancel)
|
||||
setPollError(true);
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Network error: surface as error state
|
||||
setPollError(true);
|
||||
setLoading(false);
|
||||
console.warn(
|
||||
"[ResilienceConnectionsClient] fetch error:",
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pollGenRef = useRef(0);
|
||||
|
||||
// Retry handler: reset stopped state, trigger effect restart (defined before use in errorBanner)
|
||||
const handleRetry = () => {
|
||||
stoppedRef.current = false;
|
||||
setStopReason("none"); // reset reason on retry
|
||||
setRetryCount((c) => c + 1); // triggers useEffect cleanup + restart
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (stoppedRef.current) return;
|
||||
const gen = ++pollGenRef.current; // generation counter: stale chains self-terminate
|
||||
const poll = async () => {
|
||||
await fetchData(windowMs);
|
||||
if (gen !== pollGenRef.current) return; // stale chain: stop rescheduling
|
||||
if (stoppedRef.current) return;
|
||||
pollTimerRef.current = setTimeout(poll, POLL_INTERVAL_MS);
|
||||
};
|
||||
poll();
|
||||
return () => {
|
||||
// pollGenRef is a mutable counter (not a DOM node); cleanup intentionally
|
||||
// bumps it so the captured `gen` goes stale and in-flight chains self-terminate.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
pollGenRef.current++; // invalidate any in-flight chain
|
||||
abortRef.current?.abort();
|
||||
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
|
||||
};
|
||||
}, [windowMs, fetchData, retryCount]); // retryCount restarts chain on retry
|
||||
|
||||
// Resume on tab visible
|
||||
useEffect(() => {
|
||||
const onVis = () => {
|
||||
if (!document.hidden && !stoppedRef.current) fetchData(windowMs);
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVis);
|
||||
return () => document.removeEventListener("visibilitychange", onVis);
|
||||
}, [windowMs, fetchData]);
|
||||
|
||||
// Cleanup abort on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const errorBanner = pollError ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px",
|
||||
borderRadius: "8px",
|
||||
background: "rgba(239,68,68,0.1)",
|
||||
color: "var(--color-error)",
|
||||
}}
|
||||
>
|
||||
{stopReason === "local_only"
|
||||
? t("pollErrorLocalOnly") // LOCAL_ONLY gate rejection - page only accessible from localhost/LAN
|
||||
: stoppedRef.current
|
||||
? t("pollErrorStopped")
|
||||
: t("pollErrorTransient")}
|
||||
{stopReason !== "local_only" && (
|
||||
<button type="button" onClick={handleRetry} style={{ marginLeft: "8px" }}>
|
||||
{t("retry")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
if (pollError && !data) return errorBanner;
|
||||
if (loading && !data)
|
||||
return (
|
||||
<EmptyState icon="shield" title={t("loading.title")} description={t("loading.description")} />
|
||||
);
|
||||
if (!data)
|
||||
return (
|
||||
<EmptyState icon="shield" title={t("empty.title")} description={t("empty.description")} />
|
||||
);
|
||||
// Show degradation banner above empty state when sources failed
|
||||
if (data.connections.length === 0 && data.meta.degraded.length === 0)
|
||||
return (
|
||||
<EmptyState icon="shield" title={t("empty.title")} description={t("empty.description")} />
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{errorBanner}
|
||||
<div
|
||||
style={{ display: "flex", gap: "16px", fontSize: "12px", color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<span>
|
||||
{t("summary.total", { count: data.meta.totalConnections })}
|
||||
{data.meta.countsCapped ? ` (${t("summary.capped")})` : ""}
|
||||
</span>
|
||||
<span>{t("summary.coolingDown", { count: data.meta.coolingDownCount })}</span>
|
||||
<span>{t("summary.unhealthyBreakers", { count: data.meta.unhealthyBreakerCount })}</span>
|
||||
</div>
|
||||
{data.meta.degraded.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px",
|
||||
borderRadius: "8px",
|
||||
background: "rgba(245,158,11,0.1)",
|
||||
color: "var(--color-warning)",
|
||||
}}
|
||||
>
|
||||
{t("degraded", {
|
||||
sources: data.meta.degraded.map((s) => t(`degraded.source.${s}`)).join(", "),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: "11px", color: "var(--color-text-muted)" }}>{t("pollingNote")}</div>
|
||||
<ConnectionsTable
|
||||
connections={data.connections}
|
||||
receivedAt={data.receivedAt ?? Date.now()}
|
||||
degraded={data.meta.degraded}
|
||||
/>
|
||||
<BreakerTimeline breakers={data.breakers} onWindowChange={setWindowMs} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import ResilienceConnectionsClient from "./components/ResilienceConnectionsClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ResilienceConnectionsPage() {
|
||||
const t = await getTranslations("resilienceConnections");
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "24px" }}>
|
||||
<h1>{t("title")}</h1>
|
||||
<ResilienceConnectionsClient />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button, Card } from "@/shared/components";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import { formatRemaining } from "@/shared/utils/formatRemaining";
|
||||
|
||||
type CooldownItem = {
|
||||
provider: string;
|
||||
@@ -13,13 +14,6 @@ type CooldownItem = {
|
||||
unavailableSince: string;
|
||||
};
|
||||
|
||||
function formatRemaining(ms: number): string {
|
||||
const totalSec = Math.max(0, Math.ceil(ms / 1000));
|
||||
const min = Math.floor(totalSec / 60);
|
||||
const sec = totalSec % 60;
|
||||
return `${min}m ${sec}s`;
|
||||
}
|
||||
|
||||
export default function ModelCooldownsCard() {
|
||||
const t = useTranslations("settings");
|
||||
const notify = useNotificationStore();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import ThinkingBudgetTab from "../components/ThinkingBudgetTab";
|
||||
import VisionBridgeSettingsTab from "../components/VisionBridgeSettingsTab";
|
||||
import ModalityBridgeMovedCard from "../components/ModalityBridgeMovedCard";
|
||||
import SystemPromptTab from "../components/SystemPromptTab";
|
||||
import ResponsesStatePolicyTab from "../components/ResponsesStatePolicyTab";
|
||||
import CodexFastTierTab from "../components/CodexFastTierTab";
|
||||
@@ -19,7 +19,7 @@ export default function SettingsAiPage() {
|
||||
<div className="space-y-6">
|
||||
<p className="text-sm text-text-muted">{t("aiSettingsIntro")}</p>
|
||||
<ThinkingBudgetTab />
|
||||
<VisionBridgeSettingsTab />
|
||||
<ModalityBridgeMovedCard />
|
||||
<SystemPromptTab />
|
||||
<ResponsesStatePolicyTab />
|
||||
<UsageTokenBufferTab />
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
// One-cycle migration notice. Settings → AI no longer renders or writes the
|
||||
// legacy controls; the dedicated Modality Bridge page owns these settings.
|
||||
export default function ModalityBridgeMovedCard() {
|
||||
const t = useTranslations("settings");
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-border/70 bg-surface/40 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="material-symbols-outlined text-[21px] text-fuchsia-500" aria-hidden="true">
|
||||
image_search
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="text-base font-semibold text-text-main">
|
||||
{t("modalityBridgeMovedTitle")}
|
||||
</h4>
|
||||
<p className="mt-1 text-sm text-text-muted">{t("modalityBridgeMovedBody")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border-t border-border pt-3 text-sm">
|
||||
<Link href="/dashboard/settings/modality-bridge" className="text-primary hover:underline">
|
||||
{t("modalityBridgeMovedCta")}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, Toggle } from "@/shared/components";
|
||||
import { VISION_BRIDGE_DEFAULTS } from "@/shared/constants/visionBridgeDefaults";
|
||||
|
||||
type SettingsState = {
|
||||
visionBridgeEnabled: boolean;
|
||||
visionBridgeModel: string;
|
||||
visionBridgePrompt: string;
|
||||
visionBridgeTimeout: number;
|
||||
visionBridgeMaxImages: number;
|
||||
};
|
||||
|
||||
export default function VisionBridgeSettingsTab() {
|
||||
const t = useTranslations("settings");
|
||||
const [settings, setSettings] = useState<SettingsState>({
|
||||
visionBridgeEnabled: VISION_BRIDGE_DEFAULTS.enabled,
|
||||
visionBridgeModel: VISION_BRIDGE_DEFAULTS.model,
|
||||
visionBridgePrompt: VISION_BRIDGE_DEFAULTS.prompt,
|
||||
visionBridgeTimeout: VISION_BRIDGE_DEFAULTS.timeoutMs,
|
||||
visionBridgeMaxImages: VISION_BRIDGE_DEFAULTS.maxImagesPerRequest,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/settings")
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
if (!data) return;
|
||||
setSettings({
|
||||
visionBridgeEnabled: data.visionBridgeEnabled ?? VISION_BRIDGE_DEFAULTS.enabled,
|
||||
visionBridgeModel: data.visionBridgeModel ?? VISION_BRIDGE_DEFAULTS.model,
|
||||
visionBridgePrompt: data.visionBridgePrompt ?? VISION_BRIDGE_DEFAULTS.prompt,
|
||||
visionBridgeTimeout: data.visionBridgeTimeout ?? VISION_BRIDGE_DEFAULTS.timeoutMs,
|
||||
visionBridgeMaxImages:
|
||||
data.visionBridgeMaxImages ?? VISION_BRIDGE_DEFAULTS.maxImagesPerRequest,
|
||||
});
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const updateSetting = async (patch: Partial<SettingsState>) => {
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
if (res.ok) {
|
||||
setSettings((prev) => ({ ...prev, ...patch }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to update Vision Bridge settings:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-fuchsia-500/10 text-fuchsia-500">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
image_search
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{t("visionBridge")}</h3>
|
||||
<p className="text-sm text-text-muted">{t("visionBridgeDesc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-medium">{t("visionBridgeEnabledLabel")}</p>
|
||||
<p className="text-sm text-text-muted">{t("visionBridgeEnabledDesc")}</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={settings.visionBridgeEnabled}
|
||||
onChange={(checked) => updateSetting({ visionBridgeEnabled: checked })}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-border space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t("visionBridgeModel")}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.visionBridgeModel}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({ ...prev, visionBridgeModel: e.target.value }))
|
||||
}
|
||||
onBlur={() => updateSetting({ visionBridgeModel: settings.visionBridgeModel.trim() })}
|
||||
className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"
|
||||
placeholder={t("visionBridgeModelPlaceholder")}
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">{t("visionBridgeModelHint")}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t("visionBridgePrompt")}</label>
|
||||
<textarea
|
||||
value={settings.visionBridgePrompt}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({ ...prev, visionBridgePrompt: e.target.value }))
|
||||
}
|
||||
onBlur={() =>
|
||||
updateSetting({ visionBridgePrompt: settings.visionBridgePrompt.trim() })
|
||||
}
|
||||
className="min-h-[100px] w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"
|
||||
placeholder={t("visionBridgePromptPlaceholder")}
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">{t("visionBridgePromptHint")}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t("visionBridgeTimeoutMs")}</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1000}
|
||||
max={300000}
|
||||
value={settings.visionBridgeTimeout}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
visionBridgeTimeout: Number.parseInt(e.target.value, 10) || 0,
|
||||
}))
|
||||
}
|
||||
onBlur={() =>
|
||||
updateSetting({
|
||||
visionBridgeTimeout: Math.min(
|
||||
300000,
|
||||
Math.max(
|
||||
1000,
|
||||
settings.visionBridgeTimeout || VISION_BRIDGE_DEFAULTS.timeoutMs
|
||||
)
|
||||
),
|
||||
})
|
||||
}
|
||||
className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{t("visionBridgeMaxImagesPerRequest")}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={settings.visionBridgeMaxImages}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
visionBridgeMaxImages: Number.parseInt(e.target.value, 10) || 0,
|
||||
}))
|
||||
}
|
||||
onBlur={() =>
|
||||
updateSetting({
|
||||
visionBridgeMaxImages: Math.min(
|
||||
20,
|
||||
Math.max(
|
||||
1,
|
||||
settings.visionBridgeMaxImages || VISION_BRIDGE_DEFAULTS.maxImagesPerRequest
|
||||
)
|
||||
),
|
||||
})
|
||||
}
|
||||
className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface ModalityBridgeComingSoonTabProps {
|
||||
bodyKey: string;
|
||||
}
|
||||
|
||||
export default function ModalityBridgeComingSoonTab({ bodyKey }: ModalityBridgeComingSoonTabProps) {
|
||||
const t = useTranslations("settings");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-card border border-dashed border-border p-6">
|
||||
<div className="flex items-center gap-2 text-text-muted">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
hourglass_top
|
||||
</span>
|
||||
<p className="text-sm">{t(bodyKey)}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type BridgeKind = "vision" | "audio";
|
||||
|
||||
interface BridgeStats {
|
||||
bridged: number;
|
||||
cacheHits: number;
|
||||
failures: number;
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
|
||||
interface ModalityBridgeStatsRowProps {
|
||||
kind: BridgeKind;
|
||||
}
|
||||
|
||||
function parseStats(value: unknown): BridgeStats | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const lastUsedAt = record.lastUsedAt;
|
||||
if (lastUsedAt !== null && typeof lastUsedAt !== "string") return null;
|
||||
if (
|
||||
typeof record.bridged !== "number" ||
|
||||
typeof record.cacheHits !== "number" ||
|
||||
typeof record.failures !== "number"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
bridged: record.bridged,
|
||||
cacheHits: record.cacheHits,
|
||||
failures: record.failures,
|
||||
lastUsedAt: typeof lastUsedAt === "string" ? lastUsedAt : null,
|
||||
};
|
||||
}
|
||||
|
||||
export default function ModalityBridgeStatsRow({ kind }: ModalityBridgeStatsRowProps) {
|
||||
const t = useTranslations("settings");
|
||||
const [stats, setStats] = useState<BridgeStats | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch("/api/modality-bridge/stats")
|
||||
.then((response) => (response.ok ? response.json() : Promise.reject(new Error("fetch"))))
|
||||
.then((data: unknown) => {
|
||||
if (cancelled || !data || typeof data !== "object") return;
|
||||
setStats(parseStats((data as Record<string, unknown>)[kind]));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setStats(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [kind]);
|
||||
|
||||
if (!stats) return null;
|
||||
|
||||
const lastUsed = stats.lastUsedAt
|
||||
? new Date(stats.lastUsedAt).toLocaleString()
|
||||
: t("modalityBridgeStatsNever");
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-text-muted" aria-live="polite">
|
||||
<span>
|
||||
{stats.bridged} {t("modalityBridgeStatsBridged")}
|
||||
</span>
|
||||
<span>
|
||||
{stats.cacheHits} {t("modalityBridgeStatsCacheHits")}
|
||||
</span>
|
||||
<span>
|
||||
{stats.failures} {t("modalityBridgeStatsFailures")}
|
||||
</span>
|
||||
<span>
|
||||
{t("modalityBridgeStatsLastUsed")}: {lastUsed}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const SAMPLE_INPUT = {
|
||||
model: "modality-bridge/self-test",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "What is in this image?" },
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const DISABLED_GUARDRAILS = ["pii-masker", "prompt-injection", "credential-masker"];
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function findVisionMeta(value: unknown): Record<string, unknown> | null {
|
||||
const body = asRecord(value);
|
||||
if (!Array.isArray(body?.results)) return null;
|
||||
for (const entry of body.results) {
|
||||
const result = asRecord(entry);
|
||||
if (result?.guardrail === "vision-bridge") return asRecord(result.meta);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readErrorMessage(value: unknown): string | null {
|
||||
const body = asRecord(value);
|
||||
const error = asRecord(body?.error);
|
||||
return typeof error?.message === "string" ? error.message : null;
|
||||
}
|
||||
|
||||
export default function ModalityBridgeTestButton() {
|
||||
const t = useTranslations("settings");
|
||||
const [running, setRunning] = useState(false);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
|
||||
const runTest = async () => {
|
||||
setRunning(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const response = await fetch("/api/guardrails/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
input: SAMPLE_INPUT,
|
||||
disabledGuardrails: DISABLED_GUARDRAILS,
|
||||
}),
|
||||
});
|
||||
const body: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(readErrorMessage(body) ?? `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const meta = findVisionMeta(body);
|
||||
if (meta?.rerouted === true) {
|
||||
setResult(
|
||||
t("modalityBridgeTestReroute", {
|
||||
model: String(meta.toModel ?? "unknown"),
|
||||
})
|
||||
);
|
||||
} else if (typeof meta?.imagesProcessed === "number" && meta.imagesProcessed >= 1) {
|
||||
setResult(
|
||||
t("modalityBridgeTestOk", {
|
||||
count: meta.imagesProcessed,
|
||||
model: String(meta.visionModel ?? "unknown"),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
setResult(t("modalityBridgeTestNoop"));
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setResult(t("modalityBridgeTestError", { message }));
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-control border border-border px-3 py-2 text-sm font-medium hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={running}
|
||||
onClick={() => void runTest()}
|
||||
>
|
||||
{t(running ? "modalityBridgeTestRunning" : "modalityBridgeTestButton")}
|
||||
</button>
|
||||
{result && (
|
||||
<p className="text-xs text-text-muted" role="status">
|
||||
{result}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Card, ModelSelectField, Toggle } from "@/shared/components";
|
||||
import {
|
||||
MODALITY_BRIDGE_DEFAULTS,
|
||||
resolveVisionBridgeRuntimeSettings,
|
||||
type VisionBridgeMode,
|
||||
} from "@/shared/constants/modalityBridgeDefaults";
|
||||
import { VISION_BRIDGE_DEFAULTS } from "@/shared/constants/visionBridgeDefaults";
|
||||
|
||||
import ModalityBridgeStatsRow from "./ModalityBridgeStatsRow";
|
||||
import ModalityBridgeTestButton from "./ModalityBridgeTestButton";
|
||||
|
||||
interface VisionState {
|
||||
modalityBridgeVisionEnabled: boolean;
|
||||
modalityBridgeVisionMode: VisionBridgeMode;
|
||||
modalityBridgeVisionModel: string;
|
||||
modalityBridgeVisionTaskAware: boolean;
|
||||
modalityBridgeVisionPrompt: string;
|
||||
modalityBridgeVisionTimeout: number;
|
||||
modalityBridgeVisionMaxImages: number;
|
||||
modalityBridgeCacheEnabled: boolean;
|
||||
modalityBridgeCacheTtlMinutes: number;
|
||||
modalityBridgeCacheMaxEntries: number;
|
||||
}
|
||||
|
||||
function fromApi(data: Record<string, unknown>): VisionState {
|
||||
const runtime = resolveVisionBridgeRuntimeSettings(data);
|
||||
return {
|
||||
modalityBridgeVisionEnabled: runtime.enabled,
|
||||
modalityBridgeVisionMode: runtime.mode,
|
||||
modalityBridgeVisionModel: runtime.model,
|
||||
modalityBridgeVisionTaskAware: runtime.taskAware,
|
||||
modalityBridgeVisionPrompt: runtime.prompt,
|
||||
modalityBridgeVisionTimeout: runtime.timeoutMs,
|
||||
modalityBridgeVisionMaxImages: runtime.maxImages,
|
||||
modalityBridgeCacheEnabled: runtime.cacheEnabled,
|
||||
modalityBridgeCacheTtlMinutes: runtime.cacheTtlMinutes,
|
||||
modalityBridgeCacheMaxEntries: runtime.cacheMaxEntries,
|
||||
};
|
||||
}
|
||||
|
||||
function clampNumber(raw: string, min: number, max: number, fallback: number): number {
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return Math.min(max, Math.max(min, Number.isFinite(parsed) ? parsed : fallback));
|
||||
}
|
||||
|
||||
export default function ModalityBridgeVisionTab() {
|
||||
const t = useTranslations("settings");
|
||||
const [settings, setSettings] = useState<VisionState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch("/api/settings")
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((data: unknown) => {
|
||||
if (cancelled) return;
|
||||
setSettings(fromApi(asSettingsRecord(data)));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setSettings(fromApi({}));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const update = async (patch: Partial<VisionState>) => {
|
||||
try {
|
||||
const response = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
if (response.ok) {
|
||||
setSettings((previous) => (previous ? { ...previous, ...patch } : previous));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to update Modality Bridge settings:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
const setLocal = (patch: Partial<VisionState>) => {
|
||||
setSettings((previous) => (previous ? { ...previous, ...patch } : previous));
|
||||
};
|
||||
|
||||
const commitNumber = (
|
||||
key:
|
||||
| "modalityBridgeVisionTimeout"
|
||||
| "modalityBridgeVisionMaxImages"
|
||||
| "modalityBridgeCacheTtlMinutes"
|
||||
| "modalityBridgeCacheMaxEntries",
|
||||
raw: string,
|
||||
min: number,
|
||||
max: number,
|
||||
fallback: number
|
||||
) => {
|
||||
const value = clampNumber(raw, min, max, fallback);
|
||||
setLocal({ [key]: value });
|
||||
void update({ [key]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={t("modalityBridgeVisionTitle")}
|
||||
subtitle={t("modalityBridgeVisionDesc")}
|
||||
icon="image_search"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Toggle
|
||||
checked={settings.modalityBridgeVisionEnabled}
|
||||
onChange={(checked) => void update({ modalityBridgeVisionEnabled: checked })}
|
||||
label={t("visionBridgeEnabledLabel")}
|
||||
description={t("visionBridgeEnabledDesc")}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium" htmlFor="modality-bridge-mode">
|
||||
{t("modalityBridgeMode")}
|
||||
</label>
|
||||
<select
|
||||
id="modality-bridge-mode"
|
||||
data-testid="modality-bridge-mode"
|
||||
className="mt-1 w-full rounded-control border border-border bg-surface px-3 py-2 text-sm"
|
||||
value={settings.modalityBridgeVisionMode}
|
||||
onChange={(event) =>
|
||||
void update({ modalityBridgeVisionMode: event.target.value as VisionBridgeMode })
|
||||
}
|
||||
>
|
||||
<option value="auto">{t("modalityBridgeModeAuto")}</option>
|
||||
<option value="describe">{t("modalityBridgeModeDescribe")}</option>
|
||||
<option value="reroute">{t("modalityBridgeModeReroute")}</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
{settings.modalityBridgeVisionMode === "auto" && t("modalityBridgeModeAutoHint")}
|
||||
{settings.modalityBridgeVisionMode === "describe" &&
|
||||
t("modalityBridgeModeDescribeHint")}
|
||||
{settings.modalityBridgeVisionMode === "reroute" && t("modalityBridgeModeRerouteHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ModelSelectField
|
||||
label={t("modalityBridgeVisionModel")}
|
||||
value={settings.modalityBridgeVisionModel}
|
||||
placeholder={t("modalityBridgeVisionModelAuto")}
|
||||
allowEmpty
|
||||
onChange={(value) => void update({ modalityBridgeVisionModel: value })}
|
||||
className="text-sm"
|
||||
/>
|
||||
|
||||
<Toggle
|
||||
checked={settings.modalityBridgeVisionTaskAware}
|
||||
onChange={(checked) => void update({ modalityBridgeVisionTaskAware: checked })}
|
||||
label={t("modalityBridgeTaskAware")}
|
||||
description={t("modalityBridgeTaskAwareDesc")}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium" htmlFor="modality-bridge-prompt">
|
||||
{t("modalityBridgePrompt")}
|
||||
</label>
|
||||
<textarea
|
||||
id="modality-bridge-prompt"
|
||||
className="mt-1 min-h-[100px] w-full rounded-control border border-border bg-surface px-3 py-2 text-sm"
|
||||
value={settings.modalityBridgeVisionPrompt}
|
||||
onChange={(event) =>
|
||||
setLocal({ modalityBridgeVisionPrompt: event.currentTarget.value })
|
||||
}
|
||||
onBlur={(event) => {
|
||||
const value = event.currentTarget.value.trim();
|
||||
setLocal({ modalityBridgeVisionPrompt: value });
|
||||
void update({ modalityBridgeVisionPrompt: value });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<details className="rounded-control border border-border p-3">
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
{t("modalityBridgeAdvanced")}
|
||||
</summary>
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<NumberField
|
||||
testId="modality-bridge-timeout"
|
||||
label={t("modalityBridgeTimeoutMs")}
|
||||
min={1000}
|
||||
max={300000}
|
||||
value={settings.modalityBridgeVisionTimeout}
|
||||
onChange={(value) => setLocal({ modalityBridgeVisionTimeout: value })}
|
||||
onBlur={(raw) =>
|
||||
commitNumber(
|
||||
"modalityBridgeVisionTimeout",
|
||||
raw,
|
||||
1000,
|
||||
300000,
|
||||
VISION_BRIDGE_DEFAULTS.timeoutMs
|
||||
)
|
||||
}
|
||||
/>
|
||||
<NumberField
|
||||
testId="modality-bridge-max-images"
|
||||
label={t("modalityBridgeMaxImages")}
|
||||
min={1}
|
||||
max={20}
|
||||
value={settings.modalityBridgeVisionMaxImages}
|
||||
onChange={(value) => setLocal({ modalityBridgeVisionMaxImages: value })}
|
||||
onBlur={(raw) =>
|
||||
commitNumber(
|
||||
"modalityBridgeVisionMaxImages",
|
||||
raw,
|
||||
1,
|
||||
20,
|
||||
VISION_BRIDGE_DEFAULTS.maxImagesPerRequest
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="md:col-span-2">
|
||||
<Toggle
|
||||
checked={settings.modalityBridgeCacheEnabled}
|
||||
onChange={(checked) => void update({ modalityBridgeCacheEnabled: checked })}
|
||||
label={t("modalityBridgeCacheEnabled")}
|
||||
description={t("modalityBridgeCacheEnabledDesc")}
|
||||
/>
|
||||
</div>
|
||||
<NumberField
|
||||
testId="modality-bridge-cache-ttl"
|
||||
label={t("modalityBridgeCacheTtlMinutes")}
|
||||
min={1}
|
||||
max={1440}
|
||||
value={settings.modalityBridgeCacheTtlMinutes}
|
||||
onChange={(value) => setLocal({ modalityBridgeCacheTtlMinutes: value })}
|
||||
onBlur={(raw) =>
|
||||
commitNumber(
|
||||
"modalityBridgeCacheTtlMinutes",
|
||||
raw,
|
||||
1,
|
||||
1440,
|
||||
MODALITY_BRIDGE_DEFAULTS.cacheTtlMinutes
|
||||
)
|
||||
}
|
||||
/>
|
||||
<NumberField
|
||||
testId="modality-bridge-cache-max-entries"
|
||||
label={t("modalityBridgeCacheMaxEntries")}
|
||||
min={10}
|
||||
max={5000}
|
||||
value={settings.modalityBridgeCacheMaxEntries}
|
||||
onChange={(value) => setLocal({ modalityBridgeCacheMaxEntries: value })}
|
||||
onBlur={(raw) =>
|
||||
commitNumber(
|
||||
"modalityBridgeCacheMaxEntries",
|
||||
raw,
|
||||
10,
|
||||
5000,
|
||||
MODALITY_BRIDGE_DEFAULTS.cacheMaxEntries
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<ModalityBridgeStatsRow kind="vision" />
|
||||
<ModalityBridgeTestButton />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function asSettingsRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
interface NumberFieldProps {
|
||||
testId: string;
|
||||
label: string;
|
||||
min: number;
|
||||
max: number;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
onBlur: (raw: string) => void;
|
||||
}
|
||||
|
||||
function NumberField({ testId, label, min, max, value, onChange, onBlur }: NumberFieldProps) {
|
||||
return (
|
||||
<label className="block text-sm font-medium">
|
||||
{label}
|
||||
<input
|
||||
type="number"
|
||||
data-testid={testId}
|
||||
min={min}
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={(event) => onChange(Number.parseInt(event.currentTarget.value, 10) || 0)}
|
||||
onBlur={(event) => onBlur(event.currentTarget.value)}
|
||||
className="mt-1 w-full rounded-control border border-border bg-surface px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useMemo } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import ModalityBridgeComingSoonTab from "@/app/(dashboard)/dashboard/settings/components/modalityBridge/ModalityBridgeComingSoonTab";
|
||||
import ModalityBridgeVisionTab from "@/app/(dashboard)/dashboard/settings/components/modalityBridge/ModalityBridgeVisionTab";
|
||||
|
||||
type TabId = "vision" | "audio" | "video";
|
||||
|
||||
const TABS: ReadonlyArray<{ id: TabId; labelKey: string }> = [
|
||||
{ id: "vision", labelKey: "modalityBridgeVisionTab" },
|
||||
{ id: "audio", labelKey: "modalityBridgeAudioTab" },
|
||||
{ id: "video", labelKey: "modalityBridgeVideoTab" },
|
||||
];
|
||||
|
||||
function ModalityBridgePageContent() {
|
||||
const t = useTranslations("settings");
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const activeTab = useMemo<TabId>(() => {
|
||||
const requested = searchParams.get("tab") as TabId | null;
|
||||
return requested && TABS.some((tab) => tab.id === requested) ? requested : "vision";
|
||||
}, [searchParams]);
|
||||
|
||||
const handleTabChange = (tab: TabId) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("tab", tab);
|
||||
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-text-muted">{t("modalityBridgeIntro")}</p>
|
||||
<div
|
||||
className="flex gap-1 overflow-x-auto border-b border-border"
|
||||
role="tablist"
|
||||
aria-label={t("modalityBridgeSubTabsAria")}
|
||||
>
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab.id}
|
||||
aria-controls="modality-bridge-tabpanel"
|
||||
onClick={() => handleTabChange(tab.id)}
|
||||
className={`whitespace-nowrap border-b-2 px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-text-muted hover:text-text"
|
||||
}`}
|
||||
>
|
||||
{t(tab.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div id="modality-bridge-tabpanel" role="tabpanel">
|
||||
{activeTab === "vision" && <ModalityBridgeVisionTab />}
|
||||
{activeTab === "audio" && (
|
||||
<ModalityBridgeComingSoonTab bodyKey="modalityBridgeAudioComingSoon" />
|
||||
)}
|
||||
{activeTab === "video" && (
|
||||
<ModalityBridgeComingSoonTab bodyKey="modalityBridgeVideoComingSoon" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ModalityBridgePage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ModalityBridgePageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ const LEGACY_TAB_ROUTES: Record<string, string> = {
|
||||
"feature-flags": "/dashboard/settings/feature-flags",
|
||||
cache: "/dashboard/settings/cache",
|
||||
general: "/dashboard/settings/general",
|
||||
modalityBridge: "/dashboard/settings/modality-bridge",
|
||||
"modality-bridge": "/dashboard/settings/modality-bridge",
|
||||
resilience: "/dashboard/settings/resilience",
|
||||
routing: "/dashboard/settings/routing",
|
||||
security: "/dashboard/settings/security",
|
||||
|
||||
@@ -20,7 +20,142 @@ function resolveProviderSlug(connection: Record<string, unknown> | null): string
|
||||
return "";
|
||||
}
|
||||
|
||||
// ─── POST: Start login flow ────────────────────────────────────────────────
|
||||
function isAdobeFireflyProvider(
|
||||
connection: { provider?: unknown } | null,
|
||||
providerSlug: string
|
||||
): boolean {
|
||||
const raw = String(connection?.provider || "").trim();
|
||||
return ADOBE_FIREFLY_SLUGS.has(raw) || ADOBE_FIREFLY_SLUGS.has(providerSlug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist JWT + Cookie the way desktop clients (and generate) expect:
|
||||
* multi-line api_key, plus camelCase providerSpecificData for updateProviderConnection.
|
||||
*/
|
||||
async function persistAdobeFireflyCredentials(
|
||||
connectionId: string,
|
||||
opts: {
|
||||
accessToken?: string;
|
||||
cookie?: string;
|
||||
account?: string;
|
||||
arpSessionId?: string;
|
||||
}
|
||||
): Promise<{
|
||||
accessToken: string;
|
||||
cookie: string;
|
||||
credential: string;
|
||||
account: string;
|
||||
}> {
|
||||
const accessToken = String(opts.accessToken || "").trim();
|
||||
const cookie = String(opts.cookie || "").trim();
|
||||
const account = String(opts.account || "").trim();
|
||||
const credential =
|
||||
accessToken && cookie
|
||||
? `${accessToken}\n${cookie}`
|
||||
: accessToken ||
|
||||
cookie ||
|
||||
JSON.stringify({
|
||||
mode: "browser-profile",
|
||||
account,
|
||||
signedInAt: Date.now(),
|
||||
});
|
||||
|
||||
const marker = {
|
||||
mode: "browser-profile",
|
||||
account,
|
||||
signedInAt: Date.now(),
|
||||
arpSessionId: String(opts.arpSessionId || ""),
|
||||
};
|
||||
|
||||
try {
|
||||
// camelCase only — updateProviderConnection / encryptConnectionFields read apiKey +
|
||||
// providerSpecificData (snake_case keys are silently ignored and never persisted).
|
||||
await updateProviderConnection(connectionId, {
|
||||
apiKey: credential,
|
||||
providerSpecificData: {
|
||||
...marker,
|
||||
cookie: cookie || credential,
|
||||
access_token: accessToken || undefined,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
/* non-fatal — return credentials to the host app either way */
|
||||
}
|
||||
|
||||
return { accessToken, cookie, credential, account };
|
||||
}
|
||||
|
||||
function adobeFireflySuccessResponse(data: {
|
||||
accessToken: string;
|
||||
cookie: string;
|
||||
credential: string;
|
||||
account: string;
|
||||
arpSessionId?: string;
|
||||
via: "pure-cdp";
|
||||
}): NextResponse {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
account: data.account || undefined,
|
||||
accessToken: data.accessToken || undefined,
|
||||
cookie: data.cookie || undefined,
|
||||
arpSessionId: data.arpSessionId || undefined,
|
||||
credential: data.credential,
|
||||
credentials: {
|
||||
access_token: data.accessToken || undefined,
|
||||
cookie: data.cookie || undefined,
|
||||
},
|
||||
via: data.via,
|
||||
persisted: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adobe Firefly browser sign-in:
|
||||
* pure system Chrome/Edge CDP only (packaged-safe, no Playwright/browser bundle).
|
||||
*/
|
||||
async function loginAdobeFirefly(
|
||||
connectionId: string,
|
||||
body: { timeout?: unknown; freshSession?: unknown }
|
||||
): Promise<NextResponse> {
|
||||
const timeout = typeof body.timeout === "number" ? body.timeout : undefined;
|
||||
const freshSession = typeof body.freshSession === "boolean" ? body.freshSession : true;
|
||||
|
||||
// Pure system-browser CDP is the packaged-safe implementation. Do not open a second browser
|
||||
// after failure: it creates ambiguous success/error races and the packaged runtime has no
|
||||
// reliable Playwright browser bundle.
|
||||
// startAdobeFireflyBrowserLogin always kills its Chrome tree in `finally` (no orphans).
|
||||
try {
|
||||
const { startAdobeFireflyBrowserLogin } =
|
||||
await import("@omniroute/open-sse/services/adobeFireflyBrowserLogin.ts");
|
||||
const pure = await startAdobeFireflyBrowserLogin(timeout, {
|
||||
sessionKey: connectionId,
|
||||
freshSession,
|
||||
});
|
||||
if (pure.success && pure.credentials?.accessToken) {
|
||||
const persisted = await persistAdobeFireflyCredentials(connectionId, {
|
||||
accessToken: pure.credentials.accessToken,
|
||||
cookie: pure.credentials.cookie,
|
||||
account: pure.account,
|
||||
});
|
||||
return adobeFireflySuccessResponse({
|
||||
...persisted,
|
||||
via: "pure-cdp",
|
||||
});
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: pure.error || "Adobe Firefly sign-in did not capture an authenticated IMS JWT.",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : err);
|
||||
return NextResponse.json({ success: false, error: msg }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// --- POST: Start login flow -------------------------------------------------
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
@@ -35,69 +170,35 @@ export async function POST(
|
||||
return NextResponse.json({ success: false, error: "Provider not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const timeout = typeof body.timeout === "number" ? body.timeout : undefined;
|
||||
const body = (await req.json().catch(() => ({}))) as {
|
||||
timeout?: unknown;
|
||||
freshSession?: unknown;
|
||||
};
|
||||
const providerSlug = resolveProviderSlug(provider as Record<string, unknown>);
|
||||
|
||||
try {
|
||||
// Adobe Firefly is special: the IMS JWT is only ever in the Authorization
|
||||
// header of firefly-3p.ff.adobe.io XHRs (never cookies/localStorage), so
|
||||
// the generic cookie-extraction service cannot capture it. Use a dedicated
|
||||
// Playwright service that intercepts that request instead.
|
||||
if (ADOBE_FIREFLY_SLUGS.has(providerSlug)) {
|
||||
const { startAdobeFireflyBrowserLogin } =
|
||||
await import("@omniroute/open-sse/services/adobeFireflyBrowserLogin.ts");
|
||||
const fireflyResult = await startAdobeFireflyBrowserLogin(timeout);
|
||||
|
||||
if (fireflyResult.success && fireflyResult.credentials) {
|
||||
const credentials = fireflyResult.credentials;
|
||||
try {
|
||||
// Store the JWT in apiKey (where resolveAdobeAccessToken looks first)
|
||||
// and the cookie + access_token in providerSpecificData (camelCase —
|
||||
// updateProviderConnection ignores snake_case keys).
|
||||
const providerSpecificData: Record<string, string> = {};
|
||||
if (credentials.accessToken) {
|
||||
providerSpecificData.access_token = credentials.accessToken;
|
||||
}
|
||||
if (credentials.cookie) {
|
||||
providerSpecificData.cookie = credentials.cookie;
|
||||
}
|
||||
|
||||
await updateProviderConnection(id, {
|
||||
apiKey: credentials.accessToken || "",
|
||||
providerSpecificData,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
accessToken: credentials.accessToken || "",
|
||||
cookie: credentials.cookie || "",
|
||||
account: fireflyResult.account || "",
|
||||
credentials: providerSpecificData,
|
||||
persisted: true,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : err);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Extracted but failed to persist: ${msg}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Adobe Firefly: dedicated JWT capture (never cookies/localStorage alone).
|
||||
if (isAdobeFireflyProvider(provider as { provider?: unknown }, providerSlug)) {
|
||||
try {
|
||||
return await loginAdobeFirefly(id, body);
|
||||
} catch (err) {
|
||||
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : err);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: fireflyResult.error || "Adobe Firefly sign-in failed" },
|
||||
{ status: 400 }
|
||||
{ success: false, error: `Adobe Firefly sign-in error: ${msg}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Generic web-cookie path: pass the provider SLUG (not the DB id) so
|
||||
// TOKEN_EXTRACTION_CONFIGS can find the extraction config.
|
||||
// Bug: the previous code passed `id` (connection UUID), so the lookup always
|
||||
// missed and returned "No extraction config" without launching a browser.
|
||||
const { inAppLoginService } = await import("@omniroute/open-sse/services/inAppLoginService.ts");
|
||||
|
||||
const result = await inAppLoginService.startLogin(providerSlug || id, { timeout });
|
||||
const result = await inAppLoginService.startLogin(providerSlug || id, {
|
||||
timeout: typeof body.timeout === "number" ? body.timeout : undefined,
|
||||
});
|
||||
|
||||
// Persist credentials if extraction succeeded
|
||||
if (result.success && result.credentials) {
|
||||
|
||||
246
src/app/api/resilience/connections/route.ts
Normal file
246
src/app/api/resilience/connections/route.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getRawProviderConnections, getProviderConnectionsCount } from "@/lib/db/providers";
|
||||
import { getAllCircuitBreakerStatuses } from "@/shared/utils/circuitBreaker";
|
||||
import { resolveProviderId } from "@/shared/constants/providers";
|
||||
import { TERMINAL_CONNECTION_STATUSES } from "@/lib/quota/connectionRecovery";
|
||||
import { sanitizeErrorMessage, buildErrorBody } from "@omniroute/open-sse/utils/error";
|
||||
import {
|
||||
getAllModelLockouts,
|
||||
cooldownUntilMs,
|
||||
type ModelLockoutInfo,
|
||||
} from "@omniroute/open-sse/services/accountFallback";
|
||||
import type {
|
||||
ResilienceConnectionsResponse,
|
||||
ConnectionState,
|
||||
BreakerWithHistory,
|
||||
} from "@/types/resilience";
|
||||
|
||||
// Explicit column whitelist -- getRawProviderConnections() DEFAULTS TO SELECT *,
|
||||
// so passing columns is MANDATORY to avoid leaking api_key, access_token,
|
||||
// refresh_token, id_token, email, scope, project_id, provider_specific_data, last_error.
|
||||
const CONNECTION_COLUMNS: string[] = [
|
||||
"id",
|
||||
"provider",
|
||||
"name",
|
||||
"auth_type",
|
||||
"priority",
|
||||
"is_active",
|
||||
"test_status",
|
||||
"error_code",
|
||||
"last_error_type",
|
||||
"last_error_at",
|
||||
"backoff_level",
|
||||
"rate_limited_until",
|
||||
"last_used_at",
|
||||
];
|
||||
|
||||
const CONNECTION_LIMIT = 1000; // shared with UI cap indicator
|
||||
|
||||
const querySchema = z.object({
|
||||
windowMs: z.preprocess(
|
||||
(v) => (typeof v === "string" && v.trim() === "" ? undefined : v),
|
||||
z.coerce.number().int().min(0).max(86400000).default(3600000)
|
||||
),
|
||||
provider: z.string().trim().min(1).max(64).optional(),
|
||||
});
|
||||
|
||||
function categorizeErrorCode(code: string | number): string {
|
||||
const s = String(code).toLowerCase();
|
||||
if (s.includes("rate") || s.includes("429") || s.includes("quota")) return "rate_limit";
|
||||
if (s.includes("auth") || s.includes("401") || s.includes("403") || s.includes("key"))
|
||||
return "auth";
|
||||
if (s.includes("500") || s.includes("502") || s.includes("503") || s.includes("504"))
|
||||
return "server";
|
||||
if (s.includes("404") || s.includes("not_found") || s.includes("model")) return "not_found";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function toConnectionState(
|
||||
row: Record<string, unknown>,
|
||||
breakersMap: Map<string, BreakerWithHistory>,
|
||||
lockoutsMap: Map<string, ModelLockoutInfo[]>,
|
||||
now: number // server timestamp captured before fetch (avoids drift)
|
||||
): ConnectionState {
|
||||
// getRawProviderConnections returns camelCase keys (via rowToCamel)
|
||||
const provider = String(row.provider ?? "");
|
||||
const breaker = breakersMap.get(resolveProviderId(provider)) ?? null;
|
||||
const lockouts = lockoutsMap.get(String(row.id ?? "")) ?? [];
|
||||
const testStatus = row.testStatus ? String(row.testStatus).trim().toLowerCase() : null; // normalize to match TERMINAL_CONNECTION_STATUSES
|
||||
const rateLimitedUntil = row.rateLimitedUntil ? String(row.rateLimitedUntil) : null;
|
||||
// cooldownUntilMs() handles both ISO strings and numeric epoch TEXT (#3954)
|
||||
const rawCooldown = rateLimitedUntil ? cooldownUntilMs(rateLimitedUntil) - now : 0;
|
||||
const cooldownRemainingMs = Number.isFinite(rawCooldown) ? Math.max(0, rawCooldown) : 0;
|
||||
|
||||
// Derive connection status for UI badge (terminal states take priority over cooldown)
|
||||
let connectionStatus: ConnectionState["connectionStatus"] = "healthy";
|
||||
if (testStatus && TERMINAL_CONNECTION_STATUSES.has(testStatus)) {
|
||||
connectionStatus = "terminal"; // permanent unavailability takes priority
|
||||
} else if (breaker?.state === "OPEN") {
|
||||
connectionStatus = "circuit_open";
|
||||
} else if (cooldownRemainingMs > 0) {
|
||||
connectionStatus = "cooling_down";
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(row.id ?? ""),
|
||||
provider,
|
||||
name: row.name != null && row.name !== "" ? String(row.name) : null,
|
||||
authType: String(row.authType ?? ""),
|
||||
priority: Number(row.priority ?? 0),
|
||||
isActive: Boolean(row.isActive),
|
||||
connectionStatus,
|
||||
rateLimitedUntil,
|
||||
backoffLevel: Number(row.backoffLevel ?? 0),
|
||||
testStatus,
|
||||
lastErrorType: row.lastErrorType ? String(row.lastErrorType) : null,
|
||||
lastErrorAt: row.lastErrorAt ? String(row.lastErrorAt) : null,
|
||||
errorCode: row.errorCode != null ? categorizeErrorCode(String(row.errorCode)) : null, // coarse category, not raw upstream code
|
||||
lastUsedAt: row.lastUsedAt ? String(row.lastUsedAt) : null,
|
||||
cooldownRemainingMs,
|
||||
isCoolingDown: cooldownRemainingMs > 0,
|
||||
breaker: breaker
|
||||
? {
|
||||
state: breaker.state,
|
||||
failureCount: breaker.failureCount,
|
||||
retryAfterMs: breaker.retryAfterMs,
|
||||
lastFailureKind: breaker.lastFailureKind,
|
||||
}
|
||||
: null,
|
||||
lockouts: lockouts.map((l) => ({
|
||||
model: l.model,
|
||||
reason: l.reason,
|
||||
remainingMs: l.remainingMs,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const params = querySchema.safeParse(Object.fromEntries(new URL(req.url).searchParams));
|
||||
if (!params.success) {
|
||||
return NextResponse.json(
|
||||
buildErrorBody(400, params.error.issues[0]?.message ?? "Invalid query parameters"),
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const { windowMs, provider } = params.data;
|
||||
|
||||
const degraded: string[] = [];
|
||||
|
||||
// Fetch all three sources independently (partial degradation)
|
||||
let rawConnections: Record<string, unknown>[] = [];
|
||||
try {
|
||||
rawConnections = await getRawProviderConnections(
|
||||
{ provider },
|
||||
CONNECTION_LIMIT,
|
||||
undefined,
|
||||
CONNECTION_COLUMNS
|
||||
);
|
||||
} catch (err) {
|
||||
degraded.push("database");
|
||||
console.error("[API] resilience/connections database error:", err);
|
||||
}
|
||||
|
||||
// NOTE: getAllCircuitBreakerStatuses() calls getStatus() internally. If a single
|
||||
// getStatus() throws (e.g., onStateChange callback error), the entire function
|
||||
// throws before reaching our loop. This is an accepted limitation - per-item
|
||||
// fault tolerance is not possible with the current getAllCircuitBreakerStatuses()
|
||||
// API. The outer try/catch handles this case.
|
||||
let breakers: BreakerWithHistory[] = [];
|
||||
try {
|
||||
const allStatuses = getAllCircuitBreakerStatuses();
|
||||
breakers = allStatuses.map((status) => ({
|
||||
name: status.name,
|
||||
state: status.state,
|
||||
failureCount: status.failureCount,
|
||||
retryAfterMs: status.retryAfterMs,
|
||||
lastFailureKind: status.lastFailureKind,
|
||||
transitionHistory: status.transitionHistory ?? [],
|
||||
}));
|
||||
} catch (err) {
|
||||
degraded.push("circuitBreaker");
|
||||
console.error("[API] resilience/connections breaker module error:", err);
|
||||
}
|
||||
|
||||
// Capture window timestamps AFTER source fetches complete (includes lazy recovery transitions)
|
||||
const now = Date.now();
|
||||
const sinceMs = windowMs ? now - windowMs : 0;
|
||||
// Apply window filter to all breakers.
|
||||
// Both `now` and breaker transition timestamps come from the same Node.js process,
|
||||
// so clock skew is negligible -- no future-buffer needed.
|
||||
breakers = breakers.map((b) => ({
|
||||
...b,
|
||||
transitionHistory:
|
||||
windowMs > 0
|
||||
? b.transitionHistory.filter((tr) => tr.timestamp >= sinceMs && tr.timestamp <= now)
|
||||
: b.transitionHistory,
|
||||
}));
|
||||
|
||||
let lockouts: ModelLockoutInfo[] = [];
|
||||
try {
|
||||
lockouts = getAllModelLockouts();
|
||||
} catch (err) {
|
||||
degraded.push("modelLockouts");
|
||||
console.error("[API] resilience/connections lockout module error:", err);
|
||||
}
|
||||
|
||||
// Join all sources AFTER all fetches complete (so toConnectionState has full context)
|
||||
// NOTE: If multiple breaker instances resolve to the same canonical provider (e.g., alias + canonical),
|
||||
// the last one wins in the map. This is an accepted limitation -- connections typically have one
|
||||
// active breaker per provider. The top-level breakers[] array preserves all instances.
|
||||
const breakersMap = new Map(breakers.map((b) => [resolveProviderId(b.name), b]));
|
||||
const lockoutsMap = new Map<string, ModelLockoutInfo[]>();
|
||||
for (const l of lockouts) {
|
||||
const arr = lockoutsMap.get(l.connectionId) ?? [];
|
||||
arr.push(l);
|
||||
lockoutsMap.set(l.connectionId, arr);
|
||||
}
|
||||
const connections = rawConnections.map((row) =>
|
||||
toConnectionState(row, breakersMap, lockoutsMap, now)
|
||||
);
|
||||
|
||||
// Total count (separate query; falls back to connections.length on failure)
|
||||
let totalConnections = connections.length;
|
||||
let countFailed = false;
|
||||
try {
|
||||
totalConnections = getProviderConnectionsCount({ provider });
|
||||
} catch (err) {
|
||||
// Non-critical: connections.length is acceptable fallback
|
||||
console.error("[API] resilience/connections count error:", err);
|
||||
degraded.push("count"); // surface count degradation for UI transparency
|
||||
countFailed = true;
|
||||
}
|
||||
// When totalConnections > CONNECTION_LIMIT, counts reflect only the first LIMIT rows
|
||||
const coolingDownCount = connections.filter((c) => c.isCoolingDown).length;
|
||||
// Count all non-healthy breaker states (OPEN + HALF_OPEN + DEGRADED) for accurate summary
|
||||
const unhealthyBreakerCount = connections.filter(
|
||||
(c) =>
|
||||
c.breaker?.state === "OPEN" ||
|
||||
c.breaker?.state === "HALF_OPEN" ||
|
||||
c.breaker?.state === "DEGRADED"
|
||||
).length;
|
||||
// Flag indicates counts may be incomplete due to LIMIT capping or count query failure.
|
||||
// When countFailed=true, we returned a limited page and can't verify the true total,
|
||||
// so treat as potentially capped to give the client an honest signal.
|
||||
const countsCapped =
|
||||
totalConnections > CONNECTION_LIMIT ||
|
||||
(countFailed && connections.length === CONNECTION_LIMIT);
|
||||
|
||||
// Assemble window metadata (absolute timestamps)
|
||||
const windowMeta = { sinceMs, untilMs: now, now: now };
|
||||
|
||||
// Assemble response (top-level fields, no `data` wrapper -- matches ResilienceConnectionsResponse)
|
||||
const response: ResilienceConnectionsResponse = {
|
||||
connections,
|
||||
breakers,
|
||||
window: windowMeta,
|
||||
meta: { totalConnections, coolingDownCount, unhealthyBreakerCount, countsCapped, degraded },
|
||||
};
|
||||
return NextResponse.json(response);
|
||||
} catch (err) {
|
||||
console.error("[API] resilience/connections unexpected error:", err);
|
||||
return NextResponse.json(buildErrorBody(500, sanitizeErrorMessage(err)), { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { extractChatMessage, sendTelegramMessage } from "@/lib/telegram/botApi";
|
||||
import { getTelegramBotToken, isTelegramEnabled } from "@/lib/telegram/config";
|
||||
import { verifyInitData, parseInitData } from "@/lib/telegram/initData";
|
||||
import { proxyChat } from "@/lib/telegram/chatProxy";
|
||||
import { formatTelegramGatewayError } from "@/lib/telegram/errorMessage";
|
||||
import { resolveOmniRouteBaseUrl } from "@/shared/utils/resolveOmniRouteBaseUrl";
|
||||
|
||||
/**
|
||||
@@ -151,7 +152,7 @@ async function handleAndReply(chatId: number, text: string, messageId?: number):
|
||||
try {
|
||||
await sendTelegramMessage({
|
||||
chat_id: chatId,
|
||||
text: `⚠️ Gateway error: ${(err as Error)?.message || "unknown"}`,
|
||||
text: formatTelegramGatewayError(err),
|
||||
});
|
||||
} catch {
|
||||
// Nothing more we can do — the reply channel is down.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user