diff --git a/.env.example b/.env.example index aee4c12a0d..e73f3203f1 100644 --- a/.env.example +++ b/.env.example @@ -737,6 +737,12 @@ NEXT_PUBLIC_CLOUD_URL= ENABLE_SOCKS5_PROXY=true NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true +# Opt-in feature flag (default off; a dashboard DB override wins over this value): proxy pools +# and per-account rotation stop re-serving a member that just failed (TCP probe refused, or a +# 429 received through it) for a period that doubles on each repeat, up to a cap. No proxy +# status is written. "true" (or 1, yes) enables it; unset keeps plain selection. +# PROXY_SKIP_RECENTLY_FAILED=false + # Standard proxy variables (lowercase variants also supported). # HTTP_PROXY=http://127.0.0.1:7890 # HTTPS_PROXY=http://127.0.0.1:7890 @@ -1686,6 +1692,8 @@ CURSOR_USER_AGENT="Cursor/3.4" # ── TLS client (wreq-js fingerprint proxy) ── # TLS_CLIENT_TIMEOUT_MS=600000 # Inherits from FETCH_TIMEOUT_MS by default # TLS_FIRST_BYTE_WATCHDOG_MS=10000 # #12656: bounds time-to-first-byte on the wreq body (0 disables) +# OPENCODE_RESPONSES_STALL_ROTATION=false # #13484 feature flag (Settings → Feature Flags wins): rotate once when a streamed Responses reply stalls before its first byte +# RESPONSES_FIRST_BYTE_TIMEOUT_MS=15000 # #13484: OpenCode Responses first-byte window, only used when the OPENCODE_RESPONSES_STALL_ROTATION flag is on (0 disables) # ── API Bridge (/v1 proxy server) ── # API_BRIDGE_PROXY_TIMEOUT_MS=600000 # Proxy hop timeout (default: 10min) @@ -2231,6 +2239,11 @@ APP_LOG_TO_FILE=true # proxy — only the operator sets active/inactive (a flaky probe must not strand an # assigned proxy; #6246). Set "true" to restore the legacy test-and-set behaviour. # PROXY_HEALTH_AUTO_DEACTIVATE=false +# Opt-in feature flag (default off; a dashboard DB override wins over this value): show, +# under a proxy pool in the dashboard, how many observed egress IPs served its members over +# the last 24 h and how many connections used them (read-only, computed from the proxy log, +# never used for routing). "true" (or 1, yes) enables it. +# PROXY_POOL_EGRESS_OBSERVATION=false # Allow OAuth and provider validation flows to bypass a pinned proxy and connect # directly when proxy reachability pre-checks fail. Default: false. diff --git a/.gitleaks.toml b/.gitleaks.toml index 86e5f49649..3768394fbe 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -97,4 +97,11 @@ # credential; the generic-api-key rule flags the long hyphenated string. '''omniroute-cheaperinference-sponsor-banner-dismissed-v\d+''', '''SunbreakWebUI1''', + # Uzbek dashboard catalog (#13727, src/i18n/messages/uz.json `outputTokenDesc`): + # "Yakunlash/javob tokenlari" = "completion/response tokens". The rule reads the + # `...TokenDesc` key as a token assignment and the translated words as its value. + '''Yakunlash/javob''', + # Feature-flag id from #13439 (src/shared/constants/featureFlagDefinitions.ts): + # `key: "PROTECTED_PRIORITY_INFRA_502_ENABLED"` is a flag name, not a credential. + '''PROTECTED_PRIORITY_INFRA_502_ENABLED''', ] diff --git a/AGENTS.md b/AGENTS.md index 947377b8d1..69e81b7ab3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,7 @@ Repository map and Reference Documentation sections below. | Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) | | Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions | | Services | `open-sse/services/` | Combo routing, rate limits, caching, etc | -| Database | `src/lib/db/` | SQLite domain modules (175 migrations) | +| Database | `src/lib/db/` | SQLite domain modules (176 migrations) | | Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic | | MCP Server | `open-sse/mcp-server/` | 110 tools (45 canonical + memory/skill/GitHub/pool/gamification/plugin/Notion/Obsidian/local-corpus/RTK modules), 3 transports (stdio / SSE / Streamable HTTP), 33 scopes | | A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol | diff --git a/README.md b/README.md index 3208e32b04..edca9ae592 100644 --- a/README.md +++ b/README.md @@ -1268,7 +1268,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi RuntimeNode.js 22.x / 24.x LTS — >=22.22.2 <23 || >=24.0.0 <27 LanguageTypeScript 6.0 — 100% TypeScript across src/ and open-sse/ (zero any in core since v2.0) FrameworkNext.js 16 + React 19 + Tailwind CSS 4 - Databasebetter-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 122 domain modules, 175 migrations + Databasebetter-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 122 domain modules, 176 migrations MemorySQLite FTS5 full-text + int8-quantized vector embeddings, typed decay SchemasZod 4 — MCP tool I/O validation + API contracts ProtocolsMCP (stdio / HTTP / SSE) + A2A v0.3 (JSON-RPC 2.0 + SSE) diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index 3ed2f2dbcf..4b37832ba3 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -256,6 +256,7 @@ "max_restarts": "Max crash restarts within 30s before giving up (default: 2)", "tray": "Start in the system tray (desktop only, opt-in)", "no_tray": "Disable system tray icon", + "ready_timeout": "Readiness probe timeout in ms (also OMNIROUTE_READY_TIMEOUT_MS, default 60000)", "tls_cert": "Path to a TLS certificate (PEM) to serve HTTPS (also OMNIROUTE_TLS_CERT)", "tls_key": "Path to the TLS private key (PEM) to serve HTTPS (also OMNIROUTE_TLS_KEY)" }, diff --git a/bin/cli/locales/zh-CN.json b/bin/cli/locales/zh-CN.json index 31be9d4c16..d3f1ab718b 100644 --- a/bin/cli/locales/zh-CN.json +++ b/bin/cli/locales/zh-CN.json @@ -254,6 +254,7 @@ "max_restarts": "30 秒内的最大崩溃重启次数(默认:2)", "tray": "显示系统托盘图标(仅桌面,选择加入)", "no_tray": "禁用系统托盘图标", + "ready_timeout": "就绪探测超时(毫秒)(也可用 OMNIROUTE_READY_TIMEOUT_MS,默认 60000)", "tls_cert": "用于提供 HTTPS 服务的 TLS 证书(PEM)路径(也可用 OMNIROUTE_TLS_CERT)", "tls_key": "用于提供 HTTPS 服务的 TLS 私钥(PEM)路径(也可用 OMNIROUTE_TLS_KEY)" }, diff --git a/bin/cli/locales/zh-TW.json b/bin/cli/locales/zh-TW.json index fa7ca866b8..f4c9c39e10 100644 --- a/bin/cli/locales/zh-TW.json +++ b/bin/cli/locales/zh-TW.json @@ -254,6 +254,7 @@ "max_restarts": "30 秒內的最大崩潰重啟次數(預設:2)", "tray": "顯示系統托盤圖示(僅桌面,選擇加入)", "no_tray": "停用系統托盤圖示", + "ready_timeout": "就緒探測逾時(毫秒)(也可用 OMNIROUTE_READY_TIMEOUT_MS,預設 60000)", "tls_cert": "用於提供 HTTPS 服務的 TLS 憑證(PEM)路徑(也可用 OMNIROUTE_TLS_CERT)", "tls_key": "用於提供 HTTPS 服務的 TLS 私鑰(PEM)路徑(也可用 OMNIROUTE_TLS_KEY)" }, diff --git a/changelog.d/features/13578-proxy-skip-recently-failed.md b/changelog.d/features/13578-proxy-skip-recently-failed.md new file mode 100644 index 0000000000..96e7c20147 --- /dev/null +++ b/changelog.d/features/13578-proxy-skip-recently-failed.md @@ -0,0 +1 @@ +- **feat(proxies):** proxy pools and opencode's per-account rotation stop re-serving a proxy that just failed (refused TCP probe, or a 429 through it) for a period that doubles on each repeat up to a cap, without writing any proxy status; with every candidate set aside the choice is unchanged. Opt-in via the `PROXY_SKIP_RECENTLY_FAILED` feature flag (default off: selection unchanged) ([#13578](https://github.com/diegosouzapw/OmniRoute/pull/13578)) — thanks @maxmad64bis diff --git a/changelog.d/features/13580-proxy-log-upstream-status.md b/changelog.d/features/13580-proxy-log-upstream-status.md new file mode 100644 index 0000000000..bf3e5ea683 --- /dev/null +++ b/changelog.d/features/13580-proxy-log-upstream-status.md @@ -0,0 +1 @@ +- **feat(proxy-logs):** proxy log rows keep the HTTP status the provider actually returned (`upstream_status`, null when no response arrived), so a throttled egress IP (429), a refused one (403) and a provider outage (500) are no longer the same "error" line, and a 429 generated locally is no longer mistaken for one from the provider ([#13580](https://github.com/diegosouzapw/OmniRoute/pull/13580)) — thanks @maxmad64bis diff --git a/changelog.d/features/13581-pool-egress-observation.md b/changelog.d/features/13581-pool-egress-observation.md new file mode 100644 index 0000000000..c535c5f2d5 --- /dev/null +++ b/changelog.d/features/13581-pool-egress-observation.md @@ -0,0 +1 @@ +- **feat(proxies):** the proxy pool editor shows, for the last 24 h, how many distinct egress IPs actually served the pool's members, how many connections went through them and the most seen behind one IP, read from the proxy log through a separate route so it can never break the pool screen; opt-in with the `PROXY_POOL_EGRESS_OBSERVATION` feature flag (default off) ([#13581](https://github.com/diegosouzapw/OmniRoute/pull/13581)) — thanks @maxmad64bis diff --git a/changelog.d/features/13602-pool-skips-refused-member.md b/changelog.d/features/13602-pool-skips-refused-member.md new file mode 100644 index 0000000000..2c96150e63 --- /dev/null +++ b/changelog.d/features/13602-pool-skips-refused-member.md @@ -0,0 +1 @@ +- **feat(proxies):** a proxy pool stops re-serving a member the provider just refused through it and tries another member instead, reusing the existing skip cooldown; a later success through the member clears it. Opt-in with the `PROXY_SKIP_RECENTLY_FAILED` feature flag (default off: pool selection unchanged) ([#13602](https://github.com/diegosouzapw/OmniRoute/pull/13602)) — thanks @maxmad64bis diff --git a/changelog.d/fixes/0000-files-batches-ownership-null-owner-anonymous-list.md b/changelog.d/fixes/0000-files-batches-ownership-null-owner-anonymous-list.md new file mode 100644 index 0000000000..a6f4d3f3b9 --- /dev/null +++ b/changelog.d/fixes/0000-files-batches-ownership-null-owner-anonymous-list.md @@ -0,0 +1 @@ +- **fix(api):** `/v1/files` and `/v1/batches` now enforce one ownership rule everywhere — a dashboard session is the instance operator, an API key acts on its own records only, and a record with no owner is denied to every non-session caller. Previously a file or batch whose `api_key_id` was null (a dashboard-session or anonymous upload, or a batch artifact inheriting one) could be read, downloaded, deleted, cancelled or used as a batch input by any other key or by an unauthenticated caller (GHSA-2jm2-mpx8-6523), and `GET /v1/files` / `GET /v1/batches` returned every tenant's records to an anonymous or invalid-bearer caller under the default `REQUIRE_API_KEY=false` (GHSA-m3hp-hq9g-fpmv) — both lists now fail closed with a `401`, and only a dashboard session without a key reads the whole instance. The same shared rule lets the dashboard cancel any batch, not just unowned ones. Behaviour change: the anonymous upload → batch → download flow no longer works without an API key, since a null owner cannot be attributed. Subsumes [#13683](https://github.com/diegosouzapw/OmniRoute/pull/13683) — thanks @hartmark diff --git a/changelog.d/fixes/0000-jwt-bootstrap-chain-real-peer-loopback.md b/changelog.d/fixes/0000-jwt-bootstrap-chain-real-peer-loopback.md new file mode 100644 index 0000000000..e8cd375ba7 --- /dev/null +++ b/changelog.d/fixes/0000-jwt-bootstrap-chain-real-peer-loopback.md @@ -0,0 +1 @@ +- **fix(auth):** closed the JWT_SECRET bootstrap chain (GHSA-7pq4-8pvv-rx7r). The fresh-install bootstrap gate in `isAuthRequired()` now decides "loopback" from the trusted peer — the token-stamped real TCP peer the custom server writes, the pipeline's own locality verdict, or a real socket — and never from the client-controlled `Host` / `nextUrl.hostname` whenever a stamping server is in front (every supported runtime), so `Host: localhost` from a remote address no longer opens the window; the anonymous first-password write (`POST /api/settings/require-login`) is under the same loopback constraint instead of being open to every network peer, and `managementPolicy` hands its `peerContext` verdict down explicitly. `/api/settings/obsidian` (incl. `/webdav`, which mints reusable WebDAV Basic credentials for a caller-chosen root served before Next.js) joined `ALWAYS_PROTECTED_API_PATHS`, and `enableObsidianVaultSync()` refuses a vault that is, sits inside, or contains the data directory (realpath-resolved), so the WebDAV file service can no longer be pointed at `server.env` / `storage.sqlite` diff --git a/changelog.d/fixes/13153-early-eof-sibling-failover.md b/changelog.d/fixes/13153-early-eof-sibling-failover.md new file mode 100644 index 0000000000..b55cc45ea6 --- /dev/null +++ b/changelog.d/fixes/13153-early-eof-sibling-failover.md @@ -0,0 +1 @@ +- **fix(sse):** fail over once to a sibling connection on stream early EOF (the original `STREAM_EARLY_EOF` 502 is kept when no sibling can serve the request), gated behind `STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED` (default off) ([#13153](https://github.com/diegosouzapw/OmniRoute/pull/13153)) — thanks @maxmad64bis diff --git a/changelog.d/fixes/13218-wal-busy-counter.md b/changelog.d/fixes/13218-wal-busy-counter.md new file mode 100644 index 0000000000..7b82d5a484 --- /dev/null +++ b/changelog.d/fixes/13218-wal-busy-counter.md @@ -0,0 +1 @@ +- **fix(db):** the WAL checkpoint busy counter reported by `/api/monitoring/health` now survives restarts — busy checkpoints are counted in memory and persisted from the next clean maintenance tick or at shutdown, never with a write while the database is contended ([#13218](https://github.com/diegosouzapw/OmniRoute/pull/13218)) — thanks @maxmad64bis diff --git a/changelog.d/fixes/13280-bounded-routing-caches.md b/changelog.d/fixes/13280-bounded-routing-caches.md new file mode 100644 index 0000000000..639f25ecce --- /dev/null +++ b/changelog.d/fixes/13280-bounded-routing-caches.md @@ -0,0 +1 @@ +- **fix(quota):** in-process routing and quota caches (quality tracker, saturation and rate-limit header caches, quota-fetcher cache, learned rate limits, account buckets) are now size-bounded through one shared `boundedMap` — caps sit far above normal deployments, evictions are logged once per minute per cache instead of per entry, and state whose loss would change routing (live saturated quota buckets, evaluator quality scores) is never evicted ([#13280](https://github.com/diegosouzapw/OmniRoute/pull/13280)) — thanks @maxmad64bis diff --git a/changelog.d/fixes/13436-client-bundle-server-only-guard.md b/changelog.d/fixes/13436-client-bundle-server-only-guard.md new file mode 100644 index 0000000000..7b8cf30097 --- /dev/null +++ b/changelog.d/fixes/13436-client-bundle-server-only-guard.md @@ -0,0 +1 @@ +- **fix(build):** the production build no longer breaks when a client component reaches a server-only module, and the client-bundle guard now discovers server-only modules instead of matching a fixed list ([#13436](https://github.com/diegosouzapw/OmniRoute/pull/13436)) — thanks @maxmad64bis diff --git a/changelog.d/fixes/13439-protected-priority-502.md b/changelog.d/fixes/13439-protected-priority-502.md new file mode 100644 index 0000000000..8d32643b8b --- /dev/null +++ b/changelog.d/fixes/13439-protected-priority-502.md @@ -0,0 +1 @@ +- **fix(combo):** new opt-in flag `PROTECTED_PRIORITY_INFRA_502_ENABLED` (default off): when a priority target marked fallback-only-on-quota-exhaustion stops the combo because its provider circuit breaker is open or a predictive latency check rejected it — causes that are provably not quota — the response is 502 instead of a quota-looking 503; lockout, cooldown, unavailable, exhaustion, credential-gate and concurrency-cap stops keep 503 ([#13439](https://github.com/diegosouzapw/OmniRoute/pull/13439)) — thanks @maxmad64bis diff --git a/changelog.d/fixes/13440-daily-reset-tz.md b/changelog.d/fixes/13440-daily-reset-tz.md new file mode 100644 index 0000000000..7211394453 --- /dev/null +++ b/changelog.d/fixes/13440-daily-reset-tz.md @@ -0,0 +1 @@ +- **fix(resilience):** non-TPD daily-quota cooldowns honor the provider node's configured daily-reset clock (timezone + hour) instead of server midnight, on single-model and combo (priority and round-robin) paths; timezone edits apply without a restart ([#13440](https://github.com/diegosouzapw/OmniRoute/pull/13440)) — thanks @maxmad64bis diff --git a/changelog.d/fixes/13441-error-type-write-guard.md b/changelog.d/fixes/13441-error-type-write-guard.md new file mode 100644 index 0000000000..f7d5f763e1 --- /dev/null +++ b/changelog.d/fixes/13441-error-type-write-guard.md @@ -0,0 +1 @@ +- **fix(call-logs):** the call-log write point validates `error_type` against the versioned vocabulary with a Zod schema and stores `unknown` for any value outside it, so a classifier family that drifts from `ERROR_TYPE_CONTRACT` can never persist free text ([#13441](https://github.com/diegosouzapw/OmniRoute/pull/13441)) — thanks @maxmad64bis diff --git a/changelog.d/fixes/13484-opencode-responses-first-byte-stall.md b/changelog.d/fixes/13484-opencode-responses-first-byte-stall.md new file mode 100644 index 0000000000..0ba03b49d5 --- /dev/null +++ b/changelog.d/fixes/13484-opencode-responses-first-byte-stall.md @@ -0,0 +1 @@ +- **fix(opencode):** opt-in `OPENCODE_RESPONSES_STALL_ROTATION` flag (default off): a streamed Responses reply that sends headers and then nothing is cut after `RESPONSES_FIRST_BYTE_TIMEOUT_MS` (default 15 s) instead of waiting for the stream readiness timeout — the account is cooled down and the request rotates to the next account once (proxied or proxy-less), a second stall fails fast; with the flag off nothing changes ([#13484](https://github.com/diegosouzapw/OmniRoute/pull/13484)) — thanks @maxmad64bis diff --git a/changelog.d/fixes/13498-opencode-user-blocked-rotation.md b/changelog.d/fixes/13498-opencode-user-blocked-rotation.md new file mode 100644 index 0000000000..cd4675a75f --- /dev/null +++ b/changelog.d/fixes/13498-opencode-user-blocked-rotation.md @@ -0,0 +1 @@ +- **fix(sse):** opt-in `OPENCODE_USER_BLOCKED_ROTATION` flag (default off): an opencode 403 or 451 carrying a `user_blocked` refusal cools the refused account down and fails over to the next account at most once per request, cancelling the abandoned response body; with the flag off the refusal is returned unchanged ([#13498](https://github.com/diegosouzapw/OmniRoute/pull/13498)) — thanks @maxmad64bis diff --git a/changelog.d/fixes/13602-i18n-duplicated-flag-description-key.md b/changelog.d/fixes/13602-i18n-duplicated-flag-description-key.md new file mode 100644 index 0000000000..a82492e493 --- /dev/null +++ b/changelog.d/fixes/13602-i18n-duplicated-flag-description-key.md @@ -0,0 +1 @@ +- **fix(i18n):** drop the second copy of `featureFlagProxySkipRecentlyFailedDescription` that the 2026-09-15 batch merges left in 59 dashboard catalogs (a scripted keep-both conflict resolution concatenated the key both PRs carried; `JSON.parse` silently kept the last copy) and the duplicated `ERROR_TYPE_CONTRACT` import in `src/lib/db/callLogStats.ts` (TS2300); adds `tests/unit/i18n-catalogs-no-duplicate-keys.test.ts`, a raw-text guard that fails on any key declared twice in one object of `src/i18n/messages/*.json` or `bin/cli/locales/*.json` ([#13602](https://github.com/diegosouzapw/OmniRoute/pull/13602), [#13641](https://github.com/diegosouzapw/OmniRoute/pull/13641)) diff --git a/changelog.d/fixes/13605-socks-userinfo-decode-guard.md b/changelog.d/fixes/13605-socks-userinfo-decode-guard.md new file mode 100644 index 0000000000..34d94231bc --- /dev/null +++ b/changelog.d/fixes/13605-socks-userinfo-decode-guard.md @@ -0,0 +1 @@ +- **fix(proxy):** proxy credentials holding a literal `%` (e.g. `pa%ss`) no longer break the proxy — HTTP(S) proxies now receive a correctly built `Proxy-Authorization` header instead of undici throwing `URIError`, SOCKS5 proxies get the raw credential, and the proxy registry, subscription import and legacy settings parsers keep the value instead of dropping the entry; correctly percent-encoded credentials decode exactly as before ([#13605](https://github.com/diegosouzapw/OmniRoute/pull/13605)) — thanks @maxmad64bis diff --git a/changelog.d/fixes/13608-blocked-resets-streak.md b/changelog.d/fixes/13608-blocked-resets-streak.md new file mode 100644 index 0000000000..3a866921d9 --- /dev/null +++ b/changelog.d/fixes/13608-blocked-resets-streak.md @@ -0,0 +1 @@ +- **fix(proxy-health):** a target-refused probe (401/403/429) can reset the consecutive-failure streak instead of staying neutral, behind the opt-in `PROXY_HEALTH_BLOCKED_RESETS_STREAK` feature flag (default off: refusals keep the #10654 neutral policy); a relayed 5xx stays inconclusive and a refusal never removes or disables a proxy ([#13608](https://github.com/diegosouzapw/OmniRoute/pull/13608)) — thanks @maxmad64bis diff --git a/changelog.d/fixes/13609-mistral-401-ambiguous-auth.md b/changelog.d/fixes/13609-mistral-401-ambiguous-auth.md new file mode 100644 index 0000000000..58d4b00d59 --- /dev/null +++ b/changelog.d/fixes/13609-mistral-401-ambiguous-auth.md @@ -0,0 +1 @@ +- **fix(providers):** opt-in `MISTRAL_AMBIGUOUS_401_SOFT_LOCKOUT` flag (default off): a bare Mistral 401 with no explicit auth signal (identical for a revoked key and an exhausted quota) cools the connection down instead of parking it as expired, at most 3 times per hour per connection before it parks, so a revoked key still converges; the ambiguity check is now one implementation shared by the connection test and the runtime ([#13609](https://github.com/diegosouzapw/OmniRoute/pull/13609)) — thanks @maxmad64bis diff --git a/changelog.d/fixes/13614-routing-error-guard.md b/changelog.d/fixes/13614-routing-error-guard.md new file mode 100644 index 0000000000..5a8818ea01 --- /dev/null +++ b/changelog.d/fixes/13614-routing-error-guard.md @@ -0,0 +1 @@ +- **fix(routing):** a failed stale-pin (LKGP) clear on the combo fallback path now logs the combo and execution key while staying non-blocking, and a new opt-in `npm run check:routing-error-guard` script (not wired into CI) flags new swallowed catches and unanchored fire-and-forget async on routing paths, with frozen entries keyed by file and catch body instead of line numbers ([#13614](https://github.com/diegosouzapw/OmniRoute/pull/13614)) — thanks @maxmad64bis diff --git a/changelog.d/fixes/13615-opencode-transient-retry-delay.md b/changelog.d/fixes/13615-opencode-transient-retry-delay.md new file mode 100644 index 0000000000..fb389806e6 --- /dev/null +++ b/changelog.d/fixes/13615-opencode-transient-retry-delay.md @@ -0,0 +1 @@ +- **fix(opencode):** opt-in `OPENCODE_TRANSIENT_FAILOVER_BACKOFF` flag (default off): once two consecutive opencode accounts fail with a transient upstream error, the rotation pauses before the next account (1.5 s doubling, capped at 6 s per pause and 10 s per request), releases the failed response body first and stops dispatching if the client disconnects during the pause; with the flag off failover stays immediate ([#13615](https://github.com/diegosouzapw/OmniRoute/pull/13615)) — thanks @maxmad64bis diff --git a/changelog.d/fixes/13633-stream-recovery-toolcall-order.md b/changelog.d/fixes/13633-stream-recovery-toolcall-order.md new file mode 100644 index 0000000000..acbac114e0 --- /dev/null +++ b/changelog.d/fixes/13633-stream-recovery-toolcall-order.md @@ -0,0 +1 @@ +- **fix(stream-recovery):** opt-in `STREAM_RECOVERY_TOOLCALL_ORDER_FIX` (default off) makes mid-stream continuation tool-call safe — a cut stream is never resumed once a tool call was emitted, whether still in flight or already finished with `finish_reason: "tool_calls"` — and closes after one empty continuation instead of spending the whole budget ([#13633](https://github.com/diegosouzapw/OmniRoute/pull/13633)) — thanks @maxmad64bis diff --git a/changelog.d/fixes/13641-search-ghost.md b/changelog.d/fixes/13641-search-ghost.md new file mode 100644 index 0000000000..3623f9d9a7 --- /dev/null +++ b/changelog.d/fixes/13641-search-ghost.md @@ -0,0 +1 @@ +- **fix(db):** search stats and analytics no longer surface "ghost" rows — a NULL/`-` provider or a keyed search provider whose connection was deleted — while keyless providers (`duckduckgo-free`, `searxng-search`, anonymous `context7`) and credential-fallback providers (`perplexity-search` on a `perplexity` key) stay visible; the analytics totals apply the same filter, so `total` always matches the per-provider breakdown ([#13641](https://github.com/diegosouzapw/OmniRoute/pull/13641)) — thanks @maxmad64bis diff --git a/changelog.d/fixes/13645-free-badge-auth.md b/changelog.d/fixes/13645-free-badge-auth.md new file mode 100644 index 0000000000..01c0b1e43b --- /dev/null +++ b/changelog.d/fixes/13645-free-badge-auth.md @@ -0,0 +1 @@ +- **fix(dashboard):** new opt-in flag `FREE_BADGE_REQUIRES_PROVIDER_FREE_TIER` (default off) makes the provider-page Free badge strict — it drops the display-name heuristic, non-boolean `free` fields and `:free` suffixes on registered providers without a documented free tier, while keeping catalogued free models, explicit `free: true` and `:free` on free-tier providers and compatible nodes; with the flag off the badges are unchanged ([#13645](https://github.com/diegosouzapw/OmniRoute/pull/13645)) — thanks @maxmad64bis diff --git a/changelog.d/fixes/13646-socks-flag-reader.md b/changelog.d/fixes/13646-socks-flag-reader.md new file mode 100644 index 0000000000..af2032e4f8 --- /dev/null +++ b/changelog.d/fixes/13646-socks-flag-reader.md @@ -0,0 +1 @@ +- **fix(api):** share the single SOCKS5 flag reader across the settings proxy routes so the dashboard and the dispatcher stay consistent ([#13646](https://github.com/diegosouzapw/OmniRoute/pull/13646)) — thanks @maxmad64bis diff --git a/changelog.d/fixes/13650-recovery-trace-logging.md b/changelog.d/fixes/13650-recovery-trace-logging.md new file mode 100644 index 0000000000..9a4ccf8b70 --- /dev/null +++ b/changelog.d/fixes/13650-recovery-trace-logging.md @@ -0,0 +1 @@ +- **fix(stream-recovery):** log every mid-stream continuation outcome with its `attempt N/MAX` token — the stitched suffix, overlap rejection, terminal/empty continuation and tool-call refusals at debug, and a recovery that gives up (budget spent, or the continuation request returned no stream) at warn — without adding any warn line to a healthy or tool-call stream; the existing `mid-stream continuation attempt N/MAX` line is unchanged ([#13650](https://github.com/diegosouzapw/OmniRoute/pull/13650)) — thanks @maxmad64bis diff --git a/changelog.d/fixes/13657-rate-limited-429-single-retry-drain.md b/changelog.d/fixes/13657-rate-limited-429-single-retry-drain.md new file mode 100644 index 0000000000..93f2bd0166 --- /dev/null +++ b/changelog.d/fixes/13657-rate-limited-429-single-retry-drain.md @@ -0,0 +1 @@ +- **fix(sse):** opt-in `OPENCODE_RATE_LIMITED_429_EARLY_STOP` flag (default off): an opencode 429 classified as a real rate limit (parseable `Retry-After`, or a body naming a rate/usage limit) stops the cross-account wave and returns that upstream 429 unchanged — body, `Retry-After` and quota headers intact, so the opencode quota error rules still apply; unclassified 429s keep rotating, and with the flag off every 429 rotates as before (#9611) ([#13657](https://github.com/diegosouzapw/OmniRoute/pull/13657)) — thanks @maxmad64bis diff --git a/changelog.d/fixes/13671-dst-gap.md b/changelog.d/fixes/13671-dst-gap.md new file mode 100644 index 0000000000..cec8fa3160 --- /dev/null +++ b/changelog.d/fixes/13671-dst-gap.md @@ -0,0 +1 @@ +- **fix(sse):** a configured daily-quota reset hour that falls inside a daylight-saving gap (New York 02:00 on spring-forward, Havana/Santiago midnight) now resolves to the first wall-clock time that exists instead of landing an hour early, sometimes on the previous day ([#13671](https://github.com/diegosouzapw/OmniRoute/pull/13671)) — thanks @maxmad64bis diff --git a/changelog.d/fixes/13672-retry-after-provenance.md b/changelog.d/fixes/13672-retry-after-provenance.md new file mode 100644 index 0000000000..4006b2c3f4 --- /dev/null +++ b/changelog.d/fixes/13672-retry-after-provenance.md @@ -0,0 +1 @@ +- **fix(sse):** new opt-in flag `RETRY_AFTER_PROVENANCE_ENABLED` (default off): aggregated 429/503 unavailable responses omit `Retry-After` when no concrete future retry time is known instead of sending a synthetic 1s, carry `error.retry_after_provenance` (`signal` | `none`), and combo drain paths read prose retry hints from JSON and plain-text upstream bodies; non-JSON upstream error pages no longer log at warn ([#13672](https://github.com/diegosouzapw/OmniRoute/pull/13672)) — thanks @maxmad64bis diff --git a/changelog.d/fixes/13686-estimated-usage-guard.md b/changelog.d/fixes/13686-estimated-usage-guard.md new file mode 100644 index 0000000000..cb8cfbd2e0 --- /dev/null +++ b/changelog.d/fixes/13686-estimated-usage-guard.md @@ -0,0 +1 @@ +- **fix(usage):** mark locally estimated token usage in the call log (`_omniroute.usageEstimated` on the logged response) so operators can tell estimated counts and costs from provider-reported ones — covers OmniRoute's own estimate for streams without upstream usage and web executors that report `estimated: true`; billing, API-key budgets, quota-share and client payloads are unchanged ([#13686](https://github.com/diegosouzapw/OmniRoute/pull/13686)) — thanks @maxmad64bis diff --git a/changelog.d/fixes/ghsa-34rg-image-url-ssrf-public-only.md b/changelog.d/fixes/ghsa-34rg-image-url-ssrf-public-only.md new file mode 100644 index 0000000000..7835ca750a --- /dev/null +++ b/changelog.d/fixes/ghsa-34rg-image-url-ssrf-public-only.md @@ -0,0 +1 @@ +- **fix(security):** client-supplied image URLs (`image_url` / `mask_url` / message parts on image generation and upscale, chat `image_url` parts inlined by the vision bridge) and the NanoBanana result download now pin the `public-only` outbound guard with DNS validation, instead of inheriting the operator provider policy — `block-metadata` on a default install let a request body make the server fetch loopback/LAN URLs and forward the bytes upstream (GHSA-34rg-3pqj-35g9) diff --git a/changelog.d/fixes/ghsa-35fw-jx89-local-only-gates.md b/changelog.d/fixes/ghsa-35fw-jx89-local-only-gates.md new file mode 100644 index 0000000000..cb74cb6fc4 --- /dev/null +++ b/changelog.d/fixes/ghsa-35fw-jx89-local-only-gates.md @@ -0,0 +1 @@ +- **fix(authz):** classify the 14 remaining spawn-capable `/api/cli-tools/*` routes (`all-statuses`, `status`, `detect` and the `claude/cline/codewhale/codex/crush/deepseek-tui/droid/kilo/openclaw/pi/smelt-settings` writers) and the `/api/skills/install` + `/api/skills/executions` pair as LOCAL_ONLY — they reach `child_process.spawn` transitively (`getCliRuntimeStatus()` / `detectAllTools()` / the skills sandbox) but only sat behind Tier 3 MANAGEMENT auth, which `requireLogin=false` waives; loopback/LAN enforcement now runs before any auth check, matching their already-gated siblings (GHSA-35fw-cv32-2373 — thanks Parth Narula; GHSA-jx89-f37j-pq89 — thanks Aeon). Tunnel-served dashboards lose the CLI Tools status badges, the same trade-off already accepted for grok/forge/jcode/qwen. diff --git a/changelog.d/fixes/ghsa-r4q7-credential-catalog-groq-xai-sk.md b/changelog.d/fixes/ghsa-r4q7-credential-catalog-groq-xai-sk.md new file mode 100644 index 0000000000..d058d6fc58 --- /dev/null +++ b/changelog.d/fixes/ghsa-r4q7-credential-catalog-groq-xai-sk.md @@ -0,0 +1 @@ +- **fix(security):** redact Groq (`gsk_…`), xAI (`xai-…`) and every OpenAI-compatible `sk-…` key shape (DeepSeek 32-hex, Moonshot/Kimi, Together, …) in error bodies and the opt-in credential-masker guardrail — the catalog only knew the exact 48-char OpenAI form, so those keys passed through the guardrail verbatim and `gsk_`/`xai-` also reached public error responses (GHSA-r4q7-7f24-m29p) diff --git a/changelog.d/fixes/sec-adm-zip-0.6.1-symlink-follow.md b/changelog.d/fixes/sec-adm-zip-0.6.1-symlink-follow.md new file mode 100644 index 0000000000..00ae8cde35 --- /dev/null +++ b/changelog.d/fixes/sec-adm-zip-0.6.1-symlink-follow.md @@ -0,0 +1 @@ +- **fix(security):** bump the `adm-zip` override to `^0.6.1` — 0.6.0 followed a symlink already present inside the extraction root and could write outside it (GHSA-vwc7-r8mq-g2x9 / CVE-2026-76845); 0.6.1 walks every path component with `lstat` and refuses symlinks. Reached only through `onnxruntime-node`'s install script, which unpacks the vendor's own binary — no request-path exposure. diff --git a/changelog.d/maintenance/12732-attempt-log-tests-trace-id.md b/changelog.d/maintenance/12732-attempt-log-tests-trace-id.md new file mode 100644 index 0000000000..cb2365161a --- /dev/null +++ b/changelog.d/maintenance/12732-attempt-log-tests-trace-id.md @@ -0,0 +1 @@ +- **test(call-logs):** the early-keepalive merge and video-bridge redaction tests pass a `traceId` (defaulting to `pendingRequestId`) now that #13546 keys each attempt's call-log row on it, the dashboard `request.failed` redaction probe reads the persisted row by `traceId`, and the keepalive test polls against a 30s wall-clock deadline like the video-bridge test instead of a 2.4s try count ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) diff --git a/changelog.d/maintenance/12732-call-log-stats-duplicate-import.md b/changelog.d/maintenance/12732-call-log-stats-duplicate-import.md new file mode 100644 index 0000000000..7c6af99ee4 --- /dev/null +++ b/changelog.d/maintenance/12732-call-log-stats-duplicate-import.md @@ -0,0 +1 @@ +- **fix(db):** drop the duplicated `ERROR_TYPE_CONTRACT` import in `src/lib/db/callLogStats.ts` left by the #13641 merge; the TS2300 duplicate-identifier error failed the API-route and dashboard typecheck gates on every PR ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) diff --git a/changelog.d/maintenance/12732-cli-serve-ready-timeout-i18n.md b/changelog.d/maintenance/12732-cli-serve-ready-timeout-i18n.md new file mode 100644 index 0000000000..e54864c118 --- /dev/null +++ b/changelog.d/maintenance/12732-cli-serve-ready-timeout-i18n.md @@ -0,0 +1 @@ +- **fix(cli):** add the `serve.ready_timeout` string to the `en`, `zh-CN` and `zh-TW` CLI catalogs; `--ready-timeout` shipped calling `t("serve.ready_timeout")` without a catalog entry, which the CLI i18n key-coverage and parity tests report ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) diff --git a/changelog.d/maintenance/12732-file-size-test-growth-13748-13749.md b/changelog.d/maintenance/12732-file-size-test-growth-13748-13749.md new file mode 100644 index 0000000000..d37ac87858 --- /dev/null +++ b/changelog.d/maintenance/12732-file-size-test-growth-13748-13749.md @@ -0,0 +1 @@ +- **fix(ci):** re-freeze `tests/unit/image-generation-handler.test.ts` (2133→2235, #13748) and `tests/unit/batch_api.test.ts` (1345→1348, #13749) at their merged size; PR-mode `check:file-size` does not relax `testFrozen` against the base, so that regression coverage turned the gate red on every PR into the release line ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) diff --git a/changelog.d/maintenance/12732-gitleaks-uzbek-token-desc.md b/changelog.d/maintenance/12732-gitleaks-uzbek-token-desc.md new file mode 100644 index 0000000000..7a7a65615c --- /dev/null +++ b/changelog.d/maintenance/12732-gitleaks-uzbek-token-desc.md @@ -0,0 +1 @@ +- **fix(ci):** allowlist the Uzbek `outputTokenDesc` translation ("Yakunlash/javob tokenlari") and the `PROTECTED_PRIORITY_INFRA_502_ENABLED` feature-flag id (#13439) in `.gitleaks.toml`; the `generic-api-key` rule reads the `...TokenDesc` key and the flag `key:` as token assignments, which the secrets ratchet reported as new findings ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) diff --git a/changelog.d/maintenance/12732-jina-custom-rows-provider-prefix.md b/changelog.d/maintenance/12732-jina-custom-rows-provider-prefix.md new file mode 100644 index 0000000000..543617a21b --- /dev/null +++ b/changelog.d/maintenance/12732-jina-custom-rows-provider-prefix.md @@ -0,0 +1 @@ +- **test(models):** the custom Jina specialty-model catalog test expects the `jina-ai/` prefix again: custom rows keep the connection provider id, only synced rows resolve through the `jina` alias, and #13403 had switched the custom assertion to `jina/` ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) diff --git a/changelog.d/maintenance/12732-paid-target-fixture-gemini-pro.md b/changelog.d/maintenance/12732-paid-target-fixture-gemini-pro.md new file mode 100644 index 0000000000..9520ada679 --- /dev/null +++ b/changelog.d/maintenance/12732-paid-target-fixture-gemini-pro.md @@ -0,0 +1 @@ +- **test(settings):** the #6540 paid-target tests now use `gemini/gemini-3.1-pro-preview` as the paid fixture and assert the fixtures still classify as paid/free/unknown; the old Together target became "unknown" once #13407 removed Together's one-time signup credit from the free catalog, so the three save-time blocking tests read a correct 200 as a missing guard ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) diff --git a/changelog.d/maintenance/12732-stryker-noauth-token-budget-coverage.md b/changelog.d/maintenance/12732-stryker-noauth-token-budget-coverage.md new file mode 100644 index 0000000000..34cbaedb43 --- /dev/null +++ b/changelog.d/maintenance/12732-stryker-noauth-token-budget-coverage.md @@ -0,0 +1 @@ +- **fix(ci):** register `noauth-model-lockout`, `local-token-budget-429-skips-cooldown`, `free-badge-provider-gate` (#13645) and `daily-reset-tz-threading` (#13440) in `stryker.conf.json` `tap.testFiles`; they cover `accountFallback.ts`/`auth.ts`/`comboPredicates.ts`/`rrState.ts`, so the strict `mutation-test-coverage` gate failed Fast Quality Gates on every PR into the release line ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) diff --git a/changelog.d/maintenance/13729-rename-wvxc-route-test-labels.md b/changelog.d/maintenance/13729-rename-wvxc-route-test-labels.md new file mode 100644 index 0000000000..8bf8330e4b --- /dev/null +++ b/changelog.d/maintenance/13729-rename-wvxc-route-test-labels.md @@ -0,0 +1,2 @@ +- **test(batches):** the two seeded-batch labels of the delete-completed route-scope suite that sat right after a `key*.id` argument are renamed to short literals (`route401`/`route500`), so a gitleaks scan that reads those lines (full-tree, or git-mode on a branch that adds them) no longer reports them as `generic-api-key` hits ([#13729](https://github.com/diegosouzapw/OmniRoute/pull/13729)) + — no gate changes: the CI secret ratchet scans `src`/`open-sse`/`bin`/`electron`/`scripts`, never `tests/` diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 2b2dabce66..5ffac46924 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -469,7 +469,7 @@ }, "open-sse/services/combo.ts": { "@typescript-eslint/no-unused-vars": { - "count": 21 + "count": 1 } }, "open-sse/services/combo/providerWildcard.ts": { @@ -585,7 +585,7 @@ }, "open-sse/services/rateLimitManager.ts": { "@typescript-eslint/no-unused-vars": { - "count": 2 + "count": 1 } }, "open-sse/services/routing/index.ts": { diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index fef8ea6cfe..79d974eab8 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,12 @@ { + "_rebaseline_2026_09_15_13609_combined_growth": "Combined growth of the 2026-09-15 maxmad64bis uplift batch (each PR rebaselined its own growth; the merged sum is larger): open-sse/services/accountFallback.ts->2507. Every hunk is flag-gated or a verified fix covered by that PR's tests; see the batch report.", + "_rebaseline_2026_09_15_13602_combined_growth": "Combined growth of the 2026-09-15 maxmad64bis uplift batch (each PR rebaselined its own growth; the merged sum is larger): src/sse/handlers/chatHelpers.ts->1214. Every hunk is flag-gated or a verified fix covered by that PR's tests; see the batch report.", + "_rebaseline_2026_09_15_13580_combined_growth": "Combined growth of the 2026-09-15 maxmad64bis uplift batch (each PR rebaselined its own growth; the merged sum is larger): src/sse/handlers/chatHelpers.ts->1202. Every hunk is flag-gated or a verified fix covered by that PR's tests; see the batch report.", + "_rebaseline_2026_09_13_13581_pool_egress_observation": "PR #13581 own growth: src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx 1475->1477 (+2 = the PoolEgressObservation import and its one-line mount under the pool members label). The observation itself lives outside the frozen file, all under cap: PoolEgressObservation.tsx, the dedicated GET /api/settings/proxies/pool/egress-observation route, src/lib/proxyPoolEgressObservation.ts and getPoolEgressObservation in src/lib/db/proxyLogs.ts. Only the mount point is irreducible. Covered by tests/unit/proxy-pool-egress-observation.test.ts, tests/unit/proxy-pool-egress-observation-route.test.ts and tests/unit/ui/PoolEgressObservation.test.tsx.", + "_rebaseline_2026_09_15_13439_combined_growth": "Combined growth of the 2026-09-15 maxmad64bis uplift batch (each PR rebaselined its own growth; the merged sum is larger): open-sse/services/combo/executeTargetAttempt.ts->1228. Every hunk is flag-gated or a verified fix covered by that PR's tests; see the batch report.", + "_rebaseline_2026_09_15_13672_combined_growth": "Combined growth of the 2026-09-15 maxmad64bis uplift batch (each PR rebaselined its own growth; the merged sum is larger): open-sse/services/combo/executeTargetAttempt.ts->1223; open-sse/services/combo/roundRobinCombo.ts->1213. Every hunk is flag-gated or a verified fix covered by that PR's tests; see the batch report.", + "_rebaseline_2026_09_15_13441_combined_growth": "Combined growth of the 2026-09-15 maxmad64bis uplift batch (each PR rebaselined its own growth; the merged sum is larger): src/lib/db/core.ts->1770. Every hunk is flag-gated or a verified fix covered by that PR's tests; see the batch report.", + "_rebaseline_2026_09_13_13580_proxy_log_upstream_status": "PR #13580 own growth: open-sse/utils/proxyFetch.ts 1271->1275 (+4 = one import, one blank line, the applied-proxy sink getter and the const that wraps the existing fetch patch with withUpstreamStatusCapture). Irreducible plumbing at the single fetch install site: every return path of the patched fetch (dispatchers, direct sentinel, TLS layer, relay, native fallback, Bun) is covered by one wrapper instead of instrumenting each return. The capture logic lives in the new open-sse/utils/upstreamStatusCapture.ts (under cap). Covered by tests/unit/upstream-status-capture.test.ts and tests/unit/proxyfetch-upstream-status-capture.test.ts. Rework 2026-09-15: the migration is renumbered 177->179 (tip already ships 177/178), no size change.", "_rebaseline_2026_09_14_13547_noauth_model_lockout": "PR #13547 own growth: src/sse/services/auth.ts 3542->3556 (+14). The synthetic noauth connection short-circuited before the per-connection status pass, so a recorded model-only lockout was never enforced and the locked model was retried on every request. Irreducible at the early-return site. Covered by tests/unit/noauth-model-lockout.test.ts (carried from #13527).", "_rebaseline_2026_09_14_13404_healthcheck_backup_prune": "PR #13404 own growth: src/lib/db/core.ts 1745->1767 (+22). createManagedDbBackup (the health-check-repair snapshot path) never ran retention, so every restart of a healthy DB added a full-size copy to db_backups/; it now prunes with the same env-driven limits as backup.ts, importing backupRetention directly to avoid the backup.ts cycle. Covered by tests/unit/db-backup-healthcheck-prune-13308.test.ts.", "_rebaseline_2026_09_14_13349_virtualfactory_custom_models_guard": "PR #13349 own growth: open-sse/services/autoCombo/virtualFactory.ts 1219->1230 (+11). The customModels key_value blob is operator-writable raw JSON, so a null or non-object row null-derefed every read and no auto/* pool could materialize; the builder now filters rows the same way catalog.ts already does. Irreducible at the read site. Covered by tests/unit/combo-auto-pool-visible-only.test.ts.", @@ -6,6 +14,8 @@ "_rebaseline_2026_09_11_12945_image_only_model_guard": "PR #12945 own growth: open-sse/handlers/imageGeneration.ts 3259->3293 (+35/-1). The image-only-model guard the PR adds to clear its base-red: the handler now recognises a model that only serves image generation and answers before the chat path can mis-route it. Irreducible at this call site; the predicate itself lives outside the file. Landed as its own PR rather than on #12945 because that branch has a live worktree in another session and pushing to it would pull the branch out from under whoever is working it. Covered by the batch run: 203/208 with the 5 remaining failures reproducing on the pure tip.", "_rebaseline_2026_09_11_mergebatch_v3851_diego": "/merge-batch 2026-09-11 (v3.8.51), owner batch. open-sse/handlers/chatCore.ts 6144->6146 (+2): #13278 requires a Responses-shaped body before the native OpenAI-compatible passthrough (+1) and #13276 stops the reactive-compaction log from claiming a compaction when compression is disabled (+2/-1). Both are guard conditions at existing call sites, no new branching structure. open-sse/utils/stream.ts is deliberately NOT rebaselined: already 3115 > 3098 on the pure tip with zero contribution from this batch (base-red #12732, owned by /sweep-reds). Covered by 256 assertions across the batch's test files (246 node:test + 10 vitest).", "_rebaseline_2026_09_11_mergebatch_v3851_houminxi": "/merge-batch 2026-09-11 (v3.8.51), batch by HouMinXi. Final combined values, set on the first PR merged so every intermediate state is covered. open-sse/handlers/chatCore.ts 6036->6144: #13069 routes the non-streaming leg through the same provider-failure classification, model lockout and credential-refresh path the streaming leg already used (+443/-340 = +103 net; it extracts applyProviderFailureClassification and wires both legs to it, which is what #13043 reported missing), plus #13050 stamping that the client asked for SSE before the web_search fallback flips stream off (+6) and #13038 threading the dispatched target index (+3). src/sse/services/auth.ts 3488->3542: #13017 adds the explicit-pin one-shot probe for a recoverable inactive row with its 60s storm gate (+42 net) and #13061 makes a grok-cli 402 a connection-wide shared-wallet signal instead of a per-model billing miss (+12 net). src/sse/handlers/chat.ts 2458->2462: #13038 (+5). open-sse/services/combo/executeTargetAttempt.ts 1205->1212: #13006 feeds the 402 it already classified into the quota cache instead of dropping it (+7). open-sse/services/accountFallback.ts 2468->2469: #13060 adds the Cline re-auth phrase to OAUTH_INVALID_TOKEN_SIGNALS (+1). open-sse/utils/stream.ts is deliberately NOT rebaselined: already 3115 > 3098 on the pure tip with zero contribution from this batch (base-red #12732, owned by /sweep-reds). The file also carried \"open-sse/handlers/chatCore.ts\" twice (6026 and 6036); JSON keeps the last, so the first was dead weight any writer could have picked instead. Collapsed to one entry at the live value. Covered by 531 focused assertions across the batch's 46 test files.", + "_rebaseline_2026_09_15_13153_early_eof_one_hop": "PR #13153 rework on tip c0f92ec9 (release/v3.8.51): src/sse/handlers/chat.ts 2471->2490 (+19; 2462 on the pure tip, so +28 in total for the PR). +8 is not this PR's logic: the tip carries an unformatted one-line comboTargetPassesKeyModelPolicy call from #12886 that the pre-commit lint-staged prettier reflows as soon as any commit touches chat.ts. +11 is the rework: a per-request earlyEofOriginal slot (declaration + 2-line comment), a one-line early return of the original STREAM_EARLY_EOF 502 at the top of the no-credentials branch, and the multi-line gate that bounds the flag-gated sibling hop to one hop per request. All behind STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED (default off). Covered by tests/unit/chat-stream-early-eof-failover.test.ts (7/7: flag off, one hop, singleton, sibling failure, forced pin, readiness timeout).", + "_rebaseline_2026_09_12_13153_early_eof_flag_gate_v2": "PR #13153 own growth on tip 00f16a27: src/sse/handlers/chat.ts 2462->2471 (+9, check-file-size split-newline counting: block import+condition+8-line failover on the new base, which already carries +1 from #13038). The post-retry early-EOF terminal path gains a guarded exclude-and-continue block (import line + gate condition calling isEarlyEofSiblingFailoverOn() from chatHelpers.ts, where the fail-safe flag read lives under cap, + warn/exclude/state-carryover/continue). Irreducible call-site wiring at the existing terminal chokepoint; the sibling hop itself is unchanged from the PR first commit. Covered by tests/unit/chat-stream-early-eof-failover.test.ts (6/6, incl. flag-off terminal case).", "_rebaseline_2026_09_11_12358_chat_pipeline_custom_node": "PR #12358 own test growth: tests/integration/chat-pipeline.test.ts 1648->1736 (+88). One new integration case, \"#11884 chat pipeline sends a custom node's edited Chat API type upstream\": it seeds a custom OpenAI-compatible node with an edited Chat/Responses API type, stubs fetch, drives handleChatCore and asserts the upstream request carries the live connection setting rather than the format baked into the node id at creation. Irreducible at this layer — the point of the test is the full route-to-upstream path, which is what #11884 regressed. Nothing else in the file changed. Covered by the case itself plus tests/unit/chat-helpers.test.ts (28/28).", "_rebaseline_2026_09_10_12975_rotation_correlation_id": "PR #12975 own growth: open-sse/executors/base.ts 1751->1753 (+2) and open-sse/handlers/chatCore.ts 6021->6024 (+3). The opencode rotation lines carry the request correlationId: one optional ExecuteInput field and one correlationId argument at each of the three executor.execute call sites in handleChatCore. Irreducible plumbing at existing call sites; the rotation logic itself lives in open-sse/executors/opencode.ts and the new leaf predicates (under cap). Covered by tests/unit/opencode-transient-rotation.test.ts and tests/unit/chat-correlation-id-exhaustion.test.ts.", "_rebaseline_2026_09_11_mergebatch_v3851_maxmad_opencode": "/merge-batch 2026-09-11 (v3.8.51), PRs #13141, #13146 and #12975 by maxmad64bis. src/sse/services/auth.ts 3450->3488 (+38): #13146 adds the narrow ruleScope===model branch to markAccountUnavailable (gated on status 400; every other status keeps its path) plus the HONORS_RULE_LOCK_SCOPE_PROVIDERS opencode entry, taking it to 3464; #12975 then adds buildExhaustionOptions so the exhaustion log lines carry the request correlationId (+24). open-sse/services/accountFallback.ts 2467->2468 (+1): #13141 routes hasFutureRateLimitUntil through the tolerant epoch normalizer; #13146 is net zero there (+16/-16). open-sse/executors/base.ts 1751->1753 (+2): #12975 adds the optional ExecuteInput.correlationId field with its doc comment. src/sse/handlers/chat.ts is NOT rebaselined: #12975 threads correlationId through the three executor call sites (+2) but the file lands at 2452, still under its existing 2458 freeze. open-sse/utils/stream.ts is deliberately NOT rebaselined either: it is already 3115 > 3098 on the pure tip with zero contribution from this batch (base-red #12732, owned by /sweep-reds). No new branching beyond the two guarded branches named above. Covered by tests/unit/combo-predicates-epoch-cooldown.test.ts, opencode-400-model-unavailable.test.ts, agentrouter-error-rules.test.ts, opencode-transient-rotation.test.ts and chat-correlation-id-exhaustion.test.ts.", @@ -225,12 +235,13 @@ "_rebaseline_2026_07_22_8213_combo_config_cooldown_wait_tests": "PR #8213 (hartmark, fix/gemini-tpm-quota-cooldown-wait) own growth: tests/unit/combo-config.test.ts 880->940 (+60, entirely this PR's diff — testFrozen add covering isComboCooldownWaitEligible (gating cooldown-wait to auto/quota-share strategies with the feature enabled) and resolveComboTargetTimeoutMsForCombo (raising the per-target timeout floor to cover the cooldown-wait budget + buffer for eligible strategies, fixing the 120s default cutting off a 130s wait early and returning a synthetic 524)). Covered by the new assertions themselves.", "_rebaseline_2026_07_23_8122_codex_image_edits": "#8122 (@xiaoyaner0201) own growth: tests/unit/image-generation-handler.test.ts 2019->2029 (+10) — new coverage for Codex reference image edits (POST /v1/images/edits) plus the sanitizeImageProviderError/redactSensitiveErrorText hardening it introduces. Test-only growth at the existing handler test file.", "_rebaseline_2026_07_25_8510_adobe_firefly_reference_images_tests": "#8510 (artickc, feat/adobe-firefly-reference-images) own test growth: tests/unit/adobe-firefly.test.ts 711->871 (+159, entirely this PR's diff — new referenceBlobs upload/dispatch coverage for handleAdobeFireflyImageGeneration, resolveAdobeSourceImageIds, and the storage-upload wire contract). Route-level /v1/images/edits coverage (credentials/rate-limit/4-ref-cap branches added to route.ts) lives in the new tests/unit/8510-adobe-firefly-edits-route.test.ts instead of growing this file further.", + "_rebaseline_2026_09_15_13748_13749_merged_test_growth_basereds": "Base-red drain (#12732): two security fixes merged on 2026-09-15 each grew a frozen test file with their own regression coverage, and PR-mode check:file-size does not relax testFrozen against the base, so every PR into release/v3.8.51 went red on file-size. Recorded against the merged state: tests/unit/image-generation-handler.test.ts 2133->2235 (#13748 public-only guard on client-supplied image URLs); tests/unit/batch_api.test.ts 1345->1348 (#13749 API-key ownership on files and batches). No cap is raised beyond the merged LOC.", "_rebaseline_2026_08_24_video_bridge_fu01_fu03_fu04_result_cache_tests": "PRs #11362 (FU-01 cache hardening) + #11382 (FU-03 visual dedup policy identity) + #11383 (FU-04 focused analysis mode) own test growth: videoBridgeResultCache.test.ts <1000->1040, +40 (sum of three stacked PRs boarded together in the same merge-batch, each adding its own cache-identity assertions on the shared result-cache seam). Owner pre-authorized rebaseline for legitimate PR growth (2026-08-19 directive).", "_rebaseline_basered_codebuddy_cn": "Base-red fix (#4664 CodeBuddy CN): oauth-providers-config.test.ts 867->870 (+3) to align the EXPECTED provider list/config with the codebuddy-cn provider that #4664 added to the registry without updating this test (it asserts 'exactly once').", "_rebaseline_pr4613_compatible_provider_groups": "Reconcile #4613 already-merged growth: providers-page-utils.test.ts 1004->1052 (+48, buildCompatibleProviderGroups partition unit test). Fast-gate PR->release does not run check:file-size, so this surfaced post-merge.", "tests/integration/chat-pipeline.test.ts": 1736, "tests/unit/account-fallback-service.test.ts": 2056, - "tests/unit/batch_api.test.ts": 1345, + "tests/unit/batch_api.test.ts": 1348, "tests/unit/cc-compatible-provider.test.ts": 1225, "tests/unit/chatcore-translation-paths.test.ts": 3447, "tests/unit/chatgpt-web.test.ts": 4911, @@ -239,7 +250,7 @@ "tests/unit/executor-codex.test.ts": 1465, "tests/unit/executor-default-base.test.ts": 1632, "tests/unit/grok-web.test.ts": 2985, - "tests/unit/image-generation-handler.test.ts": 2133, + "tests/unit/image-generation-handler.test.ts": 2235, "tests/unit/models-catalog-route.test.ts": 1653, "tests/unit/perplexity-web.test.ts": 1384, "tests/unit/provider-models-route.test.ts": 1783, @@ -335,6 +346,8 @@ "_rebaseline_2026_07_27_3850_relax_filesize_cap": "OWNER-APPROVED TEMPORARY relax for v3.8.50-3.8.54 PREPARE phase (docs/ROADMAP.md). cap 800->900 (+100), testCap 800->900 (+100). Targets: decompose-existing-frozen unchanged (frozen still only-shrink); this only relaxes the cap for NEW files in the decompose/extract-while-PREPARE phase (.51='executor registry in-place' and .52='combo.ts decomposition' create new leaf modules above 800). RE-TIGHTENING MANDATORY in v3.8.51: cap target 850 = 850 once decomposition wave stabilizes. SUPERSEDED by _rebaseline_2026_07_27_3850_relax_filesize_cap_v2_20pct (v1 +20% buffer) — retained for audit. Tracked via same roadmap issue.", "_rebaseline_2026_07_27_v3849_train1h": "Merge-train 1H (31 PRs) — owner-approved 2026-07-27. Two distinct causes, kept separate on purpose: (1) GENUINE irreducible growth at existing chokepoints — providerLimits/auth (#8632 Kimi quota-reset recovery), rateLimitManager (#8616 idle wedged limiters), models-catalog-route.test (#8610 OpenCode Go effort aliases); (2) COLLISION with #8585, which banked shrinks measured on the pre-train release tip while 30 sibling PRs in the SAME train grew those files again — chat/accountFallback (#8628), chatCore (#8613), videoGeneration (#8581), imageGeneration. The zero-headroom frozen entries cannot absorb either. Ceilings re-pinned to the post-merge tip; #8612 (also in this train) automates shrink-banking so this self-inflicted drift stops recurring. Detail: src/lib/usage/providerLimits.ts 1006->1013 (#8632); src/sse/services/auth.ts 2492->2508 (#8632); open-sse/services/rateLimitManager.ts 1014->1060 (#8616); src/sse/handlers/chat.ts 1842->1845 (#8628); open-sse/handlers/chatCore.ts 4939->4955 (#8613); open-sse/handlers/imageGeneration.ts 3100->3101 ((sem PR — teto do #8585)); open-sse/handlers/videoGeneration.ts 1038->1063 (#8581); open-sse/services/accountFallback.ts 1965->1966 (#8628); tests/unit/models-catalog-route.test.ts 1608->1636 (#8610)", "frozen": { + "src/sse/handlers/chatHelpers.ts": 1214, + "_rebaseline_2026_09_15_13609_mistral_ambiguous_401": "PR #13609 rework (maxmad64bis, bare Mistral 401 soft lockout behind MISTRAL_AMBIGUOUS_401_SOFT_LOCKOUT, default off). open-sse/services/accountFallback.ts 2469->2501 (+32): +14 are the change itself (shared-predicate + flag imports, the documented ambiguousAuth field on the checkFallbackError return type, and the flag-gated 401 branch formatted normally instead of the PR's 139-char squeezed configuredRule line); +18 are the lint-staged prettier pass normalizing lines that were already unformatted on the release tip (multi-import, ISO_RETRY_RE, two regex arrays, persistAntigravityFamilyCooldownIfQuota call, applyErrorState guard, trailing commas) — pure formatting, no logic. src/sse/services/auth.ts 3556->3557 (+1): markAccountUnavailable passes connectionId to resolveTerminalConnectionStatus so the soft-strike bound is per connection. The predicate and strike tracker live in the leaf open-sse/services/accountFallback/mistralAmbiguousAuth.ts (under cap). Covered by tests/unit/provider-401-ambiguous-runtime.test.ts (flag off/on, end-to-end through markAccountUnavailable).", "_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, 1145 (measured real line count after both #9133 and #11481 merged together = one import line for filterModelExposureCandidates plus the filter-and-reassign block at the existing buildPreparedPool chokepoint, immediately after the filterPaidOnlyCandidates call it mirrors — the exact pattern #6512 already established for hidePaidModels). The actual predicate (isModelExposureAllowed, glob support via the shared globToRegex matcher) lives in the new src/shared/utils/modelExposureList.ts leaf, and the pool-filter wrapper lives in the new open-sse/services/autoCombo/modelExposureFilter.ts leaf (both well under cap) — this file only carries the minimal call-site wiring plus import, not extractable further without hiding the buildPreparedPool filter chain. Covered by tests/unit/autoCombo/model-exposure-filter-11481.test.ts (pure filter, all branches) and tests/unit/model-exposure-list.test.ts (predicate).", "_rebaseline_2026_08_29_9133_candidates_inspector_skip_flag": "#9133 own growth: open-sse/services/autoCombo/virtualFactory.ts 1138->1139 (+1, net of extraction). Fix: prepareVirtualAutoComboInputs gained an opt-in `skip` parameter so the read-only #7819 candidate inspector (open-sse/handlers/autoComboCandidates.ts) can build the FULL, unfiltered pool and decorate a resilience-blocked candidate as reachable:false instead of filterResilienceBlockedCandidates silently dropping the row before the inspector ever sees it (routing is unaffected — it never passes `skip`). The connectionsById map-building loop was extracted to buildConnectionResilienceMap() in resilienceCandidateFilter.ts (net 0 there since Prettier still breaks the call over multiple lines) and the now-unused ConnectionResilienceView import was dropped; the sole remaining growth is the new `skip` default parameter itself, which Prettier always places on its own line once the preceding options object parameter already breaks across lines — not further reducible without splitting prepareVirtualAutoComboInputs's signature away from its own body. Covered by tests/unit/auto-combo-candidates-locked-model-visible.test.ts (TDD repro: red before the fix, green after) plus the existing tests/unit/noauth-autocombo-lockout-7623.test.ts and tests/unit/auto-combo-credentialed-model-pool.test.ts (unaffected routing-path behavior).", "_rebaseline_2026_08_30_11703_json_tree_viewer": "/merge-batch 2026-08-30 (v3.8.51): #11703 (hartmark) own growth: src/shared/components/RequestLoggerDetail.tsx 1018->1111 (+93). The 2026-07-22 annotation on this same file said 'no further growth without split rationale' — this PR does split: the collapsible-JSON-tree rendering logic itself lives in the sibling RequestLoggerDetail.sections.tsx (PayloadSection/StreamSection extraction, +82 lines there) plus two new leaves (JsonTreeExpandControls.tsx, useTimestampTitles.ts) and a new store (jsonTreeExpandStore.ts) — all well under cap. The +93 remaining here is the irreducible call-site wiring: import + mount JsonTreeExpandControls, wire the per-section expand-level state and timestamp-tooltip hook into the existing detail panel layout. Covered by the PR's own tests/unit/dashboard/payload-section-collapsible-json.test.tsx, timestamp-titles.test.tsx, tests/unit/shared/json-tree-expand-store.test.ts, short-call-id.test.ts (43/43 vitest + 11/11 native pass).", + "_rebaseline_2026_09_15_13440_daily_reset_tz": "#13440 rework: open-sse/services/accountFallback.ts 2469->2493 (+24): +6 for the operator-clock-first branch in checkFallbackError non-TPD daily quota (nextConfiguredResetMs leaf lives in dailyQuotaReset.ts, under cap) and +18 from the mandatory lint-staged Prettier pass over pre-existing unformatted lines of the touched file (no logic). executeTargetAttempt.ts 1212->1215 and roundRobinCombo.ts 1205->1208 (+3 each): one import plus the rotation/dailyReset arguments at the existing checkFallbackError call site; the lookup itself is the new comboDailyResetClock.ts leaf (under cap). Covered by tests/unit/daily-reset-tz-threading.test.ts.", + "_rebaseline_2026_09_15_13672_retry_after_provenance": "#13672 rework (opt-in RETRY_AFTER_PROVENANCE_ENABLED): open-sse/services/combo/executeTargetAttempt.ts 1212->1220 (+8) and roundRobinCombo.ts 1205->1210 (+5) at the existing drain-path clone/parse block: capture the already-read body text, log an unreadable hint (debug for a non-JSON page, warn for a failed clone) instead of an empty catch, and one flag-gated prose fallback line; the import grows by the two helpers. Parsing, flag read and the Retry-After/provenance logic live in open-sse/utils/error.ts (under cap). Covered by tests/unit/retry-after-provenance.test.ts (flag off and on).", + "_rebaseline_2026_09_15_13439_protected_priority_stop_status": "#13439 rework (opt-in PROTECTED_PRIORITY_INFRA_502_ENABLED): open-sse/services/combo/executeTargetAttempt.ts 1212->1217 (+5): two import lines for the new protectedPriorityStopStatus.ts leaf (where the provably-non-quota cause list and the flag read live) and the predictive_ttft cause argument at the existing stopProtectedPriorityTarget call, which Prettier splits over three lines. Covered by tests/unit/combo/protected-priority-stop-status-13439.test.ts (every stop cause, flag off and on).", "_rebaseline_pr1043_minimax_tts": "Upstream port decolua/9router#1043 (toanalien) own growth: audioSpeech.ts 965->1061 (+96). Adds MiniMax T2A v2 TTS dispatch (handleMinimaxSpeech + hexToBytes helper) — provider entry was already in audioRegistry (format: minimax-tts) but no handler existed, falling through to the OpenAI-compatible default that fails (T2A has custom shape + hex-encoded audio + base_resp envelope). New branch sits next to the other inline provider branches (xiaomi-mimo, coqui, tortoise, aws-polly) — extracting would just create indirection. Covered by tests/unit/minimax-tts-1043.test.ts (3 tests, GREEN: success, base_resp error, invalid-hex).", "_rebaseline_pr4592_exclude_exhausted_auto": "Reconcile #4592 already-merged growth: combo.ts 2991->3036 (+45, terminal-status quota-cutoff exclusion in buildAutoCandidates + opt-in gate). Fast-gate PR->release does not run check:file-size.", "open-sse/executors/antigravity.ts": 1665, @@ -442,13 +458,13 @@ "open-sse/handlers/search.ts": 1789, "open-sse/mcp-server/schemas/tools.ts": 1621, "open-sse/mcp-server/server.ts": 1572, - "open-sse/services/accountFallback.ts": 2469, + "open-sse/services/accountFallback.ts": 2507, "open-sse/services/adobeFireflyBrowserLogin.ts": 1401, "open-sse/services/combo.ts": 4080, - "open-sse/services/combo/executeTargetAttempt.ts": 1212, + "open-sse/services/combo/executeTargetAttempt.ts": 1228, "open-sse/translator/response/openai-responses.ts": 1466, "open-sse/utils/cursorAgentProtobuf.ts": 1547, - "open-sse/utils/proxyFetch.ts": 1271, + "open-sse/utils/proxyFetch.ts": 1275, "open-sse/utils/stream.ts": 3098, "open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/browser-worker.ts": 4398, "open-sse/vendor/codex-chatgpt-web/bridge.ts": 1335, @@ -460,7 +476,7 @@ "src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1631, "src/app/(dashboard)/dashboard/providers/page.tsx": 2025, "src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1222, - "src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1475, + "src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1477, "src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": 1271, "src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": 1607, "src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": 1597, @@ -470,19 +486,19 @@ "src/app/api/v1/models/catalog.ts": 2075, "src/app/docs/lib/openapi.generated.ts": 1347, "src/lib/db/apiKeys.ts": 1625, - "src/lib/db/core.ts": 1767, + "src/lib/db/core.ts": 1770, "src/lib/db/migrationRunner.ts": 1206, "src/lib/tailscaleTunnel.ts": 1208, "src/lib/tokenHealthCheck.ts": 1218, "src/shared/components/RequestLoggerV2.tsx": 1718, "src/shared/constants/providers/apikey/gateways.ts": 1502, "src/shared/services/cliRuntime.ts": 1296, - "src/sse/handlers/chat.ts": 2462, - "src/sse/services/auth.ts": 3556, + "src/sse/handlers/chat.ts": 2490, + "src/sse/services/auth.ts": 3557, "tests/unit/account-fallback-service.test.ts": 2453, "tests/unit/provider-validation-specialty.test.ts": 4656, "open-sse/services/autoCombo/virtualFactory.ts": 1230, - "open-sse/services/combo/roundRobinCombo.ts": 1205 + "open-sse/services/combo/roundRobinCombo.ts": 1213 }, "_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.", "_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).", diff --git a/docs/i18n/am/llm.txt b/docs/i18n/am/llm.txt index 1c9716e605..39a61f6de3 100644 --- a/docs/i18n/am/llm.txt +++ b/docs/i18n/am/llm.txt @@ -4,6 +4,7 @@ --- + > OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 358 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +19,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +129,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +394,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ar/llm.txt b/docs/i18n/ar/llm.txt index b2a1ae93e3..9134543a98 100644 --- a/docs/i18n/ar/llm.txt +++ b/docs/i18n/ar/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/az/llm.txt b/docs/i18n/az/llm.txt index 70db156845..0ed9ba4afe 100644 --- a/docs/i18n/az/llm.txt +++ b/docs/i18n/az/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/bg/llm.txt b/docs/i18n/bg/llm.txt index 23fe71dece..6ec72e9b07 100644 --- a/docs/i18n/bg/llm.txt +++ b/docs/i18n/bg/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/bn/llm.txt b/docs/i18n/bn/llm.txt index 52db37b7eb..3f682bf3b7 100644 --- a/docs/i18n/bn/llm.txt +++ b/docs/i18n/bn/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/cs/llm.txt b/docs/i18n/cs/llm.txt index 39bebfcc55..0846e63050 100644 --- a/docs/i18n/cs/llm.txt +++ b/docs/i18n/cs/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/da/llm.txt b/docs/i18n/da/llm.txt index ba70410f19..28c6c9004b 100644 --- a/docs/i18n/da/llm.txt +++ b/docs/i18n/da/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/de/llm.txt b/docs/i18n/de/llm.txt index 93ab0c4b82..b7c8f543cb 100644 --- a/docs/i18n/de/llm.txt +++ b/docs/i18n/de/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/el/llm.txt b/docs/i18n/el/llm.txt index 740c213da8..bb42596d24 100644 --- a/docs/i18n/el/llm.txt +++ b/docs/i18n/el/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/es/llm.txt b/docs/i18n/es/llm.txt index ead2cc4113..35277266c1 100644 --- a/docs/i18n/es/llm.txt +++ b/docs/i18n/es/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/et/llm.txt b/docs/i18n/et/llm.txt index 16fa187a52..05ea61696e 100644 --- a/docs/i18n/et/llm.txt +++ b/docs/i18n/et/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/fa/llm.txt b/docs/i18n/fa/llm.txt index 6dae87ab96..10c736125b 100644 --- a/docs/i18n/fa/llm.txt +++ b/docs/i18n/fa/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/fi/llm.txt b/docs/i18n/fi/llm.txt index b0feeb0fb3..b90155fe90 100644 --- a/docs/i18n/fi/llm.txt +++ b/docs/i18n/fi/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/fr/llm.txt b/docs/i18n/fr/llm.txt index 942d8c5934..b4575e8b23 100644 --- a/docs/i18n/fr/llm.txt +++ b/docs/i18n/fr/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ga/llm.txt b/docs/i18n/ga/llm.txt index c65ade94d9..000ec04984 100644 --- a/docs/i18n/ga/llm.txt +++ b/docs/i18n/ga/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/gu/llm.txt b/docs/i18n/gu/llm.txt index d4b7b37d1b..625e9d85e3 100644 --- a/docs/i18n/gu/llm.txt +++ b/docs/i18n/gu/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ha/llm.txt b/docs/i18n/ha/llm.txt index 9c336597b4..c1c8f69cb1 100644 --- a/docs/i18n/ha/llm.txt +++ b/docs/i18n/ha/llm.txt @@ -4,6 +4,7 @@ --- + > OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 358 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +19,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +129,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +394,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/he/llm.txt b/docs/i18n/he/llm.txt index 45879f183a..e3bbdcd90d 100644 --- a/docs/i18n/he/llm.txt +++ b/docs/i18n/he/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/hi/llm.txt b/docs/i18n/hi/llm.txt index 358803cf51..e38bc37c27 100644 --- a/docs/i18n/hi/llm.txt +++ b/docs/i18n/hi/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/hr/llm.txt b/docs/i18n/hr/llm.txt index 85bc94fd10..8ef00fa5c7 100644 --- a/docs/i18n/hr/llm.txt +++ b/docs/i18n/hr/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/hu/llm.txt b/docs/i18n/hu/llm.txt index bbb7d0e0f2..106c2b25a7 100644 --- a/docs/i18n/hu/llm.txt +++ b/docs/i18n/hu/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/hy/llm.txt b/docs/i18n/hy/llm.txt index d713242700..4de463ecd4 100644 --- a/docs/i18n/hy/llm.txt +++ b/docs/i18n/hy/llm.txt @@ -4,6 +4,7 @@ --- + > OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 358 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +19,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +129,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +394,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/id/llm.txt b/docs/i18n/id/llm.txt index 309dcd1169..ccfb8f192d 100644 --- a/docs/i18n/id/llm.txt +++ b/docs/i18n/id/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ig/llm.txt b/docs/i18n/ig/llm.txt index 895c950c95..27c0f08db8 100644 --- a/docs/i18n/ig/llm.txt +++ b/docs/i18n/ig/llm.txt @@ -4,6 +4,7 @@ --- + > OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 358 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +19,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +129,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +394,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/it/llm.txt b/docs/i18n/it/llm.txt index 787c31b404..586c70bf9e 100644 --- a/docs/i18n/it/llm.txt +++ b/docs/i18n/it/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ja/llm.txt b/docs/i18n/ja/llm.txt index 25ddac8890..42aa3211aa 100644 --- a/docs/i18n/ja/llm.txt +++ b/docs/i18n/ja/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ka/llm.txt b/docs/i18n/ka/llm.txt index fbcc262642..b87709baa1 100644 --- a/docs/i18n/ka/llm.txt +++ b/docs/i18n/ka/llm.txt @@ -4,6 +4,7 @@ --- + > OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 358 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +19,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +129,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +394,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/km/llm.txt b/docs/i18n/km/llm.txt index d765584847..c559c1c069 100644 --- a/docs/i18n/km/llm.txt +++ b/docs/i18n/km/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/kn/llm.txt b/docs/i18n/kn/llm.txt index d9b80fdda5..8b90b5bd75 100644 --- a/docs/i18n/kn/llm.txt +++ b/docs/i18n/kn/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ko/llm.txt b/docs/i18n/ko/llm.txt index f2f0b41d39..e2c88a3bc8 100644 --- a/docs/i18n/ko/llm.txt +++ b/docs/i18n/ko/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/lt/llm.txt b/docs/i18n/lt/llm.txt index 4d0623903c..30c84480ef 100644 --- a/docs/i18n/lt/llm.txt +++ b/docs/i18n/lt/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/lv/llm.txt b/docs/i18n/lv/llm.txt index ec1f7ceaf9..a10e5cfb44 100644 --- a/docs/i18n/lv/llm.txt +++ b/docs/i18n/lv/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ml/llm.txt b/docs/i18n/ml/llm.txt index 8aa3e4dbf8..c35b9e0c3f 100644 --- a/docs/i18n/ml/llm.txt +++ b/docs/i18n/ml/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/mr/llm.txt b/docs/i18n/mr/llm.txt index 5369de769a..267d20e389 100644 --- a/docs/i18n/mr/llm.txt +++ b/docs/i18n/mr/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ms/llm.txt b/docs/i18n/ms/llm.txt index aab979e702..1cecd2685f 100644 --- a/docs/i18n/ms/llm.txt +++ b/docs/i18n/ms/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/mt/llm.txt b/docs/i18n/mt/llm.txt index 60e1d2d828..06b08636c9 100644 --- a/docs/i18n/mt/llm.txt +++ b/docs/i18n/mt/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/my/llm.txt b/docs/i18n/my/llm.txt index 4e912ba476..7f5052c484 100644 --- a/docs/i18n/my/llm.txt +++ b/docs/i18n/my/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ne/llm.txt b/docs/i18n/ne/llm.txt index ee9a879b25..7e3c7b9afe 100644 --- a/docs/i18n/ne/llm.txt +++ b/docs/i18n/ne/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/nl/llm.txt b/docs/i18n/nl/llm.txt index f4003a7aa9..0f60572235 100644 --- a/docs/i18n/nl/llm.txt +++ b/docs/i18n/nl/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/no/llm.txt b/docs/i18n/no/llm.txt index b86e8b3814..d800a7dddc 100644 --- a/docs/i18n/no/llm.txt +++ b/docs/i18n/no/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/or/llm.txt b/docs/i18n/or/llm.txt index 4e0e0df770..ec6ac7fce7 100644 --- a/docs/i18n/or/llm.txt +++ b/docs/i18n/or/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/pa/llm.txt b/docs/i18n/pa/llm.txt index e2342e8caf..b55f4ae03d 100644 --- a/docs/i18n/pa/llm.txt +++ b/docs/i18n/pa/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/phi/llm.txt b/docs/i18n/phi/llm.txt index bb97e5f7eb..9fc54131b0 100644 --- a/docs/i18n/phi/llm.txt +++ b/docs/i18n/phi/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/pl/llm.txt b/docs/i18n/pl/llm.txt index 23a1b5a3b0..e78a340602 100644 --- a/docs/i18n/pl/llm.txt +++ b/docs/i18n/pl/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/pt-BR/llm.txt b/docs/i18n/pt-BR/llm.txt index ccd0d779da..1bec9af6e6 100644 --- a/docs/i18n/pt-BR/llm.txt +++ b/docs/i18n/pt-BR/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/pt/llm.txt b/docs/i18n/pt/llm.txt index 659b94dd1d..31a8ae5a12 100644 --- a/docs/i18n/pt/llm.txt +++ b/docs/i18n/pt/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ro/llm.txt b/docs/i18n/ro/llm.txt index eee7e13ae4..d723831165 100644 --- a/docs/i18n/ro/llm.txt +++ b/docs/i18n/ro/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ru/llm.txt b/docs/i18n/ru/llm.txt index 3677f6727d..ada7cab271 100644 --- a/docs/i18n/ru/llm.txt +++ b/docs/i18n/ru/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/si/llm.txt b/docs/i18n/si/llm.txt index d4adb7c4db..663f79b27f 100644 --- a/docs/i18n/si/llm.txt +++ b/docs/i18n/si/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/sk/llm.txt b/docs/i18n/sk/llm.txt index b9d05cf329..d6c5098f0a 100644 --- a/docs/i18n/sk/llm.txt +++ b/docs/i18n/sk/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/sl/llm.txt b/docs/i18n/sl/llm.txt index 2db02bc74f..7a6e4b2a6d 100644 --- a/docs/i18n/sl/llm.txt +++ b/docs/i18n/sl/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/sr/llm.txt b/docs/i18n/sr/llm.txt index 25b151b122..56bc46640c 100644 --- a/docs/i18n/sr/llm.txt +++ b/docs/i18n/sr/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/sv/llm.txt b/docs/i18n/sv/llm.txt index 6c9bd92b24..7d4698e370 100644 --- a/docs/i18n/sv/llm.txt +++ b/docs/i18n/sv/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/sw/llm.txt b/docs/i18n/sw/llm.txt index 0ee02966f2..7d041ae8e9 100644 --- a/docs/i18n/sw/llm.txt +++ b/docs/i18n/sw/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ta/llm.txt b/docs/i18n/ta/llm.txt index c318001ff4..a6c2c2852d 100644 --- a/docs/i18n/ta/llm.txt +++ b/docs/i18n/ta/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/te/llm.txt b/docs/i18n/te/llm.txt index 660136519b..4b7315bc31 100644 --- a/docs/i18n/te/llm.txt +++ b/docs/i18n/te/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/th/llm.txt b/docs/i18n/th/llm.txt index 5f3f258b98..c363c854bb 100644 --- a/docs/i18n/th/llm.txt +++ b/docs/i18n/th/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/tr/llm.txt b/docs/i18n/tr/llm.txt index 84c4a26fb2..2cf5ea45dc 100644 --- a/docs/i18n/tr/llm.txt +++ b/docs/i18n/tr/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/uk-UA/llm.txt b/docs/i18n/uk-UA/llm.txt index bd282832b4..253b94e9f5 100644 --- a/docs/i18n/uk-UA/llm.txt +++ b/docs/i18n/uk-UA/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ur/llm.txt b/docs/i18n/ur/llm.txt index d8b45c0cec..304f6d87fa 100644 --- a/docs/i18n/ur/llm.txt +++ b/docs/i18n/ur/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/uz/llm.txt b/docs/i18n/uz/llm.txt index 74b47a25cc..e9217f5d03 100644 --- a/docs/i18n/uz/llm.txt +++ b/docs/i18n/uz/llm.txt @@ -4,6 +4,7 @@ --- + > OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 358 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +19,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +129,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +394,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/vi/llm.txt b/docs/i18n/vi/llm.txt index 180106850f..6a7c3c25c7 100644 --- a/docs/i18n/vi/llm.txt +++ b/docs/i18n/vi/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/yo/llm.txt b/docs/i18n/yo/llm.txt index 82d352c8cb..9956519f16 100644 --- a/docs/i18n/yo/llm.txt +++ b/docs/i18n/yo/llm.txt @@ -4,6 +4,7 @@ --- + > OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 358 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +19,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +129,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +394,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/zh-CN/llm.txt b/docs/i18n/zh-CN/llm.txt index b6ff51e032..9f130ca59b 100644 --- a/docs/i18n/zh-CN/llm.txt +++ b/docs/i18n/zh-CN/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/zh-TW/llm.txt b/docs/i18n/zh-TW/llm.txt index 22e134c685..be78d6b4a5 100644 --- a/docs/i18n/zh-TW/llm.txt +++ b/docs/i18n/zh-TW/llm.txt @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 16a39eef8d..95d9778f48 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -3999,12 +3999,14 @@ paths: get: tags: [CLI Tools] summary: Get Claude CLI settings + x-loopback-only: true responses: "200": description: Claude CLI configuration post: tags: [CLI Tools] summary: Apply Claude CLI settings + x-loopback-only: true requestBody: required: true content: @@ -4017,6 +4019,7 @@ paths: delete: tags: [CLI Tools] summary: Reset Claude CLI settings + x-loopback-only: true responses: "200": description: Claude CLI settings reset @@ -4025,12 +4028,14 @@ paths: get: tags: [CLI Tools] summary: Get Cline CLI settings + x-loopback-only: true responses: "200": description: Cline CLI configuration post: tags: [CLI Tools] summary: Apply Cline CLI settings + x-loopback-only: true requestBody: required: true content: @@ -4043,6 +4048,7 @@ paths: delete: tags: [CLI Tools] summary: Reset Cline CLI settings + x-loopback-only: true responses: "200": description: Cline CLI settings reset @@ -4093,12 +4099,14 @@ paths: get: tags: [CLI Tools] summary: Get Codex CLI settings + x-loopback-only: true responses: "200": description: Codex CLI configuration post: tags: [CLI Tools] summary: Apply Codex CLI settings + x-loopback-only: true requestBody: required: true content: @@ -4111,6 +4119,7 @@ paths: delete: tags: [CLI Tools] summary: Reset Codex CLI settings + x-loopback-only: true responses: "200": description: Codex CLI settings reset @@ -4119,12 +4128,14 @@ paths: get: tags: [CLI Tools] summary: Get Droid CLI settings + x-loopback-only: true responses: "200": description: Droid CLI configuration post: tags: [CLI Tools] summary: Apply Droid CLI settings + x-loopback-only: true requestBody: required: true content: @@ -4137,6 +4148,7 @@ paths: delete: tags: [CLI Tools] summary: Reset Droid CLI settings + x-loopback-only: true responses: "200": description: Droid CLI settings reset @@ -4145,12 +4157,14 @@ paths: get: tags: [CLI Tools] summary: Get Kilo CLI settings + x-loopback-only: true responses: "200": description: Kilo CLI configuration post: tags: [CLI Tools] summary: Apply Kilo CLI settings + x-loopback-only: true requestBody: required: true content: @@ -4163,6 +4177,7 @@ paths: delete: tags: [CLI Tools] summary: Reset Kilo CLI settings + x-loopback-only: true responses: "200": description: Kilo CLI settings reset @@ -4171,12 +4186,14 @@ paths: get: tags: [CLI Tools] summary: Get OpenClaw CLI settings + x-loopback-only: true responses: "200": description: OpenClaw CLI configuration post: tags: [CLI Tools] summary: Apply OpenClaw CLI settings + x-loopback-only: true requestBody: required: true content: @@ -4189,6 +4206,7 @@ paths: delete: tags: [CLI Tools] summary: Reset OpenClaw CLI settings + x-loopback-only: true responses: "200": description: OpenClaw CLI settings reset @@ -8256,6 +8274,7 @@ paths: tags: - CLI Tools summary: Read Crush CLI OmniRoute config + x-loopback-only: true description: Local-only. Reads the OmniRoute provider block in Crush's config. x-internal: true responses: @@ -8265,6 +8284,7 @@ paths: tags: - CLI Tools summary: Write Crush CLI OmniRoute config + x-loopback-only: true description: Local-only. Registers OmniRoute as an `openai-compat` provider in Crush's config. x-internal: true responses: @@ -8274,6 +8294,7 @@ paths: tags: - CLI Tools summary: Remove OmniRoute from Crush CLI config + x-loopback-only: true description: Local-only. Removes the OmniRoute provider block from Crush's config. x-internal: true responses: @@ -8284,6 +8305,7 @@ paths: tags: - CLI Tools summary: Read CodeWhale CLI OmniRoute config + x-loopback-only: true description: >- Local-only. Reads the OmniRoute config block from `~/.codewhale/config.toml` (with `~/.deepseek/config.toml` legacy @@ -8296,6 +8318,7 @@ paths: tags: - CLI Tools summary: Write CodeWhale CLI OmniRoute config + x-loopback-only: true description: Local-only. Writes the OmniRoute config block in CodeWhale TOML format. x-internal: true responses: @@ -8305,6 +8328,7 @@ paths: tags: - CLI Tools summary: Remove OmniRoute from CodeWhale CLI config + x-loopback-only: true description: Local-only. Removes the OmniRoute config block from CodeWhale's config. x-internal: true responses: @@ -8566,6 +8590,7 @@ paths: tags: - Cli tools summary: "GET cli tools › all statuses" + x-loopback-only: true responses: "200": description: OK @@ -8597,6 +8622,7 @@ paths: tags: - Cli tools summary: "DELETE cli tools › deepseek tui settings" + x-loopback-only: true responses: "200": description: OK @@ -8604,6 +8630,7 @@ paths: tags: - Cli tools summary: "GET cli tools › deepseek tui settings" + x-loopback-only: true responses: "200": description: OK @@ -8611,6 +8638,7 @@ paths: tags: - Cli tools summary: "POST cli tools › deepseek tui settings" + x-loopback-only: true responses: "200": description: OK @@ -8619,6 +8647,7 @@ paths: tags: - Cli tools summary: "GET cli tools › detect" + x-loopback-only: true responses: "200": description: OK @@ -8791,6 +8820,7 @@ paths: tags: - Cli tools summary: "DELETE cli tools › pi settings" + x-loopback-only: true responses: "200": description: OK @@ -8798,6 +8828,7 @@ paths: tags: - Cli tools summary: "GET cli tools › pi settings" + x-loopback-only: true responses: "200": description: OK @@ -8805,6 +8836,7 @@ paths: tags: - Cli tools summary: "POST cli tools › pi settings" + x-loopback-only: true responses: "200": description: OK @@ -8838,6 +8870,7 @@ paths: tags: - Cli tools summary: "DELETE cli tools › smelt settings" + x-loopback-only: true responses: "200": description: OK @@ -8845,6 +8878,7 @@ paths: tags: - Cli tools summary: "GET cli tools › smelt settings" + x-loopback-only: true responses: "200": description: OK @@ -8852,6 +8886,7 @@ paths: tags: - Cli tools summary: "POST cli tools › smelt settings" + x-loopback-only: true responses: "200": description: OK @@ -8860,6 +8895,7 @@ paths: tags: - Cli tools summary: "GET cli tools › status" + x-loopback-only: true responses: "200": description: OK @@ -11280,6 +11316,7 @@ paths: tags: - Settings summary: "DELETE settings › obsidian" + x-always-protected: true responses: "200": description: OK @@ -11287,6 +11324,7 @@ paths: tags: - Settings summary: "GET settings › obsidian" + x-always-protected: true responses: "200": description: OK @@ -11294,6 +11332,7 @@ paths: tags: - Settings summary: "POST settings › obsidian" + x-always-protected: true responses: "200": description: OK @@ -11302,6 +11341,7 @@ paths: tags: - Settings summary: "DELETE settings › obsidian › webdav" + x-always-protected: true responses: "200": description: OK @@ -11309,6 +11349,7 @@ paths: tags: - Settings summary: "GET settings › obsidian › webdav" + x-always-protected: true responses: "200": description: OK @@ -11316,6 +11357,7 @@ paths: tags: - Settings summary: "POST settings › obsidian › webdav" + x-always-protected: true responses: "200": description: OK @@ -11501,6 +11543,73 @@ paths: responses: "200": description: OK + /api/settings/proxies/pool/egress-observation: + get: + tags: + - Settings + summary: "GET settings › proxies › pool › egress observation" + description: >- + Read-only observation of how many distinct egress IPs actually served the members of a + proxy pool over the last 24 h, read from the proxy log (numbers only, never used for + routing). Opt-in through the PROXY_POOL_EGRESS_OBSERVATION feature flag: while it is + off, or when the read fails, the body is JSON null. Results are cached for 30 seconds + per normalized scope (key is read as account, global as the stored global pool). + security: + - ManagementSessionAuth: [] + parameters: + - name: scope + in: query + required: true + schema: + type: string + enum: + - global + - provider + - account + - combo + - key + - name: scopeId + in: query + required: false + description: Required for every scope except global; ignored for global. + schema: + type: string + maxLength: 256 + responses: + "200": + description: The observation, or null when the feature flag is off or the read failed. + content: + application/json: + schema: + type: object + nullable: true + required: + - connections + - distinctExits + - maxConnectionsOnOneExit + - windowHours + properties: + connections: + type: integer + minimum: 0 + description: Distinct OmniRoute connections logged through the pool members. + distinctExits: + type: integer + minimum: 0 + description: Distinct egress IPs observed for those members. + maxConnectionsOnOneExit: + type: integer + minimum: 0 + description: Most connections seen behind one egress IP over the window. + windowHours: + type: integer + example: 24 + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/ManagementAuthenticationRequired" + "500": + $ref: "#/components/responses/InternalError" /api/settings/proxy/cloudflare-deploy: post: tags: @@ -11709,6 +11818,7 @@ paths: tags: - Skills summary: "GET skills › executions" + x-loopback-only: true responses: "200": description: OK @@ -11716,6 +11826,7 @@ paths: tags: - Skills summary: "POST skills › executions" + x-loopback-only: true responses: "200": description: OK @@ -11724,6 +11835,7 @@ paths: tags: - Skills summary: "POST skills › install" + x-loopback-only: true responses: "200": description: OK diff --git a/docs/reference/API_REFERENCE.md b/docs/reference/API_REFERENCE.md index 0c470b525c..18cc55cacf 100644 --- a/docs/reference/API_REFERENCE.md +++ b/docs/reference/API_REFERENCE.md @@ -530,7 +530,12 @@ OpenAI-compatible files endpoint for batch input/output and file-purpose uploads | DELETE | `/v1/files/[id]` | Delete a file | | GET | `/v1/files/[id]/content` | Stream the raw file body back | -**Auth:** Bearer API key — files are scoped per-API-key via `getApiKeyRequestScope`. +**Auth:** Bearer API key — files are scoped per-API-key via `getApiKeyRequestScope`. A key +sees, downloads and deletes its own files only; a dashboard session without a key reads the +whole instance; a file with no owner (anonymous or dashboard-session upload) is denied to every +non-session caller. `GET /v1/files` rejects an anonymous caller — and a presented key that does +not resolve — with `401` even when `REQUIRE_API_KEY=false`, instead of listing every tenant's +files (GHSA-m3hp-hq9g-fpmv, GHSA-2jm2-mpx8-6523). --- @@ -546,7 +551,10 @@ OpenAI-compatible batch processing. | DELETE | `/v1/batches/[id]` | Delete a finished/failed batch | | POST | `/v1/batches/[id]/cancel` | Cancel an in-progress batch | -**Auth:** Bearer API key. Batches are scoped per-API-key. +**Auth:** Bearer API key. Batches are scoped per-API-key under the same three-way rule as +files: own key only, dashboard session instance-wide, null-owner records denied to every +non-session caller (retrieve, delete, cancel, and the `input_file_id` check on create). +`GET /v1/batches` rejects an anonymous caller with `401` even when `REQUIRE_API_KEY=false`. --- diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 5ac099334e..c4052e9748 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -363,6 +363,7 @@ Route upstream LLM provider calls through an HTTP or SOCKS5 proxy for egress con | ---------------------------------------- | --------- | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ENABLE_SOCKS5_PROXY` | `true` | `open-sse/executors` | Enable SOCKS5 proxy agent for upstream calls. Opt-out with `false`. | | `NEXT_PUBLIC_ENABLE_SOCKS5_PROXY` | `true` | Client-side | Client-side awareness of SOCKS5 availability. | +| `PROXY_SKIP_RECENTLY_FAILED` | `false` | `src/shared/utils/featureFlags.ts` | Opt-in feature flag (see [FEATURE_FLAGS.md](./FEATURE_FLAGS.md); a dashboard DB override wins). Proxy pools and per-account rotation stop re-serving a member that just failed (refused TCP probe, or a 429 through it) for a period that doubles on each repeat, up to a cap. `true` (or `1`, `yes`) enables it. | | `HTTP_PROXY` | _(unset)_ | Node.js standard | HTTP proxy for upstream calls. | | `HTTPS_PROXY` | _(unset)_ | Node.js standard | HTTPS proxy for upstream calls. | | `ALL_PROXY` | _(unset)_ | Node.js standard | Universal proxy (supports `socks5://`). | @@ -737,6 +738,7 @@ REQUEST_TIMEOUT_MS (global override) │ ├─→ FETCH_BODY_TIMEOUT_MS (inherits from FETCH_TIMEOUT_MS) │ ├─→ TLS_CLIENT_TIMEOUT_MS (inherits from FETCH_TIMEOUT_MS) │ │ └── TLS_FIRST_BYTE_WATCHDOG_MS (independent, default: 10000) +│ ├── RESPONSES_FIRST_BYTE_TIMEOUT_MS (independent, default: 15000) │ ├── FETCH_CONNECT_TIMEOUT_MS (independent, default: 30000) │ └── FETCH_KEEPALIVE_TIMEOUT_MS (independent, default: 4000) ├─→ STREAM_IDLE_TIMEOUT_MS (inherits from REQUEST_TIMEOUT_MS, default: 600000) @@ -775,6 +777,7 @@ REQUEST_TIMEOUT_MS (global override) | `FETCH_KEEPALIVE_TIMEOUT_MS` | `4000` | Keep-alive socket idle timeout. | | `TLS_CLIENT_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | TLS fingerprint proxy (wreq-js) timeout. | | `TLS_FIRST_BYTE_WATCHDOG_MS` | `10000` | Bounds time-to-first-byte on the wreq-js TLS-fingerprint transport's body specifically; `TLS_CLIENT_TIMEOUT_MS` alone cannot catch a stalled body since it resolves as soon as headers arrive (#12656). A timeout cancels the wreq reader and falls back to the direct/proxy dispatcher; `0` disables the watchdog. | +| `RESPONSES_FIRST_BYTE_TIMEOUT_MS` | `15000` | OpenCode executor only, and only while the `OPENCODE_RESPONSES_STALL_ROTATION` feature flag is on (default off): bounds the wait for the first body byte of a streamed Responses reply after its headers (#13484). A Responses stream opens with `response.created`, so silence past this window is a stall: the account is cooled down and the request rotates to the next account once; a second stall fails fast. `0` disables the guard even with the flag on. | | `API_BRIDGE_PROXY_TIMEOUT_MS` | `30000` | Proxy hop timeout for `/v1` bridge requests. | | `FIRECRAWL_BASE_URL` | `https://api.firecrawl.dev` | Point the Firecrawl web-fetch executor at a self-hosted instance (API key optional off-cloud). | | `FIRECRAWL_TIMEOUT_MS` | `30000` | Per-request timeout for the Firecrawl web-fetch executor. | @@ -1113,6 +1116,7 @@ Anthropic-compatible provider instead. | `PROXY_HEALTH_TEST_STAGGER_MS` | `100` | `src/lib/proxyHealth/probeTarget.ts` | Delay in ms between two probe departures inside a batch. Without it the whole batch leaves at the same moment and a shared egress IP can trip a rate-limited target. Set to `0` to disable the spacing; capped at 5000. | | `PROXY_HEALTH_USE_PROVIDER_TARGET` | `true` | `src/lib/proxyHealth/providerProbeTarget.ts` | Set "false" to stop probing the real host of a proxy's assigned provider (`GET /models`, no API key) and always use `PROXY_HEALTH_TEST_URL` instead. | | `PROXY_HEALTH_AUTO_DEACTIVATE` | `false` | `src/lib/proxyHealth/statusPolicy.ts` | When `false` (default), automated reachability probes (the scheduler + the `/api/settings/proxies/auto-test` "Test All" button) are **read-only** and never write a proxy's status — only the operator sets active/inactive, so a flaky probe can't strand an assigned proxy (#6246). Set `true` to restore the legacy test-and-set behaviour. | +| `PROXY_POOL_EGRESS_OBSERVATION` | `false` | `src/shared/utils/featureFlags.ts` | Opt-in feature flag (see [FEATURE_FLAGS.md](./FEATURE_FLAGS.md); a dashboard DB override wins). `true` (or `1`, `yes`) shows the read-only pool egress observation under a proxy pool in the dashboard (distinct egress IPs, connections and the most seen behind one IP over the last 24 h, from the proxy log). Never used for routing. | | `PROXY_AUTO_REMOVE` | `false` | `src/lib/proxyHealth/scheduler.ts` | Set `true` to let the scheduler auto-remove proxies after repeated consecutive failures. | | `PROXY_AUTO_REMOVE_AFTER` | `3` | `src/lib/proxyHealth/scheduler.ts` | Consecutive failures before the scheduler auto-removes a proxy (when `PROXY_AUTO_REMOVE=true`). | | `PROXY_AUTO_DISABLE` | `false` | `src/lib/proxyHealth/scheduler.ts` | Set `true` to let the scheduler soft-disable (status `dead`, never deleted) a proxy after repeated consecutive failures, instead of removing it. Non-destructive alternative to `PROXY_AUTO_REMOVE`: the proxy drops out of pool/rotation resolution immediately (the alive-status filter used by scope-pool resolution already excludes it) and is automatically re-activated once it starts passing probes again. Shares the `PROXY_AUTO_REMOVE_AFTER` threshold. If both flags are `true`, `PROXY_AUTO_REMOVE` wins. | diff --git a/docs/reference/FEATURE_FLAGS.md b/docs/reference/FEATURE_FLAGS.md index 65a5bcac12..7c203eae7d 100644 --- a/docs/reference/FEATURE_FLAGS.md +++ b/docs/reference/FEATURE_FLAGS.md @@ -46,7 +46,7 @@ A boolean flag is considered **enabled** when its effective value is `"true"`, ## Flag Catalog -55 flags across 6 categories. **Default** is the definition default — the value +69 flags across 6 categories. **Default** is the definition default — the value used when neither a DB override nor an environment variable is present. ### Security (10) @@ -64,7 +64,7 @@ used when neither a DB override nor an environment variable is present. | `AUTH_LOG_INCLUDE_ACCOUNT_ID` | boolean | `false` | Include account prefix in AUTH log lines (e.g. "Using account: abc12345..."). Disabled by default so account identifiers are redacted from shared/multi-tenant process logs. Independent from Debug Mode; flipping Debug Mode does not reveal this. | | `OMNIROUTE_OIDC_DISABLE_PASSWORD_LOGIN` | boolean | `false` | When OIDC is enabled, disable password login so users can only authenticate via OIDC Single Sign-On. When disabled (default), both password login and OIDC are available. | -### Network (9) +### Network (15) | Key | Type | Default | Restart | Description | | ----------------------------------------------- | ------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -73,6 +73,12 @@ used when neither a DB override nor an environment variable is present. | `PROXY_AUTO_SELECT_ENABLED` | boolean | `false` | | When no proxy is assigned to a connection, auto-select the first working proxy from the registry. Off by default (otherwise any registry proxy becomes a global fallback — #3332). | | `OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK` | boolean | `false` | | Allow OAuth and provider validation flows to bypass a pinned proxy and connect directly when proxy reachability pre-checks fail. Off by default because this can change egress IP. | | `NETWORK_ROTATION_SHARED_EGRESS_GUARD` | boolean | `true` | | On a network exception (timeout, connection refused/reset) for a multi-account rotation executor, when the failing account has no dedicated proxy, apply a short cooldown and skip other proxy-less accounts for the rest of the request instead of retrying each one. On by default (safe: no egress IP change, only reduces latency/cooldown risk on shared-egress accounts). Disable to restore immediate propagation on the first proxy-less throw. | +| `PROXY_SKIP_RECENTLY_FAILED` | boolean | `false` | | Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default. | +| `PROXY_POOL_EGRESS_OBSERVATION` | boolean | `false` | | Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h and how many connections used them. Read-only, computed from the proxy log, never used for routing. Off by default. | +| `OPENCODE_RESPONSES_STALL_ROTATION` | boolean | `false` | | For the OpenCode executor, watch the first body byte of a streamed Responses reply (window: `RESPONSES_FIRST_BYTE_TIMEOUT_MS`, default `15000`). A 2xx Responses stream that stays silent past the window is treated as stalled: the account is cooled down and the request rotates to the next account once; a second stall fails fast. Off by default: stalled streams keep today's wait until the stream readiness timeout. | +| `OPENCODE_USER_BLOCKED_ROTATION` | boolean | `false` | | OpenCode executor: on a 403/451 carrying a `user_blocked` refusal (not geo, not a Cloudflare fingerprint rejection), cool the refused account down and rotate to the next account at most once per request; a second refusal is returned as-is, without a success mark. Off by default: routing around an upstream user block can look like evasion and spread the flag across the fleet. | +| `OPENCODE_TRANSIENT_FAILOVER_BACKOFF` | boolean | `false` | | OpenCode rotation: after two consecutive transient upstream failures (5xx or an empty 400), pause before the next account — 1.5s doubling per further failure, capped at 6s per pause and 10s per request, skipped on client disconnect; the failed body is released before waiting. Off by default: failover stays immediate. | +| `OPENCODE_RATE_LIMITED_429_EARLY_STOP` | boolean | `false` | | OpenCode rotation: stop the account wave at the first 429 classified as a real rate limit (parseable `Retry-After`, or a body naming a rate/usage limit) and return that upstream 429 unchanged. Unclassified 429s keep rotating. Off by default: the free tier is limited per egress IP (#9611), so every 429 rotates and an exhausted wave returns the last upstream 429. | | `MITM_DISABLE_TLS_VERIFY` | boolean | `false` | ✓ | Disable TLS certificate verification for the MITM proxy. **Danger.** | | `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` | boolean | `false` | | Allow provider URLs pointing to private/internal networks. | | `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` | boolean | `true` | | Allow adding/validating providers on local/private addresses (127.0.0.1, localhost, LAN). On by default (local-first); disable for strict public-only blocking. Cloud-metadata stays blocked. | @@ -88,7 +94,7 @@ used when neither a DB override nor an environment variable is present. | `CAPABILITY_FILTER_ENABLED` | boolean | `false` | Reject requests before dispatch when the target model lacks required capabilities (vision, tools, structured output, context window). Protects direct single-provider requests that bypass the combo-layer compatibility filter. | | `RADAR_ENABLED` | boolean | `false` | Enable the OmniRoute Radar module (catalog feed screens and sync). Off by default; enabling only unlocks the UI — data sync remains a separate opt-in. | -### Runtime (23) +### Runtime (30) | Key | Type | Default | Restart | Description | | ------------------------------------------- | ------- | ------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -105,6 +111,8 @@ used when neither a DB override nor an environment variable is present. | `OMNIROUTE_EMERGENCY_FALLBACK` | boolean | `true` | | Route budget-exhausted requests to the emergency free fallback provider/model. (See [Emergency Budget Fallback](#emergency-budget-fallback) below.) | | `STREAM_RECOVERY_ENABLED` | boolean | `false` | | Enable transparent early retry for truncated upstream SSE streams before any response bytes reach the client. | | `STREAM_RECOVERY_MIDSTREAM_ENABLED` | boolean | `false` | | Allow stream recovery to re-request and stitch a response after bytes have already reached the client. | +| `STREAM_RECOVERY_TOOLCALL_ORDER_FIX` | boolean | `false` | | Make mid-stream continuation tool-call safe: never resume a cut stream once a tool call was emitted (in flight or already finished with finish_reason tool_calls), and close after one empty continuation instead of spending the whole budget. Off: release behavior. | +| `STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED` | boolean | `false` | | Fail over once to a sibling connection when an SSE stream closes before emitting any useful frame and the bounded same-connection retry is spent; with no usable sibling the original `STREAM_EARLY_EOF` 502 is returned. Off by default: early-EOF stays terminal after the same-connection retry. | | `MODEL_CATALOG_INCLUDE_NAMES` | boolean | `true` | | Include display-friendly name fields in `/v1/models` responses. Disable for clients that expect model IDs only. | | `MODELS_CATALOG_PREFIX_MODE` | enum | `dual` | | Controls how model IDs are prefixed in /v1/models. 'dual' (default) emits both alias and canonical provider-id prefixes for backward compatibility. 'alias' emits only the short alias prefix (e.g. ds-web/model, not deepseek-web/model). 'canonical' emits only the full provider-id prefix. Values: `dual`, `alias`, `canonical`. | | `ARENA_ELO_SYNC_ENABLED` | boolean | `true` | | Enable periodic Arena AI leaderboard ELO sync for model intelligence rankings. | @@ -115,6 +123,11 @@ used when neither a DB override nor an environment variable is present. | `EXPOSE_FUNCTIONAL_GATEWAY_MIRRORS` | boolean | `false` | | Advertise / mirror ids on /v1/models for models whose canonical owner has no active credential but a passthrough gateway with an active credential routes them. Warning: adds catalog entries for all clients when enabled globally. | | `NEWAPI_AGGREGATOR_BALANCE` | boolean | `false` | | Enable balance detection for New-API / One-API / Sub2API aggregator compatible nodes. When enabled, compatible nodes with the aggregator flag set will report their balance in the dashboard and quota-preflight routing. | | `SERVER_OWNED_TOOL_LOOP_ENABLED` | boolean | `false` | | Continue non-streaming server-owned tool calls until the model returns a client-usable response. | +| `SEARCH_STATS_HIDE_DELETED_CONNECTIONS` | boolean | `false` | | Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id. | +| `FREE_BADGE_REQUIRES_PROVIDER_FREE_TIER` | boolean | `false` | | Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule. | +| `RETRY_AFTER_PROVENANCE_ENABLED` | boolean | `false` | | On aggregated 429/503 unavailable responses, omit `Retry-After` when no concrete future retry time is known (instead of a synthetic 1s), add `error.retry_after_provenance` (`signal` \| `none`), and let combo drain paths read prose retry hints from JSON and plain-text upstream bodies. The field only appears on responses built by `unavailableResponse()`; other 429/503 bodies are unchanged. | +| `PROTECTED_PRIORITY_INFRA_502_ENABLED` | boolean | `false` | | When a `priority` combo target marked fallback-only-on-quota-exhaustion stops the combo for a cause that is provably not quota (provider circuit breaker open, predictive latency skip), answer 502 instead of the quota-looking 503. Lockout, cooldown, unavailable, exhaustion and concurrency-cap stops keep 503. | +| `MISTRAL_AMBIGUOUS_401_SOFT_LOCKOUT` | boolean | `false` | | A bare Mistral 401 (`{"detail":"Unauthorized"}`, no explicit auth signal) is identical for a revoked key and for exhausted quota. When on, it cools the connection down instead of parking it as `expired`, at most 3 times per hour per connection; the next one parks it, so a revoked key still converges. Off by default: every bare Mistral 401 parks the connection as before. | ### CLI (5) @@ -126,13 +139,14 @@ used when neither a DB override nor an environment variable is present. | `OMNIROUTE_AUTO_SYNC_CODEX_PROFILES` | boolean | `false` | | After a provider model sync, automatically (re)write ~/.codex/*.config.toml profile files from the live catalog. Never changes the active/default Codex config. Off by default. | | `OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES` | boolean | `false` | | After a provider model sync, automatically (re)write ~/.claude/profiles//settings.json Claude Code profiles from the live catalog. Never changes the active/default Claude config. Off by default. | -### Health (3) +### Health (4) -| Key | Type | Default | Description | -| ------------------------------------- | ------- | ------- | -------------------------------------------------------- | -| `OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK` | boolean | `false` | Disable the local instance health check endpoint. | -| `OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK` | boolean | `false` | Disable the token validation health check. | -| `SKILLS_SANDBOX_NETWORK_ENABLED` | boolean | `false` | Enable network access in the skills sandbox environment. | +| Key | Type | Default | Description | +| ------------------------------------- | ------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK` | boolean | `false` | Disable the local instance health check endpoint. | +| `OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK` | boolean | `false` | Disable the token validation health check. | +| `SKILLS_SANDBOX_NETWORK_ENABLED` | boolean | `false` | Enable network access in the skills sandbox environment. | +| `PROXY_HEALTH_BLOCKED_RESETS_STREAK` | boolean | `false` | In the proxy health sweep, a probe the target refused (401/403/429) resets the proxy's consecutive-failure streak. Off by default: a refusal stays neutral (#10654). A 5xx stays inconclusive either way; a refusal never removes, disables or re-activates a proxy. | > [!NOTE] > `INPUT_SANITIZER_BLOCK_THRESHOLD` and its legacy alias @@ -195,10 +209,10 @@ Returns every flag with its effective value, source, and a summary. "requiresRestart": false, "warningLevel": "caution", }, - // ... all 55 flags + // ... all 69 flags ], "summary": { - "total": 54, + "total": 56, "active": 0, "inactive": 0, "overriddenByDb": 0, diff --git a/docs/security/ROUTE_GUARD_TIERS.md b/docs/security/ROUTE_GUARD_TIERS.md index fe1ca48ba2..15b25fba0f 100644 --- a/docs/security/ROUTE_GUARD_TIERS.md +++ b/docs/security/ROUTE_GUARD_TIERS.md @@ -39,41 +39,44 @@ spawn-capable route: a leaked token over a tunnel still can't reach the spawn. `check-route-guard-membership` gate enumerates every `route.ts` under the spawn-capable prefixes and fails CI if any is not classified local-only. -| Prefix / pattern | Why it's local-only | -| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -| `/api/mcp/` | MCP server — spawns stdio bridges + SSE handlers | -| `/api/cli-tools/runtime/` | CLI tool runtime — executes arbitrary plugin code | -| `/api/cli-tools/{omp,letta,grok-build,forge,jcode,qwen}-settings` | Per-tool settings writers that can touch tool binaries/config on the host | -| `/api/cli-tools/antigravity-mitm` | Antigravity MITM proxy control (spawns/points system proxy) | -| `/api/modality-bridge/video/` | Strict trusted-loopback Video Bridge runtime probe and internal extraction bridge | -| `/api/services/` | Embedded services (9Router / CLIProxy / Bifrost / Mux / Dario) — `npm install` + spawn | -| `/dashboard/providers/services/` | Reverse proxy to embedded-service UIs | -| `/api/tunnels/cloudflared` | Installs/spawns the cloudflared binary | -| `/api/tunnels/tailscale/{install,enable,disable,login,start-daemon}` | Installs/controls tailscaled on the host | -| `/api/copilot/` | Unauthenticated LLM driver — CLI-only by default | -| `/api/tools/agent-bridge/` | AgentBridge — spawns MITM server + DNS edits | -| `/api/tools/traffic-inspector/` | Traffic Inspector — http-proxy listener + system proxy | -| `/api/settings/mitm` | Enables MITM interception (system-level proxy state) | -| `/api/issue-agent/` | Issue agent — spawns local tooling against the repo | -| `/api/plugins/`, `/api/plugins` | Plugins — load/execute via `worker_threads` + `child_process` | -| `/api/middleware/` | User middleware — loads/executes operator code in-process | -| `/api/system/version` | Auto-update (POST only; GET/HEAD/OPTIONS exempt) — spawns `git checkout` + `npm install` | -| `/api/db-backups/exportAll` | Spawns `tar` for the export archive | -| `/api/local/` | 1-click local launchers (Redis today) — spawns podman/docker | -| `/api/headroom/start`, `/api/headroom/stop` | Headroom proxy lifecycle — spawns python CLI / signals PID | -| `/api/jobs`, `/api/jobs/` | Job runner control — executes scheduled host-side work | -| `/api/oauth/cursor/auto-import` | `execFile("which", ["cursor"])` before importing creds | -| `/api/oauth/kiro/auto-import` | Reads Kiro CLI credential files from the host | -| `/api/skills/collect/` | Skill collection — detects/installs local tooling | -| `/api/discovery/` | Local network/provider discovery probes | -| `/api/vnc-session` (`VNC_ROUTE_PREFIX`) | Spawns a headful browser + VNC session for interactive logins | -| `/api/acp/agents` | ACP — discovers and spawns local CLI agent binaries | -| `/api/resilience/connections`, `/dashboard/resilience/connections` | Connection maintenance actions that can touch local CLI state | -| `/api/providers/cursor/agent-availability` | Dashboard install-nudge check — spawns `cursor-agent status --format json` | -| `/api/providers/{id}/login` (regex) | Launches a headful Playwright Chromium for web-cookie login | -| `/api/providers/volcengine-plan/connect` (regex) | Manual headful flow + session-based phone/SMS auto-login (spawns Playwright) | -| `/api/providers/{id}/refresh-cursor` (regex) | Manual Cursor session renewal — nudges `cursor-agent` | -| `/api/providers/{id}/chatgpt-web-codex-doctor` (regex) | Diagnoses the local Codex CLI install (spawns the binary) | +| Prefix / pattern | Why it's local-only | +| -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| `/api/mcp/` | MCP server — spawns stdio bridges + SSE handlers | +| `/api/cli-tools/runtime/` | CLI tool runtime — executes arbitrary plugin code | +| `/api/cli-tools/{omp,letta,grok-build,forge,jcode,qwen}-settings` | Per-tool settings writers that can touch tool binaries/config on the host | +| `/api/cli-tools/{claude,cline,codewhale,codex,crush,deepseek-tui,droid,kilo,openclaw,pi,smelt}-settings` | Same `getCliRuntimeStatus()` spawn as the six siblings above (GHSA-35fw-cv32-2373) | +| `/api/cli-tools/{all-statuses,status,detect}` | CLI inventory probes — spawn `command -v` / `--version` per tool (GHSA-35fw-cv32-2373) | +| `/api/cli-tools/antigravity-mitm` | Antigravity MITM proxy control (spawns/points system proxy) | +| `/api/modality-bridge/video/` | Strict trusted-loopback Video Bridge runtime probe and internal extraction bridge | +| `/api/services/` | Embedded services (9Router / CLIProxy / Bifrost / Mux / Dario) — `npm install` + spawn | +| `/dashboard/providers/services/` | Reverse proxy to embedded-service UIs | +| `/api/tunnels/cloudflared` | Installs/spawns the cloudflared binary | +| `/api/tunnels/tailscale/{install,enable,disable,login,start-daemon}` | Installs/controls tailscaled on the host | +| `/api/copilot/` | Unauthenticated LLM driver — CLI-only by default | +| `/api/tools/agent-bridge/` | AgentBridge — spawns MITM server + DNS edits | +| `/api/tools/traffic-inspector/` | Traffic Inspector — http-proxy listener + system proxy | +| `/api/settings/mitm` | Enables MITM interception (system-level proxy state) | +| `/api/issue-agent/` | Issue agent — spawns local tooling against the repo | +| `/api/plugins/`, `/api/plugins` | Plugins — load/execute via `worker_threads` + `child_process` | +| `/api/middleware/` | User middleware — loads/executes operator code in-process | +| `/api/system/version` | Auto-update (POST only; GET/HEAD/OPTIONS exempt) — spawns `git checkout` + `npm install` | +| `/api/db-backups/exportAll` | Spawns `tar` for the export archive | +| `/api/local/` | 1-click local launchers (Redis today) — spawns podman/docker | +| `/api/headroom/start`, `/api/headroom/stop` | Headroom proxy lifecycle — spawns python CLI / signals PID | +| `/api/jobs`, `/api/jobs/` | Job runner control — executes scheduled host-side work | +| `/api/oauth/cursor/auto-import` | `execFile("which", ["cursor"])` before importing creds | +| `/api/oauth/kiro/auto-import` | Reads Kiro CLI credential files from the host | +| `/api/skills/collect/` | Skill collection — detects/installs local tooling | +| `/api/skills/install`, `/api/skills/executions` | Skill handler registration + execution — reach the sandbox container spawn (GHSA-jx89) | +| `/api/discovery/` | Local network/provider discovery probes | +| `/api/vnc-session` (`VNC_ROUTE_PREFIX`) | Spawns a headful browser + VNC session for interactive logins | +| `/api/acp/agents` | ACP — discovers and spawns local CLI agent binaries | +| `/api/resilience/connections`, `/dashboard/resilience/connections` | Connection maintenance actions that can touch local CLI state | +| `/api/providers/cursor/agent-availability` | Dashboard install-nudge check — spawns `cursor-agent status --format json` | +| `/api/providers/{id}/login` (regex) | Launches a headful Playwright Chromium for web-cookie login | +| `/api/providers/volcengine-plan/connect` (regex) | Manual headful flow + session-based phone/SMS auto-login (spawns Playwright) | +| `/api/providers/{id}/refresh-cursor` (regex) | Manual Cursor session renewal — nudges `cursor-agent` | +| `/api/providers/{id}/chatgpt-web-codex-doctor` (regex) | Diagnoses the local Codex CLI install (spawns the binary) | **Response on violation:** `403 LOCAL_ONLY` @@ -166,9 +169,30 @@ server process. | `/api/settings/export-json` | Exports the full settings blob (incl. secrets) | | `/api/settings/import-json` | Replaces the full settings blob | | `/api/providers/health-autopilot/actions` | Executes autopilot remediation actions | +| `/api/settings/obsidian` | Mints reusable WebDAV creds for any vault root | **Response on violation:** `401 Authentication required` +`/api/settings/obsidian` covers its `/webdav` child: `POST` points the WebDAV file service — +served by the custom Node layer before Next.js, outside this pipeline — at a caller-chosen root +and echoes freshly minted Basic credentials, `DELETE` rotates them, and the parent `POST` stores +the Obsidian REST API token. GHSA-62vw only masked the `GET` password reveal; the issuance was +still on the fail-open tier (GHSA-7pq4-8pvv-rx7r). `enableObsidianVaultSync()` additionally +refuses a vault that is, sits inside, or contains the data directory. + +### Fresh-install bootstrap is loopback-only — by real peer, not `Host` + +With no management password configured (and no `INITIAL_PASSWORD`), `isAuthRequired()` in +`src/shared/utils/apiAuth.ts` keeps the anonymous bootstrap open **only for loopback peers**. +Loopback is decided from the trusted peer signals, in order: the token-stamped real TCP peer +(`PEER_IP_HEADER` + `VIA_PROXY_HEADER`, what the policy sees), the pipeline's own +`AUTHZ_HEADER_PEER_LOCALITY` verdict (what route handlers see, trusted only while +`OMNIROUTE_PEER_STAMP_TOKEN` is set), or a real socket peer for direct callers. `Host` / +`nextUrl.hostname` are never consulted, and the first-password write +(`POST /api/settings/require-login`) is under the same constraint rather than open to every +network peer (GHSA-7pq4-8pvv-rx7r). `managementPolicy` passes its own `peerContext` verdict +down explicitly, so the ORIGINAL (pre-strip) request's headers never decide it. + ### Tier 3 — MANAGEMENT (default) All other management routes. Auth required unless `requireLogin=false` is diff --git a/llm.txt b/llm.txt index 4ff0db6357..4d7f1d1f63 100644 --- a/llm.txt +++ b/llm.txt @@ -14,7 +14,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 175 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 176 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -124,7 +124,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 175 versioned SQL migration files +│ │ │ └── migrations/ # 176 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -389,7 +389,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 175 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 176 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -433,7 +433,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 175 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 176 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts index ce8ea29b60..9c18e7945a 100644 --- a/open-sse/executors/opencode.ts +++ b/open-sse/executors/opencode.ts @@ -28,9 +28,34 @@ import { isEmptyUpstreamRejection, extractChatcmplId, } from "./accountRotation.ts"; -import { isOpencodeGeoBlocked, proxyKeyOf } from "./opencodeGeoBlock.ts"; -import { isRetriableUpstreamFailure } from "./opencodeTransientFailure.ts"; -import { isNetworkRotationSharedEgressGuardEnabled } from "@/shared/utils/featureFlags"; +import { isOpencodeGeoBlocked, proxyKeyOf, isOpencodeUserBlocked } from "./opencodeGeoBlock.ts"; +import { + guardResponsesStall, + isResponsesFirstByteTimeout, + resolveResponsesStallWindowMs, +} from "./opencodeResponsesStall.ts"; +import { discardResponseBody } from "./opencodeResponseBody.ts"; +import { + isRetriableUpstreamFailure, + releaseResponseBody, + sleepAbortable, + transientRetryDelayMs, +} from "./opencodeTransientFailure.ts"; +import { + hasProxyRefusals, + isProxyAvoided, + noteProxyRefusal, + noteProxyServed, + proxyEgressKey, +} from "../utils/proxyRefusalMemory.ts"; +import { + isNetworkRotationSharedEgressGuardEnabled, + isProxySkipRecentlyFailedEnabled, + isOpencodeUserBlockedRotationEnabled, + isOpencodeTransientFailoverBackoffEnabled, + isOpencodeRateLimited429EarlyStopEnabled, +} from "@/shared/utils/featureFlags"; +import { classifyUpstream429 } from "./opencodeRateLimited.ts"; /** * The main OpenCode Zen host, shared by the `opencode` and `opencode-zen` @@ -294,6 +319,10 @@ export class OpencodeExecutor extends BaseExecutor { // pickRotatableAccount(), which needs a plain `{ nextAccountIdx }` shape — // TS's private-member nominal check rejects `this` there otherwise. nextAccountIdx = 0; + // Sleep used by the opt-in transient failover pause (#13615). Not `private`: + // tests swap in a recording fake instead of waiting on real timers. + transientPauseSleep: (ms: number, signal?: AbortSignal | null) => Promise = + sleepAbortable; constructor(provider: string) { super(provider, PROVIDERS[provider] || PROVIDERS.openai); @@ -352,6 +381,9 @@ export class OpencodeExecutor extends BaseExecutor { private markSuccess(account: OpencodeAccountState): void { markAccountSuccess(account); + // A response came back through this proxy: it is usable again for every refusal kind. + // Nothing is held unless PROXY_SKIP_RECENTLY_FAILED was on, so this costs no flag read. + if (hasProxyRefusals()) noteProxyServed(proxyEgressKey(account.proxy)); } /** @@ -521,6 +553,9 @@ export class OpencodeExecutor extends BaseExecutor { const cid = input.correlationId ? `correlationId=${input.correlationId} ` : ""; const hasProxies = this.accounts.some((a) => a.proxy !== null); + // Opt-in Responses first-byte stall guard (#13484); a no-op when the window is 0. + const stallWindowMs = resolveResponsesStallWindowMs(input.stream, this._requestFormat); + const guardStall = (r: T) => guardResponsesStall(r, stallWindowMs, input.signal); // Fast path: no multi-account proxy wiring configured → original behavior, // plus exactly ONE bounded retry when the upstream answers a 400 empty // rejection (same predicate and logging as the rotation loop). Everything @@ -533,9 +568,9 @@ export class OpencodeExecutor extends BaseExecutor { // Only pin direct egress when no such context exists; otherwise let the // ambient proxy stand instead of clobbering it with the direct sentinel. const dispatch = () => super.execute(input); - const single = (await (hasAmbientProxyContext() - ? dispatch() - : runWithDirectFetchContext(dispatch))) as HttpExecuteResult; + const single = (await guardStall( + await (hasAmbientProxyContext() ? dispatch() : runWithDirectFetchContext(dispatch)) + )) as HttpExecuteResult; if (single.response.status === 400) { let bodyText: string | null = null; try { @@ -550,7 +585,10 @@ export class OpencodeExecutor extends BaseExecutor { "OPENCODE", `${cid}upstream empty rejection on direct account (${chatcmplId}), retrying once…` ); - return this.normalizeMuseSparkResponse(input, await super.execute(input)); + return this.normalizeMuseSparkResponse( + input, + await guardStall(await super.execute(input)) + ); } log?.debug?.( "OPENCODE", @@ -586,7 +624,21 @@ export class OpencodeExecutor extends BaseExecutor { // model (geo-blocked, or transient 5xx). Request-local only — nothing // persists past execute(). const geoTriedProxyKeys = new Set(); + // Opt-in (PROXY_SKIP_RECENTLY_FAILED, default off): members the provider just refused + // (received refusal or refused TCP probe) are skipped. Off = plain rotation. + const skipRecentlyFailed = isProxySkipRecentlyFailedEnabled(); let directTried = false; + // Stalls before the first Responses byte: one rotation, then fail fast. + let stalledAttempts = 0; + // A response an opt-in branch rotated away from. It stays lastResult (and + // intact) until a newer attempt replaces it, then its body is cancelled. + let abandonedResponse: Response | null = null; + // OPENCODE_USER_BLOCKED_ROTATION: rotations spent on user_blocked refusals (max 1). + let userBlockedRotations = 0; + // Consecutive transient failures (5xx / empty 400) and the pause time spent on + // them this request — only acted on when OPENCODE_TRANSIENT_FAILOVER_BACKOFF is on. + let transientStreak = 0; + let transientPausedMs = 0; for (let attempt = 0; attempt < this.accounts.length + emptyRejectionBudget; attempt++) { const isProxiedCandidate = (a: OpencodeAccountState): boolean => { @@ -594,6 +646,7 @@ export class OpencodeExecutor extends BaseExecutor { // Without any geo evidence this pass, every cooldown-ready account // stays eligible (preserves the plain round-robin first pick). if (a.proxy === null) return !directTried || geoTriedProxyKeys.size === 0; + if (skipRecentlyFailed && isProxyAvoided(proxyEgressKey(a.proxy))) return false; const k = proxyKeyOf(a.proxy); return k !== null && !geoTriedProxyKeys.has(k); }; @@ -639,6 +692,19 @@ export class OpencodeExecutor extends BaseExecutor { continue; } + // Opt-in (#13615): after repeated transient failures, release the failed body + // and wait (bounded) before the next account; a client abort stops the loop. + const pauseMs = transientRetryDelayMs(transientStreak, transientPausedMs); + if (pauseMs > 0 && lastResult !== null && isOpencodeTransientFailoverBackoffEnabled()) { + lastResult = { ...lastResult, response: releaseResponseBody(lastResult.response) }; + transientPausedMs += pauseMs; + log?.info?.( + "OPENCODE", + `${cid}${transientStreak} transient failures, pausing ${pauseMs}ms` + ); + if (!(await this.transientPauseSleep(pauseMs, input.signal))) break; + } + // #5217 (Gap 2): promoted debug→info so the per-request account/proxy // rotation selection is visible in the Console log view at the default // APP_LOG_LEVEL=info (users could not see which account/proxy was used). @@ -659,11 +725,30 @@ export class OpencodeExecutor extends BaseExecutor { // super.execute() here always dispatches the HTTP path (opencode is an // OpenAI-compatible API, never the web/scraping bare-Response arm) — // see base.ts:290-294. - result = (await runWithProxyContext(account.proxy, () => - super.execute({ ...input, skipUpstreamRetry: true }) + result = (await guardStall( + await runWithProxyContext(account.proxy, () => + super.execute({ ...input, skipUpstreamRetry: true }) + ) )) as HttpExecuteResult; } catch (err) { const reason = err instanceof Error ? err.message : String(err); + // Stall guard: headers arrived, so the egress works — never a shared-egress + // outage; proxied and proxy-less accounts rotate alike. A client abort never rotates. + if (stallWindowMs > 0 && (isResponsesFirstByteTimeout(err) || input.signal?.aborted)) { + if (input.signal?.aborted) throw err; + this.markCooldown(account); + const stallKey = proxyKeyOf(account.proxy); + if (stallKey !== null) geoTriedProxyKeys.add(stallKey); + else directTried = true; + const rotate = ++stalledAttempts === 1; + log?.warn?.( + "OPENCODE", + `${cid}Responses stream stalled on account ${masked}, ${rotate ? "rotating to next…" : "not rotating again"} (${reason})` + ); + if (!rotate) throw err; + continue; + } + transientStreak = 0; // A network exception (timeout, connection refused/reset) is only // account-scoped when this account has its OWN egress (a configured // proxy) — that's the case a dead/unreachable proxy justifies rotating @@ -694,14 +779,38 @@ export class OpencodeExecutor extends BaseExecutor { ); continue; } + discardResponseBody(abandonedResponse); + abandonedResponse = null; lastResult = result; + const priorTransientStreak = transientStreak; + transientStreak = 0; const status = result.response.status; if (status === 429) { this.markCooldown(account); + // The provider refused through this member: set it aside beyond the account + // cooldown. A direct account has a null key and is never set aside. + const setAsideMs = skipRecentlyFailed + ? noteProxyRefusal(proxyEgressKey(account.proxy), "ip_quota_429") + : null; + // Opt-in (#13657): a 429 that names a real rate limit stops the wave and + // the real upstream 429 is returned untouched (body, Retry-After, quota + // headers), so provider error rules still apply. Flag off → rotate. + if ( + isOpencodeRateLimited429EarlyStopEnabled() && + (await classifyUpstream429(result.response)) === "rate_limited" + ) { + log?.warn?.( + "OPENCODE", + `${cid}rate-limited 429 on account ${masked}, stopping the account wave` + ); + return result; + } log?.warn?.( "OPENCODE", - `${cid}Rate limited (429) on account ${masked}, rotating to next…` + `${cid}Rate limited (429) on account ${masked}` + + (setAsideMs ? `, member set aside for ${Math.round(setAsideMs / 1000)}s` : "") + + ", rotating to next…" ); continue; } @@ -710,6 +819,7 @@ export class OpencodeExecutor extends BaseExecutor { const key = proxyKeyOf(account.proxy); if (key !== null) geoTriedProxyKeys.add(key); else directTried = true; + transientStreak = priorTransientStreak + 1; log?.warn?.( "OPENCODE", `${cid}transient upstream ${status} on account ${masked} (proxy ${key ?? "direct"}), rotating to next…` @@ -744,6 +854,29 @@ export class OpencodeExecutor extends BaseExecutor { if (this.accounts.length === 1) return result; continue; } + // Opt-in (#13498): an upstream user_blocked refusal (403 or 451, same + // predicate) cools the refused account down, joins the tried-set and + // rotates at most once per request. Never a success mark. Flag off → + // falls through to the unchanged path below. + if ( + bodyText !== null && + isOpencodeUserBlocked(status, bodyText) && + isOpencodeUserBlockedRotationEnabled() + ) { + const key = proxyKeyOf(account.proxy); + if (key !== null) geoTriedProxyKeys.add(key); + else directTried = true; + this.markCooldown(account); + const rotate = userBlockedRotations === 0 && this.accounts.length > 1; + log?.warn?.( + "OPENCODE", + `${cid}user_blocked ${status} on account ${masked} (proxy ${key ?? "direct"}), ${rotate ? "rotating to next account once…" : "returning the refusal"}` + ); + if (!rotate) return result; + userBlockedRotations++; + abandonedResponse = result.response; + continue; + } } // Empty upstream rejection (malformed 400: no error field, no real @@ -762,6 +895,7 @@ export class OpencodeExecutor extends BaseExecutor { } if (bodyText !== null && isRetriableUpstreamFailure(400, bodyText)) { const chatcmplId = extractChatcmplId(bodyText); + transientStreak = priorTransientStreak + 1; log?.warn?.( "OPENCODE", `${cid}upstream empty rejection on account ${masked} (${chatcmplId}), rotating to next…` @@ -789,7 +923,10 @@ export class OpencodeExecutor extends BaseExecutor { } // All accounts returned 429 (or errored) — surface the last response. - return this.normalizeMuseSparkResponse(input, lastResult ?? (await super.execute(input))); + return this.normalizeMuseSparkResponse( + input, + lastResult ?? (await guardStall(await super.execute(input))) + ); } finally { this._requestFormat = null; } diff --git a/open-sse/executors/opencodeGeoBlock.ts b/open-sse/executors/opencodeGeoBlock.ts index 71f911d2ae..dfd5cf4e56 100644 --- a/open-sse/executors/opencodeGeoBlock.ts +++ b/open-sse/executors/opencodeGeoBlock.ts @@ -11,6 +11,12 @@ // (2026-09-07 — app.log: "This model is not available in your country."); // siblings cover the same class, not the single incident. No bare "in your // country/region": location text without the full prefix is not a geo signal. +// `user_blocked` refusal (observed 2026-09-13 — upstream 403 with this token). +// Rotation on it is opt-in (OPENCODE_USER_BLOCKED_ROTATION) and bounded to one +// hop; when enabled it reuses the geo tried-set. 403 and 451 are classified the +// same way. Literal exact token only; `user-blocked` / `user blocked` are +// unobserved phrasings (fail closed). +const USER_BLOCKED_SIGNAL = "user_blocked"; const GEO_SIGNALS = [ "not available in your country", "not available in your region", @@ -49,6 +55,14 @@ export function isOpencodeGeoBlocked(status: number, bodyText: string): boolean return GEO_SIGNALS.some((signal) => lower.includes(signal)); } +/** 403 or 451 whose body carries the user_blocked token and is not a geo block or 1010 rejection. */ +export function isOpencodeUserBlocked(status: number, bodyText: string | null): boolean { + if (status !== 403 && status !== 451) return false; + const text = String(bodyText || ""); + if (isFingerprintRejection(text) || isOpencodeGeoBlocked(status, text)) return false; + return text.toLowerCase().includes(USER_BLOCKED_SIGNAL); +} + export function proxyKeyOf(proxy: { host: string; port: number } | null): string | null { if (!proxy) return null; return `${proxy.host}:${proxy.port}`; diff --git a/open-sse/executors/opencodeRateLimited.ts b/open-sse/executors/opencodeRateLimited.ts new file mode 100644 index 0000000000..0bf8e7dec3 --- /dev/null +++ b/open-sse/executors/opencodeRateLimited.ts @@ -0,0 +1,92 @@ +/** + * opencodeRateLimited.ts — 429 classifier for the opencode executor loop (#13657). + * + * Leaf module: zero imports, no registry, no DB. Headers first: a parseable + * Retry-After alone marks a real rate limit; otherwise a bounded prefix of the + * body is matched against generic English rate-limit phrasings (derived from a + * captured 429 body). Anything else is a "burst" 429 that keeps the normal + * cross-account rotation. Only consulted when OPENCODE_RATE_LIMITED_429_EARLY_STOP + * is on; the classifier never rewrites the response it inspects. + */ + +const RATE_LIMITED_SIGNALS: ReadonlyArray = [ + /rate.?limited/i, + /usage.?limit/i, + /too many requests/i, +]; + +/** Bytes of a 429 body inspected by the classifier. */ +export const RATE_LIMIT_BODY_SNIFF_BYTES = 8192; + +/** Seconds until retry from a Retry-After value (delta-seconds or HTTP date), or null. */ +export function parseRetryAfterSeconds( + retryAfter: string | number | null | undefined, + now = Date.now() +): number | null { + if (typeof retryAfter === "number") { + return Number.isFinite(retryAfter) && retryAfter > 0 ? Math.ceil(retryAfter) : null; + } + if (typeof retryAfter !== "string") return null; + const text = retryAfter.trim(); + if (text === "") return null; + if (/^\d+$/.test(text)) return Math.max(Number(text), 1); + const ms = Date.parse(text); + if (Number.isFinite(ms)) return Math.max(Math.ceil((ms - now) / 1000), 1); + return null; +} + +export type RateLimit429Verdict = "rate_limited" | "burst"; + +export function classify429(input: { + retryAfter?: string | number | null; + bodyText?: string | null; +}): RateLimit429Verdict { + if (parseRetryAfterSeconds(input.retryAfter) !== null) return "rate_limited"; + const body = typeof input.bodyText === "string" ? input.bodyText : ""; + if (body !== "" && RATE_LIMITED_SIGNALS.some((re) => re.test(body))) return "rate_limited"; + return "burst"; +} + +/** + * Read at most `maxBytes` of a response body from a clone, then cancel the + * clone's reader. The original response keeps its full, unread body. Returns + * null when the body cannot be read. + */ +export async function readBodyPrefix( + response: Response, + maxBytes = RATE_LIMIT_BODY_SNIFF_BYTES +): Promise { + if (!response.body) return ""; + let reader: ReadableStreamDefaultReader | undefined; + try { + reader = response.clone().body?.getReader(); + } catch { + return null; + } + if (!reader) return ""; + const decoder = new TextDecoder(); + let text = ""; + let bytes = 0; + try { + while (bytes < maxBytes) { + const { done, value } = await reader.read(); + if (done || !value) break; + const chunk = + value.byteLength > maxBytes - bytes ? value.subarray(0, maxBytes - bytes) : value; + bytes += chunk.byteLength; + text += decoder.decode(chunk, { stream: true }); + } + return text + decoder.decode(); + } catch { + return null; + } finally { + void reader.cancel().catch(() => undefined); + } +} + +/** Classify an upstream 429: Retry-After header first, bounded body prefix only if needed. */ +export async function classifyUpstream429(response: Response): Promise { + const retryAfter = response.headers.get("retry-after"); + if (parseRetryAfterSeconds(retryAfter) !== null) return "rate_limited"; + return classify429({ retryAfter, bodyText: await readBodyPrefix(response) }); +} diff --git a/open-sse/executors/opencodeResponseBody.ts b/open-sse/executors/opencodeResponseBody.ts new file mode 100644 index 0000000000..63cb8244e0 --- /dev/null +++ b/open-sse/executors/opencodeResponseBody.ts @@ -0,0 +1,18 @@ +/** + * opencodeResponseBody.ts — body hygiene for the opencode rotation loop. + * + * Leaf module: zero imports. An upstream response the loop decides not to + * return (a refusal it rotates away from, a failure it pauses after) keeps its + * connection busy until the body is consumed or cancelled. Cancelling releases + * the socket right away instead of whenever the Response is garbage-collected. + */ + +/** + * Cancel a response body nobody will read. Never throws. A body that is already + * locked (a reader holds it) is left to that reader. + */ +export function discardResponseBody(response: Response | null | undefined): void { + const body = response?.body; + if (!body || body.locked) return; + void body.cancel().catch(() => undefined); +} diff --git a/open-sse/executors/opencodeResponsesStall.ts b/open-sse/executors/opencodeResponsesStall.ts new file mode 100644 index 0000000000..d1b1a8e87c --- /dev/null +++ b/open-sse/executors/opencodeResponsesStall.ts @@ -0,0 +1,48 @@ +/** + * opencodeResponsesStall.ts — opt-in first-byte stall guard for streamed + * Responses replies in the opencode executor (#13484). + * + * A streamed Responses reply opens with `response.created` before any + * generation, so a 2xx Responses stream that stays silent past the window is + * stalled, not thinking. Chat Completions streams are left alone: gateways may + * legitimately hold them until the answer is ready. + * + * Gated by OPENCODE_RESPONSES_STALL_ROTATION (default off). With the flag off + * the window is 0 and every guard call hands back the very same result object, + * so the stream readiness timeout stays the only bound, as before. + */ + +import { isOpencodeResponsesStallRotationEnabled } from "@/shared/utils/featureFlags"; +import { getResponsesFirstByteTimeoutMs } from "@/shared/utils/runtimeTimeouts"; +import { guardResponsesStreamFirstByte } from "../utils/firstByteWatchdog.ts"; + +export { isResponsesFirstByteTimeout } from "../utils/firstByteWatchdog.ts"; + +/** First-byte window (ms) for this request, or 0 when the guard does not apply. */ +export function resolveResponsesStallWindowMs( + stream: boolean | undefined, + requestFormat: string | null +): number { + if (!stream || requestFormat !== "openai-responses") return 0; + if (!isOpencodeResponsesStallRotationEnabled()) return 0; + return getResponsesFirstByteTimeoutMs(); +} + +/** + * Returns `result` itself when `windowMs` is 0 or it carries no 2xx body; + * otherwise resolves once the first body byte arrives, or throws + * RESPONSES_FIRST_BYTE_TIMEOUT (or the abort reason when `signal` fires). + */ +export async function guardResponsesStall( + result: T, + windowMs: number, + signal?: AbortSignal | null +): Promise { + if (windowMs <= 0 || !result || typeof result !== "object" || !("response" in result)) { + return result; + } + const response = (result as { response: Response }).response; + if (!response?.ok || !response.body) return result; + const guarded = await guardResponsesStreamFirstByte(response, windowMs, signal); + return { ...result, response: guarded }; +} diff --git a/open-sse/executors/opencodeTransientFailure.ts b/open-sse/executors/opencodeTransientFailure.ts index 9af52a1fa4..320e5de8be 100644 --- a/open-sse/executors/opencodeTransientFailure.ts +++ b/open-sse/executors/opencodeTransientFailure.ts @@ -2,12 +2,14 @@ * opencodeTransientFailure.ts — retriable-upstream predicate for the opencode * executor loop. * - * Leaf module: one internal import only (isEmptyUpstreamRejection, same - * executors layer — no registry, no DB). 5xx short-circuits on status alone; - * the 400 arm delegates to the existing empty-rejection classifier. + * Leaf module: internal imports from the same executors layer only + * (isEmptyUpstreamRejection, discardResponseBody — no registry, no DB). 5xx + * short-circuits on status alone; the 400 arm delegates to the existing + * empty-rejection classifier. */ import { isEmptyUpstreamRejection } from "./accountRotation.ts"; +import { discardResponseBody } from "./opencodeResponseBody.ts"; export function isRetriableUpstreamFailure(status: number, bodyText?: string): boolean { if (status >= 500 && status < 600) return true; @@ -15,3 +17,68 @@ export function isRetriableUpstreamFailure(status: number, bodyText?: string): b if (typeof bodyText !== "string" || bodyText === "") return false; return isEmptyUpstreamRejection(status, bodyText); } + +// ── Failover pause after repeated transient failures (#13615, opt-in) ────── +// Gated by OPENCODE_TRANSIENT_FAILOVER_BACKOFF (default off). The first retry +// after a transient failure stays immediate (a distinct egress already guards +// a one-off flap); from the second consecutive transient failure on, the loop +// waits before the next account so a briefly overloaded upstream can recover. + +/** Consecutive transient failures before the first pause. */ +export const TRANSIENT_PAUSE_STREAK = 2; +/** First pause, same magnitude as BaseExecutor.WAF_RETRY_CONFIG.delayMs. */ +export const TRANSIENT_RETRY_BASE_DELAY_MS = 1500; +/** Upper bound of a single pause. */ +export const TRANSIENT_RETRY_MAX_DELAY_MS = 6000; +/** Upper bound of all pauses in one request. */ +export const TRANSIENT_RETRY_TOTAL_BUDGET_MS = 10_000; + +/** + * Pause before the next dispatch after `consecutiveFailures` transient failures + * in a row, given `pausedMs` already spent this request. 0 means dispatch now. + * Doubles per further failure (1.5s, 3s, 6s, 6s…) and never exceeds the + * per-pause cap or what is left of the per-request budget. + */ +export function transientRetryDelayMs(consecutiveFailures: number, pausedMs = 0): number { + if (!Number.isFinite(consecutiveFailures) || consecutiveFailures < TRANSIENT_PAUSE_STREAK) { + return 0; + } + const step = Math.min(consecutiveFailures - TRANSIENT_PAUSE_STREAK, 16); + const delay = Math.min(TRANSIENT_RETRY_BASE_DELAY_MS * 2 ** step, TRANSIENT_RETRY_MAX_DELAY_MS); + const left = TRANSIENT_RETRY_TOTAL_BUDGET_MS - Math.max(0, pausedMs); + return Math.max(0, Math.min(delay, left)); +} + +/** + * Sleep that resolves `false` as soon as `signal` aborts (or immediately when it + * already has), `true` once `ms` elapsed. The listener and timer are always + * released. + */ +export function sleepAbortable(ms: number, signal?: AbortSignal | null): Promise { + if (signal?.aborted) return Promise.resolve(false); + return new Promise((resolve) => { + const onAbort = () => { + clearTimeout(timer); + resolve(false); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(true); + }, ms); + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +/** + * Cancel a failed response's body before a pause and return a body-less copy + * that keeps its status, status text and headers (what the exhaustion path may + * still surface once the loop ends). + */ +export function releaseResponseBody(response: Response): Response { + discardResponseBody(response); + return new Response(null, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); +} diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 9bcdccd34e..7ef9ef5541 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -229,6 +229,7 @@ import { } from "../config/constants.ts"; import { applyStatusRestatement } from "../config/upstreamStatusRestatement.ts"; import { createRecoverableStream, makeContinuationBody } from "../services/streamRecovery.ts"; +import { buildContinuationLogHooks } from "./chatCore/recoveryTraceLogging.ts"; import { resolveResilienceSettings, isStreamRecoveryExplicitlyConfigured, @@ -3404,11 +3405,7 @@ export async function handleChatCore({ }` ), continueStream, - onContinue: (attempt) => - log?.warn?.( - "STREAM_RECOVERY", - `mid-stream continuation attempt ${attempt}/${STREAM_RECOVERY.EARLY_RETRY_MAX}` - ), + ...buildContinuationLogHooks(log), throughputWatchdog, onWatchdogAbort: () => log?.warn?.( diff --git a/open-sse/handlers/chatCore/attemptLogging.ts b/open-sse/handlers/chatCore/attemptLogging.ts index f1ddcc1678..b434645595 100644 --- a/open-sse/handlers/chatCore/attemptLogging.ts +++ b/open-sse/handlers/chatCore/attemptLogging.ts @@ -20,6 +20,7 @@ import type { VideoBridgeLogRedactionEntry } from "@/lib/guardrails/videoBridge" import { FORMATS } from "../../translator/formats.ts"; import { takeEarlyKeepaliveBytes } from "../../utils/earlyKeepaliveByteBuffer.ts"; import { sanitizeErrorMessage } from "../../utils/error.ts"; +import { isEstimatedUsage } from "../../utils/usageTracking.ts"; import { cloneBoundedChatLogPayload, truncateForLog } from "./logTruncation.ts"; import { attachLogMeta } from "./cacheUsageMeta.ts"; @@ -493,6 +494,9 @@ export function persistAttemptLogs(args: PersistAttemptLogsArgs, ctx: PersistAtt } : null, claudePromptCacheUsage: claudeCacheUsageMeta, + // Operators can tell estimated token counts (and the cost derived from them) + // apart from provider-reported ones. Log-only: billing is unchanged. + usageEstimated: isEstimatedUsage(tokens) ? true : null, }) ), error: error || null, diff --git a/open-sse/handlers/chatCore/quotaShareConsumption.ts b/open-sse/handlers/chatCore/quotaShareConsumption.ts index cb50ea2e22..70de289966 100644 --- a/open-sse/handlers/chatCore/quotaShareConsumption.ts +++ b/open-sse/handlers/chatCore/quotaShareConsumption.ts @@ -21,9 +21,8 @@ export async function scheduleQuotaShareConsumption(args: { }): Promise { if (!args.apiKeyId || !args.connectionId) return; try { - const { scheduleRecordConsumption, buildConsumptionCost } = await import( - "@/lib/quota/spendRecorder" - ); + const { scheduleRecordConsumption, buildConsumptionCost } = + await import("@/lib/quota/spendRecorder"); scheduleRecordConsumption( { apiKeyId: args.apiKeyId, diff --git a/open-sse/handlers/chatCore/recoveryTraceLogging.ts b/open-sse/handlers/chatCore/recoveryTraceLogging.ts new file mode 100644 index 0000000000..6ead4fbfba --- /dev/null +++ b/open-sse/handlers/chatCore/recoveryTraceLogging.ts @@ -0,0 +1,59 @@ +/** + * Log wiring for mid-stream continuation (stream recovery). Kept out of chatCore so the + * call site stays one line. + * + * Levels: the continuation attempt line keeps its release wording at warn; a recovery that + * gives up (the continuation budget is spent, or the continuation request returned no + * stream) is warn; every other outcome — stitched suffix, overlap rejection, terminal or + * empty continuation, a cut refused because of a tool call — is debug, so a healthy stream + * never adds a warn line. Every line carries `attempt N/MAX` so it joins the attempt line. + */ +import { STREAM_RECOVERY } from "../../config/constants.ts"; +import type { + ContinuationOutcome, + RecoverableStreamOptions, +} from "../../services/streamRecovery.ts"; + +type RecoveryLogger = + | { + warn?: (tag: string, message: string) => void; + debug?: (tag: string, message: string) => void; + } + | null + | undefined; + +const TAG = "STREAM_RECOVERY"; +const MAX = STREAM_RECOVERY.EARLY_RETRY_MAX; + +export function formatContinuationOutcome(event: ContinuationOutcome): string { + const head = `mid-stream continuation attempt ${event.attempt}/${MAX} outcome=${event.outcome}`; + switch (event.outcome) { + case "suffix": + return `${head} suffixChars=${event.suffixChars}`; + case "overlap-reject": + return `${head} overlapChars=${event.overlapChars}`; + case "refused": + return `${head} reason=${event.reason}`; + default: + return head; + } +} + +/** True for the outcomes that end a recovery without delivering the missing text. */ +export function isContinuationGiveUp(event: ContinuationOutcome): boolean { + if (event.outcome === "no-stream") return true; + return event.outcome === "refused" && event.reason === "budget" && event.attempt > 0; +} + +export function buildContinuationLogHooks( + log: RecoveryLogger +): Pick { + return { + onContinue: (attempt) => log?.warn?.(TAG, `mid-stream continuation attempt ${attempt}/${MAX}`), + onContinueOutcome: (event) => { + const line = formatContinuationOutcome(event); + if (isContinuationGiveUp(event)) log?.warn?.(TAG, line); + else log?.debug?.(TAG, line); + }, + }; +} diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 8c3596e14d..bd6f1b68aa 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -2243,7 +2243,15 @@ async function resolveImageSource(source) { } if (isHttpUrl(trimmed)) { - const remoteImage = await fetchRemoteImage(trimmed); + // GHSA-34rg-3pqj-35g9: this URL is caller input (`image_url` / `mask_url` / message + // parts) — pin `public-only` explicitly (string check + DNS validation of every + // resolved answer). Never let it fall back to the operator outbound policy + // (`getProviderOutboundGuard()`), which is `block-metadata` on a local-first default + // install and would let a request body make the server fetch loopback/LAN URLs and + // forward the bytes upstream. `pinDns` stays off on purpose: this handler's only + // transport is `globalThis.fetch` (no `fetchImpl` seam) and connection pinning + // replaces it with a raw undici fetch — same shape as the AI Horde result download. + const remoteImage = await fetchRemoteImage(trimmed, { guard: "public-only" }); return { buffer: remoteImage.buffer, base64: remoteImage.buffer.toString("base64"), @@ -3242,7 +3250,10 @@ async function normalizeNanoBananaTaskResult(taskData, body, log) { if (urlCandidates.length > 0) { const firstUrl = urlCandidates[0]; - const remoteImage = await fetchRemoteImage(firstUrl); + // GHSA-34rg-3pqj-35g9: upstream-supplied result URL, not an OmniRoute-controlled + // host — pin `public-only` exactly like the AI Horde result download does, never + // the operator outbound policy (see `resolveImageSource` for why `pinDns` is off). + const remoteImage = await fetchRemoteImage(firstUrl, { guard: "public-only" }); const base64 = remoteImage.buffer.toString("base64"); return [{ b64_json: base64, revised_prompt: body.prompt }]; } diff --git a/open-sse/handlers/imageUpscale/shared.ts b/open-sse/handlers/imageUpscale/shared.ts index cf37e99910..557274b88b 100644 --- a/open-sse/handlers/imageUpscale/shared.ts +++ b/open-sse/handlers/imageUpscale/shared.ts @@ -71,7 +71,9 @@ export function extractUpscaleSourceImage(body: unknown): string | null { if (!body || typeof body !== "object") return null; const b = body as Record; const providerOptions = - b.provider_options && typeof b.provider_options === "object" && !Array.isArray(b.provider_options) + b.provider_options && + typeof b.provider_options === "object" && + !Array.isArray(b.provider_options) ? (b.provider_options as Record) : {}; @@ -161,7 +163,15 @@ export async function resolveUpscaleImageSource(source: string): Promise= 24 && - buffer[0] === 0x89 && - buffer.toString("ascii", 1, 4) === "PNG" - ) { + if (buffer.length >= 24 && buffer[0] === 0x89 && buffer.toString("ascii", 1, 4) === "PNG") { // IHDR is always the first chunk: 8-byte signature + 4 length + 4 "IHDR". return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }; } @@ -308,10 +314,7 @@ export function scaleDimensions( const source = readImageDimensions(buffer); if (!source || source.width <= 0 || source.height <= 0) return null; const safeFactor = Number.isFinite(factor) && factor > 0 ? factor : 2; - const scale = Math.min( - safeFactor, - maxEdge / Math.max(source.width, source.height) - ); + const scale = Math.min(safeFactor, maxEdge / Math.max(source.width, source.height)); return { width: Math.max(1, Math.round(source.width * Math.max(1, scale))), height: Math.max(1, Math.round(source.height * Math.max(1, scale))), @@ -365,9 +368,7 @@ export function saveUpscaleErrorResult(opts: { provider: opts.provider, duration: Date.now() - opts.startTime, error: - typeof opts.error === "string" - ? opts.error.slice(0, 500) - : String(opts.error).slice(0, 500), + typeof opts.error === "string" ? opts.error.slice(0, 500) : String(opts.error).slice(0, 500), requestBody: opts.requestBody ?? null, }).catch(() => {}); diff --git a/open-sse/handlers/usageExtractor.ts b/open-sse/handlers/usageExtractor.ts index 789b1019ca..e0076112a9 100644 --- a/open-sse/handlers/usageExtractor.ts +++ b/open-sse/handlers/usageExtractor.ts @@ -2,6 +2,8 @@ * Extract usage from non-streaming response body * Handles different provider response formats */ +import { carryEstimatedUsageMarker } from "../utils/usageTracking.ts"; + export function extractUsageFromResponse(responseBody, provider) { if (!responseBody || typeof responseBody !== "object") return null; const providerId = typeof provider === "string" ? provider.toLowerCase() : ""; @@ -23,7 +25,7 @@ export function extractUsageFromResponse(responseBody, provider) { responseBody.usage.prompt_tokens_details?.cache_write_tokens ?? responseBody.usage.input_tokens_details?.cache_write_tokens ?? responseBody.usage.cache_write_tokens; - return { + const openAiUsage = { prompt_tokens: responseBody.usage.prompt_tokens || 0, completion_tokens: responseBody.usage.completion_tokens || 0, // DeepSeek native API uses flat prompt_cache_hit_tokens (NOT @@ -60,6 +62,7 @@ export function extractUsageFromResponse(responseBody, provider) { ? { cost_in_usd_ticks: responseBody.usage.cost_in_usd_ticks } : {}), }; + return carryEstimatedUsageMarker(responseBody.usage, openAiUsage); } // Claude format diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 5d947d429a..aa5a62f03f 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -50,7 +50,10 @@ import { } from "../../src/shared/constants/providers"; import { resolveUseUpstream429BreakerHints } from "../../src/shared/utils/providerHints"; import { getCodexModelScope } from "../config/codexQuotaScopes.ts"; -import { getQuotaScopedModelForProvider, isAntigravityQuotaProvider } from "./antigravityQuotaFamily.ts"; +import { + getQuotaScopedModelForProvider, + isAntigravityQuotaProvider, +} from "./antigravityQuotaFamily.ts"; import { persistAntigravityFamilyCooldownIfQuota } from "./antigravityFamilyCooldown.ts"; import { classifyGeminiQuotaMetricFromText, @@ -66,12 +69,13 @@ import { MAX_SHORT_RETRY_HINT_MS, } from "./retryAfterJson.ts"; import { isMoonshotAccountBalanceExhausted } from "./usage/moonshotOpenPlatform.ts"; -import { isTpdRateLimit, resolveTpdCooldownMs } from "./dailyQuotaReset.ts"; +import { isTpdRateLimit, resolveTpdCooldownMs, nextConfiguredResetMs } from "./dailyQuotaReset.ts"; // Pre-compiled regex constants for hot-path retry parsing (avoid per-call compilation) const RETRY_AFTER_RE = /retry\s+after\s+(\d+)\s*s/i; const PLEASE_RETRY_RE = /please retry in\s+([\d.]+\s*s)/i; -const ISO_RETRY_RE = /\b(?:try again at|wait until|reset(?:s)? at|available at|retry after)\s+(\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)/i; +const ISO_RETRY_RE = + /\b(?:try again at|wait until|reset(?:s)? at|available at|retry after)\s+(\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)/i; const RESETS_AFTER_RE = /resets? after (\d+h)?(\d+m)?(\d+s)?/i; const WILL_RESET_AFTER_RE = /will reset after (\d+h)?(\d+m)?(\d+s)?/i; const RESETS_IN_RE = /resets? in (\d+h)?(\d+m)?(\d+s)?/i; @@ -100,6 +104,8 @@ import { capScaledCooldownMs } from "./accountFallback/cooldownCap.ts"; import { resolveApiKeyForbiddenFallback } from "./accountFallback/nonRetryableUpstream.ts"; import * as exactModelLock from "./accountFallback/exactModelLock.ts"; import { isCreditsExhaustedWithSharedWallet } from "./accountFallback/sharedWalletCredits.ts"; +import { isMistralAmbiguous401 } from "./accountFallback/mistralAmbiguousAuth.ts"; +import { isMistralAmbiguous401SoftLockoutEnabled } from "@/shared/utils/featureFlags"; export type ProviderProfile = { baseCooldownMs: number; useUpstreamRetryHints: boolean; @@ -376,7 +382,8 @@ export const MODEL_ACCESS_DENIED_PATTERNS = [ /\bunsupported\s+model\b/i, /\baccess.*denied.*model\b/i, /\bmodel.*access.*denied\b/i, - /\bplease select a different model\b/i, /\bunknown\s+provider\s+for\s+model\b/i, + /\bplease select a different model\b/i, + /\bunknown\s+provider\s+for\s+model\b/i, // "...access to the requested model" / "model ... access" — bounded lookahead // (no nested quantifiers) so it stays ReDoS-safe while requiring BOTH an // access/permission word and "model" so a pure auth error never matches. @@ -416,7 +423,8 @@ const PROVIDER_MODEL_UNSUPPORTED_PATTERNS = [ /\bmodel\b[\s\S]{0,80}?\b(?:does\s+not\s+support|doesn't\s+support|unsupported)\b/i, /\b(?:does\s+not\s+support|doesn't\s+support|unsupported)\b[\s\S]{0,80}?\bmodel\b/i, /\bunsupported\s+model\b/i, - /\bplease select a different model\b/i, /\bunknown\s+provider\s+for\s+model\b/i, + /\bplease select a different model\b/i, + /\bunknown\s+provider\s+for\s+model\b/i, ]; /** @@ -656,7 +664,13 @@ export async function recordCoreOwnedAntigravityQuotaState({ } ); if (lockout.cooldownMs > 0 && isProviderExhaustedReason(fallback)) { - persistAntigravityFamilyCooldownIfQuota({ provider, connectionId, model, cooldownMs: lockout.cooldownMs, reason: "quota_exhausted" }); + persistAntigravityFamilyCooldownIfQuota({ + provider, + connectionId, + model, + cooldownMs: lockout.cooldownMs, + reason: "quota_exhausted", + }); } return { cooldownMs: lockout.cooldownMs, failureCount: lockout.failureCount }; } @@ -1661,7 +1675,7 @@ export function checkFallbackError( timezone?: unknown; hour?: unknown; nowMs?: number; - } | null, + } | null ): { shouldFallback: boolean; cooldownMs: number; @@ -1673,6 +1687,8 @@ export function checkFallbackError( permanent?: boolean; creditsExhausted?: boolean; dailyQuotaExhausted?: boolean; + /** #13609: bare Mistral 401 softened to a cooldown (MISTRAL_AMBIGUOUS_401_SOFT_LOCKOUT). */ + ambiguousAuth?: boolean; /** G-02: true when the error originates from an embedded service supervisor (not the upstream AI * provider itself). Callers should apply connection cooldown only — do NOT record a provider * circuit-breaker failure when this flag is set. */ @@ -1987,7 +2003,7 @@ export function checkFallbackError( // no clock, no header — short 429, do not guess midnight console.warn( "[accountFallback] TPD 429 without node daily-reset clock or Reset header; using short cooldown", - { provider }, + { provider } ); } else { return { @@ -1998,7 +2014,13 @@ export function checkFallbackError( }; } } else { - const msUntilTomorrow = getMsUntilTomorrow(); + // Operator node clock first; host-midnight estimate when unconfigured. + const tzMs = nextConfiguredResetMs( + dailyReset?.timezone, + dailyReset?.hour, + dailyReset?.nowMs ?? Date.now() + ); + const msUntilTomorrow = tzMs ?? getMsUntilTomorrow(); // Cap at 24 hours to handle timezone edge cases const cooldownMs = Math.min(msUntilTomorrow, 24 * 60 * 60 * 1000); return { @@ -2161,6 +2183,16 @@ export function checkFallbackError( resolveRuleMatchBody(provider, structuredError ?? null, errorStr) ) : null; + // #13609 (opt-in): a bare Mistral 401 is not proof of a dead key — back off + // instead; resolveTerminalConnectionStatus bounds how often (ambiguousAuth). + if ( + status === HTTP_STATUS.UNAUTHORIZED && + !providerMatch && + isMistralAmbiguous401(provider, errorStr) && + isMistralAmbiguous401SoftLockoutEnabled() + ) { + return { ...buildRetryableFallback(RateLimitReason.UNKNOWN), ambiguousAuth: true }; + } const cooldownMs = providerMatch?.cooldownMs ?? configuredRule.cooldownMs ?? 0; const ruleScope = providerMatch && honorsRuleLockScope(provider) ? providerMatch.scope : undefined; @@ -2434,7 +2466,13 @@ export function applyErrorState( // (`markConnectionQuotaExhausted`) so a DB failure can never crash the // chat path. See issue #1 (per-account 429 cascade not persisting). const connId = (account as AccountState | null | undefined)?.id; - if (typeof connId === "string" && connId.length > 0 && effectiveCooldownMs > 0 && nextState.rateLimitedUntil && !isAntigravityQuotaProvider(prov)) { + if ( + typeof connId === "string" && + connId.length > 0 && + effectiveCooldownMs > 0 && + nextState.rateLimitedUntil && + !isAntigravityQuotaProvider(prov) + ) { try { const untilMs = cooldownUntilMs(nextState.rateLimitedUntil); if (Number.isFinite(untilMs) && untilMs > Date.now()) { diff --git a/open-sse/services/accountFallback/mistralAmbiguousAuth.ts b/open-sse/services/accountFallback/mistralAmbiguousAuth.ts new file mode 100644 index 0000000000..5f1ba872a1 --- /dev/null +++ b/open-sse/services/accountFallback/mistralAmbiguousAuth.ts @@ -0,0 +1,58 @@ +/** + * Mistral ambiguous 401 (#7638 / #7718 / #13609). + * + * Mistral answers a revoked key and an exhausted quota with the same bare + * `401 {"detail":"Unauthorized"}`. This leaf module (zero imports) is the ONE + * implementation of that ambiguity check, shared by the connection-test + * diagnosis (`src/app/api/providers/[id]/test/mistralAmbiguousAuth.ts`) and the + * runtime fallback path (`checkFallbackError` + `resolveTerminalConnectionStatus`). + * + * It also holds the bound for the opt-in runtime soft lockout + * (MISTRAL_AMBIGUOUS_401_SOFT_LOCKOUT): a connection gets at most + * MISTRAL_AMBIGUOUS_401_MAX_SOFT_STRIKES soft cooldowns inside the strike window; + * the next bare 401 parks it as expired, so a genuinely revoked key converges. + */ + +const AUTH_SIGNALS = ["invalid api key", "token invalid", "revoked", "access denied"]; + +/** Soft cooldowns allowed per connection inside the strike window. */ +export const MISTRAL_AMBIGUOUS_401_MAX_SOFT_STRIKES = 3; +/** Window, measured from the first strike, in which soft strikes accumulate. */ +export const MISTRAL_AMBIGUOUS_401_STRIKE_WINDOW_MS = 60 * 60 * 1000; + +/** True for a Mistral 401 body that carries no explicit auth signal (callers check the status). */ +export function isMistralAmbiguous401( + provider: string | null | undefined, + errorText: string | null | undefined +): boolean { + if (provider !== "mistral") return false; + const normalized = String(errorText || "").toLowerCase(); + return !AUTH_SIGNALS.some((signal) => normalized.includes(signal)); +} + +const strikes = new Map(); + +/** + * Record one ambiguous bare 401 for `connectionId` and say whether it may still + * be softened (true) or must park the connection (false). Crossing the bound + * clears the entry, so a re-authenticated connection starts a fresh count. + */ +export function takeMistralAmbiguous401SoftStrike(connectionId: string, now = Date.now()): boolean { + const entry = strikes.get(connectionId); + const current = + entry && now - entry.firstAt < MISTRAL_AMBIGUOUS_401_STRIKE_WINDOW_MS + ? entry + : { count: 0, firstAt: now }; + current.count += 1; + if (current.count > MISTRAL_AMBIGUOUS_401_MAX_SOFT_STRIKES) { + strikes.delete(connectionId); + return false; + } + strikes.set(connectionId, current); + return true; +} + +/** Test hook: forget every recorded strike. */ +export function resetMistralAmbiguous401Strikes(): void { + strikes.clear(); +} diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index acc72de4ae..8b8da3a0e2 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -20,11 +20,7 @@ import { import { getHiddenModelsByProvider } from "@/models"; -import { - evaluateQuotaCutoff, - getQuotaFetcher, - type QuotaInfo, -} from "./quotaPreflight.ts"; +import { evaluateQuotaCutoff, getQuotaFetcher, type QuotaInfo } from "./quotaPreflight.ts"; import { resolveProviderId } from "../../src/shared/constants/providers.ts"; import { getQuotaFetchScope } from "./antigravityQuotaFamily.ts"; import { getCircuitBreaker } from "../../src/shared/utils/circuitBreaker"; @@ -37,10 +33,7 @@ import { projectAccountTier, type ProviderCandidate } from "./autoCombo/scoring. import { getSessionConnection } from "./sessionManager.ts"; import { getOAuthSessionAvailability } from "./oauthSessionOccupancy.ts"; -import { - clearStickyBinding, - peekStickyConnectionId, -} from "./combo/sessionStickiness.ts"; +import { clearStickyBinding, peekStickyConnectionId } from "./combo/sessionStickiness.ts"; import { lookupPositiveCap } from "./combo/concurrencyCaps.ts"; import { acquireQuotaShareConcurrencySlot } from "./combo/quotaShareConcurrency.ts"; @@ -107,20 +100,13 @@ import { tryPipelineDispatch, tryRuntimeUnitDispatch, } from "./combo/dispatchPrelude.ts"; -import { - resolveShadowTargets, - scheduleShadowRouting, -} from "./combo/shadowRouting.ts"; +import { resolveShadowTargets, scheduleShadowRouting } from "./combo/shadowRouting.ts"; import { filterTargetsByRequestCompatibility, resolveComboRuntimeUnits, resolveComboTargets, } from "./combo/comboStructure.ts"; -import { - createInvocationId, - getComboTrace, - startComboTrace, -} from "./combo/decisionTrace.ts"; +import { createInvocationId, getComboTrace, startComboTrace } from "./combo/decisionTrace.ts"; import { QUOTA_SOFT_DEPRIORITIZE_FACTOR, setCandidateQuotaSoftPenalty, @@ -135,20 +121,15 @@ import { calculateResetWindowAffinity, type ResetWindowConfig, } from "./combo/quotaScoring.ts"; -import { - fetchResetAwareQuotaWithCache, - preScreenTargets, -} from "./combo/quotaStrategies.ts"; +import { fetchResetAwareQuotaWithCache, preScreenTargets } from "./combo/quotaStrategies.ts"; import { buildAutoQuotaThresholds } from "./combo/quotaExhaustionCutoff.ts"; import { expandTargetsByFingerprints } from "./combo/fingerprintExpansion.ts"; import { resolveComboTargetPipeline } from "./combo/targetResolution.ts"; import { dispatchWithCooldownRetry } from "./combo/comboAttemptLoop.ts"; import { evaluateExecuteTargetGates } from "./combo/executeTargetGates.ts"; import { executeTargetAttempt } from "./combo/executeTargetAttempt.ts"; -import type { - AttemptLoopDeps, - AttemptLoopState, -} from "./combo/attemptLoopTypes.ts"; +import type { AttemptLoopDeps, AttemptLoopState } from "./combo/attemptLoopTypes.ts"; +import { clearStaleLKGP } from "./combo/staleLkgpClear.ts"; export { RESET_WINDOW_NAMES, QUOTA_SOFT_DEPRIORITIZE_FACTOR, setCandidateQuotaSoftPenalty }; export { scoreAutoTargets, expandAutoComboCandidatePool }; @@ -195,32 +176,8 @@ export function releaseStickyPinOnFailure( clearStickyBinding(messageHash); } -/** - * Clear persisted LKGP pins when a target fails or is skipped due to - * exhaustion, cooldown, or unavailability (#11911 #919). - */ -export function clearStaleLKGP( - comboName: string, - executionKey?: string | null, - comboId?: string | null, - log?: { warn?: (tag: string, msg: string, data?: unknown) => void } | null, - tag: string = "COMBO" -): void { - void (async () => { - try { - const { clearLKGP } = await import("@/lib/db/settings"); - const promises: Promise[] = [clearLKGP(comboName, comboId || comboName)]; - if (executionKey) { - promises.push(clearLKGP(comboName, executionKey)); - } - await Promise.all(promises); - } catch (err) { - log?.warn?.(tag, "Failed to clear Last Known Good Provider. This is non-fatal.", { - err, - }); - } - })(); -} +// #11911 #919: non-blocking stale-pin clear whose failures log with combo context. +export { clearStaleLKGP }; const DEFAULT_MODEL_P95_MS: Record = { "grok-4-fast-non-reasoning": 1143, @@ -1081,4 +1038,3 @@ async function handleComboChatInner({ _unregisterExecutionCandidates(_registeredExecutionKeys); } } - diff --git a/open-sse/services/combo/comboDailyResetClock.ts b/open-sse/services/combo/comboDailyResetClock.ts new file mode 100644 index 0000000000..f514935b45 --- /dev/null +++ b/open-sse/services/combo/comboDailyResetClock.ts @@ -0,0 +1,34 @@ +/** + * Operator daily-reset clock lookup for the combo failure paths. + * + * Combo targets classify upstream failures with `checkFallbackError` directly, + * so they need the same per-provider `{ timezone, hour }` clock that the + * single-model path resolves in `src/sse/services/auth.ts` + * (`resolveDailyResetForProvider`): the provider node matched by id or prefix. + * + * Resolved on every failure through `getCachedProviderNodes`, which already + * owns caching (short TTL, invalidated on every provider_nodes write). There is + * deliberately no second cache here: a timezone/hour edit reaches combos + * without a restart, and a failed lookup returns null (host-midnight fallback in + * `checkFallbackError`) without being remembered. + * + * Dynamic import keeps the combo leaf free of a static edge into the DB layer. + */ + +export type ComboDailyResetClock = { timezone?: unknown; hour?: unknown }; + +export async function resolveComboDailyReset( + provider: string | null | undefined +): Promise { + if (!provider || provider === "unknown") return null; + try { + const { getCachedProviderNodes } = await import("@/lib/db/readCache"); + const nodes = await getCachedProviderNodes(); + const node = nodes.find((n) => n && (n.id === provider || n.prefix === provider)); + if (!node) return null; + return { timezone: node.dailyQuotaResetTimezone, hour: node.dailyQuotaResetHour }; + } catch { + // no-effect: an unreadable node table falls back to host midnight in checkFallbackError + return null; + } +} diff --git a/open-sse/services/combo/executeTargetAttempt.ts b/open-sse/services/combo/executeTargetAttempt.ts index bd948cf535..2572dc2c20 100644 --- a/open-sse/services/combo/executeTargetAttempt.ts +++ b/open-sse/services/combo/executeTargetAttempt.ts @@ -18,7 +18,12 @@ import { retryHintBypassesMaxCooldownMs, selectLockoutCooldownMs, } from "../accountFallback.ts"; -import { errorResponse, errorResponseWithComboDiagnostics } from "../../utils/error.ts"; +import { + errorResponse, + errorResponseWithComboDiagnostics, + logRetryHintUnreadable, + readProseRetryAfter, +} from "../../utils/error.ts"; import { recordComboFailure, clearComboFailureTracking } from "./failureTracker.ts"; import { buildRecoveryHint } from "./pinRecovery.ts"; import { formatExhaustedConnectionKey } from "./comboDiagFormat.ts"; @@ -91,6 +96,9 @@ import type { AttemptLoopDeps, AttemptLoopState, ExecuteTargetResult } from "./a import type { ComboDiagnostics } from "../../utils/error.ts"; import type { ComboErrorBody, ComboRetryAfter, ResolvedComboTarget } from "./types.ts"; import type { ResponseValidationConfig } from "./responseValidation.ts"; +import { resolveComboDailyReset } from "./comboDailyResetClock.ts"; +import { protectedPriorityStopStatus } from "./protectedPriorityStopStatus.ts"; +import type { ProtectedPriorityStopCause } from "./protectedPriorityStopStatus.ts"; export async function executeTargetAttempt(opts: { index: number; @@ -114,11 +122,11 @@ export async function executeTargetAttempt(opts: { const fallbackDelayMs = resolveDelayMs(deps.config.fallbackDelayMs, 0); const universalHandoffConfig = deps.universalHandoffConfig ?? DEFAULT_UNIVERSAL_HANDOFF_CONFIG; - const stopProtectedPriorityTarget = (message: string) => { + const stopProtectedPriorityTarget = (message: string, cause?: ProtectedPriorityStopCause) => { state.observeFailure(false, target.executionKey); deps.clearStaleLKGP(deps.combo.name, target.executionKey, deps.combo.id, deps.log, "COMBO"); return protectedPriorityTarget - ? { ok: false as const, response: errorResponse(503, message) } + ? { ok: false as const, response: errorResponse(protectedPriorityStopStatus(cause), message) } : null; }; @@ -194,7 +202,10 @@ export async function executeTargetAttempt(opts: { decision: "skipped_before_dispatch", reason: "predictive_ttft", }); - return stopProtectedPriorityTarget(`Predictive latency check rejected ${modelStr}`); + return stopProtectedPriorityTarget( + `Predictive latency check rejected ${modelStr}`, + "predictive_ttft" + ); } } } @@ -667,10 +678,12 @@ export async function executeTargetAttempt(opts: { let errorText = result.statusText || ""; let errorBody: ComboErrorBody = null; let retryAfter: ComboRetryAfter | null = null; + let bodyText = ""; try { const cloned = result.clone(); try { const text = await cloned.text(); + bodyText = text; if (text) { errorText = text.substring(0, 500); errorBody = JSON.parse(text); @@ -702,11 +715,12 @@ export async function executeTargetAttempt(opts: { : null); } } catch { - /* Clone parse failed */ + logRetryHintUnreadable(deps.log, "COMBO", modelStr, result.status, "unparseable body"); } } catch { - /* Clone failed */ + logRetryHintUnreadable(deps.log, "COMBO", modelStr, result.status, "clone failed"); } + retryAfter ||= readProseRetryAfter(bodyText); // #13672 opt-in prose retry hints // Track earliest retryAfter if ( @@ -835,7 +849,9 @@ export async function executeTargetAttempt(opts: { provider, result.headers, profile, - structuredError + structuredError, + null, + await resolveComboDailyReset(provider) ); const { cooldownMs } = fallbackResult; // #6863: a parsed upstream quota reset (e.g. Antigravity "Resets in 92h27m28s") diff --git a/open-sse/services/combo/executeTargetGates.ts b/open-sse/services/combo/executeTargetGates.ts index a649a54b50..4bf24601bb 100644 --- a/open-sse/services/combo/executeTargetGates.ts +++ b/open-sse/services/combo/executeTargetGates.ts @@ -25,6 +25,8 @@ import { resolvePersistedConnectionCooldownSkipReason, } from "./comboPredicates.ts"; import { resolveQuotaExhaustionCutoffForTarget } from "./quotaExhaustionCutoff.ts"; +import { protectedPriorityStopStatus } from "./protectedPriorityStopStatus.ts"; +import type { ProtectedPriorityStopCause } from "./protectedPriorityStopStatus.ts"; import type { AttemptLoopDeps, AttemptLoopState, GateDecision } from "./attemptLoopTypes.ts"; import type { ResolvedComboTarget } from "./types.ts"; @@ -58,11 +60,11 @@ export async function evaluateExecuteTargetGates(opts: { const protectedPriorityTarget = deps.strategy === "priority" && target.fallbackOnlyOnQuotaExhaustion === true; - const stopProtectedPriorityTarget = (message: string) => { + const stopProtectedPriorityTarget = (message: string, cause?: ProtectedPriorityStopCause) => { state.observeFailure(false, target.executionKey); deps.clearStaleLKGP(deps.combo.name, target.executionKey, deps.combo.id, deps.log, "COMBO"); return protectedPriorityTarget - ? { ok: false as const, response: errorResponse(503, message) } + ? { ok: false as const, response: errorResponse(protectedPriorityStopStatus(cause), message) } : null; }; @@ -93,7 +95,10 @@ export async function evaluateExecuteTargetGates(opts: { bumpFallback(); return { kind: "skip", - result: stopProtectedPriorityTarget(`Provider ${provider} circuit breaker is open`), + result: stopProtectedPriorityTarget( + `Provider ${provider} circuit breaker is open`, + "circuit_open" + ), }; } diff --git a/open-sse/services/combo/protectedPriorityStopStatus.ts b/open-sse/services/combo/protectedPriorityStopStatus.ts new file mode 100644 index 0000000000..0396a8714c --- /dev/null +++ b/open-sse/services/combo/protectedPriorityStopStatus.ts @@ -0,0 +1,31 @@ +/** + * #13439 — HTTP status for a protected-priority stop: a `priority` target marked + * `fallbackOnlyOnQuotaExhaustion` stops the combo instead of falling through, and + * every such stop answers 503, which reads like quota exhaustion. + * + * Only causes that are provably NOT quota, rate limit or cooldown may answer 502: + * - `circuit_open`: the whole-provider breaker opens on 408/5xx only + * (PROVIDER_BREAKER_FAILURE_STATUSES; 429 and request-scoped failures never trip it); + * - `predictive_ttft`: skipped on recorded latency alone. + * Everything else (model lockout, provider/connection cooldown, request exhaustion, + * unavailable credentials, credential gate, concurrency cap, quota cutoff) keeps 503: + * those cannot be proven non-quota. + * + * Opt-in via PROTECTED_PRIORITY_INFRA_502_ENABLED (default off) because it changes a + * client-visible status; a flag-read failure keeps 503. + * + * @internal — not part of the public combo.ts barrel. + */ +import { isFeatureFlagEnabled } from "../../../src/shared/utils/featureFlags.ts"; + +export type ProtectedPriorityStopCause = "circuit_open" | "predictive_ttft"; + +export function protectedPriorityStopStatus(cause?: ProtectedPriorityStopCause): 502 | 503 { + if (cause !== "circuit_open" && cause !== "predictive_ttft") return 503; + try { + return isFeatureFlagEnabled("PROTECTED_PRIORITY_INFRA_502_ENABLED") ? 502 : 503; + } catch { + // no-effect: an unreadable flag store keeps the legacy 503 + return 503; + } +} diff --git a/open-sse/services/combo/roundRobinCombo.ts b/open-sse/services/combo/roundRobinCombo.ts index 2cc3c6895f..be973e57d0 100644 --- a/open-sse/services/combo/roundRobinCombo.ts +++ b/open-sse/services/combo/roundRobinCombo.ts @@ -12,6 +12,8 @@ import { errorResponse, unavailableResponse, errorResponseWithComboDiagnostics, + logRetryHintUnreadable, + readProseRetryAfter, } from "../../utils/error.ts"; import { buildRecoveryHint } from "./pinRecovery.ts"; import { formatExhaustedConnectionKey } from "./comboDiagFormat.ts"; @@ -112,6 +114,7 @@ import { resolveComboTargets, } from "./comboStructure.ts"; import { releaseStickyPinOnFailure, clearStaleLKGP } from "../combo.ts"; +import { resolveComboDailyReset } from "./comboDailyResetClock.ts"; /** Per-connection TPM budget for quota reservation. Undefined = store keeps prior limit. */ async function resolveTargetTokenLimit(target: { @@ -811,10 +814,12 @@ export async function handleRoundRobinCombo({ let errorText = result.statusText || ""; let retryAfter: ComboRetryAfter | null = null; let errorBody: ComboErrorBody = null; + let bodyText = ""; try { const cloned = result.clone(); try { const text = await cloned.text(); + bodyText = text; if (text) { errorText = text.substring(0, 500); errorBody = JSON.parse(text); @@ -827,11 +832,12 @@ export async function handleRoundRobinCombo({ retryAfter = errorBody?.retryAfter || null; } } catch { - /* Clone parse failed */ + logRetryHintUnreadable(log, "COMBO-RR", modelStr, result.status, "unparseable body"); } } catch { - /* Clone failed */ + logRetryHintUnreadable(log, "COMBO-RR", modelStr, result.status, "clone failed"); } + retryAfter ||= readProseRetryAfter(bodyText); // #13672 opt-in prose hints if (result.status === 499) { log.info( @@ -921,7 +927,9 @@ export async function handleRoundRobinCombo({ provider, result.headers, profile, - structuredError + structuredError, + null, + await resolveComboDailyReset(provider) ); const { cooldownMs } = fallbackResult; const selectedConnectionId = diff --git a/open-sse/services/combo/staleLkgpClear.ts b/open-sse/services/combo/staleLkgpClear.ts new file mode 100644 index 0000000000..5d9824b4a9 --- /dev/null +++ b/open-sse/services/combo/staleLkgpClear.ts @@ -0,0 +1,44 @@ +/** + * Clear persisted LKGP pins when a combo target fails or is skipped for exhaustion, + * cooldown or unavailability (#11911 #919). + * + * Non-blocking by design: the fallback loop never waits on these SQLite writes. A + * failed clear is not silent — it logs a warning carrying the combo and the + * execution key. The returned promise never rejects: routing callers ignore it, + * tests await it. + * + * @internal — re-exported by combo.ts as `clearStaleLKGP`. + */ + +type WarnLogger = { warn?: (tag: string, msg: string, data?: unknown) => void } | null; +type ClearLkgp = (comboName: string, modelKey: string) => Promise; + +async function clearPins( + comboName: string, + executionKey: string | null | undefined, + comboId: string | null | undefined, + clearLKGP: ClearLkgp | undefined +): Promise { + const clear = clearLKGP ?? (await import("@/lib/db/settings")).clearLKGP; + const keys = [comboId || comboName, ...(executionKey ? [executionKey] : [])]; + await Promise.all(keys.map((key) => clear(comboName, key))); +} + +export function clearStaleLKGP( + comboName: string, + executionKey?: string | null, + comboId?: string | null, + log?: WarnLogger, + tag: string = "COMBO", + /** Test seam; the routing path always resolves clearLKGP from @/lib/db/settings. */ + clearLKGP?: ClearLkgp +): Promise { + return clearPins(comboName, executionKey, comboId, clearLKGP).catch((err: unknown) => { + log?.warn?.(tag, "Failed to clear Last Known Good Provider. This is non-fatal.", { + combo: comboName, + comboId: comboId ?? null, + executionKey: executionKey ?? null, + err, + }); + }); +} diff --git a/open-sse/services/dailyQuotaReset.ts b/open-sse/services/dailyQuotaReset.ts index a5108d132f..fd9360d49b 100644 --- a/open-sse/services/dailyQuotaReset.ts +++ b/open-sse/services/dailyQuotaReset.ts @@ -32,17 +32,30 @@ type ZonedParts = { second: number; }; +// Formatter construction dominates zonedParts; the DST-gap walk below calls it +// hundreds of times, so reuse one formatter per (validated) IANA zone. +const zonedFormatters = new Map(); + +function zonedFormatter(timeZone: string): Intl.DateTimeFormat { + let fmt = zonedFormatters.get(timeZone); + if (!fmt) { + fmt = new Intl.DateTimeFormat("en-US", { + timeZone, + hourCycle: "h23", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + zonedFormatters.set(timeZone, fmt); + } + return fmt; +} + function zonedParts(ms: number, timeZone: string): ZonedParts { - const fmt = new Intl.DateTimeFormat("en-US", { - timeZone, - hourCycle: "h23", - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); + const fmt = zonedFormatter(timeZone); const bag: Record = {}; for (const part of fmt.formatToParts(new Date(ms))) { if (part.type !== "literal") bag[part.type] = part.value; @@ -57,7 +70,11 @@ function zonedParts(ms: number, timeZone: string): ZonedParts { }; } -function addCalendarDay(year: number, month: number, day: number): { +function addCalendarDay( + year: number, + month: number, + day: number +): { year: number; month: number; day: number; @@ -67,6 +84,35 @@ function addCalendarDay(year: number, month: number, day: number): { return { year: dt.getUTCFullYear(), month: dt.getUTCMonth() + 1, day: dt.getUTCDate() }; } +/** + * Offset-iteration wall-clock → epoch conversion. `exact` is false when the + * iteration never lands on the wanted wall time, which is what a wall time + * inside a DST gap (a local time that does not exist) does. + */ +function convergeWallTime( + year: number, + month: number, + day: number, + hour: number, + minute: number, + second: number, + timeZone: string +): { ms: number; exact: boolean } { + const wanted = Date.UTC(year, month - 1, day, hour, minute, second); + let guess = wanted; + for (let i = 0; i < 4; i++) { + const p = zonedParts(guess, timeZone); + const asIfUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second); + const delta = asIfUtc - wanted; + if (delta === 0) return { ms: guess, exact: true }; + guess -= delta; + } + return { ms: guess, exact: false }; +} + +/** Gap-walk bound: one full day covers every civil gap, including a skipped calendar day. */ +const MAX_GAP_WALK_MINUTES = 24 * 60; + /** Convert wall-clock time in `timeZone` to epoch ms. */ function zonedLocalToUtc( year: number, @@ -75,18 +121,35 @@ function zonedLocalToUtc( hour: number, minute: number, second: number, - timeZone: string, + timeZone: string ): number { - const wanted = Date.UTC(year, month - 1, day, hour, minute, second); - let guess = wanted; - for (let i = 0; i < 4; i++) { - const p = zonedParts(guess, timeZone); - const asIfUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second); - const delta = asIfUtc - wanted; - if (delta === 0) return guess; - guess -= delta; + const first = convergeWallTime(year, month, day, hour, minute, second, timeZone); + if (first.exact) return first.ms; + // DST gap (New York 02:00 on spring-forward, Havana/Santiago 00:00): the offset + // iteration settles an hour EARLY. Walk the wall clock forward minute by minute to + // the first wall time that exists; gap widths vary (30 min, 1 h), so never add a + // fixed offset. + let date = { year, month, day }; + let minuteOfDay = hour * 60 + minute; + for (let step = 0; step < MAX_GAP_WALK_MINUTES; step++) { + minuteOfDay += 1; + if (minuteOfDay >= 24 * 60) { + minuteOfDay -= 24 * 60; + date = addCalendarDay(date.year, date.month, date.day); + } + const h = Math.floor(minuteOfDay / 60); + const candidate = convergeWallTime( + date.year, + date.month, + date.day, + h, + minuteOfDay % 60, + second, + timeZone + ); + if (candidate.exact) return candidate.ms; } - return guess; + return first.ms; } /** @@ -130,7 +193,7 @@ export type TpdCooldownOptions = { */ export function resolveTpdCooldownMs( errorText: string | null | undefined, - options: TpdCooldownOptions = {}, + options: TpdCooldownOptions = {} ): number | null { if (!isTpdRateLimit(errorText)) return null; const now = options.nowMs ?? Date.now(); @@ -143,3 +206,19 @@ export function resolveTpdCooldownMs( } return null; } + +/** + * Milliseconds until the next operator-configured daily reset, or null when + * the clock is absent, invalid, or already passed. Shared by the non-TPD + * daily-quota paths so configured and unconfigured behavior stay in one place. + */ +export function nextConfiguredResetMs( + timezone: unknown, + hour: unknown, + nowMs: number +): number | null { + if (typeof timezone !== "string" || !isValidResetHour(hour)) return null; + if (!nodeDailyResetConfigured(timezone, hour)) return null; + const ms = nextDailyResetAtMs(timezone, hour, nowMs) - nowMs; + return ms > 0 ? ms : null; +} diff --git a/open-sse/services/genericQuotaFetcher.ts b/open-sse/services/genericQuotaFetcher.ts index ab5a87a693..43ed09d68d 100644 --- a/open-sse/services/genericQuotaFetcher.ts +++ b/open-sse/services/genericQuotaFetcher.ts @@ -24,10 +24,8 @@ import { type QuotaFetcher, type QuotaInfo, } from "./quotaPreflight.ts"; -import { - getAntigravityQuotaFamily, - getQuotaFetchScope, -} from "./antigravityQuotaFamily.ts"; +import { getAntigravityQuotaFamily, getQuotaFetchScope } from "./antigravityQuotaFamily.ts"; +import { boundedMap } from "../../src/lib/quota/boundedMap.ts"; type UsageFetcher = ( connection: Parameters[0], @@ -77,29 +75,19 @@ export function __resetGenericQuotaFetcherForTests(): void { pendingForceRefreshMiss.clear(); } -interface CacheEntry { - quota: QuotaInfo; - fetchedAt: number; -} - -const cache = new Map(); +// One entry per (provider, connection); 4096 keeps even very large account pools +// from ever evicting. An evicted entry only costs one extra upstream quota read. +const cache = boundedMap("quota-fetcher-cache", 4096, "ttl", CACHE_TTL_MS); function connectionKey(provider: string, connectionId: string): string { return `${provider.trim()}::${connectionId.trim()}`; } -function quotaCacheScope( - provider: string, - requestedModel?: string | null -): string { +function quotaCacheScope(provider: string, requestedModel?: string | null): string { return getQuotaFetchScope(provider, requestedModel); } -function cacheKey( - provider: string, - connectionId: string, - requestedModel?: string | null -): string { +function cacheKey(provider: string, connectionId: string, requestedModel?: string | null): string { return `${connectionKey(provider, connectionId)}::${quotaCacheScope(provider, requestedModel)}`; } @@ -125,22 +113,14 @@ function markPendingForceRefreshMiss(key: string): void { if (isPendingForceRefresh(key)) pendingForceRefreshMiss.set(key, Date.now()); } -function cachedQuotaIfFresh( - key: string, - forceRefresh: boolean, - now: number -): QuotaInfo | null { +function cachedQuotaIfFresh(key: string, forceRefresh: boolean, now: number): QuotaInfo | null { if (forceRefresh) return null; - const cached = cache.get(key); - if (cached && now - cached.fetchedAt < CACHE_TTL_MS) return cached.quota; + const cached = cache.get(key, now); + if (cached !== undefined) return cached; return null; } -function isForceRefreshMissCooling( - key: string, - forceRefresh: boolean, - now: number -): boolean { +function isForceRefreshMissCooling(key: string, forceRefresh: boolean, now: number): boolean { if (!forceRefresh) return false; const missedAt = pendingForceRefreshMiss.get(key); return missedAt !== undefined && now - missedAt < CACHE_TTL_MS; @@ -150,18 +130,17 @@ function isForceRefreshMissCooling( function isConcurrentForceRefresh(key: string, refreshStamp: number | undefined): boolean { const currentStamp = pendingForceRefresh.get(key); if (currentStamp === refreshStamp) return false; - return ( - currentStamp !== undefined && - Date.now() - currentStamp <= PENDING_FORCE_REFRESH_TTL_MS - ); + return currentStamp !== undefined && Date.now() - currentStamp <= PENDING_FORCE_REFRESH_TTL_MS; } -// 5min — same as Codex. Expiry is lazy on read (`isPendingForceRefresh`); -// this timer only reaps keys nobody fetches after the 5min TTL. +// 5min — same TTL as the original reap (CACHE_TTL_MS * 5). Expiry lazy on read +// (boundedMap ttl policy); this timer only keeps the sweep of +// pendingForceRefresh (5-min TTL, no systematic lazy read) + an opportunistic purge +// of stale cache entries along the way (get auto-purges). const _cacheCleanup = setInterval(() => { const now = Date.now(); - for (const [key, entry] of cache) { - if (now - entry.fetchedAt > CACHE_TTL_MS * 5) cache.delete(key); + for (const key of cache.keys()) { + cache.get(key); } for (const key of pendingForceRefresh.keys()) { dropExpiredPendingForceRefresh(key, now); @@ -289,10 +268,7 @@ export function convertUsageToQuotaInfo( const normalized = normalizeQuotaWindows(providerScopedWindows, context); const scopedEntries = Object.values(providerScopedWindows); - const percentUsed = scopedEntries.reduce( - (worst, entry) => Math.max(worst, entry.percentUsed), - 0 - ); + const percentUsed = scopedEntries.reduce((worst, entry) => Math.max(worst, entry.percentUsed), 0); const resetAt = scopedEntries.reduce<{ percentUsed: number; resetAt: string | null } | null>( (worst, entry) => (!worst || entry.percentUsed > worst.percentUsed ? entry : worst), @@ -322,10 +298,7 @@ function isAntigravityProvider(provider: string | null | undefined): boolean { return provider === "antigravity" || provider === "agy"; } -function antigravityWeeklyWindowMatchesFamily( - key: string, - family: "gemini" | "claude" -): boolean { +function antigravityWeeklyWindowMatchesFamily(key: string, family: "gemini" | "claude"): boolean { if (!key.endsWith("_weekly")) return false; return family === "gemini" ? key === "gemini_weekly" : key === "claude_gpt_weekly"; } @@ -456,7 +429,7 @@ export const fetchGenericQuota: QuotaFetcher = async (connectionId, connection) const unscopedQuota = convertUsageToQuotaInfo(usage, { provider }); registerQuotaWindows(provider, Object.keys(unscopedQuota?.windows || quota.windows || {})); - cache.set(key, { quota, fetchedAt: Date.now() }); + cache.set(key, quota); return quota; }; diff --git a/open-sse/services/model.ts b/open-sse/services/model.ts index ec0080ef83..1d36623205 100644 --- a/open-sse/services/model.ts +++ b/open-sse/services/model.ts @@ -1,7 +1,10 @@ import { PROVIDER_ID_TO_ALIAS, PROVIDER_MODELS } from "../config/providerModels.ts"; +import { ALIAS_TO_PROVIDER_ID, resolveProviderAlias } from "./providerAlias.ts"; import { resolveWildcardAlias } from "./wildcardRouter.ts"; import { getRegisteredProviderEffortBaseModelId } from "../utils/registeredEffortVariants.ts"; +export { resolveProviderAlias }; + type ProviderModelAliasMap = Record>; type ModelAliasValue = string | { provider?: string; model?: string }; type ModelAliasMap = Record; @@ -27,38 +30,6 @@ export function stripContextWindowSuffix( return modelStr.replace(CONTEXT_WINDOW_SUFFIX_RE, "").trimEnd(); } -// Derive alias→provider mapping from the single source of truth (PROVIDER_ID_TO_ALIAS) -// This prevents the two maps from drifting out of sync -const ALIAS_TO_PROVIDER_ID: Record = {}; -for (const [id, alias] of Object.entries(PROVIDER_ID_TO_ALIAS)) { - if (ALIAS_TO_PROVIDER_ID[alias]) { - console.log( - `[MODEL] Warning: alias "${alias}" maps to both "${ALIAS_TO_PROVIDER_ID[alias]}" and "${id}". Using "${id}".` - ); - } - ALIAS_TO_PROVIDER_ID[alias] = id; -} -// Manual alias overrides — maps slug-style prefixes to canonical provider IDs. -// These live outside the registry because they represent multiple providers -// or backward-compatible slug changes, not a single provider's display name. -// opencode/ → opencode-zen (the main free/open tier; opencode-go is a separate paid tier) -ALIAS_TO_PROVIDER_ID["opencode"] = "opencode-zen"; -// xiaomi/ is the user-visible prefix for MiMo models; register it so -// parseModel("xiaomi/mimo-v2-flash") resolves provider = "xiaomi-mimo" instead -// of falling through to the identity fallback ("xiaomi"). -ALIAS_TO_PROVIDER_ID["xiaomi"] = "xiaomi-mimo"; -// llamacpp/ is the user-visible alias for the llama-cpp self-hosted provider. -// The canonical ID is "llama-cpp" (with a hyphen), but the catalog and user-facing -// prefix is "llamacpp". Register it so parseModel("llamacpp/") resolves -// provider = "llama-cpp" instead of the identity fallback ("llamacpp"). -ALIAS_TO_PROVIDER_ID["llamacpp"] = "llama-cpp"; -// agy/ is the short alias for antigravity provider. -ALIAS_TO_PROVIDER_ID["agy"] = "antigravity"; -// aq/ is the user-visible prefix for the Amazon Q (AWS Builder ID) provider. -// The canonical provider ID is "amazon-q". Register it so parseModel("aq/") -// resolves provider = "amazon-q" instead of falling through to the identity fallback. -ALIAS_TO_PROVIDER_ID["aq"] = "amazon-q"; - // Provider-scoped legacy model aliases. Used to normalize provider/model inputs // and keep backward compatibility when upstream IDs change. const PROVIDER_MODEL_ALIASES: ProviderModelAliasMap = { @@ -180,31 +151,6 @@ interface ProviderConnectionLike { is_active?: unknown; } -/** - * Resolve provider alias to provider ID - */ -export function resolveProviderAlias(aliasOrId: string | null | undefined): string | null { - if (typeof aliasOrId !== "string") return null; - // Follow the alias chain transitively so intermediate alias-only hops resolve - // to the final target, but STOP as soon as a hop lands on a registered - // provider id (#2901): "oc" must resolve to the no-auth "opencode" provider, - // NOT continue through the manual "opencode" → "opencode-zen" slug override — - // that override is for user-typed `opencode/` prefixes only. Without this - // boundary the no-auth provider becomes unreachable by any prefix. - // Guarded against infinite loops with both a depth limit and a seen-set. - let current = aliasOrId; - const seen = new Set(); - for (let i = 0; i < 10; i++) { - const next = ALIAS_TO_PROVIDER_ID[current]; - if (!next || next === current) return current; - if (next in PROVIDER_ID_TO_ALIAS) return next; - if (seen.has(next)) return next; - seen.add(next); - current = next; - } - return current; -} - /** * #474 — Resolve a bare model name to the selected connection's `defaultModel`. * diff --git a/open-sse/services/providerAlias.ts b/open-sse/services/providerAlias.ts new file mode 100644 index 0000000000..a5941b22e0 --- /dev/null +++ b/open-sse/services/providerAlias.ts @@ -0,0 +1,58 @@ +import { PROVIDER_ID_TO_ALIAS } from "../config/providerModels.ts"; + +// Derive alias→provider mapping from the single source of truth (PROVIDER_ID_TO_ALIAS) +// This prevents the two maps from drifting out of sync +export const ALIAS_TO_PROVIDER_ID: Record = {}; +for (const [id, alias] of Object.entries(PROVIDER_ID_TO_ALIAS)) { + if (ALIAS_TO_PROVIDER_ID[alias]) { + console.log( + `[MODEL] Warning: alias "${alias}" maps to both "${ALIAS_TO_PROVIDER_ID[alias]}" and "${id}". Using "${id}".` + ); + } + ALIAS_TO_PROVIDER_ID[alias] = id; +} +// Manual alias overrides — maps slug-style prefixes to canonical provider IDs. +// These live outside the registry because they represent multiple providers +// or backward-compatible slug changes, not a single provider's display name. +// opencode/ → opencode-zen (the main free/open tier; opencode-go is a separate paid tier) +ALIAS_TO_PROVIDER_ID["opencode"] = "opencode-zen"; +// xiaomi/ is the user-visible prefix for MiMo models; register it so +// parseModel("xiaomi/mimo-v2-flash") resolves provider = "xiaomi-mimo" instead +// of falling through to the identity fallback ("xiaomi"). +ALIAS_TO_PROVIDER_ID["xiaomi"] = "xiaomi-mimo"; +// llamacpp/ is the user-visible alias for the llama-cpp self-hosted provider. +// The canonical ID is "llama-cpp" (with a hyphen), but the catalog and user-facing +// prefix is "llamacpp". Register it so parseModel("llamacpp/") resolves +// provider = "llama-cpp" instead of the identity fallback ("llamacpp"). +ALIAS_TO_PROVIDER_ID["llamacpp"] = "llama-cpp"; +// agy/ is the short alias for antigravity provider. +ALIAS_TO_PROVIDER_ID["agy"] = "antigravity"; +// aq/ is the user-visible prefix for the Amazon Q (AWS Builder ID) provider. +// The canonical provider ID is "amazon-q". Register it so parseModel("aq/") +// resolves provider = "amazon-q" instead of falling through to the identity fallback. +ALIAS_TO_PROVIDER_ID["aq"] = "amazon-q"; + +/** + * Resolve provider alias to provider ID + */ +export function resolveProviderAlias(aliasOrId: string | null | undefined): string | null { + if (typeof aliasOrId !== "string") return null; + // Follow the alias chain transitively so intermediate alias-only hops resolve + // to the final target, but STOP as soon as a hop lands on a registered + // provider id (#2901): "oc" must resolve to the no-auth "opencode" provider, + // NOT continue through the manual "opencode" → "opencode-zen" slug override — + // that override is for user-typed `opencode/` prefixes only. Without this + // boundary the no-auth provider becomes unreachable by any prefix. + // Guarded against infinite loops with both a depth limit and a seen-set. + let current = aliasOrId; + const seen = new Set(); + for (let i = 0; i < 10; i++) { + const next = ALIAS_TO_PROVIDER_ID[current]; + if (!next || next === current) return current; + if (next in PROVIDER_ID_TO_ALIAS) return next; + if (seen.has(next)) return next; + seen.add(next); + current = next; + } + return current; +} diff --git a/open-sse/services/rateLimitManager.ts b/open-sse/services/rateLimitManager.ts index 562aa3ec30..1546351865 100644 --- a/open-sse/services/rateLimitManager.ts +++ b/open-sse/services/rateLimitManager.ts @@ -39,6 +39,7 @@ import { getExecutorTimeoutMs, resolveConnectionTimeoutMs, } from "../handlers/chatCore/upstreamTimeouts.ts"; +import { boundedMap } from "../../src/lib/quota/boundedMap.ts"; interface LearnedLimitEntry { provider: string; @@ -90,8 +91,12 @@ const enabledConnections = new Set(); const connectionRateLimitOverrides = new Map>(); // Store learned limits for persistence (debounced) -const learnedLimits: Record = {}; -const MAX_LEARNED_LIMITS = 200; +// One learned entry per limiter key (provider:connection[:model]). The previous +// `MAX_LEARNED_LIMITS = 200` was declared but never enforced; enforcing 200 would +// start evicting (dropping persisted limits) on deployments with many +// connection×model limiters, so the enforced cap is set well above that. +export const MAX_LEARNED_LIMITS = 2048; +const learnedLimits = boundedMap("learned-limits", MAX_LEARNED_LIMITS, "lru"); const limiterLastUsed = new Map(); let persistTimer: ReturnType | null = null; const pendingAsyncOperations = new Set>(); @@ -969,7 +974,7 @@ export function getAllRateLimitStatus() { * Get all learned limits (for dashboard display). */ export function getLearnedLimits() { - return { ...learnedLimits }; + return { ...Object.fromEntries(learnedLimits) }; } // ─── Persistence ──────────────────────────────────────────────────────────── @@ -977,10 +982,8 @@ export function getLearnedLimits() { async function persistLearnedLimitsNow() { try { const { updateSettings } = await import("@/lib/db/settings"); - await updateSettings({ learnedRateLimits: JSON.stringify(learnedLimits) }); - logRateLimit( - `💾 [RATE-LIMIT] Persisted learned limits for ${Object.keys(learnedLimits).length} provider(s)` - ); + await updateSettings({ learnedRateLimits: JSON.stringify(Object.fromEntries(learnedLimits)) }); + logRateLimit(`💾 [RATE-LIMIT] Persisted learned limits for ${learnedLimits.size} provider(s)`); } catch (err) { errorRateLimit("[RATE-LIMIT] Failed to persist learned limits:", err.message); } @@ -996,12 +999,12 @@ function recordLearnedLimit( model: string | null = null ) { const key = getLimiterKey(provider, connectionId, model); - learnedLimits[key] = { + learnedLimits.set(key, { ...limits, provider, connectionId, lastUpdated: Date.now(), - }; + }); // Debounce: save at most once per PERSIST_DEBOUNCE_MS if (!persistTimer) { @@ -1054,8 +1057,8 @@ export async function __resetRateLimitManagerForTests() { limiterWatchdog.reset(); shutdownHandlersRegistered = false; - for (const key of Object.keys(learnedLimits)) { - delete learnedLimits[key]; + for (const key of [...learnedLimits.keys()]) { + learnedLimits.delete(key); } if (pendingAsyncOperations.size > 0) { @@ -1108,14 +1111,14 @@ async function loadPersistedLimits() { const remaining = toNumber(data.remaining, 0); const minTime = toNumber(data.minTime, 0); - learnedLimits[key] = { + learnedLimits.set(key, { provider, connectionId, lastUpdated, ...(limit > 0 ? { limit } : {}), ...(remaining >= 0 ? { remaining } : {}), ...(minTime >= 0 ? { minTime } : {}), - }; + }); // Apply to limiter if it exists and has rate limit enabled if (connectionId && enabledConnections.has(connectionId)) { diff --git a/open-sse/services/routing/quality.ts b/open-sse/services/routing/quality.ts index b4e06a9ed9..f24f2fd269 100644 --- a/open-sse/services/routing/quality.ts +++ b/open-sse/services/routing/quality.ts @@ -30,6 +30,7 @@ * Statistics are plain arithmetic (EWMA + small counters), O(1) per event, safe * under the Node event loop's single thread — no lock-free/atomic trickery. */ +import { boundedMap } from "../../../src/lib/quota/boundedMap.ts"; /** EWMA smoothing factor (alpha). Lower = slower adaptation. */ const OPERATIONAL_ALPHA = 0.2; @@ -60,7 +61,18 @@ interface QualityState { lastTs: number; } -const states = new Map(); +/** + * Cap on tracked (provider, model) pairs. Only pairs that actually carry traffic + * are tracked, so normal deployments stay far below it; past it the + * least-recently-used pair without an evaluator score is dropped (it restarts + * cold/neutral). Pairs holding a semantic score are never evicted — that score + * only comes from an evaluator run and cannot be re-learned from traffic. + */ +export const QUALITY_STATES_CAP = 4096; + +const states = boundedMap("routing-quality", QUALITY_STATES_CAP, "lru", 0, { + shouldEvict: (s) => s.semantic === null, +}); function keyOf(provider: string, model: string): string { return `${provider}/${model}`; @@ -273,7 +285,9 @@ export function getQualityScore(provider: string, model: string): number { /** Full snapshot of the tracker for explainability / dashboard. */ export function getQualitySnapshot(limit = 200): ProviderQuality[] { const views: ProviderQuality[] = []; - for (const [key] of states) { + // Snapshot copy: LRU get refreshes recency (reinsertion), so iterating live + get() + // would loop forever. Snapshot behavior unchanged. + for (const [key] of [...states]) { const slash = key.indexOf("/"); const provider = slash >= 0 ? key.slice(0, slash) : key; const model = slash >= 0 ? key.slice(slash + 1) : key; diff --git a/open-sse/services/streamRecovery.ts b/open-sse/services/streamRecovery.ts index 3a95e9a2a3..3bd95e5123 100644 --- a/open-sse/services/streamRecovery.ts +++ b/open-sse/services/streamRecovery.ts @@ -11,6 +11,7 @@ * without real sockets. The ReadableStream wiring lives in `createRecoverableStream`. */ import { STREAM_RECOVERY } from "../config/constants.ts"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; import { createThroughputWatchdog, ThroughputWatchdogError, @@ -19,6 +20,20 @@ import { export { ThroughputWatchdogError } from "./throughputWatchdog.ts"; +const TOOLCALL_ORDER_FIX_FLAG = "STREAM_RECOVERY_TOOLCALL_ORDER_FIX"; + +/** + * Read the opt-in tool-call-safe continuation flag. Fail-closed: any resolution failure + * (DB not ready, unknown key) keeps the release behavior. + */ +function isToolcallOrderFixEnabled(): boolean { + try { + return isFeatureFlagEnabled(TOOLCALL_ORDER_FIX_FLAG); + } catch { + return false; + } +} + /** Raised internally when an upstream stream ends without a terminal SSE marker. */ export class TruncatedStreamError extends Error { constructor(message = "Provider stream ended without a terminal marker") { @@ -335,6 +350,21 @@ export function trimContinuationOverlap(emitted: string, continuation: string): return continuation; } +/** Why a post-commit cut was not continued (see `ContinuationOutcome`). */ +export type ContinuationRefusal = "budget" | "tool-call" | "not-continuable"; + +/** + * Result of one mid-stream continuation decision, for observability only. `attempt` is the + * continuation counter `onContinue` reported (0 when a cut is refused before any attempt). + * A refusal is reported only for an abnormal end (read error, watchdog abort, or a graceful + * end with no terminal marker) of an OpenAI-compatible stream — never for a nominal end. + */ +export type ContinuationOutcome = + | { attempt: number; outcome: "suffix"; suffixChars: number } + | { attempt: number; outcome: "overlap-reject"; overlapChars: number } + | { attempt: number; outcome: "terminal" | "empty" | "no-stream" } + | { attempt: number; outcome: "refused"; reason: ContinuationRefusal }; + export interface RecoverableStreamOptions { /** Released exactly once when the wrapped stream closes, errors, or is cancelled. */ finalize: () => void; @@ -356,6 +386,8 @@ export interface RecoverableStreamOptions { maxContinuations?: number; /** Observability hook fired on each continuation attempt. */ onContinue?: (attempt: number, assistantSoFar: string) => void; + /** Observability hook fired with each continuation outcome or refused cut. */ + onContinueOutcome?: (event: ContinuationOutcome) => void; /** Opt-in active-stream output-quality watchdog. Disabled when omitted. */ throughputWatchdog?: ThroughputWatchdogOptions; /** Sanitized observability hook fired before the active attempt is aborted. */ @@ -433,7 +465,12 @@ export function createRecoverableStream( let emittedTerminal = false; let emittedToolCallInFlight = false; let emittedSawToolCall = false; // any tool_call delta seen, complete or not + let emittedToolCallFinish = false; // any finish_reason "tool_calls" seen let emittedParsedOpenAi = false; + // STREAM_RECOVERY_TOOLCALL_ORDER_FIX, resolved lazily at most once per stream and only + // on a recovery decision, so the flag costs nothing on streams that end cleanly. + let toolCallOrderFix: boolean | undefined; + const isToolCallOrderFixOn = () => (toolCallOrderFix ??= isToolcallOrderFixEnabled()); // Enqueue to the client and, when continuation is enabled, fold the chunk into the // running scan so a later continuation can be prefilled with exactly what was sent. @@ -455,6 +492,7 @@ export function createRecoverableStream( if (scan.terminal) emittedTerminal = true; if (scan.sawToolCallInFlight) emittedToolCallInFlight = true; if (scan.sawToolCall) emittedSawToolCall = true; + if (scan.finishReason === "tool_calls") emittedToolCallFinish = true; if (scan.parsedOpenAi) emittedParsedOpenAi = true; }; @@ -492,12 +530,33 @@ export function createRecoverableStream( emittedText.length === 0 && emittedReasoningText.length > 0; + // With STREAM_RECOVERY_TOOLCALL_ORDER_FIX on, any tool-call activity makes the turn + // non-continuable. The per-batch scan above is order-blind: a batch carrying a finished + // call followed by a new partial call reports nothing in flight, and a call finished with + // finish_reason "tool_calls" is a completed turn where only [DONE] can be missing — a + // continuation there spends an upstream request and appends content plus a second + // finish_reason after the tool-call finish. Every tool call is either still pending or + // already finished, so the order-independent check is exact. Off: the release gate. + const toolCallBlocksContinuation = () => + (emittedSawToolCall || emittedToolCallFinish) && isToolCallOrderFixOn(); + const canContinue = () => continueEnabled && continuations < maxContinuations && emittedParsedOpenAi && !emittedToolCallInFlight && - (emittedText.length > 0 ? !emittedTerminal : hallucinatedEmptyStop()); + (emittedText.length > 0 ? !emittedTerminal : hallucinatedEmptyStop()) && + !toolCallBlocksContinuation(); + + // Report why a cut is not continued. Silent for non-OpenAI bodies (continuation never + // applies to them) so the hook stays quiet on every Claude/Gemini-format stream end. + const reportRefusal = () => { + if (!continueEnabled || !emittedParsedOpenAi || !options.onContinueOutcome) return; + let reason: ContinuationRefusal = "not-continuable"; + if (continuations >= maxContinuations) reason = "budget"; + else if (emittedToolCallInFlight || toolCallBlocksContinuation()) reason = "tool-call"; + options.onContinueOutcome({ attempt: continuations, outcome: "refused", reason }); + }; const emitCleanTerminal = (controller: ReadableStreamDefaultController) => { controller.enqueue( @@ -509,12 +568,18 @@ export function createRecoverableStream( // Re-request from the partial text and stitch the missing suffix into the client stream. // Returns true once the recovered stream has been terminated (caller closes); false to // fall back to the unchanged #4131 error/close behavior. + // `cut` is false only for a graceful end that carried a terminal marker (nominal end). const tryContinue = async ( - controller: ReadableStreamDefaultController + controller: ReadableStreamDefaultController, + cut = true ): Promise => { - if (!canContinue()) return false; + if (!canContinue()) { + if (cut) reportRefusal(); + return false; + } continuations += 1; options.onContinue?.(continuations, emittedText); + const report = (event: ContinuationOutcome) => options.onContinueOutcome?.(event); let contStream: ReadableStream | null = null; try { @@ -522,7 +587,10 @@ export function createRecoverableStream( } catch { contStream = null; } - if (!contStream) return false; + if (!contStream) { + report({ attempt: continuations, outcome: "no-stream" }); + return false; + } // Drain the continuation fully (recovery favors correctness over token-by-token // streaming of the recovered tail), then emit only the de-duplicated suffix. @@ -554,6 +622,7 @@ export function createRecoverableStream( scan.text.length > 0 && overlapChars < STREAM_RECOVERY.MIN_CONTINUATION_OVERLAP_CHARS; if (isSuspectedRestart) { + report({ attempt: continuations, outcome: "overlap-reject", overlapChars }); if (await tryContinue(controller)) return true; emitCleanTerminal(controller); return true; @@ -566,9 +635,26 @@ export function createRecoverableStream( `data: ${JSON.stringify({ choices: [{ index: 0, delta: { content: suffix } }] })}\n\n` ) ); + report({ attempt: continuations, outcome: "suffix", suffixChars: suffix.length }); } // A clean finish, or a tool call we cannot safely stitch, ends the recovered stream. if (scan.terminal || scan.sawToolCall) { + if (!suffix) report({ attempt: continuations, outcome: "terminal" }); + emitCleanTerminal(controller); + return true; + } + // With STREAM_RECOVERY_TOOLCALL_ORDER_FIX on, a continuation that delivered no text + // carries no new information (the next re-request replays the same prefill), so close + // after this one spent request instead of burning the rest of the budget. + if (scan.text.length === 0 && isToolCallOrderFixOn()) { + report({ attempt: continuations, outcome: "empty" }); + emitCleanTerminal(controller); + return true; + } + // With STREAM_RECOVERY_TOOLCALL_ORDER_FIX on, a continuation that delivered no text + // carries no new information (the next re-request replays the same prefill), so close + // after this one spent request instead of burning the rest of the budget. + if (scan.text.length === 0 && isToolCallOrderFixOn()) { emitCleanTerminal(controller); return true; } @@ -619,7 +705,7 @@ export function createRecoverableStream( // says the stream is worth continuing (silent truncation, or a clean-but-empty // reasoning-only stop) — canContinue() is the single source of truth here, same as // the read-error branch above. - if (await tryContinue(controller)) { + if (await tryContinue(controller, !emittedTerminal)) { runFinalize(); controller.close(); return; diff --git a/open-sse/utils/credentialPatterns.ts b/open-sse/utils/credentialPatterns.ts index b9a2366d70..24083675c1 100644 --- a/open-sse/utils/credentialPatterns.ts +++ b/open-sse/utils/credentialPatterns.ts @@ -18,6 +18,12 @@ export const CREDENTIAL_PATTERNS: CredentialPattern[] = [ regex: /sk-ant-[A-Za-z0-9_-]{20,}/g, replacement: "[REDACTED:anthropic]", }, + // GHSA-r4q7-7f24-m29p: Groq (`gsk_` + 52) and xAI (`xai-` + 80) had no entry, so both + // the opt-in guardrail and the public error sanitizer echoed them verbatim. Lower bound + // only, for the same reason as `google` below — an error body that over-redacts a + // look-alike costs nothing; one that under-redacts leaks a credential. + { name: "groq", regex: /\bgsk_[A-Za-z0-9]{20,}/g, replacement: "[REDACTED:groq]" }, + { name: "xai", regex: /\bxai-[A-Za-z0-9]{20,}/g, replacement: "[REDACTED:xai]" }, // {20,} rather than the exact {35} of a standard 39-char Google API key. #12506 added // this pattern with the exact length; #12620 landed the anti-drift test that asserts // /\bAIza[A-Za-z0-9_-]{20,}/ must not survive. Anything shorter or longer than 39 was @@ -82,4 +88,17 @@ export const CREDENTIAL_PATTERNS: CredentialPattern[] = [ /((?:["\x27]?(?:Authorization|x-api-key|api-key|apikey)["\x27]?\s*[:=]\s*["\x27]?)(?:(?:Bearer|Basic|Token)\s+)?)[A-Za-z0-9._~+/=-]{10,}/gi, replacement: "$1[REDACTED:auth_header]", }, + // GHSA-r4q7-7f24-m29p: generic `sk-` fallback for every OpenAI-compatible provider whose + // key is not exactly 48 chars (DeepSeek 32-hex, Moonshot/Kimi 47-49, Together, …). The + // guardrail is catalog-only, so all of those passed through it untouched. MUST stay the + // LAST entry: both consumers iterate in order and replace as they go, so `openai_proj`, + // `openai` and `anthropic*` have already stamped their specific label before this one + // runs — it only ever sees the `sk-` shapes nothing else claimed. The lookbehind + // (mirroring STRONG_CREDENTIAL_TOKEN in errorSanitization.ts) keeps `risk-…`-style words + // from matching. + { + name: "openai_compatible", + regex: /(? 0 ? totalMs : null; } +const MAX_PROSE_RETRY_MS = 24 * 60 * 60 * 1000; + +/** + * Retry delay in ms from upstream error prose (Antigravity "reset after 2h7m23s", + * generic "retry after 30s"), capped at 24h; null when the text carries no hint. + */ +export function parseProseRetryDelayMs(text: unknown): number | null { + if (typeof text !== "string" || text === "") return null; + const antigravityMs = parseAntigravityRetryTime(text); + if (antigravityMs) return Math.min(antigravityMs, MAX_PROSE_RETRY_MS); + const m = /retry\s+after\s+(\d{1,9})\s*s/i.exec(text); + const ms = m ? Number.parseInt(m[1], 10) * 1000 : 0; + return ms > 0 ? Math.min(ms, MAX_PROSE_RETRY_MS) : null; +} + +/** + * Combo drain paths: ISO retry time read from the prose of an upstream error body, + * JSON or plain text. Null when RETRY_AFTER_PROVENANCE_ENABLED is off (legacy: + * only structured retry fields are read) or when the text carries no hint. + */ +export function readProseRetryAfter(text: unknown): string | null { + if (!isRetryAfterProvenanceEnabled()) return null; + const ms = parseProseRetryDelayMs(text); + return ms ? new Date(Date.now() + ms).toISOString() : null; +} + +/** + * Combo drain paths: the upstream error body could not be read for a retry hint. + * A non-JSON body (an HTML 502 page, plain text) is ordinary, so it logs at debug; + * a failed clone means the body was already consumed and logs at warn. + */ +export function logRetryHintUnreadable( + log: { warn: (...args: unknown[]) => void; debug?: (...args: unknown[]) => void }, + tag: string, + model: string, + status: number | undefined, + reason: "unparseable body" | "clone failed" +): void { + const message = `Retry hint unreadable for ${model} (${reason})`; + if (reason === "clone failed") log.warn(tag, message, { status }); + else log.debug?.(tag, message, { status }); +} + /** * Parse upstream provider error response * @param {Response} response - Fetch response from provider @@ -925,15 +1004,23 @@ export function unavailableResponse( retryAfter?: string | number | Date | null, retryAfterHuman?: string ) { - const retryAfterSec = normalizeRetryAfterSeconds(retryAfter); + // #13672 (opt-in): only a concrete future retry time earns a Retry-After header, and the + // body says whether one existed. Off: legacy header, always present and clamped to >= 1s. + const provenance = isRetryAfterProvenanceEnabled(); + const retryAfterSec = provenance + ? resolveRetryAfterHintSeconds(retryAfter) + : normalizeRetryAfterSeconds(retryAfter); const safeMessage = sanitizeErrorMessage(message) || getDefaultErrorMessage(statusCode); const safeRetryAfterHuman = retryAfterHuman ? sanitizeErrorMessage(retryAfterHuman) : ""; const msg = safeRetryAfterHuman ? `${safeMessage} (${safeRetryAfterHuman})` : safeMessage; - return new Response(JSON.stringify({ error: { message: msg } }), { + const error = provenance + ? { message: msg, retry_after_provenance: retryAfterSec === null ? "none" : "signal" } + : { message: msg }; + return new Response(JSON.stringify({ error }), { status: statusCode, headers: { "Content-Type": "application/json", - "Retry-After": String(retryAfterSec), + ...(retryAfterSec === null ? {} : { "Retry-After": String(retryAfterSec) }), }, }); } diff --git a/open-sse/utils/firstByteWatchdog.ts b/open-sse/utils/firstByteWatchdog.ts new file mode 100644 index 0000000000..f58664030d --- /dev/null +++ b/open-sse/utils/firstByteWatchdog.ts @@ -0,0 +1,133 @@ +// Races a streamed body's first read() against a short deadline. A healthy body is untouched: +// the first chunk is replayed and the rest is relayed on demand, so a slow consumer never makes +// us buffer upstream bytes. A body that stays silent is cancelled and the caller gets a +// TimeoutError carrying its own code (callers pick the code so logs say which guard fired). + +export const RESPONSES_FIRST_BYTE_TIMEOUT_CODE = "RESPONSES_FIRST_BYTE_TIMEOUT"; + +export type FirstByteGuardOptions = { + timeoutMs: number; + signal?: AbortSignal | null; + code: string; + message: (timeoutMs: number) => string; +}; + +type BodyReader = ReadableStreamDefaultReader; +type FirstReadResult = ReadableStreamReadResult; + +function createTimeoutError(options: FirstByteGuardOptions): Error & { code: string } { + const err = new Error(options.message(options.timeoutMs)) as Error & { + code: string; + }; + err.name = "TimeoutError"; + err.code = options.code; + return err; +} + +function createAbortError(signal: AbortSignal): Error { + if (signal.reason instanceof Error) return signal.reason; + const err = new Error("The operation was aborted"); + err.name = "AbortError"; + return err; +} + +async function raceFirstChunk( + reader: BodyReader, + options: FirstByteGuardOptions +): Promise { + const { signal } = options; + let timer: ReturnType | undefined; + let onAbort: (() => void) | undefined; + const guards = new Promise((_, reject) => { + timer = setTimeout(() => reject(createTimeoutError(options)), options.timeoutMs); + // The timer stays referenced: the race can be the only live handle + // (direct callers, unit tests), where an unref'd timer would let the + // event loop drain before it fires. Request paths always have other + // handles, so this changes nothing there; cleared on every settle. + if (!signal) return; + if (signal.aborted) { + reject(createAbortError(signal)); + return; + } + onAbort = () => reject(createAbortError(signal)); + signal.addEventListener("abort", onAbort, { once: true }); + }); + try { + return await Promise.race([reader.read(), guards]); + } finally { + clearTimeout(timer); + if (onAbort) signal?.removeEventListener("abort", onAbort); + } +} + +function buildOnDemandStream( + reader: BodyReader, + first: FirstReadResult +): ReadableStream { + let replayFirst = true; + return new ReadableStream({ + async pull(controller) { + if (replayFirst) { + replayFirst = false; + if (first.value) controller.enqueue(first.value); + if (first.done) controller.close(); + return; + } + try { + const { done, value } = await reader.read(); + if (done) controller.close(); + else if (value) controller.enqueue(value); + } catch (error) { + controller.error(error); + } + }, + cancel(reason) { + void reader.cancel(reason).catch(() => {}); + }, + }); +} + +export async function guardFirstByte( + response: Response, + options: FirstByteGuardOptions +): Promise { + if (!options.timeoutMs || options.timeoutMs <= 0 || !response.body) return response; + + const reader = response.body.getReader(); + let first: FirstReadResult; + try { + first = await raceFirstChunk(reader, options); + } catch (error) { + // A wedged upstream may never settle its cancel; never reintroduce the wait. + void reader.cancel(error).catch(() => {}); + throw error; + } + + return new Response(buildOnDemandStream(reader, first), { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); +} + +export function isResponsesFirstByteTimeout(err: unknown): boolean { + return ( + !!err && + typeof err === "object" && + "code" in err && + (err as { code?: unknown }).code === RESPONSES_FIRST_BYTE_TIMEOUT_CODE + ); +} + +export function guardResponsesStreamFirstByte( + response: Response, + timeoutMs: number, + signal?: AbortSignal | null +): Promise { + return guardFirstByte(response, { + timeoutMs, + signal, + code: RESPONSES_FIRST_BYTE_TIMEOUT_CODE, + message: (ms) => `Responses stream produced no first body byte within ${ms}ms`, + }); +} diff --git a/open-sse/utils/providerRequestLogging.ts b/open-sse/utils/providerRequestLogging.ts index 5b10726fb5..c349f8da75 100644 --- a/open-sse/utils/providerRequestLogging.ts +++ b/open-sse/utils/providerRequestLogging.ts @@ -127,9 +127,56 @@ export function captureCurrentProviderBody( return captureCurrentProviderRequest(url, headers, parseBody(bodyString), bodyString, log); } +const DISPATCH_STATE_KEY = Symbol.for("omniroute.providerRequestCapture.dispatch"); +const dispatchContext = (( + globalThis as typeof globalThis & { + [DISPATCH_STATE_KEY]?: AsyncLocalStorage<{ settled: boolean }>; + } +)[DISPATCH_STATE_KEY] ??= new AsyncLocalStorage<{ settled: boolean }>()); + +type DispatchStartListener = () => void; + +const DISPATCH_LISTENERS_KEY = Symbol.for( + "omniroute.providerRequestCapture.dispatchStartListeners" +); + +function getDispatchStartListeners(): Set { + const scopedGlobal = globalThis as typeof globalThis & { + [DISPATCH_LISTENERS_KEY]?: Set; + }; + return (scopedGlobal[DISPATCH_LISTENERS_KEY] ??= new Set()); +} + +/** + * Run listeners at the entry of every provider dispatch, before fn starts. A + * listener resolves what it needs through its own closure (usually an ALS + * reader), so a dispatch for another request never touches this request's + * state. Listeners must stay synchronous and side-effect free beyond the + * request's own sink. + */ +export function onDispatchStart(listener: DispatchStartListener): void { + getDispatchStartListeners().add(listener); +} + export function runWithCapture(requestCapture: Capture, fn: () => Promise): Promise { installFetchCapture(); - return captureState.context.run(requestCapture, fn); + const dispatch = { settled: false }; + return dispatchContext.run(dispatch, () => { + for (const listener of getDispatchStartListeners()) listener(); + return captureState.context.run(requestCapture, fn).finally(() => { + dispatch.settled = true; + }); + }); +} + +/** + * True while a provider request is being dispatched: inside runWithCapture and before its + * fn settles. A fetch the executor leaves running after it returns (a background + * bookkeeping call) keeps the async context but no longer counts as the dispatch. + */ +export function isProviderRequestCaptureActive(): boolean { + const dispatch = dispatchContext.getStore(); + return dispatch !== undefined && !dispatch.settled; } function installFetchCapture() { diff --git a/open-sse/utils/proxyDispatcher.ts b/open-sse/utils/proxyDispatcher.ts index e557fe7f31..6d9c6714ae 100644 --- a/open-sse/utils/proxyDispatcher.ts +++ b/open-sse/utils/proxyDispatcher.ts @@ -1,5 +1,6 @@ import "./setupPolyfill.ts"; import { Agent, ProxyAgent, type Dispatcher } from "undici"; +import { decodeUserinfo } from "@/shared/utils/decodeUserinfo"; import { getUpstreamTimeoutConfig } from "@/shared/utils/runtimeTimeouts"; import { stripIpv6Brackets, detectIpLiteralFamily, parseProxyFamily } from "./proxyFamily.ts"; import { createSocksDispatcherWithFamily } from "./socksConnectorWithFamily.ts"; @@ -248,8 +249,7 @@ function normalizePort(port: string | number | null | undefined, protocol: strin * listen on these ports, so we must always include the port explicitly. */ function buildProxyUrlString(parsed: URL, port: string): string { - const auth = - parsed.username || parsed.password ? `${parsed.username}:${parsed.password}@` : ""; + const auth = parsed.username || parsed.password ? `${parsed.username}:${parsed.password}@` : ""; return `${parsed.protocol}//${auth}${parsed.hostname}:${port}`; } @@ -436,6 +436,23 @@ export function __createRoundRobinDispatcherForTest(dispatchers: Dispatcher[]): return createRoundRobinDispatcher(dispatchers); } +/** + * `Proxy-Authorization` value for an HTTP(S) proxy URL carrying userinfo, or null. + * + * undici's ProxyAgent builds this header itself with a bare `decodeURIComponent` on the + * URL's username/password, which throws `URIError` for a credential holding a literal + * `%` (e.g. `pa%ss`) — the dispatcher could not even be constructed. We build the same + * header (same `Basic base64(user:pass)` / `user:` shapes undici emits) with the guarded + * decoder and hand it over as `token`, so undici never decodes. Correctly encoded + * credentials (`user%40corp`) produce exactly the header undici produced before. + */ +function buildProxyAuthorizationToken(parsed: URL): string | null { + if (!parsed.username) return null; + const user = decodeUserinfo(parsed.username); + const pass = parsed.password ? decodeUserinfo(parsed.password) : ""; + return `Basic ${Buffer.from(`${user}:${pass}`).toString("base64")}`; +} + /** * Build a ProxyAgent / socks dispatcher for a normalized proxy URL using the * given options. Shared by the pooled dispatcher (keep-alive, pipelining 4) @@ -458,8 +475,8 @@ function buildProxyDispatcher( host: stripIpv6Brackets(parsed.hostname), port: Number(port), }; - if (parsed.username) socksOptions.userId = decodeURIComponent(parsed.username); - if (parsed.password) socksOptions.password = decodeURIComponent(parsed.password); + if (parsed.username) socksOptions.userId = decodeUserinfo(parsed.username); + if (parsed.password) socksOptions.password = decodeUserinfo(parsed.password); return createSocksDispatcherWithFamily( socksOptions as unknown as Parameters[0], family, @@ -473,6 +490,7 @@ function buildProxyDispatcher( // `{ family, autoSelectFamily }` pin. At runtime undici merges these options into // net.connect (the uri already carries the host:port), so the partial pin is // valid; the cast suppresses the spurious missing-`port` error. + const proxyAuthorization = buildProxyAuthorizationToken(parsed); return new ProxyAgent({ uri: cleanUri, // undici 8.6+ forwards plain-HTTP requests through the proxy as an origin @@ -482,6 +500,7 @@ function buildProxyDispatcher( // undici <8.6 → silently ignored (that version already tunneled by default). proxyTunnel: true, ...options, + ...(proxyAuthorization ? { token: proxyAuthorization } : {}), ...(family !== null ? { proxyTls: { family, autoSelectFamily: false } as ProxyAgent.Options["proxyTls"] } : {}), @@ -553,7 +572,7 @@ export function __getSocksOptionsForTest(proxyUrl: string): SocksDispatcherOptio host: stripIpv6Brackets(parsed.hostname), port: Number(port), }; - if (parsed.username) socksOptions.userId = decodeURIComponent(parsed.username); - if (parsed.password) socksOptions.password = decodeURIComponent(parsed.password); + if (parsed.username) socksOptions.userId = decodeUserinfo(parsed.username); + if (parsed.password) socksOptions.password = decodeUserinfo(parsed.password); return socksOptions; } diff --git a/open-sse/utils/proxyFallback.ts b/open-sse/utils/proxyFallback.ts index 5590dbd16a..a2fc1550fe 100644 --- a/open-sse/utils/proxyFallback.ts +++ b/open-sse/utils/proxyFallback.ts @@ -12,6 +12,7 @@ import { fetch as undiciFetch } from "undici"; import { createProxyDispatcher, normalizeProxyUrl } from "./proxyDispatcher.ts"; import { resolveProxyForScopeFromRegistry, listProxies } from "@/lib/db/proxies"; import { listOneproxyProxies } from "@/lib/db/oneproxy"; +import { decodeUserinfo } from "@/shared/utils/decodeUserinfo"; import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; // --------------------------------------------------------------------------- @@ -427,8 +428,8 @@ export async function selectWorkingProxyFallback(_connectionId?: string): Promis type: url.protocol.replace(":", "") || "http", host: url.hostname, port: parseInt(url.port, 10) || (url.protocol === "https:" ? 443 : 80), - username: url.username ? decodeURIComponent(url.username) : "", - password: url.password ? decodeURIComponent(url.password) : "", + username: url.username ? decodeUserinfo(url.username) : "", + password: url.password ? decodeUserinfo(url.password) : "", }, level: "autoSelect", levelId: null, diff --git a/open-sse/utils/proxyFetch.ts b/open-sse/utils/proxyFetch.ts index 349bb71a2d..f060573f98 100644 --- a/open-sse/utils/proxyFetch.ts +++ b/open-sse/utils/proxyFetch.ts @@ -14,6 +14,7 @@ import { proxyUrlForLogs, } from "./proxyDispatcher.ts"; import tlsClient, { type TlsFetchOptions, guardTlsFirstByte } from "./tlsClient.ts"; +import { withUpstreamStatusCapture } from "./upstreamStatusCapture.ts"; import { isProxyReachable } from "@/lib/proxyHealth"; import { isControlPlaneProxyDirectFallbackEnabled, @@ -188,7 +189,7 @@ type TlsFingerprintStore = { * the egress logger read the innermost applied proxy (the last writer wins, which * is the executor's per-account proxy). */ -export type AppliedProxySink = { proxy: unknown }; +export type AppliedProxySink = { proxy: unknown; upstreamStatus?: number }; const appliedProxyContext = new AsyncLocalStorage(); /** @@ -753,7 +754,7 @@ export async function runWithProxyContextOrDirect(proxyConfig, fn) { return runWithProxyContext(proxyConfig, fn, { directFallbackOnUnreachable: true }); } -async function patchedFetch( +async function patchedFetchUnrecorded( input: RequestInfo | URL, options: FetchWithDispatcherOptions = {}, deps: ProxyFetchDeps = {} @@ -1179,6 +1180,9 @@ async function patchedFetch( throw lastProxyError; } +const getAppliedProxySink = () => appliedProxyContext.getStore(); +const patchedFetch = withUpstreamStatusCapture(patchedFetchUnrecorded, getAppliedProxySink); + /** * Named export for proxyFetch — identical to the patched globalThis.fetch but * accepts an optional ProxyFetchDeps for unit test dependency injection. diff --git a/open-sse/utils/proxyRefusalMemory.ts b/open-sse/utils/proxyRefusalMemory.ts new file mode 100644 index 0000000000..7479b720d9 --- /dev/null +++ b/open-sse/utils/proxyRefusalMemory.ts @@ -0,0 +1,198 @@ +/** + * Short-lived, per-process memory of proxies that just failed, shared by both places that + * pick a proxy: registry pools (#6365) and the per-account rotation of noauth executors. + * A failed proxy is set aside for a period that doubles on each repeat, up to a cap, then + * comes back. Nothing is persisted and no proxy status is written: only the order in which + * candidates are tried changes. Keys are entry points (scheme, username, host, port), + * never passwords. + * + * This module is a pure store: it never reads the PROXY_SKIP_RECENTLY_FAILED feature flag + * (callers gate writes and decisions on it) and it stays free of the proxy dispatcher, so + * the DB layer can consult it without loading undici or the SOCKS connector. + */ +import { COOLDOWN_MS } from "../config/errorConfig.ts"; +import { stripIpv6Brackets } from "./proxyFamily.ts"; + +export const REFUSAL_POLICIES = { + /** The TCP probe could not open a connection to the proxy. */ + proxy_unreachable: { baseMs: 60_000, maxMs: 600_000 }, + /** The provider refused through this proxy; the member is set aside for a cooldown. */ + ip_quota_429: { baseMs: COOLDOWN_MS.rateLimit, maxMs: 3_600_000 }, +} as const; + +export type ProxyRefusalKind = keyof typeof REFUSAL_POLICIES; + +// `seq` orders set-aside events so a cache can tell whether it already saw this one. +type RefusalState = { streak: number; until: number; seq: number }; + +const MAX_ENTRIES = 1000; +const REFUSAL_KINDS = Object.keys(REFUSAL_POLICIES) as ProxyRefusalKind[]; +// Same protocol set and default ports as proxyConfigToUrl() in proxyDispatcher.ts. +const DEFAULT_PORTS: Record = { http: "8080", https: "443", socks5: "1080" }; +const RELAY_TYPES = new Set(["vercel", "deno", "cloudflare"]); +const FAMILY_MARKER = /\?family=(ipv4|ipv6)$/; + +const memory = new Map(); +let refusalSeq = 0; + +const textField = (value: unknown): string => (typeof value === "string" ? value : ""); + +// The port as proxyConfigToUrl() normalizes it: the scheme default when unset, null if invalid. +function configPort(port: unknown, type: string): string | null { + if (!port) return DEFAULT_PORTS[type] ?? ""; + const parsed = Number(port); + return Number.isInteger(parsed) && parsed >= 1 && parsed <= 65535 ? String(parsed) : null; +} + +// A config object as the URL proxyConfigToUrl() would build from it; null when unusable. +function configObjectToUrl(proxy: Record): string | null { + const host = textField(proxy.host); + const type = String(proxy.type || "http").toLowerCase(); + const port = configPort(proxy.port, type); + if (!host || RELAY_TYPES.has(type) || port === null) return null; + const bracketed = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; + const username = textField(proxy.username); + const password = textField(proxy.password); + const auth = + username || password ? `${encodeURIComponent(username)}:${encodeURIComponent(password)}@` : ""; + return `${type}://${auth}${bracketed}:${port}`; +} + +// The port written in the authority, which `new URL()` drops when it is the scheme default. +function explicitPortOf(url: string): string | null { + const start = url.indexOf("://"); + if (start === -1) return null; + const rest = url.slice(start + 3); + const slash = rest.indexOf("/"); + const authority = slash === -1 ? rest : rest.slice(0, slash); + const colon = authority.lastIndexOf(":"); + if (colon === -1 || colon < authority.lastIndexOf("@") || colon < authority.lastIndexOf("]")) { + return null; + } + const port = Number(authority.slice(colon + 1)); + return /^\d+$/.test(authority.slice(colon + 1)) && port >= 1 && port <= 65535 + ? String(port) + : null; +} + +/** + * One key per proxy entry point, whether the proxy comes as a config object, a URL or a + * legacy string: scheme, decoded username, lower-case host without IPv6 brackets, port as + * normalization writes it. Password and ?family= are ignored. Anything unusable, and edge + * relays, give null, which never sets anything aside. + */ +export function proxyEgressKey(proxy: unknown): string | null { + try { + let url: string | null = null; + if (typeof proxy === "string") url = proxy.trim(); + else if (proxy && typeof proxy === "object" && !Array.isArray(proxy)) { + url = configObjectToUrl(proxy as Record); + } + if (!url) return null; + url = url.replace(FAMILY_MARKER, ""); + const parsed = new URL(url); + const scheme = parsed.protocol.replace(/:$/, "").toLowerCase(); + const defaultPort = DEFAULT_PORTS[scheme]; + if (!defaultPort || !parsed.hostname) return null; + const port = explicitPortOf(url) || parsed.port || defaultPort; + const user = parsed.username ? decodeURIComponent(parsed.username) : ""; + return `${scheme}://${user}@${stripIpv6Brackets(parsed.hostname).toLowerCase()}:${port}`; + } catch { + return null; + } +} + +function entryId(key: string, kind: ProxyRefusalKind): string { + return `${kind} ${key}`; +} + +// Read one (key, kind) state, dropping it once its period ended more than 2 x maxMs ago. +function readState(key: string, kind: ProxyRefusalKind, nowMs: number): RefusalState | undefined { + const id = entryId(key, kind); + const state = memory.get(id); + if (state && nowMs - state.until >= 2 * REFUSAL_POLICIES[kind].maxMs) { + memory.delete(id); + return undefined; + } + return state; +} + +/** Set a proxy aside for `kind`. Returns the new period in ms, or null if nothing changed. */ +export function noteProxyRefusal( + key: string | null, + kind: ProxyRefusalKind, + nowMs: number = Date.now() +): number | null { + if (key === null) return null; + const state = readState(key, kind, nowMs); + if (state && state.until > nowMs) return null; + const policy = REFUSAL_POLICIES[kind]; + const streak = (state?.streak ?? 0) + 1; + const periodMs = Math.min(policy.baseMs * 2 ** (streak - 1), policy.maxMs); + const id = entryId(key, kind); + memory.delete(id); + memory.set(id, { streak, until: nowMs + periodMs, seq: ++refusalSeq }); + if (memory.size > MAX_ENTRIES) { + const oldest = memory.keys().next().value; + if (oldest !== undefined) memory.delete(oldest); + } + return periodMs; +} + +/** The proxy answered again: end its period now, keep the streak so a repeat doubles. */ +export function noteProxyRecovered( + key: string | null, + kind: ProxyRefusalKind, + nowMs: number = Date.now() +): void { + if (key === null) return; + const state = readState(key, kind, nowMs); + if (state && state.until > nowMs) state.until = nowMs; +} + +/** A response came back through this proxy: forget every refusal kind for it. */ +export function noteProxyServed(key: string | null): void { + if (key === null) return; + for (const kind of REFUSAL_KINDS) memory.delete(entryId(key, kind)); +} + +export function isProxyAvoided(key: string | null, nowMs: number = Date.now()): boolean { + return proxySetAsideSeq(key, nowMs) !== null; +} + +/** + * Sequence number of the most recent set-aside event still in force for this proxy, or + * null when it is not set aside. Compare with getProxyRefusalSeq() captured earlier to + * know whether the event happened after that point. + */ +export function proxySetAsideSeq(key: string | null, nowMs: number = Date.now()): number | null { + if (key === null || memory.size === 0) return null; + let latest: number | null = null; + for (const kind of REFUSAL_KINDS) { + const state = readState(key, kind, nowMs); + if (state && state.until > nowMs && (latest === null || state.seq > latest)) { + latest = state.seq; + } + } + return latest; +} + +/** Sequence number of the last set-aside event recorded in this process (0 = none yet). */ +export function getProxyRefusalSeq(): number { + return refusalSeq; +} + +/** True when anything is held at all: lets hot paths skip key computation and flag reads. */ +export function hasProxyRefusals(): boolean { + return memory.size > 0; +} + +/** Test-only: forget everything. */ +export function __resetProxyRefusalMemoryForTesting(): void { + memory.clear(); +} + +/** Test-only: number of (key, kind) entries held. */ +export function __proxyRefusalMemorySizeForTesting(): number { + return memory.size; +} diff --git a/open-sse/utils/upstreamStatusCapture.ts b/open-sse/utils/upstreamStatusCapture.ts new file mode 100644 index 0000000000..6f336ff15d --- /dev/null +++ b/open-sse/utils/upstreamStatusCapture.ts @@ -0,0 +1,43 @@ +import { isProviderRequestCaptureActive, onDispatchStart } from "./providerRequestLogging.ts"; + +/** + * Wrap the process-wide fetch so the HTTP status the provider actually returned lands on + * the request's applied-proxy sink. Only calls that settle while a provider request is + * being dispatched count: side fetches of the same request (usage sync, dashboard events) + * and background calls an executor leaves running after it returns must not overwrite the + * provider's status. A new dispatch invalidates the earlier status at entry, so a retry + * that never reaches the network (local refusal, start timeout) leaves nothing stale + * behind; within the dispatch the last response received wins, which follows an + * executor's own retries. A background call keeps a settled dispatch token and never + * writes, so clearing at entry cannot race with it. A call that throws clears the + * status, so a network error on one proxy after a 429 on another leaves no stale 429 + * behind. The response and any exception pass through. In cloud mode the default export + * is the unpatched fetch, so nothing is captured and the log keeps null. + * + * `isDispatching` is injectable for tests only. + */ +export function withUpstreamStatusCapture( + inner: (...args: A) => Promise, + getSink: () => { upstreamStatus?: number } | undefined, + isDispatching: () => boolean = isProviderRequestCaptureActive +): (...args: A) => Promise { + if (isDispatching === isProviderRequestCaptureActive) { + onDispatchStart(() => { + const sink = getSink(); + if (sink) delete sink.upstreamStatus; + }); + } + return async (...args: A) => { + const sink = isDispatching() ? getSink() : undefined; + if (!sink) return inner(...args); + let response: Response; + try { + response = await inner(...args); + } catch (error) { + if (isDispatching()) sink.upstreamStatus = undefined; + throw error; + } + if (isDispatching()) sink.upstreamStatus = response.status; + return response; + }; +} diff --git a/open-sse/utils/usageTracking.ts b/open-sse/utils/usageTracking.ts index d71b858cc8..16339a95af 100644 --- a/open-sse/utils/usageTracking.ts +++ b/open-sse/utils/usageTracking.ts @@ -642,6 +642,33 @@ export function normalizeUsage(usage: UsageLike | null | undefined) { return normalized; } +// Internal marker for usage that was estimated locally (a web/cookie executor with no +// upstream metering). A NON-enumerable symbol: JSON.stringify, object spread and +// filterUsageForFormat never copy it, so it cannot reach a client payload or change any +// usage field, cost or budget — it only lets the call-log sink tell estimated usage apart +// after extraction rebuilt the object without the provider's `estimated` flag. +const ESTIMATED_USAGE_MARKER = Symbol.for("omniroute.usage.estimated"); + +export function carryEstimatedUsageMarker(source: unknown, rebuilt: T): T { + const estimated = + !!source && typeof source === "object" && (source as UsageLike).estimated === true; + if (estimated && rebuilt && typeof rebuilt === "object") { + Object.defineProperty(rebuilt, ESTIMATED_USAGE_MARKER, { value: true, enumerable: false }); + } + return rebuilt; +} + +/** + * True when token usage was estimated locally instead of reported by the provider: either + * the usage still carries `estimated: true` (OmniRoute's own estimateUsage fallback) or + * extraction kept the internal marker. Observability only — billing does not read it. + */ +export function isEstimatedUsage(usage: unknown): boolean { + if (!usage || typeof usage !== "object") return false; + if ((usage as UsageLike).estimated === true) return true; + return Reflect.get(usage, ESTIMATED_USAGE_MARKER) === true; +} + /** * Check if usage has valid token data * Valid = has at least one token field with value > 0 @@ -786,7 +813,7 @@ export function extractUsage(chunk: UsagePayloadLike | null | undefined) { typeof chunk.usage === "object" && (chunk.usage.prompt_tokens !== undefined || chunk.usage.input_tokens !== undefined) ) { - return normalizeUsage({ + const normalized = normalizeUsage({ prompt_tokens: chunk.usage.prompt_tokens ?? chunk.usage.input_tokens ?? 0, completion_tokens: chunk.usage.completion_tokens ?? chunk.usage.output_tokens ?? 0, cached_tokens: @@ -804,6 +831,7 @@ export function extractUsage(chunk: UsagePayloadLike | null | undefined) { // xAI's exact provider-reported cost (port of decolua/9router#2453, capability A). cost_in_usd_ticks: chunk.usage.cost_in_usd_ticks, }); + return carryEstimatedUsageMarker(chunk.usage, normalized); } // Gemini format (Antigravity) diff --git a/package-lock.json b/package-lock.json index 280d229a98..b5d62cb4e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15484,9 +15484,9 @@ } }, "node_modules/adm-zip": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz", - "integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.1.tgz", + "integrity": "sha512-Xwrja8nx9e5o2N1my4DsKCeKpdrnACyr1wtbPxBDgGzKzKyE9kRtBFA8mWldI+RVlD7CBZNWY/wQ2+ydwOR6kQ==", "license": "MIT", "optional": true, "engines": { diff --git a/package.json b/package.json index a91eaade9e..a42bf3a0cf 100644 --- a/package.json +++ b/package.json @@ -201,6 +201,7 @@ "check:agent-skills-sync": "node --import tsx/esm scripts/skills/generate-agent-skills.mjs", "check:build-scope": "node scripts/check/check-build-scope.mjs", "check:error-helper": "node scripts/check/check-error-helper.mjs", + "check:routing-error-guard": "node scripts/check/check-routing-error-guard.mjs", "check:migration-numbering": "node scripts/check/check-migration-numbering.mjs", "check:public-creds": "node scripts/check/check-public-creds.mjs", "check:db-rules": "node scripts/check/check-db-rules.mjs", @@ -503,7 +504,7 @@ "concurrently": { "shell-quote": "^1.9.0" }, - "adm-zip": "^0.6.0", + "adm-zip": "^0.6.1", "promptfoo": { "js-yaml": "^5.2.2", "undici": "^7.29.0" diff --git a/scripts/check/allowlist-routing-swallowed-catch.json b/scripts/check/allowlist-routing-swallowed-catch.json new file mode 100644 index 0000000000..5aa8719efe --- /dev/null +++ b/scripts/check/allowlist-routing-swallowed-catch.json @@ -0,0 +1,372 @@ +{ + "$schema": "allowlist-routing-swallowed-catch", + "_comment": "Frozen swallowed catches on routing paths for scripts/check/check-routing-error-guard.mjs. Keyed by file + normalized catch-body snippet (not line numbers); count = identical bodies in that file. Do NOT add entries without a justification; shrink or remove an entry when its catch is fixed.", + "entries": [ + { + "file": "open-sse/services/combo.ts", + "snippet": "// keep empty stats — auto-combo will use runtime + bootstrap signals", + "count": 1, + "reason": "stats fallback to defaults, auto path uses runtime signals" + }, + { + "file": "open-sse/services/combo.ts", + "snippet": "connectionPoolCounts.set(provider, 0); connectionsByProvider.set(provider, []);", + "count": 1, + "reason": "pool counts fallback to empty lists" + }, + { + "file": "open-sse/services/combo.ts", + "snippet": "// keep default cost", + "count": 1, + "reason": "cost fallback to default pricing" + }, + { + "file": "open-sse/services/combo.ts", + "snippet": "log?.debug?.( \"COMBO\", `resolveTargetTimeoutMsForTarget connection lookup failed: ${ err instanceof Error ? err.message ", + "count": 1, + "reason": "logged at debug, undefined fallback" + }, + { + "file": "open-sse/services/combo/applyStrategyOrdering.ts", + "snippet": "log.warn(\"COMBO\", \"Failed to retrieve Last Known Good Provider. This is non-fatal.\", { err });", + "count": 1, + "reason": "logged, best-effort provider read fallback" + }, + { + "file": "open-sse/services/combo/applyStrategyOrdering.ts", + "snippet": "log.warn({ err }, \"manifest routing failed, falling back to standard strategy\");", + "count": 1, + "reason": "logged, manifest routing fallback" + }, + { + "file": "open-sse/services/combo/autoStrategy.ts", + "snippet": "log.warn?.( \"COMBO\", `Tag routing failed to load connections for provider=${providerId}: ${error instanceof Error ? erro", + "count": 1, + "reason": "logged, tag routing connections fallback" + }, + { + "file": "open-sse/services/combo/autoStrategy.ts", + "snippet": "// Best-effort candidate expansion only: if loading active connections or // provider models fails, fall back to the exp", + "count": 1, + "reason": "expanded targets fallback, abort-safe" + }, + { + "file": "open-sse/services/combo/autoStrategy.ts", + "snippet": "return null;", + "count": 1, + "reason": "null fallback, best-effort expansion" + }, + { + "file": "open-sse/services/combo/comboPredicates.ts", + "snippet": "// A DB read failure must never block dispatch — fall through to the upstream call. return null;", + "count": 1, + "reason": "null fallback, DB read failure" + }, + { + "file": "open-sse/services/combo/concurrencyCaps.ts", + "snippet": "return null; // fail-open: never block routing on a lookup error", + "count": 1, + "reason": "null fallback, fail-open routing" + }, + { + "file": "open-sse/services/combo/connectionAwareExpansion.ts", + "snippet": "// Fail-open (spec section 3.1): expansion is a best-effort pre-filter, never a // hard dependency. Auth-layer gates rem", + "count": 1, + "reason": "logged, fail-open expansion" + }, + { + "file": "open-sse/services/combo/dispatchPrelude.ts", + "snippet": "return false;", + "count": 1, + "reason": "false fallback, pinned dispatch check" + }, + { + "file": "open-sse/services/combo/dispatchPrelude.ts", + "snippet": "pinnedClone = pinnedResult;", + "count": 1, + "reason": "pinned clone fallback, release on failure" + }, + { + "file": "open-sse/services/combo/dispatchPrelude.ts", + "snippet": "log.warn( \"COMBO\", `Pinned model ${pinnedModel} threw error: ${pinErr instanceof Error ? pinErr.message : String(pinErr)", + "count": 1, + "reason": "logged, pinned model fallthrough" + }, + { + "file": "open-sse/services/combo/executeTargetAttempt.ts", + "snippet": "qualityClone = result;", + "count": 1, + "reason": "clone fallback to original response" + }, + { + "file": "open-sse/services/combo/executeTargetAttempt.ts", + "snippet": "deps.log.warn( \"COMBO\", \"Failed to record Last Known Good Provider. This is non-fatal.\", { err, } );", + "count": 1, + "reason": "logged at warn, success-path best-effort LKGP persist (failure only loses a routing optimization)" + }, + { + "file": "open-sse/services/combo/executeTargetAttempt.ts", + "snippet": "/* Clone parse failed */", + "count": 1, + "reason": "nested clone-parse fallback, error text preserved" + }, + { + "file": "open-sse/services/combo/executeTargetAttempt.ts", + "snippet": "/* Clone failed */", + "count": 1, + "reason": "clone fallback, error parse skipped" + }, + { + "file": "open-sse/services/combo/executeTargetAttempt.ts", + "snippet": "errorText = String(errorText);", + "count": 1, + "reason": "stringify fallback to String()" + }, + { + "file": "open-sse/services/combo/failureTracker.ts", + "snippet": "// Best effort — the counter still records the streak, future clears will // retry on the next threshold-cross.", + "count": 1, + "reason": "counter kept, retry on next threshold" + }, + { + "file": "open-sse/services/combo/failureTracker.ts", + "snippet": "return { count: 0, pinClearedNow: false };", + "count": 1, + "reason": "zeroed streak fallback" + }, + { + "file": "open-sse/services/combo/failureTracker.ts", + "snippet": "/* fail-open */", + "count": 1, + "reason": "fail-open tracker state fallback" + }, + { + "file": "open-sse/services/combo/failureTracker.ts", + "snippet": "return 0;", + "count": 1, + "reason": "zero fallback, fail-open counter" + }, + { + "file": "open-sse/services/combo/nativeCodexTurnPin.ts", + "snippet": "return undefined;", + "count": 1, + "reason": "undefined fallback, best-effort pin" + }, + { + "file": "open-sse/services/combo/promptCacheAffinity.ts", + "snippet": "return \"\";", + "count": 1, + "reason": "empty-string fallback" + }, + { + "file": "open-sse/services/combo/promptCacheAffinity.ts", + "snippet": "connectionsByProvider.set(provider, []);", + "count": 1, + "reason": "connections fallback to empty list" + }, + { + "file": "open-sse/services/combo/providerWildcard.ts", + "snippet": "return modelIds;", + "count": 1, + "reason": "model list fallback" + }, + { + "file": "open-sse/services/combo/quotaExhaustion.ts", + "snippet": "try { text = await response.clone().text(); } catch { // The status and trusted in-process classification remain availab", + "count": 1, + "reason": "status preserved, cloned text fallback" + }, + { + "file": "open-sse/services/combo/quotaExhaustionCutoff.ts", + "snippet": "connection = undefined;", + "count": 1, + "reason": "undefined connection fallback" + }, + { + "file": "open-sse/services/combo/quotaExhaustionCutoff.ts", + "snippet": "// Fail-open: never block routing because the preflight fetch itself errored. return { blocked: false };", + "count": 1, + "reason": "fail-open, blocked false" + }, + { + "file": "open-sse/services/combo/quotaShareConcurrency.ts", + "snippet": "// Fail-open: a saturated queue / timeout must never worsen availability — // proceed without a slot rather than reject ", + "count": 1, + "reason": "fail-open, proceed without a slot" + }, + { + "file": "open-sse/services/combo/quotaStrategies.ts", + "snippet": "log.warn?.(\"COMBO\", \"Reset-aware failed to load quota-aware connections.\", { comboName, err: error, operation: \"getProvi", + "count": 1, + "reason": "logged, quota-aware connections fallback" + }, + { + "file": "open-sse/services/combo/quotaStrategies.ts", + "snippet": "log.warn?.(\"COMBO\", \"Reset-aware quota fetch failed.\", { comboName, connectionId, err: error, operation: \"quotaFetch\", p", + "count": 1, + "reason": "logged, reset-aware quota fetch fallback" + }, + { + "file": "open-sse/services/combo/quotaStrategies.ts", + "snippet": "log.warn?.( { err: (err as Error)?.message, comboName }, \"headroom ordering failed — keeping target order\" ); return tar", + "count": 1, + "reason": "logged, headroom ordering kept" + }, + { + "file": "open-sse/services/combo/resolveAutoStrategy.ts", + "snippet": "log.warn(\"COMBO\", \"Failed to retrieve Last Known Good Provider. This is non-fatal.\", { err });", + "count": 1, + "reason": "logged, provider read best-effort" + }, + { + "file": "open-sse/services/combo/resolveAutoStrategy.ts", + "snippet": "log.warn( \"COMBO\", `Auto strategy '${routingStrategy}' failed (${err?.message || \"unknown\"}), falling back to rules` );", + "count": 1, + "reason": "logged, auto strategy rules fallback" + }, + { + "file": "open-sse/services/combo/roundRobinCombo.ts", + "snippet": "return undefined;", + "count": 1, + "reason": "undefined fallback, quota path unaffected" + }, + { + "file": "open-sse/services/combo/roundRobinCombo.ts", + "snippet": "// best-effort only", + "count": 1, + "reason": "best-effort quota reserve only" + }, + { + "file": "open-sse/services/combo/roundRobinCombo.ts", + "snippet": "rrClone = result;", + "count": 1, + "reason": "clone fallback to original" + }, + { + "file": "open-sse/services/combo/roundRobinCombo.ts", + "snippet": "log.warn( \"COMBO-RR\", \"Failed to record Last Known Good Provider. This is non-fatal.\", { err, } );", + "count": 1, + "reason": "logged at warn, success-path best-effort LKGP persist (failure only loses a routing optimization)" + }, + { + "file": "open-sse/services/combo/roundRobinCombo.ts", + "snippet": "/* Clone parse failed */", + "count": 1, + "reason": "clone-parse fallback, error text preserved" + }, + { + "file": "open-sse/services/combo/roundRobinCombo.ts", + "snippet": "/* Clone failed */", + "count": 1, + "reason": "clone fallback, error parse skipped" + }, + { + "file": "open-sse/services/combo/roundRobinCombo.ts", + "snippet": "errorText = String(errorText);", + "count": 1, + "reason": "stringify fallback to String()" + }, + { + "file": "open-sse/services/combo/roundRobinCombo.ts", + "snippet": "// G4: unexpected exception in the round-robin loop must never crash the // request silently — surface a 500 instead of ", + "count": 1, + "reason": "logged at error, 500 response surfaced" + }, + { + "file": "open-sse/services/combo/runtimeUnits.ts", + "snippet": "unitClone = response;", + "count": 1, + "reason": "clone fallback to original response" + }, + { + "file": "open-sse/services/combo/sessionStickiness.ts", + "snippet": "return undefined;", + "count": 2, + "reason": "undefined fallback, cooldown read" + }, + { + "file": "open-sse/services/combo/sessionStickiness.ts", + "snippet": "return false;", + "count": 1, + "reason": "false fallback, sticky write best-effort" + }, + { + "file": "open-sse/services/combo/sessionStickiness.ts", + "snippet": "// Completely unexpected error — fail-open return noOp;", + "count": 1, + "reason": "no-op fallback, fail-open stickiness" + }, + { + "file": "open-sse/services/combo/shadowRouting.ts", + "snippet": "// Shadow draining is best-effort and must never affect the production response.", + "count": 1, + "reason": "best-effort shadow drain only" + }, + { + "file": "open-sse/services/combo/shadowRouting.ts", + "snippet": "log.warn(\"COMBO\", \"Shadow routing skipped: failed to clone request body\", { error: error instanceof Error ? error.messag", + "count": 1, + "reason": "logged, shadow body clone skipped" + }, + { + "file": "open-sse/services/combo/shadowRouting.ts", + "snippet": "recordComboShadowRequest(combo.name, target.modelStr, { success: false, latencyMs: Date.now() - startedAt, target: toRec", + "count": 1, + "reason": "combo shadow request recorded as failed" + }, + { + "file": "open-sse/services/combo/targetResolution.ts", + "snippet": "logPipelineFallthrough(pipelineErr, log); return null;", + "count": 1, + "reason": "logged, pipeline fallthrough to null" + }, + { + "file": "open-sse/services/combo/targetSorters.ts", + "snippet": "return { modelStr, cost: Infinity };", + "count": 1, + "reason": "infinite-cost fallback" + }, + { + "file": "open-sse/services/combo/targetSorters.ts", + "snippet": "// If pricing lookup fails entirely, return original order return models;", + "count": 1, + "reason": "original order fallback" + }, + { + "file": "open-sse/services/combo/targetTimeoutRunner.ts", + "snippet": "// Diagnostic logging failed — never let this break the process.", + "count": 1, + "reason": "diagnostic logging failed" + }, + { + "file": "open-sse/services/combo/validateQuality.ts", + "snippet": "return null;", + "count": 1, + "reason": "null fallback, quality check skipped" + }, + { + "file": "open-sse/services/combo/validateQuality.ts", + "snippet": "controller.close();", + "count": 1, + "reason": "controller closed, stream cleanup" + }, + { + "file": "open-sse/services/combo/validateQuality.ts", + "snippet": "// If reading the stream fails due to a locked stream or pipe error, // the content cannot be verified — mark as invalid", + "count": 1, + "reason": "invalid fallback, unverifiable stream" + }, + { + "file": "open-sse/services/combo/validateQuality.ts", + "snippet": "return { valid: true };", + "count": 2, + "reason": "valid fallback, teardown race" + }, + { + "file": "open-sse/services/combo/validateQuality.ts", + "snippet": "// An SSE stream body is expected for streamed upstreams. Besides `data:` and // `event:` frames, the SSE spec also allo", + "count": 1, + "reason": "comment-line SSE frame skipped" + } + ] +} diff --git a/scripts/check/allowlist-void-async.json b/scripts/check/allowlist-void-async.json new file mode 100644 index 0000000000..ded2cd9f4b --- /dev/null +++ b/scripts/check/allowlist-void-async.json @@ -0,0 +1,15 @@ +{ + "$schema": "allowlist-void-async", + "entries": [ + { + "file": "open-sse/services/combo/executeTargetAttempt.ts", + "anchor": "Failed to record Last Known Good Provider", + "reason": "success-path best-effort persist; failure only loses an optimization and is logged" + }, + { + "file": "open-sse/services/combo/roundRobinCombo.ts", + "anchor": "Failed to record Last Known Good Provider", + "reason": "same as above, round-robin success path" + } + ] +} diff --git a/scripts/check/check-route-guard-membership.ts b/scripts/check/check-route-guard-membership.ts index 14e5968278..3122fd8ecd 100644 --- a/scripts/check/check-route-guard-membership.ts +++ b/scripts/check/check-route-guard-membership.ts @@ -53,6 +53,26 @@ export const SPAWN_CAPABLE_ROUTE_ROOTS: ReadonlyArray = [ "src/app/api/cli-tools/forge-settings", // GET calls getCliRuntimeStatus() to detect the `forge` CLI install (Hard Rules #15 + #17, #7263) "src/app/api/cli-tools/jcode-settings", // GET calls getCliRuntimeStatus() to detect the `jcode` CLI install (Hard Rules #15 + #17, #7263) "src/app/api/cli-tools/qwen-settings", // GET calls getCliRuntimeStatus("qwen") and writes local ~/.qwen config files (Hard Rules #15 + #17) + // GHSA-35fw-cv32-2373: the 14 cli-tools routes that reach the same spawn as the siblings + // above via getCliRuntimeStatus() (13) or detectAllTools() -> execFile (detect). + "src/app/api/cli-tools/all-statuses", // GET calls getCliRuntimeStatus() per CLI_TOOL_IDS entry (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "src/app/api/cli-tools/claude-settings", // GET calls getCliRuntimeStatus() to detect the `claude` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "src/app/api/cli-tools/cline-settings", // GET calls getCliRuntimeStatus() to detect the `cline` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "src/app/api/cli-tools/codewhale-settings", // GET calls getCliRuntimeStatus() to detect the `codewhale` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "src/app/api/cli-tools/codex-settings", // GET calls getCliRuntimeStatus() to detect the `codex` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "src/app/api/cli-tools/crush-settings", // GET calls getCliRuntimeStatus() to detect the `crush` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "src/app/api/cli-tools/deepseek-tui-settings", // GET calls getCliRuntimeStatus() to detect the `deepseek-tui` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "src/app/api/cli-tools/detect", // GET calls detectAllTools() -> execFile(binary, --version) + execFile("which") per tool via src/lib/cli-helper/tool-detector.ts (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "src/app/api/cli-tools/droid-settings", // GET calls getCliRuntimeStatus() to detect the `droid` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "src/app/api/cli-tools/kilo-settings", // GET calls getCliRuntimeStatus() to detect the `kilo` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "src/app/api/cli-tools/openclaw-settings", // GET calls getCliRuntimeStatus() to detect the `openclaw` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "src/app/api/cli-tools/pi-settings", // GET calls getCliRuntimeStatus() to detect the `pi` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "src/app/api/cli-tools/smelt-settings", // GET calls getCliRuntimeStatus() to detect the `smelt` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "src/app/api/cli-tools/status", // GET calls getCliRuntimeStatus() per CLI_TOOL_IDS entry (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + // GHSA-jx89-f37j-pq89: skills install + execute reach childProcess.spawn transitively + // (executor.ts -> builtins.ts -> sandbox.ts) — invisible to the source-scan subcheck. + "src/app/api/skills/install", // POST stores handlerCode verbatim; a built-in name aliases execute_command / eval_code (Hard Rules #15 + #17, GHSA-jx89-f37j-pq89) + "src/app/api/skills/executions", // POST runs skillExecutor.execute() -> sandbox container spawn (Hard Rules #15 + #17, GHSA-jx89-f37j-pq89) ]; // Frozen pre-existing exceptions: spawn-capable routes NOT yet classified diff --git a/scripts/check/check-routing-error-guard.mjs b/scripts/check/check-routing-error-guard.mjs new file mode 100644 index 0000000000..b8769231e3 --- /dev/null +++ b/scripts/check/check-routing-error-guard.mjs @@ -0,0 +1,274 @@ +#!/usr/bin/env node +// scripts/check/check-routing-error-guard.mjs +// Gate: swallowed `catch` blocks and fire-and-forget `void (async ...)` on routing +// paths (open-sse/services/combo.ts + open-sse/services/combo/). +// +// Run with `npm run check:routing-error-guard`. It is NOT wired into CI; run it when +// touching routing error handling. +// +// Rule A (swallowed-catch): a `catch` block with no `throw` and no inline +// `// no-effect: ` marker is a violation unless frozen in +// scripts/check/allowlist-routing-swallowed-catch.json. Entries are keyed by file + +// the normalized catch-body snippet (never by line number, so unrelated edits that +// shift lines do not break the gate) with a `count` for identical bodies in one file. +// More live catches than the frozen count → violation; fewer → stale entry (anti-rot: +// lower the count or remove the entry). Chained `.catch(...)` promise handlers are +// ignored by construction. +// +// Rule B (void-async): `void (async` is a violation unless an entry in +// scripts/check/allowlist-void-async.json names the file and an `anchor` substring +// found within the next VOID_ASYNC_ANCHOR_WINDOW lines of that site; a `reason` is +// mandatory and entries matching no site are stale. +// +// Output mirrors scripts/check/check-error-helper.mjs: `file:line :: rule :: hint`. +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const cwd = process.cwd(); + +const SCOPE_FILES = [path.join(cwd, "open-sse/services/combo.ts")]; +const SCOPE_DIRS = [path.join(cwd, "open-sse/services/combo")]; +const VOID_ASYNC_ALLOWLIST_PATH = path.join(cwd, "scripts/check/allowlist-void-async.json"); +const SWALLOWED_CATCH_ALLOWLIST_PATH = path.join( + cwd, + "scripts/check/allowlist-routing-swallowed-catch.json" +); + +const NO_EFFECT_MARKER = /\/\/\s*no-effect\s*:/; +const THROW_PATTERN = /\bthrow\b/; +const VOID_ASYNC_PATTERN = /\bvoid\s*\(\s*async\b/; +export const SNIPPET_MAX_LENGTH = 120; +export const VOID_ASYNC_ANCHOR_WINDOW = 25; + +function stripStringsAndComments(source) { + // Length-preserving mask: every string/comment char becomes a space (newlines + // kept) so offsets and line numbers survive. Keyword scans use the masked copy; + // marker reads and snippets use the raw slice at the same offsets. + const chars = source.split(""); + const blank = (from, to) => { + for (let i = from; i < to; i++) if (chars[i] !== "\n") chars[i] = " "; + }; + let i = 0; + while (i < chars.length) { + const c = chars[i]; + const next = chars[i + 1]; + if (c === "/" && next === "/") { + let j = i; + while (j < chars.length && chars[j] !== "\n") j++; + blank(i, j); + i = j; + } else if (c === "/" && next === "*") { + const end = source.indexOf("*/", i + 2); + const j = end === -1 ? chars.length : end + 2; + blank(i, j); + i = j; + } else if (c === '"' || c === "'" || c === "`") { + let j = i + 1; + while (j < chars.length && (chars[j] !== c || chars[j - 1] === "\\") && chars[j] !== "\n") + j++; + blank(i, Math.min(j + 1, chars.length)); + i = Math.min(j + 1, chars.length); + } else { + i++; + } + } + return chars.join(""); +} + +function skipBalanced(masked, i, open, close) { + let depth = 0; + while (i < masked.length) { + if (masked[i] === open) depth++; + else if (masked[i] === close) { + depth--; + if (depth === 0) return i; + } + i++; + } + return -1; +} + +function findCatchBlocks(source) { + const masked = stripStringsAndComments(source); + const blocks = []; + const catchKeyword = /\bcatch\b/g; + let match; + while ((match = catchKeyword.exec(masked)) !== null) { + if (match.index > 0 && masked[match.index - 1] === ".") continue; + let i = match.index + 5; + while (i < masked.length && /\s/.test(masked[i])) i++; + if (masked[i] === "(") { + const closeParen = skipBalanced(masked, i, "(", ")"); + if (closeParen === -1) continue; + i = closeParen + 1; + } + while (i < masked.length && /\s/.test(masked[i])) i++; + if (masked[i] !== "{") continue; + const end = skipBalanced(masked, i, "{", "}"); + if (end === -1) continue; + blocks.push({ + line: source.slice(0, match.index).split("\n").length, + body: source.slice(i + 1, end), + maskedBody: masked.slice(i + 1, end), + }); + catchKeyword.lastIndex = end + 1; + } + return blocks; +} + +/** Line-independent identity of a catch body: whitespace-collapsed raw text, truncated. */ +export function catchSnippet(body) { + return body.replace(/\s+/g, " ").trim().slice(0, SNIPPET_MAX_LENGTH); +} + +/** Every catch that neither rethrows nor carries a `// no-effect:` marker. */ +export function collectSwallowedCatches(files) { + const swallowed = []; + for (const { path: rel, source } of files) { + for (const block of findCatchBlocks(source)) { + if (THROW_PATTERN.test(block.maskedBody)) continue; + if (NO_EFFECT_MARKER.test(block.body)) continue; + swallowed.push({ file: rel, line: block.line, snippet: catchSnippet(block.body) }); + } + } + return swallowed; +} + +const entryKey = (file, snippet) => `${file} :: ${snippet}`; + +/** + * Compare live swallowed catches against the frozen allowlist. + * @returns {{ violations: string[], stale: string[] }} + */ +export function evaluateSwallowedCatches(files, frozenEntries = []) { + const allowed = new Map(); + for (const entry of frozenEntries) { + allowed.set(entryKey(entry.file, entry.snippet), entry); + } + const live = new Map(); + for (const hit of collectSwallowedCatches(files)) { + const key = entryKey(hit.file, hit.snippet); + if (!live.has(key)) live.set(key, []); + live.get(key).push(hit); + } + + const violations = []; + for (const [key, hits] of live) { + const entry = allowed.get(key); + const frozenCount = entry ? Number(entry.count ?? 1) : 0; + if (entry && !String(entry.reason ?? "").trim()) { + violations.push(`${hits[0].file}:${hits[0].line} :: swallowed-catch :: entry needs a reason`); + } + for (const hit of hits.slice(frozenCount)) { + violations.push( + `${hit.file}:${hit.line} :: swallowed-catch :: add 'throw' or '// no-effect: '` + + (hit.snippet ? ` (body: ${hit.snippet})` : " (empty body)") + ); + } + } + + const stale = []; + for (const [key, entry] of allowed) { + const liveCount = live.get(key)?.length ?? 0; + const frozenCount = Number(entry.count ?? 1); + if (liveCount < frozenCount) { + stale.push(`${key} (frozen ${frozenCount}, live ${liveCount})`); + } + } + return { violations, stale }; +} + +/** + * Rule B. An allowlist entry covers a `void (async` site only when its anchor appears + * within VOID_ASYNC_ANCHOR_WINDOW lines of that site in the same file. + * @returns {{ violations: string[], stale: string[] }} + */ +export function evaluateVoidAsyncSites(files, allowlist = []) { + const violations = []; + const used = new Set(); + for (const { path: rel, source } of files) { + const lines = source.split("\n"); + for (let i = 0; i < lines.length; i++) { + if (!VOID_ASYNC_PATTERN.test(lines[i])) continue; + const window = lines.slice(i, i + VOID_ASYNC_ANCHOR_WINDOW).join("\n"); + const entry = allowlist.find( + (candidate) => candidate.file === rel && window.includes(candidate.anchor) + ); + if (!entry) { + violations.push( + `${rel}:${i + 1} :: void-async :: await the async work, attach a .catch, or add an allowlist entry` + ); + continue; + } + used.add(entry); + if (!String(entry.reason ?? "").trim()) { + violations.push(`${rel}:${i + 1} :: void-async :: allowlist entry needs a reason`); + } + } + } + const stale = allowlist + .filter((entry) => !used.has(entry)) + .map((entry) => `${entry.file} :: ${entry.anchor}`); + return { violations, stale }; +} + +function loadEntries(allowlistPath) { + const raw = JSON.parse(fs.readFileSync(allowlistPath, "utf8")); + return raw.entries ?? raw; +} + +function collectFiles() { + const files = []; + const push = (p) => { + files.push({ + path: path.relative(cwd, p).replace(/\\/g, "/"), + source: fs.readFileSync(p, "utf8"), + }); + }; + for (const file of SCOPE_FILES) { + if (fs.existsSync(file)) push(file); + } + const walk = (dir) => { + if (!fs.existsSync(dir)) return; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, entry.name); + if (entry.isDirectory()) walk(p); + else if (/\.tsx?$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name)) push(p); + } + }; + for (const dir of SCOPE_DIRS) walk(dir); + return files; +} + +function main() { + const files = collectFiles(); + const catchEntries = loadEntries(SWALLOWED_CATCH_ALLOWLIST_PATH); + const voidEntries = loadEntries(VOID_ASYNC_ALLOWLIST_PATH); + const catches = evaluateSwallowedCatches(files, catchEntries); + const voids = evaluateVoidAsyncSites(files, voidEntries); + + const violations = [...catches.violations, ...voids.violations]; + const stale = [...catches.stale, ...voids.stale]; + if (violations.length) { + console.error( + `[check-routing-error-guard] ${violations.length} violation(s) on routing paths:\n` + + violations.map((v) => ` ✗ ${v}`).join("\n") + ); + } + if (stale.length) { + console.error( + `[check-routing-error-guard] ${stale.length} stale allowlist entr(y/ies) — the site was fixed or changed; shrink or remove the entry:\n` + + stale.map((s) => ` ✗ ${s}`).join("\n") + ); + } + if (violations.length || stale.length) { + process.exitCode = 1; + return; + } + console.log( + `[check-routing-error-guard] OK (${files.length} files scanned, ${catchEntries.length} frozen catch entries, ${voidEntries.length} void-async entries)` + ); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/scripts/dev/run-protocol-clients-tests.mjs b/scripts/dev/run-protocol-clients-tests.mjs index c32320c2f1..5300fd1ac3 100644 --- a/scripts/dev/run-protocol-clients-tests.mjs +++ b/scripts/dev/run-protocol-clients-tests.mjs @@ -57,11 +57,11 @@ async function main() { OMNIROUTE_BASE_URL: baseUrl, }), OMNIROUTE_E2E_BOOTSTRAP_MODE: process.env.OMNIROUTE_E2E_BOOTSTRAP_MODE || "open", - // Pin the custom server's bind address to loopback (#11535): under the - // programmatic next() entry the middleware's nextUrl.hostname mirrors the - // configured HOST (default "0.0.0.0"), and apiAuth.isLoopbackRequest() reads - // nextUrl.hostname FIRST — an unpinned boot makes every request look remote, - // so the anonymous open-bootstrap allow never fires (401 green-shallow). + // Pin the custom server's bind address to loopback (#11535). The bootstrap + // loopback verdict (apiAuth.isLoopbackRequest) comes from the peer stamp the + // custom server writes from the real TCP socket (GHSA-7pq4-8pvv-rx7r), never + // from nextUrl.hostname / Host — the pin keeps the harness's own clients on a + // loopback socket so that stamp resolves to 127.0.0.1. HOST: process.env.HOST || "127.0.0.1", }; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx index 45f0301c28..096dc23584 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx @@ -22,7 +22,8 @@ import { type CompatModelRow, } from "../providerPageHelpers"; import { ModelVisibilityToolbar } from "./ModelRow"; -import { sortModelsFreeFirst, isFreeModel } from "@/shared/utils/freeModels"; +import { sortModelsFreeFirst, isModelFreeBadge } from "@/shared/utils/freeModels"; +import { useStrictFreeBadge } from "./useStrictFreeBadge"; import PassthroughModelRow, { type PassthroughModelRowProps } from "./PassthroughModelRow"; // --------------------------------------------------------------------------- @@ -127,6 +128,7 @@ export default function CompatibleModelsSection({ const [freeFilter, setFreeFilter] = useState<"all" | "free" | "paid">("all"); const [sortFreeFirst, setSortFreeFirst] = useState(false); const notify = useNotificationStore(); + const strictFreeBadge = useStrictFreeBadge(); const customModelMap = useMemo(() => buildCompatMap(customModels), [customModels]); const providerAliases = useMemo( @@ -164,11 +166,16 @@ export default function CompatibleModelsSection({ alias: aliasByModelId.get(model.id) || null, displayName: model.name || model.id, source, - isFree: - Boolean((model as any).free) || - model.id.endsWith(":free") || - /\bgr[aá]tis\b|\bfree\b/i.test(model.name || "") || - isFreeModel(providerStorageAlias, { id: model.id, isFree: (model as any).isFree }), + isFree: isModelFreeBadge( + providerStorageAlias, + { + id: model.id, + name: model.name, + free: (model as { free?: unknown }).free, + isFree: model.isFree, + }, + { strict: strictFreeBadge } + ), isHidden: isModelHidden(model.id), }); seenModelIds.add(model.id); @@ -201,11 +208,16 @@ export default function CompatibleModelsSection({ alias: displayAlias, displayName: displayAlias, source: customModel ? customModel.source || "custom" : "alias", - isFree: - modelId.endsWith(":free") || - Boolean((customModel as any)?.free) || - /\bgr[aá]tis\b|\bfree\b/i.test(customModel?.name || alias || "") || - isFreeModel(providerStorageAlias, { id: modelId, isFree: (customModel as any)?.isFree }), + isFree: isModelFreeBadge( + providerStorageAlias, + { + id: modelId, + name: customModel?.name || (alias as string) || "", + free: (customModel as { free?: unknown } | undefined)?.free, + isFree: customModel?.isFree, + }, + { strict: strictFreeBadge } + ), isHidden: isModelHidden(modelId), }); seenModelIds.add(modelId); @@ -220,6 +232,7 @@ export default function CompatibleModelsSection({ isModelHidden, providerAliases, providerStorageAlias, + strictFreeBadge, ]); const filteredModels = allModels.filter((model) => { diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx index c8d181577d..94ca769c7b 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx @@ -32,7 +32,8 @@ import { type CompatByProtocolMap, } from "../providerPageHelpers"; import { ModelVisibilityToolbar } from "./ModelRow"; -import { sortModelsFreeFirst, isFreeModel } from "@/shared/utils/freeModels"; +import { sortModelsFreeFirst, isModelFreeBadge } from "@/shared/utils/freeModels"; +import { useStrictFreeBadge } from "./useStrictFreeBadge"; import PassthroughModelRow from "./PassthroughModelRow"; // --------------------------------------------------------------------------- @@ -138,6 +139,7 @@ export default function PassthroughModelsSection({ const [freeFilter, setFreeFilter] = useState<"all" | "free" | "paid">("all"); const [sortFreeFirst, setSortFreeFirst] = useState(false); const notify = useNotificationStore(); + const strictFreeBadge = useStrictFreeBadge(); const customModelMap = useMemo(() => buildCompatMap(customModels), [customModels]); const handleTestAll = async () => { @@ -254,11 +256,16 @@ export default function PassthroughModelsSection({ alias: aliasByModelId.get(model.id) || defaultAlias, displayName: model.name || model.id, source, - isFree: - Boolean((model as any).free) || - model.id.endsWith(":free") || - /\bgr[aá]tis\b|\bfree\b/i.test(model.name || "") || - isFreeModel(providerId, { id: model.id, isFree: (model as any).isFree }), + isFree: isModelFreeBadge( + providerId, + { + id: model.id, + name: model.name, + free: (model as { free?: unknown }).free, + isFree: model.isFree, + }, + { strict: strictFreeBadge } + ), isHidden: isModelHidden(model.id), }); seenModelIds.add(model.id); @@ -292,11 +299,16 @@ export default function PassthroughModelsSection({ alias: displayAlias, displayName: displayAlias, source: customModel ? customModel.source || "custom" : "alias", - isFree: - modelId.endsWith(":free") || - Boolean((customModel as any)?.free) || - /\bgr[aá]tis\b|\bfree\b/i.test(customModel?.name || alias || "") || - isFreeModel(providerId, { id: modelId, isFree: (customModel as any)?.isFree }), + isFree: isModelFreeBadge( + providerId, + { + id: modelId, + name: customModel?.name || (alias as string) || "", + free: (customModel as { free?: unknown } | undefined)?.free, + isFree: customModel?.isFree, + }, + { strict: strictFreeBadge } + ), isHidden: isModelHidden(modelId), }); seenModelIds.add(modelId); @@ -312,6 +324,7 @@ export default function PassthroughModelsSection({ providerAlias, providerAliases, providerId, + strictFreeBadge, ]); const filteredModels = allModels.filter((model) => { diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/useStrictFreeBadge.ts b/src/app/(dashboard)/dashboard/providers/[id]/components/useStrictFreeBadge.ts new file mode 100644 index 0000000000..f415188c8a --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/useStrictFreeBadge.ts @@ -0,0 +1,38 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { FREE_BADGE_STRICT_FLAG } from "@/shared/utils/freeModels"; + +type FlagEntry = { key?: unknown; effectiveValue?: unknown }; + +/** + * Reads the FREE_BADGE_REQUIRES_PROVIDER_FREE_TIER feature flag for the provider-page + * model lists. Fails closed: until the flag is loaded, and on any error, it returns + * false — the historical badge rule. + */ +export function useStrictFreeBadge(): boolean { + const [strict, setStrict] = useState(false); + + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const res = await fetch("/api/settings/feature-flags"); + if (!res.ok) return; + const data = (await res.json()) as { flags?: FlagEntry[] }; + const entry = Array.isArray(data?.flags) + ? data.flags.find((flag) => flag?.key === FREE_BADGE_STRICT_FLAG) + : undefined; + const value = String(entry?.effectiveValue ?? "").toLowerCase(); + if (!cancelled) setStrict(value === "true" || value === "1" || value === "on"); + } catch { + // Keep the historical rule. + } + })(); + return () => { + cancelled = true; + }; + }, []); + + return strict; +} diff --git a/src/app/(dashboard)/dashboard/settings/components/PoolEgressObservation.tsx b/src/app/(dashboard)/dashboard/settings/components/PoolEgressObservation.tsx new file mode 100644 index 0000000000..73e39e9f43 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/PoolEgressObservation.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; + +type Observation = { + connections: number; + distinctExits: number; + maxConnectionsOnOneExit: number; + windowHours: number; +}; + +const OBSERVATION_KEYS = [ + "connections", + "distinctExits", + "maxConnectionsOnOneExit", + "windowHours", +] as const; + +function isObservation(value: unknown): value is Observation { + if (!value || typeof value !== "object") return false; + const record = value as Record; + return OBSERVATION_KEYS.every((key) => typeof record[key] === "number"); +} + +/** + * One line under a pool's member list: how many observed egress IPs actually served its + * members over the window, and how many connections the busiest one carried. Renders + * nothing when the observation is off, failed, or the request itself errored. + */ +export function PoolEgressObservation({ query }: { query: string }) { + const t = useTranslations("proxyRegistry"); + const [loaded, setLoaded] = useState<{ query: string; observation: Observation | null }>({ + query: "", + observation: null, + }); + + useEffect(() => { + let cancelled = false; + fetch(`/api/settings/proxies/pool/egress-observation?${query}`) + .then((res) => (res.ok ? res.json() : null)) + .then((body: unknown) => { + if (!cancelled) setLoaded({ query, observation: isObservation(body) ? body : null }); + }) + .catch(() => { + if (!cancelled) setLoaded({ query, observation: null }); + }); + return () => { + cancelled = true; + }; + }, [query]); + + const observation = loaded.query === query ? loaded.observation : null; + if (!observation) return null; + + const text = + observation.connections === 0 + ? t("poolEgressObservationEmpty", { hours: observation.windowHours }) + : t("poolEgressObservation", { + exits: observation.distinctExits, + connections: observation.connections, + max: observation.maxConnectionsOnOneExit, + hours: observation.windowHours, + }); + + return ( +

+ {text} +

+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx b/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx index 3af5b91361..2978266e08 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx @@ -8,6 +8,7 @@ import { ProxyStatusBadge } from "./ProxyStatusBadge"; import { ProxyHealthCell } from "./ProxyHealthCell"; import { ProxyBatchActions } from "./ProxyBatchActions"; import { ProxyCheckboxCell } from "./ProxyCheckboxCell"; +import { PoolEgressObservation } from "./PoolEgressObservation"; import { parseBulkImportText, type ParsedProxyEntry, @@ -1238,6 +1239,7 @@ import { + {poolMembers.length === 0 ? (
{t("poolNoMembers")} diff --git a/src/app/api/providers/[id]/test/mistralAmbiguousAuth.ts b/src/app/api/providers/[id]/test/mistralAmbiguousAuth.ts index 8f47be63a5..9f997ea0a9 100644 --- a/src/app/api/providers/[id]/test/mistralAmbiguousAuth.ts +++ b/src/app/api/providers/[id]/test/mistralAmbiguousAuth.ts @@ -1,3 +1,5 @@ +import { isMistralAmbiguous401 } from "@omniroute/open-sse/services/accountFallback/mistralAmbiguousAuth.ts"; + /** * #7638: Mistral's quota-exhausted response is `401 {"detail":"Unauthorized"}` — byte-identical * to a genuinely revoked key. Unlike other providers, a bare Mistral 401 with no auth-specific @@ -23,16 +25,6 @@ export interface ClassifyFailureArgs { provider?: string; } -function isMistralAmbiguous401(provider: string | undefined, normalized: string): boolean { - if (provider !== "mistral") return false; - const hasAuthSignal = - normalized.includes("invalid api key") || - normalized.includes("token invalid") || - normalized.includes("revoked") || - normalized.includes("access denied"); - return !hasAuthSignal; -} - /** Decides the diagnosis for a 401/403 status: ambiguous (Mistral-only) or the generic auth error. */ export function classifyAmbiguousOrAuthError( provider: string | undefined, diff --git a/src/app/api/settings/proxies/pool/egress-observation/route.ts b/src/app/api/settings/proxies/pool/egress-observation/route.ts new file mode 100644 index 0000000000..25c523e30b --- /dev/null +++ b/src/app/api/settings/proxies/pool/egress-observation/route.ts @@ -0,0 +1,39 @@ +import { errorResponse } from "@omniroute/open-sse/utils/error"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { readPoolEgressObservation } from "@/lib/proxyPoolEgressObservation"; +import { proxyPoolEgressObservationQuerySchema } from "@/shared/validation/schemas"; +import { + formatValidationMessage, + isValidationFailure, + validateBody, +} from "@/shared/validation/helpers"; + +// Observed egress spread of a proxy pool, read from the proxy log (numbers only). Kept +// apart from GET /api/settings/proxies/pool on purpose: a failure here answers null and +// can never break the pool editor. Same management-auth tier as the pool route. +// +// GET ?scope=global|provider|account|combo|key&scopeId= +// -> { connections, distinctExits, maxConnectionsOnOneExit, windowHours } +// | null (read failed, or the PROXY_POOL_EGRESS_OBSERVATION feature flag is off) +// -> 400 on an unknown scope or a missing scopeId outside global + +export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { + const { searchParams } = new URL(request.url); + const validation = validateBody(proxyPoolEgressObservationQuerySchema, { + scope: searchParams.get("scope") ?? undefined, + scopeId: searchParams.get("scopeId"), + }); + if (isValidationFailure(validation)) { + return errorResponse(400, formatValidationMessage(validation.error)); + } + const { scope, scopeId } = validation.data; + return Response.json( + readPoolEgressObservation(scope, scope === "global" ? null : (scopeId ?? null)) + ); + } catch { + return errorResponse(500, "Failed to load pool egress observation"); + } +} diff --git a/src/app/api/settings/proxies/route.ts b/src/app/api/settings/proxies/route.ts index fe3153629d..35031d4aa4 100644 --- a/src/app/api/settings/proxies/route.ts +++ b/src/app/api/settings/proxies/route.ts @@ -1,3 +1,4 @@ +import { isSocks5ProxyEnabled } from "@omniroute/open-sse/utils/proxyDispatcher"; import { listProxies } from "@/lib/db/proxies"; import { handleProxyCreate, @@ -42,10 +43,8 @@ export async function GET(request: Request) { // #5890: coarse relay health pulse for the dashboard — how many relay // probes have run, and how many came back alive. relayProbeStats: getRelayProbeStats(), - // Default ON (opt-out): only an explicit falsey value disables SOCKS5. - socks5Enabled: !["false", "0", "no", "off"].includes( - (process.env.ENABLE_SOCKS5_PROXY ?? "").trim().toLowerCase() - ), + // SOCKS5 defaults ON — see isSocks5ProxyEnabled(). + socks5Enabled: isSocks5ProxyEnabled(), }); } catch (error) { return createErrorResponseFromUnknown(error, "Failed to load proxies"); diff --git a/src/app/api/settings/proxy/route.ts b/src/app/api/settings/proxy/route.ts index d05d0eb4c2..01d07e6331 100755 --- a/src/app/api/settings/proxy/route.ts +++ b/src/app/api/settings/proxy/route.ts @@ -6,7 +6,10 @@ import { resolveProxyForConnection, } from "@/lib/db/settings"; import { getProxyAssignments, getProxyById } from "@/lib/db/proxies"; -import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher"; +import { + clearDispatcherCache, + isSocks5ProxyEnabled, +} from "@omniroute/open-sse/utils/proxyDispatcher"; import { updateProxyConfigSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { @@ -29,21 +32,15 @@ const PROXY_LEVEL_TO_REGISTRY_SCOPE = { key: "account", } as const; -function isSocks5Enabled() { - // Default ON (opt-out): only an explicit falsey value disables SOCKS5. - const raw = (process.env.ENABLE_SOCKS5_PROXY ?? "").trim().toLowerCase(); - return !["false", "0", "no", "off"].includes(raw); -} - function getSupportedProxyTypes() { - if (isSocks5Enabled()) { + if (isSocks5ProxyEnabled()) { return new Set([...BASE_SUPPORTED_PROXY_TYPES, "socks5"]); } return BASE_SUPPORTED_PROXY_TYPES; } function supportedTypesMessage() { - return isSocks5Enabled() ? "http, https, or socks5" : "http or https"; + return isSocks5ProxyEnabled() ? "http, https, or socks5" : "http or https"; } function createInvalidProxyError(message: string): ApiRouteError { @@ -104,7 +101,7 @@ function normalizeAndValidateProxy( } const type = String(proxy.type || "http").toLowerCase() as NonNullable; - if (type === "socks5" && !isSocks5Enabled()) { + if (type === "socks5" && !isSocks5ProxyEnabled()) { throw createInvalidProxyError( "SOCKS5 proxy is disabled (remove ENABLE_SOCKS5_PROXY=false to enable — it is ON by default)" ); diff --git a/src/app/api/v1/_helpers/apiKeyScope.ts b/src/app/api/v1/_helpers/apiKeyScope.ts index 4d238a7818..21aba3fca9 100644 --- a/src/app/api/v1/_helpers/apiKeyScope.ts +++ b/src/app/api/v1/_helpers/apiKeyScope.ts @@ -1,6 +1,9 @@ +import { NextResponse } from "next/server"; import { getApiKeyMetadata } from "@/lib/db/apiKeys"; import { extractApiKey } from "@/sse/services/auth"; import { isDashboardSessionAuthenticated } from "@/shared/utils/apiAuth"; +import { CORS_HEADERS } from "@/shared/utils/cors"; +import { buildErrorBody } from "@omniroute/open-sse/utils/error"; export interface ApiKeyRequestScope { apiKey: string | null; @@ -26,3 +29,78 @@ export async function getApiKeyRequestScope(request: Request): Promise, + recordApiKeyId: string | null | undefined +): boolean { + if (scope.isSessionAuth) return true; + if (recordApiKeyId === null || recordApiKeyId === undefined) return false; + return recordApiKeyId === scope.apiKeyId; +} + +/** + * Owner scope of a CLIENT_API list/count read (`GET /v1/files`, `GET /v1/batches`). + * The intent is explicit on purpose, exactly like the `delete-completed` sweep: + * a caller is either scoped to the API key it presented, or it is the operator's + * dashboard session reading the whole instance, or it is rejected — there is no + * default that widens a read to every tenant (GHSA-m3hp-hq9g-fpmv). + */ +export type OwnedListScope = + | { mode: "api_key"; apiKeyId: string } + | { mode: "instance" } + | { mode: "rejected"; response: Response }; + +function unauthorized(message: string): Response { + return NextResponse.json(buildErrorBody(401, message), { status: 401, headers: CORS_HEADERS }); +} + +/** + * Resolve the {@link OwnedListScope} of a list/count request, failing closed: + * + * - a presented bearer that does not resolve to a key row (deleted, rotated, + * mistyped) → 401 "Invalid API key" — even when a session cookie is also + * present, so an unresolvable key never falls through to the session branch; + * - a resolved key → scoped to that key, even alongside a session cookie (the + * key wins, so a leaked or over-shared key can never widen a read); + * - a dashboard session WITHOUT a key → instance-wide (the operator's own + * dashboard is the one legitimate instance-wide reader); + * - anything else (anonymous under `REQUIRE_API_KEY=false`) → 401 + * "Authentication required". + * + * The list handlers used to coerce `apiKeyId || undefined`, and the DB layer + * reads `undefined` as "no owner filter" — so the anonymous caller landed in the + * same unfiltered bucket as the operator. + */ +export function resolveListScope(scope: ApiKeyRequestScope): OwnedListScope { + if (scope.apiKey && !scope.apiKeyId) { + return { mode: "rejected", response: unauthorized("Invalid API key") }; + } + if (scope.apiKeyId) { + return { mode: "api_key", apiKeyId: scope.apiKeyId }; + } + if (scope.isSessionAuth) { + return { mode: "instance" }; + } + return { mode: "rejected", response: unauthorized("Authentication required") }; +} diff --git a/src/app/api/v1/batches/[id]/cancel/route.ts b/src/app/api/v1/batches/[id]/cancel/route.ts index 3222f0f0d8..d441f9911c 100644 --- a/src/app/api/v1/batches/[id]/cancel/route.ts +++ b/src/app/api/v1/batches/[id]/cancel/route.ts @@ -1,7 +1,7 @@ import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; import { getBatch, updateBatch } from "@/lib/db/batches"; import { NextResponse } from "next/server"; -import { getApiKeyRequestScope } from "@/app/api/v1/_helpers/apiKeyScope"; +import { getApiKeyRequestScope, canAccessOwnedRecord } from "@/app/api/v1/_helpers/apiKeyScope"; import { formatBatchResponse } from "../../formatBatchResponse"; export async function OPTIONS() { @@ -11,12 +11,15 @@ export async function OPTIONS() { export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { const scope = await getApiKeyRequestScope(request); if (scope.rejection) return scope.rejection; - const apiKeyId = scope.apiKeyId; const { id } = await params; const batch = getBatch(id); - if (!batch || (batch.apiKeyId !== null && batch.apiKeyId !== apiKeyId)) { + // The shared 3-way rule: the operator's dashboard (session auth) may cancel + // ANY batch — the old inline check 404'd every dashboard cancel of a + // key-owned batch (#13683) — a key cancels its own, and a null-owner batch + // is denied to a foreign key and to an anonymous caller (GHSA-2jm2-mpx8-6523). + if (!batch || !canAccessOwnedRecord(scope, batch.apiKeyId)) { return NextResponse.json( { error: { message: "Batch not found", type: "invalid_request_error" } }, { status: 404, headers: CORS_HEADERS } diff --git a/src/app/api/v1/batches/[id]/route.ts b/src/app/api/v1/batches/[id]/route.ts index 7ce3867906..b9d841f8f4 100644 --- a/src/app/api/v1/batches/[id]/route.ts +++ b/src/app/api/v1/batches/[id]/route.ts @@ -1,22 +1,13 @@ import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; import { getBatch, deleteBatch } from "@/lib/db/batches"; import { NextResponse } from "next/server"; -import { getApiKeyRequestScope } from "@/app/api/v1/_helpers/apiKeyScope"; +import { getApiKeyRequestScope, canAccessOwnedRecord } from "@/app/api/v1/_helpers/apiKeyScope"; import { formatBatchResponse } from "../formatBatchResponse"; export async function OPTIONS() { return handleCorsOptions(); } -function scopeCheck( - scope: { isSessionAuth: boolean; apiKeyId: string | null }, - recordApiKeyId: string | null | undefined -): boolean { - if (scope.isSessionAuth) return true; - if (recordApiKeyId === null || recordApiKeyId === undefined) return true; - return recordApiKeyId === scope.apiKeyId; -} - export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { const scope = await getApiKeyRequestScope(request); if (scope.rejection) return scope.rejection; @@ -24,7 +15,10 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: const { id } = await params; const batch = getBatch(id); - if (!batch || !scopeCheck(scope, batch.apiKeyId)) { + // Session = operator, key = own rows only, null owner = denied + // (GHSA-2jm2-mpx8-6523): the previous local check let ANY caller read or + // delete an unowned batch by id. + if (!batch || !canAccessOwnedRecord(scope, batch.apiKeyId)) { return NextResponse.json( { error: { message: "Batch not found", type: "invalid_request_error" } }, { status: 404, headers: CORS_HEADERS } @@ -41,7 +35,7 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i const { id } = await params; const batch = getBatch(id); - if (!batch || !scopeCheck(scope, batch.apiKeyId)) { + if (!batch || !canAccessOwnedRecord(scope, batch.apiKeyId)) { return NextResponse.json( { error: { message: "Batch not found", type: "invalid_request_error" } }, { status: 404, headers: CORS_HEADERS } diff --git a/src/app/api/v1/batches/delete-completed/route.ts b/src/app/api/v1/batches/delete-completed/route.ts index fa60d98d61..5e095fc845 100644 --- a/src/app/api/v1/batches/delete-completed/route.ts +++ b/src/app/api/v1/batches/delete-completed/route.ts @@ -51,8 +51,8 @@ export async function DELETE(request: Request) { if (policy.rejection) return policy.rejection; // A presented API key always scopes the sweep to that key — even when the - // request also carries a dashboard session cookie — exactly like the - // list/count siblings (`apiKeyId || undefined`), so a leaked or over-shared + // request also carries a dashboard session cookie — the same rule the list + // siblings apply through `resolveListScope()`, so a leaked or over-shared // key can never widen a destructive sweep. Only a dashboard session WITHOUT a // key sweeps the whole instance; otherwise an ordinary key would delete every // tenant's completed batches and null out their file contents diff --git a/src/app/api/v1/batches/route.ts b/src/app/api/v1/batches/route.ts index f64a46d972..46e080aa26 100644 --- a/src/app/api/v1/batches/route.ts +++ b/src/app/api/v1/batches/route.ts @@ -3,7 +3,11 @@ import { createBatch, listBatches, countBatches } from "@/lib/db/batches"; import { getFile } from "@/lib/db/files"; import { v1BatchCreateSchema } from "@/shared/validation/schemas"; import { NextResponse } from "next/server"; -import { getApiKeyRequestScope } from "@/app/api/v1/_helpers/apiKeyScope"; +import { + getApiKeyRequestScope, + canAccessOwnedRecord, + resolveListScope, +} from "@/app/api/v1/_helpers/apiKeyScope"; import { formatBatchResponse } from "./formatBatchResponse"; import { parseBatchListLimit } from "./parseListLimit"; @@ -32,8 +36,12 @@ export async function POST(request: Request) { } const validated = validation.data; + // The batch runs LLM requests over the input file's content, so the caller + // must be allowed to READ that file: own key, or the operator's session. A + // null-owner input file is denied to a foreign key and to an anonymous + // caller alike (GHSA-2jm2-mpx8-6523). const inputFile = getFile(validated.input_file_id); - if (!inputFile || (inputFile.apiKeyId !== null && inputFile.apiKeyId !== apiKeyId)) { + if (!inputFile || !canAccessOwnedRecord(scope, inputFile.apiKeyId)) { return NextResponse.json( { error: { message: "Input file not found", type: "invalid_request_error" } }, { status: 400, headers: CORS_HEADERS } @@ -68,7 +76,14 @@ export async function POST(request: Request) { export async function GET(request: Request) { const scope = await getApiKeyRequestScope(request); if (scope.rejection) return scope.rejection; - const apiKeyId = scope.apiKeyId; + + // Key → own batches only; dashboard session without a key → instance-wide; + // anonymous / unresolvable bearer → 401. `listBatches`/`countBatches` read an + // absent owner as "every tenant", so the widening must be an explicit + // decision here, never a fallback (GHSA-m3hp-hq9g-fpmv). + const listScope = resolveListScope(scope); + if (listScope.mode === "rejected") return listScope.response; + const ownerFilter = listScope.mode === "api_key" ? listScope.apiKeyId : undefined; const url = new URL(request.url); const parsedLimit = parseBatchListLimit(url.searchParams.get("limit")); @@ -81,13 +96,13 @@ export async function GET(request: Request) { const limit = parsedLimit.limit; const after = url.searchParams.get("after") || undefined; - const batches = listBatches(apiKeyId || undefined, limit + 1, after); + const batches = listBatches(ownerFilter, limit + 1, after); const hasMore = batches.length > limit; const data = hasMore ? batches.slice(0, limit) : batches; const formattedData = data.map((b) => formatBatchResponse(b)); - const totalCount = countBatches(apiKeyId || undefined); + const totalCount = countBatches(ownerFilter); return NextResponse.json( { diff --git a/src/app/api/v1/files/[id]/content/route.ts b/src/app/api/v1/files/[id]/content/route.ts index 33bf4fdab4..73255c6982 100644 --- a/src/app/api/v1/files/[id]/content/route.ts +++ b/src/app/api/v1/files/[id]/content/route.ts @@ -1,7 +1,7 @@ import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; import { getFile, getFileContent } from "@/lib/db/files"; import { NextResponse } from "next/server"; -import { getApiKeyRequestScope } from "@/app/api/v1/_helpers/apiKeyScope"; +import { getApiKeyRequestScope, canAccessOwnedRecord } from "@/app/api/v1/_helpers/apiKeyScope"; export async function OPTIONS() { return handleCorsOptions(); @@ -10,12 +10,13 @@ export async function OPTIONS() { export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { const scope = await getApiKeyRequestScope(request); if (scope.rejection) return scope.rejection; - const apiKeyId = scope.apiKeyId; const { id } = await params; const file = getFile(id); - if (!file || (file.apiKeyId !== null && file.apiKeyId !== apiKeyId && !scope.isSessionAuth)) { + // `getFileContent` has no ownership check of its own — this guard is the only + // thing between a caller and the raw bytes (GHSA-2jm2-mpx8-6523). + if (!file || !canAccessOwnedRecord(scope, file.apiKeyId)) { return NextResponse.json( { error: { message: "File not found", type: "invalid_request_error" } }, { status: 404, headers: CORS_HEADERS } diff --git a/src/app/api/v1/files/[id]/route.ts b/src/app/api/v1/files/[id]/route.ts index 953903cca4..91e47872e7 100644 --- a/src/app/api/v1/files/[id]/route.ts +++ b/src/app/api/v1/files/[id]/route.ts @@ -1,7 +1,7 @@ import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; import { getFile, deleteFile, formatFileResponse } from "@/lib/db/files"; import { NextResponse } from "next/server"; -import { getApiKeyRequestScope } from "@/app/api/v1/_helpers/apiKeyScope"; +import { getApiKeyRequestScope, canAccessOwnedRecord } from "@/app/api/v1/_helpers/apiKeyScope"; export async function OPTIONS() { return handleCorsOptions(); @@ -10,12 +10,14 @@ export async function OPTIONS() { export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { const scope = await getApiKeyRequestScope(request); if (scope.rejection) return scope.rejection; - const apiKeyId = scope.apiKeyId; const { id } = await params; const file = getFile(id); - if (!file || (file.apiKeyId !== null && file.apiKeyId !== apiKeyId && !scope.isSessionAuth)) { + // Session = operator, key = own rows only, null owner = denied + // (GHSA-2jm2-mpx8-6523). A foreign or anonymous caller gets the same 404 as + // a missing id so the id space cannot be probed. + if (!file || !canAccessOwnedRecord(scope, file.apiKeyId)) { return NextResponse.json( { error: { message: "File not found", type: "invalid_request_error" } }, { status: 404, headers: CORS_HEADERS } @@ -28,21 +30,11 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { const scope = await getApiKeyRequestScope(request); if (scope.rejection) return scope.rejection; - const apiKeyId = scope.apiKeyId; const { id } = await params; const file = getFile(id); - if (!file) { - return NextResponse.json( - { error: { message: "File not found", type: "invalid_request_error" } }, - { status: 404, headers: CORS_HEADERS } - ); - } - - // Allow session-authenticated (dashboard) requests to delete any file; - // for API-key-authenticated requests, enforce scope. - if (!scope.isSessionAuth && file.apiKeyId !== null && file.apiKeyId !== apiKeyId) { + if (!file || !canAccessOwnedRecord(scope, file.apiKeyId)) { return NextResponse.json( { error: { message: "File not found", type: "invalid_request_error" } }, { status: 404, headers: CORS_HEADERS } diff --git a/src/app/api/v1/files/route.ts b/src/app/api/v1/files/route.ts index 4550755a15..63d8380782 100644 --- a/src/app/api/v1/files/route.ts +++ b/src/app/api/v1/files/route.ts @@ -1,7 +1,7 @@ import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; import { createFile, listFiles, formatFileResponse, countFiles } from "@/lib/db/files"; import { NextResponse } from "next/server"; -import { getApiKeyRequestScope } from "@/app/api/v1/_helpers/apiKeyScope"; +import { getApiKeyRequestScope, resolveListScope } from "@/app/api/v1/_helpers/apiKeyScope"; export async function OPTIONS() { return handleCorsOptions(); @@ -130,7 +130,14 @@ export async function POST(request: Request) { export async function GET(request: Request) { const scope = await getApiKeyRequestScope(request); if (scope.rejection) return scope.rejection; - const apiKeyId = scope.apiKeyId; + + // Key → own files only; dashboard session without a key → instance-wide; + // anonymous / unresolvable bearer → 401. `listFiles`/`countFiles` read an + // absent owner as "every tenant", so the widening must be an explicit + // decision here, never a fallback (GHSA-m3hp-hq9g-fpmv). + const listScope = resolveListScope(scope); + if (listScope.mode === "rejected") return listScope.response; + const ownerFilter = listScope.mode === "api_key" ? listScope.apiKeyId : undefined; const { searchParams } = new URL(request.url); const parsed = parseFilesListQuery(searchParams); @@ -139,7 +146,7 @@ export async function GET(request: Request) { // We fetch limit + 1 to check if there are more items const files = listFiles({ - apiKeyId: apiKeyId || undefined, + apiKeyId: ownerFilter, purpose, limit: limit + 1, after, @@ -148,7 +155,7 @@ export async function GET(request: Request) { const hasMore = files.length > limit; const data = files.slice(0, limit); - const totalCount = countFiles({ apiKeyId: apiKeyId || undefined, purpose }); + const totalCount = countFiles({ apiKeyId: ownerFilter, purpose }); return NextResponse.json( { diff --git a/src/i18n/messages/am.json b/src/i18n/messages/am.json index 96c5068ff8..fdae2f38f0 100644 --- a/src/i18n/messages/am.json +++ b/src/i18n/messages/am.json @@ -10970,7 +10970,10 @@ "poolAddMember": "ጨምር", "poolAddFailed": "ፕሮክሲውን ወደ ጥምረቱ ማከል አልተሳካም", "poolSelectProxy": "ፕሮክሲ ይምረጡ…", - "poolSaveFailed": "የጥምረት ምደባውን ማስቀመጥ አልተሳካም" + "poolSaveFailed": "የጥምረት ምደባውን ማስቀመጥ አልተሳካም", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family." }, "playground": { "title": "የሞዴል መሞከሪያ", @@ -13020,6 +13023,20 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "በአገልጋዩ የሚተዳደር የመሣሪያ ዑደት", "description": "ሞዴሉ በደንበኛው ሊጠቀምበት የሚችል ምላሽ እስኪመልስ ድረስ በአገልጋዩ የሚተዳደሩ ተከታታይ ያልሆኑ የመሣሪያ ጥሪዎችን ቀጥል።" + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths.", + "label": "__MISSING__:Retry-After Provenance" + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip.", + "label": "__MISSING__:Protected-Priority Infra Stops as 502" + }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "__MISSING__:Fail over once to a sibling connection when a stream closes before emitting any useful frame and the bounded same-connection retry is spent. With no usable sibling, the original early-EOF error is returned. Off: early-EOF stays terminal after the retry." } } }, @@ -14143,5 +14160,10 @@ "partnerLinkNote": "የአጋር አገናኝ", "dismissAriaLabel": "ዝጋ" }, - "featureFlagOmnirouteDisableThinkingLevelVariantsDescription": "በ/v1/models ካታሎግ ውስጥ የአስተሳሰብ ደረጃ ተለዋጮችን (ለምሳሌ -low, -medium, -high) ማመንጨትን አሰናክል።" + "featureFlagOmnirouteDisableThinkingLevelVariantsDescription": "በ/v1/models ካታሎግ ውስጥ የአስተሳሰብ ደረጃ ተለዋጮችን (ለምሳሌ -low, -medium, -high) ማመንጨትን አሰናክል።", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "__MISSING__:Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "__MISSING__:Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy." } diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 183a9f54f2..41df367baa 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "اعلن عن معرفات مرآة claude/<provider>/<model> على /v1/models حتى تظهر قائمة اكتشاف نماذج بوابة Claude Code نماذج غير Claude. تحذير: يؤدي إلى تكرار إدخالات الكتالوج لجميع العملاء عند تفعيله عالميًا.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "فعّل مسارات القبول الافتراضية التكيفية لكل مستأجر (tenant) لتوزيع المزودين (#9654): لم يعد انفجار حركة أحد المستأجرين يسبب خطأ 503 لمستأجر آخر. متغير البيئة OMNIROUTE_CHAT_VIRTUAL_LANES له الأولوية على هذا الإعداد في لوحة التحكم؛ تصبح التغييرات سارية بعد إعادة تشغيل الخادم.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "الصفحة الرئيسية", "dashboard": "لوحة القيادة", @@ -10957,6 +10961,9 @@ "strategyLatency": "محسن لزمن الاستجابة", "poolMembersLabel": "أعضاء التجمع ({count})", "poolNoMembers": "لا توجد وكلاء في هذا التجمع بعد.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "إزالة", "poolRemoveFailed": "فشل إزالة الوكيل من التجمع", "poolAddLabel": "إضافة وكيل", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "السماح لاسترداد التدفق بطلب الاستجابة مرة أخرى ودمجها بعد وصول البايتات بالفعل إلى العميل." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "الانتقال مرة واحدة إلى اتصال شقيق عندما يُغلق التدفق قبل إرسال أي إطار مفيد وتُستنفد إعادة المحاولة المحدودة على الاتصال نفسه. إذا لم يتوفر اتصال شقيق صالح، يُعاد خطأ early-EOF الأصلي. عند التعطيل: يبقى early-EOF نهائيًا بعد إعادة المحاولة." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "تضمين حقول الأسماء المناسبة للعرض في استجابات /v1/models. عطل هذا للعملاء الذين يقبلون معرفات النماذج فقط." }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "تمكين الوصول إلى الشبكة في بيئة اختبار المهارات المعزولة." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "رفض الطلبات قبل الإرسال عندما يفتقر النموذج المستهدف إلى القدرات المطلوبة (الرؤية، الأدوات، المخرجات المنظمة، نافذة السياق). يحمي الطلبات المباشرة من مزود واحد التي تتجاوز فلتر توافق الطبقة المجمعة.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "الصفحة غير موجودة", diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index c9ff4293ac..81972d8d4d 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "/v1/models üzərində claude/<provider>/<model> güzgü id-lərini reklam edin ki, Claude Code keçid modeli kəşfiyyatında qeyri-Claude modelləri siyahıya alsın. Diqqət: qlobal olaraq aktivləşdirildikdə bütün müştərilər üçün kataloq girişlərini ikiqat artırır.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Təchizatçı göndərişi üçün hər bir icarəçi (tenant) üzrə adaptiv virtual qəbul zolaqlarını aktivləşdirin (#9654): bir icarəçinin ani yükü artıq digərinə 503 qaytarmır. OMNIROUTE_CHAT_VIRTUAL_LANES mühit dəyişəni bu idarəetmə paneli ayarından üstündür; dəyişikliklər server yenidən işə salındıqda qüvvəyə minir.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Home", "dashboard": "Dashboard", @@ -10957,6 +10961,9 @@ "strategyLatency": "Gecikməyə görə optimallaşdırılmış", "poolMembersLabel": "Hovuz üzvləri ({count})", "poolNoMembers": "Bu hovuzda hələ proksi yoxdur.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Çıxar", "poolRemoveFailed": "Proksini hovuzdan çıxarmaq mümkün olmadı", "poolAddLabel": "Proksi əlavə et", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Baytlar artıq müştəriyə çatdıqdan sonra axının bərpasına cavabı yenidən sorğulamağa və onu birləşdirməyə icazə verin." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Axın heç bir faydalı çərçivə göndərmədən bağlandıqda və eyni bağlantıda məhdud təkrar cəhd tükəndikdə bir dəfə qardaş bağlantıya keçin. Uyğun qardaş bağlantı yoxdursa, ilkin early-EOF xətası qaytarılır. Söndürülüb: early-EOF təkrar cəhddən sonra son nəticə olaraq qalır." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models cavablarına göstərilməsi asan olan ad sahələrini daxil edin. Bunu yalnız model ID-lərini qəbul edən müştərilər üçün sıradan çıxarın." }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Bacarıqlar sandbox-unda şəbəkəyə girişi aktivləşdirin." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Tələb olunan imkanlar (görmə, alətlər, strukturlaşdırılmış çıxış, kontekst pəncərəsi) olmayan hədəf modelində göndərilmədən əvvəl tələbləri rədd edin. Kombinasiya qatının uyğunluq filtrini keçən birbaşa tək təminatçı tələblərini qoruyur.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Səhifə tapılmadı", diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index 53a43ecc88..ba02624d17 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Рекламирайте claude/<provider>/<model> mirror идентификатори на /v1/models, така че списъкът с модели на Claude Code gateway да включва неклаудови модели. Внимание: удвоява записите в каталога за всички клиенти, когато е активирано глобално.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Активирайте адаптивни виртуални ленти за допускане за всеки наемател (tenant) при изпращане към доставчици (#9654): скокът в натоварването на един наемател вече не връща 503 на друг. Променливата на средата OMNIROUTE_CHAT_VIRTUAL_LANES има предимство пред тази настройка в таблото; промените влизат в сила след рестартиране на сървъра.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Начало", "dashboard": "Табло", @@ -10957,6 +10961,9 @@ "strategyLatency": "Оптимизирано по латентност (Latency-optimized)", "poolMembersLabel": "Членове на пула ({count})", "poolNoMembers": "Все още няма проксита в този пул.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Премахване", "poolRemoveFailed": "Неуспешно премахване на проксито от пула", "poolAddLabel": "Добавяне на прокси", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Разрешаване на възстановяването на потока да поиска отговора отново и да го съедини, след като байтовете вече са достигнали до клиента." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Еднократно превключване към сродна връзка, когато потокът се затвори, преди да изпрати полезен фрейм, и ограниченият повторен опит на същата връзка е изчерпан. Ако няма използваема сродна връзка, се връща оригиналната грешка early-EOF. Изключено: early-EOF остава окончателен след повторния опит." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Включване на лесни за четене полета за имена в отговорите на /v1/models. Деактивирайте това за клиенти, които приемат само идентификатори на модели." }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Активиране на мрежов достъп в пясъчника за умения." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Отхвърлете заявките преди изпращане, когато целевият модел няма необходимите възможности (визия, инструменти, структурирани изходи, контекстен прозорец). Защитава директните заявки от един доставчик, които заобикалят филтъра за съвместимост на комбинирания слой.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Страницата не е намерена", diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index ffb54ef8d6..6ddf474014 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "/v1/models এ claude/<provider>/<model> মিরর আইডি বিজ্ঞাপন দিন যাতে Claude Code গেটওয়ে মডেল আবিষ্কার non-Claude মডেল তালিকাভুক্ত করে। সতর্কতা: এটি গ্লোবালি সক্ষম হলে সমস্ত ক্লায়েন্টের জন্য ক্যাটালগ এন্ট্রি দ্বিগুণ করে।", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "প্রোভাইডার ডিসপ্যাচের জন্য প্রতি-টেন্যান্ট অ্যাডাপ্টিভ ভার্চুয়াল অ্যাডমিশন লেন সক্ষম করুন (#9654): এক টেন্যান্টের বিস্ফোরণ আর অন্য টেন্যান্টে 503 ফেরায় না। OMNIROUTE_CHAT_VIRTUAL_LANES এনভায়রনমেন্ট ভেরিয়েবল এই ড্যাশবোর্ড সেটিংয়ের উপরে প্রাধান্য পায়; পরিবর্তনগুলি সার্ভার পুনরায় চালু হলে কার্যকর হয়।", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Home", "dashboard": "Dashboard", @@ -10957,6 +10961,9 @@ "strategyLatency": "লেটেন্সি-অপ্টিমাইজড", "poolMembersLabel": "পুলের সদস্য ({count})", "poolNoMembers": "এই পুলে এখনও কোনো প্রক্সি নেই।", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "সরান", "poolRemoveFailed": "পুল থেকে প্রক্সি সরাতে ব্যর্থ হয়েছে", "poolAddLabel": "একটি প্রক্সি যোগ করুন", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "ক্লায়েন্টের কাছে ইতিমধ্যে বাইট পৌঁছানোর পরে স্ট্রিম রিকভারিকে প্রতিক্রিয়ার জন্য আবার অনুরোধ করার এবং এটি যুক্ত করার অনুমতি দিন।" }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "স্ট্রিম কোনো উপযোগী ফ্রেম পাঠানোর আগেই বন্ধ হলে এবং একই সংযোগে সীমিত পুনঃচেষ্টা শেষ হয়ে গেলে একবার একটি সহোদর সংযোগে ফেইলওভার করুন। ব্যবহারযোগ্য সহোদর সংযোগ না থাকলে মূল early-EOF ত্রুটি ফেরত দেওয়া হয়। বন্ধ থাকলে: পুনঃচেষ্টার পরে early-EOF চূড়ান্ত থাকে।" + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models প্রতিক্রিয়াগুলিতে প্রদর্শন-বান্ধব নামের ক্ষেত্রগুলি অন্তর্ভুক্ত করুন। শুধুমাত্র মডেল ID গ্রহণ করে এমন ক্লায়েন্টদের জন্য এটি নিষ্ক্রিয় করুন।" }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "স্কিল স্যান্ডবক্সে নেটওয়ার্ক অ্যাক্সেস সক্ষম করুন।" + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "লক্ষ্য মডেলের প্রয়োজনীয় সক্ষমতা (দৃষ্টি, সরঞ্জাম, কাঠামোবদ্ধ আউটপুট, প্রসঙ্গ উইন্ডো) অনুপস্থিত থাকলে প্রেরণের আগে অনুরোধগুলি প্রত্যাখ্যান করুন। এটি কম্বো-লেয়ার সামঞ্জস্য ফিল্টারকে বাইপাস করা সরাসরি একক-প্রদানকারী অনুরোধগুলি রক্ষা করে।", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "পৃষ্ঠা পাওয়া যায়নি", diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index df7ff0803e..9abb23fe98 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Inzerujte claude/<provider>/<model> zrcadlové ID na /v1/models, aby seznam objevování modelů brány Claude Code zahrnoval modely, které nejsou Claude. Upozornění: při globálním povolení zdvojuje katalogové položky pro všechny klienty.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Povolte adaptivní virtuální vstupní pruhy pro každého tenanta při odesílání poskytovatelům (#9654): špička jednoho tenanta už nezpůsobí 503 u jiného. Proměnná prostředí OMNIROUTE_CHAT_VIRTUAL_LANES má přednost před tímto nastavením na řídicím panelu; změny se projeví po restartu serveru.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Domov", "dashboard": "Nástěnka", @@ -10957,6 +10961,9 @@ "strategyLatency": "Optimalizováno podle latence", "poolMembersLabel": "Členové poolu ({count})", "poolNoMembers": "V tomto poolu zatím nejsou žádné proxy.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Odebrat", "poolRemoveFailed": "Nepodařilo se odebrat proxy z poolu", "poolAddLabel": "Přidat proxy", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Povolit obnovení streamu pro opětovné vyžádání odpovědi a její spojení poté, co bajty již dorazily ke klientovi." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Jednorázově přepnout na sesterské připojení, když se stream uzavře před odesláním jakéhokoli užitečného rámce a omezený opakovaný pokus na stejném připojení je vyčerpán. Pokud není k dispozici použitelné sesterské připojení, vrátí se původní chyba early-EOF. Vypnuto: early-EOF zůstává po opakovaném pokusu konečný." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Zahrnout uživatelsky přívětivá pole názvů v odpovědích /v1/models. Zakažte to pro klienty, kteří přijímají pouze ID modelů." }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Povolit přístup k síti v sandboxu dovedností." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Odmítnout požadavky před odesláním, když cílový model postrádá požadované schopnosti (vidění, nástroje, strukturovaný výstup, kontextové okno). Chrání přímé požadavky od jednotlivých poskytovatelů, které obcházejí filtr kompatibility kombinované vrstvy.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Stránka nebyla nalezena", diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index e817957613..ffabe002ee 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Reklamer claude/<provider>/<model> spejl-id'er på /v1/models, så Claude Code gateway modelopdagelse viser ikke-Claude modeller. Advarsel: fordobler katalogposter for alle klienter, når det er aktiveret globalt.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Aktivér adaptive virtuelle adgangsbaner pr. tenant til providerudlevering (#9654): en tenants burst giver ikke længere en anden 503. Miljøvariablen OMNIROUTE_CHAT_VIRTUAL_LANES har forrang over denne dashboard-indstilling; ændringer træder i kraft ved genstart af serveren.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Hjem", "dashboard": "Dashboard", @@ -10957,6 +10961,9 @@ "strategyLatency": "Latensoptimeret", "poolMembersLabel": "Pool-medlemmer ({count})", "poolNoMembers": "Ingen proxyer i denne pool endnu.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Fjern", "poolRemoveFailed": "Kunne ikke fjerne proxyen fra poolen", "poolAddLabel": "Tilføj en proxy", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Tillad stream-gendannelse at anmode om svaret igen og sammenføje det, efter at bytes allerede har nået klienten." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Skift én gang over til en søsterforbindelse, når en stream lukker, før den har sendt en brugbar frame, og det begrænsede genforsøg på samme forbindelse er brugt. Uden en brugbar søsterforbindelse returneres den oprindelige early-EOF-fejl. Fra: early-EOF forbliver endelig efter genforsøget." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Inkluder visningsvenlige navnefelter i /v1/models-svar. Deaktivér dette for klienter, der kun accepterer model-id'er." }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Aktivér netværksadgang i skills-sandkassen." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Afvis anmodninger før afsendelse, når målmodellen mangler de nødvendige funktioner (vision, værktøjer, struktureret output, kontekstvindue). Beskytter direkte anmodninger fra en enkelt udbyder, der omgår kombinationslagets kompatibilitetsfilter.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Siden blev ikke fundet", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index b50c224873..65b8e9deae 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Bewerben Sie claude/<provider>/<model> Spiegel-IDs auf /v1/models, damit die Claude Code-Gateway-Modellentdeckung Nicht-Claude-Modelle auflistet. Warnung: Verdoppelt Katalogeinträge für alle Clients, wenn global aktiviert.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Aktivieren Sie adaptive virtuelle Zulassungsspuren pro Tenant für die Provider-Zustellung (#9654): Ein Burst eines Tenants führt nicht mehr zu 503 bei einem anderen. Die Umgebungsvariable OMNIROUTE_CHAT_VIRTUAL_LANES hat Vorrang vor dieser Dashboard-Einstellung; Änderungen werden erst nach einem Serverneustart wirksam.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Zuhause", "dashboard": "Dashboard", @@ -10957,6 +10961,9 @@ "strategyLatency": "Latenzoptimiert", "poolMembersLabel": "Pool-Mitglieder ({count})", "poolNoMembers": "Noch keine Proxys in diesem Pool.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Entfernen", "poolRemoveFailed": "Fehler beim Entfernen des Proxys aus dem Pool", "poolAddLabel": "Proxy hinzufügen", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Stream-Wiederherstellung erlauben, um die Antwort erneut anzufordern und zusammenzufügen, nachdem bereits Bytes den Client erreicht haben." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Einmaliger Failover auf eine Geschwisterverbindung, wenn ein Stream schließt, bevor er einen nützlichen Frame gesendet hat, und der begrenzte Wiederholungsversuch auf derselben Verbindung aufgebraucht ist. Ohne nutzbare Geschwisterverbindung wird der ursprüngliche early-EOF-Fehler zurückgegeben. Aus: early-EOF bleibt nach dem Wiederholungsversuch endgültig." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Benutzerfreundliche Namensfelder in /v1/models-Antworten einschließen. Deaktivieren Sie dies für Clients, die nur Modell-IDs akzeptieren." }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Netzwerkzugriff in der Skills-Sandbox aktivieren." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Lehnen Sie Anfragen ab, bevor sie versendet werden, wenn das Zielmodell über die erforderlichen Funktionen (Vision, Werkzeuge, strukturierte Ausgabe, Kontextfenster) nicht verfügt. Schützt direkte Einzelanbieteranfragen, die den Kombo-Schicht-Kompatibilitätsfilter umgehen.", "featureFlagDisableContextWindowChecksDescription": "Lokale Kontextfenster- und Maximaleingabetoken-Prüfung von OmniRoute für direkte Einzelmodell-Anfragen überspringen. Upstream-Anbieter erzwingen weiterhin ihre tatsächlichen Grenzen. Prompt-Komprimierung und Ausgabetoken-Grenzen bleiben aktiv.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Seite nicht gefunden", diff --git a/src/i18n/messages/el.json b/src/i18n/messages/el.json index 5a6f50b767..9a2e2b43c0 100644 --- a/src/i18n/messages/el.json +++ b/src/i18n/messages/el.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Διαφήμιση αναγνωριστικών καθρέφτη claude/<provider>/<model> στο /v1/models ώστε η ανακάλυψη μοντέλων πύλης Claude Code να εμφανίζει μη-Claude μοντέλα. Προειδοποίηση: διπλασιάζει τις εγγραφές καταλόγου για όλους τους πελάτες όταν ενεργοποιείται καθολικά.", "featureFlagNoThinkingAliasEnabledDescription": "Κύριος διακόπτης για τα ψευδώνυμα πύλης no-think/<provider>/<model>. Ενεργό (προεπιλογή): το /v1/models διαφημίζει μια παραλλαγή χωρίς σκέψη για κάθε κατάλληλο Claude μοντέλο ικανό για σκέψη, και ένα αναγνωριστικό no-think/ που αποστέλλεται σε αίτημα επιλύεται στο πραγματικό μοντέλο με καταστολή του συλλογισμού. Ανενεργό: δεν διαφημίζονται παραλλαγές και ένα αναγνωριστικό no-think/ αντιμετωπίζεται όπως οποιοδήποτε άλλο άγνωστο αναγνωριστικό μοντέλου. Η επιλογή ενεργοποίησης/απενεργοποίησης ανά μοντέλο ModelSpec.noThinkingAlias εξακολουθεί να ισχύει ενώ αυτό είναι ενεργό.", "featureFlagChatVirtualLanesEnabledDescription": "Ενεργοποίηση προσαρμοστικών εικονικών λωρίδων αποδοχής ανά ενοικιαστή για αποστολή παρόχου (#9654): η έκρηξη ενός ενοικιαστή δεν προκαλεί πλέον 503 σε άλλον. Η μεταβλητή περιβάλλοντος OMNIROUTE_CHAT_VIRTUAL_LANES υπερισχύει αυτής της παράκαμψης του πίνακα ελέγχου· οι αλλαγές τίθενται σε ισχύ κατά την επανεκκίνηση του διακομιστή.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Αρχική", "dashboard": "Πίνακας Ελέγχου", @@ -10964,6 +10968,9 @@ "strategyLatency": "Βελτιστοποιημένο βάσει καθυστέρησης", "poolMembersLabel": "Μέλη pool ({count})", "poolNoMembers": "Δεν υπάρχουν ακόμα proxy σε αυτό το pool.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Αφαίρεση", "poolRemoveFailed": "Αποτυχία αφαίρεσης του proxy από το pool", "poolAddLabel": "Προσθήκη διαμεσολαβητή", @@ -12984,6 +12991,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Εξουσιοδότηση αποκατάστασης ροής να ζητά εκ νέου την απόκριση και να την συνενώνει αφού bytes έχουν ήδη φτάσει στον πελάτη." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Μία εναλλαγή σε αδελφή σύνδεση όταν μια ροή κλείνει πριν στείλει οποιοδήποτε χρήσιμο πλαίσιο και η περιορισμένη επανάληψη στην ίδια σύνδεση έχει εξαντληθεί. Χωρίς διαθέσιμη αδελφή σύνδεση επιστρέφεται το αρχικό σφάλμα early-EOF. Ανενεργό: το early-EOF παραμένει τελικό μετά την επανάληψη." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Συμπερίληψη φιλικών προς εμφάνιση πεδίων ονόματος στις αποκρίσεις /v1/models. Απενεργοποιήστε το για πελάτες που δέχονται μόνο αναγνωριστικά μοντέλων." }, @@ -13020,6 +13033,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Βρόχος εργαλείων ελεγχόμενος από τον διακομιστή", "description": "Συνέχιση των μη συνεχιζόμενων κλήσεων εργαλείων που ελέγχονται από τον διακομιστή, έως ότου το μοντέλο επιστρέψει μια απόκριση που μπορεί να χρησιμοποιήσει ο πελάτης." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13654,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Απόρριψη αιτημάτων πριν από την αποστολή όταν το στοχευόμενο μοντέλο δεν διαθέτει τις απαιτούμενες δυνατότητες (όραση, εργαλεία, δομημένη έξοδος, παράθυρο περιβάλλοντος). Προστατεύει άμεσα αιτήματα μεμονωμένου παρόχου που παρακάμπτουν το φίλτρο συμβατότητας του επιπέδου combo.", "featureFlagDisableContextWindowChecksDescription": "Παράλειψη του τοπικού ελέγχου παραθύρου περιβάλλοντος και μέγιστου εισόδου διακριτικών του OmniRoute για άμεσα αιτήματα μεμονωμένου μοντέλου. Οι upstream πάροχοι εξακολουθούν να επιβάλλουν τα πραγματικά τους όρια. Η συμπίεση προτροπής και τα ανώτατα όρια διακριτικών εξόδου παραμένουν ενεργά.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Η σελίδα δεν βρέθηκε", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 508e3f4ee2..0a685beed5 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Advertise claude/<provider>/<model> mirror ids on /v1/models so Claude Code gateway model discovery lists non-Claude models. Warning: doubles catalog entries for all clients when enabled globally.", "featureFlagNoThinkingAliasEnabledDescription": "Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Enable per-tenant adaptive virtual admission lanes for provider dispatch (#9654): one tenant's burst no longer 503s another. The OMNIROUTE_CHAT_VIRTUAL_LANES env var wins over this dashboard override; changes take effect at server restart.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Home", "dashboard": "Dashboard", @@ -10964,6 +10968,9 @@ "strategyLatency": "Latency-optimized", "poolMembersLabel": "Pool members ({count})", "poolNoMembers": "No proxies in this pool yet.", + "poolEgressObservation": "{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "No traffic observed in the last {hours} h", + "poolEgressObservationHint": "Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Remove", "poolRemoveFailed": "Failed to remove the proxy from the pool", "poolAddLabel": "Add a proxy", @@ -12984,6 +12991,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Allow stream recovery to request the response again and stitch it after bytes have already reached the client." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Fail over once to a sibling connection when a stream closes before emitting any useful frame and the bounded same-connection retry is spent. With no usable sibling, the original early-EOF error is returned. Off: early-EOF stays terminal after the retry." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Include display-friendly name fields in /v1/models responses. Disable this for clients that accept model IDs only." }, @@ -13020,6 +13033,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Server-Owned Tool Loop", "description": "Continue non-streaming server-owned tool calls until the model returns a client-usable response." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "Retry-After Provenance", + "description": "On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "Protected-Priority Infra Stops as 502", + "description": "Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13654,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Reject requests before dispatch when the target model lacks required capabilities (vision, tools, structured output, context window). Protects direct single-provider requests that bypass the combo-layer compatibility filter.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Page not found", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 1d2f539aeb..18916f167f 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Anunciar los ids de espejo claude/<provider>/<model> en /v1/models para que la lista de descubrimiento de modelos del gateway de Claude Code incluya modelos que no son de Claude. Advertencia: duplica las entradas del catálogo para todos los clientes cuando se habilita globalmente.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Activa carriles de admisión virtuales adaptativos por tenant para el envío de proveedores (#9654): el pico de un tenant ya no devuelve 503 a otro. La variable de entorno OMNIROUTE_CHAT_VIRTUAL_LANES tiene prioridad sobre esta opción del panel; los cambios surten efecto al reiniciar el servidor.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Inicio", "dashboard": "Panel de control", @@ -10957,6 +10961,9 @@ "strategyLatency": "Latency-optimized", "poolMembersLabel": "Pool members ({count})", "poolNoMembers": "No proxies in this pool yet.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Remove", "poolRemoveFailed": "Failed to remove the proxy from the pool", "poolAddLabel": "Add a proxy", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Allow stream recovery to request the response again and stitch it after bytes have already reached the client." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Conmutar una sola vez a una conexión hermana cuando un stream se cierra antes de emitir cualquier trama útil y se agotó el reintento limitado en la misma conexión. Si no hay una conexión hermana utilizable, se devuelve el error early-EOF original. Desactivado: early-EOF sigue siendo terminal tras el reintento." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Include display-friendly name fields in /v1/models responses. Disable this for clients that accept model IDs only." }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Enable network access in the skills sandbox." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Rechazar solicitudes antes del despacho cuando el modelo objetivo carece de capacidades requeridas (visión, herramientas, salida estructurada, ventana de contexto). Protege las solicitudes directas de un solo proveedor que eluden el filtro de compatibilidad de la capa combinada.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Página no encontrada", diff --git a/src/i18n/messages/et.json b/src/i18n/messages/et.json index d3d818f641..b72c7a30ee 100644 --- a/src/i18n/messages/et.json +++ b/src/i18n/messages/et.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Avaldage claude/<provider>/<model> peegel-ID-d lõpp-punktis /v1/models, et Claude Code'i lüüsi mudeliotsing loetleks ka mitte-Claude'i mudelid. Hoiatus: globaalsel lubamisel kahekordistab see kõigi klientide kataloogikirjete arvu.", "featureFlagNoThinkingAliasEnabledDescription": "Lüüsi no-think/<provider>/<model> aliaste pealüliti. Sees (vaikimisi): /v1/models avaldab iga sobiliku mõtlemisvõimelise Claude'i mudeli jaoks mõtlemiseta variandi ning päringus saadetud no-think/ ID lahendatakse tagasi tegelikuks mudeliks, mille arutluskäik on maha surutud. Väljas: variante ei avaldata ja no-think/ ID-d käsitletakse nagu mis tahes muud tundmatut mudeli-ID-d. Kui see on sisse lülitatud, kehtib endiselt mudelipõhine ModelSpec.noThinkingAlias lubamisest või keelamisest loobumise säte.", "featureFlagChatVirtualLanesEnabledDescription": "Lubage pakkujale edastamiseks rentnikupõhised kohanduvad virtuaalsed vastuvõturajad (#9654): ühe rentniku koormushoog ei põhjusta enam teisele tõrget 503. Keskkonnamuutuja OMNIROUTE_CHAT_VIRTUAL_LANES alistab selle juhtpaneeli sätte; muudatused jõustuvad serveri taaskäivitamisel.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Avaleht", "dashboard": "Juhtpaneel", @@ -10964,6 +10968,9 @@ "strategyLatency": "Latentsusoptimeeritud", "poolMembersLabel": "Kogumi liikmed ({count})", "poolNoMembers": "Selles kogumis pole veel ühtegi puhverserverit.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Eemalda", "poolRemoveFailed": "Puhverserveri eemaldamine kogumist nurjus", "poolAddLabel": "Lisa puhverserver", @@ -12984,6 +12991,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Luba voo taastamisel vastus uuesti pärida ja jätkata selle liitmist pärast seda, kui baidid on juba kliendini jõudnud." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Lülitu üks kord ümber sõsarühendusele, kui voog sulgub enne ühegi kasuliku kaadri saatmist ja sama ühenduse piiratud kordusproov on ära kasutatud. Kasutatava sõsarühenduse puudumisel tagastatakse algne early-EOF viga. Väljas: early-EOF jääb pärast kordusproovi lõplikuks." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Kaasa /v1/models vastustesse kuvamiseks sobivad nimeväljad. Keela see klientide puhul, mis aktsepteerivad ainult mudeli-ID-sid." }, @@ -13020,6 +13033,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Serveri hallatav tööriistatsükkel", "description": "Jätka serveri hallatavate voogedastuseta tööriistakutsete tegemist, kuni mudel tagastab kliendi jaoks kasutatava vastuse." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13654,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Lükka päringud enne edastamist tagasi, kui sihtmudelil puuduvad nõutavad võimalused (nägemine, tööriistad, struktureeritud väljund, kontekstiaken). See kaitseb ühe teenusepakkuja otsepäringuid, mis mööduvad kombokihi ühilduvusfiltrist.", "featureFlagDisableContextWindowChecksDescription": "Jäta ühe mudeli otsepäringute puhul OmniRoute'i kohalik kontekstiakna ja sisendtokenite maksimumarvu kontroll vahele. Ülesvoolu teenusepakkujad jõustavad endiselt oma tegelikud piirangud. Viiba tihendamine ja väljundtokenite piirangud jäävad aktiivseks.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Lehte ei leitud", diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index 1f00f515c3..68f6e65028 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "آگهی شناسه‌های آینه claude/<provider>/<model> را در /v1/models به‌گونه‌ای تنظیم کنید که لیست کشف مدل‌های دروازه کد Claude شامل مدل‌های غیر Claude باشد. هشدار: در صورت فعال‌سازی جهانی، ورودی‌های کاتالوگ را برای تمام مشتریان دو برابر می‌کند.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "خط‌های پذیرش مجازی تطبیقی به‌ازای هر مستاجر (tenant) را برای ارسال به ارائه‌دهندگان فعال کنید (#9654): افزایش ناگهانی بار یک مستاجر دیگر خطای 503 را برای مستاجر دیگر ایجاد نمی‌کند. متغیر محیطی OMNIROUTE_CHAT_VIRTUAL_LANES بر این تنظیم داشبورد اولویت دارد؛ تغییرات پس از راه‌اندازی مجدد سرور اعمال می‌شوند.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Home", "dashboard": "Dashboard", @@ -10957,6 +10961,9 @@ "strategyLatency": "بهینه‌شده برای تاخیر", "poolMembersLabel": "اعضای استخر ({count})", "poolNoMembers": "هنوز هیچ پروکسی در این استخر وجود ندارد.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "حذف", "poolRemoveFailed": "حذف پروکسی از استخر ناموفق بود", "poolAddLabel": "افزودن یک پروکسی", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "اجازه دادن به بازیابی جریان برای درخواست مجدد پاسخ و پیوند زدن آن پس از اینکه بایت‌ها قبلاً به کلاینت رسیده‌اند." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "یک بار به یک اتصال هم‌خانواده منتقل شوید وقتی جریان پیش از ارسال هر فریم مفیدی بسته می‌شود و تلاش مجدد محدود روی همان اتصال تمام شده است. اگر اتصال هم‌خانواده قابل‌استفاده‌ای نباشد، خطای اصلی early-EOF برگردانده می‌شود. خاموش: early-EOF پس از تلاش مجدد نهایی می‌ماند." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "گنجاندن فیلدهای نام مناسب برای نمایش در پاسخ‌های /v1/models. این را برای کلاینت‌هایی که فقط شناسه مدل را می‌پذیرند غیرفعال کنید." }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "فعال‌سازی دسترسی به شبکه در محیط ایزوله مهارت‌ها." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "درخواست‌ها را قبل از ارسال رد کنید زمانی که مدل هدف قابلیت‌های مورد نیاز (بینایی، ابزارها، خروجی ساختاریافته، پنجره زمینه) را ندارد. از درخواست‌های مستقیم تک‌تأمین‌کننده که فیلتر سازگاری لایه ترکیبی را دور می‌زنند، محافظت می‌کند.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "صفحه پیدا نشد", diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index 4a1a88098f..0c70a65d10 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Mainosta claude/<provider>/<model> peilid tunnuksia /v1/models, jotta Claude Code -portin mallin löytölistalla näkyvät ei-Claude-mallit. Varoitus: kaksinkertaistaa luettelo-merkinnät kaikille asiakkaille, kun se on otettu käyttöön globaalisti.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Ota käyttöön mukautuvat virtuaaliset sisäänottokaistat vuokraajaa (tenant) kohti palveluntarjoajien välitystä varten (#9654): yhden vuokraajan kuormapiikki ei enää aiheuta 503-virhettä toiselle. Ympäristömuuttuja OMNIROUTE_CHAT_VIRTUAL_LANES ohittaa tämän hallintapaneelin asetuksen; muutokset tulevat voimaan palvelimen uudelleenkäynnistyksessä.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Kotiin", "dashboard": "Kojelauta", @@ -10957,6 +10961,9 @@ "strategyLatency": "Viiveoptimoitu", "poolMembersLabel": "Poolin jäsenet ({count})", "poolNoMembers": "Tässä poolissa ei ole vielä proxyja.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Poista", "poolRemoveFailed": "Proxyn poistaminen poolista epäonnistui", "poolAddLabel": "Lisää proxy", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Salli virran palautuksen pyytää vastausta uudelleen ja liittää se sen jälkeen, kun tavuja on jo saapunut asiakkaalle." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Siirry kerran rinnakkaisyhteyteen, kun virta sulkeutuu ennen yhdenkään hyödyllisen kehyksen lähettämistä ja saman yhteyden rajattu uusintayritys on käytetty. Jos käyttökelpoista rinnakkaisyhteyttä ei ole, palautetaan alkuperäinen early-EOF-virhe. Pois: early-EOF on lopullinen uusintayrityksen jälkeen." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Sisällytä näyttöystävälliset nimikentät /v1/models-vastauksiin. Poista tämä käytöstä asiakkaille, jotka hyväksyvät vain mallitunnuksia." }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Ota käyttöön verkkoyhteys taitojen hiekkalaatikossa." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Hylkää pyynnöt ennen lähettämistä, kun kohdemallilta puuttuu vaadittuja ominaisuuksia (näkö, työkalut, jäsennelty ulostulo, kontekstikkelu). Suojaa suorat yhden tarjoajan pyynnöt, jotka ohittavat yhdistelmäkerroksen yhteensopivuussuodattimen.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Sivua ei löytynyt", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 1de3c1a998..cceef76e5b 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Afficher les identifiants miroir claude/<provider>/<model> dans /v1/models afin que la découverte de modèles de la passerelle Claude Code répertorie les modèles non-Claude. Attention : cette option double les entrées du catalogue pour tous les clients lorsqu'elle est activée globalement.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Activez des voies d'admission virtuelles adaptatives par tenant pour la répartition des fournisseurs (#9654) : le pic d'un tenant ne renvoie plus 503 à un autre. La variable d'environnement OMNIROUTE_CHAT_VIRTUAL_LANES prime sur ce réglage du tableau de bord ; les modifications prennent effet au redémarrage du serveur.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Accueil", "dashboard": "Tableau de bord", @@ -10957,6 +10961,9 @@ "strategyLatency": "Optimisé pour la latence", "poolMembersLabel": "Membres du pool ({count})", "poolNoMembers": "Aucun proxy dans ce pool pour le moment.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Retirer", "poolRemoveFailed": "Échec du retrait du proxy du pool", "poolAddLabel": "Ajouter un proxy", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Autoriser la récupération de flux à demander à nouveau la réponse et à la raccorder après que des octets ont déjà atteint le client." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Basculer une seule fois vers une connexion sœur lorsqu'un flux se ferme avant d'émettre la moindre trame utile et que la nouvelle tentative limitée sur la même connexion est épuisée. Sans connexion sœur utilisable, l'erreur early-EOF d'origine est renvoyée. Désactivé : early-EOF reste terminal après la nouvelle tentative." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Inclure des champs de nom conviviaux pour l'affichage dans les réponses /v1/models. Désactivez cette option pour les clients qui n'acceptent que les ID de modèle." }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Activer l'accès réseau dans le bac à sable des compétences." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Rejeter les demandes avant l'expédition lorsque le modèle cible manque des capacités requises (vision, outils, sortie structurée, fenêtre de contexte). Protège les demandes directes à un seul fournisseur qui contournent le filtre de compatibilité de la couche combo.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Page introuvable", diff --git a/src/i18n/messages/ga.json b/src/i18n/messages/ga.json index 111f3a290f..a3dcf8d2e5 100644 --- a/src/i18n/messages/ga.json +++ b/src/i18n/messages/ga.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Fógraigh aitheantais scátháin claude/<soláthraí>/<samhail> ar /v1/models ionas go bhfógróidh fionnachtain samhla geata Claude Code samhlacha neamh-Claude. Rabhadh: déanann sé iontrálacha catalóige a dhúbailt do gach cliant nuair a chumasaítear go domhanda é.", "featureFlagNoThinkingAliasEnabledDescription": "Príomh-lasc do na haliasanna geataí no-think/<soláthraí>/<samhail>. Ar (réamhshocrú): fógraíonn /v1/models leagan gan smaoineamh do gach samhail Claude atá in ann smaoineamh, agus réitíonn aitheantas no-think/ a sheoltar ar iarratas ar ais go dtí an tsamhail fíor le réasúnaíocht faoi chois. As: ní fhógraítear aon leaganacha agus caitear le haitheantas no-think/ mar aon aitheantas samhla anaithnid eile. Tá an rogha per-model ModelSpec.noThinkingAlias fós i bhfeidhm agus é seo ar siúl.", "featureFlagChatVirtualLanesEnabledDescription": "Cumasaigh lánaí iontrála oiriúnaitheacha fíorúla in aghaidh an tionónta le haghaidh seolta soláthraí (#9654): ní chruthaíonn pléascadh tionónta amháin 503 do thionónta eile a thuilleadh. Tá an athróg timpeallachta OMNIROUTE_CHAT_VIRTUAL_LANES níos cumhachtaí ná an sárú deais seo; tagann athruithe i bhfeidhm ag atosú freastalaí.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Baile", "dashboard": "Deais", @@ -10964,6 +10968,9 @@ "strategyLatency": "Optamaithe ag moill", "poolMembersLabel": "Comhaltaí na linne ({count})", "poolNoMembers": "Níl aon seachfhreastalaithe sa linn seo fós.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Bain", "poolRemoveFailed": "Theip ar bhaint an seachfhreastalaí as an linn", "poolAddLabel": "Cuir procsí leis", @@ -12984,6 +12991,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Ceadaigh d'aisghabháil srutha an freagra a iarraidh arís agus é a fhuáil le chéile tar éis do bhearta a bheith sroichte ag an gcliant cheana féin." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Aistrigh uair amháin chuig nasc deirfiúr nuair a dhúntar sruth sula seoltar fráma úsáideach ar bith agus nuair atá an athiarracht theoranta ar an nasc céanna ídithe. Mura bhfuil nasc deirfiúr inúsáidte ann, seoltar an earráid early-EOF bhunaidh ar ais. As: fanann early-EOF críochnaitheach tar éis na hathiarrachta." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Cuir réimsí ainmneacha atá cairdiúil don taispeáint san áireamh i bhfreagraí /v1/models. Díchumasaigh é seo do chliaint a ghlacann le haitheantóirí múnla amháin." }, @@ -13020,6 +13033,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Lúb Uirlisí faoi Úinéireacht an Fhreastalaí", "description": "Lean ar aghaidh le glaonna uirlise neamhshruthaithe atá faoi úinéireacht an fhreastalaí go dtí go gcuirfidh an tsamhail freagra ar fáil is féidir leis an gcliant a úsáid." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13654,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Diúltaigh iarratais roimh seoladh nuair nach bhfuil cumais riachtanacha ag an sprioc-mhúnla (radharc, uirlisí, aschur struchtúrtha, fuinneog comhthéacs). Cosnaíonn sé iarrataí aonair díreach-aonair-bhunaithe a sheachann an scagaire comhoiriúlachta sraithe chomhcheangail.", "featureFlagDisableContextWindowChecksDescription": "Léim thar seiceáil fuinneog comhthéacs agus ionchur comhartha uasta OmniRoute d'iarrataí múnla-aonair dhíreacha. Fórsíonn na soláthraithe suasshrutha a dteorainn fíor-fholláin fós. Fanann comhbhrú leideanna agus teorainn aschur comhartha gníomhach.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Leathanach gan aimsiú", diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index 60c163447d..9328e707f7 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "/v1/models પર claude/<provider>/<model> મિરર આઈડીઓનું જાહેરાત કરો જેથી Claude Code ગેટવે મોડલ શોધી કાઢે છે non-Claude મોડલ. ચેતવણી: જ્યારે વૈશ્વિક રીતે સક્રિય કરવામાં આવે ત્યારે તમામ ક્લાયન્ટ માટે કૅટલોગ એન્ટ્રીઓ ડબલ કરે છે.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "પ્રોવાઇડર ડિસ્પેચ માટે પ્રતિ-ટેનન્ટ અનુકૂલનશીલ વર્ચ્યુઅલ એડમિશન લેન સક્ષમ કરો (#9654): એક ટેનન્ટનો બર્સ્ટ હવે બીજા ટેનન્ટને 503 આપતો નથી. OMNIROUTE_CHAT_VIRTUAL_LANES એન્વાયર્નમેન્ટ વેરિયેબલ આ ડેશબોર્ડ સેટિંગ કરતાં વધુ પ્રાધાન્ય ધરાવે છે; ફેરફારો સર્વર પુનઃપ્રારંભ પર અસરકારક થાય છે.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Home", "dashboard": "Dashboard", @@ -10957,6 +10961,9 @@ "strategyLatency": "લેટન્સી-ઓપ્ટિમાઇઝ્ડ", "poolMembersLabel": "પૂલ સભ્યો ({count})", "poolNoMembers": "આ પૂલમાં હજી સુધી કોઈ પ્રોક્સી નથી.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "દૂર કરો", "poolRemoveFailed": "પૂલમાંથી પ્રોક્સી દૂર કરવામાં નિષ્ફળ", "poolAddLabel": "પ્રોક્સી ઉમેરો", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "બાઇટ્સ પહેલેથી જ ક્લાયન્ટ સુધી પહોંચી ગયા પછી પ્રતિસાદની ફરીથી વિનંતી કરવા અને તેને જોડવા માટે સ્ટ્રીમ પુનઃપ્રાપ્તિને મંજૂરી આપો." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "જ્યારે સ્ટ્રીમ કોઈ ઉપયોગી ફ્રેમ મોકલતા પહેલાં બંધ થઈ જાય અને એ જ કનેક્શન પરનો મર્યાદિત પુનઃપ્રયાસ પૂરો થઈ જાય ત્યારે એક વાર સહોદર કનેક્શન પર ફેલઓવર કરો. ઉપયોગી સહોદર કનેક્શન ન હોય તો મૂળ early-EOF ભૂલ પરત કરવામાં આવે છે. બંધ: પુનઃપ્રયાસ પછી early-EOF અંતિમ રહે છે." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models પ્રતિસાદોમાં પ્રદર્શન-અનુકૂળ નામ ફીલ્ડ્સ શામેલ કરો. ફક્ત મોડલ IDs સ્વીકારતા ક્લાયન્ટ્સ માટે આને નિષ્ક્રિય કરો." }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "સ્કિલ્સ સેન્ડબોક્સમાં નેટવર્ક એક્સેસ સક્ષમ કરો." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "જ્યારે લક્ષ્ય મોડેલમાં જરૂરી ક્ષમતાઓ (દૃષ્ટિ, સાધનો, રચિત આઉટપુટ, સંદર્ભ વિન્ડો) નથી ત્યારે વિતરણ પહેલાં વિનંતીઓને નકારી નાખો. કોમ્બો-લેયર સુસંગતતા ફિલ્ટરને બાયપાસ કરતી સીધી એકલ-પ્રદાતા વિનંતિઓને સુરક્ષિત કરે છે.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "પૃષ્ઠ મળ્યું નથી", diff --git a/src/i18n/messages/ha.json b/src/i18n/messages/ha.json index 7f0aa59b2d..c6b9435d2e 100644 --- a/src/i18n/messages/ha.json +++ b/src/i18n/messages/ha.json @@ -10970,7 +10970,10 @@ "poolAddMember": "Ƙara", "poolAddFailed": "An kasa ƙara proxy ɗin zuwa rukuni", "poolSelectProxy": "Zaɓi proxy…", - "poolSaveFailed": "An kasa adana rabon rukuni" + "poolSaveFailed": "An kasa adana rabon rukuni", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family." }, "playground": { "title": "Filin Gwajin Model", @@ -13020,6 +13023,20 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Madaukin Kayan Aiki Mallakar Sabar", "description": "Ci gaba da kiran kayan aikin sabar marasa gudana har sai samfurin ya dawo da amsar da abokin ciniki zai iya amfani da ita." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths.", + "label": "__MISSING__:Retry-After Provenance" + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip.", + "label": "__MISSING__:Protected-Priority Infra Stops as 502" + }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "__MISSING__:Fail over once to a sibling connection when a stream closes before emitting any useful frame and the bounded same-connection retry is spent. With no usable sibling, the original early-EOF error is returned. Off: early-EOF stays terminal after the retry." } } }, @@ -14143,5 +14160,10 @@ "partnerLinkNote": "Hanyar haɗin abokin hulɗa", "dismissAriaLabel": "Yi watsi" }, - "featureFlagOmnirouteDisableThinkingLevelVariantsDescription": "Kashe samar da nau'ikan matakan tunani (misali -low, -medium, -high) a cikin kundin /v1/models." + "featureFlagOmnirouteDisableThinkingLevelVariantsDescription": "Kashe samar da nau'ikan matakan tunani (misali -low, -medium, -high) a cikin kundin /v1/models.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "__MISSING__:Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "__MISSING__:Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy." } diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index 9db4315147..bcd41f8dc9 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "פרסם את מזהי המראה של claude/<provider>/<model> ב-/v1/models כך שרשימות גילוי המודלים של Claude Code יכללו מודלים שאינם של Claude. אזהרה: מכפיל את רשומות הקטלוג עבור כל הלקוחות כאשר זה מופעל באופן גלובלי.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "הפעל נתיבי קבלה וירטואליים אדפטיביים לכל דייר (tenant) עבור שליחת ספקים (#9654): פרץ עומס של דייר אחד כבר לא מחזיר 503 לדייר אחר. משתנה הסביבה OMNIROUTE_CHAT_VIRTUAL_LANES גובר על הגדרה זו בלוח הבקרה; השינויים נכנסים לתוקף לאחר הפעלת השרת מחדש.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "בית", "dashboard": "לוח מחוונים", @@ -10957,6 +10961,9 @@ "strategyLatency": "מותאם השהיה", "poolMembersLabel": "חברי המאגר ({count})", "poolNoMembers": "אין עדיין שרתי פרוקסי במאגר זה.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "הסר", "poolRemoveFailed": "הסרת הפרוקסי מהמאגר נכשלה", "poolAddLabel": "הוסף פרוקסי", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "התרת שחזור זרם כדי לבקש את התגובה מחדש ולחבר אותה לאחר שביתים כבר הגיעו ללקוח." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "מעבר חד-פעמי לחיבור אח כאשר זרם נסגר לפני ששלח מסגרת שימושית כלשהי והניסיון החוזר המוגבל באותו חיבור מוצה. אם אין חיבור אח שמיש, מוחזרת שגיאת early-EOF המקורית. כבוי: early-EOF נשאר סופי לאחר הניסיון החוזר." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "הכללת שדות שם ידידותיים לתצוגה בתגובות של /v1/models. השבת זאת עבור לקוחות המקבלים מזהי מודל בלבד." }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "הפעלת גישה לרשת בארגז החול של המיומנויות." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "דחה בקשות לפני שליחה כאשר המודל המטרה חסר יכולות נדרשות (חזון, כלים, פלט מובנה, חלון הקשר). מגן על בקשות ישירות מספק אחד שעוקפות את מסנן ההתאמה של שכבת הקומבו.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "העמוד לא נמצא", diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index e9670d7747..5f0e06fdf5 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "/v1/models पर claude/<provider>/<model> मिरर आईडी का विज्ञापन करें ताकि Claude Code गेटवे मॉडल खोज सूची में गैर-Claude मॉडल शामिल हो सकें। चेतावनी: जब वैश्विक रूप से सक्षम किया जाता है तो सभी ग्राहकों के लिए कैटलॉग प्रविष्टियों को डबल करता है।", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "प्रदाता डिस्पैच के लिए प्रति-टेनेंट अनुकूली वर्चुअल एडमिशन लेन सक्षम करें (#9654): एक टेनेंट का बर्स्ट अब दूसरे टेनेंट को 503 नहीं देता। OMNIROUTE_CHAT_VIRTUAL_LANES पर्यावरण चर इस डैशबोर्ड सेटिंग पर प्राथमिकता रखता है; परिवर्तन सर्वर पुनः आरंभ पर प्रभावी होते हैं।", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "घर", "dashboard": "डैशबोर्ड", @@ -10957,6 +10961,9 @@ "strategyLatency": "लेटेंसी-ऑप्टिमाइज़्ड", "poolMembersLabel": "पूल सदस्य ({count})", "poolNoMembers": "इस पूल में अभी तक कोई प्रॉक्सी नहीं है।", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "हटाएं", "poolRemoveFailed": "पूल से प्रॉक्सी को हटाने में विफल", "poolAddLabel": "एक प्रॉक्सी जोड़ें", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "बाइट्स पहले से ही क्लाइंट तक पहुँचने के बाद प्रतिक्रिया का फिर से अनुरोध करने और उसे जोड़ने के लिए स्ट्रीम रिकवरी की अनुमति दें।" }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "जब स्ट्रीम कोई उपयोगी फ़्रेम भेजे बिना बंद हो जाए और उसी कनेक्शन पर सीमित पुनःप्रयास समाप्त हो जाए, तब एक बार सहोदर कनेक्शन पर फ़ेलओवर करें। उपयोग योग्य सहोदर कनेक्शन न होने पर मूल early-EOF त्रुटि लौटाई जाती है। बंद: पुनःप्रयास के बाद early-EOF अंतिम रहता है।" + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models प्रतिक्रियाओं में प्रदर्शन-अनुकूल नाम फ़ील्ड शामिल करें। उन क्लाइंट्स के लिए इसे अक्षम करें जो केवल मॉडल ID स्वीकार करते हैं।" }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "स्किल्स सैंडबॉक्स में नेटवर्क एक्सेस सक्षम करें।" + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "डिस्पैच से पहले अनुरोधों को अस्वीकार करें जब लक्षित मॉडल आवश्यक क्षमताओं (दृष्टि, उपकरण, संरचित आउटपुट, संदर्भ विंडो) से रहित हो। यह सीधे एकल-प्रदाता अनुरोधों की रक्षा करता है जो कॉम्बो-लेयर संगतता फ़िल्टर को बायपास करते हैं।", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "पृष्ठ नहीं मिला", diff --git a/src/i18n/messages/hr.json b/src/i18n/messages/hr.json index ead5ea6cab..e2672a03cd 100644 --- a/src/i18n/messages/hr.json +++ b/src/i18n/messages/hr.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Oglašavaj claude/<provider>/<model> zrcalne identifikatore na /v1/models kako bi otkrivanje modela Claude Code gatewaya prikazivalo modele koji nisu Claude. Upozorenje: udvostručuje unose kataloga za sve klijente kada je globalno omogućeno.", "featureFlagNoThinkingAliasEnabledDescription": "Glavni prekidač za no-think/<provider>/<model> pseudonime gatewaya. Uključeno (zadano): /v1/models oglašava varijantu bez razmišljanja za svaki prihvatljivi Claude model sposoban za razmišljanje, a no-think/ identifikator poslan u zahtjevu razrješava se natrag na pravi model s potisnutim zaključivanjem. Isključeno: nijedna varijanta se ne oglašava i no-think/ identifikator tretira se kao bilo koji drugi nepoznati identifikator modela. Opt-in/opt-out ModelSpec.noThinkingAlias po modelu i dalje se primjenjuje dok je ovo uključeno.", "featureFlagChatVirtualLanesEnabledDescription": "Omogući adaptivne virtualne prijamne trake po korisniku za raspodjelu pružatelja (#9654): opterećenje jednog korisnika više neće uzrokovati 503 grešku drugome. Varijabla okoline OMNIROUTE_CHAT_VIRTUAL_LANES ima prednost nad ovim nadjačavanjem nadzorne ploče; promjene stupaju na snagu pri ponovnom pokretanju poslužitelja.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Početna", "dashboard": "Nadzorna ploča", @@ -10964,6 +10968,9 @@ "strategyLatency": "Optimizirano prema latenciji", "poolMembersLabel": "Članovi poola ({count})", "poolNoMembers": "Još nema proxyja u ovom poolu.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Ukloni", "poolRemoveFailed": "Uklanjanje proxyja iz poola nije uspjelo", "poolAddLabel": "Dodaj proxy", @@ -12984,6 +12991,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Dopusti oporavku toka da ponovo zatraži odgovor i spoji ga nakon što su bajtovi već stigli do klijenta." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Jednom se prebaci na srodnu vezu kada se tok zatvori prije slanja ijednog korisnog okvira, a ograničeni ponovni pokušaj na istoj vezi je iskorišten. Ako nema upotrebljive srodne veze, vraća se izvorna early-EOF pogreška. Isključeno: early-EOF ostaje konačan nakon ponovnog pokušaja." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Uključi polja s imenima prilagođenim za prikaz u odgovorima /v1/models. Onemogući ovo za klijente koji prihvaćaju samo ID-ove modela." }, @@ -13020,6 +13033,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Petlja alata pod nadzorom poslužitelja", "description": "Nastavi s nestrujnim pozivima alata pod nadzorom poslužitelja sve dok model ne vrati odgovor koji klijent može upotrijebiti." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13654,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Odbij zahtjeve prije otpreme kada ciljnom modelu nedostaju potrebne mogućnosti (vizija, alati, strukturirani izlaz, kontekstni prozor). Štiti izravne zahtjeve prema jednom pružatelju koji zaobilaze filtar kompatibilnosti combo-sloja.", "featureFlagDisableContextWindowChecksDescription": "Preskoči OmniRoute-ovu lokalnu provjeru kontekstnog prozora i maksimalnog ulaznog tokena za izravne zahtjeve prema jednom modelu. Uzlazni pružatelji i dalje primjenjuju stvarna ograničenja. Kompresija upita i ograničenja izlaznih tokena ostaju aktivni.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Stranica nije pronađena", diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index 39dce97236..4a4478aa56 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Hirdesse a claude/<provider>/<model> tükör azonosítókat a /v1/models-on, hogy a Claude Code átjáró modell felfedezése nem Claude modelleket is listázzon. Figyelmeztetés: globális engedélyezés esetén megduplázza a katalógus bejegyzéseket minden kliens számára.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Tegye lehetővé a bérlőnkénti adaptív virtuális beléptetősávokat a szolgáltatók felé történő továbbításhoz (#9654): az egyik bérlő kiugró terhelése már nem okoz 503-as hibát egy másiknál. Az OMNIROUTE_CHAT_VIRTUAL_LANES környezeti változó felülírja ezt a vezérlőpult-beállítást; a változtatások a szerver újraindításakor lépnek életbe.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Otthon", "dashboard": "Irányítópult", @@ -10957,6 +10961,9 @@ "strategyLatency": "Késleltetésre optimalizált", "poolMembersLabel": "Készlet tagjai ({count})", "poolNoMembers": "Még nincsenek proxyk ebben a készletben.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Eltávolítás", "poolRemoveFailed": "Nem sikerült eltávolítani a proxyt a készletből", "poolAddLabel": "Proxy hozzáadása", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Az adatfolyam-helyreállítás engedélyezése a válasz újbóli lekérésére és összefűzésére, miután a bájtok már elérték az ügyfelet." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Egyszeri átállás egy testvérkapcsolatra, ha az adatfolyam még az első hasznos keret előtt lezárul, és az ugyanazon a kapcsolaton végzett korlátozott újrapróbálkozás elfogyott. Használható testvérkapcsolat hiányában az eredeti early-EOF hiba kerül visszaadásra. Kikapcsolva: az early-EOF az újrapróbálkozás után végleges marad." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Megjelenítésbarát névmezők szerepeltetése a /v1/models válaszokban. Tiltsa le ezt azon ügyfelek esetében, amelyek csak modell-azonosítókat fogadnak el." }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Hálózati hozzáférés engedélyezése a készségek homokozójában." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Elutasítja a kéréseket a kiszállítás előtt, amikor a célmodell hiányzik a szükséges képességekből (látás, eszközök, strukturált kimenet, kontextusablak). Védi a közvetlen, egy szolgáltatótól érkező kéréseket, amelyek megkerülik a kombinált réteg kompatibilitási szűrőt.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Az oldal nem található", diff --git a/src/i18n/messages/hy.json b/src/i18n/messages/hy.json index 001f14aaec..2be3692da6 100644 --- a/src/i18n/messages/hy.json +++ b/src/i18n/messages/hy.json @@ -10970,7 +10970,10 @@ "poolAddMember": "Ավելացնել", "poolAddFailed": "Չհաջողվեց պրոքսին ավելացնել պուլում", "poolSelectProxy": "Ընտրեք պրոքսի…", - "poolSaveFailed": "Չհաջողվեց պահպանել պուլին վերագրումը" + "poolSaveFailed": "Չհաջողվեց պահպանել պուլին վերագրումը", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family." }, "playground": { "title": "Մոդելների փորձարկման հարթակ", @@ -13020,6 +13023,20 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Սերվերի կողմից կառավարվող գործիքային ցիկլ", "description": "Շարունակել սերվերի կողմից կառավարվող ոչ հոսքային գործիքների կանչերը, մինչև մոդելը վերադարձնի հաճախորդի համար օգտագործելի պատասխան։" + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths.", + "label": "__MISSING__:Retry-After Provenance" + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip.", + "label": "__MISSING__:Protected-Priority Infra Stops as 502" + }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "__MISSING__:Fail over once to a sibling connection when a stream closes before emitting any useful frame and the bounded same-connection retry is spent. With no usable sibling, the original early-EOF error is returned. Off: early-EOF stays terminal after the retry." } } }, @@ -14143,5 +14160,10 @@ "partnerLinkNote": "Գործընկերային հղում", "dismissAriaLabel": "Փակել" }, - "featureFlagOmnirouteDisableThinkingLevelVariantsDescription": "Անջատել մտածողության մակարդակի տարբերակների (օր.՝ -low, -medium, -high) ստեղծումը /v1/models կատալոգում։" + "featureFlagOmnirouteDisableThinkingLevelVariantsDescription": "Անջատել մտածողության մակարդակի տարբերակների (օր.՝ -low, -medium, -high) ստեղծումը /v1/models կատալոգում։", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "__MISSING__:Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "__MISSING__:Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy." } diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index 5f9a9f7459..0fc0cc2302 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Iklankan claude/<provider>/<model> mirror ids di /v1/models sehingga daftar penemuan model gateway Claude Code mencantumkan model non-Claude. Peringatan: menggandakan entri katalog untuk semua klien saat diaktifkan secara global.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Aktifkan jalur penerimaan virtual adaptif per-tenant untuk pengiriman penyedia (#9654): lonjakan satu tenant tidak lagi mengembalikan 503 ke tenant lain. Variabel lingkungan OMNIROUTE_CHAT_VIRTUAL_LANES menang atas pengaturan dasbor ini; perubahan berlaku setelah server dimulai ulang.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Rumah", "dashboard": "Dasbor", @@ -10957,6 +10961,9 @@ "strategyLatency": "Dioptimalkan untuk latensi", "poolMembersLabel": "Anggota pool ({count})", "poolNoMembers": "Belum ada proksi di pool ini.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Hapus", "poolRemoveFailed": "Gagal menghapus proksi dari pool", "poolAddLabel": "Tambahkan proksi", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Izinkan pemulihan streaming untuk meminta respons kembali dan menggabungkannya setelah byte telah mencapai klien." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Alihkan sekali ke koneksi saudara ketika stream tertutup sebelum mengirim frame yang berguna dan percobaan ulang terbatas pada koneksi yang sama sudah habis. Jika tidak ada koneksi saudara yang dapat digunakan, error early-EOF asli dikembalikan. Nonaktif: early-EOF tetap final setelah percobaan ulang." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Sertakan bidang nama yang mudah dibaca dalam respons /v1/models. Nonaktifkan ini untuk klien yang hanya menerima ID model." }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Aktifkan akses jaringan di sandbox keterampilan." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Tolak permintaan sebelum pengiriman ketika model target tidak memiliki kemampuan yang diperlukan (visi, alat, output terstruktur, jendela konteks). Melindungi permintaan penyedia tunggal langsung yang melewati filter kompatibilitas lapisan kombinasi.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Halaman tidak ditemukan", diff --git a/src/i18n/messages/ig.json b/src/i18n/messages/ig.json index 1190e530c1..7ce2832f65 100644 --- a/src/i18n/messages/ig.json +++ b/src/i18n/messages/ig.json @@ -10970,7 +10970,10 @@ "poolAddMember": "Tinye", "poolAddFailed": "Ịtinye proxy ahụ na pool dara", "poolSelectProxy": "Họrọ proxy…", - "poolSaveFailed": "Ịchekwa nkesa pool dara" + "poolSaveFailed": "Ịchekwa nkesa pool dara", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family." }, "playground": { "title": "Ebe Nnwale Model", @@ -13020,6 +13023,20 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Okirikiri Ngwaọrụ nke Sava Na-achịkwa", "description": "Gaa n'ihu na oku ngwaọrụ sava na-achịkwa ndị na-abụghị streaming ruo mgbe model weghachiri nzaghachi onye ahịa nwere ike iji." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths.", + "label": "__MISSING__:Retry-After Provenance" + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip.", + "label": "__MISSING__:Protected-Priority Infra Stops as 502" + }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "__MISSING__:Fail over once to a sibling connection when a stream closes before emitting any useful frame and the bounded same-connection retry is spent. With no usable sibling, the original early-EOF error is returned. Off: early-EOF stays terminal after the retry." } } }, @@ -14143,5 +14160,10 @@ "partnerLinkNote": "Njikọ onye mmekọ", "dismissAriaLabel": "Wepụ" }, - "featureFlagOmnirouteDisableThinkingLevelVariantsDescription": "Gbanyụọ imepụta ụdị ọkwa echiche dị iche iche (dịka -low, -medium, -high) na katalọgụ /v1/models." + "featureFlagOmnirouteDisableThinkingLevelVariantsDescription": "Gbanyụọ imepụta ụdị ọkwa echiche dị iche iche (dịka -low, -medium, -high) na katalọgụ /v1/models.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "__MISSING__:Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "__MISSING__:Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy." } diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index bc41744b5b..21c08e71ad 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Mostra gli id specchio claude/<provider>/<model> su /v1/models in modo che la scoperta dei modelli gateway di Claude Code elenchi i modelli non-Claude. Attenzione: raddoppia le voci nel catalogo per tutti i client quando abilitato globalmente.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Attiva corsie di ammissione virtuali adattive per tenant per l'invio ai provider (#9654): il picco di un tenant non restituisce più 503 a un altro. La variabile d'ambiente OMNIROUTE_CHAT_VIRTUAL_LANES ha la precedenza su questa impostazione della dashboard; le modifiche hanno effetto al riavvio del server.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Casa", "dashboard": "Pannello di controllo", @@ -10957,6 +10961,9 @@ "strategyLatency": "Ottimizzato per la latenza", "poolMembersLabel": "Membri del pool ({count})", "poolNoMembers": "Nessun proxy ancora presente in questo pool.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Rimuovi", "poolRemoveFailed": "Impossibile rimuovere il proxy dal pool", "poolAddLabel": "Aggiungi un proxy", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Consenti al ripristino del flusso di richiedere nuovamente la risposta e ricongiungerla dopo che i byte hanno già raggiunto il client." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Esegui un solo failover verso una connessione sorella quando uno stream si chiude prima di emettere qualsiasi frame utile e il nuovo tentativo limitato sulla stessa connessione è esaurito. Senza una connessione sorella utilizzabile viene restituito l'errore early-EOF originale. Disattivato: early-EOF resta terminale dopo il nuovo tentativo." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Includi campi con nomi descrittivi nelle risposte di /v1/models. Disabilita questa opzione per i client che accettano solo ID modello." }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Abilita l'accesso alla rete nella sandbox delle skill." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Rifiuta le richieste prima della spedizione quando il modello di destinazione manca delle capacità richieste (visione, strumenti, output strutturato, finestra di contesto). Protegge le richieste dirette a singolo fornitore che bypassano il filtro di compatibilità del livello combinato.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Pagina non trovata", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index c18f7d9ffa..9c888b1654 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "/v1/models で Claude Code ゲートウェイのモデル発見リストに非 Claude モデルを表示するために、claude/<provider>/<model> ミラー ID を広告します。警告: グローバルに有効にすると、すべてのクライアントのカタログエントリが重複します。", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "プロバイダーへのディスパッチ用に、テナントごとの適応型仮想受付レーンを有効にします(#9654):あるテナントのバーストが他のテナントに503を返さなくなります。OMNIROUTE_CHAT_VIRTUAL_LANES環境変数はこのダッシュボード設定より優先されます。変更はサーバー再起動時に反映されます。", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "ホーム", "dashboard": "ダッシュボード", @@ -10957,6 +10961,9 @@ "strategyLatency": "レイテンシー最適化", "poolMembersLabel": "プールメンバー ({count})", "poolNoMembers": "このプールにはまだプロキシがありません。", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "削除", "poolRemoveFailed": "プールからプロキシを削除できませんでした", "poolAddLabel": "プロキシを追加", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Allow stream recovery to request the response again and stitch it after bytes have already reached the client." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "ストリームが有用なフレームを送出する前に閉じ、同一接続での上限付き再試行も使い切った場合に、兄弟接続へ一度だけフェイルオーバーします。使用可能な兄弟接続がない場合は、元の early-EOF エラーを返します。オフ: 再試行後の early-EOF はそのまま終了扱いです。" + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Include display-friendly name fields in /v1/models responses. Disable this for clients that accept model IDs only." }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Enable network access in the skills sandbox." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "ディスパッチ前にリクエストを拒否します。ターゲットモデルに必要な機能(ビジョン、ツール、構造化出力、コンテキストウィンドウ)が欠けている場合。コンボレイヤーの互換性フィルターをバイパスする直接の単一プロバイダーリクエストを保護します。", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "ページが見つかりません", diff --git a/src/i18n/messages/ka.json b/src/i18n/messages/ka.json index c47cb2f420..a8f036cb29 100644 --- a/src/i18n/messages/ka.json +++ b/src/i18n/messages/ka.json @@ -10970,7 +10970,10 @@ "poolAddMember": "დამატება", "poolAddFailed": "პროქსის პულში დამატება ვერ მოხერხდა", "poolSelectProxy": "აირჩიეთ პროქსი…", - "poolSaveFailed": "პულზე მინიჭების შენახვა ვერ მოხერხდა" + "poolSaveFailed": "პულზე მინიჭების შენახვა ვერ მოხერხდა", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family." }, "playground": { "title": "მოდელების სატესტო გარემო", @@ -13020,6 +13023,20 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "სერვერის მიერ მართული ხელსაწყოების ციკლი", "description": "გააგრძელეთ სერვერის მიერ მართული ხელსაწყოების არასტრიმინგული გამოძახებები, სანამ მოდელი კლიენტისთვის გამოსაყენებელ პასუხს არ დააბრუნებს." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths.", + "label": "__MISSING__:Retry-After Provenance" + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip.", + "label": "__MISSING__:Protected-Priority Infra Stops as 502" + }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "__MISSING__:Fail over once to a sibling connection when a stream closes before emitting any useful frame and the bounded same-connection retry is spent. With no usable sibling, the original early-EOF error is returned. Off: early-EOF stays terminal after the retry." } } }, @@ -14143,5 +14160,10 @@ "partnerLinkNote": "პარტნიორის ბმული", "dismissAriaLabel": "დახურვა" }, - "featureFlagOmnirouteDisableThinkingLevelVariantsDescription": "გამორთეთ აზროვნების დონის ვარიანტების (მაგ. -low, -medium, -high) გენერირება /v1/models კატალოგში." + "featureFlagOmnirouteDisableThinkingLevelVariantsDescription": "გამორთეთ აზროვნების დონის ვარიანტების (მაგ. -low, -medium, -high) გენერირება /v1/models კატალოგში.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "__MISSING__:Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "__MISSING__:Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy." } diff --git a/src/i18n/messages/km.json b/src/i18n/messages/km.json index 458baf9e35..34960ce503 100644 --- a/src/i18n/messages/km.json +++ b/src/i18n/messages/km.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "ផ្សព្វផ្សាយ mirror ids របស់ claude/<provider>/<model> នៅលើ /v1/models ដើម្បីឱ្យការស្វែងរកម៉ូដែលតាម gateway របស់ Claude Code រាយបញ្ជីម៉ូដែលដែលមិនមែនជា Claude។ ការព្រមាន៖ វានឹងបង្កើនធាតុក្នុងកាតាឡុកទ្វេដងសម្រាប់ client ទាំងអស់ នៅពេលបើកជាសកល។", "featureFlagNoThinkingAliasEnabledDescription": "កុងតាក់មេសម្រាប់ gateway aliases របស់ no-think/<provider>/<model>។ បើក (លំនាំដើម)៖ /v1/models ផ្សព្វផ្សាយវ៉ារ្យ៉ង់មិនគិតសម្រាប់គ្រប់ម៉ូដែល Claude ដែលមានសមត្ថភាពគិត និងមានលក្ខណៈសម្បត្តិគ្រប់គ្រាន់ ហើយ no-think/ id ដែលបានផ្ញើក្នុងសំណើ នឹងត្រូវដោះស្រាយត្រឡប់ទៅម៉ូដែលពិត ដោយបិទការវែកញែក។ បិទ៖ គ្មានវ៉ារ្យ៉ង់ណាមួយត្រូវបានផ្សព្វផ្សាយទេ ហើយ no-think/ id ត្រូវបានចាត់ទុកដូចជា model id មិនស្គាល់ផ្សេងទៀត។ ការជ្រើសរើសបើក/បិទ ModelSpec.noThinkingAlias សម្រាប់ម៉ូដែលនីមួយៗ នៅតែអនុវត្ត ខណៈដែលវាត្រូវបានបើក។", "featureFlagChatVirtualLanesEnabledDescription": "បើកផ្លូវចូលនិម្មិតដែលសម្របខ្លួនតាម tenant នីមួយៗ សម្រាប់ការបញ្ជូនទៅ provider (#9654)៖ ការកើនឡើងខ្លាំងភ្លាមៗរបស់ tenant មួយ នឹងលែងបណ្ដាលឱ្យ tenant មួយទៀតទទួល 503។ env var OMNIROUTE_CHAT_VIRTUAL_LANES មានអាទិភាពលើការកំណត់ជំនួសពី dashboard នេះ ហើយការផ្លាស់ប្ដូរនឹងមានប្រសិទ្ធភាពនៅពេលចាប់ផ្ដើម server ឡើងវិញ។", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "ទំព័រដើម", "dashboard": "ផ្ទាំងគ្រប់គ្រង", @@ -10964,6 +10968,9 @@ "strategyLatency": "បង្កើនប្រសិទ្ធភាពតាមភាពយឺតយ៉ាវ", "poolMembersLabel": "សមាជិកក្រុម ({count})", "poolNoMembers": "មិនទាន់មានប្រូកស៊ីនៅក្នុងក្រុមនេះទេ។", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "ដកចេញ", "poolRemoveFailed": "បានបរាជ័យក្នុងការដកប្រូកស៊ីចេញពីក្រុម", "poolAddLabel": "បន្ថែមប្រូកស៊ី", @@ -12984,6 +12991,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "អនុញ្ញាតឱ្យការសង្គ្រោះ stream ស្នើសុំ response ម្តងទៀត និងភ្ជាប់វាបន្ត បន្ទាប់ពី bytes បានទៅដល់ client រួចហើយ។" }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "ប្តូរទៅការតភ្ជាប់បងប្អូនម្តងគត់ នៅពេល stream បិទមុនពេលបញ្ចេញ frame ដែលមានប្រយោជន៍ណាមួយ ហើយការព្យាយាមឡើងវិញដែលមានកំណត់លើការតភ្ជាប់ដដែលត្រូវបានប្រើអស់ហើយ។ បើគ្មានការតភ្ជាប់បងប្អូនដែលអាចប្រើបាន កំហុស early-EOF ដើមនឹងត្រូវបានត្រឡប់មកវិញ។ បិទ៖ early-EOF នៅតែជាចុងក្រោយបន្ទាប់ពីការព្យាយាមឡើងវិញ។" + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "រួមបញ្ចូលវាលឈ្មោះដែលងាយស្រួលបង្ហាញក្នុង response របស់ /v1/models។ បិទវាសម្រាប់ clients ដែលទទួលយកតែ model IDs ប៉ុណ្ណោះ។" }, @@ -13020,6 +13033,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "រង្វិលជុំ Tool ដែលគ្រប់គ្រងដោយ Server", "description": "បន្តការហៅ tool ដែលគ្រប់គ្រងដោយ server ដោយមិនប្រើ streaming រហូតដល់ model ត្រឡប់ response ដែល client អាចប្រើបាន។" + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13654,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "បដិសេធសំណើមុនពេលបញ្ជូន នៅពេលម៉ូដែលគោលដៅខ្វះសមត្ថភាពដែលត្រូវការ (ចក្ខុវិស័យ ឧបករណ៍ លទ្ធផលមានរចនាសម្ព័ន្ធ បង្អួចបរិបទ)។ វាការពារសំណើទៅកាន់អ្នកផ្តល់សេវាតែមួយដោយផ្ទាល់ ដែលរំលងតម្រងភាពត្រូវគ្នានៃស្រទាប់បន្សំ។", "featureFlagDisableContextWindowChecksDescription": "រំលងការត្រួតពិនិត្យបង្អួចបរិបទ និងចំនួនថូខិនបញ្ចូលអតិបរមាក្នុងមូលដ្ឋានរបស់ OmniRoute សម្រាប់សំណើទៅកាន់ម៉ូដែលតែមួយដោយផ្ទាល់។ អ្នកផ្តល់សេវាខាងលើនៅតែអនុវត្តដែនកំណត់ជាក់ស្តែងរបស់ពួកគេ។ ការបង្ហាប់ប្រូម និងដែនកំណត់ថូខិនលទ្ធផលនៅតែដំណើរការ។", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "រកមិនឃើញទំព័រ", diff --git a/src/i18n/messages/kn.json b/src/i18n/messages/kn.json index 4f0fc6e096..5e87887aad 100644 --- a/src/i18n/messages/kn.json +++ b/src/i18n/messages/kn.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "claude/<provider>/<model> ಮಿರರ್ ಐಡಿಗಳನ್ನು /v1/models ನಲ್ಲಿ ಪ್ರಕಟಿಸಿ, ಆದ್ದರಿಂದ Claude Code gateway ಮಾಡೆಲ್ ಆವಿಷ್ಕಾರವು non-Claude ಮಾಡೆಲ್ಗಳನ್ನು ಪಟ್ಟಿಮಾಡುತ್ತದೆ. ಎಚ್ಚರಿಕೆ: ಜಾಗತಿಕವಾಗಿ ಸಕ್ರಿಯಗೊಳಿಸಿದಾಗ ಎಲ್ಲಾ ಕ್ಲೈಂಟ್ಗಳಿಗೆ ಕ್ಯಾಟಲಾಗ್ ಎಂಟ್ರಿಗಳನ್ನು ದ್ವಿಗುಣಗೊಳಿಸುತ್ತದೆ.", "featureFlagNoThinkingAliasEnabledDescription": "no-think/<provider>/<model> gateway ಅಲಿಯಾಸ್ಗಳಿಗಾಗಿ ಮಾಸ್ಟರ್ ಸ್ವಿಚ್. ಆನ್ (ಡೀಫಾಲ್ಟ್): /v1/models ಪ್ರತಿ ಅರ್ಹ ಥಿಂಕಿಂಗ್-ಸಾಮರ್ಥ್ಯ Claude ಮಾಡೆಲ್ಗಾಗಿ ನೋ-ಥಿಂಕಿಂಗ್ ವೇರಿಯಂಟ್ ಅನ್ನು ಪ್ರಕಟಿಸುತ್ತದೆ, ಮತ್ತು ವಿನಂತಿಯಲ್ಲಿ ಕಳುಹಿಸಿದ no-think/ ಐಡಿಯು ಕಾರಣವನ್ನು ಅಡಗಿಸಿ ನಿಜವಾದ ಮಾಡೆಲ್ಗೆ ಪರಿಹರಿಸುತ್ತದೆ. ಆಫ್: ಯಾವುದೇ ವೇರಿಯಂಟ್ಗಳನ್ನು ಪ್ರಕಟಿಸಲಾಗುವುದಿಲ್ಲ ಮತ್ತು no-think/ ಐಡಿಯನ್ನು ಯಾವುದೇ ಅಜ್ಞಾತ ಮಾಡೆಲ್ ಐಡಿಯಂತೆ ಪರಿಗಣಿಸಲಾಗುತ್ತದೆ. ಈ ಆನ್ ಆಗಿರುವಾಗ ಪ್ರತಿ-ಮಾಡೆಲ್ ModelSpec.noThinkingAlias ಆಯ್ಕೆ-ಆನ್/ಆಫ್ ಇನ್ನೂ ಅನ್ವಯಿಸುತ್ತದೆ.", "featureFlagChatVirtualLanesEnabledDescription": "ಪ್ರೊವೈಡರ್ ಡಿಸ್ಪ್ಯಾಚ್ ಗಾಗಿ ಪ್ರತಿ-ಟೆನಂಟ್ ಅಡಾಪ್ಟಿವ್ ವರ್ಚುವಲ್ ಅಡ್ಮಿಷನ್ ಲೇನ್ಗಳನ್ನು ಸಕ್ರಿಯಗೊಳಿಸಿ (#9654): ಒಂದು ಟೆನಂಟ್ನ ಬರ್ಸ್ಟ್ ಇನ್ನು ಮುಂದೆ ಮತ್ತೊಂದನ್ನು 503 ಮಾಡುವುದಿಲ್ಲ. OMNIROUTE_CHAT_VIRTUAL_LANES ಎನ್ವಿ ವೇರಿಯಬಲ್ ಈ ಡ್ಯಾಶ್ಬೋರ್ಡ್ ಓವರ್ರೈಡ್ ಮೇಲೆ ಗೆಲ್ಲುತ್ತದೆ; ಬದಲಾವಣೆಗಳು ಸರ್ವರ್ ರೀಸ್ಟಾರ್ಟ್ ನಲ್ಲಿ ಜಾರಿಗೆ ಬರುತ್ತವೆ.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "ಹೋಮ್", "dashboard": "ಡ್ಯಾಶ್ಬೋರ್ಡ್", @@ -10964,6 +10968,9 @@ "strategyLatency": "ಲೇಟೆನ್ಸಿ-ಆಪ್ಟಿಮೈಸ್ಡ್", "poolMembersLabel": "ಪೂಲ್ ಸದಸ್ಯರು ({count})", "poolNoMembers": "ಈ ಪೂಲ್ನಲ್ಲಿ ಇನ್ನೂ ಯಾವುದೇ ಪ್ರಾಕ್ಸಿಗಳಿಲ್ಲ.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "ತೆಗೆದುಹಾಕಿ", "poolRemoveFailed": "ಪೂಲ್ನಿಂದ ಪ್ರಾಕ್ಸಿಯನ್ನು ತೆಗೆದುಹಾಕಲು ವಿಫಲವಾಗಿದೆ", "poolAddLabel": "ಪ್ರಾಕ್ಸಿಯನ್ನು ಸೇರಿಸಿ", @@ -12984,6 +12991,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "ಬೈಟ್ಗಳು ಈಗಾಗಲೇ ಕ್ಲೈಂಟ್ ಅನ್ನು ತಲುಪಿದ ನಂತರವೂ ಪ್ರತಿಕ್ರಿಯೆಯನ್ನು ಮತ್ತೊಮ್ಮೆ ವಿನಂತಿಸಿ, ಅದನ್ನು ಜೋಡಿಸಲು ಸ್ಟ್ರೀಮ್ ಮರುಪಡೆಯುವಿಕೆಗೆ ಅನುಮತಿಸಿ." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "ಸ್ಟ್ರೀಮ್ ಯಾವುದೇ ಉಪಯುಕ್ತ ಫ್ರೇಮ್ ಕಳುಹಿಸುವ ಮೊದಲೇ ಮುಚ್ಚಿದಾಗ ಮತ್ತು ಅದೇ ಸಂಪರ್ಕದ ಸೀಮಿತ ಮರುಪ್ರಯತ್ನ ಮುಗಿದಿದ್ದಾಗ ಒಮ್ಮೆ ಸಹೋದರ ಸಂಪರ್ಕಕ್ಕೆ ಫೇಲ್‌ಓವರ್ ಮಾಡಿ. ಬಳಸಬಹುದಾದ ಸಹೋದರ ಸಂಪರ್ಕ ಇಲ್ಲದಿದ್ದರೆ ಮೂಲ early-EOF ದೋಷವನ್ನು ಹಿಂತಿರುಗಿಸಲಾಗುತ್ತದೆ. ಆಫ್: ಮರುಪ್ರಯತ್ನದ ನಂತರ early-EOF ಅಂತಿಮವಾಗಿಯೇ ಉಳಿಯುತ್ತದೆ." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models ಪ್ರತಿಕ್ರಿಯೆಗಳಲ್ಲಿ ಪ್ರದರ್ಶನಕ್ಕೆ ಸೂಕ್ತವಾದ ಹೆಸರು ಕ್ಷೇತ್ರಗಳನ್ನು ಸೇರಿಸಿ. ಕೇವಲ ಮಾದರಿ IDಗಳನ್ನು ಸ್ವೀಕರಿಸುವ ಕ್ಲೈಂಟ್ಗಳಿಗಾಗಿ ಇದನ್ನು ನಿಷ್ಕ್ರಿಯಗೊಳಿಸಿ." }, @@ -13020,6 +13033,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "ಸರ್ವರ್-ಸ್ವಾಮ್ಯದ ಪರಿಕರ ಲೂಪ್", "description": "ಮಾದರಿಯು ಕ್ಲೈಂಟ್ಗೆ ಬಳಸಬಹುದಾದ ಪ್ರತಿಕ್ರಿಯೆಯನ್ನು ಹಿಂದಿರುಗಿಸುವವರೆಗೆ ಸ್ಟ್ರೀಮಿಂಗ್ ಅಲ್ಲದ ಸರ್ವರ್-ಸ್ವಾಮ್ಯದ ಪರಿಕರ ಕರೆಗಳನ್ನು ಮುಂದುವರಿಸಿ." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13654,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "ಗುರಿ ಮಾದರಿಯಲ್ಲಿ ಅಗತ್ಯ ಸಾಮರ್ಥ್ಯಗಳು (ದೃಷ್ಟಿ, ಪರಿಕರಗಳು, ರಚನಾತ್ಮಕ ಔಟ್ಪುಟ್, ಸಂದರ್ಭ ವಿಂಡೋ) ಇಲ್ಲದಿದ್ದಾಗ ರವಾನಿಸುವ ಮೊದಲು ವಿನಂತಿಗಳನ್ನು ತಿರಸ್ಕರಿಸಿ. ಇದು ಕಾಂಬೊ-ಲೇಯರ್ ಹೊಂದಾಣಿಕೆ ಫಿಲ್ಟರ್ ಅನ್ನು ತಪ್ಪಿಸುವ ನೇರ ಏಕ-ಪೂರೈಕೆದಾರ ವಿನಂತಿಗಳನ್ನು ರಕ್ಷಿಸುತ್ತದೆ.", "featureFlagDisableContextWindowChecksDescription": "ನೇರ ಏಕ-ಮಾದರಿ ವಿನಂತಿಗಳಿಗಾಗಿ OmniRoute ನ ಸ್ಥಳೀಯ ಸಂದರ್ಭ-ವಿಂಡೋ ಮತ್ತು ಗರಿಷ್ಠ-ಇನ್ಪುಟ್-ಟೋಕನ್ ಪರಿಶೀಲನೆಯನ್ನು ಬಿಟ್ಟುಬಿಡಿ. ಅಪ್ಸ್ಟ್ರೀಮ್ ಪೂರೈಕೆದಾರರು ತಮ್ಮ ನೈಜ ಮಿತಿಗಳನ್ನು ಇನ್ನೂ ಜಾರಿಗೊಳಿಸುತ್ತಾರೆ. ಪ್ರಾಂಪ್ಟ್ ಸಂಕುಚನ ಮತ್ತು ಔಟ್ಪುಟ್-ಟೋಕನ್ ಮಿತಿಗಳು ಸಕ್ರಿಯವಾಗಿಯೇ ಇರುತ್ತವೆ.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "ಪುಟ ಕಂಡುಬಂದಿಲ್ಲ", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 302795b997..9ba9dc3094 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "/v1/models에서 Claude Code 게이트웨이 모델 검색 목록에 비Claude 모델이 포함되도록 claude/<provider>/<model> 미러 ID를 광고합니다. 경고: 전역적으로 활성화하면 모든 클라이언트에 대해 카탈로그 항목이 두 배로 증가합니다.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "공급자 디스패치를 위해 테넌트별 적응형 가상 승인 레인을 활성화합니다(#9654): 한 테넌트의 폭증이 더 이상 다른 테넌트에 503을 반환하지 않습니다. OMNIROUTE_CHAT_VIRTUAL_LANES 환경 변수가 이 대시보드 설정보다 우선하며, 변경 사항은 서버 재시작 시 적용됩니다.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "홈", "dashboard": "대시보드", @@ -10957,6 +10961,9 @@ "strategyLatency": "지연 시간 최적화", "poolMembersLabel": "풀 멤버 ({count})", "poolNoMembers": "이 풀에 아직 프록시가 없습니다.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "제거", "poolRemoveFailed": "풀에서 프록시를 제거하지 못했습니다", "poolAddLabel": "프록시 추가", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "바이트가 이미 클라이언트에 도달한 후에도 스트림 복구가 응답을 다시 요청하고 이어 붙일 수 있도록 허용합니다." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "스트림이 유용한 프레임을 보내기 전에 닫히고 같은 연결에서의 제한된 재시도도 소진되면 형제 연결로 한 번만 페일오버합니다. 사용할 수 있는 형제 연결이 없으면 원래의 early-EOF 오류를 반환합니다. 끄기: 재시도 후 early-EOF는 그대로 종료로 처리됩니다." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models 응답에 표시용 이름 필드를 포함합니다. 모델 ID만 허용하는 클라이언트의 경우 이 설정을 비활성화하세요." }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "스킬 샌드박스에서 네트워크 액세스를 활성화합니다." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "대상 모델에 필수 기능(비전, 도구, 구조화된 출력, 컨텍스트 창)이 부족할 경우 요청을 발송 전에 거부합니다. 콤보 레이어 호환성 필터를 우회하는 직접 단일 공급자 요청을 보호합니다.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "페이지를 찾을 수 없습니다", diff --git a/src/i18n/messages/lt.json b/src/i18n/messages/lt.json index fffffe2b89..c7c6f53711 100644 --- a/src/i18n/messages/lt.json +++ b/src/i18n/messages/lt.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Skelbti claude/<provider>/<model> atspindžio ID per /v1/models, kad Claude Code šliuzo modelių aptikimas rodytų ir ne-Claude modelius. Įspėjimas: įjungus visuotinai, katalogo įrašų skaičius padvigubėja visiems klientams.", "featureFlagNoThinkingAliasEnabledDescription": "Pagrindinis no-think/<provider>/<model> šliuzo aliasų jungiklis. Įjungta (numatyta): /v1/models skelbia „no-thinking“ variantą kiekvienam tinkamam mąstymo galimybę turinčiam Claude modeliui, o užklausoje pateiktas no-think/ ID nukreipiamas atgal į tikrąjį modelį su slopintu samprotavimu. Išjungta: variantai nėra skelbiami, o no-think/ ID laikomas kaip bet koks kitas nežinomas modelio ID. Kol tai įjungta, vis tiek taikomas kiekvieno modelio ModelSpec.noThinkingAlias sutikimo/atsisakymo nustatymas.", "featureFlagChatVirtualLanesEnabledDescription": "Įjungti kiekvienam nuomotojui pritaikomas adaptyvias virtualias priėmimo juostas teikėjų siuntimui (#9654): vieno nuomotojo srautas nebesukelia 503 klaidos kitam. Aplinkos kintamasis OMNIROUTE_CHAT_VIRTUAL_LANES turi pirmenybę prieš šį skydelio nustatymą; pakeitimai įsigalioja po serverio paleidimo iš naujo.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Pradžia", "dashboard": "Suvestinė", @@ -10964,6 +10968,9 @@ "strategyLatency": "Optimizuota pagal delsą", "poolMembersLabel": "Telkinio nariai ({count})", "poolNoMembers": "Šiame telkinyje dar nėra tarpinių serverių.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Pašalinti", "poolRemoveFailed": "Nepavyko pašalinti tarpinio serverio iš telkinio", "poolAddLabel": "Pridėti tarpinį serverį", @@ -12984,6 +12991,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Leisti atkuriant srautą dar kartą paprašyti atsakymo ir jį sujungti, net jei klientas jau gavo dalį baitų." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Vieną kartą perjungti į giminingą ryšį, kai srautas užsidaro neišsiuntęs jokio naudingo kadro ir ribotas pakartotinis bandymas tame pačiame ryšyje išnaudotas. Jei tinkamo giminingo ryšio nėra, grąžinama pradinė early-EOF klaida. Išjungta: early-EOF po pakartotinio bandymo lieka galutinis." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Į /v1/models atsakymus įtraukti patogiam rodymui skirtus pavadinimų laukus. Išjunkite tai klientams, kurie priima tik modelių ID." }, @@ -13020,6 +13033,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Serverio valdomas įrankių ciklas", "description": "Tęsti serverio valdomus nesrautinius įrankių iškvietimus, kol modelis pateiks klientui tinkamą atsakymą." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13654,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Atmesti užklausas prieš jas perduodant, kai tikslinis modelis neturi reikiamų galimybių (vaizdų apdorojimo, įrankių, struktūrizuotos išvesties, konteksto lango). Tai apsaugo tiesiogines vienam teikėjui skirtas užklausas, kurios apeina derinių lygmens suderinamumo filtrą.", "featureFlagDisableContextWindowChecksDescription": "Tiesioginėms vieno modelio užklausoms praleisti OmniRoute vietinę konteksto lango ir didžiausio įvesties žetonų skaičiaus patikrą. Išoriniai teikėjai vis tiek taiko savo faktinius apribojimus. Raginimų glaudinimas ir išvesties žetonų apribojimai lieka aktyvūs.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Puslapis nerastas", diff --git a/src/i18n/messages/lv.json b/src/i18n/messages/lv.json index 4e4c7ea39b..cc37c74fa6 100644 --- a/src/i18n/messages/lv.json +++ b/src/i18n/messages/lv.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Izziņot claude/<provider>/<model> spoguļa ID vietnē /v1/models, lai Claude Code vārtejas modeļu atrašana uzrādītu ne-Claude modeļus. Brīdinājums: kad iespējots globāli, dublējas katalogā ieraksti visiem klientiem.", "featureFlagNoThinkingAliasEnabledDescription": "Galvenais slēdzis no-think/<provider><model> vārtejas aizstājvārdiem. Iesl. (pēc noklusējuma): /v1/models izziņo bezdomāšanas variantu katram atbilstošajam domāšanas spējīgajam Claude modelim, un no-think/ ID, kas nosūtīts pieprasījumā, tiek atrisināts atpakaļ uz reālo modeli ar apspiestu argumentāciju. Izsl.: nekādi varianti netiek izziņoti, un no-think/ ID tiek uzskatīts par jebkuru citu nezināmu modeļa ID. Modeļa ModelSpec.noThinkingAlias iekļaušanās/izslēgšanās iespēja joprojām darbojas, kamēr šis ir iespējots.", "featureFlagChatVirtualLanesEnabledDescription": "Iespējot katra nomnieka adaptīvas virtuālās uzņemšanas joslas nodrošinātāju izsūtīšanai (#9654): viena nomnieka slodzes lēciens vairs neizraisa 503 kļūdu citam. OMNIROUTE_CHAT_VIRTUAL_LANES vides mainīgais ir prioritārāks par šo paneļa iestatījumu; izmaiņas stājas spēkā pēc servera pārstartēšanas.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Sākums", "dashboard": "Panelis", @@ -10964,6 +10968,9 @@ "strategyLatency": "Ar aizkaves optimizāciju", "poolMembersLabel": "Kopas dalībnieki ({count})", "poolNoMembers": "Šajā kopā vēl nav starpniekserveru.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Noņemt", "poolRemoveFailed": "Neizdevās noņemt starpniekserveri no kopas", "poolAddLabel": "Pievienot proksi", @@ -12984,6 +12991,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Ļaut straumes atkopšanai atkārtoti pieprasīt atbildi un pievienot to straumei pēc tam, kad baiti jau ir sasnieguši klientu." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Vienreiz pārslēgties uz radniecīgu savienojumu, ja straume aizveras, pirms nosūtīts kāds noderīgs kadrs, un ierobežotais atkārtotais mēģinājums tajā pašā savienojumā ir izlietots. Ja nav izmantojama radniecīga savienojuma, tiek atgriezta sākotnējā early-EOF kļūda. Izslēgts: early-EOF pēc atkārtotā mēģinājuma paliek galīgs." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Iekļaut lietotājam draudzīga attēlojamā nosaukuma laukus /v1/models atbildēs. Atspējojiet šo opciju klientiem, kas pieņem tikai modeļu ID." }, @@ -13020,6 +13033,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Servera pārvaldīta rīku izsaukumu cilpa", "description": "Turpināt servera pārvaldītos rīku izsaukumus bez straumēšanas, līdz modelis atgriež klientam izmantojamu atbildi." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13654,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Noraidīt pieprasījumus pirms nosūtīšanas, ja mērķa modelim trūkst nepieciešamo iespēju (redze, rīki, strukturēta izvade, konteksta logs). Aizsargā tiešus viena pakalpojumu sniedzēja pieprasījumus, kas apiet combo-layer saderības filtru.", "featureFlagDisableContextWindowChecksDescription": "Izlaist OmniRoute lokālo konteksta loga un max-input-token pārbaudi tiešiem viena modeļa pieprasījumiem. Augšupējie pakalpojumu sniedzēji joprojām piemēro savus faktiskos ierobežojumus. Uzvedņu saspiešana un izvades marķieru ierobežojumi paliek aktīvi.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Lapa nav atrasta", diff --git a/src/i18n/messages/ml.json b/src/i18n/messages/ml.json index 3ccf2ce103..f23874a8a0 100644 --- a/src/i18n/messages/ml.json +++ b/src/i18n/messages/ml.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Claude Code ഗേറ്റ്വേ മോഡൽ കണ്ടെത്തലിൽ Claude ഇതര മോഡലുകൾ പട്ടികപ്പെടുത്തുന്നതിനായി /v1/models-ൽ claude/<provider>/<model> മിറർ ഐഡികൾ പ്രസിദ്ധപ്പെടുത്തുക. മുന്നറിയിപ്പ്: ആഗോളതലത്തിൽ പ്രവർത്തനക്ഷമമാക്കുമ്പോൾ എല്ലാ ക്ലയന്റുകൾക്കുമുള്ള കാറ്റലോഗ് എൻട്രികളുടെ എണ്ണം ഇരട്ടിയാകും.", "featureFlagNoThinkingAliasEnabledDescription": "no-think/<provider>/<model> ഗേറ്റ്വേ അപരനാമങ്ങൾക്കുള്ള മാസ്റ്റർ സ്വിച്ച്. ഓൺ (ഡിഫോൾട്ട്): യോഗ്യതയുള്ള, ചിന്താശേഷിയുള്ള ഓരോ Claude മോഡലിനും ചിന്തിക്കാത്ത ഒരു വകഭേദം /v1/models പ്രസിദ്ധപ്പെടുത്തും; കൂടാതെ അഭ്യർത്ഥനയിൽ അയയ്ക്കുന്ന no-think/ ഐഡി, റീസണിങ് അടിച്ചമർത്തിക്കൊണ്ട് യഥാർഥ മോഡലിലേക്ക് തിരികെ പരിഹരിക്കപ്പെടും. ഓഫ്: വകഭേദങ്ങളൊന്നും പ്രസിദ്ധപ്പെടുത്തില്ല; no-think/ ഐഡി മറ്റേതൊരു അജ്ഞാത മോഡൽ ഐഡിയെയും പോലെ പരിഗണിക്കും. ഇത് ഓണായിരിക്കുമ്പോഴും ഓരോ മോഡലിനുമുള്ള ModelSpec.noThinkingAlias ഓപ്റ്റ്-ഇൻ/ഓപ്റ്റ്-ഔട്ട് ബാധകമാണ്.", "featureFlagChatVirtualLanesEnabledDescription": "പ്രൊവൈഡർ ഡിസ്പാച്ചിനായി ഓരോ ടെനന്റിനും അനുയോജ്യമായി മാറുന്ന വെർച്വൽ അഡ്മിഷൻ ലെയിനുകൾ പ്രവർത്തനക്ഷമമാക്കുക (#9654): ഇനി ഒരു ടെനന്റിന്റെ പെട്ടെന്നുള്ള അഭ്യർത്ഥന വർധന മറ്റൊരാൾക്ക് 503 പിശക് സൃഷ്ടിക്കില്ല. ഈ ഡാഷ്ബോർഡ് ഓവർറൈഡിനേക്കാൾ OMNIROUTE_CHAT_VIRTUAL_LANES env var-ന് മുൻഗണനയുണ്ട്; സെർവർ പുനരാരംഭിക്കുമ്പോൾ മാറ്റങ്ങൾ പ്രാബല്യത്തിൽ വരും.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "ഹോം", "dashboard": "ഡാഷ്ബോർഡ്", @@ -10964,6 +10968,9 @@ "strategyLatency": "ലേറ്റൻസി-ഒപ്റ്റിമൈസ്ഡ്", "poolMembersLabel": "പൂൾ അംഗങ്ങൾ ({count})", "poolNoMembers": "ഈ പൂളിൽ ഇതുവരെ പ്രോക്സികളൊന്നുമില്ല.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "നീക്കം ചെയ്യുക", "poolRemoveFailed": "പൂളിൽ നിന്ന് പ്രോക്സി നീക്കം ചെയ്യുന്നതിൽ പരാജയപ്പെട്ടു", "poolAddLabel": "ഒരു പ്രോക്സി ചേർക്കുക", @@ -12984,6 +12991,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "ബൈറ്റുകൾ ഇതിനകം ക്ലയന്റിൽ എത്തിയതിനുശേഷവും പ്രതികരണം വീണ്ടും അഭ്യർത്ഥിച്ച് കൂട്ടിച്ചേർക്കാൻ സ്ട്രീം വീണ്ടെടുക്കലിനെ അനുവദിക്കുക." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "ഉപയോഗപ്രദമായ ഒരു ഫ്രെയിമും അയയ്ക്കുന്നതിന് മുമ്പ് സ്ട്രീം അടയുകയും അതേ കണക്ഷനിലെ പരിമിതമായ പുനഃശ്രമം തീരുകയും ചെയ്യുമ്പോൾ ഒരിക്കൽ ഒരു സഹോദര കണക്ഷനിലേക്ക് ഫെയിൽഓവർ ചെയ്യുക. ഉപയോഗിക്കാവുന്ന സഹോദര കണക്ഷൻ ഇല്ലെങ്കിൽ യഥാർത്ഥ early-EOF പിശക് തിരികെ നൽകും. ഓഫ്: പുനഃശ്രമത്തിന് ശേഷം early-EOF അന്തിമമായി തുടരും." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models പ്രതികരണങ്ങളിൽ പ്രദർശനത്തിന് അനുയോജ്യമായ നാമ ഫീൽഡുകൾ ഉൾപ്പെടുത്തുക. മോഡൽ ID-കൾ മാത്രം സ്വീകരിക്കുന്ന ക്ലയന്റുകൾക്കായി ഇത് പ്രവർത്തനരഹിതമാക്കുക." }, @@ -13020,6 +13033,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "സെർവർ ഉടമസ്ഥതയിലുള്ള ടൂൾ ലൂപ്പ്", "description": "മോഡൽ ക്ലയന്റിന് ഉപയോഗിക്കാവുന്ന പ്രതികരണം നൽകുന്നതുവരെ സ്ട്രീമിംഗ് അല്ലാത്ത, സെർവർ ഉടമസ്ഥതയിലുള്ള ടൂൾ കോളുകൾ തുടരുക." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13654,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "ലക്ഷ്യമാക്കിയ മോഡലിന് ആവശ്യമായ ശേഷികൾ (വിഷൻ, ടൂളുകൾ, ഘടനാബദ്ധമായ ഔട്ട്പുട്ട്, കോൺടെക്സ്റ്റ് വിൻഡോ) ഇല്ലെങ്കിൽ അഭ്യർത്ഥനകൾ അയയ്ക്കുന്നതിന് മുമ്പ് നിരസിക്കുക. കോംബോ-ലെയർ അനുയോജ്യതാ ഫിൽട്ടർ മറികടക്കുന്ന നേരിട്ടുള്ള ഒറ്റ-പ്രൊവൈഡർ അഭ്യർത്ഥനകളെ ഇത് പരിരക്ഷിക്കുന്നു.", "featureFlagDisableContextWindowChecksDescription": "നേരിട്ടുള്ള ഒറ്റ-മോഡൽ അഭ്യർത്ഥനകൾക്കായി OmniRoute-ന്റെ ലോക്കൽ കോൺടെക്സ്റ്റ്-വിൻഡോ, പരമാവധി ഇൻപുട്ട്-ടോക്കൺ പരിശോധനകൾ ഒഴിവാക്കുക. അപ്സ്ട്രീം പ്രൊവൈഡർമാർ അവരുടെ യഥാർഥ പരിധികൾ തുടർന്നും നടപ്പാക്കും. പ്രോംപ്റ്റ് കംപ്രഷനും ഔട്ട്പുട്ട്-ടോക്കൺ പരിധികളും സജീവമായി തുടരും.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "പേജ് കണ്ടെത്തിയില്ല", diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index 27778d5ba6..71358f0175 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "/v1/models वर claude/<provider>/<model> मिरर आयडीज जाहिरात करा जेणेकरून Claude Code गेटवे मॉडेल शोध सूचीमध्ये नॉन-Claude मॉडेल्स समाविष्ट होतील. चेतावणी: जागतिक स्तरावर सक्षम केल्यास सर्व क्लायंटसाठी कॅटलॉग नोंदी दुहेरी होतात.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "प्रदाता डिस्पॅचसाठी प्रति-टेनंट अनुकूली व्हर्च्युअल अॅडमिशन लेन सक्षम करा (#9654): एका टेनंटचा बर्स्ट यापुढे दुसऱ्या टेनंटला 503 देत नाही. OMNIROUTE_CHAT_VIRTUAL_LANES पर्यावरण चल या डॅशबोर्ड सेटिंगपेक्षा वरचढ आहे; बदल सर्व्हर रीस्टार्ट केल्यावर प्रभावी होतात.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Home", "dashboard": "Dashboard", @@ -10957,6 +10961,9 @@ "strategyLatency": "लेटन्सी-ऑप्टिमाइझ्ड", "poolMembersLabel": "पूल सदस्य ({count})", "poolNoMembers": "या पूलमध्ये अद्याप कोणतेही प्रॉक्सी नाहीत.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "काढून टाका", "poolRemoveFailed": "पूलमधून प्रॉक्सी काढून टाकण्यात अयशस्वी", "poolAddLabel": "प्रॉक्सी जोडा", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "बाइट्स आधीच क्लायंटपर्यंत पोहोचल्यानंतर प्रतिसादाची पुन्हा विनंती करण्यासाठी आणि तो जोडण्यासाठी स्ट्रीम रिकव्हरीला अनुमती द्या." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "स्ट्रीम कोणतीही उपयुक्त फ्रेम पाठवण्यापूर्वी बंद झाल्यास आणि त्याच कनेक्शनवरील मर्यादित पुनर्प्रयत्न संपल्यास एकदा सहोदर कनेक्शनवर फेलओव्हर करा. वापरण्यायोग्य सहोदर कनेक्शन नसल्यास मूळ early-EOF त्रुटी परत केली जाते. बंद: पुनर्प्रयत्नानंतर early-EOF अंतिम राहतो." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models प्रतिसादांमध्ये प्रदर्शनासाठी अनुकूल नाव फील्ड समाविष्ट करा. केवळ मॉडेल आयडी स्वीकारणाऱ्या क्लायंटसाठी हे अक्षम करा." }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "स्किल्स सँडबॉक्समध्ये नेटवर्क ॲक्सेस सक्षम करा." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "डिस्पॅच करण्यापूर्वी विनंत्या नाकारल्या जातात जेव्हा लक्ष्य मॉडेल आवश्यक क्षमतांचा अभाव असतो (दृष्टी, साधने, संरचित आउटपुट, संदर्भ विंडो). कॉम्बो-लेयर सुसंगतता फिल्टरला बायपास करणाऱ्या थेट एकल-प्रदात्याच्या विनंत्यांचे संरक्षण करते.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "पृष्ठ सापडले नाही", diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index 0854540494..95c1e7cb66 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Iklankan claude/<provider>/<model> mirror ids pada /v1/models supaya senarai penemuan model gerbang Claude Code termasuk model bukan Claude. Amaran: menggandakan entri katalog untuk semua klien apabila diaktifkan secara global.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Aktifkan lorong kemasukan maya adaptif setiap-tenant untuk penghantaran pembekal (#9654): lonjakan satu tenant tidak lagi memberikan 503 kepada tenant lain. Pemboleh ubah persekitaran OMNIROUTE_CHAT_VIRTUAL_LANES mengatasi tetapan papan pemuka ini; perubahan berkuat kuasa apabila pelayan dimulakan semula.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Rumah", "dashboard": "Papan pemuka", @@ -10957,6 +10961,9 @@ "strategyLatency": "Dioptimumkan kependaman", "poolMembersLabel": "Ahli kolam ({count})", "poolNoMembers": "Belum ada proksi dalam kolam ini.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Alih keluar", "poolRemoveFailed": "Gagal mengalih keluar proksi daripada kolam", "poolAddLabel": "Tambah proksi", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Benarkan pemulihan strim untuk meminta respons semula dan mencantumkannya selepas bait telah sampai ke pelanggan." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Tukar sekali ke sambungan adik-beradik apabila strim ditutup sebelum menghantar sebarang bingkai berguna dan cubaan semula terhad pada sambungan yang sama telah habis. Tanpa sambungan adik-beradik yang boleh digunakan, ralat early-EOF asal dikembalikan. Mati: early-EOF kekal muktamad selepas cubaan semula." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Sertakan medan nama mesra paparan dalam respons /v1/models. Nyahdayakan ini untuk pelanggan yang hanya menerima ID model." }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Dayakan akses rangkaian dalam kotak pasir kemahiran." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Tolak permintaan sebelum penghantaran apabila model sasaran tidak mempunyai keupayaan yang diperlukan (penglihatan, alat, output terstruktur, tetingkap konteks). Melindungi permintaan penyedia tunggal secara langsung yang mengabaikan penapis keserasian lapisan gabungan.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Halaman tidak ditemui", diff --git a/src/i18n/messages/mt.json b/src/i18n/messages/mt.json index 5fbe8d926d..b7539064a5 100644 --- a/src/i18n/messages/mt.json +++ b/src/i18n/messages/mt.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Uri l-IDs mera claude/<provider>/<model> fuq /v1/models sabiex l-iskoperta tal-mudelli mill-gateway ta' Claude Code telenka mudelli mhux ta' Claude. Twissija: meta din l-għażla tkun attivata globalment, tirdoppja l-entrati fil-katalgu għall-klijenti kollha.", "featureFlagNoThinkingAliasEnabledDescription": "Swiċċ ewlieni għall-aliases tal-gateway no-think/<provider>/<model>. Mixgħul (predefinit): /v1/models juri varjant mingħajr ħsieb għal kull mudell Claude eliġibbli li kapaċi jaħseb, u ID no-think/ mibgħut f'talba jiġi solvut lura għall-mudell reali bir-raġunament imrażżan. Mitfi: ma jintwera l-ebda varjant u ID no-think/ jiġi ttrattat bħal kull ID ieħor ta' mudell mhux magħruf. L-għażla ta' inklużjoni/esklużjoni ModelSpec.noThinkingAlias għal kull mudell tibqa' tapplika waqt li din l-għażla tkun mixgħula.", "featureFlagChatVirtualLanesEnabledDescription": "Ippermetti korsiji virtwali adattivi tad-dħul għal kull tenant għad-dispaċċ tal-fornituri (#9654): żieda f'daqqa fit-traffiku ta' tenant wieħed ma tibqax tikkawża żball 503 għal ieħor. Il-varjabbli tal-ambjent OMNIROUTE_CHAT_VIRTUAL_LANES jieħu preċedenza fuq din is-sovrasKitba tad-dashboard; il-bidliet jidħlu fis-seħħ meta jerġa' jinbeda s-server.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Paġna Ewlenija", "dashboard": "Dashboard", @@ -10964,6 +10968,9 @@ "strategyLatency": "Ottimizzata għal-latenza", "poolMembersLabel": "Membri tal-pool ({count})", "poolNoMembers": "Għad m’hemm l-ebda proxy f’dan il-pool.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Neħħi", "poolRemoveFailed": "Ma rnexxiex it-tneħħija tal-proxy mill-pool", "poolAddLabel": "Żid proxy", @@ -12984,6 +12991,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Ħalli l-irkupru tal-fluss jitlob it-tweġiba mill-ġdid u jgħaqqadha wara li l-bytes ikunu diġà waslu għand il-klijent." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Aqleb darba waħda għal konnessjoni oħt meta fluss jingħalaq qabel ma jibgħat xi frame utli u l-attentat mill-ġdid limitat fuq l-istess konnessjoni jkun intuża. Jekk ma jkunx hemm konnessjoni oħt li tista' tintuża, jintbagħat lura l-iżball early-EOF oriġinali. Mitfi: early-EOF jibqa' finali wara l-attentat mill-ġdid." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Inkludi oqsma tal-isem adattati għall-wiri fit-tweġibiet ta’ /v1/models. Iddiżattiva dan għal klijenti li jaċċettaw biss IDs tal-mudelli." }, @@ -13020,6 +13033,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Ċiklu tal-Għodod Immexxi mis-Server", "description": "Kompli s-sejħiet mhux streaming tal-għodod immexxija mis-server sakemm il-mudell jirritorna tweġiba li tista’ tintuża mill-klijent." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13654,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Irrifjuta t-talbiet qabel jintbagħtu meta l-mudell fil-mira ma jkollux il-kapaċitajiet meħtieġa (viżjoni, għodod, output strutturat, tieqa tal-kuntest). Jipproteġi t-talbiet diretti lil fornitur wieħed li jaqbżu l-filtru tal-kompatibbiltà tas-saff tal-kombinazzjonijiet.", "featureFlagDisableContextWindowChecksDescription": "Aqbeż il-verifika lokali ta' OmniRoute għat-tieqa tal-kuntest u l-għadd massimu ta' tokens tal-input għal talbiet diretti lil mudell wieħed. Il-fornituri upstream xorta jinfurzaw il-limiti effettivi tagħhom. Il-kompressjoni tal-prompt u l-limiti tat-tokens tal-output jibqgħu attivi.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Il-paġna ma nstabitx", diff --git a/src/i18n/messages/my.json b/src/i18n/messages/my.json index 24aeaa4a07..25c91e45a1 100644 --- a/src/i18n/messages/my.json +++ b/src/i18n/messages/my.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Claude Code gateway ၏ မော်ဒယ်ရှာဖွေမှုစာရင်းတွင် Claude မဟုတ်သော မော်ဒယ်များ ပါဝင်စေရန် /v1/models တွင် claude/<provider>/<model> mirror ids များကို ဖော်ပြပါ။ သတိပေးချက်- အားလုံးအတွက် ဖွင့်ထားပါက client အားလုံးတွင် catalog entry အရေအတွက် နှစ်ဆဖြစ်စေသည်။", "featureFlagNoThinkingAliasEnabledDescription": "no-think/<provider>/<model> gateway aliases များအတွက် အဓိကခလုတ်။ ဖွင့်ထားလျှင် (မူလသတ်မှတ်ချက်)- /v1/models သည် သတ်မှတ်ချက်ပြည့်မီသော စဉ်းစားဆင်ခြင်နိုင်သည့် Claude မော်ဒယ်တိုင်းအတွက် မစဉ်းစားသည့် မူကွဲတစ်ခုကို ဖော်ပြပြီး တောင်းဆိုမှုတစ်ခုတွင် ပေးပို့သော no-think/ id ကို reasoning ပိတ်ထားသည့် တကယ့်မော်ဒယ်သို့ ပြန်လည်ချိတ်ဆက်ပေးသည်။ ပိတ်ထားလျှင်- မည်သည့်မူကွဲကိုမျှ မဖော်ပြဘဲ no-think/ id ကို အခြားမသိသော model id များကဲ့သို့ သတ်မှတ်သည်။ ဤခလုတ်ဖွင့်ထားစဉ် မော်ဒယ်တစ်ခုချင်းစီ၏ ModelSpec.noThinkingAlias opt-in/opt-out သတ်မှတ်ချက်သည် ဆက်လက်သက်ရောက်သည်။", "featureFlagChatVirtualLanesEnabledDescription": "provider dispatch (#9654) အတွက် tenant တစ်ခုချင်းစီအလိုက် အလိုက်သင့်ပြောင်းလဲနိုင်သော virtual admission lanes များကို ဖွင့်ပါ။ tenant တစ်ခု၏ ရုတ်တရက်မြင့်တက်လာသော အသုံးပြုမှုကြောင့် အခြား tenant တွင် 503 ဖြစ်ပေါ်တော့မည်မဟုတ်ပါ။ OMNIROUTE_CHAT_VIRTUAL_LANES env var သည် ဤ dashboard override ထက် ဦးစားပေးသက်ရောက်ပြီး ပြောင်းလဲမှုများသည် server ပြန်လည်စတင်ချိန်တွင် အသက်ဝင်မည်ဖြစ်သည်။", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "ပင်မစာမျက်နှာ", "dashboard": "ဒက်ရှ်ဘုတ်", @@ -10964,6 +10968,9 @@ "strategyLatency": "တုံ့ပြန်ချိန်အကောင်းဆုံး", "poolMembersLabel": "ပူးလ်အဖွဲ့ဝင်များ ({count})", "poolNoMembers": "ဤပူးလ်တွင် ပရောက်စီများ မရှိသေးပါ။", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "ဖယ်ရှားရန်", "poolRemoveFailed": "ပရောက်စီကို ပူးလ်မှ ဖယ်ရှား၍ မရပါ", "poolAddLabel": "ပရောက်စီတစ်ခု ထည့်ရန်", @@ -12984,6 +12991,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Byte များ client ထံ ရောက်ရှိပြီးနောက်တွင်ပင် stream recovery က response ကို ထပ်မံတောင်းခံ၍ ဆက်စပ်ပေါင်းစည်းနိုင်ရန် ခွင့်ပြုပါ။" }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "stream သည် အသုံးဝင်သော frame တစ်ခုမျှ မထုတ်မီ ပိတ်သွားပြီး ချိတ်ဆက်မှုတစ်ခုတည်းပေါ်ရှိ ကန့်သတ်ထားသော ပြန်လည်ကြိုးစားမှုလည်း ကုန်သွားပါက ညီအစ်ကိုချိတ်ဆက်မှုသို့ တစ်ကြိမ်သာ failover လုပ်ပါ။ အသုံးပြုနိုင်သော ညီအစ်ကိုချိတ်ဆက်မှု မရှိပါက မူလ early-EOF အမှားကို ပြန်ပေးပါသည်။ ပိတ်ထားလျှင်: ပြန်လည်ကြိုးစားပြီးနောက် early-EOF သည် နောက်ဆုံးအဖြစ် ရှိနေပါသည်။" + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models response များတွင် ဖတ်ရှုရလွယ်ကူသော name field များကို ထည့်သွင်းပါ။ Model ID များကိုသာ လက်ခံသော client များအတွက် ၎င်းကို ပိတ်ပါ။" }, @@ -13020,6 +13033,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Server ပိုင် Tool Loop", "description": "Model က client အသုံးပြုနိုင်သော response တစ်ခုကို ပြန်ပေးသည်အထိ non-streaming server-owned tool call များကို ဆက်လက်လုပ်ဆောင်ပါ။" + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13654,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "ပစ်မှတ်မော်ဒယ်တွင် လိုအပ်သော စွမ်းဆောင်ရည်များ (အမြင်၊ ကိရိယာများ၊ ဖွဲ့စည်းပုံကျ အထွက်၊ ကွန်တက်စ်ဝင်းဒိုး) မရှိပါက ဖြန့်ပို့ခြင်းမပြုမီ တောင်းဆိုမှုများကို ပယ်ချပါ။ ၎င်းသည် combo-layer လိုက်ဖက်ညီမှု စစ်ထုတ်မှုကို ကျော်လွှားသော တစ်ခုတည်းသောပံ့ပိုးသူထံ တိုက်ရိုက်တောင်းဆိုမှုများကို ကာကွယ်ပေးသည်။", "featureFlagDisableContextWindowChecksDescription": "တစ်ခုတည်းသောမော်ဒယ်ထံ တိုက်ရိုက်တောင်းဆိုမှုများအတွက် OmniRoute ၏ စက်တွင်း ကွန်တက်စ်ဝင်းဒိုးနှင့် အများဆုံးထည့်သွင်းတိုကင် စစ်ဆေးမှုကို ကျော်ပါ။ မူလပံ့ပိုးသူများက ၎င်းတို့၏ အမှန်တကယ်ကန့်သတ်ချက်များကို ဆက်လက်အတည်ပြုကျင့်သုံးမည်ဖြစ်သည်။ ပရောမ့်ချုံ့ခြင်းနှင့် အထွက်တိုကင် အများဆုံးကန့်သတ်ချက်များမှာ ဆက်လက်အသက်ဝင်နေမည်ဖြစ်သည်။", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "စာမျက်နှာကို ရှာမတွေ့ပါ", diff --git a/src/i18n/messages/ne.json b/src/i18n/messages/ne.json index 12beceb056..0b6318a9eb 100644 --- a/src/i18n/messages/ne.json +++ b/src/i18n/messages/ne.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Claude Code गेटवे मोडेल खोजले गैर-Claude मोडेलहरू सूचीबद्ध गरोस् भन्नका लागि /v1/models मा claude/<provider>/<model> मिरर id हरू देखाउनुहोस्। चेतावनी: विश्वव्यापी रूपमा सक्षम गर्दा सबै क्लाइन्टका लागि क्याटलग प्रविष्टिहरू दोब्बर हुन्छन्।", "featureFlagNoThinkingAliasEnabledDescription": "no-think/<provider>/<model> गेटवे एलियसहरूका लागि मुख्य स्विच। अन (पूर्वनिर्धारित): /v1/models ले हरेक योग्य सोच्न-सक्षम Claude मोडेलका लागि सोचाइ-विहीन भेरियन्ट देखाउँछ, र अनुरोधमा पठाइएको no-think/ id वास्तविक मोडेलमा फर्केर रिजोल्भ हुन्छ र रिजनिङ दबाइन्छ। अफ: कुनै पनि भेरियन्ट देखाइँदैन र no-think/ id लाई अन्य कुनै अज्ञात मोडेल id सरह व्यवहार गरिन्छ। यो अन हुँदा पनि प्रत्येक मोडेलको ModelSpec.noThinkingAlias अप्ट-इन/अप्ट-आउट लागू हुन्छ।", "featureFlagChatVirtualLanesEnabledDescription": "प्रदायक डिस्प्याच (#9654) का लागि प्रत्येक टेनेन्टअनुसार अनुकूल हुने भर्चुअल एडमिसन लेनहरू सक्षम गर्नुहोस्: अब एउटा टेनेन्टको अचानक बढेको ट्राफिकले अर्कोलाई 503 गराउँदैन। OMNIROUTE_CHAT_VIRTUAL_LANES env var ले यस ड्यासबोर्ड ओभरराइडभन्दा प्राथमिकता पाउँछ; परिवर्तनहरू सर्भर पुनः सुरु भएपछि लागू हुन्छन्।", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "गृहपृष्ठ", "dashboard": "ड्यासबोर्ड", @@ -10964,6 +10968,9 @@ "strategyLatency": "लेटेन्सी-अनुकूलित", "poolMembersLabel": "पुल सदस्यहरू ({count})", "poolNoMembers": "यस पुलमा अहिलेसम्म कुनै प्रोक्सी छैन।", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "हटाउनुहोस्", "poolRemoveFailed": "पुलबाट प्रोक्सी हटाउन असफल भयो", "poolAddLabel": "प्रोक्सी थप्नुहोस्", @@ -12984,6 +12991,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "बाइटहरू क्लाइन्टसम्म पुगिसकेपछि पनि स्ट्रिम पुनर्प्राप्तिलाई प्रतिक्रिया पुनः अनुरोध गरेर जोड्न अनुमति दिनुहोस्।" }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "स्ट्रिमले कुनै उपयोगी फ्रेम पठाउनुअघि नै बन्द भएमा र उही जडानमा सीमित पुनःप्रयास सकिएमा एक पटक सहोदर जडानमा फेलओभर गर्नुहोस्। प्रयोगयोग्य सहोदर जडान नभएमा मूल early-EOF त्रुटि फिर्ता गरिन्छ। बन्द: पुनःप्रयासपछि early-EOF अन्तिम रहन्छ।" + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models प्रतिक्रियाहरूमा प्रदर्शनमैत्री नाम फिल्डहरू समावेश गर्नुहोस्। मोडेल ID मात्र स्वीकार गर्ने क्लाइन्टहरूका लागि यसलाई अक्षम गर्नुहोस्।" }, @@ -13020,6 +13033,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "सर्भर-स्वामित्वको उपकरण लूप", "description": "मोडेलले क्लाइन्टले प्रयोग गर्न मिल्ने प्रतिक्रिया नफर्काएसम्म नन-स्ट्रिमिङ सर्भर-स्वामित्वका उपकरण कलहरू जारी राख्नुहोस्।" + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13654,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "लक्षित मोडेलमा आवश्यक क्षमताहरू (भिजन, उपकरणहरू, संरचित आउटपुट, कन्टेक्स्ट विन्डो) नभएमा डिस्प्याच गर्नुअघि अनुरोधहरू अस्वीकार गर्नुहोस्। यसले कम्बो-लेयरको अनुकूलता फिल्टरलाई बाइपास गर्ने प्रत्यक्ष एकल-प्रदायक अनुरोधहरूलाई सुरक्षित गर्छ।", "featureFlagDisableContextWindowChecksDescription": "प्रत्यक्ष एकल-मोडेल अनुरोधहरूका लागि OmniRoute को स्थानीय कन्टेक्स्ट-विन्डो र अधिकतम-इनपुट-टोकन जाँच छोड्नुहोस्। अपस्ट्रिम प्रदायकहरूले अझै पनि आफ्ना वास्तविक सीमाहरू लागू गर्छन्। प्रम्प्ट कम्प्रेसन र आउटपुट-टोकन सीमाहरू सक्रिय रहन्छन्।", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "पृष्ठ फेला परेन", diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index 436f67a31f..05c5611a3b 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Adverteer claude/<provider>/<model> spiegel-id's op /v1/models zodat Claude Code gateway modelontdekking niet-Claude modellen vermeldt. Waarschuwing: dubbele catalogusvermeldingen voor alle klanten wanneer wereldwijd ingeschakeld.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Schakel adaptieve virtuele toegangsbanen per tenant in voor provider-dispatch (#9654): een piek van de ene tenant geeft de andere niet langer een 503. De omgevingsvariabele OMNIROUTE_CHAT_VIRTUAL_LANES wint het van deze dashboard-instelling; wijzigingen gaan in bij een serverherstart.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Thuis", "dashboard": "Dashboard", @@ -10957,6 +10961,9 @@ "strategyLatency": "Latentie-geoptimaliseerd", "poolMembersLabel": "Poolleden ({count})", "poolNoMembers": "Nog geen proxy's in deze pool.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Verwijderen", "poolRemoveFailed": "Kan de proxy niet uit de pool verwijderen", "poolAddLabel": "Een proxy toevoegen", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Sta streamherstel toe om de respons opnieuw aan te vragen en samen te voegen nadat bytes de client al hebben bereikt." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Schakel eenmaal over naar een zusterverbinding wanneer een stream sluit voordat er een bruikbaar frame is verzonden en de beperkte nieuwe poging op dezelfde verbinding is opgebruikt. Zonder bruikbare zusterverbinding wordt de oorspronkelijke early-EOF-fout teruggegeven. Uit: early-EOF blijft na de nieuwe poging definitief." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Voeg weergavevriendelijke naamvelden toe aan /v1/models-responsen. Schakel dit uit voor clients die alleen model-ID's accepteren." }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Schakel netwerktoegang in de skills-sandbox in." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Weiger verzoeken vóór verzending wanneer het doellmodel ontbrekende vereiste mogelijkheden heeft (zicht, tools, gestructureerde output, contextvenster). Beschermt directe verzoeken van een enkele aanbieder die de compatibiliteitsfilter van de comb-laag omzeilen.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Pagina niet gevonden", diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index 141764faa5..c0bd2006ed 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Reklamer claude/<provider>/<model> speil-id-er på /v1/models slik at Claude Code gateway-modelloppdagelse viser ikke-Claude-modeller. Advarsel: dobler katalogoppføringer for alle klienter når det er aktivert globalt.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Aktiver adaptive virtuelle tilgangsfelt per tenant for leverandørdistribusjon (#9654): et utbrudd fra én tenant gir ikke lenger en annen 503. Miljøvariabelen OMNIROUTE_CHAT_VIRTUAL_LANES overstyrer denne innstillingen i dashbordet; endringer trer i kraft ved omstart av serveren.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Hjem", "dashboard": "Dashbord", @@ -10957,6 +10961,9 @@ "strategyLatency": "Latensoptimalisert", "poolMembersLabel": "Pool-medlemmer ({count})", "poolNoMembers": "Ingen proxyer i denne poolen ennå.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Fjern", "poolRemoveFailed": "Kunne ikke fjerne proxyen fra poolen", "poolAddLabel": "Legg til en proxy", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Tillat strømgjenoppretting å be om svaret på nytt og sy det sammen etter at bytes allerede har nådd klienten." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Bytt én gang over til en søskentilkobling når en strøm lukkes før den har sendt en nyttig ramme, og det begrensede nye forsøket på samme tilkobling er brukt opp. Uten en brukbar søskentilkobling returneres den opprinnelige early-EOF-feilen. Av: early-EOF forblir endelig etter det nye forsøket." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Inkluder visningsvennlige navnefelt i /v1/models-svar. Deaktiver dette for klienter som kun godtar modell-ID-er." }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Aktiver nettverkstilgang i ferdighetssandkassen." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Avvis forespørselene før utsendelse når målmodellen mangler nødvendige funksjoner (visjon, verktøy, strukturert utdata, kontekstvindu). Beskytter direkte forespørseler fra enkeltleverandører som omgår kombinasjonslagets kompatibilitetsfilter.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Siden ble ikke funnet", diff --git a/src/i18n/messages/or.json b/src/i18n/messages/or.json index cdf0e66af3..5ab1756cc2 100644 --- a/src/i18n/messages/or.json +++ b/src/i18n/messages/or.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "/v1/modelsରେ claude/<provider>/<model> ମିରର୍ IDଗୁଡ଼ିକ ପ୍ରକାଶ କରନ୍ତୁ, ଯାହାଦ୍ୱାରା Claude Code ଗେଟୱେ ମଡେଲ୍ ଆବିଷ୍କାରରେ Claude ବ୍ୟତୀତ ଅନ୍ୟ ମଡେଲ୍ଗୁଡ଼ିକ ତାଲିକାଭୁକ୍ତ ହେବ। ଚେତାବନୀ: ବିଶ୍ୱବ୍ୟାପୀ ଭାବେ ସକ୍ଷମ କଲେ ଏହା ସମସ୍ତ କ୍ଲାଏଣ୍ଟ ପାଇଁ କ୍ୟାଟାଲଗ୍ ଏଣ୍ଟ୍ରି ସଂଖ୍ୟାକୁ ଦ୍ୱିଗୁଣିତ କରେ।", "featureFlagNoThinkingAliasEnabledDescription": "no-think/<provider>/<model> ଗେଟୱେ ଉପନାମଗୁଡ଼ିକ ପାଇଁ ମୁଖ୍ୟ ସ୍ୱିଚ୍। ଚାଲୁ (ଡିଫଲ୍ଟ): /v1/models ପ୍ରତ୍ୟେକ ଯୋଗ୍ୟ ବିଚାର-ସକ୍ଷମ Claude ମଡେଲ୍ ପାଇଁ ଏକ ବିଚାର-ବିହୀନ ଭାର୍ସନ୍ ପ୍ରକାଶ କରେ, ଏବଂ ଅନୁରୋଧରେ ପଠାଯାଇଥିବା no-think/ ID ବିଚାର ପ୍ରକ୍ରିୟାକୁ ଦମନ କରି ପ୍ରକୃତ ମଡେଲ୍କୁ ପୁନଃ ସମାଧାନ ହୁଏ। ବନ୍ଦ: କୌଣସି ଭାର୍ସନ୍ ପ୍ରକାଶ କରାଯାଏ ନାହିଁ ଏବଂ no-think/ IDକୁ ଅନ୍ୟ ଯେକୌଣସି ଅଜଣା ମଡେଲ୍ ID ପରି ବିବେଚନା କରାଯାଏ। ଏହା ଚାଲୁ ଥିବାବେଳେ ମଧ୍ୟ ପ୍ରତି-ମଡେଲ୍ ModelSpec.noThinkingAlias ଅପ୍ଟ-ଇନ୍/ଅପ୍ଟ-ଆଉଟ୍ ପ୍ରଯୁଜ୍ୟ ହୁଏ।", "featureFlagChatVirtualLanesEnabledDescription": "ପ୍ରଦାତା ଡିସ୍ପାଚ୍ (#9654) ପାଇଁ ପ୍ରତି-ଟେନାଣ୍ଟ ଅନୁକୂଳନଶୀଳ ଭର୍ଚୁଆଲ୍ ଆଡମିଶନ୍ ଲେନ୍ଗୁଡ଼ିକ ସକ୍ଷମ କରନ୍ତୁ: ଗୋଟିଏ ଟେନାଣ୍ଟର ହଠାତ୍ ଟ୍ରାଫିକ୍ ବୃଦ୍ଧି ଆଉ ଅନ୍ୟ ଟେନାଣ୍ଟ ପାଇଁ 503 ତ୍ରୁଟି ସୃଷ୍ଟି କରିବ ନାହିଁ। OMNIROUTE_CHAT_VIRTUAL_LANES env var ଏହି ଡ୍ୟାସ୍ବୋର୍ଡ ଓଭର୍ରାଇଡ୍ଠାରୁ ପ୍ରାଥମିକତା ପାଏ; ସର୍ଭର୍ ପୁନଃଚାଳନ ପରେ ପରିବର୍ତ୍ତନଗୁଡ଼ିକ କାର୍ଯ୍ୟକାରୀ ହୁଏ।", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "ମୂଳପୃଷ୍ଠା", "dashboard": "ଡ୍ୟାସ୍ବୋର୍ଡ", @@ -10964,6 +10968,9 @@ "strategyLatency": "ଲେଟେନ୍ସି-ଅପ୍ଟିମାଇଜ୍ଡ", "poolMembersLabel": "ପୁଲ୍ ସଦସ୍ୟ ({count})", "poolNoMembers": "ଏହି ପୁଲ୍ରେ ଏପର୍ଯ୍ୟନ୍ତ କୌଣସି ପ୍ରକ୍ସି ନାହିଁ।", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "ହଟାନ୍ତୁ", "poolRemoveFailed": "ପୁଲ୍ରୁ ପ୍ରକ୍ସିକୁ ହଟାଇବାରେ ବିଫଳ", "poolAddLabel": "ଏକ ପ୍ରକ୍ସି ଯୋଡ଼ନ୍ତୁ", @@ -12984,6 +12991,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "ବାଇଟ୍ଗୁଡ଼ିକ କ୍ଲାଏଣ୍ଟ ପାଖରେ ପହଞ୍ଚିସାରିବା ପରେ ମଧ୍ୟ ଷ୍ଟ୍ରିମ୍ ପୁନରୁଦ୍ଧାରକୁ ପୁନର୍ବାର ପ୍ରତିକ୍ରିୟା ଅନୁରୋଧ କରି ତାହାକୁ ଯୋଡ଼ିବା ପାଇଁ ଅନୁମତି ଦିଅନ୍ତୁ।" }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "ଷ୍ଟ୍ରିମ୍ କୌଣସି ଉପଯୋଗୀ ଫ୍ରେମ୍ ପଠାଇବା ପୂର୍ବରୁ ବନ୍ଦ ହେଲେ ଏବଂ ସେହି ସଂଯୋଗରେ ସୀମିତ ପୁନଃପ୍ରଚେଷ୍ଟା ଶେଷ ହୋଇଗଲେ ଥରେ ଏକ ସହୋଦର ସଂଯୋଗକୁ ଫେଲଓଭର କରନ୍ତୁ। ବ୍ୟବହାରଯୋଗ୍ୟ ସହୋଦର ସଂଯୋଗ ନଥିଲେ ମୂଳ early-EOF ତ୍ରୁଟି ଫେରାଇ ଦିଆଯାଏ। ବନ୍ଦ: ପୁନଃପ୍ରଚେଷ୍ଟା ପରେ early-EOF ଅନ୍ତିମ ରହେ।" + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models ପ୍ରତିକ୍ରିୟାଗୁଡ଼ିକରେ ପ୍ରଦର୍ଶନ-ଅନୁକୂଳ ନାମ ଫିଲ୍ଡଗୁଡ଼ିକୁ ସାମିଲ କରନ୍ତୁ। କେବଳ ମଡେଲ୍ ID ଗ୍ରହଣ କରୁଥିବା କ୍ଲାଏଣ୍ଟମାନଙ୍କ ପାଇଁ ଏହାକୁ ଅକ୍ଷମ କରନ୍ତୁ।" }, @@ -13020,6 +13033,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "ସର୍ଭର-ମାଲିକାନାଧୀନ ଟୁଲ୍ ଲୁପ୍", "description": "ମଡେଲ୍ ଏକ କ୍ଲାଏଣ୍ଟ-ବ୍ୟବହାରଯୋଗ୍ୟ ପ୍ରତିକ୍ରିୟା ଫେରାଇବା ପର୍ଯ୍ୟନ୍ତ ନନ୍-ଷ୍ଟ୍ରିମିଂ ସର୍ଭର-ମାଲିକାନାଧୀନ ଟୁଲ୍ କଲ୍ଗୁଡ଼ିକୁ ଜାରି ରଖନ୍ତୁ।" + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13654,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "ଲକ୍ଷ୍ୟ ମଡେଲ୍ରେ ଆବଶ୍ୟକ କ୍ଷମତାଗୁଡ଼ିକ (ଭିଜନ୍, ଟୁଲ୍, ଷ୍ଟ୍ରକ୍ଚର୍ଡ ଆଉଟପୁଟ୍, କଣ୍ଟେକ୍ସ୍ଟ ୱିଣ୍ଡୋ) ନଥିଲେ ଡିସ୍ପାଚ୍ ପୂର୍ବରୁ ଅନୁରୋଧଗୁଡ଼ିକୁ ପ୍ରତ୍ୟାଖ୍ୟାନ କରନ୍ତୁ। ଏହା କମ୍ବୋ-ଲେୟର୍ ସୁସଙ୍ଗତତା ଫିଲ୍ଟର୍କୁ ବାଇପାସ୍ କରୁଥିବା ସିଧାସଳଖ ଏକକ-ପ୍ରଦାନକାରୀ ଅନୁରୋଧଗୁଡ଼ିକୁ ସୁରକ୍ଷିତ କରେ।", "featureFlagDisableContextWindowChecksDescription": "ସିଧାସଳଖ ଏକକ-ମଡେଲ୍ ଅନୁରୋଧଗୁଡ଼ିକ ପାଇଁ OmniRouteର ସ୍ଥାନୀୟ କଣ୍ଟେକ୍ସ୍ଟ-ୱିଣ୍ଡୋ ଏବଂ ସର୍ବାଧିକ-ଇନପୁଟ୍-ଟୋକନ୍ ଯାଞ୍ଚକୁ ଏଡ଼ାନ୍ତୁ। ଅପ୍ଷ୍ଟ୍ରିମ୍ ପ୍ରଦାନକାରୀମାନେ ତଥାପି ସେମାନଙ୍କର ପ୍ରକୃତ ସୀମାଗୁଡ଼ିକୁ ଲାଗୁ କରିବେ। ପ୍ରମ୍ପ୍ଟ କମ୍ପ୍ରେସନ୍ ଏବଂ ଆଉଟପୁଟ୍-ଟୋକନ୍ ସୀମା ସକ୍ରିୟ ରହିବ।", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "ପୃଷ୍ଠା ମିଳିଲା ନାହିଁ", diff --git a/src/i18n/messages/pa.json b/src/i18n/messages/pa.json index 51145d3716..f3443a6100 100644 --- a/src/i18n/messages/pa.json +++ b/src/i18n/messages/pa.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "/v1/models ਉੱਤੇ claude/<provider>/<model> ਮਿਰਰ IDs ਦਾ ਪ੍ਰਚਾਰ ਕਰੋ, ਤਾਂ ਜੋ Claude Code ਗੇਟਵੇ ਮਾਡਲ ਖੋਜ ਵਿੱਚ ਗੈਰ-Claude ਮਾਡਲ ਸੂਚੀਬੱਧ ਹੋਣ। ਚੇਤਾਵਨੀ: ਗਲੋਬਲ ਤੌਰ 'ਤੇ ਸਮਰੱਥ ਕਰਨ 'ਤੇ ਇਹ ਸਾਰੇ ਕਲਾਇੰਟਾਂ ਲਈ ਕੈਟਾਲਾਗ ਐਂਟਰੀਆਂ ਨੂੰ ਦੁੱਗਣਾ ਕਰ ਦਿੰਦਾ ਹੈ।", "featureFlagNoThinkingAliasEnabledDescription": "no-think/<provider>/<model> ਗੇਟਵੇ ਉਪਨਾਮਾਂ ਲਈ ਮੁੱਖ ਸਵਿੱਚ। ਚਾਲੂ (ਡਿਫਾਲਟ): /v1/models ਹਰ ਯੋਗ, ਸੋਚਣ-ਸਮਰੱਥ Claude ਮਾਡਲ ਲਈ ਇੱਕ ਬਿਨਾਂ-ਸੋਚ ਵਾਲਾ ਰੂਪ ਦਰਸਾਉਂਦਾ ਹੈ, ਅਤੇ ਬੇਨਤੀ ਵਿੱਚ ਭੇਜਿਆ ਗਿਆ no-think/ ID ਤਰਕ ਨੂੰ ਦਬਾ ਕੇ ਮੁੜ ਅਸਲ ਮਾਡਲ ਵਿੱਚ ਹੱਲ ਹੁੰਦਾ ਹੈ। ਬੰਦ: ਕੋਈ ਰੂਪ ਦਰਸਾਏ ਨਹੀਂ ਜਾਂਦੇ ਅਤੇ no-think/ ID ਨੂੰ ਕਿਸੇ ਹੋਰ ਅਣਜਾਣ ਮਾਡਲ ID ਵਾਂਗ ਮੰਨਿਆ ਜਾਂਦਾ ਹੈ। ਜਦੋਂ ਇਹ ਚਾਲੂ ਹੋਵੇ, ਤਾਂ ਪ੍ਰਤੀ-ਮਾਡਲ ModelSpec.noThinkingAlias ਔਪਟ-ਇਨ/ਔਪਟ-ਆਉਟ ਫਿਰ ਵੀ ਲਾਗੂ ਹੁੰਦਾ ਹੈ।", "featureFlagChatVirtualLanesEnabledDescription": "ਪ੍ਰਦਾਤਾ ਡਿਸਪੈਚ (#9654) ਲਈ ਪ੍ਰਤੀ-ਟੈਨੈਂਟ ਅਨੁਕੂਲ ਵਰਚੁਅਲ ਐਡਮਿਸ਼ਨ ਲੇਨ ਸਮਰੱਥ ਕਰੋ: ਹੁਣ ਇੱਕ ਟੈਨੈਂਟ ਦਾ ਅਚਾਨਕ ਵਧਿਆ ਲੋਡ ਦੂਜੇ ਲਈ 503 ਪੈਦਾ ਨਹੀਂ ਕਰੇਗਾ। OMNIROUTE_CHAT_VIRTUAL_LANES env var ਨੂੰ ਇਸ ਡੈਸ਼ਬੋਰਡ ਓਵਰਰਾਈਡ ਉੱਤੇ ਤਰਜੀਹ ਮਿਲਦੀ ਹੈ; ਤਬਦੀਲੀਆਂ ਸਰਵਰ ਮੁੜ ਚਾਲੂ ਹੋਣ 'ਤੇ ਲਾਗੂ ਹੁੰਦੀਆਂ ਹਨ।", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "ਮੁੱਖ ਪੰਨਾ", "dashboard": "ਡੈਸ਼ਬੋਰਡ", @@ -10964,6 +10968,9 @@ "strategyLatency": "ਲੇਟੈਂਸੀ-ਅਨੁਕੂਲਿਤ", "poolMembersLabel": "ਪੂਲ ਮੈਂਬਰ ({count})", "poolNoMembers": "ਇਸ ਪੂਲ ਵਿੱਚ ਹਾਲੇ ਕੋਈ ਪ੍ਰੌਕਸੀ ਨਹੀਂ ਹੈ।", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "ਹਟਾਓ", "poolRemoveFailed": "ਪੂਲ ਵਿੱਚੋਂ ਪ੍ਰੌਕਸੀ ਹਟਾਉਣ ਵਿੱਚ ਅਸਫਲ", "poolAddLabel": "ਇੱਕ ਪ੍ਰੌਕਸੀ ਜੋੜੋ", @@ -12984,6 +12991,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "ਬਾਈਟਾਂ ਦੇ ਕਲਾਇੰਟ ਤੱਕ ਪਹੁੰਚ ਜਾਣ ਤੋਂ ਬਾਅਦ ਵੀ ਸਟ੍ਰੀਮ ਰਿਕਵਰੀ ਨੂੰ ਜਵਾਬ ਦੁਬਾਰਾ ਮੰਗਣ ਅਤੇ ਉਸਨੂੰ ਜੋੜਨ ਦੀ ਇਜਾਜ਼ਤ ਦਿਓ।" }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "ਜਦੋਂ ਸਟ੍ਰੀਮ ਕੋਈ ਉਪਯੋਗੀ ਫ੍ਰੇਮ ਭੇਜਣ ਤੋਂ ਪਹਿਲਾਂ ਬੰਦ ਹੋ ਜਾਵੇ ਅਤੇ ਉਸੇ ਕਨੈਕਸ਼ਨ 'ਤੇ ਸੀਮਤ ਮੁੜ-ਕੋਸ਼ਿਸ਼ ਖਤਮ ਹੋ ਜਾਵੇ, ਤਾਂ ਇੱਕ ਵਾਰ ਸਹੋਦਰ ਕਨੈਕਸ਼ਨ 'ਤੇ ਫੇਲਓਵਰ ਕਰੋ। ਵਰਤੋਂਯੋਗ ਸਹੋਦਰ ਕਨੈਕਸ਼ਨ ਨਾ ਹੋਣ 'ਤੇ ਮੂਲ early-EOF ਗਲਤੀ ਵਾਪਸ ਕੀਤੀ ਜਾਂਦੀ ਹੈ। ਬੰਦ: ਮੁੜ-ਕੋਸ਼ਿਸ਼ ਤੋਂ ਬਾਅਦ early-EOF ਅੰਤਿਮ ਰਹਿੰਦਾ ਹੈ।" + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models ਜਵਾਬਾਂ ਵਿੱਚ ਪ੍ਰਦਰਸ਼ਨ-ਅਨੁਕੂਲ ਨਾਮ ਫੀਲਡਾਂ ਸ਼ਾਮਲ ਕਰੋ। ਸਿਰਫ਼ ਮਾਡਲ IDs ਸਵੀਕਾਰ ਕਰਨ ਵਾਲੇ ਕਲਾਇੰਟਾਂ ਲਈ ਇਸਨੂੰ ਅਸਮਰੱਥ ਕਰੋ।" }, @@ -13020,6 +13033,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "ਸਰਵਰ-ਮਲਕੀਅਤ ਵਾਲਾ ਟੂਲ ਲੂਪ", "description": "ਸਰਵਰ-ਮਲਕੀਅਤ ਵਾਲੀਆਂ ਗੈਰ-ਸਟ੍ਰੀਮਿੰਗ ਟੂਲ ਕਾਲਾਂ ਨੂੰ ਉਦੋਂ ਤੱਕ ਜਾਰੀ ਰੱਖੋ ਜਦੋਂ ਤੱਕ ਮਾਡਲ ਕਲਾਇੰਟ ਲਈ ਵਰਤਣਯੋਗ ਜਵਾਬ ਨਾ ਦੇਵੇ।" + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13654,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "ਜਦੋਂ ਟਾਰਗੇਟ ਮਾਡਲ ਵਿੱਚ ਲੋੜੀਂਦੀਆਂ ਸਮਰੱਥਾਵਾਂ (ਵਿਜ਼ਨ, ਟੂਲ, ਸਟ੍ਰਕਚਰਡ ਆਉਟਪੁੱਟ, ਕਾਂਟੈਕਸਟ ਵਿੰਡੋ) ਨਾ ਹੋਣ, ਤਾਂ ਡਿਸਪੈਚ ਤੋਂ ਪਹਿਲਾਂ ਬੇਨਤੀਆਂ ਅਸਵੀਕਾਰ ਕਰੋ। ਇਹ ਉਹਨਾਂ ਸਿੱਧੀਆਂ ਸਿੰਗਲ-ਪ੍ਰੋਵਾਈਡਰ ਬੇਨਤੀਆਂ ਦੀ ਸੁਰੱਖਿਆ ਕਰਦਾ ਹੈ ਜੋ ਕੌਂਬੋ-ਲੇਅਰ ਅਨੁਕੂਲਤਾ ਫਿਲਟਰ ਨੂੰ ਬਾਈਪਾਸ ਕਰਦੀਆਂ ਹਨ।", "featureFlagDisableContextWindowChecksDescription": "ਸਿੱਧੀਆਂ ਸਿੰਗਲ-ਮਾਡਲ ਬੇਨਤੀਆਂ ਲਈ OmniRoute ਦੀ ਸਥਾਨਕ ਕਾਂਟੈਕਸਟ-ਵਿੰਡੋ ਅਤੇ ਅਧਿਕਤਮ-ਇਨਪੁੱਟ-ਟੋਕਨ ਜਾਂਚ ਨੂੰ ਛੱਡੋ। ਅੱਪਸਟ੍ਰੀਮ ਪ੍ਰੋਵਾਈਡਰ ਫਿਰ ਵੀ ਆਪਣੀਆਂ ਅਸਲ ਸੀਮਾਵਾਂ ਲਾਗੂ ਕਰਦੇ ਹਨ। ਪ੍ਰੌਂਪਟ ਕੰਪ੍ਰੈਸ਼ਨ ਅਤੇ ਆਉਟਪੁੱਟ-ਟੋਕਨ ਸੀਮਾਵਾਂ ਸਰਗਰਮ ਰਹਿੰਦੀਆਂ ਹਨ।", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "ਪੰਨਾ ਨਹੀਂ ਮਿਲਿਆ", diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index 51bf3f513a..6e219167e1 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "I-anunsyo ang claude/<provider>/<model> mirror ids sa /v1/models upang ang Claude Code gateway model discovery ay maglista ng mga non-Claude models. Babala: nagdodoble ng catalog entries para sa lahat ng kliyente kapag pinagana nang globally.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Paganahin ang adaptive virtual admission lanes para sa bawat tenant sa pagpapadala ng provider (#9654): ang pag-akyat ng trapiko ng isang tenant ay hindi na nagbibigay ng 503 sa iba. Ang environment variable na OMNIROUTE_CHAT_VIRTUAL_LANES ay mas nangingibabaw sa setting na ito sa dashboard; magkakabisa ang mga pagbabago sa pag-restart ng server.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Bahay", "dashboard": "Dashboard", @@ -10957,6 +10961,9 @@ "strategyLatency": "Latency-optimized", "poolMembersLabel": "Mga miyembro ng pool ({count})", "poolNoMembers": "Wala pang mga proxy sa pool na ito.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Alisin", "poolRemoveFailed": "Hindi naalis ang proxy mula sa pool", "poolAddLabel": "Magdagdag ng proxy", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Payagan ang pagbawi ng stream na hilingin muli ang tugon at pagdugtungin ito pagkatapos makarating na ang mga byte sa client." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Lumipat nang minsan sa isang kapatid na koneksyon kapag nagsara ang stream bago magpadala ng anumang kapaki-pakinabang na frame at nagamit na ang limitadong muling pagsubok sa parehong koneksyon. Kung walang magagamit na kapatid na koneksyon, ibinabalik ang orihinal na error na early-EOF. Naka-off: nananatiling pinal ang early-EOF pagkatapos ng muling pagsubok." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Isama ang mga display-friendly na field ng pangalan sa mga tugon ng /v1/models. I-disable ito para sa mga client na tumatanggap lamang ng mga model ID." }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "I-enable ang access sa network sa skills sandbox." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Tanggihan ang mga kahilingan bago ang pagpapadala kapag ang target na modelo ay kulang sa mga kinakailangang kakayahan (paningin, mga tool, nakabalangkas na output, bintana ng konteksto). Pinoprotektahan ang mga direktang kahilingan mula sa isang tagapagbigay na lumalampas sa filter ng pagiging tugma ng combo-layer.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Hindi matagpuan ang pahina", diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index 6de7a9c3d2..8990f83e12 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Reklamuj identyfikatory luster claude/<provider>/<model> na /v1/models, aby brama modelu Claude Code wyświetlała listę modeli niebędących Claude. Uwaga: podwaja wpisy w katalogu dla wszystkich klientów, gdy jest włączone globalnie.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Włącz adaptacyjne wirtualne pasma przyjęć dla każdego tenanta przy wysyłce do dostawców (#9654): przeciążenie jednego tenanta nie powoduje już błędu 503 u innego. Zmienna środowiskowa OMNIROUTE_CHAT_VIRTUAL_LANES ma pierwszeństwo przed tym ustawieniem w panelu; zmiany wchodzą w życie po restarcie serwera.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Strona główna", "dashboard": "Dashboard", @@ -10957,6 +10961,9 @@ "strategyLatency": "Latency-optimized", "poolMembersLabel": "Członkowie puli ({count})", "poolNoMembers": "Brak proxy w tej puli.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Usuń", "poolRemoveFailed": "Nie udało się usunąć proxy z puli", "poolAddLabel": "Dodaj proxy", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Zezwalaj na odzyskiwanie strumienia w celu ponownego zażądania odpowiedzi i połączenia jej po tym, jak bajty dotarły już do klienta." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Jednorazowo przełącz na siostrzane połączenie, gdy strumień zamknie się przed wysłaniem jakiejkolwiek użytecznej ramki, a ograniczona ponowna próba na tym samym połączeniu została wykorzystana. Bez użytecznego siostrzanego połączenia zwracany jest pierwotny błąd early-EOF. Wyłączone: early-EOF pozostaje ostateczny po ponownej próbie." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Dołączaj przyjazne do wyświetlania pola nazw w odpowiedziach /v1/models. Wyłącz tę opcję dla klientów, którzy akceptują tylko identyfikatory modeli." }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Włącz dostęp do sieci w piaskownicy umiejętności." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Odrzuć żądania przed wysyłką, gdy docelowy model nie ma wymaganych możliwości (wizja, narzędzia, strukturalne wyjście, okno kontekstowe). Chroni bezpośrednie żądania od pojedynczego dostawcy, które omijają filtr zgodności warstwy kombinacyjnej.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Nie znaleziono strony", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index a0c44ce426..8f680263e3 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -993,6 +993,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Divulgar ids espelho claude/<provider>/<model> em /v1/models para que a descoberta de modelos do gateway Claude Code liste modelos não-Claude. Atenção: duplica as entradas do catálogo para todos os clientes quando ativado globalmente.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Ative faixas de admissão virtuais adaptativas por tenant para o despacho de provedores (#9654): o pico de um tenant não gera mais 503 para outro. A variável de ambiente OMNIROUTE_CHAT_VIRTUAL_LANES tem precedência sobre esta configuração do painel; as alterações entram em vigor ao reiniciar o servidor.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "Pools de proxy e a rotação por conta do opencode deixam de reutilizar um proxy que acabou de falhar (sonda TCP recusada, ou um 429 recebido por ele) por um período por processo que dobra a cada repetição, até um limite. Nenhum status de proxy é gravado; com todos os candidatos deixados de lado, a escolha não muda. Desligado por padrão: a ordem de seleção é exatamente a rotação simples.", + "featureFlagProxyPoolEgressObservationDescription": "Mostra, abaixo de um pool de proxy no painel, quantos IPs de saída observados atenderam seus membros nas últimas 24 h, quantas conexões os usaram e o máximo visto atrás de um mesmo IP. Somente leitura, calculado a partir do log de proxy, nunca usado para roteamento. Desligado por padrão: o editor de pool não muda e a rota de observação responde null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "Na varredura de saúde de proxy, permite que uma sonda recusada pelo destino (401/403/429: o proxy retransmitiu, o destino recusou este IP de saída) zere a sequência de falhas consecutivas do proxy, como uma sonda atendida. Desligado por padrão: a recusa continua neutra e mantém a sequência. Um 5xx continua inconclusivo em qualquer caso, e uma recusa nunca remove, desativa ou reativa um proxy.", "sidebar": { "home": "Início", "dashboard": "Painel", @@ -10965,6 +10969,9 @@ "strategyLatency": "Otimizado por latência", "poolMembersLabel": "Membros do pool ({count})", "poolNoMembers": "Ainda não há proxies neste pool.", + "poolEgressObservation": "{exits, plural, one {# saída} other {# saídas}} usada(s) por {connections, plural, one {# conexão} other {# conexões}} · até {max} em uma saída · últimas {hours} h", + "poolEgressObservationEmpty": "Nenhum tráfego observado nas últimas {hours} h", + "poolEgressObservationHint": "Contado a partir do log de proxy na janela: IPs de saída distintos observados para os membros deste pool e as conexões do OmniRoute que os usaram. \"Em uma saída\" significa ao longo da janela, não ao mesmo tempo. O bloqueio de 429 por IP só pausa conexões da mesma família de provedor.", "poolRemove": "Remover", "poolRemoveFailed": "Falha ao remover o proxy do pool", "poolAddLabel": "Adicionar um proxy", @@ -12985,6 +12992,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Permite que a recuperação de stream solicite a resposta novamente e a costure depois que bytes já chegaram ao cliente." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Faz failover uma única vez para uma conexão irmã quando um stream fecha antes de emitir qualquer frame útil e a nova tentativa limitada na mesma conexão já foi usada. Sem conexão irmã utilizável, o erro early-EOF original é retornado. Desligado: o early-EOF continua terminal após a nova tentativa." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Inclui campos de nome amigável para exibição nas respostas de /v1/models. Desative isso para clientes que aceitam apenas IDs de modelo." }, @@ -13021,6 +13034,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Server-Owned Tool Loop", "description": "Continue chamadas de ferramentas do servidor (server-owned) em não-streaming até que o modelo retorne uma resposta utilizável pelo cliente." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13634,6 +13655,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Rejeitar requisições antes do despacho quando o modelo alvo nao possui as capacidades necessarias (visao, ferramentas, saída estruturada, janela de contexto). Protege requisições diretas que ignoram o filtro de compatibilidade do combo.", "featureFlagDisableContextWindowChecksDescription": "Ignora a verificação local do OmniRoute para janela de contexto e limite máximo de tokens de entrada em solicitações diretas a um único modelo. Os provedores upstream continuam aplicando seus limites reais. A compactação de prompts e os limites de tokens de saída permanecem ativos.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Página não encontrada", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 48992b62ab..d428b35d82 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -993,6 +993,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Anuncie os ids de espelho claude/<provider>/<model> em /v1/models para que a descoberta de modelos do gateway Claude Code liste modelos não Claude. Aviso: duplica entradas de catálogo para todos os clientes quando ativado globalmente.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Ative filas de admissão virtuais adaptativas por tenant para o encaminhamento de fornecedores (#9654): um pico de tráfego de um tenant já não gera 503 noutro. A variável de ambiente OMNIROUTE_CHAT_VIRTUAL_LANES sobrepõe-se a esta definição do painel; as alterações entram em vigor ao reiniciar o servidor.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Página inicial", "dashboard": "Painel", @@ -10958,6 +10962,9 @@ "strategyLatency": "Otimizado para latência", "poolMembersLabel": "Membros do pool ({count})", "poolNoMembers": "Ainda não existem proxies neste pool.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Remover", "poolRemoveFailed": "Falha ao remover o proxy do pool", "poolAddLabel": "Adicionar um proxy", @@ -12978,6 +12985,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Permitir que a recuperação de stream solicite a resposta novamente e a junte após os bytes já terem chegado ao cliente." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Fazer failover uma única vez para uma ligação irmã quando um stream fecha antes de emitir qualquer frame útil e a nova tentativa limitada na mesma ligação já foi usada. Sem ligação irmã utilizável, é devolvido o erro early-EOF original. Desligado: o early-EOF mantém-se terminal após a nova tentativa." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Incluir campos de nome fáceis de ler nas respostas de /v1/models. Desative isto para clientes que aceitam apenas IDs de modelo." }, @@ -13010,6 +13023,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Ativar o acesso à rede na sandbox de competências." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13623,6 +13644,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Rejeitar pedidos antes do envio quando o modelo de destino não tiver as capacidades necessárias (visão, ferramentas, saída estruturada, janela de contexto). Protege pedidos diretos de um único fornecedor que contornam o filtro de compatibilidade da camada combinada.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Página não encontrada", diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index 63bea8addc..72505ae08d 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Publica id-urile mirror claude/<provider>/<model> pe /v1/models astfel încât lista de descoperire a modelului Claude Code să includă modele non-Claude. Atenție: dublează intrările din catalog pentru toți clienții când este activat global.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Activați benzile de admitere virtuale adaptive per-tenant pentru expedierea către furnizori (#9654): un vârf de trafic al unui tenant nu mai returnează 503 altui tenant. Variabila de mediu OMNIROUTE_CHAT_VIRTUAL_LANES are prioritate față de această setare din panou; modificările intră în vigoare la repornirea serverului.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Acasă", "dashboard": "Tabloul de bord", @@ -10957,6 +10961,9 @@ "strategyLatency": "Optimizat pentru latență", "poolMembersLabel": "Membrii pool-ului ({count})", "poolNoMembers": "Nu există încă proxy-uri în acest pool.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Elimină", "poolRemoveFailed": "Nu s-a putut elimina proxy-ul din pool", "poolAddLabel": "Adaugă un proxy", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Permite recuperării fluxului să solicite din nou răspunsul și să îl îmbine după ce octeții au ajuns deja la client." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Comută o singură dată pe o conexiune soră când un flux se închide înainte de a emite vreun cadru util și reîncercarea limitată pe aceeași conexiune a fost consumată. Fără o conexiune soră utilizabilă, se returnează eroarea early-EOF originală. Dezactivat: early-EOF rămâne final după reîncercare." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Include câmpuri de nume ușor de afișat în răspunsurile /v1/models. Dezactivează această opțiune pentru clienții care acceptă doar ID-uri de model." }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Activează accesul la rețea în sandbox-ul de abilități." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Respinge cererile înainte de expediere atunci când modelul țintă nu are capabilitățile necesare (viziune, instrumente, ieșire structurată, fereastră de context). Protejează cererile directe de un singur furnizor care ocolesc filtrul de compatibilitate al stratului combinat.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Pagina nu a fost găsită", diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index 22aa6ba84e..4a6947620a 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Публиковать claude/<провайдер>/<модель> зеркальные ID на /v1/models, чтобы Claude Code мог видеть не-Claude модели. Внимание: удваивает записи каталога для всех клиентов при глобальном включении.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Включите адаптивные виртуальные полосы допуска для каждого тенанта при маршрутизации к провайдерам (#9654): всплеск нагрузки одного тенанта больше не вызывает 503 у другого. Переменная окружения OMNIROUTE_CHAT_VIRTUAL_LANES имеет приоритет над этой настройкой в панели; изменения вступают в силу после перезапуска сервера.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Главная", "dashboard": "Панель управления", @@ -10957,6 +10961,9 @@ "strategyLatency": "Оптимизация по задержке", "poolMembersLabel": "Участники пула ({count})", "poolNoMembers": "В этом пуле пока нет прокси.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Удалить", "poolRemoveFailed": "Не удалось удалить прокси из пула", "poolAddLabel": "Добавить прокси", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Allow stream recovery to request the response again and stitch it after bytes have already reached the client." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Однократно переключиться на соседнее подключение, если поток закрылся, не отправив ни одного полезного фрейма, а ограниченная повторная попытка на том же подключении исчерпана. Если подходящего соседнего подключения нет, возвращается исходная ошибка early-EOF. Выкл.: early-EOF остаётся окончательным после повторной попытки." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Include display-friendly name fields in /v1/models responses. Disable this for clients that accept model IDs only." }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Enable network access in the skills sandbox." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Отклонять запросы перед отправкой, когда целевая модель не имеет необходимых возможностей (визуализация, инструменты, структурированный вывод, контекстное окно). Защищает прямые запросы от единственного поставщика, которые обходят фильтр совместимости комбинированного слоя.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Страница не найдена", diff --git a/src/i18n/messages/si.json b/src/i18n/messages/si.json index 18d09ef82f..b650ff5fef 100644 --- a/src/i18n/messages/si.json +++ b/src/i18n/messages/si.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Claude Code ද්වාරයේ මාදිලි සොයාගැනීමේදී Claude නොවන මාදිලි ලැයිස්තුගත කිරීම සඳහා /v1/models හි claude/<provider>/<model> කැඩපත් හැඳුනුම් ප්රචාරය කරන්න. අවවාදයයි: ගෝලීයව සබල කළ විට සියලු සේවාලාභීන් සඳහා නාමාවලි ඇතුළත් කිරීම් දෙගුණ වේ.", "featureFlagNoThinkingAliasEnabledDescription": "no-think/<provider>/<model> ද්වාර අන්වර්ථ සඳහා ප්රධාන ස්විචය. සක්රියයි (පෙරනිමිය): /v1/models මඟින් සුදුසුකම් ඇති, සිතා බැලීමේ හැකියාව සහිත සෑම Claude මාදිලියකටම සිතා බැලීමෙන් තොර ප්රභේදයක් ප්රචාරය කරන අතර, ඉල්ලීමක් සමඟ යවන no-think/ හැඳුනුමක් තර්කනය යටපත් කර සැබෑ මාදිලිය වෙත නැවත විසඳයි. අක්රියයි: කිසිදු ප්රභේදයක් ප්රචාරය නොකරන අතර no-think/ හැඳුනුමක් වෙනත් ඕනෑම නොදන්නා මාදිලි හැඳුනුමක් මෙන් සලකයි. මෙය සක්රියව තිබියදීත් එක් එක් මාදිලියට අදාළ ModelSpec.noThinkingAlias තෝරා සක්රිය කිරීම/අක්රිය කිරීම තවදුරටත් අදාළ වේ.", "featureFlagChatVirtualLanesEnabledDescription": "සපයන්නා වෙත යැවීම සඳහා එක් එක් ටෙනන්ට්ට අනුව අනුවර්තනය වන අතථ්ය ප්රවේශ මංතීරු සබල කරන්න (#9654): එක් ටෙනන්ට් කෙනෙකුගේ හදිසි ඉල්ලීම් වැඩිවීමක් තවදුරටත් වෙනත් අයෙකුට 503 දෝෂයක් ඇති නොකරයි. OMNIROUTE_CHAT_VIRTUAL_LANES පරිසර විචල්යය මෙම උපකරණ පුවරු අතික්රමණයට වඩා ප්රමුඛ වේ; වෙනස්කම් සේවාදායකය නැවත ආරම්භ කළ විට ක්රියාත්මක වේ.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "මුල් පිටුව", "dashboard": "උපකරණ පුවරුව", @@ -10964,6 +10968,9 @@ "strategyLatency": "ප්රමාදයට ප්රශස්ත කළ", "poolMembersLabel": "සංචිත සාමාජිකයන් ({count})", "poolNoMembers": "මෙම සංචිතයේ තවමත් ප්රොක්සි නොමැත.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "ඉවත් කරන්න", "poolRemoveFailed": "සංචිතයෙන් ප්රොක්සිය ඉවත් කිරීමට අසමත් විය", "poolAddLabel": "ප්රොක්සියක් එක් කරන්න", @@ -12984,6 +12991,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "බයිට් දැනටමත් සේවාලාභියා වෙත ළඟා වූ පසුවත් ප්රතිචාරය නැවත ඉල්ලා එය සම්බන්ධ කිරීමට ප්රවාහ ප්රතිසාධනයට ඉඩ දෙන්න." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "ප්‍රවාහය කිසිදු ප්‍රයෝජනවත් රාමුවක් යැවීමට පෙර වැසී, එකම සම්බන්ධතාවයේ සීමිත නැවත උත්සාහය ද අවසන් වූ විට වරක් සහෝදර සම්බන්ධතාවයකට මාරු වන්න. භාවිත කළ හැකි සහෝදර සම්බන්ධතාවයක් නොමැති නම් මුල් early-EOF දෝෂය ආපසු ලබා දේ. අක්‍රිය: නැවත උත්සාහයෙන් පසු early-EOF අවසාන ලෙස පවතී." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models ප්රතිචාරවල ප්රදර්ශනයට හිතකර නාම ක්ෂේත්ර ඇතුළත් කරන්න. ආකෘති ID පමණක් පිළිගන්නා සේවාලාභීන් සඳහා මෙය අක්රිය කරන්න." }, @@ -13020,6 +13033,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "සේවාදායකය සතු මෙවලම් ලූපය", "description": "ආකෘතිය සේවාලාභියාට භාවිත කළ හැකි ප්රතිචාරයක් ලබා දෙන තෙක් ප්රවාහ නොවන, සේවාදායකය සතු මෙවලම් ඇමතුම් දිගටම කරගෙන යන්න." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13654,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "ඉලක්ක මාදිලියට අවශ්ය හැකියාවන් (දෘශ්ය, මෙවලම්, ව්යුහගත ප්රතිදානය, සන්දර්භ කවුළුව) නොමැති විට යැවීමට පෙර ඉල්ලීම් ප්රතික්ෂේප කරන්න. මෙය සංයෝජන-ස්තර අනුකූලතා පෙරහන මඟහරින සෘජු තනි-සැපයුම්කරු ඉල්ලීම් ආරක්ෂා කරයි.", "featureFlagDisableContextWindowChecksDescription": "සෘජු තනි-මාදිලි ඉල්ලීම් සඳහා OmniRoute හි දේශීය සන්දර්භ-කවුළු සහ උපරිම-ආදාන-ටෝකන පරීක්ෂාව මඟහරින්න. උඩුගං සැපයුම්කරුවන් තවමත් ඔවුන්ගේ සැබෑ සීමාවන් බලාත්මක කරයි. ප්රේරක සම්පීඩනය සහ ප්රතිදාන-ටෝකන සීමා සක්රියව පවතී.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "පිටුව හමු නොවීය", diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index 5da7c79959..49c8f233d6 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Inzerujte claude/<provider>/<model> zrkadlové ID na /v1/models, aby zoznam objavovania modelov Claude Code obsahoval aj modely, ktoré nie sú Claude. Upozornenie: pri globálnom povolení zdvojuje záznamy v katalógu pre všetkých klientov.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Povoľte adaptívne virtuálne vstupné pruhy pre každého nájomcu (tenant) pri odosielaní poskytovateľom (#9654): špička jedného nájomcu už nespôsobí 503 u iného. Premenná prostredia OMNIROUTE_CHAT_VIRTUAL_LANES má prednosť pred týmto nastavením v riadiacom paneli; zmeny sa prejavia po reštarte servera.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Domov", "dashboard": "Dashboard", @@ -10957,6 +10961,9 @@ "strategyLatency": "Optimalizované podľa latencie", "poolMembersLabel": "Členovia poolu ({count})", "poolNoMembers": "V tomto poole zatiaľ nie sú žiadne proxy servery.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Odstrániť", "poolRemoveFailed": "Nepodarilo sa odstrániť proxy z poolu", "poolAddLabel": "Pridať proxy", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Povoliť obnovenie streamu na opätovné vyžiadanie odpovede a jej spojenie po tom, čo bajty už dorazili ku klientovi." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Jednorazovo prepnúť na sesterské pripojenie, keď sa stream zatvorí pred odoslaním akéhokoľvek užitočného rámca a obmedzený opakovaný pokus na tom istom pripojení je vyčerpaný. Ak nie je k dispozícii použiteľné sesterské pripojenie, vráti sa pôvodná chyba early-EOF. Vypnuté: early-EOF zostáva po opakovanom pokuse konečný." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Zahrnúť polia s používateľsky prívetivými názvami v odpovediach /v1/models. Zakážte túto možnosť pre klientov, ktorí prijímajú iba ID modelov." }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Povoliť sieťový prístup v sandboxe zručností." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Zamietnuť požiadavky pred odoslaním, keď cieľový model postráda požadované schopnosti (vízia, nástroje, štruktúrovaný výstup, kontextové okno). Chráni priamu požiadavku od jedného poskytovateľa, ktorá obchádza filter kompatibility kombinovanej vrstvy.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Stránka nenájdená", diff --git a/src/i18n/messages/sl.json b/src/i18n/messages/sl.json index 89cc39e71e..dc2ebee38f 100644 --- a/src/i18n/messages/sl.json +++ b/src/i18n/messages/sl.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Objavi zrcalne ID-je claude/<provider>/<model> na /v1/models, da odkrivanje modelov prehoda Claude Code prikaže tudi modele, ki niso Claude. Opozorilo: če je možnost omogočena globalno, se število vnosov v katalogu podvoji za vse odjemalce.", "featureFlagNoThinkingAliasEnabledDescription": "Glavno stikalo za vzdevke prehoda no-think/<provider>/<model>. Vklopljeno (privzeto): /v1/models objavi različico brez razmišljanja za vsak primeren model Claude, ki podpira razmišljanje, ID no-think/, poslan v zahtevi, pa se razreši nazaj v pravi model z onemogočenim sklepanjem. Izklopljeno: različice niso objavljene, ID no-think/ pa se obravnava kot kateri koli drug neznan ID modela. Ko je ta možnost vklopljena, še vedno velja nastavitev ModelSpec.noThinkingAlias za prijavo/odjavo posameznega modela.", "featureFlagChatVirtualLanesEnabledDescription": "Omogoči prilagodljive navidezne sprejemne pasove za posameznega najemnika pri posredovanju ponudniku (#9654): nenaden porast zahtev enega najemnika ne povzroča več napak 503 pri drugem. Spremenljivka okolja OMNIROUTE_CHAT_VIRTUAL_LANES ima prednost pred to nastavitvijo nadzorne plošče; spremembe začnejo veljati po ponovnem zagonu strežnika.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Domov", "dashboard": "Nadzorna plošča", @@ -10964,6 +10968,9 @@ "strategyLatency": "Optimizirano glede na zakasnitev", "poolMembersLabel": "Člani skupine ({count})", "poolNoMembers": "V tej skupini še ni posredniških strežnikov.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Odstrani", "poolRemoveFailed": "Posredniškega strežnika ni bilo mogoče odstraniti iz skupine", "poolAddLabel": "Dodaj posredniški strežnik", @@ -12984,6 +12991,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Obnovi toka omogoči, da znova zahteva odgovor in ga sestavi, potem ko so bajti že dosegli odjemalca." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Enkrat preklopi na sestrsko povezavo, ko se tok zapre, preden pošlje kakršen koli uporaben okvir, in je omejeni ponovni poskus na isti povezavi porabljen. Če uporabne sestrske povezave ni, se vrne izvirna napaka early-EOF. Izklopljeno: early-EOF po ponovnem poskusu ostane dokončen." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "V odgovore /v1/models vključi uporabniku prijazna polja z imeni. To onemogočite za odjemalce, ki sprejemajo samo ID-je modelov." }, @@ -13020,6 +13033,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Strežniško upravljana zanka orodij", "description": "Nadaljuj nestreamne strežniško upravljane klice orodij, dokler model ne vrne odziva, uporabnega za odjemalca." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13654,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Zavrni zahteve pred posredovanjem, ko ciljni model nima zahtevanih zmogljivosti (vid, orodja, strukturiran izhod, kontekstno okno). Ščiti neposredne zahteve za enega ponudnika, ki obidejo filter združljivosti kombinacijskega sloja.", "featureFlagDisableContextWindowChecksDescription": "Preskoči lokalno preverjanje kontekstnega okna in največjega števila vhodnih žetonov v OmniRoute za neposredne zahteve za posamezen model. Ponudniki v zaledju še vedno uveljavljajo svoje dejanske omejitve. Stiskanje pozivov in omejitve izhodnih žetonov ostanejo aktivni.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Strani ni mogoče najti", diff --git a/src/i18n/messages/sr.json b/src/i18n/messages/sr.json index 4609abce4d..6aa55b927f 100644 --- a/src/i18n/messages/sr.json +++ b/src/i18n/messages/sr.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Оглашавај claude/<provider>/<model> mirror идентификаторе на /v1/models тако да Claude Code gateway откривање модела приказује и модели који нису Claude. Упозорење: дуплира ставке каталога за све клијенте када је омогућено глобално.", "featureFlagNoThinkingAliasEnabledDescription": "Главни прекидач за no-think/<provider>/<model> gateway алиасе. Укључено (подразумевано): /v1/models оглашава варијанту без размишљања за сваки подобан Claude модел способан за размишљање, а идентификатор no-think/ послат у захтеву се разрешава на стварни модел са потиснутим резоновањем. Искључено: варијанте се не оглашавају, а идентификатор no-think/ се третира као и сваки други непознат идентификатор модела. Опција по моделу ModelSpec.noThinkingAlias за укључивање/искључивање се и даље примењује док је ово укључено.", "featureFlagChatVirtualLanesEnabledDescription": "Омогући по-закупцу адаптивне виртуелне линије пријема за расподелу провајдера (#9654): нагли скок захтева једног закупца више не изазива 503 грешку код другог. Env варијабла OMNIROUTE_CHAT_VIRTUAL_LANES има приоритет над овим прекидачем у контролној табли; промене се примењују при поновном покретању сервера.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Почетна", "dashboard": "Контролна табла", @@ -10964,6 +10968,9 @@ "strategyLatency": "Оптимизовано по кашњењу", "poolMembersLabel": "Чланови групе ({count})", "poolNoMembers": "Нема proxy-ja у овој групи још.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Уклони", "poolRemoveFailed": "Уклањање proxy-ja из групе није успело", "poolAddLabel": "Додај proxy", @@ -12984,6 +12991,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Дозволи опоравку тока да поново затражи одговор и споји га након што су бајтови већ стигли до клијента." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Једном пребаци на сродну везу када се ток затвори пре слања иједног корисног оквира, а ограничени поновни покушај на истој вези је искоришћен. Ако нема употребљиве сродне везе, враћа се изворна early-EOF грешка. Искључено: early-EOF остаје коначан након поновног покушаја." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Укључи поља са именом прилагођеним за приказ у одговорима /v1/models. Онемогући ово за клијенте који прихватају само ID-ове модела." }, @@ -13020,6 +13033,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Серверска петља алата", "description": "Наставите нестримујуће серверске позиве алата док модел не врати одговор који клијент може да користи." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13633,6 +13654,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Одбиј захтеве пре слања када циљни модел не поседује потребне могућности (визуелни унос, алати, структурирани излаз, контекстни прозор). Штити директне захтеве ка појединачном добављачу који заобилазе филтер компатибилности комбо-слоја.", "featureFlagDisableContextWindowChecksDescription": "Прескочи локалну провера контекстног прозора и максималног броја улазних токена OmniRoute-а за директне захтеве ка појединачном моделу. Добављачи услуга и даље примењују своја стварна ограничења. Компресија упита и ограничења излазних токена остају активни.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Страница није пронађена", diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index 8735d3ee6a..24f39a19fc 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Reklamera claude/<provider>/<model> spegel-id på /v1/models så att Claude Code gateway-modellens upptäcktslista visar icke-Claude-modeller. Varning: dubblerar katalogposter för alla klienter när det är aktiverat globalt.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Aktivera adaptiva virtuella åtkomstfiler per tenant för providerutskick (#9654): en tenants burst ger inte längre en annan 503. Miljövariabeln OMNIROUTE_CHAT_VIRTUAL_LANES har företräde framför den här inställningen i instrumentpanelen; ändringarna träder i kraft vid omstart av servern.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Hem", "dashboard": "Instrumentpanel", @@ -10957,6 +10961,9 @@ "strategyLatency": "Latensoptimerad", "poolMembersLabel": "Poolmedlemmar ({count})", "poolNoMembers": "Inga proxyservrar i denna pool ännu.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Ta bort", "poolRemoveFailed": "Misslyckades med att ta bort proxyn från poolen", "poolAddLabel": "Lägg till en proxy", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Tillåt strömåterställning att begära svaret igen och sammanfoga det efter att byte redan har nått klienten." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Växla en gång över till en syskonanslutning när en ström stängs innan den har skickat någon användbar ram och det begränsade nya försöket på samma anslutning är förbrukat. Utan en användbar syskonanslutning returneras det ursprungliga early-EOF-felet. Av: early-EOF förblir slutgiltigt efter det nya försöket." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Inkludera visningsvänliga namnfält i svar från /v1/models. Inaktivera detta för klienter som endast accepterar modell-ID:n." }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Aktivera nätverksåtkomst i kompetenssandlådan." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Avvisa förfrågningar innan de skickas när målmodellen saknar nödvändiga funktioner (vision, verktyg, strukturerad utdata, kontextfönster). Skyddar direkta förfrågningar från en enda leverantör som kringgår kompatibilitetsfiltret för kombinationslager.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Sidan kunde inte hittas", diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index f4ccf25ebd..629174be52 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Tangaza claude/<provider>/<model> vitambulisho vya kioo kwenye /v1/models ili orodha ya kugundua modeli za Claude Code iwe na modeli zisizo za Claude. Onyo: inafanya kuingia mara mbili kwenye katalogi kwa wateja wote inapowekwa kuwa ya ulimwengu mzima.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Washa njia za uandikishaji pepe zinazobadilika kwa kila mpangaji (tenant) kwa utumaji wa watoa huduma (#9654): mlipuko wa mpangaji mmoja hautoi tena 503 kwa mwingine. Kigezo cha mazingira cha OMNIROUTE_CHAT_VIRTUAL_LANES kinashinda mpangilio huu wa dashibodi; mabadiliko yanatumika wakati seva inapoanzishwa upya.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Home", "dashboard": "Dashboard", @@ -10957,6 +10961,9 @@ "strategyLatency": "Iliyoboreshwa kwa ucheleweshaji", "poolMembersLabel": "Wanachama wa dimbwi ({count})", "poolNoMembers": "Hakuna proksi katika dimbwi hili bado.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Ondoa", "poolRemoveFailed": "Imeshindwa kuondoa proksi kwenye dimbwi", "poolAddLabel": "Ongeza proksi", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Ruhusu urejesho wa mkondo kuomba jibu tena na kuliunganisha baada ya baiti kuwa tayari zimefikia mteja." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Hamia mara moja kwenye muunganisho ndugu wakati mkondo unafungwa kabla ya kutuma fremu yoyote yenye manufaa na jaribio la marudio lenye kikomo kwenye muunganisho uleule limekwisha. Bila muunganisho ndugu unaoweza kutumika, hitilafu asili ya early-EOF hurejeshwa. Imezimwa: early-EOF inabaki ya mwisho baada ya jaribio la marudio." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Jumuisha sehemu za majina rahisi kuonyeshwa katika majibu ya /v1/models. Zima hii kwa wateja wanaokubali vitambulisho vya mfano pekee." }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Wezesha ufikiaji wa mtandao katika sandbox ya ujuzi." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "kataa maombi kabla ya kutuma wakati mfano wa lengo hauna uwezo unaohitajika (maono, zana, matokeo yaliyoandikwa, dirisha la muktadha). Inalinda maombi ya moja kwa moja kutoka kwa mtoa huduma mmoja ambayo yanapita chujio cha ulinganifu wa safu ya mchanganyiko.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Ukurasa haukupatikana", diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index 83c6a6151d..d9c7b74fe1 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "/v1/models இல் Claude Code gateway மாதிரி கண்டுபிடிப்பு பட்டியலில் non-Claude மாதிரிகளை காட்ட Claude/<provider>/<model> மின்னூல் அடையாளங்களை விளம்பரம் செய்யவும். எச்சரிக்கை: உலகளாவியமாக செயல்படுத்தப்பட்டால் அனைத்து கிளையன்டுகளுக்கும் பட்டியல் பதிவுகளை இரட்டைப்படுத்துகிறது.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "வழங்குநர் அனுப்பீட்டிற்கு ஒவ்வொரு குத்தகைதாரருக்கும் (tenant) தகவமைப்பு மெய்நிகர் சேர்க்கைப் பாதைகளை இயக்கு (#9654): ஒரு குத்தகைதாரரின் அதிகரிப்பு இனி மற்றொருவருக்கு 503 ஐ அளிக்காது. OMNIROUTE_CHAT_VIRTUAL_LANES சூழல் மாறி இந்த டாஷ்போர்டு அமைப்பை விட முன்னுரிமை பெறுகிறது; மாற்றங்கள் சேவையகம் மறுதொடக்கத்தில் நடைமுறைக்கு வரும்.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Home", "dashboard": "Dashboard", @@ -10957,6 +10961,9 @@ "strategyLatency": "Latency-optimized", "poolMembersLabel": "தொகுப்பு உறுப்பினர்கள் ({count})", "poolNoMembers": "இந்தத் தொகுப்பில் இன்னும் proxy-கள் எதுவும் இல்லை.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "அகற்று", "poolRemoveFailed": "தொகுப்பிலிருந்து proxy-ஐ அகற்றுவது தோல்வியடைந்தது", "poolAddLabel": "ஒரு proxy-ஐச் சேர்", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "பைட்டுகள் ஏற்கனவே கிளையண்டை அடைந்த பிறகும், பதிலை மீண்டும் கோரவும் அதை இணைக்கவும் ஸ்ட்ரீம் மீட்டெடுப்பை அனுமதிக்கவும்." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "ஸ்ட்ரீம் பயனுள்ள எந்த ஃப்ரேமையும் அனுப்பும் முன் மூடப்பட்டு, அதே இணைப்பில் வரம்பிடப்பட்ட மறுமுயற்சியும் தீர்ந்துவிட்டால், ஒருமுறை சகோதர இணைப்புக்கு ஃபெயில்ஓவர் செய்யவும். பயன்படுத்தக்கூடிய சகோதர இணைப்பு இல்லையெனில் அசல் early-EOF பிழை திருப்பியனுப்பப்படும். முடக்கம்: மறுமுயற்சிக்குப் பிறகு early-EOF இறுதியாகவே இருக்கும்." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models பதில்களில் காட்சிக்கு ஏற்ற பெயர் புலங்களைச் சேர்க்கவும். மாடல் ஐடிகளை மட்டுமே ஏற்கும் கிளையண்டுகளுக்கு இதை முடக்கவும்." }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "skills சாண்ட்பாக்ஸில் நெட்வொர்க் அணுகலை இயக்கவும்." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "விருப்பமான மாதிரி தேவையான திறன்களை (காணல், கருவிகள், கட்டமைக்கப்பட்ட வெளியீடு, சூழல் ஜன்னல்) இன்றி இருந்தால், அனுப்புவதற்கு முன் கோரிக்கைகளை நிராகரிக்கவும். கம்போ-லேயர் ஒத்திசைவு வடிகட்டியை தவிர்க்கும் நேரடி ஒற்றை வழங்குநர் கோரிக்கைகளை பாதுகாக்கிறது.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "பக்கம் கிடைக்கவில்லை", diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index 9d263d3c46..d0ace81b9f 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "/v1/models లో claude/<provider>/<model> మిర్రర్ ఐడీలను ప్రచారం చేయండి కాబట్టి Claude Code గేట్వే మోడల్ డిస్కవరీ non-Claude మోడళ్లను జాబితా చేస్తుంది. హెచ్చరిక: ఇది ప్రపంచవ్యాప్తంగా ప్రారంభించినప్పుడు అన్ని క్లయింట్ల కోసం కాటలాగ్ ఎంట్రీలను డబుల్ చేస్తుంది.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "ప్రొవైడర్ డిస్పాచ్ కోసం ప్రతి-టెనెంట్ అడాప్టివ్ వర్చువల్ అడ్మిషన్ లేన్లను ప్రారంభించండి (#9654): ఒక టెనెంట్ బర్స్ట్ ఇకపై మరొక టెనెంట్కు 503 ఇవ్వదు. OMNIROUTE_CHAT_VIRTUAL_LANES ఎన్విరాన్మెంట్ వేరియబుల్ ఈ డాష్బోర్డ్ సెట్టింగ్ కంటే ప్రాధాన్యత పొందుతుంది; మార్పులు సర్వర్ పునఃప్రారంభంలో ప్రభావం చూపుతాయి.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Home", "dashboard": "Dashboard", @@ -10957,6 +10961,9 @@ "strategyLatency": "లేటెన్సీ-ఆప్టిమైజ్డ్", "poolMembersLabel": "పూల్ సభ్యులు ({count})", "poolNoMembers": "ఈ పూల్‌లో ఇంకా ప్రాక్సీలు లేవు.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "తొలగించు", "poolRemoveFailed": "పూల్ నుండి ప్రాక్సీని తొలగించడం విఫలమైంది", "poolAddLabel": "ప్రాక్సీని జోడించండి", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "బైట్లు ఇప్పటికే క్లయింట్‌కు చేరిన తర్వాత కూడా ప్రతిస్పందనను మళ్లీ అభ్యర్థించడానికి మరియు దానిని జత చేయడానికి స్ట్రీమ్ రికవరీని అనుమతించండి." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "స్ట్రీమ్ ఉపయోగకరమైన ఏ ఫ్రేమ్‌నూ పంపకముందే మూసుకుపోయి, అదే కనెక్షన్‌పై పరిమిత పునఃప్రయత్నం కూడా అయిపోతే, ఒకసారి సోదర కనెక్షన్‌కు ఫెయిల్‌ఓవర్ చేయండి. ఉపయోగించదగిన సోదర కనెక్షన్ లేకపోతే అసలు early-EOF లోపం తిరిగి ఇవ్వబడుతుంది. ఆఫ్: పునఃప్రయత్నం తర్వాత early-EOF తుది ఫలితంగానే ఉంటుంది." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models ప్రతిస్పందనలలో ప్రదర్శనకు అనుకూలమైన పేరు ఫీల్డ్‌లను చేర్చండి. మోడల్ IDలను మాత్రమే ఆమోదించే క్లయింట్‌ల కోసం దీనిని నిలిపివేయండి." }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "స్కిల్స్ శాండ్‌బాక్స్‌లో నెట్‌వర్క్ యాక్సెస్‌ను ప్రారంభించండి." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "ప్రయోజనాలు అవసరమైన సామర్థ్యాలు (దృష్టి, సాధనాలు, నిర్మిత అవుట్‌పుట్, సందర్భం విండో) లేని లక్ష్య మోడల్ ముందు పంపిణీకి అభ్యర్థనలను తిరస్కరించండి. కాంబో-లేయర్ అనుకూలత ఫిల్టర్‌ను దాటించే ప్రత్యక్ష సింగిల్-ప్రొవైడర్ అభ్యర్థనలను రక్షిస్తుంది.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "పేజీ కనుగొనబడలేదు", diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index c86a8da842..9ad2fe5e89 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "โฆษณา claude/<provider>/<model> mirror ids บน /v1/models เพื่อให้รายการการค้นหาโมเดลของ Claude Code แสดงโมเดลที่ไม่ใช่ Claude เตือน: จะทำให้มีรายการในแคตตาล็อกซ้ำสำหรับลูกค้าทุกคนเมื่อเปิดใช้งานทั่วโลก.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "เปิดใช้เลนรับเข้าเสมือนแบบปรับตัวต่อเทนแนนต์สำหรับการส่งไปยังผู้ให้บริการ (#9654): การพุ่งสูงของเทนแนนต์หนึ่งจะไม่ทำให้อีกเทนแนนต์ได้รับ 503 อีกต่อไป ตัวแปรสภาพแวดล้อม OMNIROUTE_CHAT_VIRTUAL_LANES มีผลเหนือการตั้งค่าแดชบอร์ดนี้ การเปลี่ยนแปลงมีผลเมื่อรีสตาร์ทเซิร์ฟเวอร์", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "บ้าน", "dashboard": "แดชบอร์ด", @@ -10957,6 +10961,9 @@ "strategyLatency": "ปรับตามเวลาแฝงให้เหมาะสม", "poolMembersLabel": "สมาชิกในพูล ({count})", "poolNoMembers": "ยังไม่มีพร็อกซีในพูลนี้", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "ลบออก", "poolRemoveFailed": "ลบพร็อกซีออกจากพูลไม่สำเร็จ", "poolAddLabel": "เพิ่มพร็อกซี", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "อนุญาตให้การกู้คืนสตรีมขอรับการตอบกลับอีกครั้งและต่อข้อมูลเข้าด้วยกันหลังจากที่ไบต์ไปถึงไคลเอนต์แล้ว" }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "สลับไปยังการเชื่อมต่อพี่น้องหนึ่งครั้งเมื่อสตรีมปิดก่อนส่งเฟรมที่มีประโยชน์ใด ๆ และการลองใหม่แบบจำกัดบนการเชื่อมต่อเดิมถูกใช้ไปแล้ว หากไม่มีการเชื่อมต่อพี่น้องที่ใช้งานได้ ระบบจะส่งคืนข้อผิดพลาด early-EOF เดิม ปิด: early-EOF ยังคงเป็นผลสิ้นสุดหลังการลองใหม่" + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "รวมฟิลด์ชื่อที่แสดงผลได้ง่ายในการตอบกลับ /v1/models ปิดใช้งานตัวเลือกนี้สำหรับไคลเอนต์ที่ยอมรับเฉพาะ ID โมเดลเท่านั้น" }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "เปิดใช้งานการเข้าถึงเครือข่ายใน skills sandbox" + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "ปฏิเสธคำขอก่อนการส่งเมื่อโมเดลเป้าหมายขาดความสามารถที่จำเป็น (วิสัยทัศน์, เครื่องมือ, ผลลัพธ์ที่มีโครงสร้าง, หน้าต่างบริบท) ป้องกันคำขอจากผู้ให้บริการเดียวที่ข้ามตัวกรองความเข้ากันได้ของเลเยอร์รวม", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "ไม่พบหน้า", diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index 3a8bcaa0b8..a2c1a7125b 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "/v1/models üzerinde claude/<provider>/<model> ayna kimliklerini tanıtın, böylece Claude Code geçidi model keşfi, Claude olmayan modelleri listeler. Uyarı: Küresel olarak etkinleştirildiğinde tüm istemciler için katalog girişlerini iki katına çıkarır.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Sağlayıcı gönderimi için kiracı başına uyarlanabilir sanal kabul şeritlerini etkinleştirin (#9654): bir kiracının ani yükü artık diğerinde 503 hatasına neden olmaz. OMNIROUTE_CHAT_VIRTUAL_LANES ortam değişkeni bu panel ayarına göre önceliklidir; değişiklikler sunucu yeniden başlatıldığında geçerli olur.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Ana Sayfa", "dashboard": "Kontrol Paneli", @@ -10957,6 +10961,9 @@ "strategyLatency": "Gecikme optimizasyonlu", "poolMembersLabel": "Havuz üyeleri ({count})", "poolNoMembers": "Bu havuzda henüz proxy yok.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Kaldır", "poolRemoveFailed": "Proxy havuzdan kaldırılamadı", "poolAddLabel": "Proxy ekle", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Baytlar istemciye ulaştıktan sonra akış kurtarmanın yanıtı tekrar istemesine ve birleştirmesine izin verin." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Akış herhangi bir yararlı çerçeve göndermeden kapandığında ve aynı bağlantıdaki sınırlı yeniden deneme tükendiğinde bir kez kardeş bağlantıya geçiş yapın. Kullanılabilir kardeş bağlantı yoksa özgün early-EOF hatası döndürülür. Kapalı: early-EOF yeniden denemeden sonra nihai kalır." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models yanıtlarına görüntüleme dostu ad alanlarını dahil edin. Yalnızca model kimliklerini kabul eden istemciler için bunu devre dışı bırakın." }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Yetenekler korumalı alanında (skills sandbox) ağ erişimini etkinleştirin." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Hedef model gerekli yeteneklere (görüş, araçlar, yapılandırılmış çıktı, bağlam penceresi) sahip olmadığında, gönderimden önce istekleri reddedin. Kombinasyon katmanı uyumluluk filtresini atlayan doğrudan tek sağlayıcı isteklerini korur.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Sayfa bulunamadı", diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index b517d5bf0b..601254f7a2 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Рекламуйте claude/<provider>/<model> mirror ids на /v1/models, щоб модель виявлення Claude Code gateway перераховувала не Claude моделі. Увага: подвоює записи каталогу для всіх клієнтів, коли увімкнено глобально.", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "Увімкніть адаптивні віртуальні смуги допуску для кожного тенанта під час надсилання провайдерам (#9654): сплеск навантаження одного тенанта більше не викликає 503 в іншого. Змінна середовища OMNIROUTE_CHAT_VIRTUAL_LANES має пріоритет над цим налаштуванням у панелі; зміни набувають чинності після перезапуску сервера.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "додому", "dashboard": "Приладова панель", @@ -10957,6 +10961,9 @@ "strategyLatency": "Оптимізований за затримкою", "poolMembersLabel": "Учасники пулу ({count})", "poolNoMembers": "У цьому пулі ще немає проксі.", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "Видалити", "poolRemoveFailed": "Не вдалося видалити проксі з пулу", "poolAddLabel": "Додати проксі", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Дозволити відновленню потоку повторно запитувати відповідь і зшивати її після того, як байти вже дійшли до клієнта." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Одноразово перемкнутися на сусіднє підключення, якщо потік закрився, не надіславши жодного корисного фрейму, а обмежену повторну спробу на тому самому підключенні вичерпано. Якщо придатного сусіднього підключення немає, повертається початкова помилка early-EOF. Вимкнено: early-EOF залишається остаточним після повторної спроби." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Включати зручні для відображення поля назв у відповіді /v1/models. Вимкніть це для клієнтів, які приймають лише ідентифікатори моделей." }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "Увімкнути доступ до мережі в пісочниці навичок." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Відхиляйте запити перед відправкою, коли цільова модель не має необхідних можливостей (зір, інструменти, структурований вихід, контекстне вікно). Захищає прямі запити від одного постачальника, які обходять фільтр сумісності комбінаційного шару.", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Сторінку не знайдено", diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index 6b244f374c..3f782246af 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "/v1/models پر claude/<provider>/<model> آئینہ شناختوں کا اشتہار دیں تاکہ Claude Code گیٹ وے ماڈل کی دریافت غیر-Claude ماڈلز کی فہرست بنائے۔ انتباہ: جب عالمی طور پر فعال ہو تو تمام کلائنٹس کے لیے کیٹلاگ کی اندراجات دوگنا کرتا ہے۔", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "پرووائیڈر بھیجنے کے لیے فی ٹیننٹ انکولی ورچوئل ایڈمیشن لین فعال کریں (#9654): ایک ٹیننٹ کا اچانک بوجھ اب دوسرے ٹیننٹ کو 503 نہیں دیتا۔ OMNIROUTE_CHAT_VIRTUAL_LANES ماحولیاتی متغیر اس ڈیش بورڈ سیٹنگ پر فوقیت رکھتا ہے؛ تبدیلیاں سرور دوبارہ شروع ہونے پر اثر انداز ہوتی ہیں۔", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "Home", "dashboard": "Dashboard", @@ -10957,6 +10961,9 @@ "strategyLatency": "لیٹنسی-آپٹمائزڈ", "poolMembersLabel": "پول کے ارکان ({count})", "poolNoMembers": "اس پول میں ابھی تک کوئی پروکسی نہیں ہے۔", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "ہٹائیں", "poolRemoveFailed": "پول سے پروکسی کو ہٹانے میں ناکامی", "poolAddLabel": "ایک پروکسی شامل کریں", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "اسٹریم ریکوری کو دوبارہ جواب کی درخواست کرنے اور بائٹس کے پہلے ہی کلائنٹ تک پہنچنے کے بعد اسے جوڑنے کی اجازت دیں۔" }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "جب اسٹریم کوئی مفید فریم بھیجنے سے پہلے بند ہو جائے اور اسی کنکشن پر محدود دوبارہ کوشش ختم ہو چکی ہو تو ایک بار کسی ہم رشتہ کنکشن پر فیل اوور کریں۔ قابلِ استعمال ہم رشتہ کنکشن نہ ہونے پر اصل early-EOF خرابی واپس کی جاتی ہے۔ بند: دوبارہ کوشش کے بعد early-EOF حتمی رہتا ہے۔" + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "/v1/models کے جوابات میں ڈسپلے کے لیے موزوں نام کے فیلڈز شامل کریں۔ ان کلائنٹس کے لیے اسے غیر فعال کریں جو صرف ماڈل IDs قبول کرتے ہیں۔" }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "اسکلز سینڈ باکس میں نیٹ ورک تک رسائی کو فعال کریں۔" + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "جب ہدف ماڈل میں ضروری صلاحیتیں (نظریات، ٹولز، منظم آؤٹ پٹ، سیاق و سباق کی کھڑکی) نہیں ہوتیں تو بھیجنے سے پہلے درخواستوں کو مسترد کریں۔ یہ براہ راست واحد فراہم کنندہ کی درخواستوں کی حفاظت کرتا ہے جو کمبو-لیئر کی ہم آہنگی کے فلٹر کو نظر انداز کرتی ہیں۔", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "صفحہ نہیں ملا", diff --git a/src/i18n/messages/uz.json b/src/i18n/messages/uz.json index 4e89d3abf3..0242835290 100644 --- a/src/i18n/messages/uz.json +++ b/src/i18n/messages/uz.json @@ -10970,7 +10970,10 @@ "poolAddMember": "Qo‘shish", "poolAddFailed": "Proksini pulga qo‘shib bo‘lmadi", "poolSelectProxy": "Proksini tanlang…", - "poolSaveFailed": "Pulga biriktirishni saqlab bo‘lmadi" + "poolSaveFailed": "Pulga biriktirishni saqlab bo‘lmadi", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family." }, "playground": { "title": "Model sinov maydoni", @@ -13020,6 +13023,20 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Server boshqaruvidagi vositalar sikli", "description": "Model mijoz foydalanishi mumkin boʻlgan javobni qaytarmaguncha oqimsiz server boshqaruvidagi vosita chaqiruvlarini davom ettiring." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths.", + "label": "__MISSING__:Retry-After Provenance" + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip.", + "label": "__MISSING__:Protected-Priority Infra Stops as 502" + }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "__MISSING__:Fail over once to a sibling connection when a stream closes before emitting any useful frame and the bounded same-connection retry is spent. With no usable sibling, the original early-EOF error is returned. Off: early-EOF stays terminal after the retry." } } }, @@ -14143,5 +14160,10 @@ "partnerLinkNote": "Hamkorlik havolasi", "dismissAriaLabel": "Yopish" }, - "featureFlagOmnirouteDisableThinkingLevelVariantsDescription": "/v1/models katalogida fikrlash darajasi variantlarini (masalan, -low, -medium, -high) yaratishni o‘chirib qo‘ying." + "featureFlagOmnirouteDisableThinkingLevelVariantsDescription": "/v1/models katalogida fikrlash darajasi variantlarini (masalan, -low, -medium, -high) yaratishni o‘chirib qo‘ying.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "__MISSING__:Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "__MISSING__:Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy." } diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 93033f589d..426c36546d 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -993,6 +993,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "Quảng bá các id phản chiếu claude/<provider>/<model> trên /v1/models để tính năng khám phá mô hình qua gateway của Claude Code liệt kê được các mô hình không phải Claude. Cảnh báo: khi bật ở phạm vi toàn cục, số mục trong danh mục tăng gấp đôi với mọi client.", "featureFlagNoThinkingAliasEnabledDescription": "Công tắc chính cho các bí danh gateway no-think/<provider>/<model>. Bật (mặc định): /v1/models quảng bá biến thể không suy nghĩ cho mọi mô hình Claude có khả năng suy nghĩ đủ điều kiện, và id no-think/ được gửi trên một yêu cầu sẽ giải quyết lại về mô hình thực với phần lý luận bị triệt tiêu. Tắt: không có biến thể nào được quảng bá và id no-think/ được xử lý như bất kỳ id mô hình không xác định nào khác. Tùy chọn tham gia/từ chối ModelSpec.noThinkingAlias theo từng mô hình vẫn áp dụng khi tính năng này bật.", "featureFlagChatVirtualLanesEnabledDescription": "Bật làn tiếp nhận ảo thích ứng cho từng đối tượng thuê (tenant) để phân phối nhà cung cấp (#9654): một đợt bùng phát của tenant này không còn trả 503 cho tenant khác. Biến môi trường OMNIROUTE_CHAT_VIRTUAL_LANES được ưu tiên hơn cài đặt bảng điều khiển này; các thay đổi có hiệu lực khi khởi động lại máy chủ.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "Các pool proxy và cơ chế xoay vòng theo tài khoản của opencode ngừng dùng lại một proxy vừa thất bại (thăm dò TCP bị từ chối, hoặc nhận 429 qua proxy đó) trong một khoảng thời gian theo tiến trình, tăng gấp đôi sau mỗi lần lặp lại, tối đa đến một giới hạn. Không ghi trạng thái proxy nào; khi mọi ứng viên đều bị tạm gác, lựa chọn vẫn như cũ. Tắt theo mặc định: thứ tự chọn giữ nguyên xoay vòng thông thường.", + "featureFlagProxyPoolEgressObservationDescription": "Hiển thị, bên dưới một nhóm proxy trong bảng điều khiển, số IP đầu ra quan sát được đã phục vụ các thành viên của nhóm trong 24 giờ qua, số kết nối đã dùng chúng và số lớn nhất thấy sau cùng một IP. Chỉ đọc, tính từ nhật ký proxy, không bao giờ dùng để định tuyến. Tắt theo mặc định: trình chỉnh sửa nhóm không đổi và tuyến quan sát trả về null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "Trong lượt kiểm tra sức khỏe proxy, cho phép một lần thăm dò bị đích từ chối (401/403/429: proxy đã chuyển tiếp, đích từ chối IP đầu ra này) đặt lại chuỗi lỗi liên tiếp của proxy, giống như một lần thăm dò được phục vụ. Tắt theo mặc định: lần từ chối vẫn trung lập và giữ nguyên chuỗi. Lỗi 5xx vẫn không kết luận trong mọi trường hợp, và lần từ chối không bao giờ xóa, vô hiệu hóa hay kích hoạt lại proxy.", "sidebar": { "home": "Trang chủ", "dashboard": "Trang tổng quan", @@ -10965,6 +10969,9 @@ "strategyLatency": "Tối ưu độ trễ (Latency-optimized)", "poolMembersLabel": "Thành viên nhóm proxy ({count})", "poolNoMembers": "Chưa có proxy nào trong nhóm này.", + "poolEgressObservation": "{exits, plural, other {# lối ra}} được dùng bởi {connections, plural, other {# kết nối}} · tối đa {max} trên một lối ra · {hours} giờ qua", + "poolEgressObservationEmpty": "Không ghi nhận lưu lượng trong {hours} giờ qua", + "poolEgressObservationHint": "Tính từ nhật ký proxy trong khoảng thời gian: số IP đầu ra khác nhau quan sát được của các thành viên nhóm proxy này và số kết nối OmniRoute đã dùng chúng. \"Trên một lối ra\" nghĩa là trong cả khoảng thời gian, không phải cùng lúc. Khóa 429 theo IP chỉ tạm dừng các kết nối cùng họ nhà cung cấp.", "poolRemove": "Gỡ bỏ", "poolRemoveFailed": "Không thể gỡ proxy khỏi nhóm", "poolAddLabel": "Thêm proxy", @@ -12985,6 +12992,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "Cho phép khôi phục luồng bằng cách yêu cầu lại và ghép phản hồi sau khi dữ liệu đã bắt đầu được gửi tới ứng dụng khách." }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "Giúp việc tiếp tục luồng giữa chừng an toàn với lệnh gọi công cụ: không bao giờ tiếp tục một luồng bị ngắt sau khi đã gửi lệnh gọi công cụ (đang xử lý hoặc đã hoàn tất), và dừng sau một lần tiếp tục rỗng thay vì dùng hết số lần thử lại." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "Chuyển dự phòng một lần sang kết nối anh em khi luồng dừng trước khi phát bất kỳ khung hữu ích nào và lần thử lại có giới hạn trên cùng kết nối đã cạn. Nếu không có kết nối anh em dùng được, lỗi early-EOF ban đầu sẽ được trả về. Tắt: early-EOF vẫn kết thúc sau lần thử lại." + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "Thêm trường tên dễ đọc vào phản hồi /v1/models. Tắt với các ứng dụng khách chỉ chấp nhận ID mô hình." }, @@ -13021,6 +13034,14 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Vòng lặp công cụ do máy chủ sở hữu", "description": "Tiếp tục các lời gọi công cụ do máy chủ sở hữu ở chế độ không streaming cho đến khi mô hình trả về phản hồi mà máy khách dùng được." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "Nguồn gốc của Retry-After", + "description": "Với các phản hồi không khả dụng 429/503 tổng hợp, bỏ Retry-After khi không biết thời điểm thử lại cụ thể thay vì gửi giá trị giả 1 giây, thêm error.retry_after_provenance và đọc gợi ý thử lại dạng văn bản trên các đường thoát của combo." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "Dừng ưu tiên được bảo vệ do hạ tầng trả 502", + "description": "Trả 502 thay vì 503 khi một mục tiêu ưu tiên chỉ dự phòng theo hạn mức dừng combo vì một nguyên nhân chắc chắn không phải hạn mức: bộ ngắt mạch của nhà cung cấp đang mở hoặc bỏ qua do dự đoán độ trễ." } } }, @@ -13634,6 +13655,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "Từ chối yêu cầu trước khi gửi đi khi mô hình đích thiếu các khả năng bắt buộc (thị giác, công cụ, đầu ra có cấu trúc, cửa sổ ngữ cảnh). Bảo vệ các yêu cầu trực tiếp đến một nhà cung cấp khi chúng bỏ qua bộ lọc tương thích của combo.", "featureFlagDisableContextWindowChecksDescription": "Bỏ qua kiểm tra cục bộ của OmniRoute về cửa sổ ngữ cảnh và giới hạn token đầu vào tối đa cho yêu cầu trực tiếp đến một mô hình đơn lẻ. Nhà cung cấp thượng nguồn vẫn áp dụng các giới hạn thực tế. Tính năng nén prompt và giới hạn token đầu ra vẫn hoạt động.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "Không tìm thấy trang", diff --git a/src/i18n/messages/yo.json b/src/i18n/messages/yo.json index fea5978234..9927ae0d18 100644 --- a/src/i18n/messages/yo.json +++ b/src/i18n/messages/yo.json @@ -10970,7 +10970,10 @@ "poolAddMember": "Ṣàfikún", "poolAddFailed": "Kùnà láti ṣàfikún aṣojú sí àkójọpọ̀ náà", "poolSelectProxy": "Yan aṣojú kan…", - "poolSaveFailed": "Kùnà láti fi ìpín àkójọpọ̀ pamọ́" + "poolSaveFailed": "Kùnà láti fi ìpín àkójọpọ̀ pamọ́", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family." }, "playground": { "title": "Pápá Ìdánwò Àwòṣe", @@ -13020,6 +13023,20 @@ "SERVER_OWNED_TOOL_LOOP_ENABLED": { "label": "Àyíká Irinṣẹ́ Tí Sáfà Ń Ṣàkóso", "description": "Tẹ̀síwájú pẹ̀lú àwọn ìpè irinṣẹ́ tí sáfà ń ṣàkóso tí kì í sanwọ́ títí àwòṣe yóò fi dá ìdáhùn tí oníbàárà lè lò padà." + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths.", + "label": "__MISSING__:Retry-After Provenance" + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip.", + "label": "__MISSING__:Protected-Priority Infra Stops as 502" + }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "__MISSING__:Fail over once to a sibling connection when a stream closes before emitting any useful frame and the bounded same-connection retry is spent. With no usable sibling, the original early-EOF error is returned. Off: early-EOF stays terminal after the retry." } } }, @@ -14143,5 +14160,10 @@ "partnerLinkNote": "Ọ̀nà asopọ aláṣiṣẹ́pọ̀", "dismissAriaLabel": "Pa á tì" }, - "featureFlagOmnirouteDisableThinkingLevelVariantsDescription": "Mú ṣíṣẹ̀dá àwọn ẹ̀yà ìpele ìrònú (fún àpẹẹrẹ -low, -medium, -high) nínú àkójọ /v1/models ṣiṣẹ́ mọ́." + "featureFlagOmnirouteDisableThinkingLevelVariantsDescription": "Mú ṣíṣẹ̀dá àwọn ẹ̀yà ìpele ìrònú (fún àpẹẹrẹ -low, -medium, -high) nínú àkójọ /v1/models ṣiṣẹ́ mọ́.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "__MISSING__:Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "__MISSING__:Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy." } diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index d5f97f5896..7f363529a8 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "在 /v1/models 上发布 claude/<provider>/<model> 镜像 ID,让 Claude Code 网关模型发现能列出非 Claude 模型。警告:全局启用会使所有客户端的目录条目翻倍。", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "为提供者调度启用按租户的自适应虚拟准入通道(#9654):一个租户的突发流量不再导致另一个租户收到 503。OMNIROUTE_CHAT_VIRTUAL_LANES 环境变量优先于此仪表板设置;更改在服务器重启后生效。", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "首页", "dashboard": "仪表板", @@ -10957,6 +10961,9 @@ "strategyLatency": "延迟优化", "poolMembersLabel": "池成员 ({count})", "poolNoMembers": "此池中尚无代理。", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "移除", "poolRemoveFailed": "从池中移除代理失败", "poolAddLabel": "添加代理", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "允许流恢复在字节已到达客户端后重新请求响应并进行拼接。" }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "当流在发出任何有用帧之前关闭,且同一连接上的有限重试已用尽时,故障转移一次到同级连接。若没有可用的同级连接,则返回原始的 early-EOF 错误。关闭:重试后 early-EOF 仍为终止结果。" + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "在 /v1/models 响应中包含易于显示的名称字段。对于仅接受模型 ID 的客户端,请禁用此项。" }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "在技能沙箱中启用网络访问。" + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "在目标模型缺少所需能力(视觉、工具、结构化输出、上下文窗口)时,拒绝调度前的请求。保护绕过组合层兼容性过滤器的直接单一提供者请求。", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "页面未找到", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 0a0b64d4b8..5da99c77fe 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -992,6 +992,10 @@ "featureFlagExposeCcDiscoveryAliasesDescription": "在 /v1/models 上廣告 claude/<provider>/<model> 鏡像 ID,以便 Claude Code 閘道模型發現列出非 Claude 模型。警告:當全域啟用時,會為所有客戶端重複目錄條目。", "featureFlagNoThinkingAliasEnabledDescription": "__MISSING__:Master switch for the no-think/<provider>/<model> gateway aliases. On (default): /v1/models advertises a no-thinking variant for every eligible thinking-capable Claude model, and a no-think/ id sent on a request resolves back to the real model with reasoning suppressed. Off: no variants are advertised and a no-think/ id is treated like any other unknown model id. The per-model ModelSpec.noThinkingAlias opt-in/opt-out still applies while this is on.", "featureFlagChatVirtualLanesEnabledDescription": "為提供者調度啟用按租戶的自適應虛擬准入通道(#9654):一個租戶的突發流量不再導致另一個租戶收到 503。OMNIROUTE_CHAT_VIRTUAL_LANES 環境變數優先於此儀表板設定;變更在伺服器重新啟動後生效。", + "featureFlagFreeBadgeRequiresProviderFreeTierDescription": "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + "featureFlagProxySkipRecentlyFailedDescription": "__MISSING__:Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + "featureFlagProxyPoolEgressObservationDescription": "__MISSING__:Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + "featureFlagProxyHealthBlockedResetsStreakDescription": "__MISSING__:In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the consecutive-failure streak of the proxy, like a served probe. Off by default: a refusal stays neutral and keeps the streak. A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", "sidebar": { "home": "首頁", "dashboard": "儀表板", @@ -10957,6 +10961,9 @@ "strategyLatency": "延遲最佳化", "poolMembersLabel": "池成員({count})", "poolNoMembers": "此池中尚無代理。", + "poolEgressObservation": "__MISSING__:{exits, plural, one {# exit} other {# exits}} used by {connections, plural, one {# connection} other {# connections}} · up to {max} on one exit · last {hours} h", + "poolEgressObservationEmpty": "__MISSING__:No traffic observed in the last {hours} h", + "poolEgressObservationHint": "__MISSING__:Counted from the proxy log over the window: distinct egress IPs observed for the members of this pool, and the OmniRoute connections that used them. \"On one exit\" means over the window, not at the same time. The per-IP 429 lockout only pauses connections of the same provider family.", "poolRemove": "移除", "poolRemoveFailed": "從池中移除代理失敗", "poolAddLabel": "新增代理", @@ -12977,6 +12984,12 @@ "STREAM_RECOVERY_MIDSTREAM_ENABLED": { "description": "允許串流復原在位元組已到達用戶端後再次請求回應並將其拼接。" }, + "STREAM_RECOVERY_TOOLCALL_ORDER_FIX": { + "description": "__MISSING__:Make mid-stream continuation tool-call safe: never resume a cut stream after a tool call was sent (still in progress or already finished), and stop after one empty continuation instead of using the whole retry budget." + }, + "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED": { + "description": "當串流在送出任何有用訊框之前關閉,且同一連線上的有限重試已用盡時,容錯移轉一次到同級連線。若沒有可用的同級連線,則回傳原始的 early-EOF 錯誤。關閉:重試後 early-EOF 仍為終止結果。" + }, "MODEL_CATALOG_INCLUDE_NAMES": { "description": "在 /v1/models 回應中包含顯示友善的名稱欄位。對於僅接受模型 ID 的用戶端請停用此項。" }, @@ -13009,6 +13022,14 @@ }, "SKILLS_SANDBOX_NETWORK_ENABLED": { "description": "在技能沙盒中啟用網路存取。" + }, + "RETRY_AFTER_PROVENANCE_ENABLED": { + "label": "__MISSING__:Retry-After Provenance", + "description": "__MISSING__:On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known instead of sending a synthetic 1s, add error.retry_after_provenance, and read prose retry hints on combo drain paths." + }, + "PROTECTED_PRIORITY_INFRA_502_ENABLED": { + "label": "__MISSING__:Protected-Priority Infra Stops as 502", + "description": "__MISSING__:Answer 502 instead of 503 when a quota-only-fallback priority target stops the combo for a cause that is provably not quota: an open provider circuit breaker or a predictive latency skip." } } }, @@ -13622,6 +13643,7 @@ }, "featureFlagCapabilityFilterEnabledDescription": "在目標模型缺乏所需功能(視覺、工具、結構化輸出、上下文窗口)時,拒絕發送前的請求。保護繞過組合層兼容性過濾器的直接單一提供者請求。", "featureFlagDisableContextWindowChecksDescription": "Skip OmniRoute's local context-window and max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits. Prompt compression and output-token caps remain active.", + "featureFlagSearchStatsHideDeletedConnectionsDescription": "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", "publicSystem": { "notFound": { "title": "找不到頁面", diff --git a/src/lib/combos/controlCenter.ts b/src/lib/combos/controlCenter.ts index 92a87b2738..3c16a03e8a 100644 --- a/src/lib/combos/controlCenter.ts +++ b/src/lib/combos/controlCenter.ts @@ -1,6 +1,6 @@ import { normalizeComboModels, type ComboStep } from "./steps"; import { resolveComboTargetModelStr } from "../../../open-sse/services/combo/opencodeTargetAlias.ts"; -import { resolveProviderAlias } from "../../../open-sse/services/model.ts"; +import { resolveProviderAlias } from "../../../open-sse/services/providerAlias.ts"; type JsonRecord = Record; diff --git a/src/lib/db/batches.ts b/src/lib/db/batches.ts index 9d3a8e4531..5282313ac2 100644 --- a/src/lib/db/batches.ts +++ b/src/lib/db/batches.ts @@ -436,11 +436,12 @@ export const INSTANCE_SWEEP_CHUNK = 200; * widening the sweep, and a scope carrying BOTH `apiKeyId` and `allTenants` is * rejected rather than widened. * - * Batches whose `api_key_id` IS NULL are intentionally OUT of a key-scoped sweep. - * This diverges from `scopeCheck` in `src/app/api/v1/batches/[id]/route.ts`, - * which lets any key read/delete a single unowned batch by id: a bulk destructive - * sweep must never reach records the key does not own, so unowned batches are - * only swept by `{ allTenants: true }`. + * Batches whose `api_key_id` IS NULL are intentionally OUT of a key-scoped sweep: + * a bulk destructive sweep must never reach records the key does not own, so + * unowned batches are only swept by `{ allTenants: true }`. The single-item routes + * apply the same rule through `canAccessOwnedRecord` in + * `src/app/api/v1/_helpers/apiKeyScope.ts` (a null owner is denied to every + * non-session caller — GHSA-2jm2-mpx8-6523). * * In key mode the file half is owner-scoped too: only files whose api_key_id is * the caller's are soft-deleted; a referenced file another tenant owns (or an diff --git a/src/lib/db/callLogStats.ts b/src/lib/db/callLogStats.ts index 04af1b3571..24debd628e 100644 --- a/src/lib/db/callLogStats.ts +++ b/src/lib/db/callLogStats.ts @@ -1,5 +1,10 @@ import { getDbInstance } from "./core"; import { ERROR_TYPE_CONTRACT } from "@omniroute/open-sse/services/errorClassifier.ts"; +import { + SEARCH_CREDENTIAL_FALLBACKS, + SEARCH_PROVIDERS, +} from "@omniroute/open-sse/config/searchRegistry.ts"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; /** * Aggregation queries over `call_logs` extracted from route handlers. @@ -157,19 +162,84 @@ export function getProviderUsageSince(since: string): ProviderUsageRow[] { // /api/search/stats — search provider aggregates + recent entries // --------------------------------------------------------------------------- +function sqlStringLiteral(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +let searchLiveProviderGuardSql: string | null = null; + +/** Always applied: never surface a NULL provider or the '-' sentinel. */ +const SEARCH_PROVIDER_PRESENT_SQL = "c.provider IS NOT NULL AND c.provider != '-'"; + +/** + * WHERE fragment shared by every search query below (alias `c` = call_logs). + * A search row is surfaced only when its provider is still servable: + * - never a NULL provider or the '-' sentinel; + * - with SEARCH_STATS_HIDE_DELETED_CONNECTIONS on, a keyed provider also needs a + * provider_connections row, for itself or for one of its credential fallbacks + * (perplexity-search reuses a `perplexity` key), so a deleted connection stops + * resurfacing from its retained call_logs rows; + * - keyless providers (`authType: "none"` in the search registry, e.g. + * duckduckgo-free, searxng-search, anonymous context7) are always live — + * they are served without any provider_connections row. + * The flag defaults to off, which keeps the historical stats: every retained row + * with a real provider id counts. Built from registry constants on first use. + */ +function getSearchLiveProviderGuardSql(): string { + if (!isSearchStatsHideDeletedConnectionsEnabled()) return SEARCH_PROVIDER_PRESENT_SQL; + if (searchLiveProviderGuardSql !== null) return searchLiveProviderGuardSql; + const keyless = Object.values(SEARCH_PROVIDERS) + .filter((provider) => provider.authType === "none") + .map((provider) => sqlStringLiteral(provider.id)); + const fallbackPairs = Object.entries(SEARCH_CREDENTIAL_FALLBACKS).flatMap( + ([searchId, fallback]) => + (Array.isArray(fallback) ? fallback : [fallback]).map( + (fallbackId) => `(${sqlStringLiteral(searchId)}, ${sqlStringLiteral(fallbackId)})` + ) + ); + const keylessClause = keyless.length > 0 ? `c.provider IN (${keyless.join(", ")}) OR ` : ""; + const fallbackClause = + fallbackPairs.length > 0 + ? ` + OR EXISTS ( + SELECT 1 FROM (VALUES ${fallbackPairs.join(", ")}) fb + JOIN provider_connections pcf ON pcf.provider = fb.column2 + WHERE fb.column1 = c.provider + )` + : ""; + searchLiveProviderGuardSql = `${SEARCH_PROVIDER_PRESENT_SQL} + AND ( + ${keylessClause}EXISTS ( + SELECT 1 FROM provider_connections pc WHERE pc.provider = c.provider + )${fallbackClause} + )`; + return searchLiveProviderGuardSql; +} + +/** Fail closed to the historical behavior when the flag cannot be resolved. */ +function isSearchStatsHideDeletedConnectionsEnabled(): boolean { + try { + return isFeatureFlagEnabled("SEARCH_STATS_HIDE_DELETED_CONNECTIONS"); + } catch { + return false; + } +} + /** * Per-provider request count and average latency for search requests. + * Rows pass the search live-provider guard (see getSearchLiveProviderGuardSql). */ export function getSearchProviderStats(): SearchProviderStatRow[] { const db = getDbInstance(); return db .prepare( ` - SELECT provider, COUNT(*) as requests, - CAST(AVG(duration) AS INTEGER) as avg_latency_ms - FROM call_logs - WHERE request_type = 'search' - GROUP BY provider + SELECT c.provider, COUNT(*) as requests, + CAST(AVG(c.duration) AS INTEGER) as avg_latency_ms + FROM call_logs c + WHERE c.request_type = 'search' + AND ${getSearchLiveProviderGuardSql()} + GROUP BY c.provider ` ) .all() as SearchProviderStatRow[]; @@ -177,16 +247,18 @@ export function getSearchProviderStats(): SearchProviderStatRow[] { /** * Most recent 10 search entries (request_summary + provider + timestamp). + * Only rows from providers with a live connection are surfaced. */ export function getRecentSearchLogs(): SearchRecentRow[] { const db = getDbInstance(); return db .prepare( ` - SELECT request_summary, provider, timestamp - FROM call_logs - WHERE request_type = 'search' - ORDER BY timestamp DESC + SELECT c.request_summary, c.provider, c.timestamp + FROM call_logs c + WHERE c.request_type = 'search' + AND ${getSearchLiveProviderGuardSql()} + ORDER BY c.timestamp DESC LIMIT 10 ` ) @@ -200,6 +272,8 @@ export function getRecentSearchLogs(): SearchRecentRow[] { /** * Single-pass scalar aggregations for all search entries since `todayIso`. * `todayIso` is the ISO-8601 UTC start-of-day string used for the "today" count. + * Uses the same live-provider guard as the per-provider breakdown, so `total` + * always equals the sum of `getSearchProviderCounts()`. */ export function getSearchAggregateStats(todayIso: string): SearchAggregateStats { const db = getDbInstance(); @@ -207,12 +281,13 @@ export function getSearchAggregateStats(todayIso: string): SearchAggregateStats .prepare( `SELECT COUNT(*) as total, - COALESCE(SUM(CASE WHEN timestamp >= ? THEN 1 ELSE 0 END), 0) as today, - COALESCE(SUM(CASE WHEN status >= 400 OR error_summary IS NOT NULL THEN 1 ELSE 0 END), 0) as errors, - AVG(CASE WHEN duration > 0 THEN duration END) as avg_duration, - COALESCE(SUM(CASE WHEN duration > 0 AND duration < 5 THEN 1 ELSE 0 END), 0) as cached - FROM call_logs - WHERE request_type = 'search'` + COALESCE(SUM(CASE WHEN c.timestamp >= ? THEN 1 ELSE 0 END), 0) as today, + COALESCE(SUM(CASE WHEN c.status >= 400 OR c.error_summary IS NOT NULL THEN 1 ELSE 0 END), 0) as errors, + AVG(CASE WHEN c.duration > 0 THEN c.duration END) as avg_duration, + COALESCE(SUM(CASE WHEN c.duration > 0 AND c.duration < 5 THEN 1 ELSE 0 END), 0) as cached + FROM call_logs c + WHERE c.request_type = 'search' + AND ${getSearchLiveProviderGuardSql()}` ) .get(todayIso) as SearchAggregateStats | undefined; return row ?? { total: 0, today: 0, errors: 0, avg_duration: null, cached: 0 }; @@ -220,14 +295,16 @@ export function getSearchAggregateStats(todayIso: string): SearchAggregateStats /** * Per-provider request count for search entries, ordered by count descending. + * Rows pass the search live-provider guard (see getSearchLiveProviderGuardSql). */ export function getSearchProviderCounts(): SearchProviderCountRow[] { const db = getDbInstance(); return db .prepare( - `SELECT provider, COUNT(*) as cnt - FROM call_logs WHERE request_type = 'search' - GROUP BY provider ORDER BY cnt DESC` + `SELECT c.provider, COUNT(*) as cnt + FROM call_logs c WHERE c.request_type = 'search' + AND ${getSearchLiveProviderGuardSql()} + GROUP BY c.provider ORDER BY cnt DESC` ) .all() as SearchProviderCountRow[]; } diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index 7ccacdffbe..d58c60c55c 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -904,7 +904,10 @@ function createManagedDbBackup(db: SqliteDatabase, reason: string): boolean { ? parsePositiveInt(process.env.DB_BACKUP_MAX_FILES, MAX_DB_BACKUPS) : MAX_DB_BACKUPS; const retentionDays = process.env.DB_BACKUP_RETENTION_DAYS - ? parseNonNegativeInt(process.env.DB_BACKUP_RETENTION_DAYS, DEFAULT_DB_BACKUP_RETENTION_DAYS) + ? parseNonNegativeInt( + process.env.DB_BACKUP_RETENTION_DAYS, + DEFAULT_DB_BACKUP_RETENTION_DAYS + ) : DEFAULT_DB_BACKUP_RETENTION_DAYS; pruneBackupDirectory({ backupDir, maxFiles, retentionDays }); } catch { diff --git a/src/lib/db/migrationRunner.ts b/src/lib/db/migrationRunner.ts index 58073518b8..6c38f6cc37 100644 --- a/src/lib/db/migrationRunner.ts +++ b/src/lib/db/migrationRunner.ts @@ -593,6 +593,11 @@ function isSchemaAlreadyApplied( hasColumn(db, "provider_nodes", "daily_quota_reset_timezone") && hasColumn(db, "provider_nodes", "daily_quota_reset_hour") ); + case "179": + // proxy_logs.upstream_status may already exist if ensureProxyLogsColumns ran first; + // a bare ADD COLUMN would then throw. Renumbering the migration means renaming this case + // (keyed by version only: a stale "177" here would skip 177_provider_connection_synced_models_at). + return hasColumn(db, "proxy_logs", "upstream_status"); default: return false; } diff --git a/src/lib/db/migrations/179_proxy_logs_upstream_status.sql b/src/lib/db/migrations/179_proxy_logs_upstream_status.sql new file mode 100644 index 0000000000..15318312ef --- /dev/null +++ b/src/lib/db/migrations/179_proxy_logs_upstream_status.sql @@ -0,0 +1,4 @@ +-- upstream_status: HTTP status the provider actually returned for the logged request. +-- NULL when no response was received (network error, local refusal). No index: not a +-- query dimension. +ALTER TABLE proxy_logs ADD COLUMN upstream_status INTEGER; diff --git a/src/lib/db/proxies/mappers.ts b/src/lib/db/proxies/mappers.ts index 6bcdb879c4..3903f76c2e 100644 --- a/src/lib/db/proxies/mappers.ts +++ b/src/lib/db/proxies/mappers.ts @@ -1,4 +1,5 @@ import { decrypt, looksEncrypted } from "../encryption"; +import { decodeUserinfo } from "@/shared/utils/decodeUserinfo"; import type { JsonRecord, ProxyScope, @@ -180,8 +181,8 @@ export function coerceProxyPayload(value: unknown, fallbackName: string): ProxyP type: parsed.protocol.replace(":", "") || "http", host: parsed.hostname, port: Number(parsed.port || (parsed.protocol === "https:" ? "443" : "8080")), - username: parsed.username ? decodeURIComponent(parsed.username) : "", - password: parsed.password ? decodeURIComponent(parsed.password) : "", + username: parsed.username ? decodeUserinfo(parsed.username) : "", + password: parsed.password ? decodeUserinfo(parsed.password) : "", status: "active", }; } catch { diff --git a/src/lib/db/proxies/rotation.ts b/src/lib/db/proxies/rotation.ts index 52cc695c57..853bfd4f98 100644 --- a/src/lib/db/proxies/rotation.ts +++ b/src/lib/db/proxies/rotation.ts @@ -9,6 +9,12 @@ import { randomInt } from "crypto"; import { getDbInstance } from "../core"; import { pickByLatency } from "../proxyLatency"; +import { + hasProxyRefusals, + isProxyAvoided, + proxyEgressKey, +} from "@omniroute/open-sse/utils/proxyRefusalMemory.ts"; +import { isProxySkipRecentlyFailedEnabled } from "@/shared/utils/featureFlags"; import type { JsonRecord, ProxyScope, ProxyRotationStrategy } from "./types"; import { PROXY_ROTATION_STRATEGIES, DEFAULT_PROXY_ROTATION_STRATEGY } from "./types"; import { @@ -129,11 +135,36 @@ function getOrCreateRotationRow( }; } +// Indexes of the members not currently set aside by the proxy refusal memory, or null to +// keep the plain behavior: nothing set aside, every member set aside (an all-failed pool +// keeps today's selection and its #6246 fail-closed contract), or PROXY_SKIP_RECENTLY_FAILED +// off. The flag is read last, only when skipping would actually change the pick. +function eligibleMemberIndexes(candidates: unknown[]): number[] | null { + if (!hasProxyRefusals()) return null; + const eligible: number[] = []; + candidates.forEach((row, index) => { + if (!isProxyAvoided(proxyEgressKey(row))) eligible.push(index); + }); + if (eligible.length === 0 || eligible.length === candidates.length) return null; + return isProxySkipRecentlyFailedEnabled() ? eligible : null; +} + +// First eligible index at or after `start`, going round the pool. +function firstEligibleFrom(start: number, eligible: number[], size: number): number { + for (let step = 0; step < size; step++) { + const index = (start + step) % size; + if (eligible.includes(index)) return index; + } + return start; +} + /** * Pick one member from an already-alive candidate list according to the scope's * rotation strategy. Assumes `candidates` is non-empty and ordered by position. * Round-robin uses (and persists) a monotonic cursor; random uses crypto.randomInt; * sticky holds the current member until its window elapses, then advances. + * Members that just failed (see proxyRefusalMemory) are skipped while another member is + * eligible; the cursor then advances past the member actually served. */ function pickFromCandidates( db: ReturnType, @@ -144,16 +175,20 @@ function pickFromCandidates( if (candidates.length === 1) return candidates[0]; const state = getOrCreateRotationRow(db, normalizedScope, rotationScopeId); + const eligible = eligibleMemberIndexes(candidates); if (state.strategy === "random") { // crypto.randomInt (unbiased, uniform in [0, length)) instead of Math.random — // CodeQL js/insecure-randomness flags Math.random flowing into the selected proxy's // credentials (a "security context"). Load-balancing selection is not a secret, but // crypto.randomInt silences the alert at the source and is unbiased (#6365 follow-up). + if (eligible) return candidates[eligible[randomInt(eligible.length)]]; return candidates[randomInt(candidates.length)]; } - if (state.strategy === "latency") return pickByLatency(db, candidates); + if (state.strategy === "latency") { + return pickByLatency(db, eligible ? eligible.map((index) => candidates[index]) : candidates); + } if (state.strategy === "sticky") { const windowMs = state.stickyWindowMinutes * 60_000; @@ -173,15 +208,19 @@ function pickFromCandidates( ); } const idx = ((cursor % candidates.length) + candidates.length) % candidates.length; - return candidates[idx]; + // A held member set aside is replaced for this pick only: no extra write. + return candidates[eligible ? firstEligibleFrom(idx, eligible, candidates.length) : idx]; } - // round-robin (default): pick at the current cursor, then advance it monotonically. + // round-robin (default): pick at the current cursor, then advance it monotonically, + // past any member skipped so the next pick starts after the one actually served. const idx = ((state.cursor % candidates.length) + candidates.length) % candidates.length; + const served = eligible ? firstEligibleFrom(idx, eligible, candidates.length) : idx; + const skipped = (served - idx + candidates.length) % candidates.length; db.prepare( "UPDATE proxy_scope_rotation SET cursor = ?, updated_at = ? WHERE scope = ? AND scope_id IS ?" - ).run(state.cursor + 1, new Date().toISOString(), normalizedScope, rotationScopeId); - return candidates[idx]; + ).run(state.cursor + skipped + 1, new Date().toISOString(), normalizedScope, rotationScopeId); + return candidates[served]; } // Fetch the alive, position-ordered candidate rows for a (scope, scope_id) pool. diff --git a/src/lib/db/proxyLogs.ts b/src/lib/db/proxyLogs.ts index bb1e6ea7b3..f4b0530088 100644 --- a/src/lib/db/proxyLogs.ts +++ b/src/lib/db/proxyLogs.ts @@ -67,3 +67,49 @@ export function getRecentEgressIpForConnection( if (!row) return null; return { egressIp: row.egress_ip, at: row.timestamp }; } + +export type PoolEgressObservationCounts = { + connections: number; + distinctExits: number; + maxConnectionsOnOneExit: number; +}; + +/** + * How many distinct observed egress IPs served a proxy pool's members since `since`, how + * many OmniRoute connections went through them, and the most connections seen behind one + * egress IP over that window. Members are matched to log rows by host and port, so two + * registry rows sharing one entry point count together. Only numbers leave this function. + * `scope` and `scopeId` must already be normalized (normalizeScope and + * normalizeAssignmentScopeId); an empty pool simply matches no rows. + */ +export function getPoolEgressObservation( + scope: string, + scopeId: string | null, + since: string +): PoolEgressObservationCounts { + const db = getDbInstance(); + const perExit = db + .prepare( + `SELECT COUNT(DISTINCT l.connection_id) AS n + FROM proxy_logs l + JOIN proxy_registry r ON l.proxy_host = r.host AND l.proxy_port = r.port + WHERE r.id IN (SELECT proxy_id FROM proxy_assignments WHERE scope = ? AND scope_id IS ?) + AND l.timestamp >= ? AND l.egress_ip IS NOT NULL AND l.connection_id IS NOT NULL + GROUP BY l.egress_ip` + ) + .all(scope, scopeId, since) as Array<{ n: number }>; + const total = db + .prepare( + `SELECT COUNT(DISTINCT l.connection_id) AS n + FROM proxy_logs l + JOIN proxy_registry r ON l.proxy_host = r.host AND l.proxy_port = r.port + WHERE r.id IN (SELECT proxy_id FROM proxy_assignments WHERE scope = ? AND scope_id IS ?) + AND l.timestamp >= ? AND l.egress_ip IS NOT NULL AND l.connection_id IS NOT NULL` + ) + .get(scope, scopeId, since) as { n: number }; + return { + connections: total.n, + distinctExits: perExit.length, + maxConnectionsOnOneExit: perExit.reduce((max, row) => Math.max(max, row.n), 0), + }; +} diff --git a/src/lib/db/schemaColumns.ts b/src/lib/db/schemaColumns.ts index d0d7b42493..f422d64270 100644 --- a/src/lib/db/schemaColumns.ts +++ b/src/lib/db/schemaColumns.ts @@ -292,6 +292,10 @@ export function ensureProxyLogsColumns(db: SqliteDatabase) { db.exec("ALTER TABLE proxy_logs ADD COLUMN egress_ip TEXT"); console.log("[DB] Added proxy_logs.egress_ip column"); } + if (!columnNames.has("upstream_status")) { + db.exec("ALTER TABLE proxy_logs ADD COLUMN upstream_status INTEGER"); + console.log("[DB] Added proxy_logs.upstream_status column"); + } } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); console.warn("[DB] Failed to verify proxy_logs schema:", message); diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index 8b4aa803af..cb4f410579 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -5,12 +5,20 @@ import { getDbInstance } from "./core"; import { backupDbFile } from "./backup"; import { PROVIDER_ID_TO_ALIAS } from "@omniroute/open-sse/config/providerModels.ts"; +import { + getProxyRefusalSeq, + hasProxyRefusals, + proxyEgressKey, + proxySetAsideSeq, +} from "@omniroute/open-sse/utils/proxyRefusalMemory.ts"; +import { isProxySkipRecentlyFailedEnabled } from "@/shared/utils/featureFlags"; import { invalidateDbCache } from "./readCache"; import { encrypt, decrypt } from "./encryption"; import { getProxyRegistryGeneration, resolveProxyForScopeFromRegistry } from "./proxies"; import { getComboModelProvider as getComboEntryProvider } from "@/lib/combos/steps"; import { requestBodyLimitMbFromEnv } from "@/shared/constants/bodySize"; import { DEFAULT_RESPONSES_PREVIOUS_RESPONSE_ID_MODE } from "@/shared/constants/responsesPreviousResponseId"; +import { decodeUserinfo } from "@/shared/utils/decodeUserinfo"; import { type JsonRecord, toRecord } from "./settings/shared"; import { resolveNoAuthSharedProviderProxy } from "./settings/noAuthProxyFallback"; @@ -21,11 +29,14 @@ type ProxyResolutionResult = { levelId: string | null; source?: string; }; -type ProxyResolutionCacheEntry = { +// State observed when a resolution started; an entry is stored only if it still holds. +type ProxyResolutionStamp = { generation: number; registryGeneration: number; - result: ProxyResolutionResult; + // Proxy refusal memory sequence (see isCachedPoolMemberSetAside). + refusalSeq: number; }; +type ProxyResolutionCacheEntry = ProxyResolutionStamp & { result: ProxyResolutionResult }; const PROXY_RESOLUTION_CACHE_MAX_ENTRIES = 100; @@ -43,17 +54,16 @@ export function bumpProxyConfigGeneration() { function cacheProxyResolution( connectionId: string, - generation: number, - registryGeneration: number, + stamp: ProxyResolutionStamp, result: ProxyResolutionResult ) { - if (generation !== proxyConfigGeneration) return; - if (registryGeneration !== getProxyRegistryGeneration()) return; + if (stamp.generation !== proxyConfigGeneration) return; + if (stamp.registryGeneration !== getProxyRegistryGeneration()) return; if (proxyResolutionCache.size >= PROXY_RESOLUTION_CACHE_MAX_ENTRIES) { const oldestKey = proxyResolutionCache.keys().next().value; if (oldestKey) proxyResolutionCache.delete(oldestKey); } - proxyResolutionCache.set(connectionId, { generation, registryGeneration, result }); + proxyResolutionCache.set(connectionId, { ...stamp, result }); } type ProxyMap = Record; @@ -411,8 +421,8 @@ function migrateProxyEntry(value: unknown): JsonRecord | null { port: url.port || (url.protocol === "socks5:" ? "1080" : url.protocol === "https:" ? "443" : "8080"), - username: url.username ? decodeURIComponent(url.username) : "", - password: url.password ? decodeURIComponent(url.password) : "", + username: url.username ? decodeUserinfo(url.username) : "", + password: url.password ? decodeUserinfo(url.password) : "", }; } catch { const parts = value.split(":"); @@ -503,6 +513,20 @@ export async function deleteProxyForLevel(level: string, id: string | null) { return setProxyForLevel(level, id, null); } +// With PROXY_SKIP_RECENTLY_FAILED on, a pool member set aside AFTER its resolution started is +// not re-served from the cache: the cascade runs again so the pool can pick another member. +// Once per set-aside event: the new entry records the sequence it started from, so a member +// the pool hands back anyway (every member set aside) is then served from the cache instead +// of costing a DB cascade on every request. Legacy single-proxy levels have no alternative +// and stay cached, like a result without a proxy. The flag is read last, only on a real hit. +function isCachedPoolMemberSetAside(entry: ProxyResolutionCacheEntry): boolean { + const { result } = entry; + if (!hasProxyRefusals() || result.source !== "registry" || result.proxy == null) return false; + const setAsideSeq = proxySetAsideSeq(proxyEgressKey(result.proxy)); + if (setAsideSeq === null || setAsideSeq <= entry.refusalSeq) return false; + return isProxySkipRecentlyFailedEnabled(); +} + export async function resolveProxyForConnection( connectionId: string, apiKeyId?: string, @@ -513,13 +537,17 @@ export async function resolveProxyForConnection( : apiKeyId ? `${connectionId}:${apiKeyId}` : connectionId; - const startGeneration = proxyConfigGeneration; - const startRegistryGeneration = getProxyRegistryGeneration(); + const stamp: ProxyResolutionStamp = { + generation: proxyConfigGeneration, + registryGeneration: getProxyRegistryGeneration(), + refusalSeq: getProxyRefusalSeq(), + }; const cached = proxyResolutionCache.get(cacheKey); if ( cached && - cached.generation === startGeneration && - cached.registryGeneration === startRegistryGeneration + cached.generation === stamp.generation && + cached.registryGeneration === stamp.registryGeneration && + !isCachedPoolMemberSetAside(cached) ) { return cached.result; } @@ -570,7 +598,7 @@ export async function resolveProxyForConnection( // fallback candidates from the proxy pool. if (connectionRecord && !connectionProxyEnabled) { const result: ProxyResolutionResult = { proxy: null, level: "direct", levelId: null }; - cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, result); + cacheProxyResolution(cacheKey, stamp, result); return result; } @@ -631,7 +659,7 @@ export async function resolveProxyForConnection( levelId: apiKeyId, source: "api_key" as const, }; - cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, result); + cacheProxyResolution(cacheKey, stamp, result); return result; } } @@ -644,7 +672,7 @@ export async function resolveProxyForConnection( // Step 3: Account-level registry const registryAccount = await resolveProxyForScopeFromRegistry("account", connectionId); if (registryAccount?.proxy) { - cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, registryAccount); + cacheProxyResolution(cacheKey, stamp, registryAccount); return registryAccount; } @@ -655,7 +683,7 @@ export async function resolveProxyForConnection( level: "key", levelId: connectionId, }; - cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, result); + cacheProxyResolution(cacheKey, stamp, result); return result; } @@ -668,7 +696,7 @@ export async function resolveProxyForConnection( connectionProvider ); if (registryProvider?.proxy) { - cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, registryProvider); + cacheProxyResolution(cacheKey, stamp, registryProvider); return registryProvider; } } @@ -698,7 +726,7 @@ export async function resolveProxyForConnection( const registryCombo = await resolveProxyForScopeFromRegistry("combo", comboId); if (registryCombo?.proxy) { - cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, registryCombo); + cacheProxyResolution(cacheKey, stamp, registryCombo); return registryCombo; } @@ -708,7 +736,7 @@ export async function resolveProxyForConnection( level: "combo", levelId: comboId, }; - cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, result); + cacheProxyResolution(cacheKey, stamp, result); return result; } } catch { @@ -724,7 +752,7 @@ export async function resolveProxyForConnection( level: "provider", levelId: connectionProvider, }; - cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, result); + cacheProxyResolution(cacheKey, stamp, result); return result; } } @@ -737,7 +765,7 @@ export async function resolveProxyForConnection( if (!connectionRecord) { const noAuthFallback = await resolveNoAuthSharedProviderProxy(config.providers, providerId); if (noAuthFallback) { - cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, noAuthFallback); + cacheProxyResolution(cacheKey, stamp, noAuthFallback); return noAuthFallback; } } @@ -745,14 +773,14 @@ export async function resolveProxyForConnection( // Step 9: Global registry const registryGlobal = await resolveProxyForScopeFromRegistry("global"); if (registryGlobal?.proxy) { - cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, registryGlobal); + cacheProxyResolution(cacheKey, stamp, registryGlobal); return registryGlobal; } // Step 10: Legacy global if (config.global) { const result = { proxy: withFamilyDefault(config.global), level: "global", levelId: null }; - cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, result); + cacheProxyResolution(cacheKey, stamp, result); return result; } @@ -768,12 +796,7 @@ export async function resolveProxyForConnection( fallback.proxy && typeof fallback.proxy === "object" ? { ...fallback, proxy: withFamilyDefault(fallback.proxy as ProxyValue) } : fallback; - cacheProxyResolution( - cacheKey, - startGeneration, - startRegistryGeneration, - normalizedFallback as ProxyResolutionResult - ); + cacheProxyResolution(cacheKey, stamp, normalizedFallback as ProxyResolutionResult); return normalizedFallback; } } catch (err) { diff --git a/src/lib/db/walMaintenance.ts b/src/lib/db/walMaintenance.ts index d884720a27..659500a19f 100644 --- a/src/lib/db/walMaintenance.ts +++ b/src/lib/db/walMaintenance.ts @@ -41,6 +41,9 @@ const DEFAULT_WAL_PASSIVE_INTERVAL_MS = 5 * 60 * 1000; const DEFAULT_WAL_GUARD_MAX_BYTES = 256 * 1024 * 1024; const RETRY_DELAY_MS = 60_000; +export const WAL_BUSY_NAMESPACE = "walMaintenance"; +export const WAL_BUSY_KEY = "busyTotal"; + let walTimer: NodeJS.Timeout | null = null; let walPassiveTimer: NodeJS.Timeout | null = null; let retryTimer: NodeJS.Timeout | null = null; @@ -49,13 +52,44 @@ let busyStreak = 0; let busyTotal = 0; let lastBusyAt: string | null = null; let lastOkAt: string | null = null; +// Busy events counted in memory but not yet added to the persisted counter. +let pendingBusyDelta = 0; +// The handle the running scheduler was started with; used for the shutdown flush. +let activeDb: SqliteAdapter | null = null; +/** + * Count one busy checkpoint. Memory only, on purpose: a busy checkpoint means the + * database is contended RIGHT NOW, and a write here would wait up to `busy_timeout` + * (2s) on the event loop. The increment is persisted later by flushBusyTotal() from + * a non-busy scheduler tick or at shutdown. + */ function recordBusy(): void { busyStreak++; busyTotal++; + pendingBusyDelta++; lastBusyAt = new Date().toISOString(); } +/** + * Add the pending busy events to the persisted counter. Best-effort and single-shot: + * any failure (locked, closed, missing table) keeps the delta pending for the next + * non-busy tick — there is no retry loop. The additive UPSERT stays correct when + * several processes share the database file. + */ +export function flushBusyTotal(db: SqliteAdapter | null): boolean { + if (pendingBusyDelta === 0 || !db || !db.open) return false; + try { + db.prepare( + "INSERT INTO key_value(namespace, key, value) VALUES(?, ?, ?) " + + "ON CONFLICT(namespace, key) DO UPDATE SET value = CAST(value AS INTEGER) + excluded.value" + ).run(WAL_BUSY_NAMESPACE, WAL_BUSY_KEY, pendingBusyDelta); + pendingBusyDelta = 0; + return true; + } catch { + return false; + } +} + function recordOk(): void { busyStreak = 0; lastOkAt = new Date().toISOString(); @@ -207,6 +241,7 @@ function schedulePassiveRetry(db: SqliteAdapter): void { logCheckpointOutcome(outcome, "PASSIVE", busyStreak); } else if (outcome.ok) { recordOk(); + flushBusyTotal(db); } else { logCheckpointOutcome(outcome, "PASSIVE", busyStreak); } @@ -239,6 +274,7 @@ function startWalPassiveScheduler( isBuildPhase: isNextBuildPhase(), }); if (stats.skipped) return; + if (!stats.busy && stats.ok) flushBusyTotal(db); if (stats.busy || (stats.checkpointedFrames ?? 0) > 0) { console.log( `[DB] WAL passive checkpoint (busy=${stats.busy ? 1 : 0} logFrames=${stats.logFrames} ` + @@ -273,8 +309,13 @@ export function startWalMaintenance( sqliteFile: string | null, env: NodeJS.ProcessEnv = process.env ): void { + // stopWalMaintenance() flushes what it can and zeroes session state, so capture the + // in-memory total first; the gate stays before any DB touch. + const priorBusyTotal = busyTotal; stopWalMaintenance(); if (sqliteFile === null || isCloud || isNextBuildPhase() || isAutomatedTestProcess()) return; + activeDb = db; + busyTotal = mergeBusyTotal(priorBusyTotal, loadPersistedBusyTotal(db)); const intervalMs = getWalMaintenanceIntervalMs(env); if (intervalMs <= 0) { startWalPassiveScheduler(db, sqliteFile, env); @@ -294,6 +335,7 @@ export function startWalMaintenance( schedulePassiveRetry(db); } else if (outcome.ok) { recordOk(); + flushBusyTotal(db); console.log( `[DB] Periodic SQLite WAL checkpoint completed (TRUNCATE) in ${Date.now() - startedAtMs}ms ` + `(walMbBefore=${formatWalMb(walBeforeBytes)} walMbAfter=${formatWalMb(getWalFileSizeBytes(sqliteFile))} ` + @@ -311,6 +353,10 @@ export function startWalMaintenance( } export function stopWalMaintenance(): void { + // Shutdown / restart: best-effort persist of busy events not flushed by a tick. + flushBusyTotal(activeDb); + activeDb = null; + pendingBusyDelta = 0; if (walTimer) { clearInterval(walTimer); walTimer = null; @@ -334,6 +380,27 @@ export function getWalMaintenanceState(): WalMaintenanceState { return { ticks, busyStreak, busyTotal, lastBusyAt, lastOkAt }; } +export function loadPersistedBusyTotal(db: SqliteAdapter): number { + try { + const row = db + .prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?") + .get(WAL_BUSY_NAMESPACE, WAL_BUSY_KEY) as { value: unknown } | undefined; + const n = Number(row?.value); + return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0; + } catch (error) { + // Boot read-path, not the hot scheduler path: never fail silently. + console.warn(`[DB] WAL busy counter unreadable, starting from 0: ${String(error)}`); + return 0; + } +} + +export function mergeBusyTotal(prior: number, loaded: number): number { + // Both inputs floored, non-finite or negative → 0 (matches load fallback). + const p = Number.isFinite(prior) && prior > 0 ? Math.floor(prior) : 0; + const l = Number.isFinite(loaded) && loaded > 0 ? Math.floor(loaded) : 0; + return Math.max(p, l); +} + export function __resetForTests(): void { stopWalMaintenance(); } diff --git a/src/lib/guardrails/visionBridgeHelpers.ts b/src/lib/guardrails/visionBridgeHelpers.ts index 02c99f0e94..dd9b4d45e1 100644 --- a/src/lib/guardrails/visionBridgeHelpers.ts +++ b/src/lib/guardrails/visionBridgeHelpers.ts @@ -309,6 +309,14 @@ async function fetchRemoteImageAsDataUri( fetchImpl: typeof fetch = VISION_BRIDGE_UA_FETCH ): Promise { const remoteImage = await fetchRemoteImage(imageUrl, { + // GHSA-34rg-3pqj-35g9: `imageUrl` is caller input (a chat `image_url` part) — pin + // `public-only` explicitly; never the operator outbound policy (`block-metadata` on a + // local-first default install), which would let a request body make the server + // fetch loopback/LAN URLs and inline the bytes into the vision self-call. + guard: "public-only", + // `pinDns` is validation-only here: with `fetchImpl` injected the library validates + // every DNS answer but cannot pin the connection (it never builds its own fetch). + pinDns: true, signal, // Bypass the runtime's hooked global fetch (ProxyFetch) — a dead local // proxy (e.g. 127.0.0.1:8317) would otherwise break the download. diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index 6544ebd7e4..86bfb025e2 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -20,7 +20,7 @@ import { GROK_BUILD_TOKEN_URL, } from "@omniroute/open-sse/config/grokBuild.ts"; import { resolvePublicCred } from "@omniroute/open-sse/utils/publicCreds.ts"; -import { CURSOR_AGENT_CLI_VERSION } from "@omniroute/open-sse/utils/cursorAgentCliVersion.ts"; +import { CURSOR_AGENT_CLI_VERSION } from "@omniroute/open-sse/utils/cursorAgentCliVersionPin.ts"; import { buildGitLabOAuthEndpoints, GITLAB_DUO_DEFAULT_BASE_URL } from "../gitlab"; /** @@ -375,7 +375,7 @@ export const KIRO_CONFIG = { // Cursor stores credentials in SQLite database: state.vscdb // Keys: cursorAuth/accessToken, cursorAuth/refreshToken, storage.serviceMachineId // Deep-control PKCE + refresh aligned with OpenCodex (lidge-jun/opencodex src/oauth/cursor.ts). -// clientVersion pin lives in open-sse/utils/cursorAgentCliVersion.ts — single source of truth. +// clientVersion pin lives in open-sse/utils/cursorAgentCliVersionPin.ts — single source of truth. export const CURSOR_CONFIG = { // API endpoints apiEndpoint: "https://api2.cursor.sh", diff --git a/src/lib/obsidianSync.ts b/src/lib/obsidianSync.ts index febab545f8..4ae96a5500 100644 --- a/src/lib/obsidianSync.ts +++ b/src/lib/obsidianSync.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import { resolveDataDir } from "./dataPaths"; import { getObsidianVaultPath, setObsidianVaultPath, @@ -35,6 +36,40 @@ export async function getObsidianSyncStatus(): Promise { return { vaultPath, webdavEnabled, webdavUsername, webdavPassword }; } +/** Canonical (symlink-resolved) form of a path; falls back to the lexical resolve. */ +function canonicalPath(target: string): string { + try { + return fs.realpathSync.native(target); + } catch { + return path.resolve(target); + } +} + +/** True when `child` is `parent` itself or lives anywhere below it. */ +function isSameOrInside(parent: string, child: string): boolean { + const rel = path.relative(parent, child); + if (rel === "") return true; + if (path.isAbsolute(rel)) return false; // different drive (win32) + return rel !== ".." && !rel.startsWith(`..${path.sep}`); +} + +/** + * GHSA-7pq4-8pvv-rx7r: the WebDAV file service (scripts/dev/webdav-handler.mjs) + * serves the vault root to anyone holding the Basic credentials, before Next.js + * and outside the authz pipeline. A vault that IS the data directory, sits + * inside it, or CONTAINS it turns that service into a reader for server.env + * (JWT_SECRET / STORAGE_ENCRYPTION_KEY / API_KEY_SECRET) and storage.sqlite. + * Both sides are realpath-resolved so a symlink cannot dodge the comparison. + */ +export function vaultPathOverlapsDataDir(resolvedVaultPath: string): boolean { + const vault = canonicalPath(resolvedVaultPath); + const dataDir = canonicalPath(resolveDataDir()); + return isSameOrInside(dataDir, vault) || isSameOrInside(vault, dataDir); +} + +export const VAULT_OVERLAPS_DATA_DIR_ERROR = + "Vault path must not be the OmniRoute data directory, a directory inside it, or a directory that contains it"; + export async function enableObsidianVaultSync( vaultPath: string ): Promise { @@ -49,6 +84,10 @@ export async function enableObsidianVaultSync( return { success: false, error: `Path is not a directory: ${resolvedPath}` }; } + if (vaultPathOverlapsDataDir(resolvedPath)) { + return { success: false, error: VAULT_OVERLAPS_DATA_DIR_ERROR }; + } + try { setObsidianVaultPath(resolvedPath); diff --git a/src/lib/proxyHealth.ts b/src/lib/proxyHealth.ts index 46e5685032..90aa18b8ea 100644 --- a/src/lib/proxyHealth.ts +++ b/src/lib/proxyHealth.ts @@ -11,6 +11,13 @@ import { createConnection } from "node:net"; import { stripIpv6Brackets } from "@omniroute/open-sse/utils/proxyFamily"; +import { + hasProxyRefusals, + noteProxyRecovered, + noteProxyRefusal, + proxyEgressKey, +} from "@omniroute/open-sse/utils/proxyRefusalMemory"; +import { isProxySkipRecentlyFailedEnabled } from "@/shared/utils/featureFlags"; // Configurable via env vars const FAST_FAIL_TIMEOUT_MS = parseInt(process.env.PROXY_FAST_FAIL_TIMEOUT_MS ?? "2000", 10); @@ -33,6 +40,19 @@ const proxyHealthInflight = new Map>(); type TcpCheck = (host: string, port: number, timeoutMs: number) => Promise; let tcpCheckImpl: TcpCheck = tcpCheck; +// Feed a real probe verdict to proxy selection (opt-in, PROXY_SKIP_RECENTLY_FAILED): a proxy +// that refused the TCP connection is set aside by pools and account rotation, and taken back +// as soon as it answers again. With the flag off nothing is ever written. +function noteProbeVerdict(proxyUrl: string, healthy: boolean): void { + if (healthy) { + if (hasProxyRefusals()) noteProxyRecovered(proxyEgressKey(proxyUrl), "proxy_unreachable"); + return; + } + if (isProxySkipRecentlyFailedEnabled()) { + noteProxyRefusal(proxyEgressKey(proxyUrl), "proxy_unreachable"); + } +} + /** * T14: Perform a fast TCP check to see if a proxy host:port is reachable. * Results are cached for `cacheTtlMs` (default 30s) to avoid checking every request. @@ -83,6 +103,8 @@ export async function isProxyReachable( } const probe = tcpCheckImpl(host, port, timeoutMs).then((healthy) => { + // Before the cache write, so the verdict's TTL starts after the (flag-gated) note. + noteProbeVerdict(proxyUrl, healthy); proxyHealthCache.set(proxyUrl, { healthy, checkedAt: Date.now(), diff --git a/src/lib/proxyHealth/decision.ts b/src/lib/proxyHealth/decision.ts index ae58cde705..bac37411d7 100644 --- a/src/lib/proxyHealth/decision.ts +++ b/src/lib/proxyHealth/decision.ts @@ -28,10 +28,16 @@ * both flags are set, auto-remove (destructive) wins: a proxy that is * about to be deleted has no use for a soft-disable in between. * E — a `blocked` probe (the TARGET refused this egress IP: 401/403/429) is - * neutral like `inconclusive`. The proxy relayed correctly, so it is not - * failing; but it is not serving that destination either, which `ok` hid. - * Kept out of the failure count on purpose: one target refusing an IP - * does not make the proxy dead, and the operator owns the removal policy. + * neutral like `inconclusive` by default (#10654). The proxy relayed + * correctly, so it is not failing; but it is not serving that destination + * either, which `ok` hid. Kept out of the failure count on purpose: one + * target refusing an IP does not make the proxy dead, and the operator + * owns the removal policy. + * Opt-in (`blockedResetsStreak`, the PROXY_HEALTH_BLOCKED_RESETS_STREAK + * feature flag): a refusal additionally RESETS the consecutive-failure + * streak, since the proxy demonstrably relayed. It still never counts, + * never sets a status and never removes. This covers 401/403/429 only: a + * relayed 5xx stays `inconclusive` (policy B) and keeps the streak. */ export type ProxyProbeOutcome = "ok" | "fail" | "inconclusive" | "blocked"; @@ -67,6 +73,12 @@ export interface ProxyHealthDecisionInput { autoDisable?: boolean; /** Consecutive conclusive failures required before a downgrade/removal. */ removeAfter: number; + /** + * PROXY_HEALTH_BLOCKED_RESETS_STREAK — operator opted into letting a `blocked` + * probe reset the streak (policy E). Optional/defaults to `false`: `blocked` + * stays neutral, exactly as before. + */ + blockedResetsStreak?: boolean; } export interface ProxyHealthDecision { @@ -81,16 +93,30 @@ export interface ProxyHealthDecision { } export function decideProxyHealthAction(input: ProxyHealthDecisionInput): ProxyHealthDecision { - const { outcome, priorFailures, autoRemove, autoDisable = false, removeAfter } = input; + const { + outcome, + priorFailures, + autoRemove, + autoDisable = false, + removeAfter, + blockedResetsStreak = false, + } = input; const threshold = Number.isFinite(removeAfter) && removeAfter > 0 ? removeAfter : 3; // Either opt-in flag hands status control from the operator to the sweep. const managesStatus = autoRemove || autoDisable; - // B/E: inconclusive and blocked probes are neutral — no count, no status. - if (outcome === "inconclusive" || outcome === "blocked") { + // B/E: inconclusive and (by default) blocked probes are neutral — no count, no status. + if (outcome === "inconclusive" || (outcome === "blocked" && !blockedResetsStreak)) { return { failures: priorFailures, clearFailures: false, setStatus: null, remove: false }; } + // E (opt-in): a refused relay still proves the proxy relayed, so the streak resets. + // Status and removal stay untouched: forgetting failures is not declaring the proxy + // healthy, and one target refusing an IP never removes or disables a proxy. + if (outcome === "blocked") { + return { failures: 0, clearFailures: true, setStatus: null, remove: false }; + } + // Success: reset the streak. Only (re)assert "active" when the operator has // opted into status management; otherwise never touch the user's status (C). if (outcome === "ok") { diff --git a/src/lib/proxyHealth/scheduler.ts b/src/lib/proxyHealth/scheduler.ts index 8bf026ac33..41b4c4f9d4 100644 --- a/src/lib/proxyHealth/scheduler.ts +++ b/src/lib/proxyHealth/scheduler.ts @@ -47,6 +47,7 @@ import { waitForProbeSlot, } from "./probeTarget.ts"; import { resolveProviderProbeTarget } from "./providerProbeTarget.ts"; +import { isProxyHealthBlockedResetsStreakEnabled } from "@/shared/utils/featureFlags"; // #6246: a HEAD to the public probe target through a legit (often loaded) proxy // can exceed a few seconds; the old 5s ceiling produced false negatives that @@ -133,8 +134,10 @@ function isBackgroundServicesDisabled(): boolean { * decision layer can apply the #6246 policy: * - "ok" — the proxy relayed and the target served the request. * - "blocked" — the proxy relayed, but the TARGET refused this egress IP - * (401/403/429). Neutral like "inconclusive": the proxy is - * not at fault, yet it is not serving that destination. + * (401/403/429). Neutral like "inconclusive" by default: the + * proxy is not at fault, yet it is not serving that + * destination. With PROXY_HEALTH_BLOCKED_RESETS_STREAK on it + * also resets the consecutive-failure streak (never a status). * - "inconclusive" — NOT the proxy's fault: our own timeout/abort, or the probe * TARGET returned a 5xx (the proxy connected fine). Never * penalizes the proxy. @@ -210,6 +213,7 @@ async function sweep(): Promise { const removeAfter = getRemoveAfter(); const autoRemove = isAutoRemoveEnabled(); const autoDisable = isAutoDisableEnabled(); + const blockedResetsStreak = isProxyHealthBlockedResetsStreakEnabled(); let tested = 0; let alive = 0; @@ -244,6 +248,7 @@ async function sweep(): Promise { autoRemove, autoDisable, removeAfter, + blockedResetsStreak, }); if (decision.clearFailures) failureMap.delete(id); @@ -273,7 +278,7 @@ async function sweep(): Promise { } console.log( - `${LOG_PREFIX} Sweep complete: ${tested} tested, ${alive} alive, ${blocked} blocked by target, ` + + `${LOG_PREFIX} Sweep complete: ${tested} tested, ${alive} alive, ${blocked} refused by target, ` + `${inconclusive} inconclusive, ${removed} auto-removed, ${disabled} auto-disabled` ); } diff --git a/src/lib/proxyLogger.ts b/src/lib/proxyLogger.ts index 0bb278aa0f..21786c839e 100644 --- a/src/lib/proxyLogger.ts +++ b/src/lib/proxyLogger.ts @@ -41,6 +41,8 @@ interface ProxyLogEntry { comboId: string | null; account: string | null; tlsFingerprint: boolean; + /** HTTP status the provider actually returned; null when no response was received. */ + upstreamStatus: number | null; } type ProxyLogInput = Partial & { @@ -93,6 +95,7 @@ function loadFromDb() { comboId: row.combo_id || null, account: row.account || null, tlsFingerprint: row.tls_fingerprint === 1, + upstreamStatus: typeof row.upstream_status === "number" ? row.upstream_status : null, }); } @@ -174,6 +177,7 @@ export function logProxyEvent(entry: ProxyLogInput) { comboId: entry.comboId || null, account: entry.account || null, tlsFingerprint: entry.tlsFingerprint || false, + upstreamStatus: entry.upstreamStatus ?? null, }; // Structured egress line so the operator can confirm, in the proxy logs, which @@ -263,10 +267,10 @@ export function flushProxyLogsSync() { const insertStmt = db.prepare( `INSERT INTO proxy_logs (id, timestamp, status, proxy_type, proxy_host, proxy_port, level, level_id, provider, target_url, public_ip, egress_ip, latency_ms, error, - connection_id, combo_id, account, tls_fingerprint) + connection_id, combo_id, account, tls_fingerprint, upstream_status) VALUES (@id, @timestamp, @status, @proxyType, @proxyHost, @proxyPort, @level, @levelId, @provider, @targetUrl, @clientIp, @egressIp, @latencyMs, @error, - @connectionId, @comboId, @account, @tlsFingerprint)` + @connectionId, @comboId, @account, @tlsFingerprint, @upstreamStatus)` ); const transaction = db.transaction((entries: ProxyLogEntry[]) => { @@ -290,6 +294,7 @@ export function flushProxyLogsSync() { comboId: item.comboId, account: item.account, tlsFingerprint: item.tlsFingerprint ? 1 : 0, + upstreamStatus: item.upstreamStatus, }); } }); diff --git a/src/lib/proxyPoolEgressObservation.ts b/src/lib/proxyPoolEgressObservation.ts new file mode 100644 index 0000000000..f7210fc931 --- /dev/null +++ b/src/lib/proxyPoolEgressObservation.ts @@ -0,0 +1,60 @@ +/** + * Observed egress spread of a proxy pool, for the dashboard pool editor. Read-only and + * never on the routing path: a failure returns null so it cannot break the pool screen. + * Opt-in through the PROXY_POOL_EGRESS_OBSERVATION feature flag (default off: null, the + * line stays hidden). The scope is normalized exactly like the pool read (key -> account, + * global -> "__global__"), and a result is cached for 30 seconds per normalized scope. + */ +import { + EGRESS_IP_LOOKUP_WINDOW_MS, + getPoolEgressObservation, + type PoolEgressObservationCounts, +} from "@/lib/db/proxyLogs"; +import { normalizeAssignmentScopeId, normalizeScope } from "@/lib/db/proxies/mappers"; +import { flushProxyLogsSync } from "@/lib/proxyLogger"; +import { isPoolEgressObservationEnabled } from "@/shared/utils/featureFlags"; + +export type PoolEgressObservation = PoolEgressObservationCounts & { windowHours: number }; + +const CACHE_TTL_MS = 30_000; +const CACHE_MAX_ENTRIES = 200; + +const cache = new Map(); + +export function readPoolEgressObservation( + scope: string, + scopeId: string | null, + nowMs: number = Date.now() +): PoolEgressObservation | null { + if (!isPoolEgressObservationEnabled()) return null; + const normalizedScope = normalizeScope(scope); + const normalizedScopeId = normalizeAssignmentScopeId(normalizedScope, scopeId); + const key = `${normalizedScope}:${normalizedScopeId ?? ""}`; + + const hit = cache.get(key); + if (hit && nowMs - hit.at < CACHE_TTL_MS) return hit.value; + + let value: PoolEgressObservation; + try { + flushProxyLogsSync(); + const since = new Date(nowMs - EGRESS_IP_LOOKUP_WINDOW_MS).toISOString(); + value = { + ...getPoolEgressObservation(normalizedScope, normalizedScopeId, since), + windowHours: EGRESS_IP_LOOKUP_WINDOW_MS / (60 * 60 * 1000), + }; + } catch { + // Observer only: a failed read hides the line instead of failing the pool screen. + return null; + } + + if (!cache.has(key) && cache.size >= CACHE_MAX_ENTRIES) { + const oldest = cache.keys().next().value; + if (oldest !== undefined) cache.delete(oldest); + } + cache.set(key, { at: nowMs, value }); + return value; +} + +export function resetPoolEgressObservationCache(): void { + cache.clear(); +} diff --git a/src/lib/proxySubscription/parse.ts b/src/lib/proxySubscription/parse.ts index 13d9b545bc..122adc4748 100644 --- a/src/lib/proxySubscription/parse.ts +++ b/src/lib/proxySubscription/parse.ts @@ -15,6 +15,7 @@ * Source: operator-supplied subscription feature (Karing-style proxy). */ import * as yaml from "js-yaml"; +import { decodeUserinfo } from "@/shared/utils/decodeUserinfo"; export type DirectProxyType = "http" | "https" | "socks5"; export type RawProxyProtocol = @@ -67,13 +68,7 @@ export interface ParsedSubscription { nodes: SubscriptionNode[]; needsCore: NeedsCoreNode[]; format: - | "clash-yaml" - | "clash-json" - | "v2ray-json" - | "lines" - | "base64-lines" - | "empty" - | "unknown"; + "clash-yaml" | "clash-json" | "v2ray-json" | "lines" | "base64-lines" | "empty" | "unknown"; } function looksLikeBase64(s: string): boolean { @@ -108,7 +103,9 @@ function asProtocol(raw: unknown): RawProxyProtocol { return "unknown"; } -function nodeFromClashObject(obj: Record): SubscriptionNode | NeedsCoreNode | null { +function nodeFromClashObject( + obj: Record +): SubscriptionNode | NeedsCoreNode | null { if (!obj || typeof obj !== "object") return null; const name = typeof obj.name === "string" ? obj.name : ""; const type = asProtocol(obj.type); @@ -187,8 +184,8 @@ function nodeFromUri(uri: string): SubscriptionNode | NeedsCoreNode | null { type: scheme as DirectProxyType, host, port, - username: parsed.username ? decodeURIComponent(parsed.username) : undefined, - password: parsed.password ? decodeURIComponent(parsed.password) : undefined, + username: parsed.username ? decodeUserinfo(parsed.username) : undefined, + password: parsed.password ? decodeUserinfo(parsed.password) : undefined, rawProtocol: scheme as RawProxyProtocol, }; } @@ -219,7 +216,10 @@ function nodeFromUri(uri: string): SubscriptionNode | NeedsCoreNode | null { return null; } -function collectFromArray(items: unknown[], format: ParsedSubscription["format"]): ParsedSubscription { +function collectFromArray( + items: unknown[], + format: ParsedSubscription["format"] +): ParsedSubscription { const nodes: SubscriptionNode[] = []; const needsCore: NeedsCoreNode[] = []; for (const item of items) { @@ -247,7 +247,10 @@ function parseClashYaml(content: string): ParsedSubscription { return collectFromArray(doc.proxies, "clash-yaml"); } if (doc && Array.isArray((doc as Record).outbounds)) { - return collectFromArray((doc as Record).outbounds as unknown[], "clash-yaml"); + return collectFromArray( + (doc as Record).outbounds as unknown[], + "clash-yaml" + ); } } catch { // fall through to unknown @@ -288,13 +291,17 @@ export function parseSubscription(body: string): ParsedSubscription { const json = JSON.parse(content); if (Array.isArray(json)) return collectFromArray(json, "v2ray-json"); if (json && Array.isArray(json.proxies)) return collectFromArray(json.proxies, "clash-json"); - if (json && Array.isArray(json.outbounds)) return collectFromArray(json.outbounds, "v2ray-json"); + if (json && Array.isArray(json.outbounds)) + return collectFromArray(json.outbounds, "v2ray-json"); } catch { // fall through } } - const lines = content.split(/\r?\n/).map((s) => s.trim()).filter(Boolean); + const lines = content + .split(/\r?\n/) + .map((s) => s.trim()) + .filter(Boolean); if (lines.length > 0 && lines.some((l) => /^[a-zA-Z][a-zA-Z0-9+.\-]*:\/\//.test(l))) { const res = parseLineList(lines); return base64Used ? { ...res, format: "base64-lines" } : res; @@ -304,9 +311,7 @@ export function parseSubscription(body: string): ParsedSubscription { } /** Redacted node summary for storage/display (no secrets). */ -export function redactedNodeSummary(parsed: ParsedSubscription): Array< - Record -> { +export function redactedNodeSummary(parsed: ParsedSubscription): Array> { const direct = parsed.nodes.map((n) => ({ name: n.name, type: n.type, diff --git a/src/lib/proxySubscription/subscriptionService.ts b/src/lib/proxySubscription/subscriptionService.ts index cf92db7718..b11a7fdad0 100644 --- a/src/lib/proxySubscription/subscriptionService.ts +++ b/src/lib/proxySubscription/subscriptionService.ts @@ -19,6 +19,7 @@ * protocol translation + node selection). Without it, those nodes are * reported but not routed. */ +import { decodeUserinfo } from "@/shared/utils/decodeUserinfo"; import { randomUUID } from "crypto"; import { getDbInstance } from "../db/core"; import { backupDbFile } from "../db/backup"; @@ -50,9 +51,7 @@ export type ProxySubscriptionStatus = "ok" | "error" | "empty"; * column (as JSON) so the dashboard can localize them via i18n instead of * showing server-side strings. */ export type ProxySubscriptionErrorCode = - | "LOCAL_CORE_ENDPOINT_INVALID" - | "NEEDS_CORE_NOT_CONFIGURED" - | "NO_USABLE_NODES"; + "LOCAL_CORE_ENDPOINT_INVALID" | "NEEDS_CORE_NOT_CONFIGURED" | "NO_USABLE_NODES"; /** Encode a user-facing error as `{ code, detail? }` for i18n on the client. */ export function subscriptionErrorCode(code: ProxySubscriptionErrorCode, detail?: string): string { @@ -205,14 +204,18 @@ export async function updateSubscription( const name = payload.name ?? existing.name; const url = payload.url ?? existing.url; const mode = payload.mode ?? existing.mode; - const ruleProviders = payload.ruleProviders !== undefined ? payload.ruleProviders : existing.ruleProviders; + const ruleProviders = + payload.ruleProviders !== undefined ? payload.ruleProviders : existing.ruleProviders; const localCoreEndpoint = - payload.localCoreEndpoint !== undefined ? payload.localCoreEndpoint : existing.localCoreEndpoint; + payload.localCoreEndpoint !== undefined + ? payload.localCoreEndpoint + : existing.localCoreEndpoint; const updateIntervalMinutes = payload.updateIntervalMinutes ?? existing.updateIntervalMinutes; const now = new Date().toISOString(); const enabledChanged = payload.enabled !== undefined && payload.enabled !== existing.enabled; - const enabled = payload.enabled !== undefined ? (payload.enabled ? 1 : 0) : existing.enabled ? 1 : 0; + const enabled = + payload.enabled !== undefined ? (payload.enabled ? 1 : 0) : existing.enabled ? 1 : 0; db.prepare( `UPDATE proxy_subscriptions @@ -255,7 +258,10 @@ export async function updateSubscription( return getSubscriptionById(id); } -export async function setSubscriptionEnabled(id: string, enabled: boolean): Promise { +export async function setSubscriptionEnabled( + id: string, + enabled: boolean +): Promise { return updateSubscription(id, { enabled }); } @@ -406,7 +412,15 @@ async function keepOwnedSyncedRow( async function syncSubscriptionUnsafe(id: string): Promise { const sub = await getSubscriptionById(id); if (!sub) { - return { subscriptionId: id, nodes: 0, needsCore: 0, boundProxies: 0, status: "error", error: "not found", applied: false }; + return { + subscriptionId: id, + nodes: 0, + needsCore: 0, + boundProxies: 0, + status: "error", + error: "not found", + applied: false, + }; } let body: string; @@ -415,8 +429,23 @@ async function syncSubscriptionUnsafe(id: string): Promise { } catch (e) { const msg = e instanceof Error ? e.message : String(e); const fetchConsec = (sub.consecutiveFailures || 0) + 1; - await updateSubscriptionStatus(id, "error", `Fetch failed: ${msg}`, null, new Date().toISOString(), fetchConsec); - return { subscriptionId: id, nodes: 0, needsCore: 0, boundProxies: 0, status: "error", error: msg, applied: false }; + await updateSubscriptionStatus( + id, + "error", + `Fetch failed: ${msg}`, + null, + new Date().toISOString(), + fetchConsec + ); + return { + subscriptionId: id, + nodes: 0, + needsCore: 0, + boundProxies: 0, + status: "error", + error: msg, + applied: false, + }; } const parsed: ParsedSubscription = parseSubscription(body); @@ -432,86 +461,108 @@ async function syncSubscriptionUnsafe(id: string): Promise { // better-sqlite3, so instead we guard against an unexpected DB error so a // half-completed sync can never be left flagged "ok". try { - // Directly-usable nodes → upsert into the registry as a pool. - for (const node of parsed.nodes) { - // No status: a refresh must not revive a node the operator or auto-disable turned off. - const upserted = await upsertProxy( - { - name: node.name || `${sub.name} (${node.host}:${node.port})`, - type: node.type, - host: node.host, - port: node.port, - username: node.username, - password: node.password, - source: "subscription", - subscriptionId: id, - }, - { claimOwnership: false } - ); - await keepOwnedSyncedRow(upserted, keptIds); - } + // Directly-usable nodes → upsert into the registry as a pool. + for (const node of parsed.nodes) { + // No status: a refresh must not revive a node the operator or auto-disable turned off. + const upserted = await upsertProxy( + { + name: node.name || `${sub.name} (${node.host}:${node.port})`, + type: node.type, + host: node.host, + port: node.port, + username: node.username, + password: node.password, + source: "subscription", + subscriptionId: id, + }, + { claimOwnership: false } + ); + await keepOwnedSyncedRow(upserted, keptIds); + } - // needsCore nodes → bind the operator-supplied local core endpoint (single). - if (parsed.needsCore.length > 0) { - if (sub.localCoreEndpoint && isLocalCoreEndpointAllowed(sub.localCoreEndpoint)) { - try { - const coreUrl = new URL(sub.localCoreEndpoint); - const coreType = coreUrl.protocol === "https:" ? "https" : coreUrl.protocol === "socks5:" ? "socks5" : "http"; - const upserted = await upsertProxy( - { - name: `${sub.name} (local core)`, - type: coreType, - host: coreUrl.hostname, - port: Number(coreUrl.port) || (coreType === "https" ? 443 : 8080), - username: coreUrl.username ? decodeURIComponent(coreUrl.username) : undefined, - password: coreUrl.password ? decodeURIComponent(coreUrl.password) : undefined, - source: "subscription", - subscriptionId: id, - }, - { claimOwnership: false } - ); - await keepOwnedSyncedRow(upserted, keptIds); - } catch { - warning = subscriptionErrorCode("LOCAL_CORE_ENDPOINT_INVALID"); + // needsCore nodes → bind the operator-supplied local core endpoint (single). + if (parsed.needsCore.length > 0) { + if (sub.localCoreEndpoint && isLocalCoreEndpointAllowed(sub.localCoreEndpoint)) { + try { + const coreUrl = new URL(sub.localCoreEndpoint); + const coreType = + coreUrl.protocol === "https:" + ? "https" + : coreUrl.protocol === "socks5:" + ? "socks5" + : "http"; + const upserted = await upsertProxy( + { + name: `${sub.name} (local core)`, + type: coreType, + host: coreUrl.hostname, + port: Number(coreUrl.port) || (coreType === "https" ? 443 : 8080), + username: coreUrl.username ? decodeUserinfo(coreUrl.username) : undefined, + password: coreUrl.password ? decodeUserinfo(coreUrl.password) : undefined, + source: "subscription", + subscriptionId: id, + }, + { claimOwnership: false } + ); + await keepOwnedSyncedRow(upserted, keptIds); + } catch { + warning = subscriptionErrorCode("LOCAL_CORE_ENDPOINT_INVALID"); + } + } else { + const nodes = parsed.needsCore + .map((n) => `${n.rawProtocol}://${n.host ?? ""}${n.port ? ":" + n.port : ""}`) + .join(", "); + warning = subscriptionErrorCode("NEEDS_CORE_NOT_CONFIGURED", nodes); + } + } + + // Remove stale subscription nodes no longer present in the fetched set. + if (keptIds.length > 0) { + const placeholders = keptIds.map(() => "?").join(","); + const stale = db + .prepare( + `SELECT id FROM proxy_registry WHERE subscription_id = ? AND id NOT IN (${placeholders})` + ) + .all(id, ...keptIds) as Array<{ id: string }>; + for (const r of stale) { + try { + await deleteProxyById(r.id, { force: true }); + } catch { + // ignore + } } } else { - const nodes = parsed.needsCore - .map((n) => `${n.rawProtocol}://${n.host ?? ""}${n.port ? ":" + n.port : ""}`) - .join(", "); - warning = subscriptionErrorCode("NEEDS_CORE_NOT_CONFIGURED", nodes); - } - } - - // Remove stale subscription nodes no longer present in the fetched set. - if (keptIds.length > 0) { - const placeholders = keptIds.map(() => "?").join(","); - const stale = db - .prepare(`SELECT id FROM proxy_registry WHERE subscription_id = ? AND id NOT IN (${placeholders})`) - .all(id, ...keptIds) as Array<{ id: string }>; - for (const r of stale) { - try { - await deleteProxyById(r.id, { force: true }); - } catch { - // ignore + const stale = db + .prepare("SELECT id FROM proxy_registry WHERE subscription_id = ?") + .all(id) as Array<{ id: string }>; + for (const r of stale) { + try { + await deleteProxyById(r.id, { force: true }); + } catch { + // ignore + } } } - } else { - const stale = db - .prepare("SELECT id FROM proxy_registry WHERE subscription_id = ?") - .all(id) as Array<{ id: string }>; - for (const r of stale) { - try { - await deleteProxyById(r.id, { force: true }); - } catch { - // ignore - } - } - } } catch (e) { const msg = e instanceof Error ? e.message : String(e); const writeConsec = (sub.consecutiveFailures || 0) + 1; - await updateSubscriptionStatus(id, "error", `Sync write failed: ${msg}`, null, new Date().toISOString(), writeConsec); - return { subscriptionId: id, nodes: 0, needsCore: 0, boundProxies: 0, status: "error", error: msg, applied: false }; + await updateSubscriptionStatus( + id, + "error", + `Sync write failed: ${msg}`, + null, + new Date().toISOString(), + writeConsec + ); + return { + subscriptionId: id, + nodes: 0, + needsCore: 0, + boundProxies: 0, + status: "error", + error: msg, + applied: false, + }; } const lastNodes = redactedNodeSummary(parsed); @@ -710,11 +761,15 @@ export function startSubscriptionScheduler(): void { try { await syncSubscription(s.id); } catch (e) { - console.warn(`[ProxySubscription] refresh failed for ${s.id}: ${e instanceof Error ? e.message : e}`); + console.warn( + `[ProxySubscription] refresh failed for ${s.id}: ${e instanceof Error ? e.message : e}` + ); } } } catch (e) { - console.warn(`[ProxySubscription] scheduler tick error: ${e instanceof Error ? e.message : e}`); + console.warn( + `[ProxySubscription] scheduler tick error: ${e instanceof Error ? e.message : e}` + ); } }; diff --git a/src/lib/quota/accountBuckets.ts b/src/lib/quota/accountBuckets.ts index 6b7ac1a14d..e12a286d43 100644 --- a/src/lib/quota/accountBuckets.ts +++ b/src/lib/quota/accountBuckets.ts @@ -16,6 +16,7 @@ * * Part of: Quota Sharing Engine — Phase 3 (#3 multi-window buckets). */ +import { boundedMap } from "./boundedMap"; // --------------------------------------------------------------------------- // Types @@ -62,8 +63,21 @@ export const SATURATION_THRESHOLD_PCT = 100; // In-process store // --------------------------------------------------------------------------- +/** + * Soft cap on stored buckets. Only SATURATED buckets are ever stored (a + * below-threshold observation deletes the entry), so evicting a live one would + * silently turn "saturated" into "eligible" — the fail-open this store exists to + * prevent. Over the cap only buckets whose reset instant already passed (stale + * saturation the next read would drop anyway) are evicted; live saturated + * buckets are never evicted and the store grows past the cap instead. 4096 ≈ + * 1000+ connections × their 5h/7d/7d: windows all saturated at once. + */ +export const ACCOUNT_BUCKETS_SOFT_CAP = 4096; + /** Key: `${connectionId}::${windowKey}`. */ -const _buckets = new Map(); +const _buckets = boundedMap("account-buckets", ACCOUNT_BUCKETS_SOFT_CAP, "lru", 0, { + shouldEvict: (entry, _key, nowMs) => entry.resetsAtMs > 0 && nowMs >= entry.resetsAtMs, +}); function storeKey(connectionId: string, windowKey: string): string { return `${connectionId}::${windowKey}`; @@ -104,7 +118,7 @@ export function isBucketSaturated( ): boolean { if (!connectionId || !windowKey) return false; // fail-open const key = storeKey(connectionId, windowKey); - const entry = _buckets.get(key); + const entry = _buckets.get(key, nowMs); if (!entry) return false; // fail-open // Lazy reset: the window rolled over → the saturation is stale. @@ -156,7 +170,7 @@ export function recordUsage( return; } - _buckets.set(key, { saturated: true, resetsAtMs }); + _buckets.set(key, { saturated: true, resetsAtMs }, nowMs); } /** diff --git a/src/lib/quota/boundedMap.ts b/src/lib/quota/boundedMap.ts new file mode 100644 index 0000000000..4c42be2e7a --- /dev/null +++ b/src/lib/quota/boundedMap.ts @@ -0,0 +1,179 @@ +// src/lib/quota/boundedMap.ts — size-capped Map for in-process routing/quota caches. +import { createLogger } from "@/shared/utils/logger"; + +/** + * - `lru`: no expiry; over the cap the least-recently-USED entry goes first + * (`get` refreshes recency). + * - `ttl`: entries expire `ttlMs` after they were last `set` (expired reads return + * undefined and drop the entry); over the cap expired entries are swept first, + * then the oldest-WRITTEN entry goes (reads do not refresh anything). + */ +export type BoundedMapPolicy = "lru" | "ttl"; + +export interface BoundedMapLogger { + warn(meta: Record, message: string): void; +} + +export interface BoundedMapOptions { + /** + * Return false to protect an entry from eviction. Protected entries are NEVER + * evicted: when every remaining entry is protected the map grows past its cap + * (and says so in the log) rather than dropping state whose loss would change + * routing — e.g. a saturated quota bucket (fail-open) or a semantic quality pin. + */ + shouldEvict?: (value: V, key: string, nowMs: number) => boolean; + /** Defaults to the project logger (`quota:bounded-map`). */ + log?: BoundedMapLogger; + /** Minimum gap between two eviction log lines for one map. Default 60s. */ + logIntervalMs?: number; +} + +interface Entry { + value: V; + ts: number; +} + +export interface BoundedMap { + get(key: string, nowMs?: number): V | undefined; + set(key: string, value: V, nowMs?: number): void; + delete(key: string): boolean; + clear(): void; + readonly size: number; + keys(): IterableIterator; + [Symbol.iterator](): IterableIterator<[string, V]>; + /** Lifetime counters, for tests and diagnostics. */ + stats(): { evictions: number; overflowInserts: number }; +} + +const DEFAULT_LOG_INTERVAL_MS = 60_000; + +let defaultLogger: BoundedMapLogger | null = null; +function getDefaultLogger(): BoundedMapLogger { + defaultLogger ??= createLogger("quota:bounded-map"); + return defaultLogger; +} + +export function boundedMap( + name: string, + limit: number, + policy: BoundedMapPolicy, + ttlMs = 0, + options: BoundedMapOptions = {} +): BoundedMap { + const inner = new Map>(); + const shouldEvict = options.shouldEvict ?? (() => true); + const logIntervalMs = options.logIntervalMs ?? DEFAULT_LOG_INTERVAL_MS; + const expires = policy === "ttl" && ttlMs > 0; + + let evictions = 0; + let overflowInserts = 0; + // Aggregated logging: the first event logs at once, later ones are summed and + // reported at most once per logIntervalMs — a hot cache at its cap must not + // produce one log line per request. + let pendingEvictions = 0; + let pendingOverflows = 0; + let lastLogAt = Number.NEGATIVE_INFINITY; + + function maybeLog(nowMs: number): void { + if (pendingEvictions === 0 && pendingOverflows === 0) return; + if (nowMs - lastLogAt < logIntervalMs) return; + lastLogAt = nowMs; + (options.log ?? getDefaultLogger()).warn( + { + map: name, + cap: limit, + size: inner.size, + evicted: pendingEvictions, + overflowInserts: pendingOverflows, + }, + `[boundedMap:${name}] cap ${limit} reached: evicted ${pendingEvictions} entr${pendingEvictions === 1 ? "y" : "ies"}` + + (pendingOverflows > 0 + ? `, grew past the cap ${pendingOverflows}x (all entries protected)` + : "") + ); + pendingEvictions = 0; + pendingOverflows = 0; + } + + function isExpired(entry: Entry, nowMs: number): boolean { + return expires && nowMs - entry.ts > ttlMs; + } + + function sweepExpired(nowMs: number): void { + for (const [k, e] of inner) { + if (isExpired(e, nowMs)) inner.delete(k); + } + } + + /** Map iteration order is recency (lru) or write order (ttl): the first evictable key wins. */ + function findVictim(nowMs: number): string | undefined { + for (const [k, e] of inner) { + if (shouldEvict(e.value, k, nowMs)) return k; + } + return undefined; + } + + function makeRoom(nowMs: number): void { + if (inner.size < limit) return; + if (expires) sweepExpired(nowMs); + while (inner.size >= limit) { + const victim = findVictim(nowMs); + if (victim === undefined) { + overflowInserts += 1; + pendingOverflows += 1; + break; + } + inner.delete(victim); + evictions += 1; + pendingEvictions += 1; + } + maybeLog(nowMs); + } + + return { + get(key: string, nowMs: number = Date.now()): V | undefined { + const entry = inner.get(key); + if (!entry) return undefined; + if (isExpired(entry, nowMs)) { + inner.delete(key); + return undefined; + } + if (policy === "lru") { + inner.delete(key); + inner.set(key, entry); + } + return entry.value; + }, + set(key: string, value: V, nowMs: number = Date.now()): void { + if (inner.has(key)) inner.delete(key); + else makeRoom(nowMs); + inner.set(key, { value, ts: nowMs }); + }, + delete(key: string): boolean { + return inner.delete(key); + }, + clear(): void { + inner.clear(); + }, + get size(): number { + return inner.size; + }, + keys(): IterableIterator { + return inner.keys(); + }, + [Symbol.iterator](): IterableIterator<[string, V]> { + const nowMs = Date.now(); + const it = inner.entries(); + function* gen(): Generator<[string, V]> { + for (const [k, e] of it) { + if (isExpired(e, nowMs)) continue; + yield [k, e.value]; + } + } + return gen(); + }, + stats() { + return { evictions, overflowInserts }; + }, + }; +} diff --git a/src/lib/quota/saturationSignals.ts b/src/lib/quota/saturationSignals.ts index eb0365445a..b9986f5a62 100644 --- a/src/lib/quota/saturationSignals.ts +++ b/src/lib/quota/saturationSignals.ts @@ -24,6 +24,7 @@ */ import { createLogger } from "@/shared/utils/logger"; +import { boundedMap } from "./boundedMap"; import { updateAccountBuckets, type ClaudeUsageResult } from "./accountBuckets"; import type { QuotaUnit, QuotaWindow } from "./dimensions"; @@ -33,23 +34,20 @@ const log = createLogger("quota:saturation"); // Types // --------------------------------------------------------------------------- -interface CacheEntry { - value: number; // 0..1 - ts: number; // epoch ms -} - interface DimensionSpec { unit: QuotaUnit; window: QuotaWindow; } // --------------------------------------------------------------------------- -// In-memory cache (Map) +// In-memory cache (boundedMap, 30s TTL). Caps are +// generous (one entry per connection/provider/dimension or provider/connection): +// an evicted entry only costs one extra read, and normal deployments never hit them. // --------------------------------------------------------------------------- const CACHE_TTL_MS = 30_000; // 30 seconds -const _cache = new Map(); +const _cache = boundedMap("saturation-cache", 4096, "ttl", CACHE_TTL_MS); // Pending miss fetches, keyed like _cache. Concurrent getSaturation calls for // the same key share the promise instead of firing one upstream read each. @@ -79,9 +77,19 @@ interface TokenHeaderEntry { ts: number; } -const _rateLimitHeaders = new Map(); -const _tokenHeaders = new Map(); const RL_HEADER_TTL_MS = 5 * 60 * 1000; // 5 minutes +const _rateLimitHeaders = boundedMap( + "saturation-rl-headers", + 4096, + "ttl", + RL_HEADER_TTL_MS +); +const _tokenHeaders = boundedMap( + "saturation-token-headers", + 4096, + "ttl", + RL_HEADER_TTL_MS +); /** Test-only: clear the rate-limit + token header caches between asserts. */ export function _clearRateLimitHeaders(): void { @@ -249,7 +257,7 @@ export function getTokenHeaderSaturation( connectionId: string ): { saturation: number; resetAt: number | null } | null { const entry = _tokenHeaders.get(`${provider}:${connectionId}`); - if (!entry || Date.now() - entry.ts > RL_HEADER_TTL_MS) return null; + if (!entry) return null; if (!(entry.limit > 0)) return null; const used = entry.limit - entry.remaining; const saturation = Math.min(1, Math.max(0, used / entry.limit)); @@ -342,7 +350,7 @@ async function fetchBailianSaturation(connectionId: string, dim: DimensionSpec): */ function anthropicHeaderSaturation(connectionId: string): number { const entry = _rateLimitHeaders.get(`anthropic:${connectionId}`); - if (!entry || Date.now() - entry.ts > RL_HEADER_TTL_MS) return 0; + if (!entry) return 0; const used = entry.limit - entry.remaining; return Math.min(1, Math.max(0, used / entry.limit)); @@ -538,8 +546,8 @@ export async function getSaturation( ): Promise { const key = cacheKey(connectionId, provider, dim); const cached = _cache.get(key); - if (cached && Date.now() - cached.ts < CACHE_TTL_MS) { - return cached.value; + if (cached !== undefined) { + return cached; } const pending = _inflight.get(key); @@ -569,7 +577,7 @@ export async function getSaturation( ); value = 0; } - _cache.set(key, { value, ts: Date.now() }); + _cache.set(key, value); return value; })(); _inflight.set(key, task); diff --git a/src/lib/usage/callLogs.ts b/src/lib/usage/callLogs.ts index 7a2181b0dd..693305e303 100644 --- a/src/lib/usage/callLogs.ts +++ b/src/lib/usage/callLogs.ts @@ -50,6 +50,7 @@ import { protectPipelinePayloads, buildRequestSummary, classifyCallLogError, + toStoredErrorType, } from "./callLogs/format"; import { clearArtifactReference, @@ -507,7 +508,9 @@ async function saveCallLogOperation(entry: any): Promise { // while reasoning source/char-count are recorded separately for observability. const tokensReasoning = getReasoningTokensOrNull(entry.tokens); const reasoningObservation = resolveReasoningObservation(tokensReasoning, entry.responseBody); - const errorType = classifyCallLogError(entry.status, entry.error, entry.provider); + const errorType = toStoredErrorType( + classifyCallLogError(entry.status, entry.error, entry.provider) + ); const logEntry = { id: typeof entry.id === "string" && entry.id.length > 0 ? entry.id : generateLogId(), timestamp: typeof entry.timestamp === "string" ? entry.timestamp : new Date().toISOString(), diff --git a/src/lib/usage/callLogs/format.ts b/src/lib/usage/callLogs/format.ts index ec27a7e993..0f093474dd 100644 --- a/src/lib/usage/callLogs/format.ts +++ b/src/lib/usage/callLogs/format.ts @@ -1,7 +1,9 @@ +import { z } from "zod"; import type { RequestPipelinePayloads } from "@omniroute/open-sse/utils/requestLogger.ts"; import { classifyProviderError, type ErrorTypeContract, + ERROR_TYPE_CONTRACT, } from "@omniroute/open-sse/services/errorClassifier.ts"; import { sanitizeErrorMessage, @@ -190,3 +192,31 @@ export function classifyCallLogError( if (status === 0 ? errorText.length === 0 : status < 400) return null; return classifyProviderError(status, errorText, provider) ?? "unknown"; } + +// #13441: defense in depth at the `call_logs.error_type` write boundary. The +// classifier is typed to the contract, but its runtime values come from +// PROVIDER_ERROR_TYPES while the contract is a frozen snapshot — a family added +// to one and not the other (or any future caller handing in its own string) +// would otherwise persist free text. Built once, on first use, so an import +// cycle through the classifier cannot observe the contract uninitialised. +let storedErrorTypeSchema: z.ZodEnum> | null = null; + +function getStoredErrorTypeSchema() { + if (storedErrorTypeSchema === null) { + storedErrorTypeSchema = z.enum( + ERROR_TYPE_CONTRACT as readonly [ErrorTypeContract, ...ErrorTypeContract[]] + ); + } + return storedErrorTypeSchema; +} + +/** + * Value persisted in `call_logs.error_type`. `null`/`undefined` (not a failure) + * stay NULL; a contract value passes through; anything else is stored as + * `unknown` — never thrown, so a log line is never lost. + */ +export function toStoredErrorType(value: unknown): ErrorTypeContract | null { + if (value === null || value === undefined) return null; + const parsed = getStoredErrorTypeSchema().safeParse(value); + return parsed.success ? parsed.data : "unknown"; +} diff --git a/src/server/authz/policies/management.ts b/src/server/authz/policies/management.ts index 38613f2304..ee30ae4c78 100644 --- a/src/server/authz/policies/management.ts +++ b/src/server/authz/policies/management.ts @@ -251,7 +251,17 @@ export const managementPolicy: RoutePolicy = { } // Tier 2: always-protected routes skip the requireLogin=false bypass. - if (!isAlwaysProtectedPath(path) && !(await isAuthRequired(ctx.request))) { + // + // The fresh-install bootstrap branch inside isAuthRequired() is loopback-only. + // Hand it the SAME trusted verdict the LOCAL_ONLY gate above used (token-stamped + // real TCP peer via peerContext) instead of letting it sniff ctx.request — the + // ORIGINAL request still carries every client-supplied header at this point, + // and the Host header was how a remote caller reached the anonymous + // POST /api/settings/require-login write (GHSA-7pq4-8pvv-rx7r). + if ( + !isAlwaysProtectedPath(path) && + !(await isAuthRequired(ctx.request, { loopback: isLoopbackRequest(ctx) })) + ) { return allow({ kind: "anonymous", id: "anonymous", label: "auth-disabled" }); } diff --git a/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts index b987f4fe49..340f302910 100644 --- a/src/server/authz/routeGuard.ts +++ b/src/server/authz/routeGuard.ts @@ -39,6 +39,27 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray = [ "/api/cli-tools/forge-settings", // spawns via getCliRuntimeStatus() to detect the `forge` CLI install (Hard Rules #15 + #17, #7263) "/api/cli-tools/jcode-settings", // spawns via getCliRuntimeStatus() to detect the `jcode` CLI install (Hard Rules #15 + #17, #7263) "/api/cli-tools/qwen-settings", // GET probes the local `qwen` binary; writes target ~/.qwen config files (Hard Rules #15 + #17) + // GHSA-35fw-cv32-2373: the 14 cli-tools routes below reach the SAME spawn as their six + // gated siblings above — getCliRuntimeStatus() -> locateCommand() -> runProcess("sh", -c + // 'command -v -- "$1"') -> spawn() — but sat on Tier 3 MANAGEMENT only, which + // requireManagementAuth() waives under requireLogin=false (incl. the fresh-install window). + // Exact entries on purpose: a blanket "/api/cli-tools/" prefix would also lock the + // non-spawning apply/backups/config/guide-settings/hermes-agent-settings/keys/logs/ + // openclaw/auto-order routes that tunnel-served dashboards legitimately use. + "/api/cli-tools/all-statuses", // GET calls getCliRuntimeStatus() per CLI_TOOL_IDS entry (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "/api/cli-tools/claude-settings", // spawns via getCliRuntimeStatus() to detect the `claude` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "/api/cli-tools/cline-settings", // spawns via getCliRuntimeStatus() to detect the `cline` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "/api/cli-tools/codewhale-settings", // spawns via getCliRuntimeStatus() to detect the `codewhale` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "/api/cli-tools/codex-settings", // spawns via getCliRuntimeStatus() to detect the `codex` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "/api/cli-tools/crush-settings", // spawns via getCliRuntimeStatus() to detect the `crush` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "/api/cli-tools/deepseek-tui-settings", // spawns via getCliRuntimeStatus() to detect the `deepseek-tui` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "/api/cli-tools/detect", // GET calls detectAllTools() -> execFile(binary, --version) + execFile("which") per tool (src/lib/cli-helper/tool-detector.ts) (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "/api/cli-tools/droid-settings", // spawns via getCliRuntimeStatus() to detect the `droid` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "/api/cli-tools/kilo-settings", // spawns via getCliRuntimeStatus() to detect the `kilo` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "/api/cli-tools/openclaw-settings", // spawns via getCliRuntimeStatus() to detect the `openclaw` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373). Does NOT cover the non-spawning sibling /api/cli-tools/openclaw/auto-order (different segment). + "/api/cli-tools/pi-settings", // spawns via getCliRuntimeStatus() to detect the `pi` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "/api/cli-tools/smelt-settings", // spawns via getCliRuntimeStatus() to detect the `smelt` CLI install (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) + "/api/cli-tools/status", // GET calls getCliRuntimeStatus() per CLI_TOOL_IDS entry (Hard Rules #15 + #17, GHSA-35fw-cv32-2373) "/api/services/", // T-10: embedded service lifecycle (spawn child processes) "/api/tunnels/cloudflared", // POST installs/starts/stops cloudflared; safe methods are exempted below "/api/tunnels/tailscale/disable", // stops Funnel and may stop tailscaled/Tailscale service @@ -66,6 +87,8 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray = [ "/api/oauth/cursor/auto-import", // spawns execFile("which", argv-array-of-one-arg "cursor") to verify a local Cursor install before importing creds — RCE-via-tunnel surface (Hard Rules #15 + #17, found by 6A.8 route-guard gate). Specific path only: the rest of /api/oauth/ (browser redirect/callback flows) must stay remote-reachable. Note: this comment intentionally avoids a literal closing square bracket character — check-openapi-security-tiers.mjs's naive regex parser for this array stops at the first one it finds, silently truncating its view of every entry after this one. "/api/oauth/kiro/auto-import", // reads host-local Kiro credential files (homedir kiro-cli data) — must reach the loopback-only gate, not the PUBLIC /api/oauth/ prefix (GHSA-wgwc-crjm-pmwv, GHSA-gxv4-955v-v6cm). Excluded from PUBLIC in publicApiRoutes.ts. "/api/skills/collect/", // Skill Collector CLI detection: GET .../detect probes getCliRuntimeStatus() per CLI_TOOL_IDS entry, which spawns a child process to check each tool — RCE-via-tunnel surface (Hard Rules #15 + #17, PR #6294 review). + "/api/skills/install", // POST stores the request's handlerCode verbatim as the skill handler with no allowlist; a value equal to the built-in `execute_command` / `eval_code` name aliases the real sandboxed built-in (src/lib/skills/executor.ts -> builtins.ts -> sandbox.ts childProcess.spawn). Transitive spawn the 6A.8 source-scan cannot see. Same class as /api/acp/agents (Hard Rules #15 + #17, GHSA-jx89-f37j-pq89) + "/api/skills/executions", // POST runs skillExecutor.execute() on any global/system skill with caller-chosen input — reaches the container spawn in src/lib/skills/sandbox.ts; only isAuthenticated()-gated, which requireLogin=false waives (Hard Rules #15 + #17, GHSA-jx89-f37j-pq89). Registry list/delete, marketplace and skillssh stay remote-reachable. "/api/discovery/", // Discovery tool (opt-in provider scanner): the scan route makes outbound probes to provider endpoints (SSRF-adjacent) and the whole surface is an admin research tool — strict-loopback only, no manage-scope bypass (NOT in LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES). See _tasks/features-v3.8.42/gaps/DISCOVERY_TOOL_DESIGN.md. VNC_ROUTE_PREFIX, // #7892: /api/vnc-session/* spawns Docker containers via child_process.spawn (src/lib/vncSession/service.ts) — RCE-via-tunnel surface (Hard Rules #15 + #17), same CVE class (GHSA-fhh6-4qxv-rpqj). "/api/acp/agents", // ACP custom-agent registry: POST registers a client-chosen `binary`; GET / POST {action:"refresh"} runs detectInstalledAgents() -> execFileSync(probe.command, probe.args, { shell }) transitively (src/lib/acp/registry.ts) — RCE-via-tunnel surface (Hard Rules #15 + #17, #7948) @@ -154,6 +177,17 @@ export const ALWAYS_PROTECTED_API_PATHS: ReadonlyArray = [ // as the {claude,codex}-auth/apply-local pattern below; a plain path because // it carries no dynamic segment. "/api/providers/agy-auth/apply-local", + // Obsidian integration. POST /webdav points the WebDAV file service — served by + // the custom Node layer BEFORE Next.js, outside this pipeline — at a + // caller-chosen root and echoes freshly minted, reusable Basic credentials; + // DELETE /webdav rotates/clears them; the parent POST stores the Obsidian REST + // API token. GHSA-62vw only masked the GET password reveal, leaving credential + // *issuance* on the fail-open tier: with requireLogin flipped off during the + // bootstrap window, an anonymous caller stood up a file server over DATA_DIR + // and read JWT_SECRET out of server.env (GHSA-7pq4-8pvv-rx7r). Prefix covers + // the /webdav child. ALWAYS_PROTECTED rather than LOCAL_ONLY so an operator + // driving the dashboard through a tunnel keeps the feature. + "/api/settings/obsidian", ]; /** diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index 8271d21c5b..ee089d74a5 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -191,6 +191,78 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ requiresRestart: false, warningLevel: "info", }, + { + key: "PROXY_SKIP_RECENTLY_FAILED", + label: "Skip Recently Failed Proxies", + description: + "Proxy pools and the per-account rotation of opencode stop re-serving a proxy that just failed (refused TCP probe, or a 429 received through it) for a per-process period that doubles on each repeat, up to a cap. No proxy status is written; with every candidate set aside the choice is unchanged. Off by default: selection order is exactly the plain rotation.", + descriptionI18nKey: "featureFlagProxySkipRecentlyFailedDescription", + category: "network", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "caution", + }, + { + key: "PROXY_POOL_EGRESS_OBSERVATION", + label: "Proxy Pool Egress Observation", + description: + "Show, under a proxy pool in the dashboard, how many observed egress IPs served its members over the last 24 h, how many connections used them and the most seen behind one IP. Read-only, computed from the proxy log, never used for routing. Off by default: the pool editor is unchanged and the observation route answers null.", + descriptionI18nKey: "featureFlagProxyPoolEgressObservationDescription", + category: "network", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "info", + }, + { + key: "OPENCODE_RESPONSES_STALL_ROTATION", + label: "OpenCode Responses Stall Rotation", + description: + "For the OpenCode executor, watch the first body byte of a streamed Responses reply (window: RESPONSES_FIRST_BYTE_TIMEOUT_MS, default 15000). A 2xx Responses stream that stays silent past the window is treated as stalled: the account is cooled down and the request rotates to the next account once; a second stall fails fast. Off by default: stalled streams keep today's wait until the stream readiness timeout.", + descriptionI18nKey: "featureFlagOpencodeResponsesStallRotationDescription", + category: "network", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "caution", + }, + { + key: "OPENCODE_USER_BLOCKED_ROTATION", + label: "OpenCode user_blocked Rotation", + description: + "For the OpenCode executor, when an upstream answers 403 or 451 carrying a user_blocked refusal (not a geo block, not a Cloudflare fingerprint rejection), cool the refused account down and rotate to the next account at most once per request; a second refusal is returned as-is without a success mark. Off by default: routing around an upstream user block can look like evasion and spread the flag across the account fleet, so the refusal is returned unchanged unless the operator opts in.", + descriptionI18nKey: "featureFlagOpencodeUserBlockedRotationDescription", + category: "network", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "caution", + }, + { + key: "OPENCODE_TRANSIENT_FAILOVER_BACKOFF", + label: "OpenCode Transient Failover Backoff", + description: + "For the OpenCode multi-account rotation, pause before dispatching to the next account once two consecutive attempts failed with a transient upstream error (5xx or an empty 400 rejection). The pause starts at 1.5s, doubles per further consecutive failure, is capped at 6s per pause and 10s per request, is skipped when the client disconnects, and the failed response body is released before waiting. Off by default: failover stays immediate.", + descriptionI18nKey: "featureFlagOpencodeTransientFailoverBackoffDescription", + category: "network", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "caution", + }, + { + key: "OPENCODE_RATE_LIMITED_429_EARLY_STOP", + label: "OpenCode Rate-Limited 429 Early Stop", + description: + "For the OpenCode multi-account rotation, stop the account wave at the first 429 classified as a real rate limit (a parseable Retry-After header, or a body naming a rate/usage limit) and return that upstream 429 unchanged (status, body, Retry-After and quota headers), instead of trying every remaining account. Unclassified 429s keep rotating. Off by default: the free tier is limited per egress IP (#9611), so every 429 rotates to the next account, and an exhausted wave returns the last upstream 429.", + descriptionI18nKey: "featureFlagOpencodeRateLimited429EarlyStopDescription", + category: "network", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "caution", + }, { key: "MITM_DISABLE_TLS_VERIFY", label: "Disable TLS Verify (MITM)", @@ -449,6 +521,30 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ requiresRestart: false, warningLevel: "danger", }, + { + key: "STREAM_RECOVERY_TOOLCALL_ORDER_FIX", + label: "Tool-Call-Safe Continuation", + description: + "Make mid-stream continuation tool-call safe: never resume a cut stream once a tool call was emitted (in flight or already finished with finish_reason tool_calls), and close after one empty continuation instead of spending the whole budget. Off: release behavior.", + descriptionI18nKey: "featureFlagStreamRecoveryToolcallOrderFixDescription", + category: "runtime", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "info", + }, + { + key: "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED", + label: "Early-EOF Sibling Failover", + description: + "Fail over once to a sibling connection when an SSE stream closes before emitting any useful frame and the bounded same-connection retry is spent; with no usable sibling the original STREAM_EARLY_EOF 502 is returned. Off by default: early-EOF stays terminal after the same-connection retry.", + descriptionI18nKey: "featureFlagStreamEarlyEofSiblingFailoverEnabledDescription", + category: "runtime", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "info", + }, { key: "MODEL_CATALOG_INCLUDE_NAMES", label: "Model Catalog Names", @@ -570,6 +666,66 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ requiresRestart: false, warningLevel: "caution", }, + { + key: "SEARCH_STATS_HIDE_DELETED_CONNECTIONS", + label: "Hide Deleted Search Connections", + description: + "Search stats and recent searches only count providers that still have a live connection (keyless providers such as duckduckgo-free always count). Off keeps every retained search row with a provider id.", + descriptionI18nKey: "featureFlagSearchStatsHideDeletedConnectionsDescription", + category: "runtime", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "info", + }, + { + key: "FREE_BADGE_REQUIRES_PROVIDER_FREE_TIER", + label: "Strict Free Badge", + description: + "Dashboard provider pages: show the Free badge only on signals the provider honors — drops the display-name heuristic, non-boolean free fields and :free suffixes on registered providers without a documented free tier. Off keeps the historical badge rule.", + descriptionI18nKey: "featureFlagFreeBadgeRequiresProviderFreeTierDescription", + category: "runtime", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "info", + }, + { + key: "RETRY_AFTER_PROVENANCE_ENABLED", + label: "Retry-After Provenance", + description: + "On aggregated 429/503 unavailable responses, omit Retry-After when no concrete future retry time is known (instead of a synthetic 1s), add error.retry_after_provenance (signal | none), and let combo drain paths read prose retry hints from JSON and plain-text upstream bodies.", + descriptionI18nKey: "featureFlagRetryAfterProvenanceEnabledDescription", + category: "runtime", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "caution", + }, + { + key: "PROTECTED_PRIORITY_INFRA_502_ENABLED", + label: "Protected-Priority Infra Stops as 502", + description: + "When a priority combo target marked fallback-only-on-quota-exhaustion stops the combo for a cause that is provably not quota (provider circuit breaker open, predictive latency skip), answer 502 instead of the quota-looking 503. Lockout, cooldown, unavailable, exhaustion and concurrency-cap stops keep 503.", + descriptionI18nKey: "featureFlagProtectedPriorityInfra502EnabledDescription", + category: "runtime", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "caution", + }, + { + key: "MISTRAL_AMBIGUOUS_401_SOFT_LOCKOUT", + label: "Mistral Ambiguous 401 Soft Lockout", + description: + 'A bare Mistral 401 ({"detail":"Unauthorized"}, no explicit auth signal) is byte-identical for a revoked key and for exhausted quota. When enabled, such a 401 cools the connection down instead of parking it as expired, up to 3 times within an hour; the next one still parks it as expired, so a revoked key converges. Off by default: every bare Mistral 401 parks the connection as expired, as before.', + descriptionI18nKey: "featureFlagMistralAmbiguous401SoftLockoutDescription", + category: "runtime", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "caution", + }, // ──────────────── CLI (5) ──────────────── { @@ -665,4 +821,16 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ requiresRestart: false, warningLevel: "caution", }, + { + key: "PROXY_HEALTH_BLOCKED_RESETS_STREAK", + label: "Proxy Health: Refusal Resets Failure Streak", + description: + "In the proxy health sweep, let a probe the target refused (401/403/429: the proxy relayed, the destination refused this egress IP) reset the proxy's consecutive-failure streak, like a served probe. Off by default: a refusal stays neutral and keeps the streak (#10654). A 5xx stays inconclusive either way, and a refusal never removes, disables or re-activates a proxy.", + descriptionI18nKey: "featureFlagProxyHealthBlockedResetsStreakDescription", + category: "health", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "info", + }, ]; diff --git a/src/shared/constants/spawnCapablePrefixes.ts b/src/shared/constants/spawnCapablePrefixes.ts index d6392b202b..1c9261347f 100644 --- a/src/shared/constants/spawnCapablePrefixes.ts +++ b/src/shared/constants/spawnCapablePrefixes.ts @@ -26,6 +26,23 @@ export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray = [ "/api/cli-tools/runtime/", "/api/cli-tools/qwen-settings", // GET probes the Qwen Code binary; the route also mutates local ~/.qwen files + // GHSA-35fw-cv32-2373: 14 cli-tools routes that reach the same getCliRuntimeStatus() / + // detectAllTools() spawn as their gated siblings — must never be whitelistable via + // manage-scope bypass (Hard Rules #15 + #17). Exact entries; NOT a "/api/cli-tools/" blanket. + "/api/cli-tools/all-statuses", // GET calls getCliRuntimeStatus() per CLI_TOOL_IDS entry + "/api/cli-tools/claude-settings", // GET probes the `claude` binary via getCliRuntimeStatus() + "/api/cli-tools/cline-settings", // GET probes the `cline` binary via getCliRuntimeStatus() + "/api/cli-tools/codewhale-settings", // GET probes the `codewhale` binary via getCliRuntimeStatus() + "/api/cli-tools/codex-settings", // GET probes the `codex` binary via getCliRuntimeStatus() + "/api/cli-tools/crush-settings", // GET probes the `crush` binary via getCliRuntimeStatus() + "/api/cli-tools/deepseek-tui-settings", // GET probes the `deepseek-tui` binary via getCliRuntimeStatus() + "/api/cli-tools/detect", // GET calls detectAllTools() -> execFile(binary, --version) + execFile("which") per tool + "/api/cli-tools/droid-settings", // GET probes the `droid` binary via getCliRuntimeStatus() + "/api/cli-tools/kilo-settings", // GET probes the `kilo` binary via getCliRuntimeStatus() + "/api/cli-tools/openclaw-settings", // GET probes the `openclaw` binary via getCliRuntimeStatus() + "/api/cli-tools/pi-settings", // GET probes the `pi` binary via getCliRuntimeStatus() + "/api/cli-tools/smelt-settings", // GET probes the `smelt` binary via getCliRuntimeStatus() + "/api/cli-tools/status", // GET calls getCliRuntimeStatus() per CLI_TOOL_IDS entry "/api/services/", // T-10: can run npm install + spawn node processes "/api/tunnels/cloudflared", // POST installs/starts/stops cloudflared; safe methods remain read-only exempt "/api/tunnels/tailscale/disable", // stops Funnel and may stop tailscaled/Tailscale service @@ -40,6 +57,8 @@ export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray = [ "/api/plugins/", // plugins: load/execute via worker_threads + child_process (Hard Rules #15 + #17) "/api/local/", // T-12: 1-click local service launchers (Redis today) — must never be whitelistable via manage-scope bypass (Hard Rules #15 + #17) "/api/skills/collect/", // Skill Collector CLI detection: GET .../detect spawns a child process per CLI_TOOL_IDS entry — must never be whitelistable via manage-scope bypass (Hard Rules #15 + #17, PR #6294 review) + "/api/skills/install", // POST registers a handler string that can alias the built-in execute_command / eval_code (src/lib/skills/executor.ts -> builtins.ts -> sandbox.ts childProcess.spawn) — must never be whitelistable via manage-scope bypass (Hard Rules #15 + #17, GHSA-jx89-f37j-pq89) + "/api/skills/executions", // POST runs skillExecutor.execute() -> container spawn in src/lib/skills/sandbox.ts — must never be whitelistable via manage-scope bypass (Hard Rules #15 + #17, GHSA-jx89-f37j-pq89) "/api/headroom/start", // spawns headroom-ai python CLI — must never be bypassable (Hard Rules #15 + #17) "/api/headroom/stop", // kills tracked PID — must never be bypassable (Hard Rules #15 + #17) "/api/vnc-session", // #7892: spawns Docker containers via child_process.spawn (src/lib/vncSession/service.ts) — must never be whitelistable via manage-scope bypass (Hard Rules #15 + #17) diff --git a/src/shared/utils/apiAuth.ts b/src/shared/utils/apiAuth.ts index 3c7afe96eb..ea5c8938df 100644 --- a/src/shared/utils/apiAuth.ts +++ b/src/shared/utils/apiAuth.ts @@ -9,6 +9,13 @@ import { cookies } from "next/headers"; import { getSettings } from "@/lib/db/settings"; +import { + AUTHZ_HEADER_PEER_LOCALITY, + PEER_IP_HEADER, + VIA_PROXY_HEADER, +} from "@/server/authz/headers"; +import { classifyStampedPeerLocality } from "@/server/authz/peerStamp"; +import { classifyHostLocality } from "@/server/authz/routeGuard"; import { isPublicApiRoute } from "@/shared/constants/publicApiRoutes"; import { verifyDashboardSessionToken } from "@/shared/utils/dashboardSessionToken"; import { extractApiKey } from "@/sse/services/auth"; @@ -21,9 +28,21 @@ type RequestLike = { method?: string; nextUrl?: { hostname?: string | null; pathname?: string | null } | null; url?: string; + /** Real socket peer, present only for direct Node / test callers (never in the proxy runtime). */ + ip?: string; + socket?: { remoteAddress?: string }; }; -const LOOPBACK_HOSTNAMES = new Set(["localhost", "::1"]); +export interface AuthRequiredOptions { + /** + * Pre-resolved trusted peer verdict. The authz policy layer already resolves + * locality from the token-stamped real TCP peer (`peerContext.isLoopbackRequest`) + * and hands it down, so the bootstrap gate never re-derives it from headers on + * the ORIGINAL (pre-strip) request — where a client-supplied copy of the + * pipeline's own locality header could still be present. + */ + loopback?: boolean; +} function hasConfiguredPassword(settings: Record): boolean { return typeof settings.password === "string" && settings.password.length > 0; @@ -86,54 +105,116 @@ function getRequestMethod(request: RequestLike | Request | null | undefined): st return "GET"; } -function getRequestHostname(request: RequestLike | Request | null | undefined): string | null { - const nextHostname = - request && - typeof request === "object" && - "nextUrl" in request && - request.nextUrl && - typeof request.nextUrl.hostname === "string" - ? request.nextUrl.hostname - : null; - - if (nextHostname) return nextHostname; - - const rawUrl = - request && typeof request === "object" && "url" in request && typeof request.url === "string" - ? request.url - : ""; - - if (rawUrl) { - try { - return new URL(rawUrl, "http://localhost").hostname; - } catch { - // Fall through to Host header parsing. - } - } - +function getHeaderValue( + request: RequestLike | Request | null | undefined, + name: string +): string | null { const requestHeaders = request && typeof request === "object" && "headers" in request ? request.headers : undefined; - const host = requestHeaders?.get("host") || requestHeaders?.get("Host") || null; - if (!host) return null; - - try { - return new URL(`http://${host}`).hostname; - } catch { - return host.split(":")[0] || null; - } + return requestHeaders?.get?.(name) ?? null; } -export function isLoopbackRequest(request: RequestLike | Request | null | undefined): boolean { - const hostname = getRequestHostname(request); - if (!hostname) return false; +function getSocketPeerAddress(request: RequestLike | Request | null | undefined): string | null { + if (!request || typeof request !== "object") return null; + const candidate = request as RequestLike; + if (typeof candidate.ip === "string" && candidate.ip) return candidate.ip; + const remoteAddress = candidate.socket?.remoteAddress; + return typeof remoteAddress === "string" && remoteAddress ? remoteAddress : null; +} +/** + * Trusted peer locality for the fresh-install bootstrap gate. + * + * NEVER derived from `Host` / `nextUrl.hostname` / the request URL — all three + * are client-controlled, so a remote caller sending `Host: localhost` used to be + * treated as the local operator (GHSA-7pq4-8pvv-rx7r). The verdict comes from + * the same primitives the authz pipeline already trusts, in this order: + * + * 1. The token-stamped real TCP peer (`PEER_IP_HEADER` + `VIA_PROXY_HEADER`, + * written by the custom Node server from `req.socket.remoteAddress` after + * deleting any client-supplied value, validated against + * OMNIROUTE_PEER_STAMP_TOKEN). This is what the policy layer sees on the + * ORIGINAL request. A stamp present but failing validation → not loopback. + * A loopback socket flagged as a reverse-proxy hop → not loopback. + * 2. The pipeline's own locality verdict (`AUTHZ_HEADER_PEER_LOCALITY`), which + * route handlers see after `runAuthzPipeline` stripped every client-supplied + * copy and re-stamped it from (1). Trusted only while the per-process stamp + * token exists — i.e. a stamping server is actually in front of Next, in + * which case every request that reached the policy carried (1) and this + * branch can only be the post-strip route-handler view. + * 3. A real socket peer (`request.ip` / `request.socket.remoteAddress`) for + * direct Node / unit-test callers that never went through the pipeline. + * The proxy runtime exposes neither, so nothing here is client-reachable. + * + * Anything else → not loopback (fail closed), matching + * `src/server/authz/peerContext.ts::isLoopbackRequest`. + */ +export function isLoopbackRequest(request: RequestLike | Request | null | undefined): boolean { + if (!request || typeof request !== "object") return false; + + const stampToken = process.env.OMNIROUTE_PEER_STAMP_TOKEN; + + const stampedPeer = getHeaderValue(request, PEER_IP_HEADER); + if (stampedPeer !== null) { + return ( + classifyStampedPeerLocality( + stampedPeer, + getHeaderValue(request, VIA_PROXY_HEADER), + stampToken + ) === "loopback" + ); + } + + const pipelineVerdict = getHeaderValue(request, AUTHZ_HEADER_PEER_LOCALITY); + if (pipelineVerdict !== null && stampToken) { + return pipelineVerdict === "loopback"; + } + + const socketPeer = getSocketPeerAddress(request); + if (socketPeer) return classifyHostLocality(socketPeer) === "loopback"; + + // A stamping server is in front (every supported runtime — run-next dev/start and + // standalone-server-ws for Docker, the npm CLI and Electron — calls + // ensurePeerStampToken() at boot) but neither trusted signal is on this request: + // fail closed. The Host header is never consulted in that process. + if (stampToken) return false; + + // No stamping server in this process at all: route handlers invoked directly (the + // unit-test harness) or a raw `next` launch that also bypasses every LOCAL_ONLY + // gate in peerContext. There is no real peer to read, so keep the historical + // URL/Host verdict rather than turning every direct handler call into a remote one. + return isLegacyHostLoopback(request); +} + +function isLegacyHostLoopback(request: RequestLike | Request): boolean { + let hostname: string | null = null; + const candidate = request as RequestLike; + if (candidate.nextUrl && typeof candidate.nextUrl.hostname === "string") { + hostname = candidate.nextUrl.hostname; + } else if (typeof candidate.url === "string" && candidate.url) { + try { + hostname = new URL(candidate.url, "http://localhost").hostname; + } catch { + hostname = null; + } + } + if (!hostname) { + const host = getHeaderValue(request, "host"); + if (!host) return false; + try { + hostname = new URL(`http://${host}`).hostname; + } catch { + hostname = host.split(":")[0] || null; + } + } + if (!hostname) return false; const normalized = hostname .trim() .toLowerCase() .replace(/^\[(.*)\]$/, "$1"); - if (LOOPBACK_HOSTNAMES.has(normalized)) return true; - if (/^127(?:\.\d{1,3}){3}$/.test(normalized)) return true; - return false; + return ( + normalized === "localhost" || normalized === "::1" || /^127(?:\.\d{1,3}){3}$/.test(normalized) + ); } function getCookieValueFromHeader(headers: Headers | undefined, name: string): string | null { @@ -318,9 +399,13 @@ export function isPublicRoute(pathname: string, method = "GET"): boolean { * If requireLogin is explicitly false, auth is skipped. Fresh installs without * a password keep their unauthenticated bootstrap path only on loopback * requests; exposed network requests must configure INITIAL_PASSWORD or log in. + * + * "Loopback" is the trusted peer verdict (`isLoopbackRequest` above, or the + * policy-supplied `options.loopback`), never the Host header. */ export async function isAuthRequired( - request?: RequestLike | Request | null | undefined + request?: RequestLike | Request | null | undefined, + options?: AuthRequiredOptions ): Promise { try { const settings = await getSettings(); @@ -343,11 +428,19 @@ export async function isAuthRequired( return false; } + const loopback = options?.loopback ?? isLoopbackRequest(request); + + // The first-password write is the switch that disarms every other guard + // (requireLogin=false makes isAuthenticated() true everywhere), so it is + // the one bootstrap path that MUST honour the loopback constraint — it + // used to be an unconditional `return false`, open to any network peer + // during the window (GHSA-7pq4-8pvv-rx7r). It stays open for the local + // operator even after onboarding completed without a password. if (isRequireLoginBootstrapWritePath(pathname, method)) { - return false; + return !loopback; } - return settings.setupComplete === true || !isLoopbackRequest(request); + return settings.setupComplete === true || !loopback; } return true; diff --git a/src/shared/utils/decodeUserinfo.ts b/src/shared/utils/decodeUserinfo.ts new file mode 100644 index 0000000000..418ae0482f --- /dev/null +++ b/src/shared/utils/decodeUserinfo.ts @@ -0,0 +1,15 @@ +/** + * decodeUserinfo — guarded percent-decoding for proxy URL userinfo segments. + * + * Correctly encoded values decode as before ("user%40name" -> "user@name"). + * A literal "%" ("user%name") makes decodeURIComponent throw URIError; + * fall back to the raw value instead of rejecting — the raw value may be + * the correct credential. + */ +export function decodeUserinfo(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} diff --git a/src/shared/utils/featureFlags.ts b/src/shared/utils/featureFlags.ts index 22bb1caed8..4f003e9e8a 100644 --- a/src/shared/utils/featureFlags.ts +++ b/src/shared/utils/featureFlags.ts @@ -173,6 +173,139 @@ export function isNetworkRotationSharedEgressGuardEnabled(): boolean { } } +/** + * Proxy refusal memory (#13578): pools and account rotation skip a proxy that just failed. + * Opt-in; an unreadable flag store keeps the plain selection. + */ +export function isProxySkipRecentlyFailedEnabled(): boolean { + try { + return isFeatureFlagEnabled("PROXY_SKIP_RECENTLY_FAILED"); + } catch (error) { + console.error( + "[featureFlags] Failed to resolve PROXY_SKIP_RECENTLY_FAILED, defaulting to disabled:", + error instanceof Error ? error.message : error + ); + return false; + } +} + +/** + * Pool egress observation panel (#13581): read-only dashboard line under a proxy pool. + * Opt-in; an unreadable flag store keeps it hidden. + */ +export function isPoolEgressObservationEnabled(): boolean { + try { + return isFeatureFlagEnabled("PROXY_POOL_EGRESS_OBSERVATION"); + } catch (error) { + console.error( + "[featureFlags] Failed to resolve PROXY_POOL_EGRESS_OBSERVATION, defaulting to disabled:", + error instanceof Error ? error.message : error + ); + return false; + } +} + +/** + * Proxy health sweep (#13608): a target-refused probe resets the consecutive-failure streak. + * Opt-in; an unreadable flag store keeps the neutral policy (#10654). + */ +export function isProxyHealthBlockedResetsStreakEnabled(): boolean { + try { + return isFeatureFlagEnabled("PROXY_HEALTH_BLOCKED_RESETS_STREAK"); + } catch (error) { + console.error( + "[featureFlags] Failed to resolve PROXY_HEALTH_BLOCKED_RESETS_STREAK, defaulting to disabled:", + error instanceof Error ? error.message : error + ); + return false; + } +} + +/** + * OpenCode Responses first-byte stall rotation (#13484). Opt-in: when off, the stream + * readiness timeout stays the only bound on a stalled Responses stream. + * Fail closed: an unreadable flag store keeps the pre-flag behavior (disabled). + */ +export function isOpencodeResponsesStallRotationEnabled(): boolean { + try { + return isFeatureFlagEnabled("OPENCODE_RESPONSES_STALL_ROTATION"); + } catch (error) { + console.error( + "[featureFlags] Failed to resolve OPENCODE_RESPONSES_STALL_ROTATION, defaulting to disabled:", + error instanceof Error ? error.message : error + ); + return false; + } +} + +/** + * OpenCode user_blocked 403/451 bounded rotation (#13498). Opt-in: when off, the refusal is + * returned unchanged exactly as before. + * Fail closed: an unreadable flag store keeps the pre-flag behavior (disabled). + */ +export function isOpencodeUserBlockedRotationEnabled(): boolean { + try { + return isFeatureFlagEnabled("OPENCODE_USER_BLOCKED_ROTATION"); + } catch (error) { + console.error( + "[featureFlags] Failed to resolve OPENCODE_USER_BLOCKED_ROTATION, defaulting to disabled:", + error instanceof Error ? error.message : error + ); + return false; + } +} + +/** + * OpenCode transient-failure failover pause (#13615). Opt-in: when off, failover to the next + * account stays immediate exactly as before. + * Fail closed: an unreadable flag store keeps the pre-flag behavior (disabled). + */ +export function isOpencodeTransientFailoverBackoffEnabled(): boolean { + try { + return isFeatureFlagEnabled("OPENCODE_TRANSIENT_FAILOVER_BACKOFF"); + } catch (error) { + console.error( + "[featureFlags] Failed to resolve OPENCODE_TRANSIENT_FAILOVER_BACKOFF, defaulting to disabled:", + error instanceof Error ? error.message : error + ); + return false; + } +} + +/** + * Mistral bare-401 bounded soft lockout (#13609). Opt-in: when off, a bare Mistral 401 parks + * the connection as expired exactly as before. + * Fail closed: an unreadable flag store keeps the pre-flag behavior (disabled). + */ +export function isMistralAmbiguous401SoftLockoutEnabled(): boolean { + try { + return isFeatureFlagEnabled("MISTRAL_AMBIGUOUS_401_SOFT_LOCKOUT"); + } catch (error) { + console.error( + "[featureFlags] Failed to resolve MISTRAL_AMBIGUOUS_401_SOFT_LOCKOUT, defaulting to disabled:", + error instanceof Error ? error.message : error + ); + return false; + } +} + +/** + * OpenCode classified-429 early stop (#13657). Opt-in: when off, every 429 rotates to the + * next account exactly as before. + * Fail closed: an unreadable flag store keeps the pre-flag behavior (disabled). + */ +export function isOpencodeRateLimited429EarlyStopEnabled(): boolean { + try { + return isFeatureFlagEnabled("OPENCODE_RATE_LIMITED_429_EARLY_STOP"); + } catch (error) { + console.error( + "[featureFlags] Failed to resolve OPENCODE_RATE_LIMITED_429_EARLY_STOP, defaulting to disabled:", + error instanceof Error ? error.message : error + ); + return false; + } +} + export function isServerOwnedToolLoopEnabled( reader: (key: string) => boolean = isFeatureFlagEnabled ): boolean { diff --git a/src/shared/utils/freeModels.ts b/src/shared/utils/freeModels.ts index 279e8cc295..328973013b 100644 --- a/src/shared/utils/freeModels.ts +++ b/src/shared/utils/freeModels.ts @@ -1,5 +1,5 @@ import { FREE_MODEL_BUDGETS, grantsFreeAccess } from "@omniroute/open-sse/config/freeModelCatalog"; -import { resolveProviderId } from "@/shared/constants/providers"; +import { getProviderById, resolveProviderId } from "@/shared/constants/providers"; import { globToRegex } from "@/shared/utils/globPattern"; import { AI_MODELS } from "@/shared/constants/models"; @@ -114,6 +114,53 @@ export function isFreeForProvider(provider: string, model: FreeModelCandidate): return providerHasFreeModels(provider) && isFreeModel(provider, model); } +/** Model row fields the provider-page "Free" badge looks at. */ +export interface FreeBadgeCandidate { + id: string; + name?: string | null; + free?: unknown; + isFree?: unknown; +} + +/** Feature flag that turns on the stricter badge rule (default off). */ +export const FREE_BADGE_STRICT_FLAG = "FREE_BADGE_REQUIRES_PROVIDER_FREE_TIER"; + +/** + * Whether the provider-page model list shows the "Free" badge for a model row. + * + * Default (`strict: false`) is the historical rule, unchanged: any truthy `free` field, + * a `:free` id suffix, "free"/"grátis" in the display name, or `isFreeModel`. + * + * With `strict: true` (feature flag FREE_BADGE_REQUIRES_PROVIDER_FREE_TIER) only badges + * that cannot be right are removed: + * - the display-name heuristic ("Free" in a name is not a pricing signal); + * - a truthy-but-not-`true` `free` field (e.g. `free: "false"`); + * - a `:free` suffix on a REGISTERED provider without a documented free tier — the + * suffix is an OpenRouter convention that such a provider does not implement. + * Kept: catalogued free models, explicit `free`/`isFree === true`, and `:free` on + * free-tier providers (OpenRouter…) and on compatible/custom nodes, whose upstream may + * well be OpenRouter-compatible and honor the suffix. + */ +export function isModelFreeBadge( + provider: string, + model: FreeBadgeCandidate, + options: { strict?: boolean } = {} +): boolean { + if (!options.strict) { + return ( + Boolean(model.free) || + model.id.endsWith(":free") || + /\bgr[aá]tis\b|\bfree\b/i.test(model.name || "") || + isFreeModel(provider, { id: model.id, isFree: model.isFree as boolean | undefined }) + ); + } + const explicit = model.isFree === true || model.free === true; + if (explicit || isFreeModel(provider, { id: model.id })) return true; + if (!model.id.endsWith(":free")) return false; + const registered = getProviderById(resolveProviderId(provider)) != null; + return !registered || providerHasFreeModels(provider); +} + export interface SelectModelsForImportResult { models: T[]; /** diff --git a/src/shared/utils/runtimeTimeouts.ts b/src/shared/utils/runtimeTimeouts.ts index f80219aa7a..74c7c9e96c 100644 --- a/src/shared/utils/runtimeTimeouts.ts +++ b/src/shared/utils/runtimeTimeouts.ts @@ -43,6 +43,11 @@ export const DEFAULT_STREAM_DISCONNECT_GRACE_PERIOD_MS = 10_000; // wreq body falls back fast instead of riding the 10-minute ceiling. Set to // 0 to disable the watchdog entirely. export const DEFAULT_TLS_FIRST_BYTE_WATCHDOG_MS = 10_000; +// A streamed Responses request opens with a lifecycle event (response.created) before any +// generation, so a 2xx Responses stream that stays silent past this window is stalled rather than +// thinking. Executors that can rotate accounts use it to move on instead of waiting for the +// readiness timeout. Set to 0 to disable. +export const DEFAULT_RESPONSES_FIRST_BYTE_TIMEOUT_MS = 15_000; function hasEnvValue(env: EnvSource, name: string): boolean { const raw = env[name]; @@ -230,6 +235,18 @@ export function getTlsFirstByteWatchdogMs( }); } +export function getResponsesFirstByteTimeoutMs( + env: EnvSource = process.env, + logger?: TimeoutLogger +): number { + return readTimeoutMs( + env, + "RESPONSES_FIRST_BYTE_TIMEOUT_MS", + DEFAULT_RESPONSES_FIRST_BYTE_TIMEOUT_MS, + { allowZero: true, logger } + ); +} + export function getApiBridgeTimeoutConfig( env: EnvSource = process.env, logger?: TimeoutLogger diff --git a/src/shared/validation/schemas/proxy.ts b/src/shared/validation/schemas/proxy.ts index 9fc0391652..baff42ae86 100644 --- a/src/shared/validation/schemas/proxy.ts +++ b/src/shared/validation/schemas/proxy.ts @@ -234,6 +234,23 @@ export const proxyPoolMemberSchema = z } }); +// GET /api/settings/proxies/pool/egress-observation query (#13581). Same scope vocabulary and +// scopeId rule as the pool routes: an unknown scope is rejected, never read as "global". +export const proxyPoolEgressObservationQuerySchema = z + .object({ + scope: z.enum(["global", "provider", "account", "combo", "key"]), + scopeId: z.string().trim().max(256).nullable().optional(), + }) + .superRefine((value, ctx) => { + if (value.scope !== "global" && !value.scopeId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "scopeId is required for provider/account/combo/key scope", + path: ["scopeId"], + }); + } + }); + // Set a scope pool's rotation strategy. Optional sticky window (minutes) only // applies to the `sticky` strategy; ignored otherwise. export const proxyRotationStrategySchema = z diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index d3ff15d444..3613e05071 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -98,8 +98,9 @@ import { handleNoCredentials, safeResolveProxy, safeLogEvents, - applyExecutorProxyToInfo, + mergeAppliedProxySink, shouldRetryStreamEarlyEof, + isEarlyEofSiblingFailoverOn, withSessionHeader, withSelectedConnectionHeader, withCorrelationId, @@ -1014,7 +1015,15 @@ async function handleChatImplementation( if (isComboLiveTest) return true; // #12886: combo-name allow-list must not skip inner targets (#9057 still // checks auto/* / disableNonPublic via comboTargetPassesKeyModelPolicy). - if (!(await comboTargetPassesKeyModelPolicy({ apiKey, apiKeyInfo, requestedModelStr: resolvedModelStr, targetModelStr: modelString, isModelAllowedForKey }))) { + if ( + !(await comboTargetPassesKeyModelPolicy({ + apiKey, + apiKeyInfo, + requestedModelStr: resolvedModelStr, + targetModelStr: modelString, + isModelAllowedForKey, + })) + ) { return false; } @@ -1637,6 +1646,9 @@ async function handleSingleModelChat( // re-attempt to exactly one for the whole request. Declared outside both retry // loops so it can never reset and loop. let streamEarlyEofRetries = 0; + // STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED: at most ONE sibling hop per request. Keeps the + // original early-EOF 502 so an exhausted sibling pool surfaces it verbatim (combo detection). + let earlyEofOriginal: Response | null = null; const sameAccountTransportRetries = new Map(); const occupancySessionKey = runtimeOptions.sessionAffinityKey ?? runtimeOptions.sessionId ?? `request:${randomUUID()}`; @@ -1709,6 +1721,7 @@ async function handleSingleModelChat( "allExpired" in credentials || !credentials.connectionId ) { + if (earlyEofOriginal) return earlyEofOriginal; if (credentials?.allRateLimited) { const retryDecision = getCooldownAwareRetryDecision({ retryAfter: credentials.retryAfter, @@ -1916,7 +1929,7 @@ async function handleSingleModelChat( } // #5217: sink for the proxy the executor pins internally (e.g. OpencodeExecutor // rotation) so the egress log below reflects the real egress, not "direct". - const appliedProxySink: { proxy: unknown } = { proxy: null }; + const appliedProxySink: { proxy: unknown; upstreamStatus?: number } = { proxy: null }; const proxyStartTime = Date.now(); // 4. Execute chat via core after breaker gate checks (with optional TLS tracking) if (telemetry) telemetry.startPhase("connect"); @@ -1987,7 +2000,7 @@ async function handleSingleModelChat( // #5217: reflect the proxy the executor actually applied (per-account rotation). void safeLogEvents({ result, - proxyInfo: applyExecutorProxyToInfo(proxyInfo, appliedProxySink.proxy), + proxyInfo: mergeAppliedProxySink(proxyInfo, appliedProxySink), proxyLatency, provider, model, @@ -2093,6 +2106,21 @@ async function handleSingleModelChat( // Stream readiness timeout is an upstream stall after an HTTP response was received, // not an account/quota failure. Do NOT mark the account unavailable here. + if ( + isTerminalStreamEarlyEof && + !hasForcedConnection && + !earlyEofOriginal && + isEarlyEofSiblingFailoverOn() + ) { + // Retry spent and nothing emitted yet: one hop to a sibling (routing only, no mark). + log.warn("STREAM", `${provider}/${model} early-EOF retry exhausted — trying one sibling`); + earlyEofOriginal = withSelectedConnectionHeader( + result.response, + credentials.connectionId + ); + excludedConnectionIds.add(credentials.connectionId); + continue; + } return withSelectedConnectionHeader(result.response, credentials?.connectionId); } diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts index e2277ae1ff..8a18d01c1d 100644 --- a/src/sse/handlers/chatHelpers.ts +++ b/src/sse/handlers/chatHelpers.ts @@ -46,8 +46,10 @@ import { } from "../../shared/utils/circuitBreaker"; import { classify429FromError, type FailureKind } from "../../shared/utils/classify429"; import { resolveUseUpstream429BreakerHints } from "../../shared/utils/providerHints"; +import { isFeatureFlagEnabled } from "../../shared/utils/featureFlags"; import { logProxyEvent } from "../../lib/proxyLogger"; +import { noteProxyOutcome } from "./proxyOutcomeMemory"; import { logTranslationEvent } from "../../lib/translatorEvents"; import { getRuntimeProviderProfile } from "@omniroute/open-sse/services/accountFallback.ts"; @@ -925,6 +927,20 @@ export function shouldRetryStreamEarlyEof( return errorCode === "STREAM_EARLY_EOF" && attempt < STREAM_EARLY_EOF_MAX_RETRIES; } +// The sibling hop widens the terminal/failover boundary, so it ships off +// behind STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED until observed live. +export function isEarlyEofSiblingFailoverOn(): boolean { + try { + return isFeatureFlagEnabled("STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED"); + } catch (error) { + console.error( + "[featureFlags] Failed to resolve STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED, defaulting to disabled:", + error instanceof Error ? error.message : error + ); + return false; + } +} + export function decideProxyResolutionFailure( err: unknown, env: { PROXY_FAIL_OPEN?: string } = process.env @@ -991,6 +1007,27 @@ export function applyExecutorProxyToInfo( }; } +/** + * Carry the HTTP status the provider actually returned (captured on the applied-proxy + * sink around the patched fetch) into proxyInfo. Nothing received -> info unchanged. + * Pure + unit-testable. + */ +export function withUpstreamStatus( + info: T | null | undefined, + sink: { upstreamStatus?: number } +) { + if (typeof sink.upstreamStatus !== "number") return info; + return { ...(info || {}), upstreamStatus: sink.upstreamStatus }; +} + +/** Merge both things the applied-proxy sink captured: the executor proxy, then the status. */ +export function mergeAppliedProxySink( + proxyInfo: { proxy?: unknown; level?: string; levelId?: string | null } | null | undefined, + sink: { proxy: unknown; upstreamStatus?: number } +) { + return withUpstreamStatus(applyExecutorProxyToInfo(proxyInfo, sink.proxy), sink); +} + // Async because the egress-IP lookup lazy-imports proxyEgress; callers treat // this as fire-and-forget logging (the internal try/catch swallows everything). export async function safeLogEvents({ @@ -1006,6 +1043,17 @@ export async function safeLogEvents({ clientRawRequest, tlsFingerprintUsed = false, }) { + // Feed the provider's real answer back to proxy selection (never result.status: some 429s + // are generated locally; proxyInfo carries the status captured around fetch). Must stay + // BEFORE the first await: callers fire-and-forget this function, and only the code ahead + // of that await runs synchronously at the call site, so a request picking from the same + // pool right after already sees a refused member set aside (#13602). + try { + noteProxyOutcome(provider, proxyInfo); + } catch { + // proxy selection feedback is best-effort; never break the request path + } + try { const rawIp = clientRawRequest?.headers?.["x-forwarded-for"] || @@ -1048,6 +1096,7 @@ export async function safeLogEvents({ comboId: comboName || null, account: credentials.connectionId?.slice(0, 8) || null, tlsFingerprint: tlsFingerprintUsed, + upstreamStatus: proxyInfo?.upstreamStatus ?? null, }); } catch {} diff --git a/src/sse/handlers/proxyOutcomeMemory.ts b/src/sse/handlers/proxyOutcomeMemory.ts new file mode 100644 index 0000000000..80d5f7f430 --- /dev/null +++ b/src/sse/handlers/proxyOutcomeMemory.ts @@ -0,0 +1,42 @@ +import { isEgressBucketedLockScope } from "@omniroute/open-sse/config/providerErrorRules.ts"; +import { + hasProxyRefusals, + noteProxyRecovered, + noteProxyRefusal, + noteProxyServed, + proxyEgressKey, +} from "@omniroute/open-sse/utils/proxyRefusalMemory.ts"; +import { isProxySkipRecentlyFailedEnabled } from "@/shared/utils/featureFlags"; + +/** + * Feed the outcome the provider actually returned through a proxy (captured around the + * patched fetch and carried on proxyInfo.upstreamStatus) back to proxy selection. Opt-in: + * only with PROXY_SKIP_RECENTLY_FAILED on does a refusal received through a member set it + * aside for the existing cooldown; a success through such a member clears it. A success + * from another provider proves the proxy is reachable but says nothing about that refusal + * (a global pool is shared across providers), so it only ends an unreachable period. No + * outcome (local refusal, network error), an edge relay (the outcome is the relay's, and + * its key is null) or a direct request writes nothing. Never reads result.status: several + * failures are generated locally. + * + * Synchronous on purpose: the caller runs it as soon as the upstream status is known, so + * a request picking from the pool right after already sees the member set aside. The flag + * is read only for a refusal (a write); a success only clears what an earlier opt-in wrote. + */ +export function noteProxyOutcome( + provider: string | null, + proxyInfo: { proxy?: unknown; upstreamStatus?: number | null } | null | undefined +): void { + const status = proxyInfo?.upstreamStatus; + if (typeof status !== "number") return; + const inRefusalScope = isEgressBucketedLockScope(provider); + if (status === 429 && inRefusalScope) { + const key = proxyEgressKey(proxyInfo?.proxy); + if (key !== null && isProxySkipRecentlyFailedEnabled()) noteProxyRefusal(key, "ip_quota_429"); + return; + } + if (status < 200 || status >= 300 || !hasProxyRefusals()) return; + const key = proxyEgressKey(proxyInfo?.proxy); + if (inRefusalScope) noteProxyServed(key); + else noteProxyRecovered(key, "proxy_unreachable"); +} diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index a9be1beefa..f1f5cb9dfe 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -3157,11 +3157,12 @@ export async function markAccountUnavailable( let terminalStatus = resolveTerminalConnectionStatus( status, - result as { permanent?: boolean; creditsExhausted?: boolean }, + result as { permanent?: boolean; creditsExhausted?: boolean; ambiguousAuth?: boolean }, providerErrorType, provider, isPerModelQuotaProvider, - errorText + errorText, + connectionId ); // A still-valid access token after a successful refresh is not "expired". // A follow-up 401 (timeout, hop, race) must cooldown, not park the account. diff --git a/src/sse/services/authTerminalStatus.ts b/src/sse/services/authTerminalStatus.ts index b8afed537b..6ef2f50f8b 100644 --- a/src/sse/services/authTerminalStatus.ts +++ b/src/sse/services/authTerminalStatus.ts @@ -1,5 +1,6 @@ import { PROVIDER_ERROR_TYPES } from "@omniroute/open-sse/services/errorClassifier.ts"; import { isCreditsExhausted } from "@omniroute/open-sse/services/accountFallback.ts"; +import { takeMistralAmbiguous401SoftStrike } from "@omniroute/open-sse/services/accountFallback/mistralAmbiguousAuth.ts"; import { resolveProviderId, WEB_COOKIE_PROVIDERS } from "@/shared/constants/providers"; // #8200: cookie-auth providers (perplexity-web, grok-web, ...) use a rotating browser @@ -71,11 +72,12 @@ function isExpiredAuthFailure( export function resolveTerminalConnectionStatus( status: number, - result: { permanent?: boolean; creditsExhausted?: boolean }, + result: { permanent?: boolean; creditsExhausted?: boolean; ambiguousAuth?: boolean }, providerErrorType: string | null = null, provider: string | null = null, isPerModelQuotaProvider = false, - errorText: string = "" + errorText: string = "", + connectionId: string | null = null ): string | null { if (shouldParkCreditsExhausted(status, result, isPerModelQuotaProvider, errorText)) { return "credits_exhausted"; @@ -87,6 +89,12 @@ export function resolveTerminalConnectionStatus( return "banned"; } if (isExpiredAuthFailure(status, providerErrorType, provider)) { + // #13609: checkFallbackError only sets ambiguousAuth for a bare Mistral 401 + // with MISTRAL_AMBIGUOUS_401_SOFT_LOCKOUT on. Bounded per connection: past + // the strike limit the connection parks as expired like any other 401. + if (status === 401 && result.ambiguousAuth && connectionId) { + if (takeMistralAmbiguous401SoftStrike(connectionId)) return null; + } return "expired"; } return null; diff --git a/stryker.conf.json b/stryker.conf.json index 6feacd68f8..5045154583 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -126,6 +126,7 @@ "tests/unit/chat-context-relay.test.ts", "tests/unit/chat-cooldown-aware-retry.test.ts", "tests/unit/chat-helpers.test.ts", + "tests/unit/chat-stream-early-eof-failover.test.ts", "tests/unit/chatgpt-web-codex.test.ts", "tests/unit/chat-route-coverage.test.ts", "tests/unit/chat-route-edge-cases.test.ts", @@ -234,6 +235,7 @@ "tests/unit/combo/combo-exhausted-skip.test.ts", "tests/unit/combo/combo-target-timeout-standards.test.ts", "tests/unit/combo/effective-max-concurrency.test.ts", + "tests/unit/combo/protected-priority-stop-status-13439.test.ts", "tests/unit/combo/quota-connection-eligibility.test.ts", "tests/unit/combo/quota-weighted-stale-402.test.ts", "tests/unit/combo/quota-weighted-strategy.test.ts", @@ -247,6 +249,8 @@ "tests/unit/correctness/sanitizers.property.test.ts", "tests/unit/cursor-renewal.test.ts", "tests/unit/custom-model-target-format.test.ts", + "tests/unit/daily-reset-dst-gap.test.ts", + "tests/unit/daily-reset-tz-threading.test.ts", "tests/unit/db-reset-module-state.test.ts", "tests/unit/db-server-tool-executions-migration.test.ts", "tests/unit/db/stats-dbstat-optional.test.ts", @@ -269,6 +273,7 @@ "tests/unit/follow-up-transcript.test.ts", "tests/unit/format-provider-error-cause.test.ts", "tests/unit/forwarded-header-budget.test.ts", + "tests/unit/free-badge-provider-gate.test.ts", "tests/unit/fusion-vision-panel-3378.test.ts", "tests/unit/gemini-deprecated-model-lockout.test.ts", "tests/unit/gemini-web-capabilities-9356.test.ts", @@ -291,6 +296,7 @@ "tests/unit/lkgp-stale-pin-exhaustion-11911.test.ts", "tests/unit/search-432-plan-limit-cooldown.test.ts", "tests/unit/livews-forward-backoff-4604.test.ts", + "tests/unit/local-token-budget-429-skips-cooldown.test.ts", "tests/unit/management-auth-hardening.test.ts", "tests/unit/mark-account-unavailable-numeric-epoch-guard.test.ts", "tests/unit/masked-200-exhaustion-fallback-6427.test.ts", @@ -309,6 +315,7 @@ "tests/unit/native-codex-turn-pin-model-scoped-fallback.test.ts", "tests/unit/no-memory-header.test.ts", "tests/unit/noauth-autocombo-lockout-7623.test.ts", + "tests/unit/noauth-model-lockout.test.ts", "tests/unit/ollama-404-model-lockout-11071.test.ts", "tests/unit/non-streaming-client-translate.test.ts", "tests/unit/non-streaming-provider-leg.test.ts", @@ -370,9 +377,11 @@ "tests/unit/resilience-connections.test.ts", "tests/unit/responses-handler.test.ts", "tests/unit/responses-passthrough-openai-compatible.test.ts", + "tests/unit/retry-after-provenance.test.ts", "tests/unit/rotation-config-omniroute.test.ts", "tests/unit/route-explainability.test.ts", "tests/unit/route-guard-acp-agents-local-only.test.ts", + "tests/unit/route-guard-cli-tools-settings-local-only.test.ts", "tests/unit/route-guard-cursor-agent-availability.test.ts", "tests/unit/route-guard-cursor-refresh.test.ts", "tests/unit/route-guard-forge-jcode-settings-local-only.test.ts", @@ -382,6 +391,7 @@ "tests/unit/route-guard-private-lan.test.ts", "tests/unit/route-guard-provider-login-local-only.test.ts", "tests/unit/route-guard-qwen-settings-local-only.test.ts", + "tests/unit/route-guard-skills-execute-local-only.test.ts", "tests/unit/router-strategies.test.ts", "tests/unit/routing-adaptive-e2e.test.ts", "tests/unit/rule12-error-sanitization-sweep.test.ts", @@ -408,6 +418,7 @@ "tests/unit/sse-auth.test.ts", "tests/unit/stable-json.test.ts", "tests/unit/stream-early-eof-breaker.test.ts", + "tests/unit/stream-recovery-toolcall.test.ts", "tests/unit/stream-readiness.test.ts", "tests/unit/strict-random-deck.test.ts", "tests/unit/strip-reasoning-header.test.ts", diff --git a/tests/fixtures/dashboard-request-failed-redaction-probe.ts b/tests/fixtures/dashboard-request-failed-redaction-probe.ts index f8c5cab643..7108232285 100644 --- a/tests/fixtures/dashboard-request-failed-redaction-probe.ts +++ b/tests/fixtures/dashboard-request-failed-redaction-probe.ts @@ -98,7 +98,8 @@ async function main(): Promise { const writerDrained = await callLogs.waitForCallLogSaves(10_000); assert.equal(writerDrained, true, "call-log write must drain"); - const persisted = await callLogs.getCallLogById(callLogId); + // #13546: each attempt's row is keyed on traceId, not the shared pendingRequestId. + const persisted = await callLogs.getCallLogById(traceId); assert.ok(persisted, "failed attempt must still be available to internal diagnostics"); assert.equal(persisted.error, "Error: Provider failed in with api_key='[REDACTED]'"); assert.doesNotMatch(persisted.error, /sk-live-dashboard-secret|\/srv\/omniroute|\n/); diff --git a/tests/integration/batch-e2e-rate-limit.test.ts b/tests/integration/batch-e2e-rate-limit.test.ts index 3460efbf95..686262d100 100644 --- a/tests/integration/batch-e2e-rate-limit.test.ts +++ b/tests/integration/batch-e2e-rate-limit.test.ts @@ -281,6 +281,12 @@ async function removeDirWithRetry(dir: string) { const relay = createFakeEmbeddingRelay(); let app: ReturnType; const RELAY_BASE = `http://127.0.0.1:${RELAY_PORT}`; +// The `/v1/files` + `/v1/batches` flow is owner-scoped: a file uploaded with no +// key has no owner, and a null-owner record is denied to every non-session +// caller (GHSA-2jm2-mpx8-6523 / GHSA-m3hp-hq9g-fpmv). Mint a real API key +// through the management API (open bootstrap mode, same path that seeds the +// provider node) and present it on every `/v1` call below. +let clientAuthHeaders: Record = {}; test.before(async () => { await relay.start(); @@ -307,6 +313,17 @@ test.before(async () => { `Failed to create provider node: ${nodeResp.status} ${JSON.stringify(nodeBody)}` ); } + + const keyResp = await fetch(`${app.baseUrl}/api/keys`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Batch E2E Test Key" }), + }); + const keyBody = (await keyResp.json().catch(() => null)) as { key?: string } | null; + if (!keyResp.ok || !keyBody?.key) { + throw new Error(`Failed to create API key: ${keyResp.status} ${JSON.stringify(keyBody)}`); + } + clientAuthHeaders = { Authorization: `Bearer ${keyBody.key}` }; }); test.after(async () => { @@ -348,6 +365,7 @@ test("batch E2E: upload file, create batch, verify rate-limit logs appear", asyn const uploadResp = await fetch(`${app.baseUrl}/api/v1/files`, { method: "POST", + headers: clientAuthHeaders, body: formData, }); assert.match( @@ -362,7 +380,7 @@ test("batch E2E: upload file, create batch, verify rate-limit logs appear", asyn // 2. Create batch via HTTP POST const batchResp = await fetch(`${app.baseUrl}/api/v1/batches`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...clientAuthHeaders }, body: JSON.stringify({ input_file_id: fileId, endpoint: "/v1/embeddings", @@ -381,7 +399,9 @@ test("batch E2E: upload file, create batch, verify rate-limit logs appear", asyn while (attempts < maxAttempts) { await sleep(2_000); attempts++; - const sr = await fetch(`${app.baseUrl}/api/v1/batches/${batchId}`); + const sr = await fetch(`${app.baseUrl}/api/v1/batches/${batchId}`, { + headers: clientAuthHeaders, + }); const text = await sr.text(); let sb: BatchResponse; try { @@ -433,7 +453,9 @@ test("batch E2E: upload file, create batch, verify rate-limit logs appear", asyn ); // 5. Verify batch results - const finalResp = await fetch(`${app.baseUrl}/api/v1/batches/${batchId}`); + const finalResp = await fetch(`${app.baseUrl}/api/v1/batches/${batchId}`, { + headers: clientAuthHeaders, + }); const finalBody = await readJsonForTest(finalResp, "Final batch fetch", app); assert.equal( finalBody.request_counts?.completed, diff --git a/tests/integration/files-api-limit-validation.test.ts b/tests/integration/files-api-limit-validation.test.ts index a697e07967..da24604309 100644 --- a/tests/integration/files-api-limit-validation.test.ts +++ b/tests/integration/files-api-limit-validation.test.ts @@ -1,9 +1,25 @@ -import { describe, it } from "node:test"; +import { describe, it, before } from "node:test"; import assert from "node:assert"; -import { createFile, deleteFile } from "@/lib/db/files"; -import { GET, parseFilesListQuery } from "@/app/api/v1/files/route"; + +// `GET /v1/files` fails closed for a caller that is neither an API key nor a +// dashboard session (GHSA-m3hp-hq9g-fpmv), so the HTTP cases below present a +// real key: the subject here is limit validation, not auth. +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "files-limit-validation-secret"; + +const { createFile, deleteFile } = await import("@/lib/db/files"); +const { createApiKey } = await import("@/lib/db/apiKeys"); +const { GET, parseFilesListQuery } = await import("@/app/api/v1/files/route"); + +let authHeaders: Record = {}; +let apiKeyId = ""; describe("GET /v1/files limit validation", () => { + before(async () => { + const key = await createApiKey("files-limit-validation", "machine-files-limit", []); + apiKeyId = key.id; + authHeaders = { Authorization: `Bearer ${key.key}` }; + }); + it("defaults to 20 when limit is absent", () => { const parsed = parseFilesListQuery(new URLSearchParams("order=asc")); @@ -43,6 +59,7 @@ describe("GET /v1/files limit validation", () => { purpose: "assistants", content: Buffer.from("a"), mimeType: "text/plain", + apiKeyId, }), createFile({ bytes: 1, @@ -50,12 +67,15 @@ describe("GET /v1/files limit validation", () => { purpose: "assistants", content: Buffer.from("b"), mimeType: "text/plain", + apiKeyId, }), ]; try { const response = await GET( - new Request("http://localhost/v1/files?limit=1&purpose=assistants") + new Request("http://localhost/v1/files?limit=1&purpose=assistants", { + headers: authHeaders, + }) ); assert.equal(response.status, 200); const body = await response.json(); @@ -68,10 +88,21 @@ describe("GET /v1/files limit validation", () => { }); it("returns 400 over HTTP for an invalid limit instead of listing files", async () => { - const response = await GET(new Request("http://localhost/v1/files?limit=-1")); + const response = await GET( + new Request("http://localhost/v1/files?limit=-1", { headers: authHeaders }) + ); assert.equal(response.status, 400); const body = await response.json(); assert.equal(body.error.type, "invalid_request_error"); }); + + it("rejects an anonymous list with 401 before the limit is even looked at (GHSA-m3hp-hq9g-fpmv)", async () => { + const response = await GET(new Request("http://localhost/v1/files?limit=1")); + + assert.equal(response.status, 401); + const body = await response.json(); + assert.equal(body.error.message, "Authentication required"); + assert.equal(body.error.type, "authentication_error"); + }); }); diff --git a/tests/unit/api-auth.test.ts b/tests/unit/api-auth.test.ts index 9784a688a1..017d9924ad 100644 --- a/tests/unit/api-auth.test.ts +++ b/tests/unit/api-auth.test.ts @@ -17,10 +17,16 @@ const apiAuth = await import("../../src/shared/utils/apiAuth.ts"); const { requireManagementAuth } = await import("../../src/lib/api/requireManagementAuth.ts"); const { getLegacyCliTokenSync, getMachineTokenSync } = await import("../../src/lib/machineToken.ts"); -const { CLI_TOKEN_HEADER } = await import("../../src/server/authz/headers.ts"); +const { AUTHZ_HEADER_PEER_LOCALITY, CLI_TOKEN_HEADER, PEER_IP_HEADER, VIA_PROXY_HEADER } = + await import("../../src/server/authz/headers.ts"); const ORIGINAL_JWT_SECRET = process.env.JWT_SECRET; const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD; +const ORIGINAL_PEER_STAMP_TOKEN = process.env.OMNIROUTE_PEER_STAMP_TOKEN; + +// The per-process secret the custom Node server uses to stamp the real TCP peer +// (scripts/dev/peer-stamp.mjs). Tests mint the same `|` shape. +const TEST_PEER_STAMP_TOKEN = "api-auth-test-peer-stamp-token"; async function resetStorage() { core.resetDbInstance(); @@ -29,6 +35,25 @@ async function resetStorage() { fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); delete process.env.JWT_SECRET; delete process.env.INITIAL_PASSWORD; + delete process.env.OMNIROUTE_PEER_STAMP_TOKEN; +} + +/** + * A request as the authz policy sees it: the custom server already stamped the + * real TCP peer into PEER_IP_HEADER (token-validated), so the verdict cannot be + * influenced by the URL / Host header the client chose. + */ +function stampedPeerRequest(url: string, peerIp: string, init: RequestInit = {}): Request { + process.env.OMNIROUTE_PEER_STAMP_TOKEN = TEST_PEER_STAMP_TOKEN; + const headers = new Headers(init.headers); + headers.set(PEER_IP_HEADER, `${TEST_PEER_STAMP_TOKEN}|${peerIp}`); + headers.set(VIA_PROXY_HEADER, `${TEST_PEER_STAMP_TOKEN}|0`); + return new Request(url, { ...init, headers }); +} + +/** A direct Node / non-pipeline caller carrying a real socket peer. */ +function socketPeerRequest(url: string, peerIp: string, init: RequestInit = {}): Request { + return Object.assign(new Request(url, init), { ip: peerIp }) as Request; } function makeCookieRequest(token: string) { @@ -62,6 +87,12 @@ test.after(() => { } else { process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD; } + + if (ORIGINAL_PEER_STAMP_TOKEN === undefined) { + delete process.env.OMNIROUTE_PEER_STAMP_TOKEN; + } else { + process.env.OMNIROUTE_PEER_STAMP_TOKEN = ORIGINAL_PEER_STAMP_TOKEN; + } }); test("isPublicRoute recognizes allowed API prefixes", () => { @@ -311,12 +342,212 @@ test("isAuthRequired is disabled while no password exists", async () => { test("isAuthRequired keeps fresh bootstrap open only on loopback", async () => { await localDb.updateSettings({ requireLogin: true, password: "" }); - assert.equal(await apiAuth.isAuthRequired(new Request("http://localhost/api/providers")), false); - assert.equal(await apiAuth.isAuthRequired(new Request("http://127.0.0.1/api/providers")), false); + // Loopback is decided from the trusted peer (token-stamped real TCP peer or a + // real socket), never from the URL / Host header (GHSA-7pq4-8pvv-rx7r). + assert.equal( + await apiAuth.isAuthRequired(stampedPeerRequest("http://localhost/api/providers", "127.0.0.1")), + false + ); + assert.equal( + await apiAuth.isAuthRequired(stampedPeerRequest("http://127.0.0.1/api/providers", "::1")), + false + ); + assert.equal( + await apiAuth.isAuthRequired(socketPeerRequest("http://localhost/api/providers", "127.0.0.1")), + false + ); assert.equal( await apiAuth.isAuthRequired(new Request("https://example.com/api/providers")), true ); + assert.equal( + await apiAuth.isAuthRequired( + stampedPeerRequest("https://example.com/api/providers", "203.0.113.9") + ), + true + ); +}); + +// ── GHSA-7pq4-8pvv-rx7r — the bootstrap gate must not trust Host / nextUrl ───── + +test("isLoopbackRequest ignores a spoofed Host header — a non-loopback stamped peer is never loopback (GHSA-7pq4-8pvv-rx7r)", async () => { + // Remote attacker sending `Host: localhost` (the URL's hostname is exactly what + // nextUrl.hostname / the Host header carry). The custom server stamped the real + // peer as 203.0.113.9 → NOT loopback, whatever the client put in Host. + const spoofed = stampedPeerRequest("http://localhost/api/providers", "203.0.113.9", { + headers: { host: "localhost" }, + }); + assert.equal(apiAuth.isLoopbackRequest(spoofed), false); + + // A Host-only "localhost" with no trusted peer signal at all is not loopback either. + assert.equal( + apiAuth.isLoopbackRequest(new Request("http://localhost/api/providers")), + false, + "Host / nextUrl.hostname alone must never make a request loopback" + ); + assert.equal( + apiAuth.isLoopbackRequest( + new Request("http://127.0.0.1/api/providers", { headers: { host: "127.0.0.1" } }) + ), + false + ); + + // The forged stamp shape (`|127.0.0.1`) fails closed. + process.env.OMNIROUTE_PEER_STAMP_TOKEN = TEST_PEER_STAMP_TOKEN; + assert.equal( + apiAuth.isLoopbackRequest( + new Request("http://localhost/api/providers", { + headers: { [PEER_IP_HEADER]: "not-the-process-token|127.0.0.1" }, + }) + ), + false + ); + + // The genuine stamp for a loopback peer IS loopback — but not when the custom + // server also flagged that the request arrived through a reverse-proxy hop. + assert.equal( + apiAuth.isLoopbackRequest(stampedPeerRequest("https://example.com/api/providers", "127.0.0.1")), + true + ); + assert.equal( + apiAuth.isLoopbackRequest( + new Request("http://localhost/api/providers", { + headers: { + [PEER_IP_HEADER]: `${TEST_PEER_STAMP_TOKEN}|127.0.0.1`, + [VIA_PROXY_HEADER]: `${TEST_PEER_STAMP_TOKEN}|1`, + }, + }) + ), + false + ); +}); + +test("isLoopbackRequest consults Host only when no stamping server exists in the process (GHSA-7pq4-8pvv-rx7r)", async () => { + // Every supported runtime calls ensurePeerStampToken() at boot, so once a token + // exists a signal-less request is never loopback, whatever Host says. + process.env.OMNIROUTE_PEER_STAMP_TOKEN = TEST_PEER_STAMP_TOKEN; + assert.equal( + apiAuth.isLoopbackRequest( + new Request("http://localhost/api/providers", { headers: { host: "localhost" } }) + ), + false, + "with a stamping server in front, Host must never make a request loopback" + ); + + // No token at all = no stamping server = direct handler invocation (the unit-test + // harness). There is no real peer to read, so the historical URL verdict applies — + // and it still rejects a non-loopback hostname. + delete process.env.OMNIROUTE_PEER_STAMP_TOKEN; + assert.equal(apiAuth.isLoopbackRequest(new Request("http://localhost/api/providers")), true); + assert.equal(apiAuth.isLoopbackRequest(new Request("https://example.com/api/providers")), false); +}); + +test("isLoopbackRequest trusts the pipeline locality verdict only when a stamping server is in front (GHSA-7pq4-8pvv-rx7r)", async () => { + // Route handlers see AUTHZ_HEADER_PEER_LOCALITY, re-stamped by the pipeline + // after every client-supplied copy was stripped — trustworthy only when the + // per-process stamp token exists (i.e. the custom server is actually stamping). + const verdict = new Request("https://example.com/api/providers", { + headers: { [AUTHZ_HEADER_PEER_LOCALITY]: "loopback" }, + }); + delete process.env.OMNIROUTE_PEER_STAMP_TOKEN; + assert.equal(apiAuth.isLoopbackRequest(verdict), false); + + process.env.OMNIROUTE_PEER_STAMP_TOKEN = TEST_PEER_STAMP_TOKEN; + assert.equal(apiAuth.isLoopbackRequest(verdict), true); + assert.equal( + apiAuth.isLoopbackRequest( + new Request("http://localhost/api/providers", { + headers: { [AUTHZ_HEADER_PEER_LOCALITY]: "remote" }, + }) + ), + false + ); + + // A forged locality header never outranks the real stamped peer. + assert.equal( + apiAuth.isLoopbackRequest( + stampedPeerRequest("http://localhost/api/providers", "203.0.113.9", { + headers: { [AUTHZ_HEADER_PEER_LOCALITY]: "loopback" }, + }) + ), + false + ); +}); + +test("isAuthRequired gates the bootstrap require-login write on the trusted peer (GHSA-7pq4-8pvv-rx7r)", async () => { + await localDb.updateSettings({ requireLogin: true, password: "" }); + + // The write that disarms every other guard (requireLogin=false) used to be an + // unconditional `return false` — open to any network peer in the window. + assert.equal( + await apiAuth.isAuthRequired( + new Request("https://example.com/api/settings/require-login", { method: "POST" }) + ), + true, + "remote POST /api/settings/require-login must require auth in the bootstrap window" + ); + assert.equal( + await apiAuth.isAuthRequired( + stampedPeerRequest("http://localhost/api/settings/require-login", "203.0.113.9", { + method: "POST", + headers: { host: "localhost" }, + }) + ), + true, + "Host: localhost from a non-loopback stamped peer must not reopen the write path" + ); + assert.equal( + await apiAuth.isAuthenticated( + new Request("https://example.com/api/settings/require-login", { method: "POST" }) + ), + false + ); + + // The genuine local operator keeps the first-password flow — including after + // onboarding completed without a password (setupComplete: true). + assert.equal( + await apiAuth.isAuthRequired( + stampedPeerRequest("http://localhost/api/settings/require-login", "127.0.0.1", { + method: "POST", + }) + ), + false + ); + await localDb.updateSettings({ requireLogin: true, password: "", setupComplete: true }); + assert.equal( + await apiAuth.isAuthRequired( + stampedPeerRequest("http://localhost/api/settings/require-login", "127.0.0.1", { + method: "POST", + }) + ), + false + ); + assert.equal( + await apiAuth.isAuthRequired( + new Request("https://example.com/api/settings/require-login", { method: "POST" }) + ), + true + ); +}); + +test("isAuthRequired honours an explicit trusted loopback verdict from the policy layer", async () => { + await localDb.updateSettings({ requireLogin: true, password: "" }); + + // The authz policy resolves locality itself (peerContext) and hands the + // verdict down, so the bootstrap gate never re-reads the ORIGINAL request's + // client-controlled headers. + const forged = new Request("http://localhost/api/settings/require-login", { + method: "POST", + headers: { host: "localhost", [AUTHZ_HEADER_PEER_LOCALITY]: "loopback" }, + }); + assert.equal(await apiAuth.isAuthRequired(forged, { loopback: false }), true); + assert.equal(await apiAuth.isAuthRequired(forged, { loopback: true }), false); + assert.equal( + await apiAuth.isAuthRequired(new Request("https://example.com/api/providers"), { + loopback: true, + }), + false + ); }); test("isAuthenticated rejects remote management bootstrap without a configured password", async () => { @@ -368,8 +599,11 @@ test("isAuthRequired treats partial OIDC config as not configured (bootstrap beh // missing clientId + clientSecret }); - // On loopback without full config → bootstrap allowed - assert.equal(await apiAuth.isAuthRequired(new Request("http://localhost/api/providers")), false); + // On loopback (trusted stamped peer) without full config → bootstrap allowed + assert.equal( + await apiAuth.isAuthRequired(stampedPeerRequest("http://localhost/api/providers", "127.0.0.1")), + false + ); // Remote still requires auth assert.equal( await apiAuth.isAuthRequired(new Request("https://example.com/api/providers")), diff --git a/tests/unit/attempt-logging-early-keepalive-merge.test.ts b/tests/unit/attempt-logging-early-keepalive-merge.test.ts index 2c346b4e22..0da0e0bea4 100644 --- a/tests/unit/attempt-logging-early-keepalive-merge.test.ts +++ b/tests/unit/attempt-logging-early-keepalive-merge.test.ts @@ -23,7 +23,11 @@ const { recordEarlyKeepaliveBytes, takeEarlyKeepaliveBytes } = await import("../../open-sse/utils/earlyKeepaliveByteBuffer.ts"); function baseCtx(overrides: Record = {}) { + // #13481/#13546: the call log row is keyed on traceId. It defaults to + // pendingRequestId so these tests keep polling by the id they pass in. + const pendingRequestId = (overrides.pendingRequestId as string) ?? "REPLACE"; return { + traceId: overrides.traceId ?? pendingRequestId, provider: "openai", connectionId: "conn-1", model: "gpt-x", @@ -48,13 +52,19 @@ function baseCtx(overrides: Record = {}) { } as Parameters[1]; } -async function pollForCallLog(id: string, tries = 120) { - for (let i = 0; i < tries; i++) { +// Wall-clock deadline instead of 120 tries x 20ms (2.4s): on a loaded runner the +// async SQLite write routinely outlasts that ceiling and the row reads as missing. +// Same budget and rationale as tests/unit/video-bridge-log-redaction.test.ts. +const POLL_DEADLINE_MS = 30_000; + +async function pollForCallLog(id: string, deadlineMs = POLL_DEADLINE_MS) { + const deadline = Date.now() + deadlineMs; + for (;;) { const row = await getCallLogById(id); if (row) return row as Record; + if (Date.now() >= deadline) return null; await new Promise((r) => setTimeout(r, 20)); } - return null; } before(async () => { diff --git a/tests/unit/authz/credential-export-always-protected.test.ts b/tests/unit/authz/credential-export-always-protected.test.ts index c297bad003..5ab4a802b9 100644 --- a/tests/unit/authz/credential-export-always-protected.test.ts +++ b/tests/unit/authz/credential-export-always-protected.test.ts @@ -58,6 +58,20 @@ const HARD_GATED_INVENTORY: ReadonlyArray<{ path: string; why: string }> = [ path: "/api/providers/agy-auth/apply-local", why: "writes into ~/.gemini/antigravity-cli/antigravity-oauth-token", }, + // ── Reported in GHSA-7pq4-8pvv-rx7r (JWT_SECRET bootstrap chain) ───────── + // POST points the Obsidian WebDAV file service — served by the custom Node + // layer BEFORE Next.js, so the authz pipeline never runs for it — at an + // attacker-chosen root and echoes freshly minted Basic credentials; DELETE + // rotates/clears them. GHSA-62vw only masked the GET reveal; the credential + // *issuance* was still on the fail-open tier. + { + path: "/api/settings/obsidian/webdav", + why: "POST returns reusable WebDAV Basic credentials for a caller-chosen root; DELETE rotates them (GHSA-7pq4-8pvv-rx7r)", + }, + { + path: "/api/settings/obsidian", + why: "POST stores the Obsidian Local REST API token; same credential surface as its /webdav child (GHSA-7pq4-8pvv-rx7r)", + }, // ── Already fixed; pinned so a refactor cannot silently drop them ──────── { path: "/api/db-backups/export", why: "GHSA-mghq-58h3-qcqj" }, { path: "/api/db-backups/exportAll", why: "GHSA-mghq-58h3-qcqj" }, @@ -126,7 +140,20 @@ test("a connection id cannot escape the pattern with a slash", () => { }); test("the plain-path allowlist keeps its existing entries", () => { - for (const p of ["/api/shutdown", "/api/settings/database", "/api/db-backups"]) { + for (const p of [ + "/api/shutdown", + "/api/settings/database", + "/api/db-backups", + "/api/settings/obsidian", + ]) { assert.ok(ALWAYS_PROTECTED_API_PATHS.includes(p), p); } }); + +test("the obsidian entry does not over-protect its /api/settings neighbours", () => { + // `/api/settings/obsidian` is a plain prefix; the sibling settings routes must + // stay on the MANAGEMENT tier for keyless local-first installs. + for (const path of ["/api/settings", "/api/settings/notion", "/api/settings/require-login"]) { + assert.equal(isAlwaysProtectedPath(path), false, path); + } +}); diff --git a/tests/unit/authz/management-policy.test.ts b/tests/unit/authz/management-policy.test.ts index 6e8feda318..aedd82d81d 100644 --- a/tests/unit/authz/management-policy.test.ts +++ b/tests/unit/authz/management-policy.test.ts @@ -112,7 +112,12 @@ function remoteCtx(headers: Headers, method = "GET", path = "/api/keys") { test("managementPolicy: allows when auth not required (no password set)", async () => { await settingsDb.updateSettings({ requireLogin: true, password: null }); const policy = await loadPolicy(); - const out = await policy.evaluate(ctx(new Headers())); + // Fresh-bootstrap anonymous allow is loopback-only, and loopback is decided + // from the real peer (socket.remoteAddress / stamped peer), never from the + // `http://localhost` URL the ctx() helper carries (GHSA-7pq4-8pvv-rx7r). + const out = await policy.evaluate( + ctx(new Headers(), "GET", "/api/keys", { socket: { remoteAddress: "127.0.0.1" } }) + ); assert.equal(out.allow, true); if (out.allow) { assert.equal(out.subject.kind, "anonymous"); @@ -133,6 +138,94 @@ test("managementPolicy: rejects remote fresh bootstrap without a password", asyn } }); +// ─── GHSA-7pq4-8pvv-rx7r — bootstrap first-password write is loopback-only ──── +// +// `POST /api/settings/require-login` in the bootstrap window used to be an +// unconditional anonymous allow (apiAuth.isAuthRequired returned false before +// the loopback check), and the loopback check itself read the client-controlled +// Host header. A remote caller could flip requireLogin=false, then read +// JWT_SECRET through the Obsidian WebDAV file service and forge a durable admin +// session. The policy must decide from the token-stamped real peer. + +const BOOTSTRAP_WRITE_PATH = "/api/settings/require-login"; +const POLICY_STAMP_TOKEN = "mgmt-policy-test-peer-stamp-token"; + +function stampedHeaders(peerIp: string, extra: Record = {}): Headers { + process.env.OMNIROUTE_PEER_STAMP_TOKEN = POLICY_STAMP_TOKEN; + return new Headers({ + ...extra, + "x-omniroute-peer-ip": `${POLICY_STAMP_TOKEN}|${peerIp}`, + "x-omniroute-via-proxy": `${POLICY_STAMP_TOKEN}|0`, + }); +} + +test("managementPolicy: rejects an anonymous remote POST /api/settings/require-login in the bootstrap window (GHSA-7pq4-8pvv-rx7r)", async () => { + await settingsDb.updateSettings({ requireLogin: true, password: null }); + const policy = await loadPolicy(); + try { + // Plain remote peer, no stamp at all → fail closed. + const unstamped = await policy.evaluate(remoteCtx(new Headers(), "POST", BOOTSTRAP_WRITE_PATH)); + assert.equal(unstamped.allow, false); + if (!unstamped.allow) { + assert.equal(unstamped.status, 401); + assert.equal(unstamped.code, "AUTH_001"); + } + + // Host-spoof: the URL / Host header say localhost, the stamped real peer is + // a public address, and the client even forged the pipeline's locality + // verdict header. None of that is loopback. + const spoofed = await policy.evaluate( + ctx( + stampedHeaders("203.0.113.9", { + host: "localhost:20128", + "x-omniroute-peer-locality": "loopback", + }), + "POST", + BOOTSTRAP_WRITE_PATH + ) + ); + assert.equal(spoofed.allow, false); + if (!spoofed.allow) { + assert.equal(spoofed.status, 401); + assert.equal(spoofed.code, "AUTH_001"); + } + } finally { + delete process.env.OMNIROUTE_PEER_STAMP_TOKEN; + } +}); + +test("managementPolicy: keeps the bootstrap first-password write open for the stamped loopback peer (GHSA-7pq4-8pvv-rx7r)", async () => { + await settingsDb.updateSettings({ requireLogin: true, password: null, setupComplete: true }); + const policy = await loadPolicy(); + try { + const local = await policy.evaluate( + ctx(stampedHeaders("127.0.0.1"), "POST", BOOTSTRAP_WRITE_PATH) + ); + assert.equal(local.allow, true); + if (local.allow) { + assert.equal(local.subject.kind, "anonymous"); + assert.equal(local.subject.label, "auth-disabled"); + } + + // A loopback socket that is really a reverse-proxy hop (via-proxy marker + // set by the custom server) is NOT the local operator. + process.env.OMNIROUTE_PEER_STAMP_TOKEN = POLICY_STAMP_TOKEN; + const viaProxy = await policy.evaluate( + ctx( + new Headers({ + "x-omniroute-peer-ip": `${POLICY_STAMP_TOKEN}|127.0.0.1`, + "x-omniroute-via-proxy": `${POLICY_STAMP_TOKEN}|1`, + }), + "POST", + BOOTSTRAP_WRITE_PATH + ) + ); + assert.equal(viaProxy.allow, false); + } finally { + delete process.env.OMNIROUTE_PEER_STAMP_TOKEN; + } +}); + test("managementPolicy: rejects 401 when auth required and no credentials", async () => { process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy"; process.env.INITIAL_PASSWORD = "initial-pass"; diff --git a/tests/unit/authz/pipeline.test.ts b/tests/unit/authz/pipeline.test.ts index d89b152219..88cbc50680 100644 --- a/tests/unit/authz/pipeline.test.ts +++ b/tests/unit/authz/pipeline.test.ts @@ -200,7 +200,7 @@ test("runAuthzPipeline allows onboarding when login is required but no password assert.equal(response.headers.get("x-omniroute-route-class"), "PUBLIC"); }); -test("runAuthzPipeline allows first password writes when login is required but no password exists", async () => { +test("runAuthzPipeline allows first password writes when login is required but no password exists — from the stamped loopback peer only (GHSA-7pq4-8pvv-rx7r)", async () => { delete process.env.INITIAL_PASSWORD; await settingsDb.updateSettings({ requireLogin: true, @@ -208,13 +208,47 @@ test("runAuthzPipeline allows first password writes when login is required but n password: "", }); - const response = await pipeline.runAuthzPipeline( + // The local operator (real TCP peer 127.0.0.1, stamped by the custom server) + // keeps the first-password flow, whatever hostname they typed. + process.env.OMNIROUTE_PEER_STAMP_TOKEN = "pipeline-test-peer-stamp-token"; + const local = await pipeline.runAuthzPipeline( + request("https://example.com/api/settings/require-login", { + method: "POST", + headers: { + "x-omniroute-peer-ip": "pipeline-test-peer-stamp-token|127.0.0.1", + "x-omniroute-via-proxy": "pipeline-test-peer-stamp-token|0", + }, + }), + { enforce: true } + ); + assert.equal(local.status, 200); + assert.equal(local.headers.get("x-omniroute-route-class"), "MANAGEMENT"); + + // A remote peer — even one spelling the URL as localhost and forging the + // pipeline's own locality verdict header — must not reach the anonymous write + // that flips requireLogin=false (the first link of the JWT_SECRET chain). + const spoofed = await pipeline.runAuthzPipeline( + request("http://localhost/api/settings/require-login", { + method: "POST", + headers: { + host: "localhost", + "x-omniroute-peer-locality": "loopback", + "x-omniroute-peer-ip": "pipeline-test-peer-stamp-token|203.0.113.9", + "x-omniroute-via-proxy": "pipeline-test-peer-stamp-token|0", + }, + }), + { enforce: true } + ); + assert.equal(spoofed.status, 401); + assert.equal((await spoofed.json()).error.code, "AUTH_001"); + + // No stamp at all (nothing trustworthy about the peer) → fail closed. + delete process.env.OMNIROUTE_PEER_STAMP_TOKEN; + const unstamped = await pipeline.runAuthzPipeline( request("https://example.com/api/settings/require-login", { method: "POST" }), { enforce: true } ); - - assert.equal(response.status, 200); - assert.equal(response.headers.get("x-omniroute-route-class"), "MANAGEMENT"); + assert.equal(unstamped.status, 401); }); test("runAuthzPipeline keeps management API rejections as JSON", async () => { diff --git a/tests/unit/authz/route-guard-skills-collect.test.ts b/tests/unit/authz/route-guard-skills-collect.test.ts index 795fc3f59e..03b8b80df8 100644 --- a/tests/unit/authz/route-guard-skills-collect.test.ts +++ b/tests/unit/authz/route-guard-skills-collect.test.ts @@ -21,12 +21,16 @@ test("isLocalOnlyPath: /api/skills/collect/ prefix is local-only (Hard Rules #15 }); test("isLocalOnlyPath: the rest of /api/skills/ stays remote-reachable (no over-broadening)", () => { - // Only the spawn-capable collect/* subtree is loopback-locked. The rest of the - // skills surface (registry install, marketplace, skillssh) already gates on + // Only the spawn-capable subtrees are loopback-locked. The rest of the skills + // surface (registry list/delete, marketplace, skillssh) gates on // requireManagementAuth() and must remain reachable remotely. + // (/api/skills/install used to be the negative control here, but it can alias + // the sandboxed execute_command built-in and became LOCAL_ONLY under + // GHSA-jx89-f37j-pq89 — see tests/unit/route-guard-skills-execute-local-only.test.ts.) assert.equal(isLocalOnlyPath("/api/skills"), false); - assert.equal(isLocalOnlyPath("/api/skills/install"), false); + assert.equal(isLocalOnlyPath("/api/skills/marketplace"), false); assert.equal(isLocalOnlyPath("/api/skills/marketplace/install"), false); + assert.equal(isLocalOnlyPath("/api/skills/skillssh/install"), false); }); test("isLocalOnlyBypassableByManageScope: /api/skills/collect/ is NOT bypassable (defence in depth)", () => { diff --git a/tests/unit/authz/spawn-capable-prefixes-client-safe.test.ts b/tests/unit/authz/spawn-capable-prefixes-client-safe.test.ts index f28e4e8c80..ea7bef6dc8 100644 --- a/tests/unit/authz/spawn-capable-prefixes-client-safe.test.ts +++ b/tests/unit/authz/spawn-capable-prefixes-client-safe.test.ts @@ -90,11 +90,33 @@ test("SPAWN_CAPABLE_PREFIXES is defined in the server-free constants leaf with t "/api/tunnels/tailscale/install", "/api/tunnels/tailscale/login", "/api/tunnels/tailscale/start-daemon", + // GHSA-35fw-cv32-2373: the 14 cli-tools routes that reach the same + // getCliRuntimeStatus() / detectAllTools() spawn as their gated siblings. + "/api/cli-tools/all-statuses", + "/api/cli-tools/claude-settings", + "/api/cli-tools/cline-settings", + "/api/cli-tools/codewhale-settings", + "/api/cli-tools/codex-settings", + "/api/cli-tools/crush-settings", + "/api/cli-tools/deepseek-tui-settings", + "/api/cli-tools/detect", + "/api/cli-tools/droid-settings", + "/api/cli-tools/kilo-settings", + "/api/cli-tools/openclaw-settings", + "/api/cli-tools/pi-settings", + "/api/cli-tools/smelt-settings", + "/api/cli-tools/status", + // GHSA-jx89-f37j-pq89: skills handler registration + execution reach the + // sandbox container spawn transitively. + "/api/skills/install", + "/api/skills/executions", ]) { assert.ok( SPAWN_CAPABLE_PREFIXES.includes(prefix), `SPAWN_CAPABLE_PREFIXES lost the spawn-capable prefix "${prefix}" during extraction` ); } - assert.equal(SPAWN_CAPABLE_PREFIXES.length, 20); + // 20 at extraction time + 14 (GHSA-35fw-cv32-2373) + 2 (GHSA-jx89-f37j-pq89). + // qwen-settings is the one pre-existing entry not enumerated above. + assert.equal(SPAWN_CAPABLE_PREFIXES.length, 36); }); diff --git a/tests/unit/batch-cancel-session-auth-scope.test.ts b/tests/unit/batch-cancel-session-auth-scope.test.ts new file mode 100644 index 0000000000..120d3b19f2 --- /dev/null +++ b/tests/unit/batch-cancel-session-auth-scope.test.ts @@ -0,0 +1,133 @@ +/** + * `POST /api/v1/batches/[id]/cancel` rejected the dashboard's own + * session-authenticated caller as "Batch not found" (404) for any batch + * owned by a non-null api_key_id -- which in practice is every batch created + * through the default `env-key`, i.e. every real batch on the instance. + * Cancelling from the dashboard silently did nothing. + * + * Root cause: the route carried its own inline ownership check + * (`batch.apiKeyId !== null && batch.apiKeyId !== apiKeyId`) instead of the + * canonical rule that `batches/[id]/route.ts` (GET/DELETE) and + * `deleteCompletedBatches()` (GHSA-wvxc-jp3v-5mg5) already share: session auth + * is the instance-wide operator, able to act on any record regardless of which + * API key owns it. The inline check never granted that exemption, so a + * session-authenticated caller (`apiKeyId === null`) was treated as a mismatched + * key the instant `batch.apiKeyId` was non-null. + * + * This test proves the fix at the ownership-decision boundary — the rule now + * shared as `canAccessOwnedRecord()` in `_helpers/apiKeyScope.ts` — against a + * batch shaped exactly like the two that were actually stuck in production + * (`api_key_id: "env-key"`), and proves the route source no longer contains the + * buggy inline check. The route-level proof (a real session cookie against the + * real handler) lives in tests/unit/files-batches-ownership-2jm2-m3hp.test.ts. + * + * Originally contributed in PR #13683 (@hartmark); folded into the + * GHSA-2jm2-mpx8-6523 / GHSA-m3hp-hq9g-fpmv fix, which subsumes it. + * + * Run with: + * node --import tsx/esm --test tests/unit/batch-cancel-session-auth-scope.test.ts + */ + +import { describe, it, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Self-isolating: DATA_DIR points at a fresh temp dir BEFORE any `@/lib/db/*` +// module loads, so this file never touches ~/.omniroute. +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "batch-cancel-session-scope-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { resetDbInstance } = await import("../../src/lib/db/core.ts"); +const { createFile } = await import("../../src/lib/db/files.ts"); +const { createBatch } = await import("../../src/lib/db/batches.ts"); +const { canAccessOwnedRecord } = await import("../../src/app/api/v1/_helpers/apiKeyScope.ts"); + +function seedBatch(apiKeyId: string | null, status: "validating" | "in_progress", tag: string) { + const file = createFile({ + bytes: 10, + filename: `cancel-scope-${tag}.jsonl`, + purpose: "batch", + content: Buffer.from("{}"), + apiKeyId, + }); + return createBatch({ + endpoint: "/v1/chat/completions", + completionWindow: "24h", + inputFileId: file.id, + status, + apiKeyId, + }); +} + +after(() => { + resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +describe("cancel route ownership scoping", () => { + it("session auth (dashboard) may cancel a batch owned by an API key", () => { + const batch = seedBatch("env-key", "in_progress", "a1"); + + // Exactly the check cancel/route.ts now runs: `!canAccessOwnedRecord(scope, batch.apiKeyId)` + const allowed = canAccessOwnedRecord({ isSessionAuth: true, apiKeyId: null }, batch.apiKeyId); + + assert.equal(allowed, true, "the operator's dashboard must be able to cancel any batch"); + }); + + it("an unrelated API key may not cancel someone else's batch", () => { + const batch = seedBatch("env-key", "validating", "a2"); + + const allowed = canAccessOwnedRecord( + { isSessionAuth: false, apiKeyId: "other-key" }, + batch.apiKeyId + ); + + assert.equal(allowed, false, "a foreign API key must not be able to cancel this batch"); + }); + + it("the owning API key may cancel its own batch", () => { + const batch = seedBatch("key-owns-this", "validating", "a3"); + + const allowed = canAccessOwnedRecord( + { isSessionAuth: false, apiKeyId: "key-owns-this" }, + batch.apiKeyId + ); + + assert.equal(allowed, true, "the owning API key must be able to cancel its own batch"); + }); + + it("the original buggy inline check would have rejected the session-auth caller", () => { + const batch = seedBatch("env-key", "in_progress", "a4"); + + // This is the exact predicate cancel/route.ts used to run before the fix. + const apiKeyId: string | null = null; // session auth + const rejectedByOldCheck = !batch || (batch.apiKeyId !== null && batch.apiKeyId !== apiKeyId); + + assert.equal( + rejectedByOldCheck, + true, + "documents the regression: the old inline check 404'd every dashboard cancel" + ); + }); +}); + +describe("the route uses the shared ownership rule instead of its old inline predicate", () => { + it("cancel/route.ts no longer carries the buggy apiKeyId !== null inline check", async () => { + const { readFileSync } = await import("node:fs"); + const { fileURLToPath } = await import("node:url"); + const src = readFileSync( + fileURLToPath(new URL("../../src/app/api/v1/batches/[id]/cancel/route.ts", import.meta.url)), + "utf8" + ); + assert.ok( + !/batch\.apiKeyId\s*!==\s*null\s*&&\s*batch\.apiKeyId\s*!==\s*apiKeyId/.test(src), + "the route still carries the old inline ownership check that 404s session auth" + ); + assert.ok( + /canAccessOwnedRecord\(\s*scope\s*,\s*batch\.apiKeyId\s*\)/.test(src), + "the route must delegate ownership to the shared canAccessOwnedRecord helper" + ); + }); +}); diff --git a/tests/unit/batch-deletion-route-logic.test.ts b/tests/unit/batch-deletion-route-logic.test.ts index b486145725..e3043fbc58 100644 --- a/tests/unit/batch-deletion-route-logic.test.ts +++ b/tests/unit/batch-deletion-route-logic.test.ts @@ -1,9 +1,18 @@ import { test } from "node:test"; import assert from "node:assert"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; // Tests for the business logic embedded in DELETE route handlers. // These verify every code path without importing Next.js route modules -// (which pull in pino/thread-stream — broken on Node 26). +// (which pull in pino/thread-stream — broken on Node 26). The ownership rule is +// the REAL shared helper, not a local copy: a copy drifted from production once +// (v3.8.4 tightened the copy, production stayed open — GHSA-2jm2-mpx8-6523). +// The helper's module pulls in the DB layer, so isolate DATA_DIR before it loads. +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "batch-deletion-route-logic-")); +process.env.DATA_DIR = TEST_DATA_DIR; +const { canAccessOwnedRecord } = await import("../../src/app/api/v1/_helpers/apiKeyScope.ts"); const TERMINAL = ["completed", "failed", "cancelled", "expired"]; @@ -12,15 +21,17 @@ function scopeCheck( recordApiKeyId: string | null | undefined, apiKeyId: string | null ): boolean { - if (isSessionAuth) return true; - if (recordApiKeyId === null || recordApiKeyId === undefined) return apiKeyId !== null; - return recordApiKeyId === apiKeyId; + return canAccessOwnedRecord({ isSessionAuth, apiKeyId }, recordApiKeyId); } function canDeleteBatch(status: string): boolean { return TERMINAL.includes(status); } +test.after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + test("scopeCheck — session auth always passes", () => { assert.strictEqual(scopeCheck(true, "key-1", "key-1"), true); assert.strictEqual(scopeCheck(true, "key-1", "different-key"), true); @@ -28,11 +39,11 @@ test("scopeCheck — session auth always passes", () => { assert.strictEqual(scopeCheck(true, undefined, null), true); }); -test("scopeCheck — null record ApiKeyId requires an authenticated API key", () => { - assert.strictEqual(scopeCheck(false, null, null), false); - assert.strictEqual(scopeCheck(false, null, "any-key"), true); - assert.strictEqual(scopeCheck(false, undefined, null), false); - assert.strictEqual(scopeCheck(false, undefined, "any-key"), true); +test("scopeCheck — a null-owner record is denied to every non-session caller (GHSA-2jm2-mpx8-6523)", () => { + assert.strictEqual(scopeCheck(false, null, null), false, "anonymous"); + assert.strictEqual(scopeCheck(false, null, "any-key"), false, "any authenticated key"); + assert.strictEqual(scopeCheck(false, undefined, null), false, "anonymous, undefined owner"); + assert.strictEqual(scopeCheck(false, undefined, "any-key"), false, "any key, undefined owner"); }); test("scopeCheck — matching apiKeyId passes", () => { diff --git a/tests/unit/batch_api.test.ts b/tests/unit/batch_api.test.ts index ce765a8e12..fa65b1e952 100644 --- a/tests/unit/batch_api.test.ts +++ b/tests/unit/batch_api.test.ts @@ -815,7 +815,7 @@ test("Files and batches routes expose explicit CORS preflight handlers", async ( } }); -test("Batch by-id route exposes ownerless records to anonymous requests", async () => { +test("Batch by-id route hides ownerless records from anonymous requests (GHSA-2jm2-mpx8-6523)", async () => { const file = createFile({ bytes: 2, filename: "ownerless.jsonl", @@ -830,15 +830,18 @@ test("Batch by-id route exposes ownerless records to anonymous requests", async apiKeyId: null, }); + // A null owner is unattributable: only the operator's dashboard session may + // read it. An anonymous caller (no key, no session) gets the same 404 a + // foreign key gets — never the record. const response = await batchByIdRoute.GET( new Request(`http://localhost/api/v1/batches/${batch.id}`), { params: Promise.resolve({ id: batch.id }) } ); const body = await response.json(); - assert.strictEqual(response.status, 200); - assert.strictEqual(body.id, batch.id); - assert.strictEqual(body.status, "validating"); + assert.strictEqual(response.status, 404); + assert.strictEqual(body.error?.message, "Batch not found"); + assert.strictEqual(body.id, undefined, "the ownerless record must not be returned"); }); test("Batch Cancel API", async () => { diff --git a/tests/unit/batches-delete-completed-route-scope.test.ts b/tests/unit/batches-delete-completed-route-scope.test.ts index 6e36914e94..76233cac5c 100644 --- a/tests/unit/batches-delete-completed-route-scope.test.ts +++ b/tests/unit/batches-delete-completed-route-scope.test.ts @@ -62,6 +62,13 @@ async function sessionCookie(): Promise { return `auth_token=${jwt}`; } +/** + * `label` names the seeded batch's `.jsonl` file. Keep it word-shaped or under 10 chars: the + * gitleaks generic-api-key rule reports a literal of 10+ chars with Shannon entropy >= 3.5 that + * sits right after a `key*.id` argument (the argument supplies the rule's "key" keyword). + * `wvxc-route-401` did (entropy 3.66) and became `route401` in #13729; the word-shaped + * `wvxc-route-` siblings stay under the entropy floor and are clean. + */ function seedCompletedBatch(apiKeyId: string | null, label: string) { const file = createFile({ bytes: 8, @@ -277,7 +284,8 @@ describe("DELETE /api/v1/batches/delete-completed — caller scope (GHSA-wvxc-jp it("rejects an unauthenticated request with 401 and deletes nothing", async () => { const keyB = await createApiKey("wvxc-route-401-b", "machine-wvxc-401", []); - const seeded = seedCompletedBatch(keyB.id, "wvxc-route-401"); + // short label: see the seedCompletedBatch docblock (#13729) + const seeded = seedCompletedBatch(keyB.id, "route401"); const { res, body } = await callDelete({}); @@ -290,7 +298,8 @@ describe("DELETE /api/v1/batches/delete-completed — caller scope (GHSA-wvxc-jp it("returns a sanitized 500 (no stack trace, no raw SQLite message) when the sweep throws, and deletes nothing", async () => { const keyA = await createApiKey("wvxc-route-500-a", "machine-wvxc-500", []); - const own = seedCompletedBatch(keyA.id, "wvxc-route-500"); + // short label: see the seedCompletedBatch docblock (#13729) + const own = seedCompletedBatch(keyA.id, "route500"); const db = getDbInstance(); db.exec( @@ -312,7 +321,7 @@ describe("DELETE /api/v1/batches/delete-completed — caller scope (GHSA-wvxc-jp assert.ok(getBatch(own.batch.id), "a failed sweep leaves the batch row in place"); assert.strictEqual( getFileContent(own.file.id)?.toString(), - "wvxc-route-500", + "route500", "a failed sweep rolls the file content back" ); }); diff --git a/tests/unit/call-log-error-type.test.ts b/tests/unit/call-log-error-type.test.ts index 112f492ff9..fc8f9df767 100644 --- a/tests/unit/call-log-error-type.test.ts +++ b/tests/unit/call-log-error-type.test.ts @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { getDbInstance, resetDbInstance } from "../../src/lib/db/core.ts"; -import { classifyCallLogError } from "../../src/lib/usage/callLogs/format.ts"; +import { classifyCallLogError, toStoredErrorType } from "../../src/lib/usage/callLogs/format.ts"; import { saveCallLog } from "../../src/lib/usage/callLogs.ts"; import { ERROR_TYPE_CUTOVER_ISO, getErrorTypeBreakdown } from "../../src/lib/db/callLogStats.ts"; import { getCallLogsForExport } from "../../src/lib/usage/callLogExportSource.ts"; @@ -323,3 +323,276 @@ test("log export keeps both legacy NULL and the new unknown error_type intact", deleteCallLogs([legacy, fresh]); } }); + +test("getErrorTypeBreakdown maps free-text history to unclassified, keeps pre_migration", () => { + const ids = ["hx-typo", "hx-old", "hx-new"]; + try { + insertRawErrorType("hx-typo", "typo_free", new Date().toISOString()); + insertRawErrorType("hx-old", null, "2026-01-01T00:00:00.000Z"); + insertRawErrorType("hx-new", null, new Date().toISOString()); + const byType = Object.fromEntries(breakdownFor(ids).map((r) => [r.errorType, r.count])); + assert.equal(byType["typo_free"], undefined); // no longer leaks through as-is + assert.equal(byType["unclassified"], 2); // typo + recent NULL, merged into ONE row + assert.equal(byType["pre_migration"], 1); + } finally { + deleteCallLogs(ids); + } +}); + +test("legacy NULL rows neither vanish nor double-count next to the new unknown value", async () => { + const stamp = Date.now(); + const legacyPre = `hx-legacy-pre-${stamp}`; + const legacyPost = `hx-legacy-post-${stamp}`; + const legacySuccess = `hx-legacy-ok-${stamp}`; + const vocab = `hx-vocab-${stamp}`; + const fresh = `hx-fresh-unknown-${stamp}`; + const ids = [legacyPre, legacyPost, legacySuccess, vocab, fresh]; + try { + insertRawErrorType(legacyPre, null, "2026-02-01T00:00:00.000Z", 503); + insertRawErrorType(legacyPost, null, new Date().toISOString(), 403); + insertRawErrorType(legacySuccess, null, new Date().toISOString(), 200); + insertRawErrorType(vocab, "rate_limited", new Date().toISOString(), 429); + await saveCallLog({ + id: fresh, + method: "POST", + path: "/v1/chat/completions", + status: 403, + error: "some other 403 body", + model: "m", + provider: "openai", + duration: 1, + tokens: { in: 1, out: 1 }, + }); + + const rows = breakdownFor(ids); + const byType = Object.fromEntries(rows.map((r) => [r.errorType, r.count])); + assert.deepEqual(byType, { + pre_migration: 1, + unclassified: 1, + rate_limited: 1, + unknown: 1, + }); + // One bucket per failure row: the breakdown total equals the failure count. + const failures = getDbInstance() + .prepare( + `SELECT COUNT(*) AS n FROM call_logs WHERE id IN (${ids.map(() => "?").join(",")}) AND (status >= 400 OR error_summary IS NOT NULL)` + ) + .get(...ids) as { n: number }; + assert.equal( + rows.reduce((sum, r) => sum + r.count, 0), + failures.n + ); + } finally { + deleteCallLogs(ids); + } +}); + +test("cutover boundary: pre_migration only before ERROR_TYPE_CUTOVER_ISO", () => { + assert.equal(ERROR_TYPE_CUTOVER_ISO, "2026-08-20"); + const ids = ["hx-b1", "hx-b2"]; + try { + insertRawErrorType("hx-b1", null, "2026-08-19T23:59:59.000Z"); + insertRawErrorType("hx-b2", null, "2026-08-20T00:00:00.000Z"); + const byType = Object.fromEntries(breakdownFor(ids).map((r) => [r.errorType, r.count])); + assert.equal(byType["pre_migration"], 1); + assert.equal(byType["unclassified"], 1); + } finally { + deleteCallLogs(ids); + } +}); + +test("log export keeps both legacy NULL and the new unknown error_type intact", async () => { + const stamp = Date.now(); + const legacy = `hx-export-null-${stamp}`; + const fresh = `hx-export-unknown-${stamp}`; + const db = getDbInstance(); + const before = Number( + (db.prepare("SELECT COALESCE(MAX(rowid), 0) AS m FROM call_logs").get() as { m: number }).m + ); + try { + insertRawErrorType(legacy, null, new Date().toISOString(), 500); + await saveCallLog({ + id: fresh, + method: "POST", + path: "/v1/chat/completions", + status: 403, + error: "some other 403 body", + model: "m", + provider: "openai", + duration: 1, + tokens: { in: 1, out: 1 }, + }); + + const exported = getCallLogsForExport(before, 50); + const byId = new Map(exported.map((row) => [row.record.id, row.record])); + assert.equal(byId.get(legacy)?.errorType, null); + assert.equal(byId.get(fresh)?.errorType, "unknown"); + + const exportedAt = new Date().toISOString(); + assert.equal(toBigQueryRow(byId.get(legacy)!, exportedAt).error_type, null); + assert.equal(toBigQueryRow(byId.get(fresh)!, exportedAt).error_type, "unknown"); + } finally { + deleteCallLogs([legacy, fresh]); + } +}); + +test("toStoredErrorType: contract values pass, null stays null, anything else is unknown", () => { + for (const value of ERROR_TYPE_CONTRACT) { + assert.equal(toStoredErrorType(value), value); + } + assert.equal(toStoredErrorType(null), null); + assert.equal(toStoredErrorType(undefined), null); + for (const value of ["typo_free", "RATE_LIMITED", "", " rate_limited", 42, {}, ["unknown"]]) { + assert.equal( + toStoredErrorType(value), + "unknown", + `expected unknown for ${JSON.stringify(value)}` + ); + } +}); + +test("saveCallLog stores unknown when the classifier emits a family outside the contract", async () => { + // Simulates vocabulary drift for real: classifyProviderError reads + // PROVIDER_ERROR_TYPES at call time, while ERROR_TYPE_CONTRACT is the frozen + // snapshot taken at load. A renamed family therefore reaches the write point + // as an out-of-contract string, and the guard must clamp it. + const types = PROVIDER_ERROR_TYPES as unknown as Record; + const original = types.SERVER_ERROR; + const id = `test-errtype-drift-${Date.now()}`; + try { + types.SERVER_ERROR = "server_error_v2"; + assert.equal(classifyCallLogError(503, "down", "test-provider"), "server_error_v2"); + await saveCallLog({ + id, + method: "POST", + path: "/v1/chat/completions", + status: 503, + error: "Service Unavailable", + model: "m", + provider: "test-provider", + duration: 1, + tokens: { in: 1, out: 1 }, + }); + const row = getDbInstance() + .prepare("SELECT error_type FROM call_logs WHERE id = ?") + .get(id) as { + error_type: string | null; + }; + assert.equal(row.error_type, "unknown"); + } finally { + types.SERVER_ERROR = original; + deleteCallLogs([id]); + } +}); + +test("getErrorTypeBreakdown maps free-text history to unclassified, keeps pre_migration", () => { + const ids = ["hx-typo", "hx-old", "hx-new"]; + try { + insertRawErrorType("hx-typo", "typo_free", new Date().toISOString()); + insertRawErrorType("hx-old", null, "2026-01-01T00:00:00.000Z"); + insertRawErrorType("hx-new", null, new Date().toISOString()); + const byType = Object.fromEntries(breakdownFor(ids).map((r) => [r.errorType, r.count])); + assert.equal(byType["typo_free"], undefined); // no longer leaks through as-is + assert.equal(byType["unclassified"], 2); // typo + recent NULL, merged into ONE row + assert.equal(byType["pre_migration"], 1); + } finally { + deleteCallLogs(ids); + } +}); + +test("legacy NULL rows neither vanish nor double-count next to the new unknown value", async () => { + const stamp = Date.now(); + const legacyPre = `hx-legacy-pre-${stamp}`; + const legacyPost = `hx-legacy-post-${stamp}`; + const legacySuccess = `hx-legacy-ok-${stamp}`; + const vocab = `hx-vocab-${stamp}`; + const fresh = `hx-fresh-unknown-${stamp}`; + const ids = [legacyPre, legacyPost, legacySuccess, vocab, fresh]; + try { + insertRawErrorType(legacyPre, null, "2026-02-01T00:00:00.000Z", 503); + insertRawErrorType(legacyPost, null, new Date().toISOString(), 403); + insertRawErrorType(legacySuccess, null, new Date().toISOString(), 200); + insertRawErrorType(vocab, "rate_limited", new Date().toISOString(), 429); + await saveCallLog({ + id: fresh, + method: "POST", + path: "/v1/chat/completions", + status: 403, + error: "some other 403 body", + model: "m", + provider: "openai", + duration: 1, + tokens: { in: 1, out: 1 }, + }); + + const rows = breakdownFor(ids); + const byType = Object.fromEntries(rows.map((r) => [r.errorType, r.count])); + assert.deepEqual(byType, { + pre_migration: 1, + unclassified: 1, + rate_limited: 1, + unknown: 1, + }); + // One bucket per failure row: the breakdown total equals the failure count. + const failures = getDbInstance() + .prepare( + `SELECT COUNT(*) AS n FROM call_logs WHERE id IN (${ids.map(() => "?").join(",")}) AND (status >= 400 OR error_summary IS NOT NULL)` + ) + .get(...ids) as { n: number }; + assert.equal( + rows.reduce((sum, r) => sum + r.count, 0), + failures.n + ); + } finally { + deleteCallLogs(ids); + } +}); + +test("cutover boundary: pre_migration only before ERROR_TYPE_CUTOVER_ISO", () => { + assert.equal(ERROR_TYPE_CUTOVER_ISO, "2026-08-20"); + const ids = ["hx-b1", "hx-b2"]; + try { + insertRawErrorType("hx-b1", null, "2026-08-19T23:59:59.000Z"); + insertRawErrorType("hx-b2", null, "2026-08-20T00:00:00.000Z"); + const byType = Object.fromEntries(breakdownFor(ids).map((r) => [r.errorType, r.count])); + assert.equal(byType["pre_migration"], 1); + assert.equal(byType["unclassified"], 1); + } finally { + deleteCallLogs(ids); + } +}); + +test("log export keeps both legacy NULL and the new unknown error_type intact", async () => { + const stamp = Date.now(); + const legacy = `hx-export-null-${stamp}`; + const fresh = `hx-export-unknown-${stamp}`; + const db = getDbInstance(); + const before = Number( + (db.prepare("SELECT COALESCE(MAX(rowid), 0) AS m FROM call_logs").get() as { m: number }).m + ); + try { + insertRawErrorType(legacy, null, new Date().toISOString(), 500); + await saveCallLog({ + id: fresh, + method: "POST", + path: "/v1/chat/completions", + status: 403, + error: "some other 403 body", + model: "m", + provider: "openai", + duration: 1, + tokens: { in: 1, out: 1 }, + }); + + const exported = getCallLogsForExport(before, 50); + const byId = new Map(exported.map((row) => [row.record.id, row.record])); + assert.equal(byId.get(legacy)?.errorType, null); + assert.equal(byId.get(fresh)?.errorType, "unknown"); + + const exportedAt = new Date().toISOString(); + assert.equal(toBigQueryRow(byId.get(legacy)!, exportedAt).error_type, null); + assert.equal(toBigQueryRow(byId.get(fresh)!, exportedAt).error_type, "unknown"); + } finally { + deleteCallLogs([legacy, fresh]); + } +}); diff --git a/tests/unit/call-log-search-ghost.test.ts b/tests/unit/call-log-search-ghost.test.ts new file mode 100644 index 0000000000..b3b2ced73f --- /dev/null +++ b/tests/unit/call-log-search-ghost.test.ts @@ -0,0 +1,223 @@ +/** + * Search stats must not surface "ghost" rows, and must not hide real traffic. + * + * Always hidden: rows with a NULL provider or the '-' sentinel. + * Hidden only with SEARCH_STATS_HIDE_DELETED_CONNECTIONS on: a keyed provider whose + * provider_connections row is gone (deleted connection). Off (the default) keeps + * the historical stats, where every retained row with a provider id counts. + * Kept either way: keyed providers with a live connection (directly or through a + * registry credential fallback such as perplexity-search → perplexity) and + * keyless providers (`authType: "none"` — duckduckgo-free, searxng-search, + * anonymous context7), which are served without any provider_connections row. + * Totals and per-provider rows use the same guard, so they always agree. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-db-search-ghost-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const mod = await import("../../src/lib/db/callLogStats.ts"); +const { SEARCH_PROVIDERS, SEARCH_CREDENTIAL_FALLBACKS } = + await import("../../open-sse/config/searchRegistry.ts"); +const analyticsRoute = await import("../../src/app/api/v1/search/analytics/route.ts"); + +const KEYLESS_IDS = Object.values(SEARCH_PROVIDERS) + .filter((provider) => provider.authType === "none") + .map((provider) => provider.id); + +let idSeq = 0; +function insertSearchLog(provider: string | null, fields: Record = {}) { + core + .getDbInstance() + .prepare( + `INSERT INTO call_logs (id, timestamp, method, path, status, model, provider, duration, + tokens_in, tokens_out, cache_source, request_type, detail_state, error_summary, + request_summary, has_request_body, has_response_body, has_pipeline_details) + VALUES (@id, @timestamp, 'POST', '/v1/search', @status, 'search', @provider, @duration, + 0, 0, 'upstream', 'search', 'none', NULL, @summary, 0, 0, 0)` + ) + .run({ + id: `log-ghost-${++idSeq}`, + timestamp: new Date().toISOString(), + status: 200, + duration: 100, + summary: JSON.stringify({ query: `q-${idSeq}` }), + provider, + ...fields, + }); +} + +function insertConnection(id: string, provider: string) { + const now = new Date().toISOString(); + core + .getDbInstance() + .prepare( + `INSERT INTO provider_connections (id, provider, created_at, updated_at) VALUES (?, ?, ?, ?)` + ) + .run(id, provider, now, now); +} + +test.before(() => { + core.resetDbInstance(); + insertConnection("conn-ghost-brave", "brave-search"); + insertConnection("conn-ghost-perplexity-chat", "perplexity"); + + insertSearchLog("brave-search", { duration: 50 }); + insertSearchLog("brave-search", { duration: 150, status: 502 }); + insertSearchLog("perplexity-search", { duration: 90 }); // live via credential fallback + insertSearchLog("duckduckgo-free", { duration: 70 }); // keyless, no connection row + insertSearchLog("duckduckgo-free", { duration: 30 }); + insertSearchLog("searxng-search", { duration: 40 }); // keyless, no connection row + insertSearchLog("context7", { duration: 60 }); // anonymous tier, no connection row + // Ghosts + insertSearchLog("tavily-search", { duration: 80 }); // connection deleted + insertSearchLog("-", { duration: 80 }); + insertSearchLog(null, { duration: 80 }); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +const FLAG = "SEARCH_STATS_HIDE_DELETED_CONNECTIONS"; + +function withFlag(value: "true" | undefined, fn: () => T): T { + const previous = process.env[FLAG]; + if (value === undefined) delete process.env[FLAG]; + else process.env[FLAG] = value; + try { + return fn(); + } finally { + if (previous === undefined) delete process.env[FLAG]; + else process.env[FLAG] = previous; + } +} + +const LIVE_COUNTS: Record = { + "brave-search": 2, + "duckduckgo-free": 2, + "perplexity-search": 1, + "searxng-search": 1, + context7: 1, +}; +// Flag off: the deleted tavily-search connection still counts (historical behavior). +const HISTORICAL_COUNTS: Record = { ...LIVE_COUNTS, "tavily-search": 1 }; + +function todayStartIso(): string { + const todayStart = new Date(); + todayStart.setUTCHours(0, 0, 0, 0); + return todayStart.toISOString(); +} + +test("registry fixtures used here are real: keyless ids and the perplexity fallback", () => { + for (const id of ["duckduckgo-free", "searxng-search", "context7"]) { + assert.ok(KEYLESS_IDS.includes(id), `${id} is authType none in the search registry`); + } + for (const id of ["brave-search", "tavily-search", "perplexity-search"]) { + assert.equal(SEARCH_PROVIDERS[id]?.authType, "apikey", `${id} is a keyed search provider`); + } + assert.equal(SEARCH_CREDENTIAL_FALLBACKS["perplexity-search"], "perplexity"); +}); + +test("flag off (default): NULL and '-' rows are hidden, deleted-connection traffic still counts", () => { + withFlag(undefined, () => { + const stats = mod.getSearchProviderStats(); + assert.deepEqual( + Object.fromEntries(stats.map((r) => [r.provider, r.requests])), + HISTORICAL_COUNTS + ); + assert.deepEqual( + Object.fromEntries(mod.getSearchProviderCounts().map((r) => [r.provider, r.cnt])), + HISTORICAL_COUNTS + ); + const recent = mod.getRecentSearchLogs().map((r) => r.provider); + assert.equal(recent.length, 8); + assert.ok( + recent.includes("tavily-search"), + "deleted connection still listed with the flag off" + ); + assert.ok(!recent.includes("-") && !recent.includes(null as unknown as string)); + const aggregate = mod.getSearchAggregateStats(todayStartIso()); + assert.equal(aggregate.total, 8); + assert.equal( + aggregate.total, + mod.getSearchProviderCounts().reduce((sum, r) => sum + r.cnt, 0) + ); + }); +}); + +test("flag on: getSearchProviderStats keeps live + keyless providers and drops ghosts", () => { + withFlag("true", () => { + const rows = mod.getSearchProviderStats(); + const byProvider = Object.fromEntries(rows.map((r) => [r.provider, r])); + assert.deepEqual(Object.fromEntries(rows.map((r) => [r.provider, r.requests])), LIVE_COUNTS); + assert.equal(byProvider["brave-search"].avg_latency_ms, 100); + assert.equal(byProvider["duckduckgo-free"].avg_latency_ms, 50); + }); +}); + +test("flag on: getSearchProviderCounts keeps live + keyless providers, ordered by count", () => { + withFlag("true", () => { + const rows = mod.getSearchProviderCounts(); + assert.deepEqual(Object.fromEntries(rows.map((r) => [r.provider, r.cnt])), LIVE_COUNTS); + for (let i = 1; i < rows.length; i++) { + assert.ok(rows[i - 1].cnt >= rows[i].cnt, "ordered by cnt desc"); + } + }); +}); + +test("flag on: getRecentSearchLogs keeps keyless traffic and drops ghost rows", () => { + withFlag("true", () => { + const providers = mod.getRecentSearchLogs().map((r) => r.provider); + assert.equal(providers.length, 7); + for (const ghost of ["tavily-search", "-", null]) { + assert.ok(!providers.includes(ghost as string), `${String(ghost)} excluded`); + } + for (const live of Object.keys(LIVE_COUNTS)) { + assert.ok(providers.includes(live), `${live} present`); + } + }); +}); + +test("flag on: aggregate totals agree with the per-provider breakdown", () => { + withFlag("true", () => { + const stats = mod.getSearchAggregateStats(todayStartIso()); + const breakdownTotal = mod.getSearchProviderCounts().reduce((sum, r) => sum + r.cnt, 0); + assert.equal(stats.total, breakdownTotal); + assert.equal(stats.total, 7); + assert.equal(stats.today, 7); + assert.equal(stats.errors, 1); + }); +}); + +test("GET /api/v1/search/analytics: total equals the sum of byProvider counts in both modes", async () => { + for (const mode of [undefined, "true"] as const) { + const previous = process.env[FLAG]; + if (mode === undefined) delete process.env[FLAG]; + else process.env[FLAG] = mode; + try { + const response = await analyticsRoute.GET( + new Request("http://localhost/api/v1/search/analytics") + ); + assert.equal(response.status, 200); + const body = (await response.json()) as { + total: number; + byProvider: Record; + }; + const byProviderTotal = Object.values(body.byProvider).reduce((sum, p) => sum + p.count, 0); + assert.equal(body.total, byProviderTotal, `mode=${String(mode)}`); + assert.equal(body.byProvider["duckduckgo-free"]?.count, 2); + if (mode === "true") assert.equal(body.byProvider["tavily-search"], undefined); + else assert.equal(body.byProvider["tavily-search"]?.count, 1); + } finally { + if (previous === undefined) delete process.env[FLAG]; + else process.env[FLAG] = previous; + } + } +}); diff --git a/tests/unit/chat-stream-early-eof-failover.test.ts b/tests/unit/chat-stream-early-eof-failover.test.ts new file mode 100644 index 0000000000..a477562b26 --- /dev/null +++ b/tests/unit/chat-stream-early-eof-failover.test.ts @@ -0,0 +1,364 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Stream early EOF sibling failover (direct single-model path, #13153). +// +// When the upstream opens an SSE stream but closes it before emitting any +// useful frame, the readiness gate surfaces 502 STREAM_EARLY_EOF. The bounded +// same-connection retry (#3758) makes one plain re-attempt. With +// STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED on, once that retry is spent the +// request makes exactly ONE hop to a sibling connection; if no sibling can serve +// it, the ORIGINAL STREAM_EARLY_EOF 502 is surfaced unchanged so combo-level +// detection (isStreamEarlyEofErrorBody) keeps working. No account is ever marked +// unavailable for an early close, STREAM_READINESS_TIMEOUT stays terminal, and +// with the flag off (the default) the release behavior is unchanged. +// +// These cases drive handleChat() directly rather than the /v1/chat/completions +// route: the route wraps streaming requests in withEarlyStreamKeepalive, which +// commits a synthetic 200 SSE response (dropping the handler's status and +// headers) whenever the handler takes longer than 2 s — a cold first request or +// a loaded machine is enough, and the failover assertions would then observe the +// keepalive wrapper instead of the failover. + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-early-eof-failover-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.REQUIRE_API_KEY = "false"; +process.env.DASHBOARD_PASSWORD = ""; +process.env.INITIAL_PASSWORD = ""; +delete process.env.JWT_SECRET; +if (!process.env.API_KEY_SECRET) { + process.env.API_KEY_SECRET = `test-early-eof-failover-${Date.now()}`; +} +// A short readiness window so the STREAM_READINESS_TIMEOUT case resolves quickly. +// Every other stub body is static, so readiness settles on the first read. +process.env.STREAM_READINESS_TIMEOUT_MS = "1000"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const { handleChat } = await import("../../src/sse/handlers/chat.ts"); +const { initTranslators } = await import("../../open-sse/translator/index.ts"); +const { clearInflight } = await import("../../open-sse/services/requestDedup.ts"); +const { resetAllCircuitBreakers } = await import("../../src/shared/utils/circuitBreaker.ts"); + +const originalFetch = globalThis.fetch; + +async function flushBackgroundWork() { + await new Promise((resolve) => setTimeout(resolve, 50)); + await new Promise((resolve) => setImmediate(resolve)); +} + +async function resetStorage() { + clearInflight(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + resetAllCircuitBreakers(); + initTranslators(); +} + +type SeededConnection = { id: string; apiKey: string }; + +async function seedConnection(name: string, apiKey: string): Promise { + const row = (await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name, + apiKey, + isActive: true, + testStatus: "active", + })) as { id: string }; + return { id: row.id, apiKey }; +} + +// An SSE body that closes with zero non-ping frames: the exact input shape +// the readiness gate turns into 502 STREAM_EARLY_EOF. +function pingOnlyStreamResponse(): Response { + return new Response(`: keepalive\n\ndata: ${JSON.stringify({ type: "ping" })}\n\n`, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); +} + +// An SSE body that stays open without a useful frame: STREAM_READINESS_TIMEOUT. +function stalledStreamResponse(): Response { + const encoder = new TextEncoder(); + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(": keepalive\n\n")); + }, + }), + { status: 200, headers: { "Content-Type": "text/event-stream" } } + ); +} + +function successStreamResponse(content: string): Response { + return new Response( + `data: ${JSON.stringify({ + id: "chatcmpl-early-eof-failover", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: { role: "assistant", content }, finish_reason: null }], + })}\n\ndata: ${JSON.stringify({ + id: "chatcmpl-early-eof-failover", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + })}\n\ndata: [DONE]\n\n`, + { status: 200, headers: { "Content-Type": "text/event-stream" } } + ); +} + +function unauthorizedResponse(): Response { + return new Response( + JSON.stringify({ error: { message: "Incorrect API key provided", type: "invalid_request" } }), + { status: 401, headers: { "Content-Type": "application/json" } } + ); +} + +function streamRequest(extraHeaders: Record = {}) { + // A per-request nonce keeps the semantic cache and request dedup out of the way. + const nonce = `early-eof-failover-${Date.now()}-${Math.random().toString(36).slice(2)}`; + return new Request("http://localhost/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "text/event-stream", + ...extraHeaders, + }, + body: JSON.stringify({ + model: "openai/gpt-4.1", + messages: [{ role: "user", content: `Reply with OK only. ${nonce}` }], + max_tokens: 16, + stream: true, + temperature: 0, + }), + }); +} + +// Every outbound fetch carries the connection's own credential, so the stub +// attributes each dispatch to a connection by its Authorization header. +function stubFetch(dispatches: string[], handler: (auth: string, callIndex: number) => Response) { + globalThis.fetch = (async (_url: unknown, init: { headers?: unknown }) => { + const headers = new Headers((init?.headers ?? {}) as HeadersInit); + const auth = headers.get("authorization") ?? ""; + const callIndex = dispatches.length; + dispatches.push(auth); + return handler(auth, callIndex); + }) as typeof fetch; +} + +function authOf(connection: SeededConnection): string { + return `Bearer ${connection.apiKey}`; +} + +function errorCodeOf(bodyText: string): string | undefined { + try { + return (JSON.parse(bodyText) as { error?: { code?: string } })?.error?.code; + } catch { + return undefined; + } +} + +async function assertNotMarked(connection: SeededConnection, label: string) { + const row = (await providersDb.getProviderConnectionById(connection.id)) as Record< + string, + unknown + > | null; + assert.ok(row, `the ${label} connection must still exist`); + const until = (row.rateLimitedUntil as string | null | undefined) ?? null; + assert.ok( + until === null || new Date(String(until)).getTime() <= Date.now(), + `expected no cooldown on the ${label} connection, got rateLimitedUntil=${until}` + ); + assert.notEqual(row.testStatus, "unavailable", `the ${label} connection must not be unavailable`); +} + +const SIBLING_FAILOVER_FLAG = "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED"; +const ORIGINAL_SIBLING_FAILOVER_FLAG = process.env[SIBLING_FAILOVER_FLAG]; + +function setSiblingFailoverFlag(enabled: boolean) { + if (enabled) process.env[SIBLING_FAILOVER_FLAG] = "true"; + else delete process.env[SIBLING_FAILOVER_FLAG]; +} + +test.beforeEach(async () => { + globalThis.fetch = originalFetch; + setSiblingFailoverFlag(true); + await resetStorage(); +}); + +test.afterEach(async () => { + await flushBackgroundWork(); + globalThis.fetch = originalFetch; + if (ORIGINAL_SIBLING_FAILOVER_FLAG === undefined) delete process.env[SIBLING_FAILOVER_FLAG]; + else process.env[SIBLING_FAILOVER_FLAG] = ORIGINAL_SIBLING_FAILOVER_FLAG; +}); + +test.after(async () => { + await flushBackgroundWork(); + globalThis.fetch = originalFetch; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("flag off (default): early EOF stays terminal after the same-connection retry, nothing marked", async () => { + setSiblingFailoverFlag(false); + const connA = await seedConnection("openai-flagoff-a", "sk-failover-flagoff-a"); + const connB = await seedConnection("openai-flagoff-b", "sk-failover-flagoff-b"); + + const dispatches: string[] = []; + stubFetch(dispatches, () => pingOnlyStreamResponse()); + + const response = await handleChat(streamRequest()); + const bodyText = await response.text(); + + // 1 initial + 1 bounded same-connection retry, then the terminal 502 — no + // sibling hop despite an eligible sibling. + assert.equal(dispatches.length, 2, `expected 2 dispatches, got ${dispatches.length}`); + const firstAuth = dispatches[0]; + assert.equal(dispatches[1], firstAuth, "the bounded retry stays on the same connection"); + const first = firstAuth === authOf(connA) ? connA : connB; + assert.equal(response.status, 502, `expected 502, got ${response.status}: ${bodyText}`); + assert.equal(errorCodeOf(bodyText), "STREAM_EARLY_EOF"); + assert.equal(response.headers.get("X-OmniRoute-Selected-Connection-Id"), first.id); + await assertNotMarked(connA, "first"); + await assertNotMarked(connB, "sibling"); +}); + +test("flag on: fails over to the sibling after the bounded retry, without marking any account", async () => { + const connA = await seedConnection("openai-failover-a", "sk-failover-conn-a"); + const connB = await seedConnection("openai-failover-b", "sk-failover-conn-b"); + + const dispatches: string[] = []; + stubFetch(dispatches, (_auth, callIndex) => + callIndex < 2 ? pingOnlyStreamResponse() : successStreamResponse("OK") + ); + + const response = await handleChat(streamRequest()); + const bodyText = await response.text(); + + assert.equal(dispatches.length, 3, `expected 1 + retry + sibling, got ${dispatches.length}`); + const first = dispatches[0] === authOf(connA) ? connA : connB; + const sibling = first === connA ? connB : connA; + assert.equal(dispatches[1], authOf(first), "the bounded retry stays on the same connection"); + assert.equal(dispatches[2], authOf(sibling), "the spent retry must hop to the sibling"); + assert.equal(response.status, 200, `expected 200, got ${response.status}: ${bodyText}`); + assert.equal( + response.headers.get("X-OmniRoute-Selected-Connection-Id"), + sibling.id, + "the response must carry the sibling as the selected connection" + ); + assert.match(bodyText, /OK/, "the client must receive the sibling's content"); + assert.ok(!bodyText.includes("STREAM_EARLY_EOF"), "the client must not see the early-EOF 502"); + await assertNotMarked(first, "first"); + await assertNotMarked(sibling, "sibling"); +}); + +test("flag on: bounds the failover to exactly one sibling hop per request", async () => { + const conns = [ + await seedConnection("openai-onehop-a", "sk-failover-onehop-a"), + await seedConnection("openai-onehop-b", "sk-failover-onehop-b"), + await seedConnection("openai-onehop-c", "sk-failover-onehop-c"), + ]; + + const dispatches: string[] = []; + stubFetch(dispatches, () => pingOnlyStreamResponse()); + + const response = await handleChat(streamRequest()); + const bodyText = await response.text(); + + // 1 initial + 1 same-connection retry + 1 sibling hop. The sibling's own early + // close is terminal: no second hop to the third connection. + assert.equal( + dispatches.length, + 3, + `expected exactly one hop, got ${dispatches.length} dispatches` + ); + assert.equal(dispatches[1], dispatches[0], "the bounded retry stays on the same connection"); + assert.notEqual(dispatches[2], dispatches[0], "the single hop must go to a sibling"); + const sibling = conns.find((conn) => authOf(conn) === dispatches[2]); + assert.ok(sibling, "the hop must reach a seeded sibling"); + assert.equal(response.status, 502, `expected 502, got ${response.status}: ${bodyText}`); + assert.equal(errorCodeOf(bodyText), "STREAM_EARLY_EOF", "the terminal body keeps its code"); + assert.equal(response.headers.get("X-OmniRoute-Selected-Connection-Id"), sibling.id); + for (const conn of conns) await assertNotMarked(conn, conn.id); +}); + +test("flag on: a singleton pool surfaces the original STREAM_EARLY_EOF 502", async () => { + const conn = await seedConnection("openai-singleton", "sk-failover-singleton"); + + const dispatches: string[] = []; + stubFetch(dispatches, () => pingOnlyStreamResponse()); + + const response = await handleChat(streamRequest()); + const bodyText = await response.text(); + + assert.equal(dispatches.length, 2, `expected 2 dispatches, got ${dispatches.length}`); + assert.equal(response.status, 502, `expected 502, got ${response.status}: ${bodyText}`); + assert.equal( + errorCodeOf(bodyText), + "STREAM_EARLY_EOF", + `the original early-EOF body must survive an empty sibling pool: ${bodyText}` + ); + assert.equal(response.headers.get("X-OmniRoute-Selected-Connection-Id"), conn.id); + await assertNotMarked(conn, "singleton"); +}); + +test("flag on: a sibling that fails for another reason surfaces the original early-EOF 502", async () => { + const connA = await seedConnection("openai-sibfail-a", "sk-failover-sibfail-a"); + const connB = await seedConnection("openai-sibfail-b", "sk-failover-sibfail-b"); + + const dispatches: string[] = []; + stubFetch(dispatches, (_auth, callIndex) => + callIndex < 2 ? pingOnlyStreamResponse() : unauthorizedResponse() + ); + + const response = await handleChat(streamRequest()); + const bodyText = await response.text(); + + const first = dispatches[0] === authOf(connA) ? connA : connB; + const sibling = first === connA ? connB : connA; + assert.equal(dispatches.length, 3, `expected 1 + retry + sibling, got ${dispatches.length}`); + assert.equal(dispatches[2], authOf(sibling)); + assert.equal(response.status, 502, `expected 502, got ${response.status}: ${bodyText}`); + assert.equal(errorCodeOf(bodyText), "STREAM_EARLY_EOF", `unexpected body: ${bodyText}`); + assert.equal(response.headers.get("X-OmniRoute-Selected-Connection-Id"), first.id); + await assertNotMarked(first, "first"); +}); + +test("flag on: a forced connection never hops to a sibling", async () => { + const connA = await seedConnection("openai-forced-a", "sk-failover-forced-a"); + await seedConnection("openai-forced-b", "sk-failover-forced-b"); + + const dispatches: string[] = []; + stubFetch(dispatches, () => pingOnlyStreamResponse()); + + const response = await handleChat(streamRequest({ "x-omniroute-connection": connA.id })); + const bodyText = await response.text(); + + // A forced pin skips the same-connection retry and must never rotate. + assert.equal(dispatches.length, 1, `expected 1 dispatch, got ${dispatches.length}`); + assert.equal(dispatches[0], authOf(connA)); + assert.equal(response.status, 502, `expected 502, got ${response.status}: ${bodyText}`); + assert.equal(errorCodeOf(bodyText), "STREAM_EARLY_EOF"); +}); + +test("flag on: STREAM_READINESS_TIMEOUT stays terminal even with a sibling available", async () => { + const connA = await seedConnection("openai-timeout-a", "sk-failover-timeout-a"); + const connB = await seedConnection("openai-timeout-b", "sk-failover-timeout-b"); + + const dispatches: string[] = []; + stubFetch(dispatches, () => stalledStreamResponse()); + + const response = await handleChat(streamRequest()); + const bodyText = await response.text(); + + // A slow-but-alive upstream is neither retried nor failed over. + assert.equal(dispatches.length, 1, `expected 1 dispatch, got ${dispatches.length}`); + assert.equal(response.status, 504, `expected 504, got ${response.status}: ${bodyText}`); + assert.equal(errorCodeOf(bodyText), "STREAM_READINESS_TIMEOUT"); + await assertNotMarked(connA, "first"); + await assertNotMarked(connB, "sibling"); +}); diff --git a/tests/unit/chatcore-stream-recovery-log-wiring.test.ts b/tests/unit/chatcore-stream-recovery-log-wiring.test.ts new file mode 100644 index 0000000000..fecc02ab60 --- /dev/null +++ b/tests/unit/chatcore-stream-recovery-log-wiring.test.ts @@ -0,0 +1,114 @@ +// handleChatCore wiring of the mid-stream continuation log hooks: a real streaming request +// with stream recovery + mid-stream continuation enabled, an upstream that commits the +// holdback window and then drops, and a continuation that finishes the answer. The injected +// log must receive the release attempt line at warn and the stitched outcome at debug. +import { after, before, test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-recovery-log-wiring-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts"); +const { STREAM_RECOVERY } = await import("../../open-sse/config/constants.ts"); + +const ENV_KEYS = ["STREAM_RECOVERY_ENABLED", "STREAM_RECOVERY_MIDSTREAM_ENABLED"] as const; +const originalEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); +const originalFetch = globalThis.fetch; +const enc = new TextEncoder(); + +const chunk = (content: string) => + `data: ${JSON.stringify({ + id: "chatcmpl-wiring", + object: "chat.completion.chunk", + model: "gpt-4o-mini", + choices: [{ index: 0, delta: { role: "assistant", content } }], + })}\n\n`; + +before(() => { + core.resetDbInstance(); + process.env.STREAM_RECOVERY_ENABLED = "true"; + process.env.STREAM_RECOVERY_MIDSTREAM_ENABLED = "true"; +}); + +after(() => { + globalThis.fetch = originalFetch; + for (const key of ENV_KEYS) { + if (originalEnv[key] === undefined) delete process.env[key]; + else process.env[key] = originalEnv[key]; + } + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("handleChatCore routes continuation logs through buildContinuationLogHooks", async () => { + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + if (calls === 1) { + // Two chunks spaced past the holdback window (so the stream commits), then a silent + // cut: no finish_reason, no [DONE]. + let step = 0; + const body = new ReadableStream({ + async pull(controller) { + step += 1; + if (step === 1) controller.enqueue(enc.encode(chunk("Hello there "))); + else if (step === 2) { + await new Promise((r) => setTimeout(r, STREAM_RECOVERY.HOLDBACK_MS + 150)); + controller.enqueue(enc.encode(chunk("world"))); + } else controller.close(); + }, + }); + return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }); + } + return new Response(chunk("there world, nice to meet you!") + "data: [DONE]\n\n", { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }) as typeof fetch; + + const warn: string[] = []; + const debug: string[] = []; + const log = { + info() {}, + error() {}, + warn: (tag: string, msg: string) => warn.push(`${tag} ${msg}`), + debug: (tag: string, msg: string) => debug.push(`${tag} ${msg}`), + }; + const body = { + model: "gpt-4o-mini", + stream: true, + messages: [{ role: "user", content: "hi" }], + }; + const result = await handleChatCore({ + body, + modelInfo: { provider: "openai", model: "gpt-4o-mini", extendedContext: false }, + credentials: { apiKey: "sk-test-wiring" }, + clientRawRequest: { + endpoint: "/v1/chat/completions", + body, + headers: new Headers({ accept: "text/event-stream" }), + }, + userAgent: "unit-test", + isCombo: false, + log, + } as unknown as Parameters[0]); + + const response = (result as { response?: Response }).response; + assert.ok(response?.body, "streaming response expected"); + const text = await response.text(); + + assert.equal(calls, 2, "one upstream request plus one continuation"); + assert.match(text, /nice to meet you!/); + const recoveryWarns = warn.filter((l) => l.startsWith("STREAM_RECOVERY ")); + assert.deepEqual(recoveryWarns, ["STREAM_RECOVERY mid-stream continuation attempt 1/4"]); + assert.ok( + debug.includes( + "STREAM_RECOVERY mid-stream continuation attempt 1/4 outcome=suffix suffixChars=19" + ), + debug.filter((l) => l.startsWith("STREAM_RECOVERY")).join(" | ") + ); +}); diff --git a/tests/unit/check-route-guard-membership.test.ts b/tests/unit/check-route-guard-membership.test.ts index 146772528b..b73ea025fb 100644 --- a/tests/unit/check-route-guard-membership.test.ts +++ b/tests/unit/check-route-guard-membership.test.ts @@ -9,6 +9,7 @@ import { isSpawnCapableSource, findSpawnCapableRoutes, KNOWN_UNCLASSIFIED_SOURCE_SPAWN, + SPAWN_CAPABLE_ROUTE_ROOTS, } from "../../scripts/check/check-route-guard-membership.ts"; import { isLocalOnlyPath } from "../../src/server/authz/routeGuard.ts"; @@ -64,11 +65,7 @@ test("flags a spawn-capable route that is NOT classified local-only (RCE-via-tun // this gate guards against. const leaky = (path: string): boolean => path.startsWith("/api/mcp/"); assert.deepEqual( - findUnclassifiedSpawnRoutes( - ["/api/mcp/tools", "/api/services/cliproxy/install"], - leaky, - {} - ), + findUnclassifiedSpawnRoutes(["/api/mcp/tools", "/api/services/cliproxy/install"], leaky, {}), ["/api/services/cliproxy/install"] ); }); @@ -76,11 +73,9 @@ test("flags a spawn-capable route that is NOT classified local-only (RCE-via-tun test("allowlisted routes are not flagged (frozen pre-existing exceptions)", () => { const leaky = (path: string): boolean => path.startsWith("/api/mcp/"); assert.deepEqual( - findUnclassifiedSpawnRoutes( - ["/api/mcp/tools", "/api/services/legacy/route"], - leaky, - { "/api/services/legacy/route": "frozen pre-existing exception" } - ), + findUnclassifiedSpawnRoutes(["/api/mcp/tools", "/api/services/legacy/route"], leaky, { + "/api/services/legacy/route": "frozen pre-existing exception", + }), [] ); }); @@ -128,7 +123,10 @@ test("6A.8 findSpawnCapableRoutes: detects real spawn-capable route.ts files", ( ]; const found = findSpawnCapableRoutes(repoRoot); for (const r of knownSpawnRoutes) { - assert.ok(found.includes(r), `expected ${r} in spawn-capable routes, found: ${found.join(", ")}`); + assert.ok( + found.includes(r), + `expected ${r} in spawn-capable routes, found: ${found.join(", ")}` + ); } }); @@ -153,6 +151,51 @@ test("#7948: /api/acp/agents (transitive execFileSync via registry) is classifie assert.equal(isLocalOnlyPath("/api/acp/agents"), true); }); +test("GHSA-35fw-cv32-2373: every cli-tools route that reaches getCliRuntimeStatus()/detectAllTools() is classified local-only", () => { + // Same transitive-spawn class as #7948: the spawn lives in + // src/shared/services/cliRuntime.ts (runProcess -> spawn) and + // src/lib/cli-helper/tool-detector.ts (execFile), never in the route file, so + // the source-scan subcheck is blind to it. Six siblings were already gated; + // these 14 called the same helper and were not. Each is now a + // SPAWN_CAPABLE_ROUTE_ROOT so subcheck 1 enforces membership going forward. + const routes = [ + "/api/cli-tools/all-statuses", + "/api/cli-tools/claude-settings", + "/api/cli-tools/cline-settings", + "/api/cli-tools/codewhale-settings", + "/api/cli-tools/codex-settings", + "/api/cli-tools/crush-settings", + "/api/cli-tools/deepseek-tui-settings", + "/api/cli-tools/detect", + "/api/cli-tools/droid-settings", + "/api/cli-tools/kilo-settings", + "/api/cli-tools/openclaw-settings", + "/api/cli-tools/pi-settings", + "/api/cli-tools/smelt-settings", + "/api/cli-tools/status", + ]; + for (const r of routes) { + assert.equal(isLocalOnlyPath(r), true, `${r} must be local-only`); + assert.ok( + SPAWN_CAPABLE_ROUTE_ROOTS.includes(`src/app${r}`), + `src/app${r} must be a SPAWN_CAPABLE_ROUTE_ROOT` + ); + } +}); + +test("GHSA-jx89-f37j-pq89: /api/skills/install + /api/skills/executions (transitive sandbox spawn) are classified local-only", () => { + // The spawn is three modules away from the route (executor.ts -> builtins.ts -> + // sandbox.ts childProcess.spawn), so the source-scan subcheck cannot see it. + // Both are now SPAWN_CAPABLE_ROUTE_ROOTs so subcheck 1 enforces membership. + for (const r of ["/api/skills/install", "/api/skills/executions"]) { + assert.equal(isLocalOnlyPath(r), true, `${r} must be local-only`); + assert.ok( + SPAWN_CAPABLE_ROUTE_ROOTS.includes(`src/app${r}`), + `src/app${r} must be a SPAWN_CAPABLE_ROUTE_ROOT` + ); + } +}); + test("6A.8: spawn-capable routes in SPAWN_CAPABLE_ROUTE_ROOTS are still all classified local-only", async () => { // The original subcheck (SPAWN_CAPABLE_ROUTE_ROOTS) must still pass. // This test is a regression guard — the new source-scan does not break the old check. diff --git a/tests/unit/check-routing-error-guard.test.ts b/tests/unit/check-routing-error-guard.test.ts new file mode 100644 index 0000000000..dd5cc11fec --- /dev/null +++ b/tests/unit/check-routing-error-guard.test.ts @@ -0,0 +1,133 @@ +/** + * #13614 — scripts/check/check-routing-error-guard.mjs (npm run check:routing-error-guard). + * Frozen swallowed catches are keyed by file + body snippet, so line shifts never break + * the gate; void-async allowlist anchors must sit next to the site they cover. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const { catchSnippet, collectSwallowedCatches, evaluateSwallowedCatches, evaluateVoidAsyncSites } = + await import("../../scripts/check/check-routing-error-guard.mjs"); + +const FILE = "open-sse/services/combo/example.ts"; +const file = (source: string, path = FILE) => ({ path, source }); + +const SWALLOW = "try {\n await work();\n} catch {\n pending = fallback;\n}\n"; + +test("a bare swallowed catch is a violation when not frozen", () => { + const { violations, stale } = evaluateSwallowedCatches([file(SWALLOW)], []); + assert.equal(violations.length, 1); + assert.match(violations[0], /example\.ts:3 :: swallowed-catch ::/); + assert.deepEqual(stale, []); +}); + +test("rethrowing catches, no-effect markers and chained .catch() are not swallows", () => { + const sources = [ + "try {\n await work();\n} catch (err) {\n log.warn(err);\n throw err;\n}\n", + "try {\n clone = r.clone();\n} catch {\n // no-effect: clone fallback\n clone = r;\n}\n", + "const quota = await fetchQuota(id).catch(() => null);\n", + ]; + assert.deepEqual(collectSwallowedCatches(sources.map((s) => file(s))), []); +}); + +test("a frozen entry survives line shifts (keyed by snippet, not line number)", () => { + const frozen = [ + { + file: FILE, + snippet: catchSnippet("\n pending = fallback;\n"), + count: 1, + reason: "fallback", + }, + ]; + const shifted = "// a new line\n// another\n\n" + SWALLOW; + assert.deepEqual(evaluateSwallowedCatches([file(SWALLOW)], frozen), { + violations: [], + stale: [], + }); + assert.deepEqual(evaluateSwallowedCatches([file(shifted)], frozen), { + violations: [], + stale: [], + }); +}); + +test("counts: a second identical swallow is new, a removed one makes the entry stale", () => { + const frozen = [{ file: FILE, snippet: "pending = fallback;", count: 1, reason: "fallback" }]; + const twice = evaluateSwallowedCatches([file(SWALLOW + SWALLOW)], frozen); + assert.equal(twice.violations.length, 1); + assert.match(twice.violations[0], /example\.ts:8 ::/); + + const gone = evaluateSwallowedCatches([file("const ok = 1;\n")], frozen); + assert.deepEqual(gone.violations, []); + assert.equal(gone.stale.length, 1); + assert.match(gone.stale[0], /frozen 1, live 0/); +}); + +test("editing a frozen catch body re-flags it (the snippet no longer matches)", () => { + const frozen = [{ file: FILE, snippet: "pending = fallback;", count: 1, reason: "fallback" }]; + const edited = SWALLOW.replace("pending = fallback;", "pending = otherFallback;"); + const result = evaluateSwallowedCatches([file(edited)], frozen); + assert.equal(result.violations.length, 1); + assert.equal(result.stale.length, 1); +}); + +test("a frozen entry without a reason is rejected", () => { + const frozen = [{ file: FILE, snippet: "pending = fallback;", count: 1, reason: " " }]; + const { violations } = evaluateSwallowedCatches([file(SWALLOW)], frozen); + assert.equal(violations.length, 1); + assert.match(violations[0], /entry needs a reason/); +}); + +const VOID_SITE = + "void (async () => {\n try {\n await persist();\n } catch (err) {\n log.warn('Failed to record Last Known Good Provider', err);\n }\n})();\n"; + +test("void async: an anchored allowlist entry covers the site", () => { + const allow = [ + { file: FILE, anchor: "Failed to record Last Known Good Provider", reason: "persist" }, + ]; + assert.deepEqual(evaluateVoidAsyncSites([file(VOID_SITE)], allow), { violations: [], stale: [] }); +}); + +test("void async: an unlisted site, a reasonless entry and an orphan entry all fail", () => { + const unlisted = evaluateVoidAsyncSites( + [file("void (async () => {\n await work();\n})();\n")], + [] + ); + assert.equal(unlisted.violations.length, 1); + assert.match(unlisted.violations[0], /example\.ts:1 :: void-async ::/); + + const reasonless = evaluateVoidAsyncSites( + [file(VOID_SITE)], + [{ file: FILE, anchor: "Failed to record Last Known Good Provider" }] + ); + assert.match(reasonless.violations[0], /needs a reason/); + + const orphan = evaluateVoidAsyncSites( + [file("const x = 1;\n")], + [{ file: "open-sse/services/combo/removed.ts", anchor: "gone", reason: "left over" }] + ); + assert.deepEqual(orphan.violations, []); + assert.deepEqual(orphan.stale, ["open-sse/services/combo/removed.ts :: gone"]); +}); + +test("void async: an anchor elsewhere in the file does not cover an unrelated site", () => { + const source = + "void (async () => {\n await work();\n})();\n" + + "\n".repeat(40) + + "// Failed to record Last Known Good Provider\n"; + const { violations } = evaluateVoidAsyncSites( + [file(source)], + [{ file: FILE, anchor: "Failed to record Last Known Good Provider", reason: "persist" }] + ); + assert.equal(violations.length, 1); +}); + +test("wired as the check:routing-error-guard npm script (not a CI job)", () => { + const pkg = JSON.parse(readFileSync("package.json", "utf8")) as { + scripts: Record; + }; + assert.equal( + pkg.scripts["check:routing-error-guard"], + "node scripts/check/check-routing-error-guard.mjs" + ); +}); diff --git a/tests/unit/client-bundle-no-server-only-10692.test.ts b/tests/unit/client-bundle-no-server-only-10692.test.ts index e29ae9d2fa..9206d61604 100644 --- a/tests/unit/client-bundle-no-server-only-10692.test.ts +++ b/tests/unit/client-bundle-no-server-only-10692.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import fs from "node:fs"; import path from "node:path"; +import { builtinModules } from "node:module"; import { fileURLToPath } from "node:url"; /** @@ -26,6 +27,20 @@ import { fileURLToPath } from "node:url"; * - **Dynamic `import()` is not followed.** It does not actually break a bundle edge (that was * tried for #10692 and failed), but it does move the module into a chunk the browser only * fetches on demand, which is a legitimate boundary for a lazily-used server path. + * + * A reached module counts as server-only when it statically imports a Node builtin the + * production bundler cannot resolve for the browser. The pinned list below (the original + * #10692 chain) stays explicit so it keeps failing loudly even if the discovery logic + * changes; everything else is found by walking the graph and checking each visited file + * for a builtin import. + * + * Builtins Next ships a browser polyfill for are tolerated when imported by their BARE name + * (`path`, `os`, `crypto`, `buffer`, …): Next's client build maps exactly those names to + * `next/dist/compiled/*` shims (`resolve.fallback` for the client compiler in + * `node_modules/next/dist/build/webpack-config.js`), so flagging them would cry wolf the + * same way counting `import type` did. The `node:` scheme is never tolerated — the client + * build has no fallback for it (`UnhandledSchemeError` on `node:fs` / `node:os` / `node:path` + * is what broke the build this guard was widened for). */ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); @@ -39,6 +54,45 @@ const SERVER_ONLY = new Set([ "open-sse/utils/tlsClient.ts", ]); +/** + * Bare builtin names Next's client build polyfills (the client `resolve.fallback` map in + * `next/dist/build/webpack-config.js`). Kept in sync by the drift test at the bottom. + */ +const NEXT_CLIENT_POLYFILLED_BUILTINS = new Set([ + "assert", + "buffer", + "constants", + "crypto", + "domain", + "events", + "http", + "https", + "os", + "path", + "process", + "punycode", + "querystring", + "stream", + "string_decoder", + "sys", + "timers", + "tty", + "util", + "vm", + "zlib", +]); + +const NODE_BUILTINS = new Set( + builtinModules.map((name) => name.replace(/^node:/, "")).filter((bare) => !bare.startsWith("_")) +); + +/** True when `specifier` names a Node builtin the browser bundle cannot resolve. */ +function isBrowserForbiddenBuiltin(specifier: string): boolean { + if (specifier.startsWith("node:")) return true; // no client fallback for the scheme + const root = specifier.split("/")[0]; // `fs/promises` → `fs` + return NODE_BUILTINS.has(root) && !NEXT_CLIENT_POLYFILLED_BUILTINS.has(root); +} + /** * Non-`"use client"` entry points that still end up in a client bundle because client * components import them. Kept explicit so the original #10692 chain stays pinned even if the @@ -60,6 +114,9 @@ function resolveSpecifier(fromFile: string, specifier: string): string | null { } else if (specifier.startsWith("@omniroute/open-sse")) { const rest = specifier.slice("@omniroute/open-sse".length).replace(/^\//, ""); base = path.join(REPO_ROOT, "open-sse", rest); + } else if (specifier.startsWith("@omniroute/browser-pool")) { + const rest = specifier.slice("@omniroute/browser-pool".length).replace(/^\//, ""); + base = path.join(REPO_ROOT, "packages/browser-pool/src", rest); } else if (specifier.startsWith("@/")) { base = path.join(REPO_ROOT, "src", specifier.slice(2)); } else { @@ -118,29 +175,53 @@ function staticSpecifiers(source: string): string[] { } const specifierCache = new Map(); -function edgesOf(file: string): string[] { +function specifiersOf(file: string): string[] { const cached = specifierCache.get(file); if (cached) return cached; const absolute = path.join(REPO_ROOT, file); - let edges: string[] = []; + let specs: string[] = []; if (fs.existsSync(absolute)) { - edges = staticSpecifiers(fs.readFileSync(absolute, "utf8")) - .map((specifier) => resolveSpecifier(file, specifier)) - .filter((resolved): resolved is string => resolved !== null); + specs = staticSpecifiers(fs.readFileSync(absolute, "utf8")); } - specifierCache.set(file, edges); + specifierCache.set(file, specs); + return specs; +} +// Resolved edges and verdicts are cached per file: the BFS runs once per client entry and +// re-visits the same shared modules thousands of times, so re-resolving specifiers +// (fs.existsSync/statSync per candidate) on every visit made the guard ~6x slower. +const edgeCache = new Map(); +function edgesOf(file: string): string[] { + const cached = edgeCache.get(file); + if (cached) return cached; + const edges = specifiersOf(file) + .map((specifier) => resolveSpecifier(file, specifier)) + .filter((resolved): resolved is string => resolved !== null); + edgeCache.set(file, edges); return edges; } +const serverOnlyVerdictCache = new Map(); +/** True when the file is pinned server-only or itself imports a browser-forbidden builtin. */ +function isServerOnly(file: string): boolean { + const cached = serverOnlyVerdictCache.get(file); + if (cached !== undefined) return cached; + const verdict = SERVER_ONLY.has(file) || specifiersOf(file).some(isBrowserForbiddenBuiltin); + serverOnlyVerdictCache.set(file, verdict); + return verdict; +} + /** BFS over static imports; returns the first path reaching a server-only module. */ function findServerOnlyPath(entry: string): string[] | null { const seen = new Set([entry]); + if (isServerOnly(entry)) return [entry]; const queue: Array = [[entry]]; while (queue.length > 0) { const trail = queue.shift()!; for (const resolved of edgesOf(trail[trail.length - 1])) { if (seen.has(resolved)) continue; - if (SERVER_ONLY.has(resolved)) return [...trail, resolved]; + if (isServerOnly(resolved)) { + return [...trail, resolved]; + } seen.add(resolved); queue.push([...trail, resolved]); } @@ -163,7 +244,9 @@ function walk(dir: string, acc: string[] = []): string[] { function clientEntryPoints(): string[] { return walk(path.join(REPO_ROOT, "src")).filter((file) => - /^\s*["']use client["']/m.test(fs.readFileSync(path.join(REPO_ROOT, file), "utf8").slice(0, 200)) + /^\s*["']use client["']/m.test( + fs.readFileSync(path.join(REPO_ROOT, file), "utf8").slice(0, 200) + ) ); } @@ -184,3 +267,32 @@ test("no client entry point statically reaches server-only code", () => { "carries no runtime edge." ); }); + +test("builtin classification: node: scheme always forbidden, bare polyfilled names tolerated", () => { + for (const specifier of ["node:fs", "node:path", "node:os", "node:crypto", "fs", "fs/promises"]) { + assert.equal(isBrowserForbiddenBuiltin(specifier), true, specifier); + } + for (const specifier of ["child_process", "net", "tls", "module", "worker_threads"]) { + assert.equal(isBrowserForbiddenBuiltin(specifier), true, specifier); + } + for (const specifier of ["path", "os", "crypto", "buffer", "events", "util", "stream"]) { + assert.equal(isBrowserForbiddenBuiltin(specifier), false, specifier); + } + for (const specifier of ["react", "@/lib/db/core", "./local", "zod"]) { + assert.equal(isBrowserForbiddenBuiltin(specifier), false, specifier); + } +}); + +test("the polyfilled-builtin allowlist matches Next's client resolve.fallback", () => { + const webpackConfig = fs.readFileSync( + path.join(REPO_ROOT, "node_modules/next/dist/build/webpack-config.js"), + "utf8" + ); + for (const name of NEXT_CLIENT_POLYFILLED_BUILTINS) { + assert.match( + webpackConfig, + new RegExp(`\\b${name}: require\\.resolve\\(`), + `Next no longer polyfills "${name}" for the client — drop it from the allowlist` + ); + } +}); diff --git a/tests/unit/combo-terminal-status-policy-10501.test.ts b/tests/unit/combo-terminal-status-policy-10501.test.ts index f4b8076bc5..08a982acea 100644 --- a/tests/unit/combo-terminal-status-policy-10501.test.ts +++ b/tests/unit/combo-terminal-status-policy-10501.test.ts @@ -54,7 +54,9 @@ function successResponse() { return new Response( JSON.stringify({ id: "chatcmpl-1", - choices: [{ index: 0, message: { role: "assistant", content: "hi there" }, finish_reason: "stop" }], + choices: [ + { index: 0, message: { role: "assistant", content: "hi there" }, finish_reason: "stop" }, + ], }), { status: 200, headers: { "Content-Type": "application/json" } } ); @@ -86,7 +88,11 @@ test("#10314/#10501: quality failure on target 1 + auth 401 on target 2 → 5xx result.status >= 500, `expected a 5xx terminal status for a heterogeneous quality+auth mix, got ${result.status}` ); - assert.notEqual(result.status, 401, "must not regress to surfacing the sibling target's bare 401"); + assert.notEqual( + result.status, + 401, + "must not regress to surfacing the sibling target's bare 401" + ); const body = (await result.json()) as { error?: { message?: string } }; const message = body.error?.message ?? ""; diff --git a/tests/unit/combo/protected-priority-stop-status-13439.test.ts b/tests/unit/combo/protected-priority-stop-status-13439.test.ts new file mode 100644 index 0000000000..b1fcce82cc --- /dev/null +++ b/tests/unit/combo/protected-priority-stop-status-13439.test.ts @@ -0,0 +1,317 @@ +/** + * #13439 — status of a protected-priority stop (priority strategy, target marked + * fallbackOnlyOnQuotaExhaustion) per stop cause, with PROTECTED_PRIORITY_INFRA_502_ENABLED + * off (default: every stop stays 503, as on the release tip) and on (only provably + * non-quota causes — circuit breaker open, predictive latency skip — answer 502). + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-protected-stop-13439-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +process.env.DATA_DIR = TEST_DATA_DIR; +const FLAG = "PROTECTED_PRIORITY_INFRA_502_ENABLED"; +delete process.env[FLAG]; + +const { evaluateExecuteTargetGates } = + await import("../../../open-sse/services/combo/executeTargetGates.ts"); +const { executeTargetAttempt } = + await import("../../../open-sse/services/combo/executeTargetAttempt.ts"); +const { getCircuitBreaker, STATE } = await import("../../../src/shared/utils/circuitBreaker.ts"); +const { recordProviderCooldown } = + await import("../../../open-sse/services/providerCooldownTracker.ts"); +const { lockModel, clearAllModelLockouts } = + await import("../../../open-sse/services/accountFallback.ts"); +const { setCredentialHealth, __test_resetCredentialHealthCache } = + await import("../../../src/lib/credentialHealth/cache.ts"); +const semaphore = await import("../../../open-sse/services/accountSemaphore.ts"); +const { recordComboRequest } = await import("../../../open-sse/services/comboMetrics.ts"); +const { createProviderConnection } = await import("../../../src/lib/db/providers.ts"); +const dbCore = await import("../../../src/lib/db/core.ts"); + +import type { + AttemptLoopDeps, + AttemptLoopState, +} from "../../../open-sse/services/combo/attemptLoopTypes.ts"; +import type { ResolvedComboTarget } from "../../../open-sse/services/combo/types.ts"; + +test.afterEach(() => { + delete process.env[FLAG]; + clearAllModelLockouts(); + __test_resetCredentialHealthCache(); + semaphore.resetAll(); +}); + +test.after(() => { + delete process.env[FLAG]; + try { + dbCore.resetDbInstance(); + } catch { + /* ignore */ + } + if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIGINAL_DATA_DIR; + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +let seq = 0; +const uniqueProvider = (label: string) => `pp13439-${label}-${Date.now()}-${seq++}`; + +function state(target: ResolvedComboTarget, overrides: Partial = {}) { + return { + orderedTargets: [target], + fallbackCount: 0, + recordedAttempts: 0, + comboErrors: [], + lastError: null, + lastStatus: null, + earliestRetryAfter: null, + comboExpired: false, + exhaustedProviders: new Set(), + exhaustedConnections: new Set(), + transientRateLimitedProviders: new Set(), + abortControllers: new Map([[0, new AbortController()]]), + dispatchedTargets: new Set(), + targetFailureTrust: new Map(), + comboAttemptOrder: [], + skippedForCircuitOpen: false, + earliestCircuitOpenRetryMs: 0, + globalAttempts: 0, + observedFailure: false, + allObservedFailuresQuota: true, + observeFailure() {}, + ...overrides, + } as AttemptLoopState; +} + +function deps(overrides: Partial = {}): AttemptLoopDeps { + return { + strategy: "priority", + combo: { name: "pp13439", models: [] }, + config: {}, + log: { info() {}, warn() {}, debug() {}, error() {} }, + settings: null, + resilienceSettings: { + providerCooldown: { enabled: false }, + } as AttemptLoopDeps["resilienceSettings"], + sticky: { targets: [], messageHash: null, stuck: false }, + effectiveSessionId: null, + preScreenMap: new Map(), + quotaCutoffResetWindowConfig: {} as AttemptLoopDeps["quotaCutoffResetWindowConfig"], + maxRetries: 0, + traceInvocationId: "inv-pp13439", + clientRequestedStream: false, + handleSingleModelWithTimeout: async () => { + throw new Error("a protected stop must not dispatch"); + }, + body: { messages: [{ role: "user", content: "hi" }] }, + startTime: Date.now(), + releaseStickyPinOnFailure() {}, + clearStaleLKGP() {}, + ...overrides, + }; +} + +function protectedTarget(provider: string, connectionId = "c1"): ResolvedComboTarget { + return { + kind: "model", + stepId: "s1", + executionKey: `ek-${provider}`, + modelStr: `${provider}/m1`, + provider, + providerId: null, + connectionId, + weight: 1, + label: null, + fallbackOnlyOnQuotaExhaustion: true, + } as ResolvedComboTarget; +} + +type Case = { + name: string; + provablyNonQuota: boolean; + /** Arrange the stop; returns the gate inputs. */ + arrange: () => Promise<{ target: ResolvedComboTarget; st: AttemptLoopState; d: AttemptLoopDeps }>; + message: RegExp; +}; + +const CASES: Case[] = [ + { + name: "circuit breaker open", + provablyNonQuota: true, + message: /circuit breaker is open/, + async arrange() { + const provider = uniqueProvider("cb"); + const cb = getCircuitBreaker(provider, { failureThreshold: 1, resetTimeout: 60_000 }); + cb._onFailure("transient"); + assert.equal(cb.getStatus().state, STATE.OPEN); + const target = protectedTarget(provider); + return { target, st: state(target), d: deps() }; + }, + }, + { + name: "provider cooldown", + provablyNonQuota: false, + message: /is in cooldown/, + async arrange() { + const provider = uniqueProvider("cooldown"); + const d = deps({ + resilienceSettings: { + providerCooldown: { enabled: true }, + } as AttemptLoopDeps["resilienceSettings"], + }); + recordProviderCooldown(provider, "c1", d.resilienceSettings); + const target = protectedTarget(provider); + return { target, st: state(target), d }; + }, + }, + { + name: "request exhaustion (provider)", + provablyNonQuota: false, + message: /is unavailable/, + async arrange() { + const provider = uniqueProvider("exhausted"); + const target = protectedTarget(provider); + return { + target, + st: state(target, { exhaustedProviders: new Set([provider]) }), + d: deps(), + }; + }, + }, + { + name: "request exhaustion (connection)", + provablyNonQuota: false, + message: /is unavailable/, + async arrange() { + const provider = uniqueProvider("conn-exhausted"); + const target = protectedTarget(provider); + return { + target, + st: state(target, { exhaustedConnections: new Set([`${provider}:c1`]) }), + d: deps(), + }; + }, + }, + { + name: "model lockout", + provablyNonQuota: false, + message: /is locked/, + async arrange() { + const provider = uniqueProvider("lock"); + lockModel(provider, "c1", "m1", "quota_exhausted", 60_000); + const target = protectedTarget(provider); + return { target, st: state(target), d: deps() }; + }, + }, + { + name: "model unavailable (no credentials)", + provablyNonQuota: false, + message: /Model .* is unavailable/, + async arrange() { + const target = protectedTarget(uniqueProvider("unavailable")); + return { target, st: state(target), d: deps({ isModelAvailable: async () => false }) }; + }, + }, + { + name: "credential gate", + provablyNonQuota: false, + message: /Credential gate blocked/, + async arrange() { + const provider = uniqueProvider("credgate"); + setCredentialHealth("c-credgate", provider, "error", "probe failed"); + const target = protectedTarget(provider, "c-credgate"); + return { target, st: state(target), d: deps() }; + }, + }, + { + name: "connection concurrency cap", + provablyNonQuota: false, + message: /Connection capacity reached/, + async arrange() { + const provider = uniqueProvider("cap"); + const conn = (await createProviderConnection({ + provider, + authType: "apikey", + name: `cap-${provider}`, + apiKey: "sk-test-13439-cap", + maxConcurrent: 1, + })) as { id: string }; + semaphore.markBlocked( + semaphore.buildAccountSemaphoreKey({ provider, accountKey: conn.id }), + 60_000 + ); + const target = protectedTarget(provider, conn.id); + return { target, st: state(target), d: deps() }; + }, + }, +]; + +for (const c of CASES) { + for (const flagOn of [false, true]) { + const expected = flagOn && c.provablyNonQuota ? 502 : 503; + test(`gate stop "${c.name}" with flag ${flagOn ? "on" : "off"} answers ${expected}`, async () => { + const { st, d } = await c.arrange(); + if (flagOn) process.env[FLAG] = "true"; + const decision = await evaluateExecuteTargetGates({ index: 0, state: st, deps: d }); + assert.equal(decision.kind, "skip"); + assert.ok(decision.kind === "skip" && decision.result && !decision.result.ok); + const response = decision.result.response; + assert.equal(response.status, expected); + const body = (await response.json()) as { error: { message: string } }; + assert.match(body.error.message, c.message); + }); + } +} + +for (const flagOn of [false, true]) { + const expected = flagOn ? 502 : 503; + test(`attempt stop "predictive latency" with flag ${flagOn ? "on" : "off"} answers ${expected}`, async () => { + const provider = uniqueProvider("ttft"); + const target = protectedTarget(provider); + const comboName = `pp13439-ttft-${seq++}`; + for (let i = 0; i < 6; i++) { + recordComboRequest(comboName, target.modelStr, { + success: true, + latencyMs: 9_000, + fallbackCount: 0, + strategy: "priority", + target: { executionKey: target.executionKey, modelStr: target.modelStr, provider }, + } as Parameters[2]); + } + if (flagOn) process.env[FLAG] = "true"; + const result = await executeTargetAttempt({ + index: 0, + state: state(target), + deps: deps({ + combo: { name: comboName, models: [] }, + config: { zeroLatencyOptimizationsEnabled: true, predictiveTtftMs: 1_000 }, + }), + targetForAttempt: target, + profile: {}, + protectedPriorityTarget: true, + }); + assert.ok(result && !result.ok, "predictive latency must stop the protected target"); + assert.equal(result.response.status, expected); + const body = (await result.response.json()) as { error: { message: string } }; + assert.match(body.error.message, /Predictive latency check rejected/); + }); +} + +test("a non-protected target is never stopped (flag on)", async () => { + process.env[FLAG] = "true"; + const provider = uniqueProvider("unprotected"); + const cb = getCircuitBreaker(provider, { failureThreshold: 1, resetTimeout: 60_000 }); + cb._onFailure("transient"); + const target = { ...protectedTarget(provider), fallbackOnlyOnQuotaExhaustion: false }; + const decision = await evaluateExecuteTargetGates({ + index: 0, + state: state(target as ResolvedComboTarget), + deps: deps(), + }); + assert.equal(decision.kind, "skip"); + assert.ok(decision.kind === "skip" && decision.result === null); +}); diff --git a/tests/unit/combo/stale-lkgp-clear-13614.test.ts b/tests/unit/combo/stale-lkgp-clear-13614.test.ts new file mode 100644 index 0000000000..6cb9858e84 --- /dev/null +++ b/tests/unit/combo/stale-lkgp-clear-13614.test.ts @@ -0,0 +1,100 @@ +/** + * #13614 — stale LKGP pin clears on the combo fallback path stay non-blocking, and a + * failed clear is logged with the combo and execution key instead of a bare error. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-stale-lkgp-13614-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +process.env.DATA_DIR = TEST_DATA_DIR; + +const { clearStaleLKGP } = await import("../../../open-sse/services/combo/staleLkgpClear.ts"); +const combo = await import("../../../open-sse/services/combo.ts"); +const { setLKGP, getLKGP } = await import("../../../src/lib/db/settings.ts"); +const dbCore = await import("../../../src/lib/db/core.ts"); + +test.after(() => { + try { + dbCore.resetDbInstance(); + } catch { + /* ignore */ + } + if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIGINAL_DATA_DIR; + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +function captureWarn() { + const warnings: Array<{ tag: string; msg: string; data: Record }> = []; + return { + warnings, + log: { + warn: (tag: string, msg: string, data?: unknown) => + warnings.push({ tag, msg, data: (data ?? {}) as Record }), + }, + }; +} + +test("combo.ts re-exports the non-blocking clear (single implementation)", () => { + assert.equal(combo.clearStaleLKGP, clearStaleLKGP); +}); + +test("a failed clear resolves and warns with the combo and execution key", async () => { + const { warnings, log } = captureWarn(); + const failure = new Error("database is locked"); + const pending = clearStaleLKGP("combo-a", "ek-7", "combo-id-a", log, "COMBO-RR", async () => { + throw failure; + }); + await assert.doesNotReject(pending); + assert.equal(warnings.length, 1); + assert.equal(warnings[0].tag, "COMBO-RR"); + assert.match(warnings[0].msg, /Failed to clear Last Known Good Provider/); + assert.equal(warnings[0].data.combo, "combo-a"); + assert.equal(warnings[0].data.comboId, "combo-id-a"); + assert.equal(warnings[0].data.executionKey, "ek-7"); + assert.equal(warnings[0].data.err, failure); +}); + +test("a synchronous throw from the writer is caught the same way", async () => { + const { warnings, log } = captureWarn(); + await clearStaleLKGP("combo-b", null, null, log, "COMBO", (() => { + throw new Error("sync boom"); + }) as unknown as (c: string, k: string) => Promise); + assert.equal(warnings.length, 1); + assert.equal(warnings[0].data.executionKey, null); +}); + +test("the call returns before the writes settle (the fallback loop never waits)", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const cleared: string[] = []; + let settled = false; + const pending = clearStaleLKGP("combo-c", "ek-c", "id-c", null, "COMBO", async (_c, key) => { + await gate; + cleared.push(key); + }).then(() => { + settled = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(settled, false, "clear must still be pending while the caller moves on"); + release(); + await pending; + assert.deepEqual(cleared.sort(), ["ek-c", "id-c"]); +}); + +test("default writer clears both persisted pins in the real DB, no warning", async () => { + await setLKGP("combo-db", "combo-db-id", "openai", "conn-1"); + await setLKGP("combo-db", "ek-db", "openai", "conn-1"); + assert.ok(await getLKGP("combo-db", "combo-db-id")); + const { warnings, log } = captureWarn(); + await clearStaleLKGP("combo-db", "ek-db", "combo-db-id", log, "COMBO"); + assert.equal(await getLKGP("combo-db", "combo-db-id"), null); + assert.equal(await getLKGP("combo-db", "ek-db"), null); + assert.deepEqual(warnings, []); +}); diff --git a/tests/unit/credential-masker-guardrail.test.ts b/tests/unit/credential-masker-guardrail.test.ts index 928b5467e3..177b433fd0 100644 --- a/tests/unit/credential-masker-guardrail.test.ts +++ b/tests/unit/credential-masker-guardrail.test.ts @@ -1,7 +1,9 @@ import test from "node:test"; import assert from "node:assert/strict"; +import safeRegex from "safe-regex"; import { + CREDENTIAL_PATTERNS, CredentialMaskerGuardrail, redactCredentials, } from "../../src/lib/guardrails/credentialMasker.ts"; @@ -91,3 +93,173 @@ test("does not re-redact an already-redacted structured header", async () => { assert.equal(response.headers.Authorization, "Bearer [REDACTED:auth_header]"); }); }); + +// --------------------------------------------------------------------------- +// GHSA-r4q7-7f24-m29p — Groq (`gsk_`), xAI (`xai-`) and OpenAI-compatible +// (`sk-` of any non-48 length: DeepSeek 32-hex, Moonshot/Kimi, Together, …) +// keys had no catalog entry. The runtime guardrail is catalog-only, so every +// one of those shapes passed through `redactCredentials()` untouched; the +// public sanitizer only caught the `sk-` family by coincidence through its +// STRONG_CREDENTIAL_TOKEN fallback and leaked `gsk_`/`xai-` outright. +// +// Key shapes below are deterministic fakes (shape-accurate, never real keys), +// generated the same way as the verifier probe so the regression guard and the +// empirical leak table agree byte-for-byte on what "a key" looks like. +// --------------------------------------------------------------------------- + +const ALNUM = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; +const HEX = "0123456789abcdef"; + +function fill(n: number, charset: string, seed = 7): string { + let out = ""; + for (let i = 0; i < n; i++) out += charset[(i * 31 + seed * 17 + i * i) % charset.length]; + return out; +} + +// `type` is both the detection name and the `[REDACTED:]` label. +type LeakShape = { label: string; key: string; type: string }; + +const LEAK_SHAPES: LeakShape[] = [ + { label: "groq gsk_ + 52 alnum", key: "gsk_" + fill(52, ALNUM), type: "groq" }, + { label: "xai xai- + 80 alnum", key: "xai-" + fill(80, ALNUM, 3), type: "xai" }, + { label: "deepseek sk- + 32 hex", key: "sk-" + fill(32, HEX), type: "openai_compatible" }, + { + label: "openai-compatible sk- + 40 alnum", + key: "sk-" + fill(40, ALNUM, 9), + type: "openai_compatible", + }, + { + label: "openai-compatible sk- + 51 alnum", + key: "sk-" + fill(51, ALNUM, 11), + type: "openai_compatible", + }, + { + label: "openai-compatible sk- + 20 alnum (minimum bound)", + key: "sk-" + fill(20, ALNUM, 13), + type: "openai_compatible", + }, + { + label: "openai-compatible sk- + 36 mixed [A-Za-z0-9_-]", + key: "sk-" + fill(36, ALNUM + "_-", 2), + type: "openai_compatible", + }, +]; + +const CONTEXTS: Array<[string, (key: string) => string]> = [ + ["bare", (key) => key], + ["sentence", (key) => `upstream error: Invalid API Key ${key} for model foo`], + ["json-msg", (key) => `{"error":{"message":"Incorrect API key provided: ${key}. Check docs."}}`], +]; + +for (const shape of LEAK_SHAPES) { + for (const [contextName, wrap] of CONTEXTS) { + test(`GHSA-r4q7: redacts ${shape.label} in ${contextName} context`, () => { + const input = wrap(shape.key); + const result = redactCredentials(input); + + assert.equal(result.modified, true, `not modified: ${input}`); + assert.equal(result.text.includes(shape.key), false, `key survived: ${result.text}`); + assert.ok( + result.text.includes(`[REDACTED:${shape.type}]`), + `expected [REDACTED:${shape.type}] in: ${result.text}` + ); + assert.deepEqual( + result.detections.map((d) => d.type), + [shape.type], + `unexpected detection set for ${shape.label}` + ); + }); + } +} + +test("GHSA-r4q7: leaves short prose tokens and sub-bound prefixes untouched", () => { + const benign = [ + "gsk_abc", + "xai-1", + "sk-short", + "task sk failed", + "gsk_" + fill(19, ALNUM), + "xai-" + fill(19, ALNUM), + "sk-" + fill(19, ALNUM), + // `sk-` preceded by an alphanumeric is part of a larger word, not a key prefix. + "risk-based-access-control-policy-evaluation-failed", + "Model gpt-5 is not available on this plan", + ]; + + for (const input of benign) { + const result = redactCredentials(input); + assert.equal(result.modified, false, `over-redacted: ${input} -> ${result.text}`); + assert.equal(result.text, input); + assert.deepEqual(result.detections, []); + } +}); + +test("GHSA-r4q7: specific sk- labels still win over the openai_compatible fallback", () => { + const specific: Array<[string, string, string]> = [ + ["sk-proj-" + fill(60, ALNUM + "_-", 4), "openai_proj", "[REDACTED:openai]"], + ["sk-" + fill(48, ALNUM, 21), "openai", "[REDACTED:openai]"], + // `anthropic` only allows one digit after `api`, so the real `api03` shape is + // caught by `anthropic_alt` — same label, pre-existing, out of scope here. + ["sk-ant-api03-" + fill(60, ALNUM + "_-", 6), "anthropic_alt", "[REDACTED:anthropic]"], + ["sk-ant-api3-" + fill(60, ALNUM + "_-", 6), "anthropic", "[REDACTED:anthropic]"], + ["sk-ant-" + fill(40, ALNUM + "_-", 8), "anthropic_alt", "[REDACTED:anthropic]"], + ["sk_live_" + fill(24, ALNUM, 10), "stripe", "[REDACTED:stripe]"], + ]; + + for (const [key, expectedType, expectedLabel] of specific) { + const result = redactCredentials(`upstream error: Invalid API Key ${key} for model foo`); + assert.equal(result.text.includes(key), false, `key survived: ${result.text}`); + assert.ok(result.text.includes(expectedLabel), `expected ${expectedLabel} in ${result.text}`); + assert.equal(result.text.includes("[REDACTED:openai_compatible]"), false, result.text); + assert.deepEqual( + result.detections.map((d) => d.type), + [expectedType], + `fallback must not fire when a specific pattern already matched: ${key}` + ); + } +}); + +test("GHSA-r4q7: catalog ordering keeps the generic sk- fallback last", () => { + const names = CREDENTIAL_PATTERNS.map((p) => p.name); + + assert.equal(names.at(-1), "openai_compatible", "openai_compatible must be the LAST entry"); + assert.equal(new Set(names).size, names.length, "duplicate catalog names"); + + // Every other pattern that can match a string starting with `sk-` must run + // before the fallback, or it would never get to apply its specific label. + const fallbackIndex = names.indexOf("openai_compatible"); + for (const [index, pattern] of CREDENTIAL_PATTERNS.entries()) { + if (pattern.name === "openai_compatible") continue; + if (/^\\?b?sk-/.test(pattern.regex.source)) { + assert.ok(index < fallbackIndex, `${pattern.name} is ordered after openai_compatible`); + } + } + + // The provider-specific entries sit with their siblings, before the loose + // `google` bound and after the last `sk-ant` label. + assert.ok(names.indexOf("groq") > names.indexOf("anthropic_alt")); + assert.ok(names.indexOf("xai") > names.indexOf("anthropic_alt")); + assert.ok(names.indexOf("groq") < names.indexOf("google")); + assert.ok(names.indexOf("xai") < names.indexOf("google")); +}); + +test("GHSA-r4q7: catalog regexes are ReDoS-safe and globally flagged", () => { + for (const pattern of CREDENTIAL_PATTERNS) { + assert.ok(pattern.regex.global, `${pattern.name} must carry the g flag`); + // `auth_header` predates this guard and trips safe-regex's star-height + // heuristic through `\s*` nested inside optional groups; its token class is + // bounded by `{10,}` so it is linear in practice. Everything else, including + // every future addition, must pass. + if (pattern.name === "auth_header") continue; + assert.ok(safeRegex(pattern.regex), `${pattern.name} failed safe-regex: ${pattern.regex}`); + } + + for (const name of ["groq", "xai", "openai_compatible"]) { + const pattern = CREDENTIAL_PATTERNS.find((p) => p.name === name); + assert.ok(pattern, `${name} missing from catalog`); + assert.ok(safeRegex(pattern.regex), `${name} failed safe-regex`); + // Bounded, non-nested charset with a lower length bound only — no `.*`, + // no alternation of overlapping classes. + assert.doesNotMatch(pattern.regex.source, /\.\*|\.\+|\)\*|\)\+/); + } +}); diff --git a/tests/unit/daily-reset-dst-gap.test.ts b/tests/unit/daily-reset-dst-gap.test.ts new file mode 100644 index 0000000000..0ae77e9104 --- /dev/null +++ b/tests/unit/daily-reset-dst-gap.test.ts @@ -0,0 +1,79 @@ +/** + * #13671 — a configured daily reset hour that does not exist on a DST + * spring-forward day must resolve to the first wall-clock time that exists, + * never an hour early (and never on the previous calendar day). + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { nextDailyResetAtMs } = await import("../../open-sse/services/dailyQuotaReset.ts"); + +const HOUR_MS = 60 * 60 * 1000; + +function wallClock(timeZone: string, ms: number): string { + return new Intl.DateTimeFormat("en-CA", { + timeZone, + hourCycle: "h23", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(ms)); +} + +test("New York 02:00 on spring-forward resolves to 03:00 EDT, not 01:00 EST", () => { + // 2026-03-08: 02:00 -> 03:00 in America/New_York; 02:00 does not exist. + const nowMs = Date.parse("2026-03-08T00:30:00-05:00"); + const next = nextDailyResetAtMs("America/New_York", 2, nowMs); + assert.equal(new Date(next).toISOString(), "2026-03-08T07:00:00.000Z"); + assert.equal(wallClock("America/New_York", next), "2026-03-08, 03:00"); +}); + +test("Havana midnight on spring-forward resolves to 01:00 the same day, not 23:00 the day before", () => { + // 2026-03-08: 00:00 -> 01:00 in America/Havana; midnight does not exist. + const nowMs = Date.parse("2026-03-07T20:00:00-05:00"); + const next = nextDailyResetAtMs("America/Havana", 0, nowMs); + assert.equal(wallClock("America/Havana", next), "2026-03-08, 01:00"); + assert.equal(next - nowMs, 4 * HOUR_MS); +}); + +test("Santiago midnight on spring-forward resolves to 01:00 the same day, not 23:00 the day before", () => { + // 2026-09-06: 00:00 -> 01:00 in America/Santiago; midnight does not exist. + const nowMs = Date.parse("2026-09-05T20:00:00-04:00"); + const next = nextDailyResetAtMs("America/Santiago", 0, nowMs); + assert.equal(wallClock("America/Santiago", next), "2026-09-06, 01:00"); + assert.equal(next - nowMs, 4 * HOUR_MS); +}); + +test("between the old (wrong) 23:00 and the real 01:00 the reset is still ahead", () => { + const nowMs = Date.parse("2026-03-07T23:30:00-05:00"); // Havana 23:30, before the gap + const next = nextDailyResetAtMs("America/Havana", 0, nowMs); + assert.equal(wallClock("America/Havana", next), "2026-03-08, 01:00"); + assert.equal(next - nowMs, 30 * 60 * 1000); +}); + +test("fold hour keeps the first occurrence (characterization)", () => { + // 2026-11-01: fall back, 01:00 occurs twice; the first (EDT) occurrence wins. + const nowMs = Date.parse("2026-11-01T00:30:00-04:00"); + const next = nextDailyResetAtMs("America/New_York", 1, nowMs); + assert.equal(new Date(next).toISOString(), "2026-11-01T05:00:00.000Z"); +}); + +test("a 25h fall-back day keeps its real 24.5h magnitude (characterization, no clamp)", () => { + const nowMs = Date.parse("2026-11-01T00:30:00-04:00"); + const next = nextDailyResetAtMs("America/New_York", 0, nowMs); + assert.equal(next - nowMs, 24.5 * HOUR_MS); +}); + +test("ordinary days are unchanged", () => { + const nowMs = Date.parse("2026-01-15T10:00:00Z"); + assert.equal( + new Date(nextDailyResetAtMs("Europe/Paris", 0, nowMs)).toISOString(), + "2026-01-15T23:00:00.000Z" + ); + assert.equal( + new Date(nextDailyResetAtMs("Asia/Kolkata", 0, nowMs)).toISOString(), + "2026-01-15T18:30:00.000Z" + ); +}); diff --git a/tests/unit/daily-reset-tz-threading.test.ts b/tests/unit/daily-reset-tz-threading.test.ts new file mode 100644 index 0000000000..42979e3d9d --- /dev/null +++ b/tests/unit/daily-reset-tz-threading.test.ts @@ -0,0 +1,248 @@ +/** + * #13440 — non-TPD daily-quota cooldowns honor the provider node's configured + * daily-reset clock (dailyQuotaResetTimezone + dailyQuotaResetHour) instead of + * host midnight, on both the single-model classifier and the combo call sites. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +process.env.TZ = "UTC"; +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-daily-reset-13440-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +process.env.DATA_DIR = TEST_DATA_DIR; + +const { checkFallbackError, getMsUntilTomorrow } = + await import("../../open-sse/services/accountFallback.ts"); +const { nextDailyResetAtMs } = await import("../../open-sse/services/dailyQuotaReset.ts"); +const { resolveComboDailyReset } = + await import("../../open-sse/services/combo/comboDailyResetClock.ts"); +const { handleComboChat } = await import("../../open-sse/services/combo.ts"); +const rrState = await import("../../open-sse/services/combo/rrState.ts"); +const { createProviderNode, updateProviderNode } = + await import("../../src/lib/db/providers/nodes.ts"); +const dbCore = await import("../../src/lib/db/core.ts"); + +const DAILY_TEXT = "daily quota exceeded, try again tomorrow"; +const HOUR_MS = 3_600_000; +const realDateNow = Date.now; + +/** Shift the wall clock so "now" is `nowMs` (keeps advancing in real time). */ +function shiftClockTo(nowMs: number): void { + const offset = nowMs - realDateNow(); + Date.now = () => realDateNow() + offset; +} + +function dailyCooldownMs(timezone: unknown, hour: unknown, nowMs: number): number { + return checkFallbackError(403, DAILY_TEXT, 0, null, "tz-thread-prov", null, null, null, null, { + timezone, + hour, + nowMs, + }).cooldownMs; +} + +type LogCall = { level: string; msg: string }; +function captureLog(calls: LogCall[]) { + const push = (level: string) => (_tag: string, msg: unknown) => + calls.push({ level, msg: String(msg) }); + return { info: push("info"), warn: push("warn"), debug: push("debug"), error: push("error") }; +} + +function dailyQuotaResponse(status: number): Response { + return new Response(JSON.stringify({ error: { message: DAILY_TEXT } }), { + status, + headers: { "content-type": "application/json" }, + }); +} + +async function dispatch( + combo: Record, + failingProvider: string, + failStatus: number, + calls: LogCall[] +) { + const res = await handleComboChat({ + body: { model: combo.name, messages: [{ role: "user", content: "hi" }], stream: false }, + combo, + allCombos: [combo], + isModelAvailable: async () => true, + relayOptions: undefined, + signal: undefined, + settings: {}, + log: captureLog(calls), + handleSingleModel: async (_b: unknown, modelStr: string) => { + if (modelStr.startsWith(`${failingProvider}/`)) return dailyQuotaResponse(failStatus); + return Response.json({ choices: [{ message: { role: "assistant", content: "ok" } }] }); + }, + }); + await (res as Response | undefined)?.body?.cancel().catch(() => {}); +} + +function comboFor(name: string, strategy: string, nodeId: string, config = {}) { + return { + name, + strategy, + config: { maxRetries: 0, disableSessionStickiness: true, ...config }, + models: [ + { kind: "model", provider: nodeId, providerId: nodeId, model: "m-a", id: `${name}-0` }, + { kind: "model", provider: "glm-cn", providerId: "glm-cn", model: "m-b", id: `${name}-1` }, + ], + }; +} + +function rrCooldownFromLogs(calls: LogCall[]): number | null { + for (const c of calls) { + const m = /error 429, cooldown (\d+)ms/.exec(c.msg); + if (c.level === "warn" && m) return Number(m[1]); + } + return null; +} + +test.beforeEach(() => { + rrState.rrCounters.clear(); + rrState.rrStickyTargets.clear(); +}); + +test.afterEach(() => { + Date.now = realDateNow; +}); + +test.after(() => { + Date.now = realDateNow; + try { + dbCore.resetDbInstance(); + } catch { + /* ignore */ + } + if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIGINAL_DATA_DIR; + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("checkFallbackError: Paris pre-spring-forward resolves to provider midnight", () => { + const nowMs = Date.parse("2026-03-28T21:00:00Z"); + assert.equal(dailyCooldownMs("Europe/Paris", 0, nowMs), 2 * HOUR_MS); +}); + +test("checkFallbackError: Paris pre-fall-back resolves to provider midnight", () => { + const nowMs = Date.parse("2026-10-24T10:00:00Z"); + assert.equal(dailyCooldownMs("Europe/Paris", 0, nowMs), 12 * HOUR_MS); +}); + +test("checkFallbackError: New York resolves to provider midnight, not host midnight", () => { + const nowMs = Date.parse("2026-01-16T04:00:00Z"); + assert.equal(dailyCooldownMs("America/New_York", 0, nowMs), HOUR_MS); +}); + +// The legacy value is recomputed from a live Date.now() inside getMsUntilTomorrow(), so the two +// reads are a few ms apart under load; compare within a second instead of strictly. +function assertWithinASecond(actual: number, expected: number, label: string): void { + assert.ok( + Math.abs(actual - expected) <= 1000, + `${label}: expected ${actual} within 1s of ${expected}` + ); +} + +test("checkFallbackError: unconfigured clock keeps the legacy host-midnight value", () => { + shiftClockTo(Date.parse("2026-01-15T12:00:00Z")); + assertWithinASecond( + dailyCooldownMs(undefined, undefined, Date.now()), + getMsUntilTomorrow(), + "unconfigured clock" + ); +}); + +test("checkFallbackError: invalid timezone falls back to legacy without throwing", () => { + shiftClockTo(Date.parse("2026-01-15T12:00:00Z")); + assertWithinASecond( + dailyCooldownMs("Mars/Olympus", 0, Date.now()), + getMsUntilTomorrow(), + "invalid tz" + ); +}); + +test("resolveComboDailyReset: matches id and prefix, null for unknown providers", async () => { + const node = await createProviderNode({ + type: "openai-compatible", + name: "Daily reset lookup", + prefix: "drlookup13440", + apiType: "chat", + baseUrl: "http://127.0.0.1:9/v1", + dailyQuotaResetTimezone: "Europe/Paris", + dailyQuotaResetHour: 7, + }); + const expected = { timezone: "Europe/Paris", hour: 7 }; + assert.deepEqual(await resolveComboDailyReset(String(node.id)), expected); + assert.deepEqual(await resolveComboDailyReset("drlookup13440"), expected); + assert.equal(await resolveComboDailyReset("no-such-provider-13440"), null); + assert.equal(await resolveComboDailyReset("unknown"), null); + assert.equal(await resolveComboDailyReset(null), null); +}); + +test("round-robin combo passes the node clock, and a timezone edit applies without restart", async () => { + const node = await createProviderNode({ + type: "openai-compatible", + name: "Daily reset RR", + prefix: "drrr13440", + apiType: "chat", + baseUrl: "http://127.0.0.1:9/v1", + dailyQuotaResetTimezone: "America/New_York", + dailyQuotaResetHour: 0, + }); + const nodeId = String(node.id); + + // 1h before New York midnight: provider clock says 1h, host (UTC) midnight is ~19-20h away. + shiftClockTo(nextDailyResetAtMs("America/New_York", 0, realDateNow()) - HOUR_MS); + assert.ok(getMsUntilTomorrow() > 3 * HOUR_MS, "fixture must separate host and provider clocks"); + const first: LogCall[] = []; + await dispatch(comboFor("rr13440-a", "round-robin", nodeId), nodeId, 429, first); + const firstCooldown = rrCooldownFromLogs(first); + assert.ok(firstCooldown !== null, "RR must log the semaphore cooldown for the 429"); + assert.ok( + Math.abs(firstCooldown - HOUR_MS) < 10_000, + `expected ~1h (New York midnight), got ${firstCooldown}ms` + ); + + // Operator edits the node: Tokyo midnight. No restart, no cache reset in the test. + await updateProviderNode(nodeId, { dailyQuotaResetTimezone: "Asia/Tokyo" }); + shiftClockTo(nextDailyResetAtMs("Asia/Tokyo", 0, realDateNow()) - 2 * HOUR_MS); + assert.ok(Math.abs(getMsUntilTomorrow() - 2 * HOUR_MS) > HOUR_MS); + const second: LogCall[] = []; + await dispatch(comboFor("rr13440-b", "round-robin", nodeId), nodeId, 429, second); + const secondCooldown = rrCooldownFromLogs(second); + assert.ok(secondCooldown !== null, "RR must log the semaphore cooldown for the 429"); + assert.ok( + Math.abs(secondCooldown - 2 * HOUR_MS) < 10_000, + `expected ~2h (Tokyo midnight after the edit), got ${secondCooldown}ms` + ); +}); + +test("priority combo attempt path passes the node clock to checkFallbackError", async () => { + const node = await createProviderNode({ + type: "openai-compatible", + name: "Daily reset priority", + prefix: "drprio13440", + apiType: "chat", + baseUrl: "http://127.0.0.1:9/v1", + dailyQuotaResetTimezone: "America/New_York", + dailyQuotaResetHour: 0, + }); + const nodeId = String(node.id); + + // 2s before New York midnight: the provider-clock cooldown (~2s) is short enough for + // the pre-fallback wait (<= MAX_FALLBACK_WAIT_MS); host midnight (hours) is not. + shiftClockTo(nextDailyResetAtMs("America/New_York", 0, realDateNow()) - 2_000); + const calls: LogCall[] = []; + await dispatch( + comboFor("prio13440", "priority", nodeId, { fallbackDelayMs: 25 }), + nodeId, + 503, + calls + ); + assert.ok( + calls.some((c) => c.level === "debug" && /Waiting 25ms before fallback/.test(c.msg)), + `expected the provider-clock fallback wait; logs: ${JSON.stringify(calls.map((c) => c.msg))}` + ); +}); diff --git a/tests/unit/db-call-log-stats-3500.test.ts b/tests/unit/db-call-log-stats-3500.test.ts index b4d7b48ae0..907b9c9306 100644 --- a/tests/unit/db-call-log-stats-3500.test.ts +++ b/tests/unit/db-call-log-stats-3500.test.ts @@ -81,6 +81,21 @@ function insertCallLog(row: Record) { test.before(() => { core.resetDbInstance(); + // Search queries only surface providers with a live provider_connections + // row — seed connections for the search providers used below so their + // call_logs rows are not filtered out as deleted providers. + const now = new Date().toISOString(); + const db = core.getDbInstance(); + for (const [id, provider] of [ + ["conn-3500-brave", "brave"], + ["conn-3500-serper", "serper"], + ["conn-3500-bing", "bing"], + ["conn-3500-rare-provider", "rare_provider"], + ] as const) { + db.prepare( + `INSERT INTO provider_connections (id, provider, created_at, updated_at) VALUES (?, ?, ?, ?)` + ).run(id, provider, now, now); + } }); test.after(() => { @@ -295,12 +310,12 @@ test("#3500 getSearchProviderCounts — ordered by cnt desc", () => { if (rows.length >= 2) { assert.ok(rows[0].cnt >= rows[rows.length - 1].cnt, "ordered by cnt desc"); } - // bing (5 added) should beat rare_provider (2 added) if both appear + // bing (5 added) should beat rare_provider (2 added) const bing = rows.find((r) => r.provider === "bing"); const rare = rows.find((r) => r.provider === "rare_provider"); - if (bing && rare) { - assert.ok(bing.cnt > rare.cnt, "bing cnt > rare_provider cnt"); - } + assert.ok(bing, "bing row present"); + assert.ok(rare, "rare_provider row present"); + assert.ok(bing.cnt > rare.cnt, "bing cnt > rare_provider cnt"); }); // --------------------------------------------------------------------------- diff --git a/tests/unit/error-sensitive-redaction.test.ts b/tests/unit/error-sensitive-redaction.test.ts index eb2e7e2392..187f9fa414 100644 --- a/tests/unit/error-sensitive-redaction.test.ts +++ b/tests/unit/error-sensitive-redaction.test.ts @@ -103,6 +103,10 @@ test("sanitizeErrorMessage covers the canonical credential pattern catalog", () `key-${"a".repeat(32)}`, `M${"A".repeat(23)}.${"B".repeat(6)}.${"C".repeat(27)}`, "postgresql://db-user:db-password@db.internal.example/app", + // GHSA-r4q7-7f24-m29p — Groq and xAI keys had no catalog entry and no + // STRONG_CREDENTIAL_TOKEN fallback, so they reached error bodies verbatim. + `gsk_${"A".repeat(52)}`, + `xai-${"A".repeat(80)}`, ]; for (const credential of credentials) { diff --git a/tests/unit/estimated-usage-billing-guard.test.ts b/tests/unit/estimated-usage-billing-guard.test.ts new file mode 100644 index 0000000000..193843772c --- /dev/null +++ b/tests/unit/estimated-usage-billing-guard.test.ts @@ -0,0 +1,199 @@ +// Estimated token usage: billing stays exactly as it is, and the call log records that the +// counts were estimated. Drives the real handleChatCore (non-streaming and streaming) with a +// fetch stub, then reads the persisted call log and the API-key spend ledger. +import { after, before, test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-estimated-usage-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const callLogs = await import("../../src/lib/usage/callLogs.ts"); +const { getDailyTotal } = await import("../../src/domain/costRules.ts"); +const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts"); +const { extractUsage, filterUsageForFormat, isEstimatedUsage } = + await import("../../open-sse/utils/usageTracking.ts"); +const { extractUsageFromResponse } = await import("../../open-sse/handlers/usageExtractor.ts"); + +const originalFetch = globalThis.fetch; +const silentLog = { debug() {}, info() {}, warn() {}, error() {} }; +const MODEL = "gpt-4o-mini"; + +before(() => { + core.resetDbInstance(); +}); + +after(async () => { + globalThis.fetch = originalFetch; + await callLogs.closeCallLogSaves(5_000); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +const USAGE = { prompt_tokens: 1200, completion_tokens: 800, total_tokens: 2000 }; + +function jsonCompletion(usage: Record): Response { + return new Response( + JSON.stringify({ + id: "chatcmpl-estimated", + object: "chat.completion", + model: MODEL, + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + usage, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); +} + +function sseCompletion(events: unknown[]): Response { + const body = events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("") + "data: [DONE]\n\n"; + return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }); +} + +const textChunk = (content: string, finish: string | null = null) => ({ + id: "chatcmpl-estimated", + object: "chat.completion.chunk", + model: MODEL, + choices: [{ index: 0, delta: { content }, finish_reason: finish }], +}); + +async function runChat(apiKeyId: string, stream: boolean, response: () => Response) { + globalThis.fetch = (async () => response()) as typeof fetch; + const body = { model: MODEL, stream, messages: [{ role: "user", content: "hello there" }] }; + const result = (await handleChatCore({ + body, + modelInfo: { provider: "openai", model: MODEL, extendedContext: false }, + credentials: { apiKey: "sk-test-estimated" }, + clientRawRequest: { + endpoint: "/v1/chat/completions", + body, + headers: new Headers({ accept: stream ? "text/event-stream" : "application/json" }), + }, + apiKeyInfo: { id: apiKeyId, name: apiKeyId }, + userAgent: "unit-test", + isCombo: false, + log: silentLog, + } as unknown as Parameters[0])) as { response?: Response }; + const clientText = result.response ? await result.response.text() : ""; + return clientText; +} + +async function persistedLog(apiKeyId: string) { + const deadline = Date.now() + 15_000; + for (;;) { + await callLogs.waitForCallLogSaves(5_000); + const rows = (await callLogs.getCallLogs({})) as Array<{ id: string; apiKeyId: string }>; + const row = rows.find((r) => r.apiKeyId === apiKeyId); + if (row) return callLogs.getCallLogById(row.id); + if (Date.now() > deadline) throw new Error(`no call log for ${apiKeyId}`); + await new Promise((r) => setTimeout(r, 50)); + } +} + +async function spend(apiKeyId: string): Promise { + const deadline = Date.now() + 5_000; + let total = getDailyTotal(apiKeyId); + while (total === 0 && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 50)); + total = getDailyTotal(apiKeyId); + } + return total; +} + +function usageEstimatedMeta(entry: unknown): unknown { + const responseBody = (entry as { responseBody?: { _omniroute?: Record } }) + ?.responseBody; + return responseBody?._omniroute?.usageEstimated; +} + +test("extraction keeps an internal estimated marker that never serializes or spreads", () => { + const estimatedChunk = { choices: [], usage: { ...USAGE, estimated: true } }; + const reportedChunk = { choices: [], usage: { ...USAGE } }; + const estimated = extractUsage(estimatedChunk); + const reported = extractUsage(reportedChunk); + assert.equal(isEstimatedUsage(estimated), true); + assert.equal(isEstimatedUsage(reported), false); + assert.deepStrictEqual(estimated, reported, "token fields are untouched"); + assert.equal(JSON.stringify(estimated), JSON.stringify(reported)); + assert.equal(isEstimatedUsage({ ...estimated }), false, "spread copies never carry it"); + assert.equal(isEstimatedUsage(filterUsageForFormat(estimated, "openai")), false); + + const fromResponse = extractUsageFromResponse({ usage: { ...USAGE, estimated: true } }, "x"); + assert.equal(isEstimatedUsage(fromResponse), true); + assert.equal(JSON.stringify(fromResponse).includes("estimated"), false); + assert.equal(isEstimatedUsage(extractUsageFromResponse({ usage: { ...USAGE } }, "x")), false); +}); + +test("non-streaming estimated usage is still billed and is marked in the call log", async () => { + const clientText = await runChat("key-json-estimated", false, () => + jsonCompletion({ ...USAGE, estimated: true }) + ); + assert.ok((await spend("key-json-estimated")) > 0, "API-key spend still records the cost"); + const entry = await persistedLog("key-json-estimated"); + assert.equal(entry?.tokens?.in, USAGE.prompt_tokens); + assert.equal(entry?.tokens?.out, USAGE.completion_tokens); + assert.equal(usageEstimatedMeta(entry), true); + assert.doesNotMatch(clientText, /usageEstimated/); +}); + +test("non-streaming provider-reported usage carries no estimated marker", async () => { + await runChat("key-json-reported", false, () => jsonCompletion({ ...USAGE })); + assert.ok((await spend("key-json-reported")) > 0); + const entry = await persistedLog("key-json-reported"); + assert.equal(usageEstimatedMeta(entry), undefined); +}); + +test("a stream without upstream usage is billed on the estimate and marked in the call log", async () => { + const clientText = await runChat("key-sse-silent", true, () => + sseCompletion([textChunk("hello from the model"), textChunk("", "stop")]) + ); + assert.match(clientText, /hello from the model/); + assert.ok((await spend("key-sse-silent")) > 0, "API-key spend still records the estimate"); + const entry = await persistedLog("key-sse-silent"); + assert.ok((entry?.tokens?.out ?? 0) > 0); + assert.equal(usageEstimatedMeta(entry), true); + assert.doesNotMatch(clientText, /usageEstimated/); +}); + +test("a stream whose executor reports estimated usage is billed and marked in the call log", async () => { + const clientText = await runChat("key-sse-executor", true, () => + sseCompletion([ + textChunk("hello from the model"), + textChunk("", "stop"), + { + id: "chatcmpl-estimated", + object: "chat.completion.chunk", + model: MODEL, + choices: [], + usage: { ...USAGE, estimated: true }, + }, + ]) + ); + assert.ok((await spend("key-sse-executor")) > 0); + const entry = await persistedLog("key-sse-executor"); + assert.equal(entry?.tokens?.in, USAGE.prompt_tokens); + assert.equal(usageEstimatedMeta(entry), true); + assert.doesNotMatch(clientText, /usageEstimated/); +}); + +test("a stream with provider-reported usage carries no estimated marker", async () => { + await runChat("key-sse-reported", true, () => + sseCompletion([ + textChunk("hello from the model"), + textChunk("", "stop"), + { + id: "chatcmpl-estimated", + object: "chat.completion.chunk", + model: MODEL, + choices: [], + usage: { ...USAGE }, + }, + ]) + ); + const entry = await persistedLog("key-sse-reported"); + assert.equal(entry?.tokens?.in, USAGE.prompt_tokens); + assert.equal(usageEstimatedMeta(entry), undefined); +}); diff --git a/tests/unit/feature-flags-settings.test.ts b/tests/unit/feature-flags-settings.test.ts index 9f0184f41c..c521b3f793 100644 --- a/tests/unit/feature-flags-settings.test.ts +++ b/tests/unit/feature-flags-settings.test.ts @@ -39,7 +39,8 @@ const { // OMNIROUTE_DISABLE_THINKING_LEVEL_VARIANTS bumped it from 53 to 54; // the dead ONEPROXY_ENABLED (readerless since the 1proxy purge, #12091) // brought it back to 53. UNIVERSAL_CONTEXT_HANDOFF_ENABLED bumped it to 54. -const EXPECTED_FEATURE_FLAG_COUNT = 55; +// #13641 added SEARCH_STATS_HIDE_DELETED_CONNECTIONS, bumping the count to 56. +const EXPECTED_FEATURE_FLAG_COUNT = 69; // ────────────────────────────────────────────────────── // Test group 1 — Flag definitions registry @@ -150,6 +151,21 @@ describe("featureFlagDefinitions", () => { assert.strictEqual(early.requiresRestart, false); assert.strictEqual(early.warningLevel, "caution"); + const orderFix = FEATURE_FLAG_DEFINITIONS.find( + (d) => d.key === "STREAM_RECOVERY_TOOLCALL_ORDER_FIX" + ); + + assert.ok(orderFix, "STREAM_RECOVERY_TOOLCALL_ORDER_FIX should exist"); + assert.strictEqual(orderFix.category, "runtime"); + assert.strictEqual(orderFix.type, "boolean"); + assert.strictEqual(orderFix.defaultValue, "false"); + assert.strictEqual(orderFix.requiresRestart, false); + assert.strictEqual(orderFix.warningLevel, "info"); + assert.strictEqual( + orderFix.descriptionI18nKey, + "featureFlagStreamRecoveryToolcallOrderFixDescription" + ); + assert.ok(midstream, "STREAM_RECOVERY_MIDSTREAM_ENABLED should exist"); assert.strictEqual(midstream.category, "runtime"); assert.strictEqual(midstream.type, "boolean"); @@ -158,6 +174,22 @@ describe("featureFlagDefinitions", () => { assert.strictEqual(midstream.warningLevel, "danger"); }); + it("defines early-EOF sibling failover as a runtime boolean flag disabled by default", () => { + const def = FEATURE_FLAG_DEFINITIONS.find( + (d) => d.key === "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED" + ); + assert.ok(def, "STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED should exist"); + assert.strictEqual(def.category, "runtime"); + assert.strictEqual(def.type, "boolean"); + assert.strictEqual(def.defaultValue, "false"); + assert.strictEqual(def.requiresRestart, false); + assert.strictEqual(def.warningLevel, "info"); + assert.strictEqual( + def.descriptionI18nKey, + "featureFlagStreamEarlyEofSiblingFailoverEnabledDescription" + ); + }); + it("defines control-plane proxy direct fallback as a network boolean flag disabled by default", () => { const def = FEATURE_FLAG_DEFINITIONS.find( (d) => d.key === "OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK" @@ -170,6 +202,57 @@ describe("featureFlagDefinitions", () => { assert.strictEqual(def.warningLevel, "danger"); }); + it("defines OPENCODE_RESPONSES_STALL_ROTATION as an opt-in network boolean flag disabled by default", () => { + const def = FEATURE_FLAG_DEFINITIONS.find((d) => d.key === "OPENCODE_RESPONSES_STALL_ROTATION"); + assert.ok(def, "OPENCODE_RESPONSES_STALL_ROTATION should exist"); + assert.strictEqual(def.category, "network"); + assert.strictEqual(def.type, "boolean"); + assert.strictEqual(def.defaultValue, "false"); + assert.strictEqual(def.requiresRestart, false); + }); + + it("defines OPENCODE_USER_BLOCKED_ROTATION as an opt-in network boolean flag disabled by default", () => { + const def = FEATURE_FLAG_DEFINITIONS.find((d) => d.key === "OPENCODE_USER_BLOCKED_ROTATION"); + assert.ok(def, "OPENCODE_USER_BLOCKED_ROTATION should exist"); + assert.strictEqual(def.category, "network"); + assert.strictEqual(def.type, "boolean"); + assert.strictEqual(def.defaultValue, "false"); + assert.strictEqual(def.requiresRestart, false); + }); + + it("defines OPENCODE_TRANSIENT_FAILOVER_BACKOFF as an opt-in network boolean flag disabled by default", () => { + const def = FEATURE_FLAG_DEFINITIONS.find( + (d) => d.key === "OPENCODE_TRANSIENT_FAILOVER_BACKOFF" + ); + assert.ok(def, "OPENCODE_TRANSIENT_FAILOVER_BACKOFF should exist"); + assert.strictEqual(def.category, "network"); + assert.strictEqual(def.type, "boolean"); + assert.strictEqual(def.defaultValue, "false"); + assert.strictEqual(def.requiresRestart, false); + }); + + it("defines MISTRAL_AMBIGUOUS_401_SOFT_LOCKOUT as an opt-in runtime boolean flag disabled by default", () => { + const def = FEATURE_FLAG_DEFINITIONS.find( + (d) => d.key === "MISTRAL_AMBIGUOUS_401_SOFT_LOCKOUT" + ); + assert.ok(def, "MISTRAL_AMBIGUOUS_401_SOFT_LOCKOUT should exist"); + assert.strictEqual(def.category, "runtime"); + assert.strictEqual(def.type, "boolean"); + assert.strictEqual(def.defaultValue, "false"); + assert.strictEqual(def.requiresRestart, false); + }); + + it("defines OPENCODE_RATE_LIMITED_429_EARLY_STOP as an opt-in network boolean flag disabled by default", () => { + const def = FEATURE_FLAG_DEFINITIONS.find( + (d) => d.key === "OPENCODE_RATE_LIMITED_429_EARLY_STOP" + ); + assert.ok(def, "OPENCODE_RATE_LIMITED_429_EARLY_STOP should exist"); + assert.strictEqual(def.category, "network"); + assert.strictEqual(def.type, "boolean"); + assert.strictEqual(def.defaultValue, "false"); + assert.strictEqual(def.requiresRestart, false); + }); + it("defines network rotation shared-egress guard as a network boolean flag enabled by default", () => { const def = FEATURE_FLAG_DEFINITIONS.find( (d) => d.key === "NETWORK_ROTATION_SHARED_EGRESS_GUARD" @@ -182,6 +265,39 @@ describe("featureFlagDefinitions", () => { assert.strictEqual(def.warningLevel, "info"); }); + it("defines skip-recently-failed proxies as a network boolean flag disabled by default", () => { + // Guards the routing default: with this on, pools and account rotation skip a proxy + // that just failed. Selection order must stay the plain rotation unless opted in. + const def = FEATURE_FLAG_DEFINITIONS.find((d) => d.key === "PROXY_SKIP_RECENTLY_FAILED"); + assert.ok(def, "PROXY_SKIP_RECENTLY_FAILED should exist"); + assert.strictEqual(def.category, "network"); + assert.strictEqual(def.type, "boolean"); + assert.strictEqual(def.defaultValue, "false"); + assert.strictEqual(def.requiresRestart, false); + }); + + it("defines the pool egress observation as a network boolean flag disabled by default", () => { + // Guards the UI default: the read-only panel under a proxy pool stays hidden unless opted in. + const def = FEATURE_FLAG_DEFINITIONS.find((d) => d.key === "PROXY_POOL_EGRESS_OBSERVATION"); + assert.ok(def, "PROXY_POOL_EGRESS_OBSERVATION should exist"); + assert.strictEqual(def.category, "network"); + assert.strictEqual(def.type, "boolean"); + assert.strictEqual(def.defaultValue, "false"); + assert.strictEqual(def.requiresRestart, false); + }); + + it("defines blocked-resets-streak as a health boolean flag disabled by default", () => { + // Guards the #10654 default: a target-refused probe stays neutral unless opted in. + const def = FEATURE_FLAG_DEFINITIONS.find( + (d) => d.key === "PROXY_HEALTH_BLOCKED_RESETS_STREAK" + ); + assert.ok(def, "PROXY_HEALTH_BLOCKED_RESETS_STREAK should exist"); + assert.strictEqual(def.category, "health"); + assert.strictEqual(def.type, "boolean"); + assert.strictEqual(def.defaultValue, "false"); + assert.strictEqual(def.requiresRestart, false); + }); + it("defines remote audio provider nodes as a network boolean flag disabled by default", () => { // Guards the egress default: with this on, /v1/audio/* may reach a provider node // hosted outside localhost. It must never become an implicit default (cf. #3963). diff --git a/tests/unit/files-batches-ownership-2jm2-m3hp.test.ts b/tests/unit/files-batches-ownership-2jm2-m3hp.test.ts new file mode 100644 index 0000000000..c5f4d183ff --- /dev/null +++ b/tests/unit/files-batches-ownership-2jm2-m3hp.test.ts @@ -0,0 +1,490 @@ +/** + * GHSA-2jm2-mpx8-6523 + GHSA-m3hp-hq9g-fpmv — route-level regression guard for the + * `/api/v1/files` and `/api/v1/batches` ownership model. + * + * Both advisories share one root cause: `getApiKeyRequestScope` resolves three + * different callers to the SAME `{ apiKeyId: null, isSessionAuth: false }` shape — + * an anonymous request, a request presenting an invalid/rotated bearer, and (with + * `isSessionAuth: true`) the operator's dashboard session — and the routes then + * treated "no key" as "no restriction": + * + * - the list routes coerced `apiKeyId || undefined`, which the DB layer reads as + * "instance-wide" — every tenant's file and batch metadata to an anonymous + * caller (GHSA-m3hp); + * - the single-item routes short-circuited to ALLOW when the record's own + * `api_key_id` was null, so a null-owner file (dashboard upload, anonymous + * upload, batch output inheriting a null owner) was readable, downloadable and + * deletable by anybody, and a foreign key could run a batch over it (GHSA-2jm2). + * + * The fix is one shared 3-way rule (`canAccessOwnedRecord` in + * `_helpers/apiKeyScope.ts`): a dashboard session is the instance operator and may + * act on any record; an API key may act on its own records only; a null-owner + * record is unattributable and is denied to every non-session caller. The list + * routes apply the same explicit 3-way scope as `delete-completed` and fail closed + * with a `buildErrorBody()` 401 when the caller is neither a key nor a session. + * + * Modelled on tests/unit/batches-delete-completed-route-scope.test.ts: drives the + * REAL route handlers with REAL credentials (API keys via `createApiKey`, a dashboard + * session via a signed `auth_token` cookie). Self-isolating: DATA_DIR points at a + * fresh temp dir BEFORE any `@/lib/db/*` module loads, so this file never touches + * ~/.omniroute. + */ +import { describe, it, after } from "node:test"; +import assert from "node:assert"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { SignJWT } from "jose"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "ownership-2jm2-m3hp-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "ownership-2jm2-api-secret"; +process.env.JWT_SECRET = "ownership-2jm2-jwt-secret"; + +const { resetDbInstance } = await import("../../src/lib/db/core.ts"); +const { createApiKey } = await import("../../src/lib/db/apiKeys.ts"); +const { createFile, getFile, getFileContent } = await import("../../src/lib/db/files.ts"); +const { createBatch, getBatch } = await import("../../src/lib/db/batches.ts"); +const { canAccessOwnedRecord } = await import("../../src/app/api/v1/_helpers/apiKeyScope.ts"); +const filesRoute = await import("../../src/app/api/v1/files/route.ts"); +const fileByIdRoute = await import("../../src/app/api/v1/files/[id]/route.ts"); +const fileContentRoute = await import("../../src/app/api/v1/files/[id]/content/route.ts"); +const batchesRoute = await import("../../src/app/api/v1/batches/route.ts"); +const batchByIdRoute = await import("../../src/app/api/v1/batches/[id]/route.ts"); +const batchCancelRoute = await import("../../src/app/api/v1/batches/[id]/cancel/route.ts"); + +type Headers = Record; +type ErrorBody = { error?: { message: string; type?: string; code?: string } }; +type ListBody = ErrorBody & { object?: string; data?: Array<{ id: string }>; total_count?: number }; + +async function sessionCookie(): Promise { + const secret = new TextEncoder().encode(process.env.JWT_SECRET); + const jwt = await new SignJWT({ authenticated: true, sub: "admin" }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("1h") + .sign(secret); + return `auth_token=${jwt}`; +} + +function seedFile(apiKeyId: string | null, label: string) { + return createFile({ + bytes: label.length, + filename: `${label}.jsonl`, + purpose: "batch", + content: Buffer.from(label), + mimeType: "application/jsonl", + apiKeyId, + }); +} + +function seedBatch( + apiKeyId: string | null, + label: string, + status: "validating" | "completed" = "validating" +) { + const file = seedFile(apiKeyId, label); + const batch = createBatch({ + endpoint: "/v1/chat/completions", + completionWindow: "24h", + inputFileId: file.id, + status, + apiKeyId, + }); + return { file, batch }; +} + +const params = (id: string) => ({ params: Promise.resolve({ id }) }); + +async function listFilesVia(headers: Headers) { + const res = await filesRoute.GET( + new Request("http://localhost/api/v1/files?limit=100", { headers }) + ); + return { res, body: (await res.json()) as ListBody }; +} + +async function listBatchesVia(headers: Headers) { + const res = await batchesRoute.GET( + new Request("http://localhost/api/v1/batches?limit=100", { headers }) + ); + return { res, body: (await res.json()) as ListBody }; +} + +async function getFileVia(headers: Headers, id: string) { + return fileByIdRoute.GET( + new Request(`http://localhost/api/v1/files/${id}`, { headers }), + params(id) + ); +} + +async function getFileContentVia(headers: Headers, id: string) { + return fileContentRoute.GET( + new Request(`http://localhost/api/v1/files/${id}/content`, { headers }), + params(id) + ); +} + +async function deleteFileVia(headers: Headers, id: string) { + return fileByIdRoute.DELETE( + new Request(`http://localhost/api/v1/files/${id}`, { method: "DELETE", headers }), + params(id) + ); +} + +async function getBatchVia(headers: Headers, id: string) { + return batchByIdRoute.GET( + new Request(`http://localhost/api/v1/batches/${id}`, { headers }), + params(id) + ); +} + +async function deleteBatchVia(headers: Headers, id: string) { + return batchByIdRoute.DELETE( + new Request(`http://localhost/api/v1/batches/${id}`, { method: "DELETE", headers }), + params(id) + ); +} + +async function cancelBatchVia(headers: Headers, id: string) { + return batchCancelRoute.POST( + new Request(`http://localhost/api/v1/batches/${id}/cancel`, { method: "POST", headers }), + params(id) + ); +} + +async function createBatchVia(headers: Headers, inputFileId: string) { + const res = await batchesRoute.POST( + new Request("http://localhost/api/v1/batches", { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify({ + input_file_id: inputFileId, + endpoint: "/v1/chat/completions", + completion_window: "24h", + }), + }) + ); + return { res, body: (await res.json()) as ErrorBody & { id?: string } }; +} + +function assertAuthRequired401(res: Response, body: ErrorBody, label: string) { + assert.strictEqual(res.status, 401, `${label}: anonymous caller must be rejected`); + assert.strictEqual(body.error?.message, "Authentication required", label); + assert.strictEqual(body.error?.type, "authentication_error", `${label}: buildErrorBody() shape`); + assert.strictEqual(body.error?.code, "invalid_api_key", label); + assert.ok(!body.error?.message.includes("at /"), `${label}: stack trace leaked`); +} + +function assertInvalidKey401(res: Response, body: ErrorBody, label: string) { + assert.strictEqual(res.status, 401, `${label}: an unresolvable bearer must fail closed`); + assert.strictEqual(body.error?.message, "Invalid API key", label); + assert.strictEqual(body.error?.type, "authentication_error", `${label}: buildErrorBody() shape`); + assert.ok(!body.error?.message.includes("at /"), `${label}: stack trace leaked`); +} + +describe("canAccessOwnedRecord — the shared 3-way ownership rule", () => { + it("a dashboard session may act on any record, owned or not", () => { + assert.strictEqual( + canAccessOwnedRecord({ isSessionAuth: true, apiKeyId: null }, "key-1"), + true + ); + assert.strictEqual(canAccessOwnedRecord({ isSessionAuth: true, apiKeyId: "k" }, "key-1"), true); + assert.strictEqual(canAccessOwnedRecord({ isSessionAuth: true, apiKeyId: null }, null), true); + assert.strictEqual( + canAccessOwnedRecord({ isSessionAuth: true, apiKeyId: null }, undefined), + true + ); + }); + + it("a null-owner record is denied to every non-session caller — anonymous AND any key", () => { + assert.strictEqual(canAccessOwnedRecord({ isSessionAuth: false, apiKeyId: null }, null), false); + assert.strictEqual(canAccessOwnedRecord({ isSessionAuth: false, apiKeyId: "k" }, null), false); + assert.strictEqual( + canAccessOwnedRecord({ isSessionAuth: false, apiKeyId: null }, undefined), + false + ); + assert.strictEqual( + canAccessOwnedRecord({ isSessionAuth: false, apiKeyId: "k" }, undefined), + false + ); + }); + + it("a key may act on its own records only", () => { + assert.strictEqual(canAccessOwnedRecord({ isSessionAuth: false, apiKeyId: "k1" }, "k1"), true); + assert.strictEqual(canAccessOwnedRecord({ isSessionAuth: false, apiKeyId: "k2" }, "k1"), false); + assert.strictEqual(canAccessOwnedRecord({ isSessionAuth: false, apiKeyId: null }, "k1"), false); + }); +}); + +after(() => { + resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +describe("GET /api/v1/files + GET /api/v1/batches — caller scope (GHSA-m3hp-hq9g-fpmv)", () => { + it("(a) no credential at all → 401 on both lists, nothing enumerated", async () => { + const keyA = await createApiKey("m3hp-a-key", "machine-m3hp-a", []); + seedBatch(keyA.id, "m3hp-a-victim"); + + const files = await listFilesVia({}); + assertAuthRequired401(files.res, files.body, "GET /v1/files"); + assert.strictEqual(files.body.data, undefined, "no file rows in a 401 body"); + + const batches = await listBatchesVia({}); + assertAuthRequired401(batches.res, batches.body, "GET /v1/batches"); + assert.strictEqual(batches.body.data, undefined, "no batch rows in a 401 body"); + }); + + it("(b) an invalid/rotated bearer → 401 on both lists — even alongside a session cookie", async () => { + const keyA = await createApiKey("m3hp-b-key", "machine-m3hp-b", []); + seedBatch(keyA.id, "m3hp-b-victim"); + const bogus = { Authorization: "Bearer sk-omni-this-key-was-rotated-away-m3hp" }; + + const files = await listFilesVia(bogus); + assertInvalidKey401(files.res, files.body, "GET /v1/files"); + const batches = await listBatchesVia(bogus); + assertInvalidKey401(batches.res, batches.body, "GET /v1/batches"); + + const withSession = { ...bogus, cookie: await sessionCookie() }; + const files2 = await listFilesVia(withSession); + assertInvalidKey401(files2.res, files2.body, "GET /v1/files + session cookie"); + const batches2 = await listBatchesVia(withSession); + assertInvalidKey401(batches2.res, batches2.body, "GET /v1/batches + session cookie"); + }); + + it("(c) key A lists only A's rows — B's and null-owner rows never appear", async () => { + const keyA = await createApiKey("m3hp-c-key-a", "machine-m3hp-ca", []); + const keyB = await createApiKey("m3hp-c-key-b", "machine-m3hp-cb", []); + const own = seedBatch(keyA.id, "m3hp-c-own"); + const other = seedBatch(keyB.id, "m3hp-c-other"); + const unowned = seedBatch(null, "m3hp-c-unowned"); + const headers = { Authorization: `Bearer ${keyA.key}` }; + + const files = await listFilesVia(headers); + assert.strictEqual(files.res.status, 200); + const fileIds = new Set(files.body.data!.map((f) => f.id)); + assert.ok(fileIds.has(own.file.id), "key A sees its own file"); + assert.ok(!fileIds.has(other.file.id), "key B's file must not leak to key A"); + assert.ok(!fileIds.has(unowned.file.id), "the null-owner file must not leak to key A"); + assert.strictEqual(files.body.total_count, files.body.data!.length); + assert.ok(files.body.data!.every((f) => getFile(f.id)?.apiKeyId === keyA.id)); + + const batches = await listBatchesVia(headers); + assert.strictEqual(batches.res.status, 200); + const batchIds = new Set(batches.body.data!.map((b) => b.id)); + assert.ok(batchIds.has(own.batch.id), "key A sees its own batch"); + assert.ok(!batchIds.has(other.batch.id), "key B's batch must not leak to key A"); + assert.ok(!batchIds.has(unowned.batch.id), "the null-owner batch must not leak to key A"); + assert.strictEqual(batches.body.total_count, batches.body.data!.length); + assert.ok(batches.body.data!.every((b) => getBatch(b.id)?.apiKeyId === keyA.id)); + }); + + it("(d) a dashboard session WITHOUT a key lists the whole instance", async () => { + const keyA = await createApiKey("m3hp-d-key-a", "machine-m3hp-da", []); + const keyB = await createApiKey("m3hp-d-key-b", "machine-m3hp-db", []); + const a = seedBatch(keyA.id, "m3hp-d-a"); + const b = seedBatch(keyB.id, "m3hp-d-b"); + const unowned = seedBatch(null, "m3hp-d-unowned"); + const headers = { cookie: await sessionCookie() }; + + const files = await listFilesVia(headers); + assert.strictEqual(files.res.status, 200); + const fileIds = new Set(files.body.data!.map((f) => f.id)); + for (const f of [a.file, b.file, unowned.file]) { + assert.ok(fileIds.has(f.id), `session sees ${f.filename}`); + } + + const batches = await listBatchesVia(headers); + assert.strictEqual(batches.res.status, 200); + const batchIds = new Set(batches.body.data!.map((x) => x.id)); + for (const x of [a.batch, b.batch, unowned.batch]) { + assert.ok(batchIds.has(x.id), `session sees batch ${x.id}`); + } + }); + + it("(e) a request carrying BOTH a session cookie and key A stays scoped to key A (the key wins)", async () => { + const keyA = await createApiKey("m3hp-e-key-a", "machine-m3hp-ea", []); + const keyB = await createApiKey("m3hp-e-key-b", "machine-m3hp-eb", []); + const own = seedBatch(keyA.id, "m3hp-e-own"); + const other = seedBatch(keyB.id, "m3hp-e-other"); + const unowned = seedBatch(null, "m3hp-e-unowned"); + const headers = { Authorization: `Bearer ${keyA.key}`, cookie: await sessionCookie() }; + + const files = await listFilesVia(headers); + assert.strictEqual(files.res.status, 200); + const fileIds = new Set(files.body.data!.map((f) => f.id)); + assert.ok(fileIds.has(own.file.id)); + assert.ok(!fileIds.has(other.file.id), "a session cookie never widens a key's file list"); + assert.ok(!fileIds.has(unowned.file.id)); + + const batches = await listBatchesVia(headers); + assert.strictEqual(batches.res.status, 200); + const batchIds = new Set(batches.body.data!.map((b) => b.id)); + assert.ok(batchIds.has(own.batch.id)); + assert.ok(!batchIds.has(other.batch.id), "a session cookie never widens a key's batch list"); + assert.ok(!batchIds.has(unowned.batch.id)); + }); +}); + +describe("single-item routes — null-owner records (GHSA-2jm2-mpx8-6523)", () => { + it("(f) files: a null-owner file is 404 (metadata, content, delete) for a foreign key and for anonymous; 200 for a session", async () => { + const keyB = await createApiKey("2jm2-f-key-b", "machine-2jm2-fb", []); + const file = seedFile(null, "2jm2-f-null-owner"); + const foreign = { Authorization: `Bearer ${keyB.key}` }; + const anon = {}; + + for (const [label, headers] of [ + ["foreign key", foreign], + ["anonymous", anon], + ] as const) { + const meta = await getFileVia(headers, file.id); + assert.strictEqual(meta.status, 404, `${label}: GET /v1/files/{id} on a null-owner file`); + + const content = await getFileContentVia(headers, file.id); + assert.strictEqual(content.status, 404, `${label}: GET /v1/files/{id}/content`); + const contentBody = (await content.json()) as ErrorBody; + assert.strictEqual(contentBody.error?.message, "File not found", label); + + const del = await deleteFileVia(headers, file.id); + assert.strictEqual(del.status, 404, `${label}: DELETE /v1/files/{id}`); + assert.ok(getFile(file.id), `${label}: the null-owner file must survive`); + assert.strictEqual( + getFileContent(file.id)?.toString(), + "2jm2-f-null-owner", + `${label}: the null-owner file content must not be nulled` + ); + } + + const session = { cookie: await sessionCookie() }; + const meta = await getFileVia(session, file.id); + assert.strictEqual(meta.status, 200, "session: GET /v1/files/{id} on a null-owner file"); + const content = await getFileContentVia(session, file.id); + assert.strictEqual(content.status, 200, "session: GET /v1/files/{id}/content"); + assert.strictEqual(await content.text(), "2jm2-f-null-owner"); + const del = await deleteFileVia(session, file.id); + assert.strictEqual(del.status, 200, "session: DELETE /v1/files/{id}"); + assert.strictEqual(getFile(file.id), null, "session delete takes effect"); + }); + + it("(f) files: key-owned files keep the owner-only rule — owner 200, foreign key 404, anonymous 404, session 200", async () => { + const keyA = await createApiKey("2jm2-f2-key-a", "machine-2jm2-f2a", []); + const keyB = await createApiKey("2jm2-f2-key-b", "machine-2jm2-f2b", []); + const file = seedFile(keyA.id, "2jm2-f2-owned"); + + assert.strictEqual( + (await getFileVia({ Authorization: `Bearer ${keyA.key}` }, file.id)).status, + 200 + ); + assert.strictEqual( + (await getFileVia({ Authorization: `Bearer ${keyB.key}` }, file.id)).status, + 404 + ); + assert.strictEqual((await getFileVia({}, file.id)).status, 404); + assert.strictEqual((await getFileVia({ cookie: await sessionCookie() }, file.id)).status, 200); + assert.strictEqual( + (await getFileContentVia({ Authorization: `Bearer ${keyB.key}` }, file.id)).status, + 404 + ); + assert.strictEqual((await deleteFileVia({}, file.id)).status, 404); + assert.ok(getFile(file.id), "an anonymous delete on a key-owned file is a no-op"); + }); + + it("(f) batches: a null-owner batch is 404 (get, delete, cancel) for a foreign key and for anonymous; 200 for a session", async () => { + const keyB = await createApiKey("2jm2-fb-key-b", "machine-2jm2-fbb", []); + const terminal = seedBatch(null, "2jm2-fb-null-terminal", "completed"); + const live = seedBatch(null, "2jm2-fb-null-live", "validating"); + const foreign = { Authorization: `Bearer ${keyB.key}` }; + + for (const [label, headers] of [ + ["foreign key", foreign], + ["anonymous", {}], + ] as const) { + assert.strictEqual( + (await getBatchVia(headers, terminal.batch.id)).status, + 404, + `${label}: GET /v1/batches/{id} on a null-owner batch` + ); + assert.strictEqual( + (await deleteBatchVia(headers, terminal.batch.id)).status, + 404, + `${label}: DELETE /v1/batches/{id} on a null-owner batch` + ); + assert.ok(getBatch(terminal.batch.id), `${label}: the null-owner batch must survive`); + assert.ok(getFile(terminal.file.id), `${label}: its input file must survive`); + assert.strictEqual( + (await cancelBatchVia(headers, live.batch.id)).status, + 404, + `${label}: POST /v1/batches/{id}/cancel on a null-owner batch` + ); + assert.strictEqual(getBatch(live.batch.id)?.status, "validating", `${label}: not cancelled`); + } + + const session = { cookie: await sessionCookie() }; + assert.strictEqual((await getBatchVia(session, terminal.batch.id)).status, 200); + assert.strictEqual((await cancelBatchVia(session, live.batch.id)).status, 200); + assert.strictEqual( + getBatch(live.batch.id)?.status, + "cancelling", + "session cancel takes effect" + ); + assert.strictEqual((await deleteBatchVia(session, terminal.batch.id)).status, 200); + assert.strictEqual(getBatch(terminal.batch.id), null, "session delete takes effect"); + }); + + it("(g) POST /api/v1/batches: a foreign key or an anonymous caller cannot run a batch over a null-owner input file; the owner and a session can", async () => { + const keyA = await createApiKey("2jm2-g-key-a", "machine-2jm2-ga", []); + const keyB = await createApiKey("2jm2-g-key-b", "machine-2jm2-gb", []); + const unownedInput = seedFile(null, "2jm2-g-null-input"); + const ownedInput = seedFile(keyA.id, "2jm2-g-owned-input"); + + for (const [label, headers] of [ + ["foreign key", { Authorization: `Bearer ${keyB.key}` }], + ["anonymous", {}], + ] as const) { + const { res, body } = await createBatchVia(headers, unownedInput.id); + assert.strictEqual(res.status, 400, `${label}: batch over a null-owner input file`); + assert.strictEqual(body.error?.message, "Input file not found", label); + assert.strictEqual(body.id, undefined, `${label}: no batch created`); + } + + // Key B still cannot use key A's file (the pre-existing owner rule). + const foreignOwned = await createBatchVia( + { Authorization: `Bearer ${keyB.key}` }, + ownedInput.id + ); + assert.strictEqual(foreignOwned.res.status, 400, "key B over key A's input file"); + + // The owner can. + const owner = await createBatchVia({ Authorization: `Bearer ${keyA.key}` }, ownedInput.id); + assert.strictEqual(owner.res.status, 200, "key A over its own input file"); + assert.strictEqual(getBatch(owner.body.id!)?.apiKeyId, keyA.id); + + // The operator's session can — over the null-owner file AND over a key-owned one. + const session = { cookie: await sessionCookie() }; + const sessionUnowned = await createBatchVia(session, unownedInput.id); + assert.strictEqual(sessionUnowned.res.status, 200, "session over the null-owner input file"); + const sessionOwned = await createBatchVia(session, ownedInput.id); + assert.strictEqual(sessionOwned.res.status, 200, "session over key A's input file"); + }); + + it("(h) POST /api/v1/batches/{id}/cancel: a dashboard session cancels a KEY-owned batch (#13683); the owner can; a foreign key cannot", async () => { + const keyA = await createApiKey("2jm2-h-key-a", "machine-2jm2-ha", []); + const keyB = await createApiKey("2jm2-h-key-b", "machine-2jm2-hb", []); + const bySession = seedBatch(keyA.id, "2jm2-h-session", "validating"); + const byOwner = seedBatch(keyA.id, "2jm2-h-owner", "validating"); + + assert.strictEqual( + (await cancelBatchVia({ Authorization: `Bearer ${keyB.key}` }, bySession.batch.id)).status, + 404, + "a foreign key cannot cancel key A's batch" + ); + assert.strictEqual(getBatch(bySession.batch.id)?.status, "validating"); + + const session = await cancelBatchVia({ cookie: await sessionCookie() }, bySession.batch.id); + assert.strictEqual(session.status, 200, "the operator's dashboard cancels any batch"); + assert.strictEqual(getBatch(bySession.batch.id)?.status, "cancelling"); + + const owner = await cancelBatchVia({ Authorization: `Bearer ${keyA.key}` }, byOwner.batch.id); + assert.strictEqual(owner.status, 200, "the owning key cancels its own batch"); + assert.strictEqual(getBatch(byOwner.batch.id)?.status, "cancelling"); + }); +}); diff --git a/tests/unit/first-byte-watchdog.test.ts b/tests/unit/first-byte-watchdog.test.ts new file mode 100644 index 0000000000..fef780ffbe --- /dev/null +++ b/tests/unit/first-byte-watchdog.test.ts @@ -0,0 +1,143 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + RESPONSES_FIRST_BYTE_TIMEOUT_CODE, + guardFirstByte, + guardResponsesStreamFirstByte, + isResponsesFirstByteTimeout, +} from "../../open-sse/utils/firstByteWatchdog.ts"; +import { + DEFAULT_RESPONSES_FIRST_BYTE_TIMEOUT_MS, + getResponsesFirstByteTimeoutMs, +} from "../../src/shared/utils/runtimeTimeouts.ts"; + +const encoder = new TextEncoder(); + +function neverYieldingBody(): ReadableStream { + return new ReadableStream({ + pull() { + // Never enqueue, never close. + }, + }); +} + +function chunksBody(chunks: string[]): ReadableStream { + let i = 0; + return new ReadableStream({ + pull(controller) { + if (i < chunks.length) controller.enqueue(encoder.encode(chunks[i++])); + else controller.close(); + }, + }); +} + +const OPTIONS = { + code: "TEST_CODE", + message: (ms: number) => `no first byte within ${ms}ms`, +}; + +test("a healthy body passes through byte for byte", { timeout: 5000 }, async () => { + const response = new Response(chunksBody(["data: a\n\n", "data: b\n\n"]), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + const guarded = await guardFirstByte(response, { + ...OPTIONS, + timeoutMs: 200, + }); + assert.equal(guarded.status, 200); + assert.equal(guarded.headers.get("content-type"), "text/event-stream"); + assert.equal(await guarded.text(), "data: a\n\ndata: b\n\n"); +}); + +test( + "a body that never yields throws a TimeoutError carrying the caller code", + { timeout: 5000 }, + async () => { + const response = new Response(neverYieldingBody(), { status: 200 }); + const started = Date.now(); + await assert.rejects( + guardFirstByte(response, { ...OPTIONS, timeoutMs: 60 }), + (err: unknown) => { + assert.ok(err instanceof Error); + assert.equal(err.name, "TimeoutError"); + assert.equal((err as Error & { code?: string }).code, "TEST_CODE"); + assert.equal(err.message, "no first byte within 60ms"); + return true; + } + ); + assert.ok(Date.now() - started < 1000); + } +); + +test("timeoutMs <= 0 returns the same response untouched", async () => { + const response = new Response(neverYieldingBody(), { status: 200 }); + assert.equal(await guardFirstByte(response, { ...OPTIONS, timeoutMs: 0 }), response); +}); + +test("an immediately closed body passes", { timeout: 5000 }, async () => { + const response = new Response(chunksBody([]), { status: 200 }); + const guarded = await guardFirstByte(response, { + ...OPTIONS, + timeoutMs: 200, + }); + assert.equal(await guarded.text(), ""); +}); + +test("the passthrough reads upstream on demand, not eagerly", { timeout: 5000 }, async () => { + let upstreamPulls = 0; + const infinite = new ReadableStream({ + pull(controller) { + upstreamPulls++; + controller.enqueue(encoder.encode("x")); + }, + }); + const guarded = await guardFirstByte(new Response(infinite, { status: 200 }), { + ...OPTIONS, + timeoutMs: 200, + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.ok(upstreamPulls < 10, `upstream pulled ${upstreamPulls} times without a reader`); + await guarded.body?.cancel(); +}); + +test("an aborted signal rejects without waiting for the timer", { timeout: 5000 }, async () => { + const controller = new AbortController(); + const response = new Response(neverYieldingBody(), { status: 200 }); + const started = Date.now(); + const pending = guardFirstByte(response, { + ...OPTIONS, + timeoutMs: 10_000, + signal: controller.signal, + }); + setTimeout(() => controller.abort(), 20); + await assert.rejects(pending, (err: unknown) => { + assert.ok(err instanceof Error); + assert.equal(err.name, "AbortError"); + return true; + }); + assert.ok(Date.now() - started < 1000); +}); + +test( + "the Responses helper tags its timeout so callers can recognise it", + { timeout: 5000 }, + async () => { + const response = new Response(neverYieldingBody(), { status: 200 }); + await assert.rejects(guardResponsesStreamFirstByte(response, 40), (err: unknown) => { + assert.equal((err as Error & { code?: string }).code, RESPONSES_FIRST_BYTE_TIMEOUT_CODE); + assert.equal(isResponsesFirstByteTimeout(err), true); + return true; + }); + assert.equal(isResponsesFirstByteTimeout(new Error("fetch failed")), false); + } +); + +test("Responses first-byte timeout defaults to 15s, accepts 0, ignores garbage", () => { + assert.equal(DEFAULT_RESPONSES_FIRST_BYTE_TIMEOUT_MS, 15_000); + assert.equal(getResponsesFirstByteTimeoutMs({}), 15_000); + assert.equal(getResponsesFirstByteTimeoutMs({ RESPONSES_FIRST_BYTE_TIMEOUT_MS: "0" }), 0); + assert.equal(getResponsesFirstByteTimeoutMs({ RESPONSES_FIRST_BYTE_TIMEOUT_MS: "250" }), 250); + assert.equal(getResponsesFirstByteTimeoutMs({ RESPONSES_FIRST_BYTE_TIMEOUT_MS: "abc" }), 15_000); +}); diff --git a/tests/unit/free-badge-provider-gate.test.ts b/tests/unit/free-badge-provider-gate.test.ts new file mode 100644 index 0000000000..34c1fdc0d5 --- /dev/null +++ b/tests/unit/free-badge-provider-gate.test.ts @@ -0,0 +1,211 @@ +/** + * Provider-page "Free" badge (#13645). + * + * A. Flag off (default): `isModelFreeBadge` is the historical dashboard rule — every badge + * the dashboard showed before still shows (compatible nodes with `:free`, name-labelled + * models, truthy `free` fields). + * B. Flag on (FREE_BADGE_REQUIRES_PROVIDER_FREE_TIER): only badges that cannot be right are + * removed; `:free` stays on free-tier providers and on compatible/custom nodes. + * C. Catalog cross-check, derived from FREE_MODEL_BUDGETS (no hand-kept allowlist): every + * live catalogued free model keeps its badge under both rules; retired-only entries do + * not get one from the catalog alone. + * D. Auth bypass lock: the `credits_exhausted` exemption for free models in + * `src/sse/services/auth.ts` stays scoped to openrouter + free models. + */ +import { describe, it, test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + FREE_BADGE_STRICT_FLAG, + isModelFreeBadge, + providerHasFreeModels, +} from "../../src/shared/utils/freeModels.ts"; +import { FREE_MODEL_BUDGETS, grantsFreeAccess } from "../../open-sse/config/freeModelCatalog.ts"; +import { getProviderById } from "../../src/shared/constants/providers.ts"; +import { FEATURE_FLAG_DEFINITIONS } from "../../src/shared/constants/featureFlagDefinitions.ts"; + +const PAID_REGISTERED = "openai"; // registered provider, no documented free tier +const FREE_TIER = "openrouter"; // documented free tier, implements `:free` +const COMPATIBLE_NODE = "openai-compatible-chat-7f3a"; // custom node, upstream unknown + +const LIVE = FREE_MODEL_BUDGETS.filter((m) => grantsFreeAccess(m.freeType)); +const liveIds = new Set(LIVE.map((m) => `${m.provider}/${m.modelId}`)); + +describe("fixtures", () => { + it("the providers used below have the properties the cases rely on", () => { + assert.ok(getProviderById(PAID_REGISTERED), `${PAID_REGISTERED} is registered`); + assert.equal(providerHasFreeModels(PAID_REGISTERED), false); + assert.ok(getProviderById(FREE_TIER), `${FREE_TIER} is registered`); + assert.equal(providerHasFreeModels(FREE_TIER), true); + assert.equal(getProviderById(COMPATIBLE_NODE), undefined); + assert.equal(providerHasFreeModels(COMPATIBLE_NODE), false); + }); + + it("the strict rule ships as an opt-in flag", () => { + const def = FEATURE_FLAG_DEFINITIONS.find((d) => d.key === FREE_BADGE_STRICT_FLAG); + assert.ok(def, "flag defined"); + assert.equal(def.defaultValue, "false"); + assert.equal(def.type, "boolean"); + }); +}); + +describe("A. flag off: historical badge rule unchanged", () => { + const legacy = (provider: string, model: Parameters[1]) => + isModelFreeBadge(provider, model); + + it("keeps the badge on compatible nodes pointing at :free models", () => { + assert.equal(legacy(COMPATIBLE_NODE, { id: "meta-llama/llama-3.3-70b:free" }), true); + }); + + it("keeps name-labelled and truthy-field badges", () => { + assert.equal(legacy(PAID_REGISTERED, { id: "chat-x", name: "Chat X (Free)" }), true); + assert.equal(legacy(PAID_REGISTERED, { id: "chat-y", name: "Modelo grátis" }), true); + assert.equal(legacy(PAID_REGISTERED, { id: "chat-z", free: "yes" }), true); + assert.equal(legacy(PAID_REGISTERED, { id: "gpt-9:free" }), true); + }); + + it("does not badge a plain paid model", () => { + assert.equal(legacy(PAID_REGISTERED, { id: "gpt-9", name: "GPT 9" }), false); + assert.equal(legacy(PAID_REGISTERED, { id: "gpt-9", name: "Freeform writer" }), false); + }); +}); + +describe("B. flag on: only provably wrong badges are removed", () => { + const strict = (provider: string, model: Parameters[1]) => + isModelFreeBadge(provider, model, { strict: true }); + + it("keeps :free on compatible nodes and on free-tier providers", () => { + assert.equal(strict(COMPATIBLE_NODE, { id: "meta-llama/llama-3.3-70b:free" }), true); + assert.equal(strict(FREE_TIER, { id: "meta-llama/llama-3.3-70b:free" }), true); + }); + + it("keeps explicit boolean free signals on any provider", () => { + assert.equal(strict(PAID_REGISTERED, { id: "promo", isFree: true }), true); + assert.equal(strict(PAID_REGISTERED, { id: "promo", free: true }), true); + assert.equal(strict(COMPATIBLE_NODE, { id: "local-model", free: true }), true); + }); + + it("drops the name heuristic, non-boolean free fields and :free on paid registered providers", () => { + assert.equal(strict(PAID_REGISTERED, { id: "chat-x", name: "Chat X (Free)" }), false); + assert.equal(strict(PAID_REGISTERED, { id: "chat-z", free: "false" }), false); + assert.equal(strict(PAID_REGISTERED, { id: "gpt-9:free" }), false); + assert.equal(strict(COMPATIBLE_NODE, { id: "chat-x", name: "Free chat" }), false); + }); + + it("never adds a badge the historical rule did not show", () => { + const cases: Array<[string, Parameters[1]]> = [ + [PAID_REGISTERED, { id: "gpt-9" }], + [PAID_REGISTERED, { id: "gpt-9", isFree: "true" }], + [COMPATIBLE_NODE, { id: "x", free: 0 }], + [FREE_TIER, { id: "paid/model", name: "paid" }], + ...LIVE.slice(0, 20).map((m) => [m.provider, { id: m.modelId }] as [string, { id: string }]), + ]; + for (const [provider, model] of cases) { + if (isModelFreeBadge(provider, model, { strict: true })) { + assert.equal(isModelFreeBadge(provider, model), true, `${provider}/${model.id}`); + } + } + }); +}); + +describe("C. catalog cross-check (derived from FREE_MODEL_BUDGETS)", () => { + it("every live catalogued free model keeps its badge under both rules", () => { + assert.ok(LIVE.length > 0, "catalog has live free entries"); + for (const entry of LIVE) { + const model = { id: entry.modelId }; + assert.equal( + isModelFreeBadge(entry.provider, model), + true, + `${entry.provider}/${entry.modelId}` + ); + assert.equal( + isModelFreeBadge(entry.provider, model, { strict: true }), + true, + `strict ${entry.provider}/${entry.modelId}` + ); + } + }); + + it("a retired-only catalog entry earns no badge from the catalog itself", () => { + const retiredOnly = FREE_MODEL_BUDGETS.filter( + (m) => + !grantsFreeAccess(m.freeType) && + !liveIds.has(`${m.provider}/${m.modelId}`) && + !m.modelId.endsWith(":free") && + !/\bgr[aá]tis\b|\bfree\b/i.test(m.modelId) + ); + assert.ok(retiredOnly.length > 0, "catalog has retired-only entries"); + for (const entry of retiredOnly) { + assert.equal( + isModelFreeBadge(entry.provider, { id: entry.modelId }, { strict: true }), + false, + `${entry.provider}/${entry.modelId}` + ); + } + }); +}); + +describe("D. auth bypass lock", () => { + const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-free-badge-gate-")); + process.env.DATA_DIR = TEST_DATA_DIR; + + let core: typeof import("../../src/lib/db/core.ts"); + let providersDb: typeof import("../../src/lib/db/providers.ts"); + let auth: typeof import("../../src/sse/services/auth.ts"); + + test.before(async () => { + core = await import("../../src/lib/db/core.ts"); + providersDb = await import("../../src/lib/db/providers.ts"); + auth = await import("../../src/sse/services/auth.ts"); + }); + + test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + }); + + async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + } + + test("catalogued free id without :free suffix is still served on credits_exhausted", async () => { + await resetStorage(); + const live = LIVE.find((m) => m.provider === "openrouter" && !m.modelId.endsWith(":free")); + assert.ok(live, "openrouter must have a live catalogued id without :free suffix"); + await providersDb.createProviderConnection({ + provider: "openrouter", + authType: "apikey", + apiKey: "sk-or-exhausted-catalog", + isActive: true, + testStatus: "credits_exhausted", + }); + const selected = await auth.getProviderCredentials("openrouter", null, null, live.modelId); + assert.ok(selected && "connectionId" in selected, "catalogued free id must bypass the lock"); + }); + + test("expired status still refuses a :free model", async () => { + await resetStorage(); + await providersDb.createProviderConnection({ + provider: "openrouter", + authType: "apikey", + apiKey: "sk-or-expired", + isActive: true, + testStatus: "expired", + }); + const selected = await auth.getProviderCredentials( + "openrouter", + null, + null, + "meta-llama/llama-3.1-8b-instruct:free" + ); + assert.deepEqual(selected, { + allExpired: true, + expiredCount: 1, + expiredStatus: "expired", + }); + }); +}); diff --git a/tests/unit/guardrails/vision-bridge-claude-wire.test.ts b/tests/unit/guardrails/vision-bridge-claude-wire.test.ts index 7cba4f4044..2a7b4179e3 100644 --- a/tests/unit/guardrails/vision-bridge-claude-wire.test.ts +++ b/tests/unit/guardrails/vision-bridge-claude-wire.test.ts @@ -5,11 +5,28 @@ */ import test from "node:test"; import assert from "node:assert/strict"; +import dns from "node:dns"; -const { - isClaudeWireFormatModel, - ensureBase64ImagesForClaudeWire, -} = await import("../../../src/lib/guardrails/visionBridgeHelpers.ts"); +// Stub DNS for fetchRemoteImage's GHSA-cmhj-wh2f-9cgx DNS-rebinding guard +// (assertHostnameResolvesPublic in src/shared/network/remoteImageFetch.ts). +// Since GHSA-34rg-3pqj-35g9 the vision bridge pins `guard: "public-only"`, so every +// remote image hostname is resolved before the injected fetch is reached; the +// example.com hosts below must not depend on real DNS in CI. Node --test runs each +// file in its own process, so this rebinding does not leak across files. +const originalDnsLookup = dns.promises.lookup; +(dns.promises as { lookup: unknown }).lookup = (async ( + _hostname: string, + options?: { all?: boolean } +) => { + const record = { address: "203.0.113.1", family: 4 }; + return options && options.all ? [record] : record; +}) as typeof dns.promises.lookup; +process.on("exit", () => { + (dns.promises as { lookup: unknown }).lookup = originalDnsLookup; +}); + +const { isClaudeWireFormatModel, ensureBase64ImagesForClaudeWire } = + await import("../../../src/lib/guardrails/visionBridgeHelpers.ts"); test("isClaudeWireFormatModel: true for anthropic and claude-format registry providers", () => { assert.strictEqual(isClaudeWireFormatModel("anthropic/claude-sonnet-4"), true); @@ -61,7 +78,8 @@ test("ensureBase64ImagesForClaudeWire: keeps data-URI images as-is", async () => }); test("ensureBase64ImagesForClaudeWire: resolves remote URLs to base64 for claude-wire targets", async () => { - const pngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; + const pngBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; const originalFetch = globalThis.fetch; globalThis.fetch = async () => new Response(new Uint8Array(Buffer.from(pngBase64, "base64")), { @@ -127,3 +145,62 @@ test("ensureBase64ImagesForClaudeWire: fail-open when the remote fetch fails", a globalThis.fetch = originalFetch; } }); + +// GHSA-34rg-3pqj-35g9 — the vision bridge inlines a user-supplied `image_url` to base64 for +// claude-wire targets (`visionBridge.ts` reroute) and for the Anthropic describe self-call. +// `fetchRemoteImageAsDataUri()` called `fetchRemoteImage()` with only `{ signal, fetchImpl }`, +// so the URL was validated under the OPERATOR outbound policy (`block-metadata` on a +// default install: loopback/LAN allowed, DNS check skipped) instead of `public-only`. The +// helper is fail-open, so the observable contract is: the injected fetch is NEVER invoked +// for a private host and the part is left untouched (not inlined). +for (const privateUrl of ["http://127.0.0.1:1/x.png", "http://192.168.1.50/x.png"]) { + test(`ensureBase64ImagesForClaudeWire: never fetches a private image_url (${privateUrl}) (GHSA-34rg-3pqj-35g9)`, async () => { + const fetchedUrls: string[] = []; + const body = { + model: "zai/glm-5", + messages: [ + { + role: "user", + content: [{ type: "image_url", image_url: { url: privateUrl } }], + }, + ], + }; + + const out = await ensureBase64ImagesForClaudeWire(body, "zai/glm-5", async (input) => { + fetchedUrls.push(String(input)); + // Canary: on the vulnerable code these bytes are inlined into the rerouted body. + return new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { + status: 200, + headers: { "content-type": "image/png" }, + }); + }); + + assert.deepStrictEqual(fetchedUrls, [], "the private URL must never be fetched"); + const part = out.messages[0].content[0]; + assert.strictEqual(part.image_url.url, privateUrl, "part must be left untouched (fail-open)"); + }); +} + +test("ensureBase64ImagesForClaudeWire: still inlines a public image_url whose DNS resolves to a public IP (GHSA-34rg-3pqj-35g9)", async () => { + // The module-level DNS stub answers a public IP, so the `public-only` rebinding guard + // passes and the injected fetch is reached. + const fetchedUrls: string[] = []; + const body = { + model: "zai/glm-5", + messages: [ + { + role: "user", + content: [{ type: "image_url", image_url: { url: "https://cdn.example.com/public.png" } }], + }, + ], + }; + const out = await ensureBase64ImagesForClaudeWire(body, "zai/glm-5", async (input) => { + fetchedUrls.push(String(input)); + return new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { + status: 200, + headers: { "content-type": "image/png" }, + }); + }); + assert.deepStrictEqual(fetchedUrls, ["https://cdn.example.com/public.png"]); + assert.ok(out.messages[0].content[0].image_url.url.startsWith("data:image/png;base64,")); +}); diff --git a/tests/unit/guardrails/visionBridgeHelpers.callVisionModel.test.ts b/tests/unit/guardrails/visionBridgeHelpers.callVisionModel.test.ts index 0303a9a885..a4baa63a3f 100644 --- a/tests/unit/guardrails/visionBridgeHelpers.callVisionModel.test.ts +++ b/tests/unit/guardrails/visionBridgeHelpers.callVisionModel.test.ts @@ -417,3 +417,46 @@ test("callVisionModel propagates an external abort to fetch and stops before fal globalThis.fetch = originalFetch; } }); + +// GHSA-34rg-3pqj-35g9 — the Anthropic describe self-call inlines the user's image URL to +// base64 through the same `fetchRemoteImageAsDataUri()` sink as the claude-wire reroute. +// The DNS stub at the top of this file answers a public IP for every hostname, so only the +// `public-only` string check stands between the request body and a loopback/RFC-1918 fetch. +for (const privateUrl of ["http://127.0.0.1:1/x.png", "http://192.168.1.50/x.png"]) { + test(`callVisionModel never fetches a private image URL (${privateUrl}) for the Anthropic describe path (GHSA-34rg-3pqj-35g9)`, async () => { + const fetchedUrls: string[] = []; + const fetchImpl: typeof fetch = async (url) => { + const requestUrl = String(url); + fetchedUrls.push(requestUrl); + if (requestUrl === privateUrl) { + // Canary: on the vulnerable code these bytes are inlined into the Anthropic body. + return new Response(Buffer.from("intranet-bytes"), { + status: 200, + headers: { "Content-Type": "image/png" }, + }); + } + return new Response(JSON.stringify({ content: [{ type: "text", text: "described" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + const config: VisionModelConfig = { + model: "anthropic/claude-3-haiku", + prompt: "Describe this image", + timeoutMs: 30000, + maxImages: 10, + fetchImpl, + }; + + await assert.rejects( + () => callVisionModel(privateUrl, config, "sk-ant", { maxFallbackAttempts: 1 }), + /blocked/i + ); + assert.deepStrictEqual( + fetchedUrls, + [], + "neither the private download nor the self-call may happen" + ); + }); +} diff --git a/tests/unit/i18n-catalogs-no-duplicate-keys.test.ts b/tests/unit/i18n-catalogs-no-duplicate-keys.test.ts new file mode 100644 index 0000000000..2b963454d3 --- /dev/null +++ b/tests/unit/i18n-catalogs-no-duplicate-keys.test.ts @@ -0,0 +1,123 @@ +/** + * Regression guard: no i18n catalog (dashboard `src/i18n/messages/*.json`, CLI + * `bin/cli/locales/*.json`) may declare the same key twice inside one object. + * + * `JSON.parse` silently keeps the LAST copy, so a duplicated key never fails a + * parse, a coverage gate, prettier or a focused test — it only shows up as a + * translation that "does not update" or as a diff that grows on every merge. + * The 2026-09-15 batch merges left `featureFlagProxySkipRecentlyFailedDescription` + * twice in 59 locales (a scripted keep-both conflict resolution concatenated the + * key both PRs carried). This test reads the raw text, not the parsed object. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); +const CATALOG_DIRS = ["src/i18n/messages", "bin/cli/locales"]; + +interface ObjectFrame { + type: "obj"; + keys: Set; + expectKey: boolean; + lastKey: string; + path: string[]; +} +interface ArrayFrame { + type: "arr"; + path: string[]; +} +type Frame = ObjectFrame | ArrayFrame; + +/** Returns the dotted paths of every key declared more than once in its object. */ +export function findDuplicateKeys(text: string): string[] { + const dups: string[] = []; + const stack: Frame[] = []; + let i = 0; + const n = text.length; + + const readString = (): string => { + // text[i] is the opening quote; escapes are kept raw — identity only matters here + let j = i + 1; + let out = ""; + while (j < n) { + const c = text[j]; + if (c === "\\") { + out += c + text[j + 1]; + j += 2; + continue; + } + if (c === '"') break; + out += c; + j++; + } + i = j + 1; + return out; + }; + + const childPath = (): string[] => { + const parent = stack[stack.length - 1]; + if (!parent) return []; + return parent.type === "obj" ? [...parent.path, parent.lastKey] : [...parent.path, "[]"]; + }; + + while (i < n) { + const c = text[i]; + if (c === '"') { + const s = readString(); + const top = stack[stack.length - 1]; + if (top && top.type === "obj" && top.expectKey) { + if (top.keys.has(s)) dups.push([...top.path, s].join(".")); + top.keys.add(s); + top.expectKey = false; + top.lastKey = s; + } + continue; + } + if (c === "{") { + stack.push({ type: "obj", keys: new Set(), expectKey: true, lastKey: "", path: childPath() }); + } else if (c === "[") { + stack.push({ type: "arr", path: childPath() }); + } else if (c === "}" || c === "]") { + stack.pop(); + } else if (c === ",") { + const top = stack[stack.length - 1]; + if (top && top.type === "obj") top.expectKey = true; + } + i++; // colons, whitespace and primitive values carry no key information + } + return dups; +} + +test("findDuplicateKeys reports repeated keys per object and nothing else", () => { + assert.deepEqual(findDuplicateKeys('{"a": 1, "a": 2}'), ["a"]); + assert.deepEqual( + findDuplicateKeys('{"a": {"x": 1}, "b": {"x": 1}, "c": ["x", "x"], "d": "a"}'), + [] + ); + assert.deepEqual(findDuplicateKeys('{"a": {"x": 1, "x": 2}}'), ["a.x"]); + assert.deepEqual(findDuplicateKeys('{"k": "v", "k": "v"}'), ["k"]); + assert.deepEqual(findDuplicateKeys('{"esc\\"aped": 1, "esc\\"aped": 2}'), ['esc\\"aped']); + assert.deepEqual(findDuplicateKeys('{"n": [{"a": 1}, {"a": 1}]}'), []); +}); + +for (const dir of CATALOG_DIRS) { + test(`${dir}: no catalog declares the same key twice in one object`, () => { + const files = readdirSync(path.join(ROOT, dir)) + .filter((f) => f.endsWith(".json")) + .sort(); + assert.ok(files.length > 0, `no catalogs found under ${dir}`); + const offenders: string[] = []; + for (const f of files) { + const dups = [...new Set(findDuplicateKeys(readFileSync(path.join(ROOT, dir, f), "utf8")))]; + if (dups.length > 0) offenders.push(`${f}: ${dups.join(", ")}`); + } + assert.deepEqual( + offenders, + [], + `duplicated keys — JSON.parse keeps only the last copy:\n${offenders.join("\n")}` + ); + }); +} diff --git a/tests/unit/image-generation-handler.test.ts b/tests/unit/image-generation-handler.test.ts index 9842b956cd..4092f3e807 100644 --- a/tests/unit/image-generation-handler.test.ts +++ b/tests/unit/image-generation-handler.test.ts @@ -2130,3 +2130,105 @@ test("handleImageGeneration (codex) does not mark an ordinary 400 as retryable", globalThis.fetch = originalFetch; } }); + +// GHSA-34rg-3pqj-35g9 — caller-supplied image URLs (`image_url` / `mask_url` / message +// parts) reach `fetchRemoteImage()` through `resolveImageSource()`. Without an explicit +// `guard`, the library falls back to `getProviderOutboundGuard()` — the OPERATOR outbound +// policy, which is `block-metadata` on a default install (LAN/loopback allowed, DNS +// rebinding check skipped) — so a request body could make the server fetch intranet +// URLs and forward the bytes upstream. Caller input must be pinned to `public-only` +// regardless of the operator policy. The DNS stub at the top of this file resolves every +// hostname to a public IP, so the string check is the only thing standing between the +// request body and the loopback/RFC-1918 fetch. +for (const privateUrl of ["http://127.0.0.1:1/x.png", "http://192.168.1.50/x.png"]) { + test(`handleImageGeneration rejects a private image_url (${privateUrl}) before any fetch (GHSA-34rg-3pqj-35g9)`, async () => { + const originalFetch = globalThis.fetch; + const fetchedUrls = []; + + globalThis.fetch = async (url) => { + const stringUrl = String(url); + fetchedUrls.push(stringUrl); + if (stringUrl === privateUrl) { + // Canary: on the vulnerable code the sink downloads these bytes and + // forwards them to Stability as the multipart `image` part. + return new Response(new Uint8Array([4, 5]), { + status: 200, + headers: { "content-type": "image/png" }, + }); + } + return new Response(JSON.stringify({ image: "c3RhYmlsaXR5LWltYWdl" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + try { + const result = await handleImageGeneration({ + body: { + model: "stability-ai/inpaint", + prompt: "replace the sky with aurora", + image_url: privateUrl, + mask: "data:image/png;base64,AA==", + response_format: "b64_json", + }, + credentials: { apiKey: "stability-key" }, + log: null, + }); + + assert.equal(result.success, false); + assert.match(String(result.error), /blocked/i); + assert.deepEqual( + fetchedUrls, + [], + "neither the private image download nor the upstream call may happen" + ); + } finally { + globalThis.fetch = originalFetch; + } + }); +} + +test("handleImageGeneration still downloads a public image_url whose DNS resolves to a public IP (GHSA-34rg-3pqj-35g9)", async () => { + const originalFetch = globalThis.fetch; + const fetchedUrls = []; + let requestCapture; + + globalThis.fetch = async (url, options = {}) => { + const stringUrl = String(url); + fetchedUrls.push(stringUrl); + if (stringUrl === "https://cdn.example.com/public-input.png") { + return new Response(new Uint8Array([4, 5, 6]), { + status: 200, + headers: { "content-type": "image/png" }, + }); + } + if (stringUrl === "https://api.stability.ai/v2beta/stable-image/edit/inpaint") { + requestCapture = { body: options.body }; + return new Response(JSON.stringify({ image: "c3RhYmlsaXR5LWltYWdl" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + throw new Error(`Unexpected URL: ${stringUrl}`); + }; + + try { + const result = await handleImageGeneration({ + body: { + model: "stability-ai/inpaint", + prompt: "replace the sky with aurora", + image_url: "https://cdn.example.com/public-input.png", + mask: "data:image/png;base64,AA==", + response_format: "b64_json", + }, + credentials: { apiKey: "stability-key" }, + log: null, + }); + + assert.equal(result.success, true); + assert.equal(fetchedUrls[0], "https://cdn.example.com/public-input.png"); + assert.equal((requestCapture.body.get("image") as Blob).size, 3); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/image-upscale.test.ts b/tests/unit/image-upscale.test.ts index b6eda4c365..851be6e0bc 100644 --- a/tests/unit/image-upscale.test.ts +++ b/tests/unit/image-upscale.test.ts @@ -1,5 +1,6 @@ import { test } from "node:test"; import assert from "node:assert"; +import dns from "node:dns"; import { DEFAULT_UPSCALE_FACTORS, UPSCALE_PROVIDERS, @@ -25,6 +26,7 @@ import { import { extractUpscaleSourceImage, readImageDimensions, + resolveUpscaleImageSource, scaleDimensions, sniffImageMime, } from "../../open-sse/handlers/imageUpscale/shared.ts"; @@ -67,7 +69,12 @@ function jpegHeader(width: number, height: number): Buffer { const FAKE_JWT = (() => { const header = Buffer.from(JSON.stringify({ alg: "RS256" })).toString("base64url"); const payload = Buffer.from( - JSON.stringify({ user_id: "TESTUSER@AdobeID", type: "access_token", created_at: "1", expires_in: "86400000" }) + JSON.stringify({ + user_id: "TESTUSER@AdobeID", + type: "access_token", + created_at: "1", + expires_in: "86400000", + }) ).toString("base64url"); return `${header}.${payload}.sig`; })(); @@ -104,7 +111,12 @@ test("adobe-firefly upscale models are Topaz only (video starlight/astra exclude const ids = UPSCALE_PROVIDERS["adobe-firefly"]!.models.map((m) => m.id); assert.deepEqual(ids, ["topaz", "topaz-standard", "topaz-bloom"]); for (const id of ids) assert.ok(id.startsWith("topaz"), `${id} must be a Topaz model`); - for (const forbidden of ["starlight-quality", "starlight-creative", "starlight-fast", "astra-2"]) { + for (const forbidden of [ + "starlight-quality", + "starlight-creative", + "starlight-fast", + "astra-2", + ]) { assert.ok(!ids.includes(forbidden), `${forbidden} is a video upscaler and must not be listed`); } }); @@ -133,7 +145,10 @@ test("parseUpscaleModel accepts provider prefix, alias and bare model ids", () = provider: "stability-ai", model: "creative", }); - assert.deepEqual(parseUpscaleModel("topaz-enhance"), { provider: "topaz", model: "topaz-enhance" }); + assert.deepEqual(parseUpscaleModel("topaz-enhance"), { + provider: "topaz", + model: "topaz-enhance", + }); assert.equal(parseUpscaleModel("openai/gpt-image-2").provider, null); assert.deepEqual(parseUpscaleModel(null), { provider: null, model: null }); }); @@ -211,7 +226,10 @@ test("resolveAdobeUpscaleModel maps ids to upstream topaz versions and rejects o resolveAdobeUpscaleModel("adobe-firefly/topaz-bloom")?.spec.upstreamModelId, "topaz" ); - assert.equal(resolveAdobeUpscaleModel("firefly/reimagine")?.spec.upstreamModelVersion, "reimagine"); + assert.equal( + resolveAdobeUpscaleModel("firefly/reimagine")?.spec.upstreamModelVersion, + "reimagine" + ); assert.equal(resolveAdobeUpscaleModel("nano-banana-pro"), null); assert.equal(resolveAdobeUpscaleModel(""), null); assert.equal(isAdobeFireflyUpscaleModel("topaz-bloom"), true); @@ -232,7 +250,10 @@ test("resolveAdobeCreativityLevel maps 0-100 % onto the 0-1 upsample wire float" assert.equal(resolveAdobeCreativityLevel({ creativityPercent: 40 }), 0.4); assert.equal(resolveAdobeCreativityLevel({}), 0); // Explicit 0-1 wins over percent. - assert.equal(resolveAdobeCreativityLevel({ creativityPercent: 100, creativityLevel: 0.25 }), 0.25); + assert.equal( + resolveAdobeCreativityLevel({ creativityPercent: 100, creativityLevel: 0.25 }), + 0.25 + ); // Legacy 1-5 integer scale (discovery docs) is mapped onto 0-1. assert.equal(resolveAdobeCreativityLevel({ creativityLevel: "4" }), 0.8); assert.equal(resolveAdobeCreativityLevel({ creativityLevel: 5 }), 1); @@ -359,9 +380,15 @@ test("adobeFireflyUpscaleImage rejects a non-upscale model and a missing blob", // ── Shared helpers ───────────────────────────────────────────────────────── test("extractUpscaleSourceImage finds the first image across every alias", () => { - assert.equal(extractUpscaleSourceImage({ image: "data:image/png;base64,AAA" }), "data:image/png;base64,AAA"); + assert.equal( + extractUpscaleSourceImage({ image: "data:image/png;base64,AAA" }), + "data:image/png;base64,AAA" + ); assert.equal(extractUpscaleSourceImage({ image_url: "https://x/y.png" }), "https://x/y.png"); - assert.equal(extractUpscaleSourceImage({ images: ["https://a/1.png", "https://a/2.png"] }), "https://a/1.png"); + assert.equal( + extractUpscaleSourceImage({ images: ["https://a/1.png", "https://a/2.png"] }), + "https://a/1.png" + ); assert.equal( extractUpscaleSourceImage({ image_url: { url: "https://obj/u.png" } }), "https://obj/u.png" @@ -372,7 +399,9 @@ test("extractUpscaleSourceImage finds the first image across every alias", () => ); assert.equal( extractUpscaleSourceImage({ - messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "https://m/1.png" } }] }], + messages: [ + { role: "user", content: [{ type: "image_url", image_url: { url: "https://m/1.png" } }] }, + ], }), "https://m/1.png" ); @@ -409,7 +438,10 @@ test("scaleDimensions multiplies the source size and clamps the long edge", () = // ── Dispatcher ───────────────────────────────────────────────────────────── test("handleImageUpscale rejects unknown / mismatched models before any network call", async () => { - const badModel = await handleImageUpscale({ body: { model: "openai/gpt-image-2" }, credentials: {} }); + const badModel = await handleImageUpscale({ + body: { model: "openai/gpt-image-2" }, + credentials: {}, + }); assert.equal(badModel.success, false); assert.equal(badModel.status, 400); assert.match(String(badModel.error), /Invalid upscale model/); @@ -428,7 +460,11 @@ test("handleImageUpscale rejects unknown / mismatched models before any network }); test("handleImageUpscale requires a source image for every provider", async () => { - for (const model of ["adobe-firefly/topaz-standard", "stability-ai/fast", "topaz/topaz-enhance"]) { + for (const model of [ + "adobe-firefly/topaz-standard", + "stability-ai/fast", + "topaz/topaz-enhance", + ]) { const result = await handleImageUpscale({ body: { model }, credentials: { apiKey: "k" }, @@ -590,7 +626,10 @@ test("topaz falls back to its own scale when the source dimensions are unreadabl credentials: { apiKey: "topaz-key" }, fetchImpl: (async (_url: unknown, init?: RequestInit) => { form = init?.body as FormData; - return new Response(bytes(PNG_1X1), { status: 200, headers: { "content-type": "image/png" } }); + return new Response(bytes(PNG_1X1), { + status: 200, + headers: { "content-type": "image/png" }, + }); }) as unknown as typeof fetch, }); @@ -614,7 +653,10 @@ test("topaz honors an explicit WxH size over the factor and propagates upstream credentials: { apiKey: "topaz-key" }, fetchImpl: (async (_url: unknown, init?: RequestInit) => { form = init?.body as FormData; - return new Response(bytes(PNG_1X1), { status: 200, headers: { "content-type": "image/png" } }); + return new Response(bytes(PNG_1X1), { + status: 200, + headers: { "content-type": "image/png" }, + }); }) as unknown as typeof fetch, }); assert.equal(form!.get("output_width"), "1500"); @@ -633,3 +675,102 @@ test("topaz honors an explicit WxH size over the factor and propagates upstream assert.equal(failed.status, 402); assert.match(String(failed.error), /quota exceeded/); }); + +// ── GHSA-34rg-3pqj-35g9 — caller-supplied source URL must be public-only ─── +// +// `resolveUpscaleImageSource()` is fed straight from the request body (14 aliases, +// `provider_options.*`, message parts). It called `fetchRemoteImage()` with no explicit +// `guard`, so it inherited `getProviderOutboundGuard()` — the OPERATOR outbound policy, +// `block-metadata` on a default install (loopback/LAN allowed, DNS check skipped) — and +// a request body could make the server fetch intranet URLs and upload the bytes upstream. + +/** Public-IP DNS stub (rebinding guard needs a non-empty public answer for a fake host). */ +function withPublicDns(run: () => Promise): Promise { + const originalLookup = dns.promises.lookup; + (dns.promises as { lookup: unknown }).lookup = (async ( + _hostname: string, + options?: { all?: boolean } + ) => { + const record = { address: "203.0.113.1", family: 4 }; + return options && options.all ? [record] : record; + }) as typeof dns.promises.lookup; + return run().finally(() => { + (dns.promises as { lookup: unknown }).lookup = originalLookup; + }); +} + +for (const privateUrl of ["http://127.0.0.1:1/x.png", "http://192.168.1.50/x.png"]) { + test(`resolveUpscaleImageSource rejects a private source URL (${privateUrl}) before any fetch (GHSA-34rg-3pqj-35g9)`, async () => { + const originalFetch = globalThis.fetch; + const fetchedUrls: string[] = []; + globalThis.fetch = (async (url: string | URL | Request) => { + fetchedUrls.push(String(url)); + return new Response(bytes(PNG_1X1), { + status: 200, + headers: { "content-type": "image/png" }, + }); + }) as unknown as typeof fetch; + + try { + await assert.rejects(() => resolveUpscaleImageSource(privateUrl), /blocked/i); + assert.deepEqual(fetchedUrls, [], "the private URL must never be fetched"); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test(`stability upscale never uploads bytes from a private image_url (${privateUrl}) (GHSA-34rg-3pqj-35g9)`, async () => { + const originalFetch = globalThis.fetch; + const fetchedUrls: string[] = []; + globalThis.fetch = (async (url: string | URL | Request) => { + fetchedUrls.push(String(url)); + // Canary: on the vulnerable code these bytes become the multipart `image` part. + return new Response(bytes(PNG_1X1), { + status: 200, + headers: { "content-type": "image/png" }, + }); + }) as unknown as typeof fetch; + let upstreamCalls = 0; + + try { + const result = await handleStabilityImageUpscale({ + model: "fast", + provider: "stability-ai", + providerConfig: { baseUrl: "https://api.stability.ai" }, + body: { image_url: privateUrl, response_format: "b64_json" }, + credentials: { apiKey: "sk-test" }, + fetchImpl: (async () => { + upstreamCalls += 1; + return jsonResponse({ image: PNG_1X1.toString("base64") }); + }) as unknown as typeof fetch, + }); + + assert.equal(result.success, false); + assert.match(String(result.error), /blocked/i); + assert.deepEqual(fetchedUrls, [], "the private URL must never be fetched"); + assert.equal(upstreamCalls, 0, "nothing may be uploaded to the provider"); + } finally { + globalThis.fetch = originalFetch; + } + }); +} + +test("resolveUpscaleImageSource still downloads a public URL whose DNS resolves to a public IP (GHSA-34rg-3pqj-35g9)", async () => { + const originalFetch = globalThis.fetch; + const fetchedUrls: string[] = []; + globalThis.fetch = (async (url: string | URL | Request) => { + fetchedUrls.push(String(url)); + return new Response(bytes(PNG_1X1), { status: 200, headers: { "content-type": "image/png" } }); + }) as unknown as typeof fetch; + + try { + const source = await withPublicDns(() => + resolveUpscaleImageSource("https://cdn.example.com/public.png") + ); + assert.equal(source.contentType, "image/png"); + assert.equal(source.buffer.length, PNG_1X1.length); + assert.deepEqual(fetchedUrls, ["https://cdn.example.com/public.png"]); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/merge-applied-proxy-sink.test.ts b/tests/unit/merge-applied-proxy-sink.test.ts new file mode 100644 index 0000000000..c8d4a50fe0 --- /dev/null +++ b/tests/unit/merge-applied-proxy-sink.test.ts @@ -0,0 +1,113 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// The received status travels with the applied proxy: one merge at the log call site, and +// safeLogEvents forwards it to the proxy log. A locally generated 429 (no fetch) has no +// upstream status. +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-merge-applied-sink-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const proxyLogger = await import("../../src/lib/proxyLogger.ts"); +const { withUpstreamStatus, mergeAppliedProxySink, safeLogEvents } = + await import("../../src/sse/handlers/chatHelpers.ts"); + +test.beforeEach(() => { + proxyLogger.clearProxyLogs(); +}); + +test.after(() => { + proxyLogger.clearProxyLogs(); + core.closeDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +const PROXY_B = { type: "http", host: "127.0.0.1", port: 18080 }; + +test("withUpstreamStatus leaves the info untouched when nothing was received", () => { + const info = { proxy: null, level: "direct", levelId: null }; + assert.strictEqual(withUpstreamStatus(info, {}), info); + assert.strictEqual(withUpstreamStatus(null, {}), null); +}); + +test("withUpstreamStatus adds the received status without touching other fields", () => { + const info = { proxy: PROXY_B, level: "account", levelId: "c1" }; + assert.deepEqual(withUpstreamStatus(info, { upstreamStatus: 429 }), { + ...info, + upstreamStatus: 429, + }); + assert.deepEqual(withUpstreamStatus(null, { upstreamStatus: 502 }), { upstreamStatus: 502 }); +}); + +test("mergeAppliedProxySink applies the executor proxy and the status together", () => { + const merged = mergeAppliedProxySink( + { proxy: null, level: "direct", levelId: null }, + { proxy: PROXY_B, upstreamStatus: 429 } + ); + assert.deepEqual(merged, { + proxy: PROXY_B, + level: "account", + levelId: null, + upstreamStatus: 429, + }); +}); + +test("mergeAppliedProxySink after a network error on proxy B keeps B and no status", () => { + const merged = mergeAppliedProxySink( + { proxy: null, level: "direct", levelId: null }, + { proxy: PROXY_B, upstreamStatus: undefined } + ); + assert.deepEqual(merged, { proxy: PROXY_B, level: "account", levelId: null }); +}); + +test("the captured status wins over an upstreamStatus already on proxyInfo", () => { + const merged = mergeAppliedProxySink( + { proxy: null, level: "direct", levelId: null, upstreamStatus: 111 } as { + proxy: unknown; + level: string; + levelId: string | null; + }, + { proxy: null, upstreamStatus: 429 } + ); + assert.equal((merged as { upstreamStatus?: number }).upstreamStatus, 429); +}); + +test("mergeAppliedProxySink with an empty sink returns the original proxyInfo", () => { + const proxyInfo = { proxy: null, level: "direct", levelId: null }; + assert.strictEqual(mergeAppliedProxySink(proxyInfo, { proxy: null }), proxyInfo); +}); + +function logArgs(proxyInfo: unknown, status: number) { + return { + result: { success: false, status, error: "rate limited" }, + proxyInfo, + proxyLatency: 5, + provider: "openai", + model: "gpt-5", + sourceFormat: "openai", + targetFormat: "openai", + credentials: { connectionId: "conn-12345678" }, + comboName: null, + clientRawRequest: null, + }; +} + +test("safeLogEvents forwards the received status to the proxy log", async () => { + const proxyInfo = mergeAppliedProxySink( + { proxy: null, level: "direct", levelId: null }, + { proxy: PROXY_B, upstreamStatus: 429 } + ); + await safeLogEvents(logArgs(proxyInfo, 429)); + const [entry] = proxyLogger.getProxyLogs(); + assert.equal(entry.upstreamStatus, 429); + assert.equal(entry.status, "error"); +}); + +test("a local 429 with no fetch logs a null upstream status", async () => { + await safeLogEvents(logArgs({ proxy: PROXY_B, level: "provider", levelId: "openai" }, 429)); + const [entry] = proxyLogger.getProxyLogs(); + assert.equal(entry.upstreamStatus, null); +}); diff --git a/tests/unit/migration-179-proxy-logs-upstream-status.test.ts b/tests/unit/migration-179-proxy-logs-upstream-status.test.ts new file mode 100644 index 0000000000..2136fb44b5 --- /dev/null +++ b/tests/unit/migration-179-proxy-logs-upstream-status.test.ts @@ -0,0 +1,81 @@ +// proxy_logs.upstream_status ships as migration 179. Its idempotency check lives in +// migrationRunner's version-keyed switch, so the number there must match the file: under the +// colliding "177" key the check would answer for 177_provider_connection_synced_models_at and +// skip that unrelated migration on any database whose proxy_logs already had the column +// (ensureProxyLogsColumns adds it at boot). Runs the real runner on the real SQL files. +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import Database from "better-sqlite3"; + +const repoMigrations = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "../../src/lib/db/migrations" +); +const migrationsDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-migration-179-")); +for (const file of [ + "177_provider_connection_synced_models_at.sql", + "179_proxy_logs_upstream_status.sql", +]) { + fs.copyFileSync(path.join(repoMigrations, file), path.join(migrationsDir, file)); +} +const originalMigrationsDir = process.env.OMNIROUTE_MIGRATIONS_DIR; +process.env.OMNIROUTE_MIGRATIONS_DIR = migrationsDir; + +const { runMigrations } = await import("../../src/lib/db/migrationRunner.ts"); + +test.after(() => { + fs.rmSync(migrationsDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + if (originalMigrationsDir === undefined) delete process.env.OMNIROUTE_MIGRATIONS_DIR; + else process.env.OMNIROUTE_MIGRATIONS_DIR = originalMigrationsDir; +}); + +function columns(db: Database.Database, table: string): string[] { + return (db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>).map( + (c) => c.name + ); +} + +function ledger(db: Database.Database) { + return db.prepare("SELECT version, name FROM _omniroute_migrations ORDER BY version").all(); +} + +function legacyDb(withUpstreamStatus: boolean): Database.Database { + const db = new Database(":memory:"); + db.exec(` + CREATE TABLE provider_connections (id TEXT PRIMARY KEY); + CREATE TABLE proxy_logs (id TEXT PRIMARY KEY${withUpstreamStatus ? ", upstream_status INTEGER" : ""}); + `); + return db; +} + +test("proxy_logs.upstream_status already added at boot: 177 still runs, 179 is skipped", () => { + const db = legacyDb(true); + try { + runMigrations(db, { isNewDb: true }); + assert.ok( + columns(db, "provider_connections").includes("synced_models_at"), + "177_provider_connection_synced_models_at must not be skipped by the 179 idempotency check" + ); + assert.deepEqual(ledger(db), [ + { version: "177", name: "provider_connection_synced_models_at" }, + { version: "179", name: "proxy_logs_upstream_status" }, + ]); + } finally { + db.close(); + } +}); + +test("a database without the column gets it from migration 179", () => { + const db = legacyDb(false); + try { + runMigrations(db, { isNewDb: true }); + assert.ok(columns(db, "proxy_logs").includes("upstream_status")); + assert.ok(columns(db, "provider_connections").includes("synced_models_at")); + } finally { + db.close(); + } +}); diff --git a/tests/unit/models-catalog-route.test.ts b/tests/unit/models-catalog-route.test.ts index addd8561df..ef03c3017c 100644 --- a/tests/unit/models-catalog-route.test.ts +++ b/tests/unit/models-catalog-route.test.ts @@ -1093,9 +1093,9 @@ test("v1 models catalog does not duplicate custom Jina specialty models", async assert.equal(response.status, 200); assert.equal(visibleJinaEmbeddingRows.length, 1); - assert.equal(visibleJinaEmbeddingRows[0].id, "jina/jina-embeddings-v5-text-small"); + assert.equal(visibleJinaEmbeddingRows[0].id, "jina-ai/jina-embeddings-v5-text-small"); assert.equal(visibleJinaRerankRows.length, 1); - assert.equal(visibleJinaRerankRows[0].id, "jina/jina-reranker-v3"); + assert.equal(visibleJinaRerankRows[0].id, "jina-ai/jina-reranker-v3"); }); test("v1 models catalog exposes image model input and output modalities for advanced image providers", async () => { diff --git a/tests/unit/nanobanana-image-handler.test.ts b/tests/unit/nanobanana-image-handler.test.ts index 0e956e6d56..fc438dff46 100644 --- a/tests/unit/nanobanana-image-handler.test.ts +++ b/tests/unit/nanobanana-image-handler.test.ts @@ -142,3 +142,66 @@ test("handleImageGeneration(nanobanana): response_format=b64_json converts URL t globalThis.fetch = originalFetch; } }); + +// GHSA-34rg-3pqj-35g9 — the `response_format=b64_json` path re-fetches the result URL the +// upstream task reports. That URL is upstream-supplied (lower risk than a request-body +// URL), but it went through `fetchRemoteImage()` with no explicit `guard`, i.e. under the +// OPERATOR outbound policy (`block-metadata` on a default install: LAN/loopback allowed, +// DNS check skipped). Pin it to `public-only`, mirroring the AI Horde result download. +for (const privateUrl of ["http://127.0.0.1:1/x.png", "http://192.168.1.50/x.png"]) { + test(`handleImageGeneration(nanobanana): b64_json never downloads a private result URL (${privateUrl}) (GHSA-34rg-3pqj-35g9)`, async () => { + const originalFetch = globalThis.fetch; + const fetchedUrls: string[] = []; + + globalThis.fetch = async (url) => { + const u = String(url); + fetchedUrls.push(u); + + if (u.includes("/generate")) { + return new Response( + JSON.stringify({ code: 200, msg: "success", data: { taskId: "task-ssrf-1" } }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + + if (u.includes("/record-info")) { + return new Response( + JSON.stringify({ + code: 200, + msg: "success", + data: { successFlag: 1, response: { resultImageUrl: privateUrl } }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + + if (u === privateUrl) { + // Canary: on the vulnerable code these bytes come back to the caller as b64_json. + return new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { status: 200 }); + } + + throw new Error(`Unexpected URL: ${u}`); + }; + + try { + const result = await handleImageGeneration({ + body: { + model: "nanobanana/nanobanana-flash", + prompt: "galaxy test", + response_format: "b64_json", + }, + credentials: { apiKey: "test-key" }, + log: null, + }); + + assert.equal(result.success, false); + assert.match(String(result.error), /blocked/i); + assert.ok( + !fetchedUrls.includes(privateUrl), + `the private result URL must never be fetched (fetched: ${fetchedUrls.join(", ")})` + ); + } finally { + globalThis.fetch = originalFetch; + } + }); +} diff --git a/tests/unit/obsidian-webdav-route.test.ts b/tests/unit/obsidian-webdav-route.test.ts index a92d06d332..fd03d79da6 100644 --- a/tests/unit/obsidian-webdav-route.test.ts +++ b/tests/unit/obsidian-webdav-route.test.ts @@ -42,7 +42,26 @@ async function resetStorage() { } function makeRequest(url: string, options?: RequestInit): NextRequest { - return new Request(url, options) as unknown as NextRequest; + // These tests exercise the handler in the fresh-install open mode (no password + // configured, requireLogin default). That mode is loopback-only, and loopback is + // decided from the real peer — never from the `http://localhost` URL + // (GHSA-7pq4-8pvv-rx7r) — so give the direct-call Request a loopback socket peer. + return Object.assign(new Request(url, options), { ip: "127.0.0.1" }) as unknown as NextRequest; +} + +function postVault(vaultPath: string): Promise { + return route.POST( + makeRequest("http://localhost/api/settings/obsidian/webdav", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ vaultPath }), + }) + ); +} + +async function errorMessageOf(res: Response): Promise { + const body = (await res.json()) as Record; + return (body.error as Record | undefined)?.message as string | undefined; } test.beforeEach(async () => { @@ -191,6 +210,71 @@ test("POST with a non-existent path → 400, body does NOT contain a stack trace ); }); +// ── GHSA-7pq4-8pvv-rx7r — the vault root must never expose the data directory ── +// +// The WebDAV file service (scripts/dev/webdav-handler.mjs) serves the vault root +// to anyone holding the Basic credentials, before Next.js and outside the authz +// pipeline. Pointing it at DATA_DIR (or a parent of it) hands out server.env — +// JWT_SECRET / STORAGE_ENCRYPTION_KEY / API_KEY_SECRET — and storage.sqlite. + +test("POST rejects a vaultPath that IS the data directory → 400 (GHSA-7pq4-8pvv-rx7r)", async () => { + const res = await postVault(TEST_DATA_DIR); + assert.equal(res.status, 400); + const msg = await errorMessageOf(res); + assert.ok(msg && /data directory/i.test(msg), `expected a data-directory refusal, got: ${msg}`); + assert.ok(!msg.includes("at /"), "error must not carry a stack trace"); + assert.ok(!msg.includes(TEST_DATA_DIR), "error must not echo the data directory location"); + assert.equal(obsidianDb.getWebdavEnabled(), false, "WebDAV must stay disabled"); + assert.equal(obsidianDb.getObsidianVaultPath(), null, "vault path must not be stored"); +}); + +test("POST rejects a vaultPath that CONTAINS the data directory (parent dir) → 400 (GHSA-7pq4-8pvv-rx7r)", async () => { + const res = await postVault(path.dirname(TEST_DATA_DIR)); + assert.equal(res.status, 400); + const msg = await errorMessageOf(res); + assert.ok(msg && /data directory/i.test(msg), `expected a data-directory refusal, got: ${msg}`); + assert.equal(obsidianDb.getWebdavEnabled(), false); +}); + +test("POST rejects a vaultPath INSIDE the data directory → 400 (GHSA-7pq4-8pvv-rx7r)", async () => { + const inside = path.join(TEST_DATA_DIR, "db_backups"); + fs.mkdirSync(inside, { recursive: true }); + const res = await postVault(inside); + assert.equal(res.status, 400); + assert.equal(obsidianDb.getWebdavEnabled(), false); +}); + +test("POST rejects a symlink that resolves to the data directory → 400 (GHSA-7pq4-8pvv-rx7r)", async () => { + const linkParent = fs.mkdtempSync(path.join(os.tmpdir(), "omni-vault-link-")); + const link = path.join(linkParent, "vault"); + try { + fs.symlinkSync(TEST_DATA_DIR, link, "dir"); + } catch { + fs.rmSync(linkParent, { recursive: true, force: true }); + return; // platform without symlink permission — nothing to assert + } + try { + const res = await postVault(link); + assert.equal(res.status, 400); + assert.equal(obsidianDb.getWebdavEnabled(), false); + } finally { + fs.rmSync(linkParent, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +}); + +test("POST with a directory unrelated to the data directory still succeeds (sibling in tmp)", async () => { + const vaultDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-vault-ok-")); + try { + const res = await postVault(vaultDir); + assert.equal(res.status, 200); + const body = (await res.json()) as Record; + assert.equal(body.vaultPath, path.resolve(vaultDir)); + assert.equal(obsidianDb.getWebdavEnabled(), true); + } finally { + fs.rmSync(vaultDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +}); + test("POST with invalid body (missing vaultPath) → 400", async () => { const req = makeRequest("http://localhost/api/settings/obsidian/webdav", { method: "POST", diff --git a/tests/unit/opencode-proxy-refusal-memory.test.ts b/tests/unit/opencode-proxy-refusal-memory.test.ts new file mode 100644 index 0000000000..1f3e7644d8 --- /dev/null +++ b/tests/unit/opencode-proxy-refusal-memory.test.ts @@ -0,0 +1,196 @@ +import { describe, it, before, after, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert"; +import net from "node:net"; +import { OpencodeExecutor } from "../../open-sse/executors/opencode.ts"; +import type { ExecutorLog, ProviderCredentials } from "../../open-sse/executors/base.ts"; +import { resolveProxyForRequest } from "../../open-sse/utils/proxyFetch.ts"; +import * as memory from "../../open-sse/utils/proxyRefusalMemory.ts"; + +// With PROXY_SKIP_RECENTLY_FAILED on, a refusal received on a proxied opencode account sets +// that member aside across requests; a direct account is never concerned. With the flag off +// (the default) the rotation is exactly the plain one. + +const log: ExecutorLog = { debug() {}, info() {}, warn() {}, error() {} }; +const FINGERPRINTS = ["a".repeat(32), "b".repeat(32), "c".repeat(32)]; +const servers: net.Server[] = []; +const ports: number[] = []; + +function listen(server: net.Server): Promise { + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve((server.address() as net.AddressInfo).port)); + }); +} + +before(async () => { + for (let i = 0; i < 3; i++) { + const server = net.createServer((socket) => socket.destroy()); + servers.push(server); + ports.push(await listen(server)); + } +}); + +after(() => { + for (const server of servers) server.close(); +}); + +function proxyFor(index: number) { + return { type: "http", host: "127.0.0.1", port: ports[index] }; +} + +function keyFor(index: number) { + return memory.proxyEgressKey(proxyFor(index)); +} + +function credentials( + accounts: Array<{ fp: string; proxyIndex: number | null }> +): ProviderCredentials { + return { + apiKey: null, + accessToken: null, + connectionId: "noauth", + providerSpecificData: { + fingerprints: accounts.map((a) => a.fp), + accountProxies: accounts.map((a) => ({ + fingerprint: a.fp, + proxy: a.proxyIndex === null ? null : proxyFor(a.proxyIndex), + })), + }, + }; +} + +describe("OpencodeExecutor proxy refusal memory", () => { + let originalFetch: typeof globalThis.fetch; + let observed: string[] = []; + let statuses: number[] = []; + + beforeEach(() => { + originalFetch = globalThis.fetch; + memory.__resetProxyRefusalMemoryForTesting(); + process.env.PROXY_SKIP_RECENTLY_FAILED = "true"; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const resolved = resolveProxyForRequest(url); + observed.push(resolved.proxyUrl ? new URL(resolved.proxyUrl).port : "direct"); + const status = statuses.shift() ?? 200; + return new Response(JSON.stringify({ ok: status === 200 }), { + status, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + mock.timers.reset(); + delete process.env.PROXY_SKIP_RECENTLY_FAILED; + }); + + const proxied = () => credentials(FINGERPRINTS.map((fp, i) => ({ fp, proxyIndex: i }))); + const port = (index: number) => String(ports[index]); + + async function run(exec: OpencodeExecutor, creds: ProviderCredentials, plan: number[]) { + statuses = [...plan]; + observed = []; + const result = await exec.execute({ + model: "muse-spark-1.3-contributor-free", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: creds, + log, + }); + return { status: (result as { response: Response }).response.status, observed: [...observed] }; + } + + // Account cooldowns are a separate, shorter mechanism: clear them so each assertion shows + // the effect of the proxy memory alone. + function clearCooldowns(exec: OpencodeExecutor) { + const state = exec as unknown as { accounts: Array<{ cooldownUntil: number }> }; + for (const account of state.accounts) account.cooldownUntil = 0; + } + + it("a received refusal sets that member aside for later requests", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + const first = await run(exec, proxied(), [429, 200]); + assert.deepStrictEqual(first.observed, [port(0), port(1)]); + assert.strictEqual(first.status, 200); + assert.strictEqual(memory.isProxyAvoided(keyFor(0)), true); + + clearCooldowns(exec); + assert.deepStrictEqual((await run(exec, proxied(), [200])).observed, [port(2)]); + clearCooldowns(exec); + assert.deepStrictEqual((await run(exec, proxied(), [200])).observed, [port(1)]); + }); + + it("with the flag at its default (off) the refused proxy is tried again in turn", async () => { + delete process.env.PROXY_SKIP_RECENTLY_FAILED; + const exec = new OpencodeExecutor("opencode-zen"); + await run(exec, proxied(), [429, 200]); + assert.strictEqual(memory.__proxyRefusalMemorySizeForTesting(), 0); + + clearCooldowns(exec); + assert.deepStrictEqual((await run(exec, proxied(), [200])).observed, [port(2)]); + clearCooldowns(exec); + assert.deepStrictEqual((await run(exec, proxied(), [200])).observed, [port(0)]); + }); + + it("once the period ends the proxy is tried again", async () => { + mock.timers.enable({ apis: ["Date"], now: 1_800_000_000_000 }); + const exec = new OpencodeExecutor("opencode-zen"); + await run(exec, proxied(), [429, 200]); + + mock.timers.tick(2 * 60_000 + 1); + clearCooldowns(exec); + assert.deepStrictEqual((await run(exec, proxied(), [200])).observed, [port(2)]); + clearCooldowns(exec); + assert.deepStrictEqual((await run(exec, proxied(), [200])).observed, [port(0)]); + }); + + it("with the flag off a member set aside earlier is not skipped", async () => { + memory.noteProxyRefusal(keyFor(0), "ip_quota_429"); + delete process.env.PROXY_SKIP_RECENTLY_FAILED; + const exec = new OpencodeExecutor("opencode-zen"); + assert.deepStrictEqual((await run(exec, proxied(), [200])).observed, [port(0)]); + }); + + it("when every proxy is set aside one attempt still happens and its success clears it", async () => { + for (let i = 0; i < 3; i++) memory.noteProxyRefusal(keyFor(i), "ip_quota_429"); + const exec = new OpencodeExecutor("opencode-zen"); + + const result = await run(exec, proxied(), [200]); + assert.strictEqual(result.status, 200); + assert.deepStrictEqual(result.observed, [port(0)]); + assert.strictEqual(memory.isProxyAvoided(keyFor(0)), false); + assert.strictEqual(memory.isProxyAvoided(keyFor(1)), true); + }); + + it("a refusal on a proxyless account writes nothing, direct stays eligible", async () => { + const mixed = () => + credentials([ + { fp: FINGERPRINTS[0], proxyIndex: null }, + { fp: FINGERPRINTS[1], proxyIndex: 1 }, + ]); + const exec = new OpencodeExecutor("opencode-zen"); + + const first = await run(exec, mixed(), [429, 200]); + assert.deepStrictEqual(first.observed, ["direct", port(1)]); + assert.strictEqual(memory.__proxyRefusalMemorySizeForTesting(), 0); + + clearCooldowns(exec); + assert.deepStrictEqual((await run(exec, mixed(), [200])).observed, ["direct"]); + }); + + it("a connection without configured accounts never touches the memory", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + const noAccounts: ProviderCredentials = { + apiKey: null, + accessToken: null, + connectionId: "noauth", + providerSpecificData: {}, + }; + // The fast path may retry a refusal internally: every planned answer is a refusal. + await run(exec, noAccounts, [429, 429, 429, 429, 429]); + assert.strictEqual(memory.__proxyRefusalMemorySizeForTesting(), 0); + }); +}); diff --git a/tests/unit/opencode-rate-limited-classify.test.ts b/tests/unit/opencode-rate-limited-classify.test.ts new file mode 100644 index 0000000000..43cbcc3b59 --- /dev/null +++ b/tests/unit/opencode-rate-limited-classify.test.ts @@ -0,0 +1,277 @@ +import { describe, it, beforeEach, afterEach, before, after } from "node:test"; +import assert from "node:assert"; +import net from "node:net"; +import { + classify429, + classifyUpstream429, + parseRetryAfterSeconds, + readBodyPrefix, + RATE_LIMIT_BODY_SNIFF_BYTES, +} from "../../open-sse/executors/opencodeRateLimited.ts"; +import { OpencodeExecutor } from "../../open-sse/executors/opencode.ts"; +import type { ExecutorLog, ProviderCredentials } from "../../open-sse/executors/base.ts"; +import { resolveProxyForRequest } from "../../open-sse/utils/proxyFetch.ts"; +import { getProviderErrorRuleMatch } from "../../open-sse/config/providerErrorRules.ts"; +import { resetDbInstance } from "../../src/lib/db/core.ts"; + +// #13657 rework: the 429 classifier is kept; stopping the cross-account wave at a +// classified 429 is opt-in (OPENCODE_RATE_LIMITED_429_EARLY_STOP, default off — +// the free tier is per egress IP, #9611). Whatever ends the wave, the client gets +// the REAL last upstream 429 (status, body, Retry-After, quota headers), never a +// synthetic drain, so the opencode provider error rules keep matching it. +const FLAG = "OPENCODE_RATE_LIMITED_429_EARLY_STOP"; +const log: ExecutorLog = { debug() {}, info() {}, warn() {}, error() {} }; +const FPS = ["x", "y", "z"].map((c) => c.repeat(32)); +const MONTHLY_BODY = JSON.stringify({ + error: { + message: + "[429] Monthly usage limit reached. Resets in 13 days. To continue using this model now, enable usage from your available balance.", + }, +}); + +describe("classify429 / parseRetryAfterSeconds", () => { + it("a parseable Retry-After alone classifies rate_limited", () => { + assert.strictEqual(classify429({ retryAfter: "30" }), "rate_limited"); + assert.strictEqual(classify429({ retryAfter: 45 }), "rate_limited"); + const future = new Date(Date.now() + 120_000).toUTCString(); + assert.strictEqual(classify429({ retryAfter: future }), "rate_limited"); + }); + + it("a body naming a rate or usage limit classifies rate_limited", () => { + assert.strictEqual(classify429({ bodyText: "Rate limited, slow down" }), "rate_limited"); + assert.strictEqual(classify429({ bodyText: "Too many requests" }), "rate_limited"); + assert.strictEqual(classify429({ bodyText: MONTHLY_BODY }), "rate_limited"); + }); + + it("anything else is a burst", () => { + assert.strictEqual(classify429({}), "burst"); + assert.strictEqual(classify429({ bodyText: '{"error":"boom"}' }), "burst"); + assert.strictEqual(classify429({ retryAfter: "not-a-date" }), "burst"); + assert.strictEqual(classify429({ retryAfter: "" }), "burst"); + }); + + it("parses delta-seconds and HTTP dates against an injected clock", () => { + const now = Date.parse("2026-09-15T00:00:00Z"); + assert.strictEqual(parseRetryAfterSeconds("30", now), 30); + assert.strictEqual(parseRetryAfterSeconds("Tue, 15 Sep 2026 00:02:00 GMT", now), 120); + assert.strictEqual(parseRetryAfterSeconds("not-a-date", now), null); + assert.strictEqual(parseRetryAfterSeconds(-5, now), null); + }); +}); + +describe("readBodyPrefix / classifyUpstream429", () => { + it("reads only a bounded prefix and leaves the original body intact", async () => { + const body = "a".repeat(RATE_LIMIT_BODY_SNIFF_BYTES) + " rate limited"; + const response = new Response(body, { status: 429 }); + const prefix = await readBodyPrefix(response); + assert.strictEqual( + prefix?.length, + RATE_LIMIT_BODY_SNIFF_BYTES, + "signal past the cap is unseen" + ); + assert.strictEqual(await classifyUpstream429(response), "burst"); + assert.strictEqual(response.bodyUsed, false); + assert.strictEqual(await response.text(), body, "the caller still gets the full body"); + }); + + it("checks the header before touching the body", async () => { + const response = new Response("Too many requests", { + status: 429, + headers: { "Retry-After": "7" }, + }); + assert.strictEqual(await classifyUpstream429(response), "rate_limited"); + assert.strictEqual(await response.text(), "Too many requests"); + }); +}); + +describe("OpencodeExecutor 429 wave", () => { + const servers: net.Server[] = []; + const ports: number[] = []; + let originalFetch: typeof globalThis.fetch; + let priorFlag: string | undefined; + let observed: string[]; + + before(async () => { + for (let i = 0; i < FPS.length; i++) { + const server = net.createServer((s) => s.destroy()); + servers.push(server); + ports.push( + await new Promise((resolve) => + server.listen(0, "127.0.0.1", () => resolve((server.address() as net.AddressInfo).port)) + ) + ); + } + }); + + after(() => { + servers.forEach((s) => s.close()); + resetDbInstance(); + }); + + beforeEach(() => { + originalFetch = globalThis.fetch; + priorFlag = process.env[FLAG]; + observed = []; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + if (priorFlag === undefined) delete process.env[FLAG]; + else process.env[FLAG] = priorFlag; + }); + + type Step = { status: number; body?: string; headers?: Record }; + + function installFetch(plan: Step[]) { + let call = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const resolved = resolveProxyForRequest(url); + observed.push(resolved.proxyUrl ? new URL(resolved.proxyUrl).port : "direct"); + const step = plan[Math.min(call, plan.length - 1)]; + call++; + return new Response(step.body ?? JSON.stringify({ ok: step.status === 200, call }), { + status: step.status, + headers: { "Content-Type": "application/json", ...(step.headers ?? {}) }, + }); + }) as typeof globalThis.fetch; + } + + function credentials(count: number): ProviderCredentials { + const fingerprints = FPS.slice(0, count); + return { + apiKey: null, + accessToken: null, + connectionId: "noauth", + providerSpecificData: { + fingerprints, + accountProxies: fingerprints.map((fp, i) => ({ + fingerprint: fp, + proxy: { type: "http", host: "127.0.0.1", port: ports[i] }, + })), + }, + }; + } + + async function run(exec: OpencodeExecutor, count: number) { + const result = (await exec.execute({ + model: "muse-spark-1.3-contributor-free", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: credentials(count), + log, + })) as { response: Response }; + return result.response; + } + + function cooled(exec: OpencodeExecutor): number { + const accounts = (exec as unknown as { accounts: Array<{ cooldownUntil: number }> }).accounts; + return accounts.filter((a) => a.cooldownUntil > Date.now()).length; + } + + const RATE_LIMITED: Step = { + status: 429, + body: MONTHLY_BODY, + headers: { "Retry-After": "30", "x-ratelimit-remaining-requests": "0" }, + }; + + it("flag off: a classified 429 still rotates to the next account (#9611)", async () => { + delete process.env[FLAG]; + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([RATE_LIMITED, { status: 200 }]); + + const response = await run(exec, 2); + + assert.strictEqual(response.status, 200); + assert.strictEqual(observed.length, 2); + await response.body?.cancel(); + }); + + it("flag off: an exhausted wave returns the last real upstream 429 untouched", async () => { + delete process.env[FLAG]; + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([ + { status: 429, body: '{"error":"first"}' }, + { status: 429, body: '{"error":"second"}' }, + RATE_LIMITED, + ]); + + const response = await run(exec, 3); + + assert.strictEqual(observed.length, 3); + assert.strictEqual(response.status, 429); + assert.strictEqual(response.headers.get("retry-after"), "30"); + assert.strictEqual(response.headers.get("x-ratelimit-remaining-requests"), "0"); + assert.strictEqual(response.headers.get("x-opencode-retry-state"), null, "nothing synthetic"); + assert.strictEqual(await response.text(), MONTHLY_BODY); + }); + + describe("flag on", () => { + beforeEach(() => { + process.env[FLAG] = "true"; + }); + + it("stops at the first classified 429 and returns it untouched", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([RATE_LIMITED, { status: 200 }]); + + const response = await run(exec, 3); + + assert.strictEqual(observed.length, 1, "no further account is tried"); + assert.strictEqual(response.status, 429); + assert.strictEqual(response.headers.get("retry-after"), "30"); + assert.strictEqual(response.headers.get("x-ratelimit-remaining-requests"), "0"); + assert.strictEqual(cooled(exec), 1, "the rate-limited account is cooled down"); + const text = await response.text(); + assert.strictEqual(text, MONTHLY_BODY, "upstream body preserved"); + const rule = getProviderErrorRuleMatch( + "opencode-zen", + 429, + Object.fromEntries(response.headers.entries()), + JSON.parse(text) + ); + assert.strictEqual(rule?.reason, "quota_exhausted", "provider error rules still match"); + assert.ok((rule?.cooldownMs ?? 0) > 24 * 60 * 60 * 1000, "the 13-day reset still applies"); + }); + + it("a body-only signal stops without inventing a Retry-After", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([{ status: 429, body: '{"error":"Rate limited"}' }, { status: 200 }]); + + const response = await run(exec, 2); + + assert.strictEqual(observed.length, 1); + assert.strictEqual(response.status, 429); + assert.strictEqual(response.headers.get("retry-after"), null); + assert.strictEqual(await response.text(), '{"error":"Rate limited"}'); + }); + + it("an unclassified (burst) 429 keeps rotating", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([{ status: 429, body: '{"error":"boom"}' }, { status: 200 }]); + + const response = await run(exec, 2); + + assert.strictEqual(response.status, 200); + assert.strictEqual(observed.length, 2); + await response.body?.cancel(); + }); + + it("an all-burst wave still returns the last real upstream 429", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([ + { status: 429, body: '{"error":"a"}' }, + { status: 429, body: '{"error":"b"}', headers: { "x-upstream": "last" } }, + ]); + + const response = await run(exec, 2); + + assert.strictEqual(observed.length, 2); + assert.strictEqual(response.status, 429); + assert.strictEqual(response.headers.get("x-upstream"), "last"); + assert.strictEqual(await response.text(), '{"error":"b"}'); + }); + }); +}); diff --git a/tests/unit/opencode-responses-first-byte-stall.test.ts b/tests/unit/opencode-responses-first-byte-stall.test.ts new file mode 100644 index 0000000000..693cd151b8 --- /dev/null +++ b/tests/unit/opencode-responses-first-byte-stall.test.ts @@ -0,0 +1,326 @@ +import { describe, it, beforeEach, afterEach, before, after } from "node:test"; +import assert from "node:assert/strict"; +import net from "node:net"; +import { + OpencodeExecutor, + resolveOpencodeTargetFormat, +} from "../../open-sse/executors/opencode.ts"; +import type { ExecutorLog, ProviderCredentials } from "../../open-sse/executors/base.ts"; +import { resolveProxyForRequest } from "../../open-sse/utils/proxyFetch.ts"; +import { RESPONSES_FIRST_BYTE_TIMEOUT_CODE } from "../../open-sse/utils/firstByteWatchdog.ts"; +import { resetDbInstance } from "../../src/lib/db/core.ts"; + +// OPENCODE_RESPONSES_STALL_ROTATION gates the whole guard (#13484 rework): the flag is read at +// the decision point through resolveFeatureFlag (DB override > env > default "false"). +const FLAG = "OPENCODE_RESPONSES_STALL_ROTATION"; + +const log: ExecutorLog = { debug() {}, info() {}, warn() {}, error() {} }; +const RESPONSES_MODEL = "muse-spark-1.2-contributor-free"; +const CHAT_MODEL = "deepseek-v4-flash-free"; +const FPS = ["a".repeat(32), "b".repeat(32), "c".repeat(32)]; +const servers: net.Server[] = []; +const ports: number[] = []; + +function listen(server: net.Server): Promise { + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve((server.address() as net.AddressInfo).port)); + }); +} + +before(async () => { + for (let i = 0; i < FPS.length; i++) { + const server = net.createServer((s) => s.destroy()); + servers.push(server); + ports.push(await listen(server)); + } +}); + +after(() => { + servers.forEach((s) => s.close()); + resetDbInstance(); +}); + +function proxiedCredentials(count: number): ProviderCredentials { + const fingerprints = FPS.slice(0, count); + return { + apiKey: null, + accessToken: null, + connectionId: "noauth", + providerSpecificData: { + fingerprints, + accountProxies: fingerprints.map((fp, i) => ({ + fingerprint: fp, + proxy: { type: "http", host: "127.0.0.1", port: ports[i] }, + })), + }, + }; +} + +const directCredentials: ProviderCredentials = { + apiKey: null, + accessToken: null, + connectionId: "noauth", + providerSpecificData: {}, +}; + +// Several accounts, none with a dedicated proxy: every dispatch shares the default egress. +function proxylessCredentials(count: number): ProviderCredentials { + return { + apiKey: null, + accessToken: null, + connectionId: "noauth", + providerSpecificData: { fingerprints: FPS.slice(0, count) }, + }; +} + +type Step = "stall" | "ok" | "429" | "throw"; + +function silentBody(): ReadableStream { + return new ReadableStream({ pull() {} }); +} + +function sseBody(): ReadableStream { + const text = + 'event: response.created\ndata: {"type":"response.created","response":{"id":"r1"}}\n\n'; + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(text)); + controller.close(); + }, + }); +} + +describe("OpencodeExecutor Responses first-byte stall", () => { + let originalFetch: typeof globalThis.fetch; + let priorTimeout: string | undefined; + let priorFlag: string | undefined; + let calls: string[]; + + beforeEach(() => { + originalFetch = globalThis.fetch; + priorTimeout = process.env.RESPONSES_FIRST_BYTE_TIMEOUT_MS; + priorFlag = process.env[FLAG]; + process.env.RESPONSES_FIRST_BYTE_TIMEOUT_MS = "60"; + process.env[FLAG] = "true"; + calls = []; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + if (priorTimeout === undefined) delete process.env.RESPONSES_FIRST_BYTE_TIMEOUT_MS; + else process.env.RESPONSES_FIRST_BYTE_TIMEOUT_MS = priorTimeout; + if (priorFlag === undefined) delete process.env[FLAG]; + else process.env[FLAG] = priorFlag; + }); + + function installFetch(plan: Step[]) { + let call = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const resolved = resolveProxyForRequest(url); + calls.push(resolved.proxyUrl ? new URL(resolved.proxyUrl).port : "direct"); + const step = plan[Math.min(call, plan.length - 1)]; + call++; + if (step === "throw") throw new TypeError("fetch failed"); + if (step === "429") return new Response("{}", { status: 429 }); + return new Response(step === "stall" ? silentBody() : sseBody(), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + }) as typeof globalThis.fetch; + } + + function run( + exec: OpencodeExecutor, + model: string, + creds: ProviderCredentials, + stream = true, + signal: AbortSignal | null = null + ) { + return exec.execute({ + model, + body: { input: [{ role: "user", content: "hi" }], stream }, + stream, + signal, + credentials: creds, + log, + }) as Promise<{ response: Response }>; + } + + function cooledDown(exec: OpencodeExecutor): string[] { + const accounts = ( + exec as unknown as { + accounts: Array<{ fingerprint: string; cooldownUntil: number }>; + } + ).accounts; + return accounts.filter((a) => a.cooldownUntil > Date.now()).map((a) => a.fingerprint); + } + + it("targets the Responses API for the model under test", () => { + assert.equal(resolveOpencodeTargetFormat("opencode-zen", RESPONSES_MODEL), "openai-responses"); + assert.notEqual(resolveOpencodeTargetFormat("opencode-zen", CHAT_MODEL), "openai-responses"); + }); + + it("rotates past a silent Responses stream to a healthy account", { timeout: 5000 }, async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch(["stall", "ok"]); + const result = await run(exec, RESPONSES_MODEL, proxiedCredentials(2)); + assert.equal(result.response.status, 200); + assert.deepEqual(calls, [String(ports[0]), String(ports[1])]); + assert.deepEqual(cooledDown(exec), [FPS[0]]); + await result.response.body?.cancel(); + }); + + it( + "stops after the second stall without trying further accounts", + { timeout: 5000 }, + async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch(["stall", "stall", "ok"]); + await assert.rejects(run(exec, RESPONSES_MODEL, proxiedCredentials(3)), (err: unknown) => { + assert.equal((err as { code?: string }).code, RESPONSES_FIRST_BYTE_TIMEOUT_CODE); + assert.equal((err as Error).name, "TimeoutError"); + return true; + }); + assert.equal(calls.length, 2, "third account and final direct call are never tried"); + assert.deepEqual(cooledDown(exec).sort(), [FPS[0], FPS[1]].sort()); + } + ); + + it("leaves a silent chat/completions stream alone", { timeout: 5000 }, async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch(["stall"]); + const result = await run(exec, CHAT_MODEL, proxiedCredentials(2)); + assert.equal(result.response.status, 200); + assert.equal(calls.length, 1); + await result.response.body?.cancel(); + }); + + it("leaves non-streaming Responses requests alone", { timeout: 5000 }, async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch(["stall"]); + const result = await run(exec, RESPONSES_MODEL, proxiedCredentials(2), false); + assert.equal(result.response.status, 200); + assert.equal(calls.length, 1); + await result.response.body?.cancel(); + }); + + it("fails fast on the single direct account path", { timeout: 5000 }, async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch(["stall"]); + const started = Date.now(); + await assert.rejects(run(exec, RESPONSES_MODEL, directCredentials), (err: unknown) => { + assert.equal((err as { code?: string }).code, RESPONSES_FIRST_BYTE_TIMEOUT_CODE); + return true; + }); + assert.deepEqual(calls, ["direct"]); + assert.ok(Date.now() - started < 2000); + }); + + it( + "guards the final direct call after a stall and network errors", + { timeout: 5000 }, + async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch(["stall", "throw", "stall"]); + await assert.rejects(run(exec, RESPONSES_MODEL, proxiedCredentials(2)), (err: unknown) => { + assert.equal((err as { code?: string }).code, RESPONSES_FIRST_BYTE_TIMEOUT_CODE); + return true; + }); + assert.equal(calls.length, 3); + } + ); + + it("does nothing when the timeout is set to 0", { timeout: 5000 }, async () => { + process.env.RESPONSES_FIRST_BYTE_TIMEOUT_MS = "0"; + const exec = new OpencodeExecutor("opencode-zen"); + installFetch(["stall"]); + const result = await run(exec, RESPONSES_MODEL, proxiedCredentials(2)); + assert.equal(result.response.status, 200); + assert.equal(calls.length, 1); + await result.response.body?.cancel(); + }); + + it("does not spend the stall budget on a 429", { timeout: 5000 }, async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch(["429", "stall", "ok"]); + const result = await run(exec, RESPONSES_MODEL, proxiedCredentials(3)); + assert.equal(result.response.status, 200); + assert.equal(calls.length, 3); + await result.response.body?.cancel(); + }); + it( + "flag off: a silent Responses stream is returned untouched (no guard, no rotation)", + { + timeout: 5000, + }, + async () => { + delete process.env[FLAG]; + const exec = new OpencodeExecutor("opencode-zen"); + installFetch(["stall", "ok"]); + const started = Date.now(); + const result = await run(exec, RESPONSES_MODEL, proxiedCredentials(2)); + assert.equal(result.response.status, 200); + assert.deepEqual(calls, [String(ports[0])], "no second account is dispatched"); + assert.deepEqual(cooledDown(exec), [], "no account is cooled down"); + assert.ok(Date.now() - started < 1000, "the executor itself never waits on the body"); + await result.response.body?.cancel(); + } + ); + + it( + "flag off: the single direct account path never throws on a stall", + { + timeout: 5000, + }, + async () => { + process.env[FLAG] = "false"; + const exec = new OpencodeExecutor("opencode-zen"); + installFetch(["stall"]); + const result = await run(exec, RESPONSES_MODEL, directCredentials); + assert.equal(result.response.status, 200); + assert.deepEqual(calls, ["direct"]); + await result.response.body?.cancel(); + } + ); + + it( + "rotates once across proxy-less accounts (shared egress) instead of throwing", + { + timeout: 5000, + }, + async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch(["stall", "ok"]); + const result = await run(exec, RESPONSES_MODEL, proxylessCredentials(3)); + assert.equal(result.response.status, 200); + assert.deepEqual(calls, ["direct", "direct"]); + assert.equal(cooledDown(exec).length, 1, "only the stalled account is cooled down"); + await result.response.body?.cancel(); + } + ); + + it("proxy-less fleet: the second stall fails fast", { timeout: 5000 }, async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch(["stall", "stall", "ok"]); + await assert.rejects(run(exec, RESPONSES_MODEL, proxylessCredentials(3)), (err: unknown) => { + assert.equal((err as { code?: string }).code, RESPONSES_FIRST_BYTE_TIMEOUT_CODE); + return true; + }); + assert.equal(calls.length, 2); + }); + + it("a client abort during the first-byte wait never rotates", { timeout: 5000 }, async () => { + process.env.RESPONSES_FIRST_BYTE_TIMEOUT_MS = "10000"; + const exec = new OpencodeExecutor("opencode-zen"); + installFetch(["stall", "ok"]); + const controller = new AbortController(); + setTimeout(() => controller.abort(), 30); + await assert.rejects( + run(exec, RESPONSES_MODEL, proxiedCredentials(2), true, controller.signal) + ); + assert.equal(calls.length, 1, "no dispatch after the client went away"); + assert.deepEqual(cooledDown(exec), [], "an abort is not the account's fault"); + }); +}); diff --git a/tests/unit/opencode-transient-retry-delay.test.ts b/tests/unit/opencode-transient-retry-delay.test.ts new file mode 100644 index 0000000000..c4bfb76a78 --- /dev/null +++ b/tests/unit/opencode-transient-retry-delay.test.ts @@ -0,0 +1,299 @@ +import { describe, it, beforeEach, afterEach, before, after } from "node:test"; +import assert from "node:assert"; +import net from "node:net"; +import { OpencodeExecutor } from "../../open-sse/executors/opencode.ts"; +import { BaseExecutor } from "../../open-sse/executors/base.ts"; +import type { ExecutorLog, ProviderCredentials } from "../../open-sse/executors/base.ts"; +import { + TRANSIENT_RETRY_BASE_DELAY_MS, + TRANSIENT_RETRY_MAX_DELAY_MS, + TRANSIENT_RETRY_TOTAL_BUDGET_MS, + transientRetryDelayMs, + sleepAbortable, +} from "../../open-sse/executors/opencodeTransientFailure.ts"; +import { resolveProxyForRequest } from "../../open-sse/utils/proxyFetch.ts"; +import { resetDbInstance } from "../../src/lib/db/core.ts"; + +// #13615 rework: the failover pause is opt-in (OPENCODE_TRANSIENT_FAILOVER_BACKOFF, +// default off), bounded (per-pause cap + per-request budget), honors the client +// abort signal and releases the failed body before waiting. The executor's sleep +// is injected, so no test waits on a real 1.5s timer. +const FLAG = "OPENCODE_TRANSIENT_FAILOVER_BACKOFF"; +const log: ExecutorLog = { debug() {}, info() {}, warn() {}, error() {} }; +const FPS = ["a", "b", "c", "d", "e", "f", "g"].map((c) => c.repeat(32)); + +const servers: net.Server[] = []; +const ports: number[] = []; + +function listen(server: net.Server): Promise { + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve((server.address() as net.AddressInfo).port)); + }); +} + +before(async () => { + for (let i = 0; i < FPS.length; i++) { + const server = net.createServer((s) => s.destroy()); + servers.push(server); + ports.push(await listen(server)); + } +}); + +after(() => { + servers.forEach((s) => s.close()); + resetDbInstance(); +}); + +function credentialsFor(count: number): ProviderCredentials { + const fingerprints = FPS.slice(0, count); + return { + apiKey: null, + accessToken: null, + connectionId: "noauth", + providerSpecificData: { + fingerprints, + accountProxies: fingerprints.map((fp, i) => ({ + fingerprint: fp, + proxy: { type: "http", host: "127.0.0.1", port: ports[i] }, + })), + }, + }; +} + +const GEO_BODY = JSON.stringify({ + error: { type: "RegionError", message: "This model is not available in your country." }, +}); +// Empty upstream rejection: 400 without an error field (see isEmptyUpstreamRejection). +const EMPTY_BODY = + '{"id":"chatcmpl_44fn2g6e7kk","object":"chat.completion","created":1787419957,"model":"muse-spark-1.2-contributor-free","choices":[{"index":0,"message":{"role":"assistant"},"finish_reason":null}]}'; + +describe("transient failover pause helpers", () => { + it("uses its argument: nothing before the second failure, then bounded doubling", () => { + assert.strictEqual(TRANSIENT_RETRY_BASE_DELAY_MS, BaseExecutor.WAF_RETRY_CONFIG.delayMs); + assert.strictEqual(transientRetryDelayMs(0), 0); + assert.strictEqual(transientRetryDelayMs(1), 0); + assert.strictEqual(transientRetryDelayMs(2), 1500); + assert.strictEqual(transientRetryDelayMs(3), 3000); + assert.strictEqual(transientRetryDelayMs(4), TRANSIENT_RETRY_MAX_DELAY_MS); + assert.strictEqual(transientRetryDelayMs(50), TRANSIENT_RETRY_MAX_DELAY_MS); + assert.strictEqual(transientRetryDelayMs(Number.NaN), 0); + }); + + it("never exceeds what is left of the per-request budget", () => { + assert.strictEqual(transientRetryDelayMs(4, TRANSIENT_RETRY_TOTAL_BUDGET_MS - 1000), 1000); + assert.strictEqual(transientRetryDelayMs(4, TRANSIENT_RETRY_TOTAL_BUDGET_MS), 0); + assert.strictEqual(transientRetryDelayMs(2, TRANSIENT_RETRY_TOTAL_BUDGET_MS + 5), 0); + }); + + it("sleepAbortable resolves true after the delay and false on abort", async () => { + assert.strictEqual(await sleepAbortable(5), true); + assert.strictEqual(await sleepAbortable(5, new AbortController().signal), true); + const controller = new AbortController(); + const pending = sleepAbortable(60_000, controller.signal); + controller.abort(); + assert.strictEqual(await pending, false); + const aborted = new AbortController(); + aborted.abort(); + assert.strictEqual(await sleepAbortable(60_000, aborted.signal), false); + }); +}); + +describe("opencode rotation with OPENCODE_TRANSIENT_FAILOVER_BACKOFF", () => { + let originalFetch: typeof globalThis.fetch; + let priorFlag: string | undefined; + let observed: string[]; + let upstream: Response[]; + let sleeps: number[]; + // Filled per test: what each dispatched attempt answers. + let events: string[]; + + beforeEach(() => { + originalFetch = globalThis.fetch; + priorFlag = process.env[FLAG]; + process.env[FLAG] = "true"; + observed = []; + upstream = []; + sleeps = []; + events = []; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + if (priorFlag === undefined) delete process.env[FLAG]; + else process.env[FLAG] = priorFlag; + }); + + function installFetch(plan: Array<{ status: number; body?: string }>) { + let call = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const resolved = resolveProxyForRequest(url); + observed.push(resolved.proxyUrl ? new URL(resolved.proxyUrl).port : "direct"); + const step = plan[Math.min(call, plan.length - 1)]; + call++; + events.push(`dispatch:${step.status}`); + const response = new Response(step.body ?? JSON.stringify({ ok: step.status === 200 }), { + status: step.status, + headers: { "Content-Type": "application/json", "x-upstream-call": String(call) }, + }); + upstream.push(response); + return response; + }) as typeof globalThis.fetch; + } + + function newExecutor(onSleep?: (ms: number) => boolean): OpencodeExecutor { + const exec = new OpencodeExecutor("opencode-zen"); + exec.transientPauseSleep = async (ms, signal) => { + sleeps.push(ms); + events.push(`sleep:${ms}`); + if (signal?.aborted) return false; + return onSleep ? onSleep(ms) : true; + }; + return exec; + } + + async function run(exec: OpencodeExecutor, count: number, signal: AbortSignal | null = null) { + const result = (await exec.execute({ + model: "muse-spark-1.3-contributor-free", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal, + credentials: credentialsFor(count), + log, + })) as { response: Response }; + return result.response; + } + + it("flag off: failover stays immediate even after a long transient streak", async () => { + delete process.env[FLAG]; + const exec = newExecutor(); + installFetch([{ status: 500 }, { status: 502 }, { status: 503 }, { status: 200 }]); + + const response = await run(exec, 4); + + assert.strictEqual(response.status, 200); + assert.strictEqual(observed.length, 4); + assert.deepStrictEqual(sleeps, [], "no pause without the flag"); + assert.strictEqual(upstream[0].bodyUsed, false, "flag off never touches failed bodies"); + await response.body?.cancel(); + }); + + it("the first retry after one transient failure is immediate", async () => { + const exec = newExecutor(); + installFetch([{ status: 500 }, { status: 200 }]); + + const response = await run(exec, 2); + + assert.strictEqual(response.status, 200); + assert.deepStrictEqual(sleeps, []); + await response.body?.cancel(); + }); + + it("pauses before the third account, after releasing the failed body", async () => { + const exec = newExecutor(); + installFetch([{ status: 500 }, { status: 500 }, { status: 200 }]); + + const response = await run(exec, 3); + + assert.strictEqual(response.status, 200); + assert.deepStrictEqual(events, ["dispatch:500", "dispatch:500", "sleep:1500", "dispatch:200"]); + assert.strictEqual(upstream[1].bodyUsed, true, "the failed body is cancelled before sleeping"); + await response.body?.cancel(); + }); + + it("backs off with its argument, bounded by the per-request budget", async () => { + const exec = newExecutor(); + installFetch([ + { status: 500 }, + { status: 500 }, + { status: 500 }, + { status: 500 }, + { status: 200 }, + ]); + + const response = await run(exec, 5); + + assert.strictEqual(response.status, 200); + assert.deepStrictEqual(sleeps, [1500, 3000, 5500], "1.5s, 3s, then the 10s budget remainder"); + await response.body?.cancel(); + }); + + it("stops pausing once the per-request budget is spent", async () => { + const exec = newExecutor(); + installFetch([ + { status: 500 }, + { status: 500 }, + { status: 500 }, + { status: 500 }, + { status: 500 }, + { status: 500 }, + { status: 200 }, + ]); + + const response = await run(exec, 7); + + assert.strictEqual(response.status, 200); + assert.strictEqual(observed.length, 7); + assert.strictEqual( + sleeps.reduce((a, b) => a + b, 0), + TRANSIENT_RETRY_TOTAL_BUDGET_MS, + "total pause time is bounded" + ); + await response.body?.cancel(); + }); + + it("a mixed streak (500 then empty 400) pauses; a 429 or geo 403 resets it", async () => { + const mixed = newExecutor(); + installFetch([{ status: 500 }, { status: 400, body: EMPTY_BODY }, { status: 200 }]); + const mixedResponse = await run(mixed, 3); + assert.strictEqual(mixedResponse.status, 200); + assert.deepStrictEqual(sleeps, [1500]); + await mixedResponse.body?.cancel(); + + for (const breaker of [{ status: 429 }, { status: 403, body: GEO_BODY }]) { + sleeps = []; + events = []; + const exec = newExecutor(); + installFetch([{ status: 500 }, breaker, { status: 500 }, { status: 200 }]); + const response = await run(exec, 4); + assert.strictEqual(response.status, 200); + assert.deepStrictEqual(sleeps, [], `${breaker.status} breaks the streak`); + await response.body?.cancel(); + } + }); + + it("a client abort during the pause dispatches nothing more", async () => { + const controller = new AbortController(); + const exec = newExecutor(() => { + controller.abort(); + return false; + }); + installFetch([{ status: 500 }, { status: 500 }, { status: 200 }]); + + const response = await run(exec, 3, controller.signal); + + assert.strictEqual(observed.length, 2, "no third dispatch after the abort"); + assert.strictEqual(response.status, 500, "the last failure status is surfaced"); + assert.strictEqual(response.headers.get("x-upstream-call"), "2", "its headers are kept"); + }); + + it("an already-aborted signal skips the pause and the dispatch", async () => { + const controller = new AbortController(); + const exec = newExecutor(); + installFetch([{ status: 500 }, { status: 500 }, { status: 200 }]); + let calls = 0; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (...args: Parameters) => { + calls++; + const response = await realFetch(...args); + if (calls === 2) controller.abort(); + return response; + }) as typeof globalThis.fetch; + + const response = await run(exec, 3, controller.signal); + + assert.strictEqual(calls, 2); + assert.strictEqual(response.status, 500); + }); +}); diff --git a/tests/unit/opencode-user-blocked-predicate.test.ts b/tests/unit/opencode-user-blocked-predicate.test.ts new file mode 100644 index 0000000000..0799a51d7f --- /dev/null +++ b/tests/unit/opencode-user-blocked-predicate.test.ts @@ -0,0 +1,67 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { isOpencodeUserBlocked } from "../../open-sse/executors/opencodeGeoBlock.ts"; + +const BLOCKED_BODY = JSON.stringify({ + error: { + type: "server_error", + message: + "Error from provider (Console): Upstream request failed: [user_blocked] egress refused.", + }, +}); +const AUTH_BODY = JSON.stringify({ error: { message: "invalid api key", type: "auth_error" } }); + +describe("isOpencodeUserBlocked", () => { + it("matches 403 + user_blocked signal", () => { + assert.strictEqual(isOpencodeUserBlocked(403, BLOCKED_BODY), true); + }); + it("matches regardless of case", () => { + assert.strictEqual(isOpencodeUserBlocked(403, "[USER_BLOCKED] restricted"), true); + }); + it("classifies 451 exactly like 403 (one predicate, no status special case)", () => { + assert.strictEqual(isOpencodeUserBlocked(451, BLOCKED_BODY), true); + assert.strictEqual(isOpencodeUserBlocked(451, AUTH_BODY), false); + }); + it("leaves a geo-blocked body to the geo predicate even with the token present", () => { + const geo = JSON.stringify({ + error: { type: "RegionError", message: "not available in your country [user_blocked]" }, + }); + assert.strictEqual(isOpencodeUserBlocked(403, geo), false); + assert.strictEqual(isOpencodeUserBlocked(451, geo), false); + }); + it("rejects fingerprint 1010 even with the signal present", () => { + assert.strictEqual( + isOpencodeUserBlocked(403, `{"error_code":1010,"message":"[user_blocked] restricted"}`), + false, + "keyed 1010 = fingerprint, never rotation" + ); + assert.strictEqual( + isOpencodeUserBlocked(403, "retry after 1010 seconds, [user_blocked] restricted"), + true, + "bare 1010 is not a fingerprint token; signal still matches" + ); + }); + it("rejects fingerprint tokens even with the signal present", () => { + assert.strictEqual( + isOpencodeUserBlocked(403, "[user_blocked] browser_signature_banned"), + false + ); + assert.strictEqual(isOpencodeUserBlocked(403, "[user_blocked] fingerprint_rejection"), false); + }); + it("rejects 403 without the signal", () => { + assert.strictEqual(isOpencodeUserBlocked(403, AUTH_BODY), false); + }); + it("rejects non-403 statuses at the rotation predicate", () => { + for (const status of [200, 400, 401, 429, 500]) { + assert.strictEqual(isOpencodeUserBlocked(status, BLOCKED_BODY), false); + } + }); + it("rejects separator variants without the exact token", () => { + assert.strictEqual(isOpencodeUserBlocked(403, "user-blocked restricted"), false); + assert.strictEqual(isOpencodeUserBlocked(403, "user blocked restricted"), false); + }); + it("rejects empty and null bodies", () => { + assert.strictEqual(isOpencodeUserBlocked(403, ""), false); + assert.strictEqual(isOpencodeUserBlocked(403, null), false); + }); +}); diff --git a/tests/unit/opencode-user-blocked-rotation.test.ts b/tests/unit/opencode-user-blocked-rotation.test.ts new file mode 100644 index 0000000000..ac428b00cf --- /dev/null +++ b/tests/unit/opencode-user-blocked-rotation.test.ts @@ -0,0 +1,272 @@ +import { describe, it, beforeEach, afterEach, before, after } from "node:test"; +import assert from "node:assert"; +import net from "node:net"; +import { OpencodeExecutor } from "../../open-sse/executors/opencode.ts"; +import type { ExecutorLog, ProviderCredentials } from "../../open-sse/executors/base.ts"; +import { resolveProxyForRequest } from "../../open-sse/utils/proxyFetch.ts"; +import { resetDbInstance } from "../../src/lib/db/core.ts"; + +// #13498 rework: rotation on an upstream `user_blocked` refusal is opt-in +// (OPENCODE_USER_BLOCKED_ROTATION, default off), bounded to one hop, treats 403 +// and 451 the same, cools the refused account down and cancels the body of the +// refusal it rotates away from. +const FLAG = "OPENCODE_USER_BLOCKED_ROTATION"; +const log: ExecutorLog = { debug() {}, info() {}, warn() {}, error() {} }; + +const FPS = ["a", "b", "c", "d"].map((c) => c.repeat(32)); + +const BLOCKED_BODY = JSON.stringify({ + error: { + type: "server_error", + message: + "Error from provider (Console): Upstream request failed: [user_blocked] egress refused.", + }, +}); +const GEO_BODY = JSON.stringify({ + error: { type: "RegionError", message: "This model is not available in your country." }, +}); + +const servers: net.Server[] = []; +const ports: number[] = []; + +function listen(server: net.Server): Promise { + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve((server.address() as net.AddressInfo).port)); + }); +} + +before(async () => { + for (let i = 0; i < FPS.length; i++) { + const server = net.createServer((s) => s.destroy()); + servers.push(server); + ports.push(await listen(server)); + } +}); + +after(() => { + servers.forEach((s) => s.close()); + resetDbInstance(); +}); + +function credentialsFor(count: number): ProviderCredentials { + const fingerprints = FPS.slice(0, count); + return { + apiKey: null, + accessToken: null, + connectionId: "noauth", + providerSpecificData: { + fingerprints, + accountProxies: fingerprints.map((fp, i) => ({ + fingerprint: fp, + proxy: { type: "http", host: "127.0.0.1", port: ports[i] }, + })), + }, + }; +} + +type AccountsProbe = Array<{ + fingerprint: string; + cooldownUntil: number; + consecutiveFails: number; +}>; + +function accountsOf(exec: OpencodeExecutor): AccountsProbe { + return (exec as unknown as { accounts: AccountsProbe }).accounts; +} + +describe("OpencodeExecutor user_blocked refusal (OPENCODE_USER_BLOCKED_ROTATION)", () => { + let originalFetch: typeof globalThis.fetch; + let priorFlag: string | undefined; + let observed: string[]; + // Every upstream Response handed to the executor, in dispatch order. A body the + // loop cancelled is disturbed (bodyUsed === true) even though nobody read it. + let upstream: Response[]; + + beforeEach(() => { + originalFetch = globalThis.fetch; + priorFlag = process.env[FLAG]; + observed = []; + upstream = []; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + if (priorFlag === undefined) delete process.env[FLAG]; + else process.env[FLAG] = priorFlag; + }); + + function installFetch(plan: Array<{ status: number; body?: string }>) { + let call = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const resolved = resolveProxyForRequest(url); + observed.push(resolved.proxyUrl ? new URL(resolved.proxyUrl).port : "direct"); + const step = plan[Math.min(call, plan.length - 1)]; + call++; + const response = new Response(step.body ?? JSON.stringify({ ok: true }), { + status: step.status, + headers: { "Content-Type": "application/json" }, + }); + upstream.push(response); + return response; + }) as typeof globalThis.fetch; + } + + async function run(exec: OpencodeExecutor, creds: ProviderCredentials) { + const result = (await exec.execute({ + model: "muse-spark-1.3-contributor-free", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: creds, + log, + })) as { response: Response }; + return result.response; + } + + describe("flag off (default): the refusal is returned unchanged", () => { + for (const status of [403, 451]) { + it(`${status} user_blocked: one call, no rotation, no cooldown`, async () => { + delete process.env[FLAG]; + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([{ status, body: BLOCKED_BODY }, { status: 200 }]); + + const response = await run(exec, credentialsFor(3)); + + assert.strictEqual(response.status, status); + assert.strictEqual(await response.text(), BLOCKED_BODY, "upstream body preserved"); + assert.strictEqual(observed.length, 1, "no failover without the flag"); + for (const a of accountsOf(exec)) assert.strictEqual(a.cooldownUntil, 0); + }); + } + }); + + describe("flag on", () => { + beforeEach(() => { + process.env[FLAG] = "true"; + }); + + for (const status of [403, 451]) { + it(`${status} user_blocked rotates once to a healthy account and cools the refused one`, async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([{ status, body: BLOCKED_BODY }, { status: 200 }]); + + const response = await run(exec, credentialsFor(3)); + + assert.strictEqual(response.status, 200); + assert.strictEqual(observed.length, 2); + const cooled = accountsOf(exec).filter((a) => a.cooldownUntil > Date.now()); + assert.deepStrictEqual( + cooled.map((a) => a.fingerprint), + [FPS[0]], + "only the refused account is cooled down" + ); + assert.strictEqual(upstream[0].bodyUsed, true, "the abandoned refusal body is cancelled"); + assert.strictEqual(response.bodyUsed, false, "the served body is untouched"); + await response.body?.cancel(); + }); + } + + it("rotates at most once: the second refusal is returned without trying a third account", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([ + { status: 403, body: BLOCKED_BODY }, + { status: 451, body: BLOCKED_BODY }, + { status: 200 }, + ]); + + const response = await run(exec, credentialsFor(3)); + + assert.strictEqual(response.status, 451); + assert.strictEqual(await response.text(), BLOCKED_BODY, "the returned refusal is intact"); + assert.strictEqual(observed.length, 2, "bounded: one rotation only"); + const accounts = accountsOf(exec); + assert.strictEqual(accounts.filter((a) => a.cooldownUntil > Date.now()).length, 2); + for (const fp of [FPS[0], FPS[1]]) { + const account = accounts.find((a) => a.fingerprint === fp); + assert.strictEqual( + account?.consecutiveFails, + 1, + "refused accounts are never marked success" + ); + } + assert.strictEqual(upstream[0].bodyUsed, true, "the superseded refusal body is cancelled"); + }); + + it("single proxied account: the refusal comes back after one call", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([{ status: 403, body: BLOCKED_BODY }, { status: 200 }]); + + const response = await run(exec, credentialsFor(1)); + + assert.strictEqual(response.status, 403); + assert.strictEqual(observed.length, 1); + assert.strictEqual(accountsOf(exec)[0].consecutiveFails, 1, "cooldown recorded, no success"); + }); + + it("the exhaustion path returns the kept refusal with its body intact", async () => { + // A refused (the one rotation), B throws a network error → the loop exhausts and + // surfaces A's kept refusal, whose body must still be readable. + const exec = new OpencodeExecutor("opencode-zen"); + let call = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + observed.push(resolveProxyForRequest(url).proxyUrl ?? "direct"); + call++; + if (call === 1) return new Response(BLOCKED_BODY, { status: 403 }); + throw new TypeError("fetch failed"); + }) as typeof globalThis.fetch; + + const response = await run(exec, credentialsFor(2)); + + assert.strictEqual(response.status, 403); + assert.strictEqual(await response.text(), BLOCKED_BODY); + assert.strictEqual(observed.length, 2); + }); + + it("a signal-less 403/451 still passes through untouched", async () => { + for (const status of [403, 451]) { + const exec = new OpencodeExecutor("opencode-zen"); + observed = []; + installFetch([{ status, body: JSON.stringify({ error: { message: "invalid api key" } }) }]); + + const response = await run(exec, credentialsFor(2)); + + assert.strictEqual(response.status, status); + assert.strictEqual(observed.length, 1, `no retry on signal-less ${status}`); + } + }); + + it("never rotates a Cloudflare 1010 fingerprint rejection carrying the token", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([ + { status: 403, body: JSON.stringify({ error_code: 1010, message: "[user_blocked]" }) }, + { status: 200 }, + ]); + + const response = await run(exec, credentialsFor(2)); + + assert.strictEqual(response.status, 403); + assert.strictEqual(observed.length, 1); + }); + + it("shares the tried-set with geo rotation and does not spend its budget on geo", async () => { + // A geo-blocked (geo rotation, unbounded as before), B refused (the one user_blocked hop), + // C healthy. + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([ + { status: 403, body: GEO_BODY }, + { status: 403, body: BLOCKED_BODY }, + { status: 200 }, + ]); + + const response = await run(exec, credentialsFor(3)); + + assert.strictEqual(response.status, 200); + assert.deepStrictEqual(observed, [String(ports[0]), String(ports[1]), String(ports[2])]); + await response.body?.cancel(); + }); + }); +}); diff --git a/tests/unit/paid-model-target-6540.test.ts b/tests/unit/paid-model-target-6540.test.ts index d8783f975b..cdd6d8e2f7 100644 --- a/tests/unit/paid-model-target-6540.test.ts +++ b/tests/unit/paid-model-target-6540.test.ts @@ -7,7 +7,7 @@ test("isPaidModelTarget — documented free model → 'free'", () => { }); test("isPaidModelTarget — provider in free catalog but model not listed free → 'paid'", () => { - assert.equal(isPaidModelTarget("together/Qwen/Qwen3-235B-A22B"), "paid"); + assert.equal(isPaidModelTarget("gemini/gemini-3.1-pro-preview"), "paid"); }); test("isPaidModelTarget — no separator (combo/alias name) → 'unknown' (fail open)", () => { diff --git a/tests/unit/paid-model-target-routes-6540.test.ts b/tests/unit/paid-model-target-routes-6540.test.ts index 5e6181f629..e338209fc7 100644 --- a/tests/unit/paid-model-target-routes-6540.test.ts +++ b/tests/unit/paid-model-target-routes-6540.test.ts @@ -14,10 +14,13 @@ const settingsRoute = await import("../../src/app/api/settings/route.ts"); const comboDefaultsRoute = await import("../../src/app/api/settings/combo-defaults/route.ts"); const backgroundDegradationRoute = await import("../../src/app/api/settings/background-degradation/route.ts"); +const { isPaidModelTarget } = await import("../../src/shared/utils/freeModels.ts"); // A provider present in the free-model catalog (so providerHasFreeModels is -// true) but a model id that is NOT one of its documented free models. -const PAID_TARGET = "together/Qwen/Qwen3-235B-A22B"; +// true) but a model id that is NOT one of its documented free models. Gemini's +// free tier is recurring and excludes the Pro line; the previous Together target +// stopped classifying once its one-time signup credit left the catalog (#13407). +const PAID_TARGET = "gemini/gemini-3.1-pro-preview"; // A documented free model. const FREE_TARGET = "openrouter/auto"; // No "/" or "," — a combo/alias name, fails open ("unknown"). @@ -38,6 +41,12 @@ test.after(() => { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); }); +test("fixtures classify as intended against the current free-model catalog", () => { + assert.equal(isPaidModelTarget(PAID_TARGET), "paid", "PAID_TARGET must stay a paid model"); + assert.equal(isPaidModelTarget(FREE_TARGET), "free", "FREE_TARGET must stay a free model"); + assert.equal(isPaidModelTarget(UNKNOWN_TARGET), "unknown"); +}); + // ── PATCH /api/settings — webSearchRouteModel ────────────────────────────── test("PATCH /api/settings blocks a paid webSearchRouteModel when hidePaidModels is on", async () => { diff --git a/tests/unit/pool-ip-quota-429-path.test.ts b/tests/unit/pool-ip-quota-429-path.test.ts new file mode 100644 index 0000000000..80b5e38223 --- /dev/null +++ b/tests/unit/pool-ip-quota-429-path.test.ts @@ -0,0 +1,152 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import type { AddressInfo } from "node:net"; + +// Through the real pieces of the chat path: a provider dispatch refused through a pool +// member, the capture on the applied-proxy sink, the merge at the log call site and +// safeLogEvents. With PROXY_SKIP_RECENTLY_FAILED on, the next pick of that pool skips the +// member (already before the fire-and-forget log settles); a locally generated failure does +// not; a provider outside the refusal scope does not. With the flag off nothing changes. + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-pool-refused-path-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const proxiesDb = await import("../../src/lib/db/proxies.ts"); +const proxyLogger = await import("../../src/lib/proxyLogger.ts"); +const memory = await import("../../open-sse/utils/proxyRefusalMemory.ts"); +const proxyFetchModule = await import("../../open-sse/utils/proxyFetch.ts"); +const { runWithCapture } = await import("../../open-sse/utils/providerRequestLogging.ts"); +const { mergeAppliedProxySink, safeLogEvents } = + await import("../../src/sse/handlers/chatHelpers.ts"); + +const capture = { capture: () => {}, body: (fallback: unknown) => fallback }; +let server: http.Server; +let baseUrl = ""; + +test.before(async () => { + server = http.createServer((req, res) => { + const code = Number(new URL(req.url ?? "/", "http://local").searchParams.get("code") ?? 200); + res.writeHead(code, { "content-type": "application/json" }); + res.end("{}"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +test.after(async () => { + delete process.env.PROXY_SKIP_RECENTLY_FAILED; + await new Promise((resolve) => server.close(() => resolve())); + proxyLogger.clearProxyLogs(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test.beforeEach(() => { + memory.__resetProxyRefusalMemoryForTesting(); + process.env.PROXY_SKIP_RECENTLY_FAILED = "true"; + proxyLogger.clearProxyLogs(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +}); + +async function twoMemberPool() { + const members = []; + for (const port of [9301, 9302]) { + const proxy = await proxiesDb.createProxy({ + name: `m${port}`, + type: "http", + host: "10.4.1.1", + port, + }); + await proxiesDb.addProxyToScopePool("provider", "opencode", proxy.id); + members.push({ type: "http", host: "10.4.1.1", port }); + } + return members; +} + +async function pickPort() { + const resolved = await proxiesDb.resolveProxyForScopeFromRegistry("provider", "opencode"); + return (resolved as { proxy: { port: number } }).proxy.port; +} + +// One request as chat.ts runs it: the executor pins a proxy on the sink, the provider +// dispatch goes through the patched fetch, then the log call merges the sink. +async function chatRequest( + provider: string, + pinned: unknown, + code: number | null, + onLogCalled: () => void = () => {} +) { + const sink: { proxy: unknown; upstreamStatus?: number } = { proxy: null }; + await proxyFetchModule.runWithAppliedProxyCapture(sink, async () => { + sink.proxy = pinned; + if (code !== null) { + await runWithCapture(capture, async () => { + const res = await fetch(`${baseUrl}/v1/chat/completions?code=${code}`); + await res.text(); + }); + } + }); + // chat.ts fires this without awaiting it. + const logged = safeLogEvents({ + result: { success: code === 200, status: code ?? 429, error: code === 200 ? null : "failed" }, + proxyInfo: mergeAppliedProxySink({ proxy: null, level: "provider", levelId: provider }, sink), + proxyLatency: 1, + provider, + model: "m", + sourceFormat: "openai", + targetFormat: "openai", + credentials: { connectionId: "conn-refused-path" }, + comboName: null, + clientRawRequest: null, + }); + onLogCalled(); + await logged; +} + +test("a received refusal through a pool member makes the next pick skip it", async () => { + const [first, second] = await twoMemberPool(); + assert.equal(await pickPort(), first.port); + await chatRequest("opencode", first, 429); + assert.equal(proxyLogger.getProxyLogs()[0].upstreamStatus, 429); + assert.deepEqual([await pickPort(), await pickPort()], [second.port, second.port]); +}); + +test("the member is set aside as soon as the log call returns, not when the log settles", async () => { + const [first, second] = await twoMemberPool(); + let avoidedAtCallSite: boolean | null = null; + await chatRequest("opencode", first, 429, () => { + avoidedAtCallSite = memory.isProxyAvoided(memory.proxyEgressKey(first)); + }); + assert.equal(avoidedAtCallSite, true, "a concurrent pick must already skip the refused member"); + assert.deepEqual([await pickPort(), await pickPort()], [second.port, second.port]); +}); + +test("with the flag at its default (off) a received refusal leaves the member in rotation", async () => { + const [first, second] = await twoMemberPool(); + delete process.env.PROXY_SKIP_RECENTLY_FAILED; + await chatRequest("opencode", first, 429); + assert.equal(proxyLogger.getProxyLogs()[0].upstreamStatus, 429); + assert.equal(memory.__proxyRefusalMemorySizeForTesting(), 0); + assert.deepEqual([await pickPort(), await pickPort()], [first.port, second.port]); +}); + +test("a locally generated failure (no dispatch) leaves the member in rotation", async () => { + const [first, second] = await twoMemberPool(); + await chatRequest("opencode", first, null); + assert.equal(proxyLogger.getProxyLogs()[0].upstreamStatus, null); + assert.deepEqual([await pickPort(), await pickPort()], [first.port, second.port]); +}); + +test("a refusal for a provider outside the scope leaves the member in rotation", async () => { + const [first, second] = await twoMemberPool(); + await chatRequest("opencode-zen", first, 429); + assert.deepEqual([await pickPort(), await pickPort()], [first.port, second.port]); +}); diff --git a/tests/unit/provider-401-ambiguous-runtime.test.ts b/tests/unit/provider-401-ambiguous-runtime.test.ts new file mode 100644 index 0000000000..15422df01c --- /dev/null +++ b/tests/unit/provider-401-ambiguous-runtime.test.ts @@ -0,0 +1,227 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// #13609 rework: a bare Mistral 401 is byte-identical for a revoked key and an +// exhausted quota (#7638). With MISTRAL_AMBIGUOUS_401_SOFT_LOCKOUT on, it cools the +// connection down instead of parking it as expired — at most 3 times per hour per +// connection, then it parks, so a revoked key still converges. Flag off (default): +// every bare Mistral 401 parks the connection as before. + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-13609-mistral-401-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const FLAG = "MISTRAL_AMBIGUOUS_401_SOFT_LOCKOUT"; +const BARE = '{"detail":"Unauthorized"}'; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const auth = await import("../../src/sse/services/auth.ts"); +const { checkFallbackError } = await import("../../open-sse/services/accountFallback.ts"); +const { resolveTerminalConnectionStatus } = + await import("../../src/sse/services/authTerminalStatus.ts"); +const { classifyProviderError, PROVIDER_ERROR_TYPES } = + await import("../../open-sse/services/errorClassifier.ts"); +const { setOperatorProviderErrorRules } = + await import("../../open-sse/config/providerErrorRules.ts"); +const { + isMistralAmbiguous401, + takeMistralAmbiguous401SoftStrike, + resetMistralAmbiguous401Strikes, + MISTRAL_AMBIGUOUS_401_MAX_SOFT_STRIKES, + MISTRAL_AMBIGUOUS_401_STRIKE_WINDOW_MS, +} = await import("../../open-sse/services/accountFallback/mistralAmbiguousAuth.ts"); +const { classifyFailure } = await import("../../src/app/api/providers/[id]/test/route.ts"); + +const priorFlag = process.env[FLAG]; + +function setFlag(value: string | undefined) { + if (value === undefined) delete process.env[FLAG]; + else process.env[FLAG] = value; +} + +test.beforeEach(() => { + resetMistralAmbiguous401Strikes(); + setFlag(undefined); +}); + +test.after(() => { + setFlag(priorFlag); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +function bareMistral401() { + return checkFallbackError(401, BARE, 0, null, "mistral", null, null, null); +} + +test("one shared predicate: the connection-test diagnosis and the runtime agree", () => { + assert.equal(isMistralAmbiguous401("mistral", BARE), true); + assert.equal(isMistralAmbiguous401("mistral", "Invalid API key"), false); + assert.equal(isMistralAmbiguous401("openai", BARE), false); + assert.equal( + classifyFailure({ error: BARE, statusCode: 401, provider: "mistral" }).type, + "upstream_ambiguous_auth_or_quota" + ); + assert.equal( + classifyFailure({ error: "Token invalid", statusCode: 401, provider: "mistral" }).type, + "upstream_auth_error" + ); +}); + +test("flag off: a bare Mistral 401 stays an auth_error and resolves expired", () => { + const r = bareMistral401(); + assert.equal(r.reason, "auth_error"); + assert.equal(r.ambiguousAuth, undefined); + const type = classifyProviderError(401, BARE, "mistral"); + assert.equal( + resolveTerminalConnectionStatus(401, r, type, "mistral", false, BARE, "c1"), + "expired" + ); +}); + +test("flag on: a bare Mistral 401 backs off instead of asserting an auth failure", () => { + setFlag("true"); + const r = bareMistral401(); + assert.notEqual(r.reason, "auth_error"); + assert.equal(r.ambiguousAuth, true); + assert.equal(r.shouldFallback, true); + assert.ok(!r.permanent); + assert.ok(r.cooldownMs > 0, "a real cooldown, not an immediate reselect"); +}); + +test("flag on: explicit auth signals, other providers and operator rules are unchanged", () => { + setFlag("true"); + for (const body of ["Invalid API key", "token invalid", "revoked", "access denied"]) { + const r = checkFallbackError(401, body, 0, null, "mistral", null, null, null); + assert.equal(r.reason, "auth_error", body); + assert.equal(r.ambiguousAuth, undefined, body); + } + assert.equal( + checkFallbackError(401, BARE, 0, null, "openai", null, null, null).reason, + "auth_error" + ); + setOperatorProviderErrorRules({ + mistral: [{ status: 401, match: "unauthorized", scope: "connection", cooldownMs: 99999 }], + }); + try { + const r = bareMistral401(); + assert.equal(r.reason, "quota_exhausted"); + assert.equal(r.cooldownMs, 99999); + assert.equal(r.ambiguousAuth, undefined); + } finally { + setOperatorProviderErrorRules({}); + } +}); + +test("strike bound: 3 soft strikes per window, the 4th parks and restarts the count", () => { + const t0 = 1_000_000; + for (let i = 1; i <= MISTRAL_AMBIGUOUS_401_MAX_SOFT_STRIKES; i++) { + assert.equal(takeMistralAmbiguous401SoftStrike("conn", t0 + i), true, `strike ${i}`); + } + assert.equal(takeMistralAmbiguous401SoftStrike("conn", t0 + 10), false, "bound reached"); + assert.equal(takeMistralAmbiguous401SoftStrike("conn", t0 + 11), true, "fresh count after park"); + assert.equal(takeMistralAmbiguous401SoftStrike("other", t0 + 12), true, "per connection"); + // Strikes older than the window do not accumulate. + resetMistralAmbiguous401Strikes(); + for (let i = 0; i < MISTRAL_AMBIGUOUS_401_MAX_SOFT_STRIKES; i++) { + takeMistralAmbiguous401SoftStrike("slow", t0); + } + assert.equal( + takeMistralAmbiguous401SoftStrike("slow", t0 + MISTRAL_AMBIGUOUS_401_STRIKE_WINDOW_MS), + true + ); +}); + +test("resolveTerminalConnectionStatus ignores ambiguousAuth without a connection id or for other types", () => { + const r = { ambiguousAuth: true }; + assert.equal( + resolveTerminalConnectionStatus( + 401, + r, + PROVIDER_ERROR_TYPES.UNAUTHORIZED, + "mistral", + false, + BARE + ), + "expired" + ); + assert.equal( + resolveTerminalConnectionStatus( + 401, + {}, + PROVIDER_ERROR_TYPES.UNAUTHORIZED, + "mistral", + false, + BARE, + "c" + ), + "expired" + ); +}); + +async function createMistralConnection() { + const conn = await providersDb.createProviderConnection({ + provider: "mistral", + authType: "apikey", + apiKey: "mistral-test-key", + isActive: true, + testStatus: "active", + }); + return String(conn.id); +} + +async function expireCooldown(connId: string) { + // What selection-time auto-decay does once rateLimitedUntil has passed. + await providersDb.updateProviderConnection(connId, { + rateLimitedUntil: null, + testStatus: "active", + }); +} + +test("markAccountUnavailable, flag off: one bare Mistral 401 parks the connection as expired", async () => { + const connId = await createMistralConnection(); + + await auth.markAccountUnavailable(connId, 401, BARE, "mistral", "mistral-large-latest"); + + const after = await providersDb.getProviderConnectionById(connId); + assert.equal(after.testStatus, "expired"); +}); + +test("markAccountUnavailable, flag on: cooldown for 3 bare 401s, then expired", async () => { + setFlag("true"); + const connId = await createMistralConnection(); + + for (let strike = 1; strike <= MISTRAL_AMBIGUOUS_401_MAX_SOFT_STRIKES; strike++) { + await auth.markAccountUnavailable(connId, 401, BARE, "mistral", "mistral-large-latest"); + const cooling = await providersDb.getProviderConnectionById(connId); + assert.equal(cooling.testStatus, "unavailable", `strike ${strike} cools down`); + assert.ok( + new Date(String(cooling.rateLimitedUntil)).getTime() > Date.now(), + `strike ${strike} sets a future rateLimitedUntil` + ); + await expireCooldown(connId); + } + + await auth.markAccountUnavailable(connId, 401, BARE, "mistral", "mistral-large-latest"); + const parked = await providersDb.getProviderConnectionById(connId); + assert.equal(parked.testStatus, "expired", "a persistent bare 401 still converges"); +}); + +test("markAccountUnavailable, flag on: an explicit auth signal parks on the first 401", async () => { + setFlag("true"); + const connId = await createMistralConnection(); + + await auth.markAccountUnavailable( + connId, + 401, + "Invalid API key", + "mistral", + "mistral-large-latest" + ); + + const after = await providersDb.getProviderConnectionById(connId); + assert.equal(after.testStatus, "expired"); +}); diff --git a/tests/unit/proxy-health-blocked-outcome.test.ts b/tests/unit/proxy-health-blocked-outcome.test.ts index 3acb447cf2..63ab6d2b59 100644 --- a/tests/unit/proxy-health-blocked-outcome.test.ts +++ b/tests/unit/proxy-health-blocked-outcome.test.ts @@ -103,3 +103,63 @@ test("only the target-refusal statuses are flagged blocked across the whole rang } assert.deepEqual(flagged, [401, 403, 429]); }); + +// ─── policy E, opt-in: PROXY_HEALTH_BLOCKED_RESETS_STREAK (#13608) ─── +// The probe target needs no key, so a refusal is often the normal answer of a healthy proxy +// behind a shared egress IP. With the flag on, a refused relay resets the streak; status and +// removal stay untouched. A relayed 5xx is `inconclusive` and keeps the streak either way. + +test("flag on: blocked resets the streak, never advances it, never touches status", () => { + const d = decideProxyHealthAction({ + outcome: "blocked", + priorFailures: 2, + autoRemove: false, + autoDisable: false, + removeAfter: 3, + blockedResetsStreak: true, + }); + assert.deepEqual(d, { failures: 0, clearFailures: true, setStatus: null, remove: false }); +}); + +test("flag on: blocked still cannot remove, disable or re-activate a proxy", () => { + for (const managed of [ + { autoRemove: true, autoDisable: true }, + { autoRemove: false, autoDisable: true }, + { autoRemove: true, autoDisable: false }, + ]) { + const d = decideProxyHealthAction({ + outcome: "blocked", + priorFailures: 3, + removeAfter: 3, + blockedResetsStreak: true, + ...managed, + }); + assert.equal(d.remove, false); + assert.equal(d.setStatus, null, "a refusal is not proof of health: no re-activation"); + assert.equal(d.failures, 0); + } +}); + +test("flag on: an inconclusive (5xx/timeout) probe still keeps the streak", () => { + const d = decideProxyHealthAction({ + outcome: "inconclusive", + priorFailures: 2, + autoRemove: true, + removeAfter: 3, + blockedResetsStreak: true, + }); + assert.deepEqual(d, { failures: 2, clearFailures: false, setStatus: null, remove: false }); +}); + +test("flag explicitly off is the neutral default", () => { + const input = { + outcome: "blocked" as const, + priorFailures: 2, + autoRemove: false, + removeAfter: 3, + }; + assert.deepEqual( + decideProxyHealthAction({ ...input, blockedResetsStreak: false }), + decideProxyHealthAction(input) + ); +}); diff --git a/tests/unit/proxy-health-blocked-streak-sweep.test.ts b/tests/unit/proxy-health-blocked-streak-sweep.test.ts new file mode 100644 index 0000000000..389d3796c5 --- /dev/null +++ b/tests/unit/proxy-health-blocked-streak-sweep.test.ts @@ -0,0 +1,147 @@ +/** + * #13608 through the real sweep (forceProxyHealthSweep): fail -> blocked -> fail on one + * proxy with PROXY_AUTO_DISABLE=true and a threshold of 2. + * + * - Flag off (default, #10654): the refusal is neutral, the streak survives it, and the + * second failure soft-disables the proxy. + * - PROXY_HEALTH_BLOCKED_RESETS_STREAK on: the refusal resets the streak, so the second + * failure is only the first of a new streak and the proxy keeps its status. + * + * The refusal comes from a local HTTP proxy that relays to a local target answering 403; + * the failures come from the same port with nothing listening (immediate ECONNREFUSED). + * No outbound traffic. The sweep summary line is captured to pin its refusal tally. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import http from "node:http"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-blocked-streak-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-secret"; +process.env.OMNIROUTE_DISABLE_BACKGROUND_SERVICES = "true"; +process.env.PROXY_AUTO_DISABLE = "true"; +process.env.PROXY_AUTO_REMOVE_AFTER = "2"; +process.env.PROXY_HEALTH_TEST_STAGGER_MS = "0"; +delete process.env.PROXY_AUTO_REMOVE; +delete process.env.PROXY_HEALTH_BLOCKED_RESETS_STREAK; + +// The probe target: any request is refused, the way a destination refuses an egress IP. +const target = http.createServer((_req, res) => { + res.writeHead(403); + res.end(); +}); +await new Promise((resolve) => target.listen(0, "127.0.0.1", () => resolve())); +const targetPort = (target.address() as net.AddressInfo).port; +process.env.PROXY_HEALTH_TEST_URL = `http://127.0.0.1:${targetPort}/probe`; + +const core = await import("../../src/lib/db/core.ts"); +const proxiesDb = await import("../../src/lib/db/proxies.ts"); +const { forceProxyHealthSweep } = await import("../../src/lib/proxyHealth/scheduler.ts"); + +test.after(async () => { + delete process.env.PROXY_HEALTH_BLOCKED_RESETS_STREAK; + await new Promise((resolve) => target.close(() => resolve())); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +// A minimal forward proxy on a fixed port: absolute-form requests and CONNECT tunnels both +// reach the refusing target. Tunnel sockets are detached from the server, so they are tracked +// and destroyed on stop: a kept-alive tunnel must not turn the next "fail" into a refusal. +const tunnelSockets = new Set(); + +function startRelay(port: number): Promise { + const relay = http.createServer((req, res) => { + const upstream = http.request( + { host: "127.0.0.1", port: targetPort, method: req.method, path: "/probe" }, + (answer) => { + res.writeHead(answer.statusCode ?? 502); + answer.pipe(res); + } + ); + upstream.on("error", () => res.destroy()); + req.pipe(upstream); + }); + relay.on("connect", (_req, client, head) => { + tunnelSockets.add(client as net.Socket); + const socket = net.connect(targetPort, "127.0.0.1", () => { + client.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + socket.write(head); + socket.pipe(client); + client.pipe(socket); + }); + tunnelSockets.add(socket); + socket.on("error", () => client.destroy()); + client.on("error", () => socket.destroy()); + }); + return new Promise((resolve) => relay.listen(port, "127.0.0.1", () => resolve(relay))); +} + +function stopRelay(relay: http.Server): Promise { + for (const socket of tunnelSockets) socket.destroy(); + tunnelSockets.clear(); + relay.closeAllConnections(); + return new Promise((resolve) => relay.close(() => resolve())); +} + +async function freePort(): Promise { + const probe = net.createServer(); + await new Promise((resolve) => probe.listen(0, "127.0.0.1", () => resolve())); + const { port } = probe.address() as net.AddressInfo; + await new Promise((resolve) => probe.close(() => resolve())); + return port; +} + +async function sweepCapturingSummary(): Promise { + const lines: string[] = []; + const original = console.log; + console.log = (...args: unknown[]) => { + lines.push(args.map(String).join(" ")); + }; + try { + await forceProxyHealthSweep(); + } finally { + console.log = original; + } + return lines.find((line) => line.includes("Sweep complete")) ?? ""; +} + +async function failBlockedFail(): Promise<{ status: string | undefined; summaries: string[] }> { + const port = await freePort(); + const created = await proxiesDb.createProxy({ + name: `flaky ${port}`, + type: "http", + host: "127.0.0.1", + port, + }); + const summaries: string[] = []; + summaries.push(await sweepCapturingSummary()); // nothing listening: fail (streak 1) + const relay = await startRelay(port); + try { + summaries.push(await sweepCapturingSummary()); // relayed, target refused: blocked + } finally { + await stopRelay(relay); + } + summaries.push(await sweepCapturingSummary()); // nothing listening again: fail + const row = await proxiesDb.getProxyById(created!.id, { includeSecrets: false }); + await proxiesDb.deleteProxyById(created!.id, { force: true }); + return { status: (row as { status?: string } | null)?.status, summaries }; +} + +test("flag off (default): a refusal keeps the streak, the second failure disables", async () => { + delete process.env.PROXY_HEALTH_BLOCKED_RESETS_STREAK; + const { status, summaries } = await failBlockedFail(); + assert.match(summaries[1], /1 tested, 0 alive, 1 refused by target/); + assert.equal(status, "dead"); +}); + +test("flag on: a refusal resets the streak, the second failure does not disable", async () => { + process.env.PROXY_HEALTH_BLOCKED_RESETS_STREAK = "true"; + const { status, summaries } = await failBlockedFail(); + assert.match(summaries[1], /1 tested, 0 alive, 1 refused by target/); + assert.notEqual(status, "dead"); +}); diff --git a/tests/unit/proxy-health-refusal-memory.test.ts b/tests/unit/proxy-health-refusal-memory.test.ts new file mode 100644 index 0000000000..43f84a8010 --- /dev/null +++ b/tests/unit/proxy-health-refusal-memory.test.ts @@ -0,0 +1,64 @@ +import test, { mock } from "node:test"; +import assert from "node:assert/strict"; + +// The TCP reachability probe already runs for every proxied request. With the opt-in +// PROXY_SKIP_RECENTLY_FAILED flag on, its verdict feeds proxy selection: a refused probe sets +// the proxy aside, a successful one takes it back. With the flag off nothing is written. + +const health = await import("../../src/lib/proxyHealth.ts"); +const memory = await import("../../open-sse/utils/proxyRefusalMemory.ts"); + +const PROXY_URL = "http://10.7.0.1:8080"; +const KEY = memory.proxyEgressKey(PROXY_URL); + +test.beforeEach(() => { + memory.__resetProxyRefusalMemoryForTesting(); + health.invalidateProxyHealth(PROXY_URL); + process.env.PROXY_SKIP_RECENTLY_FAILED = "true"; +}); + +test.afterEach(() => { + mock.timers.reset(); + health.__setProxyHealthTcpCheckForTesting(null); + delete process.env.PROXY_SKIP_RECENTLY_FAILED; +}); + +test("a refused probe sets the proxy aside for 60 s, then 120 s on a repeat", async () => { + mock.timers.enable({ apis: ["Date"], now: 1_800_000_000_000 }); + health.__setProxyHealthTcpCheckForTesting(async () => false); + + assert.equal(await health.isProxyReachable(PROXY_URL), false); + const first = Date.now(); + assert.equal(memory.isProxyAvoided(KEY, first + 59_999), true); + assert.equal(memory.isProxyAvoided(KEY, first + 60_000), false); + + mock.timers.tick(61_000); + health.invalidateProxyHealth(PROXY_URL); + assert.equal(await health.isProxyReachable(PROXY_URL), false); + const second = Date.now(); + assert.equal(memory.isProxyAvoided(KEY, second + 119_999), true); + assert.equal(memory.isProxyAvoided(KEY, second + 120_000), false); +}); + +test("a probe that answers again ends the period", async () => { + health.__setProxyHealthTcpCheckForTesting(async () => false); + await health.isProxyReachable(PROXY_URL); + assert.equal(memory.isProxyAvoided(KEY), true); + + health.invalidateProxyHealth(PROXY_URL); + health.__setProxyHealthTcpCheckForTesting(async () => true); + assert.equal(await health.isProxyReachable(PROXY_URL), true); + assert.equal(memory.isProxyAvoided(KEY), false); +}); + +test("with the flag at its default (off) a refused probe writes nothing", async () => { + delete process.env.PROXY_SKIP_RECENTLY_FAILED; + health.__setProxyHealthTcpCheckForTesting(async () => false); + assert.equal(await health.isProxyReachable(PROXY_URL), false); + assert.equal(memory.__proxyRefusalMemorySizeForTesting(), 0); +}); + +test("a malformed proxy URL writes nothing", async () => { + assert.equal(await health.isProxyReachable("not a url"), false); + assert.equal(memory.__proxyRefusalMemorySizeForTesting(), 0); +}); diff --git a/tests/unit/proxy-logs-upstream-status.test.ts b/tests/unit/proxy-logs-upstream-status.test.ts new file mode 100644 index 0000000000..ab5a14dd6c --- /dev/null +++ b/tests/unit/proxy-logs-upstream-status.test.ts @@ -0,0 +1,86 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// proxy_logs keeps the HTTP status the provider actually returned, next to the textual +// status column. Persistence only runs in local mode (!isCloud && !isBuildPhase), which a +// fresh DATA_DIR keeps. +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-proxy-upstream-status-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const proxyLogger = await import("../../src/lib/proxyLogger.ts"); + +function resetStorage() { + proxyLogger.clearProxyLogs(); + core.closeDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(() => { + resetStorage(); +}); + +test.after(() => { + core.closeDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("a fresh install has upstream_status and the reconciler restores it", async () => { + const { ensureProxyLogsColumns, hasColumn } = await import("../../src/lib/db/schemaColumns.ts"); + const db = core.getDbInstance(); + assert.equal(hasColumn(db, "proxy_logs", "upstream_status"), true, "column after migration"); + db.exec("ALTER TABLE proxy_logs DROP COLUMN upstream_status"); + assert.equal(hasColumn(db, "proxy_logs", "upstream_status"), false); + ensureProxyLogsColumns(db); + assert.equal(hasColumn(db, "proxy_logs", "upstream_status"), true, "column restored"); + assert.doesNotThrow(() => ensureProxyLogsColumns(db)); +}); + +test("upstreamStatus is kept in memory, in SQLite and in the export", async () => { + proxyLogger.logProxyEvent({ + status: "error", + provider: "openai", + targetUrl: "openai/gpt-5", + upstreamStatus: 429, + }); + + const [entry] = proxyLogger.getProxyLogs(); + assert.equal(entry.upstreamStatus, 429); + + proxyLogger.flushProxyLogsSync(); + const row = core.getDbInstance().prepare("SELECT upstream_status FROM proxy_logs").get() as { + upstream_status: number | null; + }; + assert.equal(row.upstream_status, 429); + + const { exportProxyLogsSince } = await import("../../src/lib/db/proxyLogs.ts"); + const [exported] = exportProxyLogsSince("1970-01-01T00:00:00.000Z"); + assert.equal(exported.upstream_status, 429); +}); + +test("an entry without upstreamStatus stores null, never 0", () => { + proxyLogger.logProxyEvent({ status: "error", provider: "openai", targetUrl: "openai/gpt-5" }); + + const [entry] = proxyLogger.getProxyLogs(); + assert.equal(entry.upstreamStatus, null); + + proxyLogger.flushProxyLogsSync(); + const row = core.getDbInstance().prepare("SELECT upstream_status FROM proxy_logs").get() as { + upstream_status: number | null; + }; + assert.equal(row.upstream_status, null); +}); + +test("the textual status filters return the same rows as before", () => { + proxyLogger.logProxyEvent({ status: "success", provider: "openai", upstreamStatus: 200 }); + proxyLogger.logProxyEvent({ status: "error", provider: "openai", upstreamStatus: 429 }); + proxyLogger.logProxyEvent({ status: "error", provider: "openai" }); + + assert.equal(proxyLogger.getProxyLogs({ status: "ok" }).length, 1); + assert.equal(proxyLogger.getProxyLogs({ status: "error" }).length, 2); + assert.equal(proxyLogger.getProxyLogs({ status: "timeout" }).length, 0); +}); diff --git a/tests/unit/proxy-outcome-memory.test.ts b/tests/unit/proxy-outcome-memory.test.ts new file mode 100644 index 0000000000..67ea235c92 --- /dev/null +++ b/tests/unit/proxy-outcome-memory.test.ts @@ -0,0 +1,100 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// With PROXY_SKIP_RECENTLY_FAILED on, a pool member is set aside only on a refusal the +// provider really returned through it, and only for a provider inside the refusal scope. +// Locally generated failures carry no upstream outcome and never set a member aside. With +// the flag off (the default) nothing is ever written. + +const memory = await import("../../open-sse/utils/proxyRefusalMemory.ts"); +const { noteProxyOutcome } = await import("../../src/sse/handlers/proxyOutcomeMemory.ts"); +const { egressBucketedLockProviders } = await import("../../open-sse/config/providerErrorRules.ts"); + +const PROXY = { type: "http", host: "10.4.0.1", port: 8080 }; +const KEY = memory.proxyEgressKey(PROXY); + +test.beforeEach(() => { + memory.__resetProxyRefusalMemoryForTesting(); + process.env.PROXY_SKIP_RECENTLY_FAILED = "true"; +}); + +test.after(() => { + delete process.env.PROXY_SKIP_RECENTLY_FAILED; +}); + +test("a received refusal from an in-scope provider sets the member aside", () => { + for (const provider of [...egressBucketedLockProviders(), "OpenCode"]) { + memory.__resetProxyRefusalMemoryForTesting(); + noteProxyOutcome(provider, { proxy: PROXY, upstreamStatus: 429 }); + assert.equal(memory.isProxyAvoided(KEY), true, provider); + } +}); + +test("a refusal from a provider outside the scope writes nothing", () => { + for (const provider of ["opencode-zen", "openai", null]) { + noteProxyOutcome(provider, { proxy: PROXY, upstreamStatus: 429 }); + } + assert.equal(memory.__proxyRefusalMemorySizeForTesting(), 0); +}); + +test("no received status, no proxy or no proxyInfo writes nothing", () => { + noteProxyOutcome("opencode", { proxy: PROXY }); + noteProxyOutcome("opencode", { proxy: PROXY, upstreamStatus: null }); + noteProxyOutcome("opencode", { proxy: null, upstreamStatus: 429 }); + noteProxyOutcome("opencode", null); + noteProxyOutcome("opencode", undefined); + assert.equal(memory.__proxyRefusalMemorySizeForTesting(), 0); +}); + +test("a success from an in-scope provider clears the member, other outcomes leave it", () => { + noteProxyOutcome("opencode", { proxy: PROXY, upstreamStatus: 429 }); + noteProxyOutcome("opencode", { proxy: PROXY, upstreamStatus: 403 }); + noteProxyOutcome("opencode", { proxy: PROXY, upstreamStatus: 500 }); + assert.equal(memory.isProxyAvoided(KEY), true); + noteProxyOutcome("opencode-go", { proxy: PROXY, upstreamStatus: 200 }); + assert.equal(memory.isProxyAvoided(KEY), false); +}); + +test("a success from another provider only ends an unreachable period, not a refusal one", () => { + noteProxyOutcome("opencode", { proxy: PROXY, upstreamStatus: 429 }); + noteProxyOutcome("openai", { proxy: PROXY, upstreamStatus: 200 }); + assert.equal( + memory.isProxyAvoided(KEY), + true, + "a shared pool must not clear another scope's refusal" + ); + + memory.__resetProxyRefusalMemoryForTesting(); + memory.noteProxyRefusal(KEY, "proxy_unreachable"); + noteProxyOutcome("openai", { proxy: PROXY, upstreamStatus: 200 }); + assert.equal(memory.isProxyAvoided(KEY), false); +}); + +test("an edge relay is ignored: its status is the relay's", () => { + noteProxyOutcome("opencode", { + proxy: { type: "vercel", host: "relay.example.vercel.app", port: 443 }, + upstreamStatus: 429, + }); + noteProxyOutcome("opencode", { + proxy: { type: "Vercel", host: "relay.example.vercel.app", port: 443 }, + upstreamStatus: 429, + }); + assert.equal(memory.__proxyRefusalMemorySizeForTesting(), 0); +}); + +test("a second note while the proxy is already set aside changes nothing", () => { + const start = Date.now(); + const periodMs = memory.noteProxyRefusal(KEY, "ip_quota_429", start) ?? 0; + noteProxyOutcome("opencode", { proxy: PROXY, upstreamStatus: 429 }); + assert.equal(memory.__proxyRefusalMemorySizeForTesting(), 1); + assert.equal(memory.isProxyAvoided(KEY, start + periodMs - 1000), true); + assert.equal(memory.isProxyAvoided(KEY, start + periodMs + 1000), false); +}); + +test("with the flag at its default (off) a received refusal writes nothing", () => { + delete process.env.PROXY_SKIP_RECENTLY_FAILED; + for (const provider of egressBucketedLockProviders()) { + noteProxyOutcome(provider, { proxy: PROXY, upstreamStatus: 429 }); + } + assert.equal(memory.__proxyRefusalMemorySizeForTesting(), 0); +}); diff --git a/tests/unit/proxy-pool-egress-observation-route.test.ts b/tests/unit/proxy-pool-egress-observation-route.test.ts new file mode 100644 index 0000000000..f3d63b7656 --- /dev/null +++ b/tests/unit/proxy-pool-egress-observation-route.test.ts @@ -0,0 +1,159 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; + +// The pool egress observation has its own route so that a failure there can never break +// GET /api/settings/proxies/pool. These tests pin its shape, its null-on-failure contract, +// its opt-in flag (PROXY_POOL_EGRESS_OBSERVATION, default off) and its Zod parameter checks. + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-pool-egress-route-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-secret"; +delete process.env.INITIAL_PASSWORD; // auth not required in this test env + +const core = await import("../../src/lib/db/core.ts"); +const proxiesDb = await import("../../src/lib/db/proxies.ts"); +const observation = await import("../../src/lib/proxyPoolEgressObservation.ts"); +const { GET } = await import("../../src/app/api/settings/proxies/pool/egress-observation/route.ts"); +const poolRoute = await import("../../src/app/api/settings/proxies/pool/route.ts"); + +function resetStorage() { + delete process.env.INITIAL_PASSWORD; + process.env.PROXY_POOL_EGRESS_OBSERVATION = "true"; + observation.resetPoolEgressObservationCache(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(() => { + resetStorage(); +}); + +test.after(() => { + delete process.env.PROXY_POOL_EGRESS_OBSERVATION; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +function request(base: string, query: Record): Request { + const params = new URLSearchParams(query); + return new Request(`http://localhost${base}?${params.toString()}`, { method: "GET" }); +} + +const OBSERVATION_PATH = "/api/settings/proxies/pool/egress-observation"; + +async function pooledProxyWithTraffic() { + const proxy = await proxiesDb.createProxy({ + name: "member", + type: "http", + host: "10.9.1.1", + port: 21001, + }); + await proxiesDb.addProxyToScopePool("provider", "openai", proxy.id); + core + .getDbInstance() + .prepare( + `INSERT INTO proxy_logs (id, timestamp, status, proxy_type, proxy_host, proxy_port, level, connection_id, egress_ip) + VALUES (?, ?, 'success', 'http', '10.9.1.1', 21001, 'provider', 'c1', '203.0.113.1')` + ) + .run(randomUUID(), new Date(Date.now() - 60_000).toISOString()); +} + +test("returns the observation with exactly the four documented keys", async () => { + await pooledProxyWithTraffic(); + const response = await GET(request(OBSERVATION_PATH, { scope: "provider", scopeId: "openai" })); + assert.equal(response.status, 200); + const body = (await response.json()) as Record; + assert.deepEqual(Object.keys(body).sort(), [ + "connections", + "distinctExits", + "maxConnectionsOnOneExit", + "windowHours", + ]); + assert.deepEqual(body, { + connections: 1, + distinctExits: 1, + maxConnectionsOnOneExit: 1, + windowHours: 24, + }); +}); + +test("returns zeros for an empty pool and leaves the pool route untouched", async () => { + const response = await GET(request(OBSERVATION_PATH, { scope: "provider", scopeId: "nobody" })); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + connections: 0, + distinctExits: 0, + maxConnectionsOnOneExit: 0, + windowHours: 24, + }); + + const pool = await poolRoute.GET( + request("/api/settings/proxies/pool", { scope: "provider", scopeId: "nobody" }) + ); + assert.equal(pool.status, 200); + const poolBody = (await pool.json()) as Record; + assert.deepEqual(Object.keys(poolBody).sort(), ["members", "strategy", "total"]); +}); + +test("returns null with status 200 when the read fails", async () => { + await pooledProxyWithTraffic(); + const db = core.getDbInstance(); + db.exec("ALTER TABLE proxy_logs RENAME TO proxy_logs_hidden"); + try { + const response = await GET(request(OBSERVATION_PATH, { scope: "provider", scopeId: "openai" })); + assert.equal(response.status, 200); + assert.equal(await response.json(), null); + } finally { + db.exec("ALTER TABLE proxy_logs_hidden RENAME TO proxy_logs"); + } +}); + +test("returns null when the feature flag is at its default (off) or turned off", async () => { + await pooledProxyWithTraffic(); + for (const value of [undefined, "false"]) { + if (value === undefined) delete process.env.PROXY_POOL_EGRESS_OBSERVATION; + else process.env.PROXY_POOL_EGRESS_OBSERVATION = value; + observation.resetPoolEgressObservationCache(); + const response = await GET(request(OBSERVATION_PATH, { scope: "provider", scopeId: "openai" })); + assert.equal(response.status, 200); + assert.equal(await response.json(), null, String(value)); + } +}); + +test("rejects an unknown scope with 400 instead of reading the global pool", async () => { + const global = await proxiesDb.createProxy({ + name: "global member", + type: "http", + host: "10.9.2.2", + port: 21002, + }); + await proxiesDb.addProxyToScopePool("global", null, global.id); + for (const scope of ["globals", "GLOBAL", "tenant", " "]) { + const response = await GET(request(OBSERVATION_PATH, { scope, scopeId: "openai" })); + assert.equal(response.status, 400, scope); + const body = (await response.json()) as { error?: { message?: string } }; + assert.equal(typeof body?.error?.message, "string"); + assert.ok(!body.error!.message!.includes("at /"), "no stack trace in the error body"); + } +}); + +test("rejects an over-long scopeId with 400", async () => { + const response = await GET( + request(OBSERVATION_PATH, { scope: "provider", scopeId: "x".repeat(300) }) + ); + assert.equal(response.status, 400); +}); + +test("rejects a missing scope, or a missing scopeId outside global, like the pool route", async () => { + const noScope = await GET(request(OBSERVATION_PATH, {})); + assert.equal(noScope.status, 400); + const noScopeId = await GET(request(OBSERVATION_PATH, { scope: "provider" })); + assert.equal(noScopeId.status, 400); + const global = await GET(request(OBSERVATION_PATH, { scope: "global" })); + assert.equal(global.status, 200); +}); diff --git a/tests/unit/proxy-pool-egress-observation.test.ts b/tests/unit/proxy-pool-egress-observation.test.ts new file mode 100644 index 0000000000..a9d4ea6269 --- /dev/null +++ b/tests/unit/proxy-pool-egress-observation.test.ts @@ -0,0 +1,187 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; + +// A pool's member count says nothing about how many egress IPs really served it. These +// tests pin the observation read from the proxy log: numbers only, the scope normalized +// the same way the pool itself is read, and any failure isolated as null. The observation is +// opt-in (PROXY_POOL_EGRESS_OBSERVATION feature flag, default off): tests opt in explicitly. + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-pool-egress-obs-")); +process.env.DATA_DIR = TEST_DATA_DIR; +delete process.env.PROXY_POOL_EGRESS_OBSERVATION; + +const core = await import("../../src/lib/db/core.ts"); +const proxiesDb = await import("../../src/lib/db/proxies.ts"); +const observation = await import("../../src/lib/proxyPoolEgressObservation.ts"); + +const HOUR_MS = 60 * 60 * 1000; +let nextPort = 20000; + +function resetStorage() { + observation.resetPoolEgressObservationCache(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(() => { + process.env.PROXY_POOL_EGRESS_OBSERVATION = "true"; + resetStorage(); +}); + +test.after(() => { + delete process.env.PROXY_POOL_EGRESS_OBSERVATION; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +type Member = { host: string; port: number }; + +async function registryProxy(): Promise { + nextPort++; + const proxy = await proxiesDb.createProxy({ + name: `proxy ${nextPort}`, + type: "http", + host: "10.9.0.1", + port: nextPort, + }); + return { id: proxy.id, host: "10.9.0.1", port: nextPort }; +} + +async function poolMember(scope: string, scopeId: string | null): Promise { + const proxy = await registryProxy(); + await proxiesDb.addProxyToScopePool(scope, scopeId, proxy.id); + return proxy; +} + +function logRow( + member: Member, + egressIp: string | null, + connectionId: string | null, + ageMs = HOUR_MS +) { + core + .getDbInstance() + .prepare( + `INSERT INTO proxy_logs (id, timestamp, status, proxy_type, proxy_host, proxy_port, level, connection_id, egress_ip) + VALUES (?, ?, 'success', 'http', ?, ?, 'provider', ?, ?)` + ) + .run( + randomUUID(), + new Date(Date.now() - ageMs).toISOString(), + member.host, + member.port, + connectionId, + egressIp + ); +} + +test("counts distinct exits, connections and the busiest exit of a pool", async () => { + const a = await poolMember("provider", "openai"); + const b = await poolMember("provider", "openai"); + const c = await poolMember("provider", "openai"); + for (const conn of ["c1", "c2", "c3", "c4"]) logRow(a, "203.0.113.1", conn); + for (const conn of ["c5", "c6", "c7"]) logRow(a, "203.0.113.2", conn); + for (const conn of ["c8", "c9"]) logRow(b, "203.0.113.3", conn); + for (const conn of ["c10", "c11"]) logRow(b, "203.0.113.4", conn); + logRow(c, "203.0.113.5", "c12"); + logRow(c, "203.0.113.5", "c1"); + logRow(c, "203.0.113.5", "c12"); + + const outsider = await registryProxy(); + logRow(outsider, "198.51.100.9", "x1"); + logRow(a, null, "x2"); + logRow(a, "203.0.113.9", null); + logRow(a, "203.0.113.8", "x3", 25 * HOUR_MS); + + assert.deepEqual(observation.readPoolEgressObservation("provider", "openai"), { + connections: 12, + distinctExits: 5, + maxConnectionsOnOneExit: 4, + windowHours: 24, + }); +}); + +test("the global pool is read under its stored scope id", async () => { + const member = await poolMember("global", null); + logRow(member, "203.0.113.1", "c1"); + assert.deepEqual(observation.readPoolEgressObservation("global", null), { + connections: 1, + distinctExits: 1, + maxConnectionsOnOneExit: 1, + windowHours: 24, + }); +}); + +test("scope key is the account scope and shares its cache entry", async () => { + const member = await poolMember("account", "acc-1"); + logRow(member, "203.0.113.1", "c1"); + const now = Date.now(); + assert.equal(observation.readPoolEgressObservation("account", "acc-1", now)?.connections, 1); + + logRow(member, "203.0.113.1", "c2"); + assert.equal(observation.readPoolEgressObservation("key", "acc-1", now + 1000)?.connections, 1); + + observation.resetPoolEgressObservationCache(); + assert.equal(observation.readPoolEgressObservation("key", "acc-1", now + 2000)?.connections, 2); +}); + +test("an empty pool yields zeros, not null", async () => { + assert.deepEqual(observation.readPoolEgressObservation("provider", "nobody"), { + connections: 0, + distinctExits: 0, + maxConnectionsOnOneExit: 0, + windowHours: 24, + }); +}); + +test("a result is reused for 30 seconds, then read again", async () => { + const member = await poolMember("provider", "openai"); + logRow(member, "203.0.113.1", "c1"); + const now = Date.now(); + assert.equal(observation.readPoolEgressObservation("provider", "openai", now)?.connections, 1); + + logRow(member, "203.0.113.2", "c2"); + const cached = observation.readPoolEgressObservation("provider", "openai", now + 29_000); + assert.equal(cached?.connections, 1); + const fresh = observation.readPoolEgressObservation("provider", "openai", now + 31_000); + assert.equal(fresh?.connections, 2); +}); + +test("any SQL failure yields null and is not cached", async () => { + const member = await poolMember("provider", "openai"); + logRow(member, "203.0.113.1", "c1"); + const db = core.getDbInstance(); + db.exec("ALTER TABLE proxy_logs RENAME TO proxy_logs_hidden"); + try { + assert.equal(observation.readPoolEgressObservation("provider", "openai"), null); + } finally { + db.exec("ALTER TABLE proxy_logs_hidden RENAME TO proxy_logs"); + } + assert.equal(observation.readPoolEgressObservation("provider", "openai")?.connections, 1); +}); + +test("the observation stays off unless the feature flag is on", async () => { + const member = await poolMember("provider", "openai"); + logRow(member, "203.0.113.1", "c1"); + delete process.env.PROXY_POOL_EGRESS_OBSERVATION; + assert.equal(observation.readPoolEgressObservation("provider", "openai"), null, "default"); + for (const value of ["false", "0", "no", "off"]) { + process.env.PROXY_POOL_EGRESS_OBSERVATION = value; + assert.equal(observation.readPoolEgressObservation("provider", "openai"), null, value); + } + process.env.PROXY_POOL_EGRESS_OBSERVATION = "true"; + assert.equal(observation.readPoolEgressObservation("provider", "openai")?.connections, 1); +}); + +test("a DB override turning the flag off wins over the environment", async () => { + const flagsDb = await import("../../src/lib/db/featureFlags.ts"); + const member = await poolMember("provider", "openai"); + logRow(member, "203.0.113.1", "c1"); + flagsDb.setFeatureFlagOverride("PROXY_POOL_EGRESS_OBSERVATION", "false"); + assert.equal(observation.readPoolEgressObservation("provider", "openai"), null); +}); diff --git a/tests/unit/proxy-pool-skips-refused-member.test.ts b/tests/unit/proxy-pool-skips-refused-member.test.ts new file mode 100644 index 0000000000..c2dc6c293c --- /dev/null +++ b/tests/unit/proxy-pool-skips-refused-member.test.ts @@ -0,0 +1,233 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// With PROXY_SKIP_RECENTLY_FAILED on, pool selection skips members that just failed, for +// every rotation strategy, and the per-connection resolution cache stops re-serving such a +// member (once per set-aside event, never a DB cascade per request). With every member set +// aside, or the flag off (the default), selection is exactly what it was. + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-pool-skip-refused-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const proxiesDb = await import("../../src/lib/db/proxies.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); +const memory = await import("../../open-sse/utils/proxyRefusalMemory.ts"); +const flagsDb = await import("../../src/lib/db/featureFlags.ts"); + +function resetStorage() { + memory.__resetProxyRefusalMemoryForTesting(); + // Opt in for every test; the flag-off tests remove it explicitly. + process.env.PROXY_SKIP_RECENTLY_FAILED = "true"; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(() => { + resetStorage(); +}); + +test.after(() => { + delete process.env.PROXY_SKIP_RECENTLY_FAILED; + memory.__resetProxyRefusalMemoryForTesting(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +type Member = { id: string; host: string; port: number }; +let seq = 0; + +async function pool(size: number, scope = "provider", scopeId = "openai"): Promise { + const members: Member[] = []; + for (let i = 0; i < size; i++) { + seq++; + const host = `10.8.0.${seq}`; + const port = 9100 + seq; + const proxy = await proxiesDb.createProxy({ name: `member ${seq}`, type: "http", host, port }); + await proxiesDb.addProxyToScopePool(scope, scopeId, proxy.id); + members.push({ id: proxy.id, host, port }); + } + return members; +} + +function keyOf(member: Member) { + return memory.proxyEgressKey({ type: "http", host: member.host, port: member.port }); +} + +function setAside(member: Member) { + memory.noteProxyRefusal(keyOf(member), "proxy_unreachable"); +} + +async function pick(scope = "provider", scopeId = "openai") { + const resolved = await proxiesDb.resolveProxyForScopeFromRegistry(scope, scopeId); + return (resolved as { proxy: { host: string } }).proxy.host; +} + +async function picks(count: number) { + const hosts: string[] = []; + for (let i = 0; i < count; i++) hosts.push(await pick()); + return hosts; +} + +function rotationRow() { + return core + .getDbInstance() + .prepare( + "SELECT cursor, rotated_at FROM proxy_scope_rotation WHERE scope = 'provider' AND scope_id IS 'openai'" + ) + .get() as { cursor: number; rotated_at: string | null }; +} + +test("round-robin skips a member set aside: A, C, A, C", async () => { + const [a, b, c] = await pool(3); + setAside(b); + assert.deepEqual(await picks(4), [a.host, c.host, a.host, c.host]); +}); + +test("round-robin wraps past the last member and advances beyond the one served", async () => { + const [a, b, c] = await pool(3); + await pick(); + core + .getDbInstance() + .prepare( + "UPDATE proxy_scope_rotation SET cursor = 2 WHERE scope = 'provider' AND scope_id IS 'openai'" + ) + .run(); + setAside(c); + assert.equal(await pick(), a.host); + assert.equal(rotationRow().cursor, 4); + assert.equal(await pick(), b.host); +}); + +test("sticky moves to the next eligible member without writing the rotation row", async () => { + const [, b, c] = await pool(3); + await proxiesDb.setScopeRotationStrategy("provider", "openai", "sticky", { + stickyWindowMinutes: 30, + }); + const held = await pick(); + const before = rotationRow(); + const heldMember = [b, c].find((m) => m.host === held) ?? null; + assert.ok(heldMember, `sticky first pick should be B or C, got ${held}`); + setAside(heldMember); + + const next = await pick(); + assert.notEqual(next, held); + assert.deepEqual(rotationRow(), before); +}); + +test("random never draws a member set aside", async () => { + const [a] = await pool(3); + await proxiesDb.setScopeRotationStrategy("provider", "openai", "random"); + setAside(a); + for (let i = 0; i < 1000; i++) assert.notEqual(await pick(), a.host); +}); + +test("latency picks among the eligible members", async () => { + const [a, b] = await pool(3); + await proxiesDb.setScopeRotationStrategy("provider", "openai", "latency"); + setAside(a); + assert.equal(await pick(), b.host); +}); + +test("with every member set aside the pool behaves as before", async () => { + const members = await pool(3); + for (const member of members) setAside(member); + assert.deepEqual( + await picks(3), + members.map((m) => m.host) + ); +}); + +test("with the flag at its default (off) a member set aside is still served in turn", async () => { + const members = await pool(3); + setAside(members[1]); + delete process.env.PROXY_SKIP_RECENTLY_FAILED; + assert.deepEqual( + await picks(3), + members.map((m) => m.host) + ); + assert.equal(rotationRow().cursor, 3); +}); + +test("a DB override turning the flag off wins over the environment", async () => { + const members = await pool(3); + setAside(members[1]); + flagsDb.setFeatureFlagOverride("PROXY_SKIP_RECENTLY_FAILED", "false"); + assert.deepEqual( + await picks(3), + members.map((m) => m.host) + ); + flagsDb.setFeatureFlagOverride("PROXY_SKIP_RECENTLY_FAILED", "true"); + delete process.env.PROXY_SKIP_RECENTLY_FAILED; + assert.deepEqual(await picks(2), [members[0].host, members[2].host]); +}); + +test("a connection's cached pool member is not re-served once set aside", async () => { + const [a, b, c] = await pool(3, "account", "conn-pool"); + const first = await settingsDb.resolveProxyForConnection("conn-pool"); + assert.equal((first as { proxy: { host: string } }).proxy.host, a.host); + assert.strictEqual(await settingsDb.resolveProxyForConnection("conn-pool"), first); + + setAside(a); + const next = await settingsDb.resolveProxyForConnection("conn-pool"); + assert.equal((next as { proxy: { host: string } }).proxy.host, b.host); + + memory.noteProxyRecovered(keyOf(a), "proxy_unreachable"); + assert.equal(memory.isProxyAvoided(keyOf(a)), false); + assert.deepEqual( + [await pick("account", "conn-pool"), await pick("account", "conn-pool")], + [c.host, a.host] + ); +}); + +test("with the flag off a connection keeps its cached pool member even once set aside", async () => { + const [a] = await pool(3, "account", "conn-off"); + delete process.env.PROXY_SKIP_RECENTLY_FAILED; + const first = await settingsDb.resolveProxyForConnection("conn-off"); + assert.equal((first as { proxy: { host: string } }).proxy.host, a.host); + setAside(a); + assert.strictEqual(await settingsDb.resolveProxyForConnection("conn-off"), first); +}); + +test("with every member set aside the cascade re-runs once, not on every request", async () => { + // Round-robin advances its persisted cursor on each cascade run, so the cursor counts + // how many times the registry pool was actually queried for this connection. + const [a, b] = await pool(2, "account", "conn-all"); + const cursor = () => + ( + core + .getDbInstance() + .prepare( + "SELECT cursor FROM proxy_scope_rotation WHERE scope = 'account' AND scope_id IS 'conn-all'" + ) + .get() as { cursor: number } + ).cursor; + + const first = await settingsDb.resolveProxyForConnection("conn-all"); + assert.equal((first as { proxy: { host: string } }).proxy.host, a.host); + assert.equal(cursor(), 1); + + setAside(a); + setAside(b); + const second = await settingsDb.resolveProxyForConnection("conn-all"); + assert.equal((second as { proxy: { host: string } }).proxy.host, b.host); + assert.equal(cursor(), 2); + + for (let i = 0; i < 5; i++) { + assert.strictEqual(await settingsDb.resolveProxyForConnection("conn-all"), second); + } + assert.equal(cursor(), 2, "a member set aside before the entry was cached must not bypass it"); +}); + +test("a legacy single-proxy level stays cached even when its proxy is set aside", async () => { + await settingsDb.setProxyForLevel("key", "conn-legacy", "http://10.9.9.9:8080"); + const first = await settingsDb.resolveProxyForConnection("conn-legacy"); + assert.equal((first as { level: string }).level, "key"); + memory.noteProxyRefusal(memory.proxyEgressKey("http://10.9.9.9:8080"), "proxy_unreachable"); + assert.strictEqual(await settingsDb.resolveProxyForConnection("conn-legacy"), first); +}); diff --git a/tests/unit/proxy-refusal-memory.test.ts b/tests/unit/proxy-refusal-memory.test.ts new file mode 100644 index 0000000000..9f940f7c38 --- /dev/null +++ b/tests/unit/proxy-refusal-memory.test.ts @@ -0,0 +1,199 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// A per-process memory of proxies that just failed, shared by pool selection and the +// per-account rotation. One canonical key per entry point, a period that doubles on each +// repeat up to a cap, and a null key that never sets anything aside. The store is pure: +// whether it is consulted is decided by the PROXY_SKIP_RECENTLY_FAILED flag at call sites. + +const memory = await import("../../open-sse/utils/proxyRefusalMemory.ts"); + +const MIN = 60_000; +const START_MS = 1_800_000_000_000; + +test.beforeEach(() => { + memory.__resetProxyRefusalMemoryForTesting(); +}); + +test("an object, its URL and a legacy string give the same key", () => { + const fromObject = memory.proxyEgressKey({ + type: "http", + host: "H", + port: 8080, + username: "a@b", + password: "pw", + }); + assert.equal(fromObject, "http://a@b@h:8080"); + assert.equal(memory.proxyEgressKey("http://a%40b:pw@h:8080"), fromObject); + assert.equal(memory.proxyEgressKey("http://a%40b:pw@H:8080"), fromObject); +}); + +test("the password and the family marker are not part of the key, the username is", () => { + const base = memory.proxyEgressKey("http://a%40b:pw@h:8080"); + assert.equal(memory.proxyEgressKey("http://a%40b:other@h:8080"), base); + assert.equal(memory.proxyEgressKey("http://a%40b:pw@h:8080?family=ipv6"), base); + assert.notEqual(memory.proxyEgressKey("http://c:pw@h:8080"), base); +}); + +test("an IPv6 host gives the same non-null key as an object or a URL", () => { + const fromObject = memory.proxyEgressKey({ type: "http", host: "::1", port: 8080 }); + assert.equal(fromObject, "http://@::1:8080"); + assert.equal(memory.proxyEgressKey("http://[::1]:8080"), fromObject); +}); + +test("invalid input, null, undefined and relays give a null key without throwing", () => { + for (const input of ["not a url", null, undefined, { type: "http" }, 42]) { + assert.equal(memory.proxyEgressKey(input), null, String(input)); + } + assert.equal(memory.proxyEgressKey({ type: "vercel", host: "x.vercel.app", port: 443 }), null); +}); + +test("repeated refusals double the period up to the one-hour cap", () => { + const key = "http://@h:8080"; + const periods: Array = []; + let now = START_MS; + for (let i = 0; i < 7; i++) { + const period = memory.noteProxyRefusal(key, "ip_quota_429", now); + periods.push(period); + now += period ?? 0; + } + assert.deepEqual(periods, [2 * MIN, 4 * MIN, 8 * MIN, 16 * MIN, 32 * MIN, 60 * MIN, 60 * MIN]); +}); + +test("a note while the proxy is set aside changes nothing", () => { + const key = "http://@h:8080"; + assert.equal(memory.noteProxyRefusal(key, "ip_quota_429", START_MS), 2 * MIN); + assert.equal(memory.noteProxyRefusal(key, "ip_quota_429", START_MS + MIN), null); + assert.equal(memory.isProxyAvoided(key, START_MS + 2 * MIN - 1), true); + assert.equal(memory.isProxyAvoided(key, START_MS + 2 * MIN), false); +}); + +test("recovery ends the period but keeps the streak for a repeat", () => { + const key = "http://@h:8080"; + memory.noteProxyRefusal(key, "proxy_unreachable", START_MS); + memory.noteProxyRecovered(key, "proxy_unreachable", START_MS + 10_000); + assert.equal(memory.isProxyAvoided(key, START_MS + 10_000), false); + assert.equal(memory.noteProxyRefusal(key, "proxy_unreachable", START_MS + 20_000), 2 * MIN); +}); + +test("a served response forgets every refusal kind for that key", () => { + const key = "http://@h:8080"; + memory.noteProxyRefusal(key, "proxy_unreachable", START_MS); + memory.noteProxyRefusal(key, "ip_quota_429", START_MS); + memory.noteProxyServed(key); + assert.equal(memory.isProxyAvoided(key, START_MS + 1), false); + assert.equal(memory.__proxyRefusalMemorySizeForTesting(), 0); +}); + +test("an old streak is purged per kind: unreachable 20 min, refusal 2 h", () => { + const key = "http://@h:8080"; + memory.noteProxyRefusal(key, "proxy_unreachable", START_MS); + const endUnreachable = START_MS + MIN; + assert.equal( + memory.noteProxyRefusal(key, "proxy_unreachable", endUnreachable + 19 * MIN), + 2 * MIN + ); + + memory.__resetProxyRefusalMemoryForTesting(); + memory.noteProxyRefusal(key, "proxy_unreachable", START_MS); + assert.equal(memory.noteProxyRefusal(key, "proxy_unreachable", endUnreachable + 20 * MIN), MIN); + + memory.__resetProxyRefusalMemoryForTesting(); + memory.noteProxyRefusal(key, "ip_quota_429", START_MS); + const endRefusal = START_MS + 2 * MIN; + assert.equal(memory.noteProxyRefusal(key, "ip_quota_429", endRefusal + 119 * MIN), 4 * MIN); + memory.__resetProxyRefusalMemoryForTesting(); + memory.noteProxyRefusal(key, "ip_quota_429", START_MS); + assert.equal(memory.noteProxyRefusal(key, "ip_quota_429", endRefusal + 120 * MIN), 2 * MIN); +}); + +test("a null key never writes and is never set aside", () => { + assert.equal(memory.noteProxyRefusal(null, "ip_quota_429", START_MS), null); + memory.noteProxyRecovered(null, "proxy_unreachable", START_MS); + memory.noteProxyServed(null); + assert.equal(memory.isProxyAvoided(null, START_MS), false); + assert.equal(memory.__proxyRefusalMemorySizeForTesting(), 0); +}); + +test("the memory keeps at most 1000 entries and evicts the oldest", () => { + for (let i = 0; i < 1001; i++) { + memory.noteProxyRefusal(`http://@h:${10000 + i}`, "ip_quota_429", START_MS); + } + assert.equal(memory.__proxyRefusalMemorySizeForTesting(), 1000); + assert.equal(memory.isProxyAvoided("http://@h:10000", START_MS + 1), false); + assert.equal(memory.isProxyAvoided("http://@h:11000", START_MS + 1), true); +}); + +test("the key matches the one derived from the dispatcher's normalized proxy URL", async () => { + // The memory module computes keys without importing the proxy dispatcher (so the DB layer + // can consult it cheaply). Guard against drift from proxyConfigToUrl() normalization. + const { proxyConfigToUrl } = await import("../../open-sse/utils/proxyDispatcher.ts"); + const authority = /^([a-z0-9+.-]+):\/\/(?:([^@/]*)@)?(\[[^\]]+\]|[^:/?#]+):(\d+)/i; + const viaDispatcher = (input: unknown) => { + let normalizedInput = input; + if (input && typeof input === "object") { + const host = (input as { host?: string }).host; + if (typeof host === "string" && host.includes(":") && !host.startsWith("[")) { + normalizedInput = { ...(input as object), host: `[${host}]` }; + } + } + const url = proxyConfigToUrl(normalizedInput, { allowSocks5: true }); + const match = url ? authority.exec(url) : null; + if (!match) return null; + const [, scheme, userinfo, host, port] = match; + const user = userinfo ? decodeURIComponent(userinfo.split(":")[0]) : ""; + const bare = host.startsWith("[") ? host.slice(1, -1) : host; + return `${scheme.toLowerCase()}://${user}@${bare.toLowerCase()}:${port}`; + }; + const inputs: unknown[] = [ + { type: "http", host: "Proxy.Example.com", port: 3128, username: "u s", password: "p" }, + { type: "https", host: "h", port: "443" }, + { type: "socks5", host: "10.0.0.2", username: "a@b", password: "x:y" }, + { type: "http", host: "h" }, + { type: "http", host: "2001:db8::1", port: 8080, family: "ipv6" }, + { host: "h", port: 80 }, + "http://user:pw@H:80", + "https://h", + "socks5://a%40b:pw@10.0.0.3:1080?family=ipv4", + "http://[2001:db8::2]:3128", + "http://h:8080/", + ]; + for (const input of inputs) { + assert.equal(memory.proxyEgressKey(input), viaDispatcher(input), JSON.stringify(input)); + } +}); + +test("an out-of-range port or an unsupported scheme gives a null key", () => { + assert.equal(memory.proxyEgressKey({ type: "http", host: "h", port: 70000 }), null); + assert.equal(memory.proxyEgressKey({ type: "ftp", host: "h", port: 21 }), null); + assert.equal(memory.proxyEgressKey("ftp://h:21"), null); +}); + +test("set-aside events are ordered, and only the one in force is reported", () => { + const a = "http://@a:8080"; + const b = "http://@b:8080"; + assert.equal(memory.hasProxyRefusals(), false); + assert.equal(memory.proxySetAsideSeq(a, START_MS), null); + const before = memory.getProxyRefusalSeq(); + + memory.noteProxyRefusal(a, "proxy_unreachable", START_MS); + const seqA = memory.proxySetAsideSeq(a, START_MS + 1); + assert.ok(seqA !== null && seqA > before); + assert.equal(memory.hasProxyRefusals(), true); + + // A note while already set aside records no new event. + memory.noteProxyRefusal(a, "proxy_unreachable", START_MS + 2); + assert.equal(memory.proxySetAsideSeq(a, START_MS + 3), seqA); + + memory.noteProxyRefusal(b, "ip_quota_429", START_MS + 4); + const seqB = memory.proxySetAsideSeq(b, START_MS + 5); + assert.ok(seqB !== null && seqB > seqA); + assert.equal(memory.getProxyRefusalSeq(), seqB); + + // A second kind on the same proxy reports the most recent event. + memory.noteProxyRefusal(a, "ip_quota_429", START_MS + 6); + assert.equal(memory.proxySetAsideSeq(a, START_MS + 7), memory.getProxyRefusalSeq()); + + // Once every period is over nothing is in force. + assert.equal(memory.proxySetAsideSeq(b, START_MS + 4 + 2 * MIN), null); +}); diff --git a/tests/unit/proxyfetch-upstream-status-capture.test.ts b/tests/unit/proxyfetch-upstream-status-capture.test.ts new file mode 100644 index 0000000000..47e2eb191a --- /dev/null +++ b/tests/unit/proxyfetch-upstream-status-capture.test.ts @@ -0,0 +1,143 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; + +// End to end through the real patched fetch: the status of a provider dispatch reaches +// the applied-proxy sink, whichever fetch path serves it, while a side fetch made later in +// the same request does not overwrite it. + +const proxyFetchModule = await import("../../open-sse/utils/proxyFetch.ts"); +const { runWithCapture } = await import("../../open-sse/utils/providerRequestLogging.ts"); + +const capture = { capture: () => {}, body: (fallback: unknown) => fallback }; +let server: http.Server; +let baseUrl = ""; + +test.before(async () => { + server = http.createServer((req, res) => { + const params = new URL(req.url ?? "/", "http://local").searchParams; + setTimeout( + () => { + res.writeHead(Number(params.get("code") ?? 200), { "content-type": "application/json" }); + res.end("{}"); + }, + Number(params.get("delay") ?? 0) + ); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +test.after(async () => { + await new Promise((resolve) => server.close(() => resolve())); +}); + +type Sink = { proxy: unknown; upstreamStatus?: number }; + +function inRequest(sink: Sink, fn: () => Promise) { + return proxyFetchModule.runWithAppliedProxyCapture(sink, fn); +} + +async function dispatch(fn: () => Promise) { + await runWithCapture(capture, async () => { + const res = await fn(); + await res.text(); + }); +} + +test("a provider dispatch records the received status", async () => { + const sink: Sink = { proxy: null }; + await inRequest(sink, () => dispatch(() => fetch(`${baseUrl}/v1?code=429`))); + assert.equal(sink.upstreamStatus, 429); +}); + +test("the last response received during the dispatch wins", async () => { + const sink: Sink = { proxy: null }; + await inRequest(sink, () => + dispatch(async () => { + await (await fetch(`${baseUrl}/v1?code=200`)).text(); + return fetch(`${baseUrl}/v1?code=500`); + }) + ); + assert.equal(sink.upstreamStatus, 500); +}); + +test("a fetch still running when the dispatch returns does not change the status", async () => { + const sink: Sink = { proxy: null }; + let background: Promise = Promise.resolve(); + await inRequest(sink, async () => { + await dispatch(async () => { + const res = await fetch(`${baseUrl}/v1?code=429`); + background = Promise.all([ + fetch(`${baseUrl}/finish?code=200&delay=50`).then((r) => r.text()), + fetch("http://127.0.0.1:1/finish").catch(() => null), + ]); + return res; + }); + await background; + }); + assert.equal(sink.upstreamStatus, 429); +}); + +test("a side fetch after the dispatch keeps the provider status", async () => { + const sink: Sink = { proxy: null }; + await inRequest(sink, async () => { + await dispatch(() => fetch(`${baseUrl}/v1?code=429`)); + const side = await fetch(`${baseUrl}/usage?code=500`); + await side.text(); + }); + assert.equal(sink.upstreamStatus, 429); +}); + +test("a second dispatch without any fetch leaves nothing stale behind", async () => { + const sink: Sink = { proxy: null }; + await inRequest(sink, async () => { + await dispatch(() => fetch(`${baseUrl}/v1?code=429`)); + assert.equal(sink.upstreamStatus, 429); + await runWithCapture(capture, async () => {}); + }); + assert.equal(sink.upstreamStatus, undefined); +}); + +test("a network error on the next dispatch clears the earlier status", async () => { + const sink: Sink = { proxy: null }; + await inRequest(sink, async () => { + await dispatch(() => fetch(`${baseUrl}/v1?code=429`)); + await assert.rejects(dispatch(() => fetch("http://127.0.0.1:1/v1"))); + }); + assert.equal(sink.upstreamStatus, undefined); +}); + +test("the explicit-dispatcher path is captured", async () => { + const sink: Sink = { proxy: null }; + const undiciFetch = async () => new Response(null, { status: 502 }); + await inRequest(sink, () => + dispatch(() => + proxyFetchModule.proxyFetch(`${baseUrl}/v1`, { dispatcher: {} } as RequestInit, { + undiciFetch, + }) + ) + ); + assert.equal(sink.upstreamStatus, 502); +}); + +test("the explicit direct-context path is captured", async () => { + const sink: Sink = { proxy: null }; + await inRequest(sink, () => + dispatch(() => + proxyFetchModule.runWithDirectFetchContext(() => fetch(`${baseUrl}/v1?code=403`)) + ) + ); + assert.equal(sink.upstreamStatus, 403); +}); + +test("a dispatch without any sink in scope still works", async () => { + let status = 0; + await runWithCapture(capture, async () => { + const res = await fetch(`${baseUrl}/v1?code=200`); + status = res.status; + await res.text(); + }); + assert.equal(status, 200); +}); diff --git a/tests/unit/quota-bounded-map.test.ts b/tests/unit/quota-bounded-map.test.ts new file mode 100644 index 0000000000..5781fb5237 --- /dev/null +++ b/tests/unit/quota-bounded-map.test.ts @@ -0,0 +1,316 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { boundedMap } = await import("../../src/lib/quota/boundedMap.ts"); +const core = await import("../../src/lib/db/core.ts"); + +type LogLine = { meta: Record; message: string }; +function captureLog() { + const lines: LogLine[] = []; + return { + lines, + log: { + warn: (meta: Record, message: string) => lines.push({ meta, message }), + }, + }; +} + +test.after(() => { + core.resetDbInstance(); +}); + +// ── boundedMap primitives ──────────────────────────────────────────────────── + +test("lru: evicts the least-recently-used entry and get refreshes recency", () => { + const { log } = captureLog(); + const m = boundedMap("t", 3, "lru", 0, { log }); + m.set("a", 1); + m.set("b", 2); + m.set("c", 3); + assert.equal(m.get("a"), 1); // a is now the most recent + m.set("d", 4); // evicts b + assert.equal(m.get("b"), undefined); + assert.equal(m.get("a"), 1); + assert.equal(m.size, 3); + assert.deepEqual(m.stats(), { evictions: 1, overflowInserts: 0 }); +}); + +test("ttl: reads never refresh — the oldest-written entry is evicted", () => { + const { log } = captureLog(); + const m = boundedMap("t", 3, "ttl", 60_000, { log }); + m.set("a", 1, 0); + m.set("b", 2, 1); + m.set("c", 3, 2); + assert.equal(m.get("a", 3), 1); // a read, but ttl ignores recency + m.set("d", 4, 4); // evicts a (oldest write), unlike lru which would evict b + assert.equal(m.get("a", 5), undefined); + assert.equal(m.get("b", 5), 2); +}); + +test("ttl: entries expire after ttlMs; lru entries never expire", () => { + const ttl = boundedMap("t", 10, "ttl", 1000); + ttl.set("a", 1, 0); + assert.equal(ttl.get("a", 1000), 1); + assert.equal(ttl.get("a", 1001), undefined); + assert.equal(ttl.size, 0, "an expired read drops the entry"); + + const lru = boundedMap("t", 10, "lru", 1000); + lru.set("a", 1, 0); + assert.equal(lru.get("a", 10_000_000), 1); +}); + +test("ttl: expired entries are swept before any fresh entry is evicted", () => { + const { log } = captureLog(); + const m = boundedMap("t", 3, "ttl", 100, { log }); + m.set("old1", 1, 0); + m.set("fresh", 2, 150); + m.set("old2", 3, 0); + m.set("new", 4, 160); // old1 + old2 expired at 160 → swept, fresh survives + assert.equal(m.get("fresh", 170), 2); + assert.equal(m.get("new", 170), 4); + assert.equal(m.stats().evictions, 0, "a sweep of expired entries is not an eviction"); +}); + +test("protected entries are never evicted: the map grows past the cap instead", () => { + const { log } = captureLog(); + const m = boundedMap<{ pin: boolean }>("t", 2, "lru", 0, { + shouldEvict: (v) => !v.pin, + log, + }); + m.set("pin1", { pin: true }); + m.set("x", { pin: false }); + m.set("y", { pin: false }); // evicts x, the only evictable entry + assert.equal(m.get("x"), undefined); + m.set("pin2", { pin: true }); // evicts y + m.set("pin3", { pin: true }); // nothing evictable → grows + assert.equal(m.size, 3); + for (const key of ["pin1", "pin2", "pin3"]) assert.deepEqual(m.get(key), { pin: true }); + assert.deepEqual(m.stats(), { evictions: 2, overflowInserts: 1 }); +}); + +test("eviction logging is aggregated and rate-limited, never one line per eviction", () => { + const { lines, log } = captureLog(); + const m = boundedMap("hot-cache", 10, "lru", 0, { log, logIntervalMs: 60_000 }); + for (let i = 0; i < 10; i++) m.set(`seed-${i}`, i, 0); + for (let i = 0; i < 1000; i++) m.set(`k-${i}`, i, 1000 + i); // 1000 evictions in ~1s + assert.equal(lines.length, 1, "first eviction logs once, the rest are aggregated"); + assert.equal(lines[0].meta.map, "hot-cache"); + assert.equal(lines[0].meta.evicted, 1); + + m.set("late", 1, 1000 + 61_000); // past the interval → one summary line + assert.equal(lines.length, 2); + assert.equal( + lines[1].meta.evicted, + 1000, + "the summary carries every eviction since the last line" + ); + assert.match(lines[1].message, /\[boundedMap:hot-cache\] cap 10 reached: evicted 1000 entries/); +}); + +test("the default logger is the project logger, not console.warn", () => { + const original = console.warn; + let consoleWarnings = 0; + console.warn = () => { + consoleWarnings += 1; + }; + try { + const m = boundedMap("console-check", 1, "lru"); + m.set("a", 1); + m.set("b", 2); + m.set("c", 3); + assert.equal(m.stats().evictions, 2); + } finally { + console.warn = original; + } + assert.equal(consoleWarnings, 0); +}); + +test("keys() iteration tolerates delete during iteration", () => { + const m = boundedMap("t", 10, "lru"); + m.set("a", 1); + m.set("b", 2); + for (const key of m.keys()) { + if (key === "a") m.delete(key); + } + assert.deepEqual([...m.keys()], ["b"]); +}); + +// ── account buckets: never fail open ───────────────────────────────────────── + +test("account buckets never evict a live saturated bucket, even past the soft cap", async () => { + const b = await import("../../src/lib/quota/accountBuckets.ts"); + b._clearBucketsForTest(); + const now = 1_800_000_000_000; + const future = new Date(now + 3_600_000).toISOString(); + try { + const total = b.ACCOUNT_BUCKETS_SOFT_CAP + 25; + for (let i = 0; i < total; i++) b.recordUsage(`conn-live-${i}`, "5h", 100, future, now); + assert.equal(b._bucketCountForTest(), total, "no saturated bucket was dropped"); + assert.equal(b.isBucketSaturated("conn-live-0", "5h", now + 1), true, "oldest still saturated"); + assert.equal(b.isBucketSaturated(`conn-live-${total - 1}`, "5h", now + 1), true); + } finally { + b._clearBucketsForTest(); + } +}); + +test("account buckets at the cap evict buckets whose reset already passed first", async () => { + const b = await import("../../src/lib/quota/accountBuckets.ts"); + b._clearBucketsForTest(); + const now = 1_800_000_000_000; + const soon = new Date(now + 1_000).toISOString(); + const later = new Date(now + 3_600_000).toISOString(); + try { + b.recordUsage("conn-stale", "5h", 100, soon, now); // resets 1s later + for (let i = 1; i < b.ACCOUNT_BUCKETS_SOFT_CAP; i++) { + b.recordUsage(`conn-keep-${i}`, "5h", 100, later, now); + } + assert.equal(b._bucketCountForTest(), b.ACCOUNT_BUCKETS_SOFT_CAP); + b.recordUsage("conn-new", "5h", 100, later, now + 5_000); // stale bucket is evictable now + assert.equal(b._bucketCountForTest(), b.ACCOUNT_BUCKETS_SOFT_CAP, "stale bucket made room"); + assert.equal(b.isBucketSaturated("conn-keep-1", "5h", now + 5_001), true); + assert.equal(b.isBucketSaturated("conn-new", "5h", now + 5_001), true); + } finally { + b._clearBucketsForTest(); + } +}); + +// ── quality tracker under real pressure ────────────────────────────────────── + +test("quality: past the cap the LRU unscored pair is dropped, semantic pins survive", async () => { + const q = await import("../../open-sse/services/routing/quality.ts"); + q.resetQualityTracker(); + const event = (provider: string, model: string) => ({ + provider, + model, + outcome: "success", + status: 200, + latencyMs: 100, + finishReason: "stop", + }); + try { + q.recordQualityEvent(event("pinned", "model")); + q.setSemanticQuality("pinned", "model", 0.9, 1); + q.recordQualityEvent(event("first", "unscored")); + for (let i = 0; i < q.QUALITY_STATES_CAP + 50; i++) { + q.recordQualityEvent(event("bulk", `m-${i}`)); + } + const snapshot = q.getQualitySnapshot(q.QUALITY_STATES_CAP * 2); + assert.equal(snapshot.length, q.QUALITY_STATES_CAP, "tracker stays at its cap"); + const pinned = snapshot.find((v) => v.provider === "pinned"); + assert.ok(pinned, "the semantic pin survived the pressure"); + assert.equal(pinned.semantic, 0.9); + assert.equal(q.getProviderQuality("first", "unscored").samples, 0, "LRU unscored pair evicted"); + assert.ok(q.getProviderQuality("bulk", `m-${q.QUALITY_STATES_CAP + 49}`).samples > 0); + } finally { + q.resetQualityTracker(); + } +}); + +// ── learned rate limits ────────────────────────────────────────────────────── + +const HEADERS = { + "x-ratelimit-limit-requests": "100", + "x-ratelimit-remaining-requests": "5", + "x-ratelimit-reset-requests": "30s", +}; + +test("learnedLimits: a deployment above the old unenforced 200 keeps every entry", async () => { + const rl = await import("../../open-sse/services/rateLimitManager.ts"); + await rl.__resetRateLimitManagerForTests(); + try { + for (let i = 0; i < 300; i++) { + rl.enableRateLimitProtection(`conn-many-${i}`); + rl.updateFromHeaders("openai", `conn-many-${i}`, HEADERS, 200); + } + assert.equal(Object.keys(rl.getLearnedLimits()).length, 300); + } finally { + await rl.__resetRateLimitManagerForTests(); + } +}); + +test("learnedLimits: capped at MAX_LEARNED_LIMITS", async () => { + const rl = await import("../../open-sse/services/rateLimitManager.ts"); + await rl.__resetRateLimitManagerForTests(); + try { + for (let i = 0; i <= rl.MAX_LEARNED_LIMITS; i++) { + rl.enableRateLimitProtection(`conn-cap-${i}`); + rl.updateFromHeaders("openai", `conn-cap-${i}`, HEADERS, 200); + } + const learned = rl.getLearnedLimits(); + assert.equal(Object.keys(learned).length, rl.MAX_LEARNED_LIMITS); + assert.equal(learned["openai:conn-cap-0"], undefined, "oldest entry evicted"); + } finally { + await rl.__resetRateLimitManagerForTests(); + } +}); + +test("learnedLimits persist/load round-trip", async () => { + const rl = await import("../../open-sse/services/rateLimitManager.ts"); + const settings = await import("../../src/lib/db/settings.ts"); + await rl.__resetRateLimitManagerForTests(); + try { + rl.enableRateLimitProtection("conn-rt"); + rl.updateFromHeaders("openai", "conn-rt", HEADERS, 200); + await rl.__flushLearnedLimitsForTests(); + const raw = (await settings.getSettings())?.learnedRateLimits; + assert.equal(typeof raw, "string"); + const parsed = JSON.parse(raw as string) as Record; + assert.equal(parsed["openai:conn-rt"]?.limit, 100); + await rl.__resetRateLimitManagerForTests(); + assert.deepEqual(rl.getLearnedLimits(), {}); + await rl.initializeRateLimits(); + assert.ok(rl.getLearnedLimits()["openai:conn-rt"], "load restores the persisted entry"); + } finally { + await rl.__resetRateLimitManagerForTests(); + } +}); + +// ── TTL caches keep their read-through behaviour ──────────────────────────── + +test("saturation cache: hits stay cached below the cap, the evicted key refetches past it", async () => { + const sat = await import("../../src/lib/quota/saturationSignals.ts"); + sat._clearSaturationCache(); + let calls = 0; + sat.__setGenericUsageFetcherForTests(async () => { + calls++; + return { percentUsed: 0.1 }; + }); + const dim = { unit: "tokens", window: "hourly" } as const; + try { + for (let i = 0; i < 600; i++) await sat.getSaturation(`conn-sat-${i}`, "some-provider", dim); + const warm = calls; + await sat.getSaturation("conn-sat-0", "some-provider", dim); + assert.equal(calls, warm, "600 entries (above the old 512 cap) are still cached"); + + for (let i = 600; i < 4097; i++) await sat.getSaturation(`conn-sat-${i}`, "some-provider", dim); + const before = calls; + // ttl policy: the read above did not refresh conn-sat-0, so as the oldest write + // it is the one entry evicted by the 4097th insert. + await sat.getSaturation("conn-sat-0", "some-provider", dim); + assert.ok(calls > before, `evicted key must refetch (calls ${before} -> ${calls})`); + } finally { + sat.__setGenericUsageFetcherForTests(null); + sat._clearSaturationCache(); + } +}); + +test("quota-fetcher cache: entries above the old 512 cap stay cached", async () => { + const g = await import("../../open-sse/services/genericQuotaFetcher.ts"); + g.__resetGenericQuotaFetcherForTests(); + let calls = 0; + g.__setGenericUsageFetcherForTests(async () => { + calls++; + return { quotas: { session: { remainingPercentage: 50, resetAt: null } } }; + }); + try { + for (let i = 0; i < 600; i++) { + await g.fetchGenericQuota(`gqf-${i}`, { id: `gqf-${i}`, provider: "openai" }); + } + const before = calls; + await g.fetchGenericQuota("gqf-0", { id: "gqf-0", provider: "openai" }); + assert.equal(calls, before, "cache hit, no refetch"); + } finally { + g.__setGenericUsageFetcherForTests(null); + g.__resetGenericQuotaFetcherForTests(); + } +}); diff --git a/tests/unit/retry-after-provenance.test.ts b/tests/unit/retry-after-provenance.test.ts new file mode 100644 index 0000000000..6b9b5d100b --- /dev/null +++ b/tests/unit/retry-after-provenance.test.ts @@ -0,0 +1,309 @@ +/** + * #13672 — Retry-After provenance on aggregated unavailable responses, opt-in via + * RETRY_AFTER_PROVENANCE_ENABLED (default off). + * + * Flag off: unavailableResponse keeps the legacy contract byte-for-byte (header always + * present, clamped to >= 1s; body is { error: { message } }) and combo drain paths only + * read structured retry fields. + * Flag on: no concrete future retry time → no Retry-After header (never a synthetic 1s, + * never "1" for an elapsed date); body carries error.retry_after_provenance; combo drain + * paths also read prose hints from JSON and plain-text bodies. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-retry-provenance-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +process.env.DATA_DIR = TEST_DATA_DIR; +const FLAG = "RETRY_AFTER_PROVENANCE_ENABLED"; +delete process.env[FLAG]; + +const { unavailableResponse, parseProseRetryDelayMs, readProseRetryAfter } = + await import("../../open-sse/utils/error.ts"); +const { executeTargetAttempt } = + await import("../../open-sse/services/combo/executeTargetAttempt.ts"); +const { handleComboChat } = await import("../../open-sse/services/combo.ts"); +const rrState = await import("../../open-sse/services/combo/rrState.ts"); +const dbCore = await import("../../src/lib/db/core.ts"); + +function withFlag(on: boolean, fn: () => T): T { + if (on) process.env[FLAG] = "true"; + else delete process.env[FLAG]; + return fn(); +} + +test.afterEach(() => { + delete process.env[FLAG]; +}); + +test.after(() => { + delete process.env[FLAG]; + try { + dbCore.resetDbInstance(); + } catch { + /* ignore */ + } + if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIGINAL_DATA_DIR; + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +const HOUR = 3_600_000; +type Input = string | number | Date | null | undefined; +const NO_SIGNAL: Array<[string, Input]> = [ + ["null", null], + ["undefined", undefined], + ["zero", 0], + ["negative", -3], + ["NaN", Number.NaN], + ["Infinity", Number.POSITIVE_INFINITY], + ["numeric string", "5"], + ["empty string", ""], + ["garbage string", "abc"], + ["past ISO", new Date(Date.now() - HOUR).toISOString()], + ["past Date", new Date(Date.now() - HOUR)], + ["past epoch ms", Date.now() - HOUR], +]; +const SIGNAL: Array<[string, Input]> = [ + ["seconds", 2], + ["future ISO", new Date(Date.now() + HOUR).toISOString()], + ["future Date", new Date(Date.now() + HOUR)], + ["future epoch ms", Date.now() + HOUR], +]; + +async function readBody(res: Response) { + return (await res.json()) as { error: Record }; +} + +test("flag off: unavailableResponse keeps the legacy header and body for every input", async () => { + for (const [label, input] of [...NO_SIGNAL, ...SIGNAL]) { + const res = withFlag(false, () => unavailableResponse(429, "drained", input)); + const header = res.headers.get("Retry-After"); + assert.ok(header !== null && Number(header) >= 1, `legacy header for ${label}: ${header}`); + assert.deepEqual(await readBody(res), { error: { message: "drained" } }, label); + } + const nullRes = withFlag(false, () => unavailableResponse(503, "busy", null)); + assert.equal(nullRes.headers.get("Retry-After"), "1"); + const pastRes = withFlag(false, () => + unavailableResponse(429, "drained", new Date(Date.now() - HOUR).toISOString()) + ); + assert.equal(pastRes.headers.get("Retry-After"), "1"); + await nullRes.body?.cancel(); + await pastRes.body?.cancel(); +}); + +test("flag on: no concrete future retry time omits Retry-After and says none", async () => { + for (const [label, input] of NO_SIGNAL) { + const res = withFlag(true, () => unavailableResponse(429, "drained", input)); + assert.equal(res.headers.get("Retry-After"), null, `no header for ${label}`); + const body = await readBody(res); + assert.equal(body.error.retry_after_provenance, "none", label); + assert.equal(body.error.message, "drained"); + } +}); + +test("flag on: a concrete future retry time keeps the legacy header value and says signal", async () => { + for (const [label, input] of SIGNAL) { + const legacy = withFlag(false, () => unavailableResponse(429, "drained", input)); + const res = withFlag(true, () => unavailableResponse(429, "drained", input)); + const header = res.headers.get("Retry-After"); + assert.ok(header !== null, `header for ${label}`); + assert.ok( + Math.abs(Number(header) - Number(legacy.headers.get("Retry-After"))) <= 1, + `${label}: ${header} vs legacy ${legacy.headers.get("Retry-After")}` + ); + assert.equal((await readBody(res)).error.retry_after_provenance, "signal", label); + await legacy.body?.cancel(); + } +}); + +test("parseProseRetryDelayMs reads Antigravity and generic prose, caps at 24h", () => { + assert.equal( + parseProseRetryDelayMs("Your quota will reset after 2h7m23s."), + (2 * 3600 + 7 * 60 + 23) * 1000 + ); + assert.equal(parseProseRetryDelayMs("Rate limited. Please retry after 30 seconds"), 30_000); + assert.equal(parseProseRetryDelayMs("quota will reset after 90h"), 24 * HOUR); + assert.equal(parseProseRetryDelayMs("502 Bad Gateway"), null); + assert.equal(parseProseRetryDelayMs(""), null); + assert.equal(parseProseRetryDelayMs(undefined), null); +}); + +test("readProseRetryAfter is inert with the flag off", () => { + assert.equal( + withFlag(false, () => readProseRetryAfter("retry after 30s")), + null + ); + const iso = withFlag(true, () => readProseRetryAfter("retry after 30s")); + assert.ok(iso && Math.abs(Date.parse(iso) - (Date.now() + 30_000)) < 5_000); +}); + +type Logged = { level: string; args: unknown[] }; + +function attemptFixture(response: () => Response) { + const logs: Logged[] = []; + const push = + (level: string) => + (...args: unknown[]) => + logs.push({ level, args }); + const target = { + kind: "model", + stepId: "s1", + executionKey: "ek-13672", + modelStr: "openai/gpt-4o", + provider: "openai", + providerId: null, + connectionId: "c-13672", + weight: 1, + label: null, + }; + const deps = { + strategy: "priority", + combo: { name: "t13672", models: [] }, + config: {}, + log: { info: push("info"), warn: push("warn"), debug: push("debug"), error: push("error") }, + settings: null, + resilienceSettings: { providerCooldown: { enabled: false } }, + sticky: { targets: [], messageHash: null, stuck: false }, + effectiveSessionId: null, + preScreenMap: new Map(), + quotaCutoffResetWindowConfig: {}, + maxRetries: 0, + traceInvocationId: "inv-13672", + clientRequestedStream: false, + handleSingleModelWithTimeout: async () => response(), + body: { messages: [{ role: "user", content: "hi" }] }, + startTime: Date.now(), + releaseStickyPinOnFailure() {}, + clearStaleLKGP() {}, + }; + const state = { + orderedTargets: [target], + fallbackCount: 0, + recordedAttempts: 0, + comboErrors: [], + lastError: null, + lastStatus: null, + earliestRetryAfter: null as string | null, + comboExpired: false, + exhaustedProviders: new Set(), + exhaustedConnections: new Set(), + transientRateLimitedProviders: new Set(), + abortControllers: new Map([[0, new AbortController()]]), + dispatchedTargets: new Set(), + targetFailureTrust: new Map(), + comboAttemptOrder: [], + skippedForCircuitOpen: false, + earliestCircuitOpenRetryMs: 0, + globalAttempts: 0, + observedFailure: false, + allObservedFailuresQuota: true, + observeFailure() {}, + }; + const run = () => + executeTargetAttempt({ + index: 0, + state, + deps, + targetForAttempt: target, + profile: {}, + protectedPriorityTarget: false, + } as unknown as Parameters[0]); + return { state, logs, run }; +} + +const hintLogs = (logs: Logged[], level: string) => + logs.filter((l) => l.level === level && /Retry hint unreadable/.test(String(l.args[1]))); + +const antigravityJson = () => + new Response( + JSON.stringify({ + error: { message: "You have exhausted your capacity. Your quota will reset after 2h7m23s." }, + }), + { status: 429, headers: { "Content-Type": "application/json" } } + ); +const plainText429 = () => + new Response("Too many requests. Please retry after 30s", { status: 429 }); + +test("drain path: JSON prose hint feeds earliestRetryAfter only with the flag on", async () => { + const off = attemptFixture(antigravityJson); + await withFlag(false, off.run); + assert.equal(off.state.earliestRetryAfter, null, "flag off: legacy ignores prose"); + + const on = attemptFixture(antigravityJson); + await withFlag(true, on.run); + const expected = Date.now() + (2 * 3600 + 7 * 60 + 23) * 1000; + assert.ok(on.state.earliestRetryAfter, "flag on: prose hint recorded"); + assert.ok(Math.abs(Date.parse(on.state.earliestRetryAfter) - expected) < 10_000); +}); + +test("drain path: a plain-text (non-JSON) prose hint is read with the flag on", async () => { + const off = attemptFixture(plainText429); + await withFlag(false, off.run); + assert.equal(off.state.earliestRetryAfter, null); + + const on = attemptFixture(plainText429); + await withFlag(true, on.run); + assert.ok(on.state.earliestRetryAfter, "plain-text hint recorded"); + assert.ok(Math.abs(Date.parse(on.state.earliestRetryAfter) - (Date.now() + 30_000)) < 10_000); +}); + +test("drain path: an HTML 502 page logs at debug, never warn", async () => { + const html = attemptFixture( + () => new Response("

502 Bad Gateway

", { status: 502 }) + ); + await withFlag(true, html.run); + assert.equal(hintLogs(html.logs, "warn").length, 0, "no warn for an ordinary non-JSON body"); + assert.equal(hintLogs(html.logs, "debug").length, 1); + assert.equal(html.state.earliestRetryAfter, null); +}); + +test("drain path: a failed clone still warns", async () => { + const bad = new Response("plain 429", { status: 429 }); + Object.defineProperty(bad, "clone", { + value() { + throw new Error("clone boom"); + }, + }); + const fx = attemptFixture(() => bad); + await fx.run(); + assert.equal(hintLogs(fx.logs, "warn").length, 1); +}); + +test("round-robin drain path: plain-text hint reaches the final Retry-After only with the flag on", async () => { + const combo = { + name: "rr13672", + strategy: "round-robin", + config: { maxRetries: 0, disableSessionStickiness: true }, + models: [{ kind: "model", provider: "openai", providerId: "openai", model: "m", id: "rr-0" }], + }; + const dispatch = async () => { + rrState.rrCounters.clear(); + rrState.rrStickyTargets.clear(); + return (await handleComboChat({ + body: { model: combo.name, messages: [{ role: "user", content: "hi" }], stream: false }, + combo, + allCombos: [combo], + isModelAvailable: async () => true, + relayOptions: undefined, + signal: undefined, + settings: {}, + log: { info() {}, warn() {}, debug() {}, error() {} }, + handleSingleModel: async () => plainText429(), + })) as Response; + }; + + delete process.env[FLAG]; + const off = await dispatch(); + assert.equal(off.headers.get("Retry-After"), null, "flag off: no hint read, legacy JSON error"); + assert.equal((await readBody(off)).error.retry_after_provenance, undefined); + + process.env[FLAG] = "true"; + const on = await dispatch(); + const header = Number(on.headers.get("Retry-After")); + assert.ok(header >= 25 && header <= 30, `Retry-After from the plain-text hint, got ${header}`); + assert.equal((await readBody(on)).error.retry_after_provenance, "signal"); +}); diff --git a/tests/unit/route-guard-cli-tools-settings-local-only.test.ts b/tests/unit/route-guard-cli-tools-settings-local-only.test.ts new file mode 100644 index 0000000000..50bcda640a --- /dev/null +++ b/tests/unit/route-guard-cli-tools-settings-local-only.test.ts @@ -0,0 +1,102 @@ +/** + * Security regression (GHSA-35fw-cv32-2373): every /api/cli-tools/* route whose + * handler reaches a child-process spawn must be classified LOCAL_ONLY so loopback + * enforcement runs unconditionally before any auth check. + * + * 13 routes call getCliRuntimeStatus(toolId) directly from their exported GET + * handler (src/shared/services/cliRuntime.ts): getCliRuntimeStatus -> + * locateCommandCandidate -> locateCommand -> runProcess("sh", ["-c", + * 'command -v -- "$1"', ...]) -> spawn(). /api/cli-tools/detect reaches the same + * class via detectAllTools() -> execFile(binary, ["--version"]) + execFile("which") + * (src/lib/cli-helper/tool-detector.ts). Their six siblings (omp, letta, + * grok-build, forge, jcode, qwen -settings + runtime/) call the SAME helper and + * were already LOCAL_ONLY; these 14 were only behind Tier 3 MANAGEMENT, which + * requireManagementAuth() waives whenever requireLogin=false (including the + * fresh-install window before a password is set). + * + * Classifying them LOCAL_ONLY closes the remote-spawn vector: a non-loopback / + * non-LAN caller — with or without a leaked JWT over a Cloudflared/Ngrok tunnel — + * cannot trigger process spawning or enumerate the host's CLI inventory. + * Hard Rules #15 + #17. See docs/security/ROUTE_GUARD_TIERS.md. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + isLocalOnlyBypassableByManageScope, + isLocalOnlyPath, +} from "../../src/server/authz/routeGuard.ts"; +import { SPAWN_CAPABLE_PREFIXES } from "../../src/shared/constants/spawnCapablePrefixes.ts"; +import { SPAWN_CAPABLE_ROUTE_ROOTS } from "../../scripts/check/check-route-guard-membership.ts"; + +/** The 14 cli-tools routes that reach a spawn (GHSA-35fw-cv32-2373). */ +const SPAWNING_CLI_TOOLS_ROUTES: ReadonlyArray = [ + "/api/cli-tools/all-statuses", + "/api/cli-tools/claude-settings", + "/api/cli-tools/cline-settings", + "/api/cli-tools/codewhale-settings", + "/api/cli-tools/codex-settings", + "/api/cli-tools/crush-settings", + "/api/cli-tools/deepseek-tui-settings", + "/api/cli-tools/detect", + "/api/cli-tools/droid-settings", + "/api/cli-tools/kilo-settings", + "/api/cli-tools/openclaw-settings", + "/api/cli-tools/pi-settings", + "/api/cli-tools/smelt-settings", + "/api/cli-tools/status", +]; + +for (const route of SPAWNING_CLI_TOOLS_ROUTES) { + test(`GHSA-35fw: ${route} is LOCAL_ONLY (reaches spawn via getCliRuntimeStatus/detectAllTools)`, () => { + assert.equal(isLocalOnlyPath(route), true); + }); + + test(`GHSA-35fw: ${route}/ (trailing slash) is LOCAL_ONLY`, () => { + assert.equal(isLocalOnlyPath(`${route}/`), true); + }); + + test(`GHSA-35fw: ${route} cannot be opened through the manage-scope bypass`, () => { + // Mirror in SPAWN_CAPABLE_PREFIXES: the zod schema rejects the prefix at + // PATCH /api/settings time and the runtime predicate refuses a malformed row. + assert.ok( + SPAWN_CAPABLE_PREFIXES.includes(route), + `${route} must be listed in SPAWN_CAPABLE_PREFIXES` + ); + assert.equal(isLocalOnlyBypassableByManageScope(route), false); + }); + + test(`GHSA-35fw: the spawn-capable route audit enumerates ${route}`, () => { + const root = `src/app${route}`; + assert.ok( + SPAWN_CAPABLE_ROUTE_ROOTS.includes(root), + `${root} must be listed in SPAWN_CAPABLE_ROUTE_ROOTS so the 6A.8 gate enforces it` + ); + }); +} + +test("GHSA-35fw: the already-gated cli-tools siblings stay LOCAL_ONLY", () => { + // Guards against a refactor dropping the precedent these entries follow. + assert.equal(isLocalOnlyPath("/api/cli-tools/omp-settings"), true); + assert.equal(isLocalOnlyPath("/api/cli-tools/letta-settings"), true); + assert.equal(isLocalOnlyPath("/api/cli-tools/grok-build-settings"), true); + assert.equal(isLocalOnlyPath("/api/cli-tools/forge-settings"), true); + assert.equal(isLocalOnlyPath("/api/cli-tools/jcode-settings"), true); + assert.equal(isLocalOnlyPath("/api/cli-tools/qwen-settings"), true); + assert.equal(isLocalOnlyPath("/api/cli-tools/runtime/claude"), true); +}); + +test("GHSA-35fw: non-spawning cli-tools routes are NOT over-gated (no blanket prefix)", () => { + // These are legitimate remote-dashboard routes: file/config reads and writes + // with no child-process reach. A blanket "/api/cli-tools/" prefix would break + // every tunnel-served dashboard, so the fix is 14 exact entries, not one. + assert.equal(isLocalOnlyPath("/api/cli-tools/apply"), false); + assert.equal(isLocalOnlyPath("/api/cli-tools/backups"), false); + assert.equal(isLocalOnlyPath("/api/cli-tools/config"), false); + assert.equal(isLocalOnlyPath("/api/cli-tools/guide-settings/claude"), false); + assert.equal(isLocalOnlyPath("/api/cli-tools/hermes-agent-settings"), false); + assert.equal(isLocalOnlyPath("/api/cli-tools/keys"), false); + assert.equal(isLocalOnlyPath("/api/cli-tools/logs"), false); + // "/api/cli-tools/openclaw-settings" must not swallow the sibling + // "/api/cli-tools/openclaw/auto-order" (different segment, no spawn). + assert.equal(isLocalOnlyPath("/api/cli-tools/openclaw/auto-order"), false); +}); diff --git a/tests/unit/route-guard-forge-jcode-settings-local-only.test.ts b/tests/unit/route-guard-forge-jcode-settings-local-only.test.ts index aef5669d9b..04c9871fdb 100644 --- a/tests/unit/route-guard-forge-jcode-settings-local-only.test.ts +++ b/tests/unit/route-guard-forge-jcode-settings-local-only.test.ts @@ -42,7 +42,10 @@ test("sibling cli-tools spawn-capable settings routes stay LOCAL_ONLY", () => { test("non-spawning cli-tools routes are NOT over-gated by this entry", () => { // The new prefixes must not accidentally widen to the whole /api/cli-tools/ subtree, - // which remote dashboards legitimately use. - assert.equal(isLocalOnlyPath("/api/cli-tools/all-statuses"), false); + // which remote dashboards legitimately use. (/api/cli-tools/all-statuses used to be + // the negative control here, but it calls getCliRuntimeStatus() too and became + // LOCAL_ONLY under GHSA-35fw-cv32-2373 — see + // route-guard-cli-tools-settings-local-only.test.ts.) assert.equal(isLocalOnlyPath("/api/cli-tools/keys"), false); + assert.equal(isLocalOnlyPath("/api/cli-tools/config"), false); }); diff --git a/tests/unit/route-guard-grok-build-settings-local-only.test.ts b/tests/unit/route-guard-grok-build-settings-local-only.test.ts index b02348f151..72da038193 100644 --- a/tests/unit/route-guard-grok-build-settings-local-only.test.ts +++ b/tests/unit/route-guard-grok-build-settings-local-only.test.ts @@ -33,7 +33,10 @@ test("sibling cli-tools spawn-capable settings routes stay LOCAL_ONLY", () => { test("non-spawning cli-tools routes are NOT over-gated by this entry", () => { // The new prefix must not accidentally widen to the whole /api/cli-tools/ subtree, - // which remote dashboards legitimately use. - assert.equal(isLocalOnlyPath("/api/cli-tools/all-statuses"), false); + // which remote dashboards legitimately use. (/api/cli-tools/all-statuses used to be + // the negative control here, but it calls getCliRuntimeStatus() too and became + // LOCAL_ONLY under GHSA-35fw-cv32-2373 — see + // route-guard-cli-tools-settings-local-only.test.ts.) assert.equal(isLocalOnlyPath("/api/cli-tools/keys"), false); + assert.equal(isLocalOnlyPath("/api/cli-tools/config"), false); }); diff --git a/tests/unit/route-guard-skills-execute-local-only.test.ts b/tests/unit/route-guard-skills-execute-local-only.test.ts new file mode 100644 index 0000000000..b08a8c30fa --- /dev/null +++ b/tests/unit/route-guard-skills-execute-local-only.test.ts @@ -0,0 +1,74 @@ +/** + * Security regression (GHSA-jx89-f37j-pq89): /api/skills/install and + * /api/skills/executions must be classified LOCAL_ONLY so loopback enforcement + * runs unconditionally before any auth check. + * + * POST /api/skills/install stores the request's `handlerCode` string verbatim as + * the skill's `handler` with no allowlist (src/app/api/skills/install/route.ts). + * POST /api/skills/executions then calls skillExecutor.execute(), whose handler + * resolution (src/lib/skills/executor.ts) falls through to the built-in table — + * so a handler string that equals `execute_command` or `eval_code` runs the real + * built-in (src/lib/skills/builtins.ts), which reaches + * childProcess.spawn() in src/lib/skills/sandbox.ts. The + * sandbox is a hardened docker/podman container, but the spawn is real and + * transitive: the 6A.8 source-scan gate only greps route.ts, so it cannot see it. + * + * Both routes were only behind requireManagementAuth() / isAuthenticated(), + * which waive auth whenever requireLogin=false — the identical class already + * closed for /api/acp/agents (GHSA-hf57-cqmx-p4gr) and /api/skills/collect/. + * Hard Rules #15 + #17. See docs/security/ROUTE_GUARD_TIERS.md. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + isLocalOnlyBypassableByManageScope, + isLocalOnlyPath, +} from "../../src/server/authz/routeGuard.ts"; +import { SPAWN_CAPABLE_PREFIXES } from "../../src/shared/constants/spawnCapablePrefixes.ts"; +import { SPAWN_CAPABLE_ROUTE_ROOTS } from "../../scripts/check/check-route-guard-membership.ts"; + +test("GHSA-jx89: /api/skills/install is LOCAL_ONLY (registers a handler that can alias execute_command)", () => { + assert.equal(isLocalOnlyPath("/api/skills/install"), true); +}); + +test("GHSA-jx89: /api/skills/install with trailing slash is LOCAL_ONLY", () => { + assert.equal(isLocalOnlyPath("/api/skills/install/"), true); +}); + +test("GHSA-jx89: /api/skills/executions is LOCAL_ONLY (skillExecutor.execute reaches sandbox spawn)", () => { + assert.equal(isLocalOnlyPath("/api/skills/executions"), true); +}); + +test("GHSA-jx89: /api/skills/executions with trailing slash is LOCAL_ONLY", () => { + assert.equal(isLocalOnlyPath("/api/skills/executions/"), true); +}); + +test("GHSA-jx89: neither skills execution route can be opened through the manage-scope bypass", () => { + for (const route of ["/api/skills/install", "/api/skills/executions"]) { + assert.ok( + SPAWN_CAPABLE_PREFIXES.includes(route), + `${route} must be listed in SPAWN_CAPABLE_PREFIXES` + ); + assert.equal(isLocalOnlyBypassableByManageScope(route), false); + } +}); + +test("GHSA-jx89: the spawn-capable route audit enumerates both skills execution routes", () => { + assert.ok(SPAWN_CAPABLE_ROUTE_ROOTS.includes("src/app/api/skills/install")); + assert.ok(SPAWN_CAPABLE_ROUTE_ROOTS.includes("src/app/api/skills/executions")); +}); + +test("GHSA-jx89: the existing /api/skills/collect/ gate is untouched", () => { + assert.equal(isLocalOnlyPath("/api/skills/collect/detect"), true); +}); + +test("GHSA-jx89: the rest of /api/skills/ stays remote-reachable (no over-broadening)", () => { + // Registry listing / delete, marketplace and skillssh do not reach the sandbox + // spawn: they stay on requireManagementAuth() and must remain tunnel-reachable. + assert.equal(isLocalOnlyPath("/api/skills"), false); + assert.equal(isLocalOnlyPath("/api/skills/"), false); + assert.equal(isLocalOnlyPath("/api/skills/some-id"), false); + assert.equal(isLocalOnlyPath("/api/skills/marketplace"), false); + assert.equal(isLocalOnlyPath("/api/skills/marketplace/install"), false); + assert.equal(isLocalOnlyPath("/api/skills/skillssh/install"), false); +}); diff --git a/tests/unit/server-owned-tool-loop-flag.test.ts b/tests/unit/server-owned-tool-loop-flag.test.ts index 2f6a383044..efd7817287 100644 --- a/tests/unit/server-owned-tool-loop-flag.test.ts +++ b/tests/unit/server-owned-tool-loop-flag.test.ts @@ -68,7 +68,7 @@ describe("isServerOwnedToolLoopEnabled wrapper", () => { describe("feature-flags-settings count update", () => { it("flag count matches updated expected value", () => { - assert.equal(FEATURE_FLAG_DEFINITIONS.length, 55); + assert.equal(FEATURE_FLAG_DEFINITIONS.length, 69); }); }); diff --git a/tests/unit/settings-socks-flag-reader.test.ts b/tests/unit/settings-socks-flag-reader.test.ts new file mode 100644 index 0000000000..9c36410c3e --- /dev/null +++ b/tests/unit/settings-socks-flag-reader.test.ts @@ -0,0 +1,98 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-socks-flag-routes-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const { isSocks5ProxyEnabled } = await import("../../open-sse/utils/proxyDispatcher.ts"); +const proxiesRoute = await import("../../src/app/api/settings/proxies/route.ts"); +const proxyRoute = await import("../../src/app/api/settings/proxy/route.ts"); + +// ENABLE_SOCKS5_PROXY is opt-out: only an explicit falsey value disables SOCKS5. +const MATRIX: Array<[string | undefined, boolean]> = [ + [undefined, true], + ["", true], + ["true", true], + ["1", true], + ["yes", true], + ["false", false], + ["0", false], + ["no", false], + ["off", false], + [" OFF ", false], + ["False", false], +]; + +async function withSocksFlag(value: string | undefined, fn: () => Promise | T): Promise { + const previous = process.env.ENABLE_SOCKS5_PROXY; + if (value === undefined) delete process.env.ENABLE_SOCKS5_PROXY; + else process.env.ENABLE_SOCKS5_PROXY = value; + try { + return await fn(); + } finally { + if (previous === undefined) delete process.env.ENABLE_SOCKS5_PROXY; + else process.env.ENABLE_SOCKS5_PROXY = previous; + } +} + +function putProxy(body: unknown) { + return proxyRoute.PUT( + new Request("http://localhost/api/settings/proxy", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }) + ); +} + +test.before(() => { + delete process.env.INITIAL_PASSWORD; + core.resetDbInstance(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("flag reader honors the opt-out matrix (unset defaults ON)", async () => { + for (const [value, expected] of MATRIX) { + await withSocksFlag(value, () => { + assert.equal(isSocks5ProxyEnabled(), expected, `ENABLE_SOCKS5_PROXY=${String(value)}`); + }); + } +}); + +test("GET /api/settings/proxies reports socks5Enabled exactly as the flag reader", async () => { + for (const [value, expected] of MATRIX) { + await withSocksFlag(value, async () => { + const response = await proxiesRoute.GET(new Request("http://localhost/api/settings/proxies")); + assert.equal(response.status, 200); + const body = (await response.json()) as { socks5Enabled: boolean }; + assert.equal(body.socks5Enabled, expected, `ENABLE_SOCKS5_PROXY=${String(value)}`); + }); + } +}); + +test("PUT /api/settings/proxy accepts or rejects socks5 following the flag", async () => { + for (const [value, expected] of MATRIX) { + await withSocksFlag(value, async () => { + const response = await putProxy({ + level: "global", + proxy: { type: "socks5", host: "127.0.0.1", port: 1080 }, + }); + const body = (await response.json()) as { error?: { message?: string } }; + if (expected) { + assert.equal(response.status, 200, `ENABLE_SOCKS5_PROXY=${String(value)}`); + } else { + assert.equal(response.status, 400, `ENABLE_SOCKS5_PROXY=${String(value)}`); + assert.match(body.error?.message ?? "", /SOCKS5 proxy is disabled/); + } + }); + } +}); diff --git a/tests/unit/socks-userinfo-decode-guard.test.ts b/tests/unit/socks-userinfo-decode-guard.test.ts new file mode 100644 index 0000000000..57fbcde169 --- /dev/null +++ b/tests/unit/socks-userinfo-decode-guard.test.ts @@ -0,0 +1,281 @@ +import { describe, it, before, after, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import net from "node:net"; +import { request } from "undici"; +import { decodeUserinfo } from "../../src/shared/utils/decodeUserinfo.ts"; +import { + __getSocksOptionsForTest, + clearDispatcherCache, + createProxyDispatcher, +} from "../../open-sse/utils/proxyDispatcher.ts"; +import { coerceProxyPayload } from "../../src/lib/db/proxies/mappers.ts"; +import { parseSubscription } from "../../src/lib/proxySubscription/parse.ts"; + +// ── local fixtures ────────────────────────────────────────────────────────── + +type Closeable = { port: number; close: () => Promise }; + +function trackSockets(server: net.Server) { + const sockets = new Set(); + server.on("connection", (socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + }); + return () => + new Promise((resolve) => { + for (const socket of sockets) socket.destroy(); + server.close(() => resolve()); + }); +} + +function listen(server: net.Server): Promise { + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve((server.address() as net.AddressInfo).port)); + }); +} + +async function startTarget(): Promise { + const server = http.createServer((_req, res) => { + res.writeHead(200, { "content-type": "text/plain" }); + res.end("target-ok"); + }); + const close = trackSockets(server); + return { port: await listen(server), close }; +} + +/** HTTP CONNECT proxy that records every Proxy-Authorization header it receives. */ +async function startConnectProxy(seen: Array): Promise { + const server = http.createServer((_req, res) => { + res.writeHead(405); + res.end(); + }); + server.on("connect", (req, clientSocket: net.Socket, head: Buffer) => { + seen.push(req.headers["proxy-authorization"]); + const [host, port] = String(req.url).split(":"); + const upstream = net.connect(Number(port), host, () => { + clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + if (head.length > 0) upstream.write(head); + upstream.pipe(clientSocket); + clientSocket.pipe(upstream); + }); + upstream.on("error", () => clientSocket.destroy()); + clientSocket.on("error", () => upstream.destroy()); + }); + const close = trackSockets(server); + return { port: await listen(server), close }; +} + +/** Minimal SOCKS5 server (RFC 1928 + RFC 1929 user/pass) that records the credentials. */ +async function startSocks5Proxy(seen: Array<{ user: string; pass: string }>): Promise { + const server = net.createServer((socket) => { + let buffer = Buffer.alloc(0); + let stage: "greeting" | "auth" | "request" | "piped" = "greeting"; + socket.on("error", () => socket.destroy()); + socket.on("data", (chunk: Buffer) => { + if (stage === "piped") return; + buffer = Buffer.concat([buffer, chunk]); + if (stage === "greeting") { + if (buffer.length < 2 || buffer.length < 2 + buffer[1]) return; + buffer = buffer.subarray(2 + buffer[1]); + socket.write(Buffer.from([0x05, 0x02])); // username/password + stage = "auth"; + } + if (stage === "auth") { + if (buffer.length < 2) return; + const ulen = buffer[1]; + if (buffer.length < 3 + ulen) return; + const plen = buffer[2 + ulen]; + if (buffer.length < 3 + ulen + plen) return; + seen.push({ + user: buffer.subarray(2, 2 + ulen).toString("utf8"), + pass: buffer.subarray(3 + ulen, 3 + ulen + plen).toString("utf8"), + }); + buffer = buffer.subarray(3 + ulen + plen); + socket.write(Buffer.from([0x01, 0x00])); + stage = "request"; + } + if (stage === "request") { + if (buffer.length < 5) return; + const atyp = buffer[3]; + let host: string; + let offset: number; + if (atyp === 0x01) { + if (buffer.length < 10) return; + host = Array.from(buffer.subarray(4, 8)).join("."); + offset = 8; + } else if (atyp === 0x03) { + const len = buffer[4]; + if (buffer.length < 7 + len) return; + host = buffer.subarray(5, 5 + len).toString("utf8"); + offset = 5 + len; + } else { + socket.destroy(); + return; + } + const port = buffer.readUInt16BE(offset); + const rest = buffer.subarray(offset + 2); + stage = "piped"; + const upstream = net.connect(port, host, () => { + socket.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0])); + if (rest.length > 0) upstream.write(rest); + upstream.pipe(socket); + socket.pipe(upstream); + }); + upstream.on("error", () => socket.destroy()); + } + }); + }); + const close = trackSockets(server); + return { port: await listen(server), close }; +} + +function basic(user: string, pass: string) { + return `Basic ${Buffer.from(`${user}:${pass}`).toString("base64")}`; +} + +async function fetchThrough(proxyUrl: string, targetPort: number) { + const dispatcher = createProxyDispatcher(proxyUrl); + const response = await request(`http://127.0.0.1:${targetPort}/`, { dispatcher }); + const text = await response.body.text(); + return { status: response.statusCode, text }; +} + +// ── decodeUserinfo ────────────────────────────────────────────────────────── + +describe("decodeUserinfo", () => { + it("decodes correctly encoded values as before", () => { + assert.equal(decodeUserinfo("user%40name"), "user@name"); + assert.equal(decodeUserinfo("p%3Ass"), "p:ss"); + }); + + it("falls back to the raw value when the value holds a literal percent", () => { + assert.equal(decodeUserinfo("user%name"), "user%name"); + assert.equal(decodeUserinfo("pa%ss"), "pa%ss"); + assert.equal(decodeUserinfo("100%"), "100%"); + }); +}); + +// ── real dispatchers against local proxies ────────────────────────────────── + +describe("HTTP proxy dispatcher credentials (real ProxyAgent + local CONNECT proxy)", () => { + const seen: Array = []; + let target: Closeable; + let proxy: Closeable; + + before(async () => { + target = await startTarget(); + proxy = await startConnectProxy(seen); + }); + afterEach(() => { + seen.length = 0; + clearDispatcherCache(); + }); + after(async () => { + clearDispatcherCache(); + await proxy.close(); + await target.close(); + }); + + it("a literal percent in the password reaches the proxy verbatim", async () => { + const result = await fetchThrough(`http://user:pa%ss@127.0.0.1:${proxy.port}`, target.port); + assert.deepEqual(result, { status: 200, text: "target-ok" }); + assert.deepEqual(seen, [basic("user", "pa%ss")]); + }); + + it("a literal percent in the username reaches the proxy verbatim", async () => { + const result = await fetchThrough(`http://us%er:secret@127.0.0.1:${proxy.port}`, target.port); + assert.equal(result.status, 200); + assert.deepEqual(seen, [basic("us%er", "secret")]); + }); + + it("correctly encoded credentials are decoded exactly as undici did before", async () => { + const result = await fetchThrough( + `http://user%40corp:p%3Ass@127.0.0.1:${proxy.port}`, + target.port + ); + assert.equal(result.status, 200); + assert.deepEqual(seen, [basic("user@corp", "p:ss")]); + }); + + it("username without password keeps undici's `user:` shape", async () => { + const result = await fetchThrough(`http://onlyuser@127.0.0.1:${proxy.port}`, target.port); + assert.equal(result.status, 200); + assert.deepEqual(seen, [basic("onlyuser", "")]); + }); + + it("no userinfo sends no Proxy-Authorization header", async () => { + const result = await fetchThrough(`http://127.0.0.1:${proxy.port}`, target.port); + assert.equal(result.status, 200); + assert.deepEqual(seen, [undefined]); + }); +}); + +describe("SOCKS5 proxy dispatcher credentials (real socks dispatcher + local SOCKS5 server)", () => { + const seen: Array<{ user: string; pass: string }> = []; + let target: Closeable; + let proxy: Closeable; + + before(async () => { + target = await startTarget(); + proxy = await startSocks5Proxy(seen); + }); + afterEach(() => { + seen.length = 0; + clearDispatcherCache(); + }); + after(async () => { + clearDispatcherCache(); + await proxy.close(); + await target.close(); + }); + + it("a literal percent in SOCKS5 credentials reaches the proxy verbatim", async () => { + const result = await fetchThrough( + `socks5://user%name:pa%ss@127.0.0.1:${proxy.port}`, + target.port + ); + assert.deepEqual(result, { status: 200, text: "target-ok" }); + assert.deepEqual(seen, [{ user: "user%name", pass: "pa%ss" }]); + }); + + it("correctly encoded SOCKS5 credentials are decoded as before", async () => { + const result = await fetchThrough( + `socks5://user%40corp:p%3Ass@127.0.0.1:${proxy.port}`, + target.port + ); + assert.equal(result.status, 200); + assert.deepEqual(seen, [{ user: "user@corp", pass: "p:ss" }]); + }); + + it("the test accessor mirrors the dispatcher", () => { + const opts = __getSocksOptionsForTest("socks5://user%name:pa%ss@host:1080"); + assert.equal(opts.userId, "user%name"); + assert.equal(opts.password, "pa%ss"); + }); +}); + +// ── other userinfo parse sites ────────────────────────────────────────────── + +describe("other proxy URL parse sites keep a literal percent", () => { + it("coerceProxyPayload (proxy registry mapper)", () => { + const payload = coerceProxyPayload("http://user:pa%ss@proxy.local:3128", "legacy"); + assert.ok(payload, "a literal percent must not drop the whole proxy entry"); + assert.equal(payload.username, "user"); + assert.equal(payload.password, "pa%ss"); + const encoded = coerceProxyPayload("http://user%40corp:p%3Ass@proxy.local:3128", "legacy"); + assert.equal(encoded?.username, "user@corp"); + assert.equal(encoded?.password, "p:ss"); + }); + + it("parseSubscription (proxy subscription URI list)", () => { + const parsed = parseSubscription( + ["http://user:pa%ss@proxy-a.example:3128#a", "socks5://us%er:x@proxy-b.example:1080#b"].join( + "\n" + ) + ); + const byName = Object.fromEntries(parsed.nodes.map((node) => [node.name, node])); + assert.equal(byName.a?.password, "pa%ss"); + assert.equal(byName.b?.username, "us%er"); + }); +}); diff --git a/tests/unit/stream-recovery-toolcall.test.ts b/tests/unit/stream-recovery-toolcall.test.ts index 7f3a765343..1cb118e853 100644 --- a/tests/unit/stream-recovery-toolcall.test.ts +++ b/tests/unit/stream-recovery-toolcall.test.ts @@ -1,10 +1,12 @@ -import { describe, it } from "node:test"; +import { after, afterEach, describe, it } from "node:test"; import assert from "node:assert/strict"; import { createRecoverableStream, TruncatedStreamError, scanOpenAiSseText, } from "../../open-sse/services/streamRecovery.ts"; +import { STREAM_RECOVERY } from "../../open-sse/config/constants.ts"; +import { resetDbInstance } from "../../src/lib/db/core.ts"; const enc = new TextEncoder(); @@ -176,3 +178,171 @@ describe("stream recovery does not duplicate an in-flight tool call", () => { assert.equal(scanFull.sawToolCallInFlight, false); }); }); + +// ── STREAM_RECOVERY_TOOLCALL_ORDER_FIX (opt-in, default off) ────────────────── +// Both sides run through the real createRecoverableStream and the real feature-flag +// lookup (env source); the flag is read lazily inside the stream, so it is set before the +// stream is drained. + +const ORDER_FIX_FLAG = "STREAM_RECOVERY_TOOLCALL_ORDER_FIX"; +const ORIGINAL_ORDER_FIX_FLAG = process.env[ORDER_FIX_FLAG]; + +function setOrderFix(on: boolean) { + if (on) process.env[ORDER_FIX_FLAG] = "true"; + else delete process.env[ORDER_FIX_FLAG]; +} + +afterEach(() => { + if (ORIGINAL_ORDER_FIX_FLAG === undefined) delete process.env[ORDER_FIX_FLAG]; + else process.env[ORDER_FIX_FLAG] = ORIGINAL_ORDER_FIX_FLAG; +}); + +after(() => { + resetDbInstance(); +}); + +// Deliver each chunk on its own read, then cut with a retryable truncation. +function makeChunkedStream(chunks: string[]): ReadableStream { + let n = 0; + return new ReadableStream({ + pull(c) { + if (n < chunks.length) { + c.enqueue(enc.encode(chunks[n++])); + return; + } + c.error(new TruncatedStreamError()); + }, + }); +} + +function streamOf(body: string): ReadableStream { + return new ReadableStream({ + start(c) { + c.enqueue(enc.encode(body)); + c.close(); + }, + }); +} + +async function drainAll(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let out = ""; + try { + for (;;) { + const r = await reader.read(); + if (r.done) break; + if (r.value) out += decoder.decode(r.value, { stream: true }); + } + } catch { + // a refused continuation surfaces the original truncation error + } + return out; +} + +async function countContinuations( + chunks: string[], + continuation: () => ReadableStream | null = () => null +): Promise<{ calls: number; out: string }> { + let calls = 0; + const wrapped = createRecoverableStream(makeChunkedStream(chunks), async () => null, { + finalize: () => {}, + now: jumpingClock(), + continueStream: async () => { + calls += 1; + return continuation(); + }, + }); + const out = await drainAll(wrapped); + return { calls, out }; +} + +const TEXT = 'data: {"choices":[{"index":0,"delta":{"content":"Let me check that. "}}]}\n'; +const CALL_COMPLETE = + 'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"id":"c1","function":{"name":"f","arguments":"{}"}}]}}]}\n'; +const CALL_PARTIAL = + 'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"id":"c2","function":{"name":"f"}}]}}]}\n'; +const FINISH_TOOL_CALLS = + 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}\n'; + +// The three tool-call shapes a cut can land on. +const COALESCED_FINISHED_CALL = [TEXT + CALL_COMPLETE + FINISH_TOOL_CALLS + "\n"]; +const COALESCED_FINISHED_THEN_PARTIAL = [ + TEXT + CALL_COMPLETE + FINISH_TOOL_CALLS + CALL_PARTIAL + "\n", +]; +const SPLIT_CALL_THEN_FINISH = [TEXT + CALL_PARTIAL + "\n", FINISH_TOOL_CALLS + "\n"]; + +// A reasoning-only "stop" (the hallucinatedEmptyStop recovery path) whose every +// continuation comes back empty and non-terminal. +const REASONING_ONLY_STOP = [ + 'data: {"choices":[{"delta":{"role":"assistant"}}]}\n\n' + + 'data: {"choices":[{"delta":{"reasoning_content":"the model thinks it through"}}]}\n\n' + + 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n', +]; +const emptyNonTerminal = () => streamOf('data: {"choices":[{"delta":{"role":"assistant"}}]}\n\n'); + +describe("STREAM_RECOVERY_TOOLCALL_ORDER_FIX off keeps the release behavior", () => { + it("resumes after a coalesced finished call and after a finished call followed by a partial one", async () => { + setOrderFix(false); + assert.equal((await countContinuations(COALESCED_FINISHED_CALL)).calls, 1); + assert.equal((await countContinuations(COALESCED_FINISHED_THEN_PARTIAL)).calls, 1); + }); + + it("stays latched when the call and its finish arrive in separate batches", async () => { + setOrderFix(false); + assert.equal((await countContinuations(SPLIT_CALL_THEN_FINISH)).calls, 0); + }); + + it("empty continuations still spend the whole continuation budget", async () => { + setOrderFix(false); + const { calls, out } = await countContinuations(REASONING_ONLY_STOP, emptyNonTerminal); + assert.equal(calls, STREAM_RECOVERY.EARLY_RETRY_MAX); + assert.match(out, /\[DONE\]/); + }); +}); + +describe("STREAM_RECOVERY_TOOLCALL_ORDER_FIX on makes continuation tool-call safe", () => { + it("never resumes a finished call followed by a partial call in the same batch", async () => { + setOrderFix(true); + assert.equal((await countContinuations(COALESCED_FINISHED_THEN_PARTIAL)).calls, 0); + }); + + it("never resumes a turn that already finished with finish_reason tool_calls", async () => { + setOrderFix(true); + const coalesced = await countContinuations(COALESCED_FINISHED_CALL); + assert.equal(coalesced.calls, 0); + assert.doesNotMatch(coalesced.out, /"finish_reason":"stop"/); + // The latch is never re-armed by a later finish: the split shape stays refused too. + assert.equal((await countContinuations(SPLIT_CALL_THEN_FINISH)).calls, 0); + }); + + it("still resumes a plain-text truncation", async () => { + setOrderFix(true); + let calls = 0; + const wrapped = createRecoverableStream( + makeStream('data: {"choices":[{"index":0,"delta":{"content":"hello brave new "}}]}\n\n'), + async () => null, + { + finalize: () => {}, + now: jumpingClock(), + continueStream: async () => { + calls += 1; + return streamOf( + 'data: {"choices":[{"index":0,"delta":{"content":"hello brave new world"}}]}\n' + + "data: [DONE]\n\n" + ); + }, + } + ); + const out = await drainAll(wrapped); + assert.equal(calls, 1); + assert.match(out, /world/); + }); + + it("stops after one empty continuation instead of spending the whole budget", async () => { + setOrderFix(true); + const { calls, out } = await countContinuations(REASONING_ONLY_STOP, emptyNonTerminal); + assert.equal(calls, 1); + assert.match(out, /\[DONE\]/, "the client still gets a clean terminal"); + }); +}); diff --git a/tests/unit/stream-recovery-trace-logging.test.ts b/tests/unit/stream-recovery-trace-logging.test.ts new file mode 100644 index 0000000000..28e815f677 --- /dev/null +++ b/tests/unit/stream-recovery-trace-logging.test.ts @@ -0,0 +1,189 @@ +// Mid-stream continuation log wiring: buildContinuationLogHooks (the exact hooks chatCore +// spreads into createRecoverableStream) driven through the real recoverable stream. Warn is +// reserved for the attempt line (release wording) and for a recovery that gives up; every +// other outcome is debug, and a healthy or tool-call stream adds no line at all. +import { after, test } from "node:test"; +import assert from "node:assert/strict"; + +import { + createRecoverableStream, + TruncatedStreamError, + type ContinuationOutcome, +} from "../../open-sse/services/streamRecovery.ts"; +import { + buildContinuationLogHooks, + formatContinuationOutcome, +} from "../../open-sse/handlers/chatCore/recoveryTraceLogging.ts"; +import { resetDbInstance } from "../../src/lib/db/core.ts"; + +after(() => { + resetDbInstance(); +}); + +const enc = new TextEncoder(); + +function steppingClock() { + let t = 0; + return () => (t += 1000); +} + +function streamFrom(chunks: string[], truncate = false) { + let i = 0; + return new ReadableStream({ + pull(controller) { + if (i < chunks.length) { + controller.enqueue(enc.encode(chunks[i++])); + return; + } + if (truncate) controller.error(new TruncatedStreamError()); + else controller.close(); + }, + }); +} + +async function drain(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const dec = new TextDecoder(); + let out = ""; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value) out += dec.decode(value, { stream: true }); + } + } catch { + // a refused continuation surfaces the original truncation + } + return out; +} + +const ROLE = 'data: {"choices":[{"delta":{"role":"assistant"}}]}\n\n'; +const DONE = "data: [DONE]\n\n"; +const content = (s: string) => `data: {"choices":[{"delta":{"content":${JSON.stringify(s)}}}]}\n\n`; +const TOOL_CALL = + 'data: {"choices":[{"delta":{"tool_calls":[{"id":"c1","function":{"name":"f"}}]}}]}\n\n'; +const FINISH_TOOL_CALLS = 'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}\n\n'; + +function capture() { + const warn: string[] = []; + const debug: string[] = []; + const log = { + warn: (tag: string, msg: string) => warn.push(`${tag} ${msg}`), + debug: (tag: string, msg: string) => debug.push(`${tag} ${msg}`), + }; + return { warn, debug, hooks: buildContinuationLogHooks(log) }; +} + +async function run( + initial: ReadableStream, + continueStream: () => Promise | null>, + hooks: ReturnType +) { + return drain( + createRecoverableStream(initial, async () => null, { + finalize: () => {}, + now: steppingClock(), + continueStream, + ...hooks, + }) + ); +} + +test("a stitched continuation warns once with the release attempt wording, outcome at debug", async () => { + const { warn, debug, hooks } = capture(); + const out = await run( + streamFrom([ROLE, content("Hello there world")]), + async () => streamFrom([ROLE, content("there world, nice to meet you!"), DONE]), + hooks + ); + assert.match(out, /nice to meet you!/); + assert.deepEqual(warn, ["STREAM_RECOVERY mid-stream continuation attempt 1/4"]); + assert.deepEqual(debug, [ + "STREAM_RECOVERY mid-stream continuation attempt 1/4 outcome=suffix suffixChars=19", + ]); +}); + +test("a streamed tool call that ends nominally logs nothing", async () => { + const { warn, debug, hooks } = capture(); + await run(streamFrom([ROLE, TOOL_CALL, FINISH_TOOL_CALLS, DONE]), async () => null, hooks); + assert.deepEqual(warn, []); + assert.deepEqual(debug, []); +}); + +test("a cut refused because a tool call is in flight is debug only", async () => { + const { warn, debug, hooks } = capture(); + let calls = 0; + await run( + streamFrom([ROLE, content("Let me check. "), TOOL_CALL], true), + async () => { + calls += 1; + return null; + }, + hooks + ); + assert.equal(calls, 0); + assert.deepEqual(warn, []); + assert.deepEqual(debug, [ + "STREAM_RECOVERY mid-stream continuation attempt 0/4 outcome=refused reason=tool-call", + ]); +}); + +test("a spent continuation budget warns that the recovery gave up", async () => { + const { warn, debug, hooks } = capture(); + let calls = 0; + await run( + streamFrom([ROLE, content("Hello there world")], true), + async () => { + calls += 1; + // Always overlaps and never terminates: every attempt truncates again. + return streamFrom([ROLE, content("there world")]); + }, + hooks + ); + assert.equal(calls, 4); + assert.deepEqual(warn, [ + "STREAM_RECOVERY mid-stream continuation attempt 1/4", + "STREAM_RECOVERY mid-stream continuation attempt 2/4", + "STREAM_RECOVERY mid-stream continuation attempt 3/4", + "STREAM_RECOVERY mid-stream continuation attempt 4/4", + "STREAM_RECOVERY mid-stream continuation attempt 4/4 outcome=refused reason=budget", + ]); + assert.deepEqual(debug, []); +}); + +test("a continuation request that returns no stream warns that the recovery gave up", async () => { + const { warn, debug, hooks } = capture(); + await run(streamFrom([ROLE, content("Hello there world")], true), async () => null, hooks); + assert.deepEqual(warn, [ + "STREAM_RECOVERY mid-stream continuation attempt 1/4", + "STREAM_RECOVERY mid-stream continuation attempt 1/4 outcome=no-stream", + ]); + assert.deepEqual(debug, []); +}); + +test("a non-OpenAI body ending without an OpenAI terminal logs nothing", async () => { + const { warn, debug, hooks } = capture(); + await run( + streamFrom(['event: content_block_delta\ndata: {"type":"content_block_delta"}\n\n']), + async () => null, + hooks + ); + assert.deepEqual(warn, []); + assert.deepEqual(debug, []); +}); + +test("every outcome formats with the attempt token and no undefined fields", () => { + const events: ContinuationOutcome[] = [ + { attempt: 2, outcome: "suffix", suffixChars: 7 }, + { attempt: 2, outcome: "overlap-reject", overlapChars: 3 }, + { attempt: 1, outcome: "terminal" }, + { attempt: 1, outcome: "empty" }, + { attempt: 1, outcome: "no-stream" }, + { attempt: 0, outcome: "refused", reason: "not-continuable" }, + ]; + for (const event of events) { + const line = formatContinuationOutcome(event); + assert.match(line, new RegExp(`^mid-stream continuation attempt ${event.attempt}/4 `)); + assert.doesNotMatch(line, /undefined|null/); + } +}); diff --git a/tests/unit/ui/PoolEgressObservation.test.tsx b/tests/unit/ui/PoolEgressObservation.test.tsx new file mode 100644 index 0000000000..209522366b --- /dev/null +++ b/tests/unit/ui/PoolEgressObservation.test.tsx @@ -0,0 +1,96 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string, values?: Record) => + values ? `${key}:${JSON.stringify(values)}` : key, +})); + +import { PoolEgressObservation } from "@/app/(dashboard)/dashboard/settings/components/PoolEgressObservation"; + +const QUERY = "scope=provider&scopeId=openai"; +let root: Root | null = null; +let container: HTMLElement | null = null; + +async function renderWith(fetchImpl: (...args: unknown[]) => Promise) { + const fetchMock = vi.fn(fetchImpl); + vi.stubGlobal("fetch", fetchMock); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root!.render(React.createElement(PoolEgressObservation, { query: QUERY })); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + return { fetchMock, element: container }; +} + +function jsonResponse(body: unknown, ok = true) { + return Promise.resolve({ ok, json: () => Promise.resolve(body) }); +} + +describe("PoolEgressObservation", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + }); + + afterEach(async () => { + await act(async () => { + root?.unmount(); + }); + root = null; + container?.remove(); + container = null; + vi.unstubAllGlobals(); + }); + + it("reads the dedicated route and shows the counts", async () => { + const { fetchMock, element } = await renderWith(() => + jsonResponse({ + connections: 12, + distinctExits: 5, + maxConnectionsOnOneExit: 4, + windowHours: 24, + }) + ); + expect(fetchMock).toHaveBeenCalledWith( + `/api/settings/proxies/pool/egress-observation?${QUERY}` + ); + expect(element.textContent).toBe( + 'poolEgressObservation:{"exits":5,"connections":12,"max":4,"hours":24}' + ); + }); + + it("says no traffic was observed when the pool had no connection", async () => { + const { element } = await renderWith(() => + jsonResponse({ + connections: 0, + distinctExits: 0, + maxConnectionsOnOneExit: 0, + windowHours: 24, + }) + ); + expect(element.textContent).toBe('poolEgressObservationEmpty:{"hours":24}'); + }); + + it("renders nothing when the route answers null", async () => { + const { element } = await renderWith(() => jsonResponse(null)); + expect(element.textContent).toBe(""); + }); + + it("renders nothing on an error status", async () => { + const { element } = await renderWith(() => jsonResponse({ error: "nope" }, false)); + expect(element.textContent).toBe(""); + }); + + it("renders nothing when the request fails", async () => { + const { element } = await renderWith(() => Promise.reject(new Error("offline"))); + expect(element.textContent).toBe(""); + }); +}); diff --git a/tests/unit/ui/free-badge-strict-flag.test.tsx b/tests/unit/ui/free-badge-strict-flag.test.tsx new file mode 100644 index 0000000000..41cc80bcd9 --- /dev/null +++ b/tests/unit/ui/free-badge-strict-flag.test.tsx @@ -0,0 +1,192 @@ +// @vitest-environment jsdom +// +// #13645: the provider-page model lists compute the "Free" badge through +// isModelFreeBadge and read FREE_BADGE_REQUIRES_PROVIDER_FREE_TIER from +// /api/settings/feature-flags. Flag off (and flag unreadable) must render exactly the +// historical badges; flag on removes only the provably wrong ones. +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("next/navigation", () => ({ + useParams: () => ({ id: "test-provider" }), + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/providers/test-provider", +})); + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +vi.mock("@/store/notificationStore", () => ({ + useNotificationStore: () => ({ + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + }), +})); + +vi.mock("@/shared/components", () => ({ + Badge: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => ( + + ), +})); + +type FlagMode = "on" | "off" | "error"; + +function mockFlagFetch(mode: FlagMode) { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (!url.includes("/api/settings/feature-flags")) { + return new Response("{}", { status: 404 }); + } + if (mode === "error") return new Response("boom", { status: 500 }); + return new Response( + JSON.stringify({ + flags: [ + { + key: "FREE_BADGE_REQUIRES_PROVIDER_FREE_TIER", + effectiveValue: mode === "on" ? "true" : "false", + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }) + ); +} + +const commonProps = { + modelAliases: {}, + description: "", + inputLabel: "Model ID", + inputPlaceholder: "", + copied: undefined, + onCopy: vi.fn(), + onSetAlias: vi.fn().mockResolvedValue(undefined), + onDeleteAlias: vi.fn(), + t: (k: string) => k, + effectiveModelNormalize: () => false, + effectiveModelPreserveDeveloper: () => true, + getUpstreamHeadersRecord: () => ({}), + saveModelCompatFlags: vi.fn().mockResolvedValue(undefined), + isModelHidden: () => false, + onToggleHidden: vi.fn().mockResolvedValue(undefined), + onBulkToggleHidden: vi.fn().mockResolvedValue(undefined), +}; + +function freeBadgeCount(container: HTMLElement): number { + return Array.from(container.querySelectorAll('[data-testid="badge"]')).filter((el) => + /^(Free|freeBadge)$/.test((el.textContent || "").trim()) + ).length; +} + +async function flush() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +describe( + "provider-page Free badge vs FREE_BADGE_REQUIRES_PROVIDER_FREE_TIER", + { timeout: 120_000 }, + () => { + let container: HTMLElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + async function renderPassthrough() { + const { default: PassthroughModelsSection } = + await import("../../../src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection"); + await act(async () => { + root.render( + + ); + }); + await flush(); + } + + async function renderCompatible() { + const { default: CompatibleModelsSection } = + await import("../../../src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection"); + await act(async () => { + root.render( + + ); + }); + await flush(); + } + + it("flag off: a paid registered provider keeps the historical badges (name + :free)", async () => { + mockFlagFetch("off"); + await renderPassthrough(); + expect(freeBadgeCount(container)).toBe(2); + }); + + it("flag unreadable: fails closed to the historical badges", async () => { + mockFlagFetch("error"); + await renderPassthrough(); + expect(freeBadgeCount(container)).toBe(2); + }); + + it("flag on: the name heuristic and :free on a paid registered provider lose the badge", async () => { + mockFlagFetch("on"); + await renderPassthrough(); + expect(freeBadgeCount(container)).toBe(0); + }); + + it("compatible node pointing at a :free model keeps its badge with the flag off", async () => { + mockFlagFetch("off"); + await renderCompatible(); + expect(freeBadgeCount(container)).toBe(1); + }); + + it("compatible node pointing at a :free model keeps its badge with the flag on", async () => { + mockFlagFetch("on"); + await renderCompatible(); + expect(freeBadgeCount(container)).toBe(1); + }); + } +); diff --git a/tests/unit/upstream-status-capture.test.ts b/tests/unit/upstream-status-capture.test.ts new file mode 100644 index 0000000000..fa96fdd472 --- /dev/null +++ b/tests/unit/upstream-status-capture.test.ts @@ -0,0 +1,130 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// The upstream status is captured once, around the process-wide fetch, and only while a +// provider request is being dispatched. These tests pin the wrapper on its own. + +const { withUpstreamStatusCapture } = await import("../../open-sse/utils/upstreamStatusCapture.ts"); +const { isProviderRequestCaptureActive, runWithCapture } = + await import("../../open-sse/utils/providerRequestLogging.ts"); + +type Sink = { upstreamStatus?: number }; + +const dispatchCapture = { capture: () => {}, body: (fallback: unknown) => fallback }; + +function respond(status: number) { + return async () => new Response(null, { status }); +} + +test("records the status of a dispatched call and returns the response untouched", async () => { + const sink: Sink = {}; + const response = new Response(null, { status: 429 }); + const wrapped = withUpstreamStatusCapture( + async () => response, + () => sink, + () => true + ); + assert.equal(await wrapped(), response); + assert.equal(sink.upstreamStatus, 429); +}); + +test("a call outside a dispatch leaves the sink alone", async () => { + const sink: Sink = { upstreamStatus: 429 }; + const wrapped = withUpstreamStatusCapture( + respond(200), + () => sink, + () => false + ); + await wrapped(); + assert.equal(sink.upstreamStatus, 429); +}); + +test("a dispatched call that throws clears the earlier status", async () => { + const sink: Sink = {}; + const first = withUpstreamStatusCapture( + respond(429), + () => sink, + () => true + ); + await first(); + assert.equal(sink.upstreamStatus, 429); + + const failing = withUpstreamStatusCapture( + async () => { + throw new TypeError("fetch failed"); + }, + () => sink, + () => true + ); + await assert.rejects(failing(), /fetch failed/); + assert.equal(sink.upstreamStatus, undefined); +}); + +test("a response that arrives after the dispatch ended leaves the sink alone", async () => { + const sink: Sink = { upstreamStatus: 429 }; + let dispatching = true; + const wrapped = withUpstreamStatusCapture( + async () => { + dispatching = false; + return new Response(null, { status: 500 }); + }, + () => sink, + () => dispatching + ); + await wrapped(); + assert.equal(sink.upstreamStatus, 429); +}); + +test("forwards every argument to the inner fetch", async () => { + const seen: unknown[][] = []; + const inner = async (...args: unknown[]) => { + seen.push(args); + return new Response(null, { status: 204 }); + }; + const wrapped = withUpstreamStatusCapture( + inner, + () => undefined, + () => true + ); + const deps = { marker: true }; + await wrapped("http://example.test", { method: "GET" }, deps); + assert.deepEqual(seen, [["http://example.test", { method: "GET" }, deps]]); +}); + +test("no sink in scope is harmless", async () => { + const wrapped = withUpstreamStatusCapture( + respond(500), + () => undefined, + () => true + ); + assert.equal((await wrapped()).status, 500); +}); + +test("a second dispatch without any fetch leaves nothing stale behind", async () => { + const sink: Sink = {}; + const wrapped = withUpstreamStatusCapture(respond(429), () => sink); + await runWithCapture(dispatchCapture, async () => { + await wrapped(); + }); + assert.equal(sink.upstreamStatus, 429); + await runWithCapture(dispatchCapture, async () => {}); + assert.equal(sink.upstreamStatus, undefined); +}); + +test("isProviderRequestCaptureActive is true only until runWithCapture settles", async () => { + assert.equal(isProviderRequestCaptureActive(), false); + const capture = { capture: () => {}, body: (fallback: unknown) => fallback }; + let release: () => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + let later: Promise = Promise.resolve(true); + const inside = await runWithCapture(capture, async () => { + later = gate.then(() => isProviderRequestCaptureActive()); + return isProviderRequestCaptureActive(); + }); + assert.equal(inside, true); + release(); + assert.equal(await later, false, "a continuation started inside sees the dispatch ended"); + assert.equal(isProviderRequestCaptureActive(), false); +}); diff --git a/tests/unit/video-bridge-log-redaction.test.ts b/tests/unit/video-bridge-log-redaction.test.ts index d828cb31a4..0d9ca50c9a 100644 --- a/tests/unit/video-bridge-log-redaction.test.ts +++ b/tests/unit/video-bridge-log-redaction.test.ts @@ -60,7 +60,11 @@ function videoBody() { } function baseCtx(overrides: Record = {}) { + // #13481/#13546: the call log row is keyed on traceId. It defaults to + // pendingRequestId so these tests keep polling by the id they pass in. + const pendingRequestId = (overrides.pendingRequestId as string) ?? "REPLACE"; return { + traceId: overrides.traceId ?? pendingRequestId, provider: "openai", connectionId: "conn-1", model: "gpt-x", diff --git a/tests/unit/wal-maintenance.test.ts b/tests/unit/wal-maintenance.test.ts index 8447de2829..0ae01c1ce3 100644 --- a/tests/unit/wal-maintenance.test.ts +++ b/tests/unit/wal-maintenance.test.ts @@ -164,3 +164,170 @@ test("start is silent and stateless under the test-process gate", async () => { test.beforeEach(async () => { (await import("../../src/lib/db/walMaintenance.ts")).__resetForTests(); }); + +test("mergeBusyTotal keeps the max, floors at 0", async () => { + const { mergeBusyTotal } = await import("../../src/lib/db/walMaintenance.ts"); + assert.equal(mergeBusyTotal(5, 3), 5); + assert.equal(mergeBusyTotal(3, 5), 5); + assert.equal(mergeBusyTotal(0, 0), 0); + assert.equal(mergeBusyTotal(-2, -7), 0); + assert.equal(mergeBusyTotal(2.9, 1), 2); +}); + +test("loadPersistedBusyTotal reads the key, falls back to 0", async () => { + const { loadPersistedBusyTotal } = await import("../../src/lib/db/walMaintenance.ts"); + const store = new Map([["walMaintenance/busyTotal", "41"]]); + const db = { + pragma: () => [{ busy: 0, log: 0, checkpointed: 0 }], + prepare: (_sql: string) => ({ + get: () => { + const v = store.get("walMaintenance/busyTotal"); + return v === undefined ? undefined : { value: v }; + }, + run: () => {}, + }), + }; + assert.equal(loadPersistedBusyTotal(db as never), 41); + store.set("walMaintenance/busyTotal", "abc"); + assert.equal(loadPersistedBusyTotal(db as never), 0); + store.delete("walMaintenance/busyTotal"); + assert.equal(loadPersistedBusyTotal(db as never), 0); +}); + +test("flushBusyTotal is a no-op with nothing pending or no open handle", async () => { + const { flushBusyTotal } = await import("../../src/lib/db/walMaintenance.ts"); + let prepared = 0; + const db = { + open: true, + prepare: () => { + prepared++; + return { run: () => {} }; + }, + }; + assert.equal(flushBusyTotal(db as never), false); + assert.equal(flushBusyTotal(null), false); + assert.equal(prepared, 0); +}); + +/** + * The scheduler is gated off inside test runners (isAutomatedTestProcess), so the real boot + * wiring runs in a child Node process that is not a test process. It drives the actual + * startWalMaintenance()/stopWalMaintenance() against a real SQLite file; only the checkpoint + * pragma result is scripted (busy vs clean) so contention can be produced on demand. Module + * paths travel through env vars so no argv token makes the child look like a test runner. + */ +const CHILD_SCRIPT = ` +const { startWalMaintenance, stopWalMaintenance, getWalMaintenanceState } = await import(process.env.WAL_MODULE_URL); +const { tryOpenSync } = await import(process.env.DRIVER_MODULE_URL); +const file = process.env.WAL_DB_FILE; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const real = tryOpenSync(file); +if (!real) { console.log("WALCHILD " + JSON.stringify({ skipped: true })); process.exit(0); } +let mode = "busy"; +const writes = []; +const db = { + get open() { return real.open; }, + pragma: (s, o) => s.startsWith("wal_checkpoint") + ? [mode === "busy" ? { busy: 1, log: 5, checkpointed: 0 } : { busy: 0, log: 0, checkpointed: 0 }] + : real.pragma(s, o), + prepare: (sql) => { if (/INSERT/i.test(sql)) writes.push(mode); return real.prepare(sql); }, + exec: (sql) => real.exec(sql), + close: () => real.close(), +}; +const persisted = () => Number(real.prepare("SELECT value FROM key_value WHERE namespace='walMaintenance' AND key='busyTotal'").get()?.value ?? 0); +const env = { OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS: "60", OMNIROUTE_WAL_PASSIVE_INTERVAL_MS: "0" }; +const out = {}; +startWalMaintenance(db, file, env); +out.restoredAtBoot = getWalMaintenanceState().busyTotal; +await sleep(400); +out.afterBusy = { total: getWalMaintenanceState().busyTotal, persisted: persisted(), writes: writes.length }; +mode = "ok"; +await sleep(300); +out.afterOkTick = { total: getWalMaintenanceState().busyTotal, persisted: persisted(), busyWrites: writes.filter((m) => m === "busy").length }; +mode = "busy"; +await sleep(300); +out.beforeStop = { total: getWalMaintenanceState().busyTotal, persisted: persisted() }; +mode = "stopping"; +stopWalMaintenance(); +out.afterStop = { persisted: persisted(), busyWrites: writes.filter((m) => m === "busy").length, stopWrites: writes.filter((m) => m === "stopping").length }; +startWalMaintenance(db, file, env); +out.restoredAfterRestart = getWalMaintenanceState().busyTotal; +stopWalMaintenance(); +real.close(); +console.log("WALCHILD " + JSON.stringify(out)); +process.exit(0); +`; + +test("boot wiring: restores the persisted total, never writes on a busy tick, flushes on a clean tick and at stop", async (t) => { + const { spawnSync } = await import("node:child_process"); + const fs = await import("node:fs"); + const os = await import("node:os"); + const path = await import("node:path"); + const { pathToFileURL } = await import("node:url"); + const { tryOpenSync } = await import("../../src/lib/db/adapters/driverFactory.ts"); + + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-wal-boot-")); + const file = path.join(dir, "storage.sqlite"); + const seed = tryOpenSync(file); + if (!seed) { + fs.rmSync(dir, { recursive: true, force: true }); + t.skip("no sync SQLite driver available"); + return; + } + try { + seed.exec( + "CREATE TABLE IF NOT EXISTS key_value (namespace TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (namespace, key))" + ); + seed + .prepare( + "INSERT INTO key_value (namespace, key, value) VALUES ('walMaintenance', 'busyTotal', '41')" + ) + .run(); + seed.close(); + + const repoRoot = path.resolve(import.meta.dirname, "../.."); + const env: NodeJS.ProcessEnv = { + ...process.env, + WAL_MODULE_URL: pathToFileURL(path.join(repoRoot, "src/lib/db/walMaintenance.ts")).href, + DRIVER_MODULE_URL: pathToFileURL(path.join(repoRoot, "src/lib/db/adapters/driverFactory.ts")) + .href, + WAL_DB_FILE: file, + NODE_ENV: "production", + }; + delete env.VITEST; + delete env.NODE_TEST_CONTEXT; + const child = spawnSync( + process.execPath, + ["--import", "tsx/esm", "--input-type=module", "-e", CHILD_SCRIPT], + { cwd: repoRoot, env, encoding: "utf8", timeout: 120_000 } + ); + const line = (child.stdout || "").split("\n").find((l) => l.startsWith("WALCHILD ")); + assert.ok(line, `child produced no result (status ${child.status}): ${child.stderr}`); + const out = JSON.parse(line.slice("WALCHILD ".length)); + if (out.skipped) { + t.skip("child could not open a sync SQLite driver"); + return; + } + + assert.equal(out.restoredAtBoot, 41, "boot restores the persisted counter"); + assert.ok(out.afterBusy.total > 41, "busy ticks are counted in memory"); + assert.equal(out.afterBusy.writes, 0, "no database write on the busy path"); + assert.equal(out.afterBusy.persisted, 41, "persisted value untouched while contended"); + + assert.equal(out.afterOkTick.busyWrites, 0); + assert.equal( + out.afterOkTick.persisted, + out.afterOkTick.total, + "a clean tick flushes the delta" + ); + + assert.ok(out.beforeStop.total > out.afterOkTick.total, "more busy ticks after the flush"); + assert.equal(out.beforeStop.persisted, out.afterOkTick.total, "still no write while busy"); + assert.equal(out.afterStop.busyWrites, 0); + assert.equal(out.afterStop.stopWrites, 1, "exactly one flush at stop"); + assert.equal(out.afterStop.persisted, out.beforeStop.total, "stop flushes the remaining delta"); + assert.equal(out.restoredAfterRestart, out.beforeStop.total, "restart restores the full total"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +});