diff --git a/.env.example b/.env.example index 8090071870..975a66d6d1 100644 --- a/.env.example +++ b/.env.example @@ -1171,6 +1171,25 @@ APP_LOG_TO_FILE=true # Accepted values: true|1|on (force on), false|0|off (force off), unset (use Dashboard). # RATE_LIMIT_AUTO_ENABLE= +# Provider cooldown tracking: minimum time (ms) before a failed provider/connection +# can be retried. Prevents subsequent requests from re-walking failing providers. +# Scaled exponentially: minCooldown * 2^(failures-1), capped at maxRetryCooldownMs. +# Used by: open-sse/services/providerCooldownTracker.ts +# PROVIDER_COOLDOWN_MIN_MS=5000 + +# Provider cooldown tracking: maximum time (ms) before a failed provider/connection +# is retried regardless. Hard cap to prevent providers from being skipped indefinitely. +# Used by: open-sse/services/providerCooldownTracker.ts +# PROVIDER_COOLDOWN_MAX_MS=300000 + +# Enable/disable global provider cooldown tracking. Opt-in: this global +# cross-request cooldown overlaps the existing Connection Cooldown / Provider +# Circuit Breaker layers, so it is OFF by default. When disabled, only the +# existing per-request/per-connection cooldown state is used (previous behavior). +# Used by: open-sse/services/providerCooldownTracker.ts +# Accepted values: true|1|on (enable). Unset or anything else = disabled (default). +# PROVIDER_COOLDOWN_ENABLED=true + # Stagger interval (ms) between provider token healthchecks at startup. # Used by: src/lib/tokenHealthCheck.ts. Default: 3000. # HEALTHCHECK_STAGGER_MS=3000 diff --git a/CHANGELOG.md b/CHANGELOG.md index 322ae8e907..5fffde046b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,55 @@ ## [Unreleased] +### ✨ New Features + +- **feat(providers):** add Claude Fable 5 (`claude-fable-5`) — wires the new flagship model across the full pipeline: `cc` and `kiro` provider registries (1M context, 128k output), pricing constants, model spec (adaptive thinking, vision, tool use), fast mode, 1M-context beta header, fallback chain (`claude-fable-5 → claude-opus-4-8 → claude-opus-4-7 → claude-sonnet-4-6`), and cost data. + +### 🔧 Bug Fixes + +- **fix(translator):** scope the Gemini `thoughtSignature` bypass to the Antigravity/CLI path and unwrap array-shaped Gemini error bodies — signature-less historical tool calls on Antigravity/CLI are emitted as native parts carrying the `skip_thought_signature_validator` sentinel (preventing upstream 400s). (The standard Gemini direct path was kept on text/context representation here, then switched to native by #3569 below.) ([#3560](https://github.com/diegosouzapw/OmniRoute/pull/3560) — thanks @oyi77 and @Six7Day via [#3414](https://github.com/diegosouzapw/OmniRoute/pull/3414)) +- **fix(translator):** the standard Gemini direct path now maps historical tool calls to **native** `functionCall`/`functionResponse` parts instead of inert text. The previous text serialization (`[tool_history_call: …]` / "Historical tool-call record only …") leaked into the model's visible output (the model echoed the annotation text). A live test against the real Gemini API confirmed thinking models accept signature-less native `functionCall` parts (`gemini-2.5-flash` returns 200 even with `tools` + `thinkingConfig`), so the text mode is no longer needed as the default and the leak is gone. The Antigravity/CLI sentinel path (#3560) is untouched. ([#3569](https://github.com/diegosouzapw/OmniRoute/pull/3569) — thanks @hartmark) +- **fix(auto-update):** the self-update flow now resolves a stable project root instead of the launch directory — `resolveProjectRoot` walks up from the module's own location (`__dirname`) to the nearest `package.json`/`.git` rather than trusting `process.cwd()` (which could point anywhere the process was started from), and every `git`/`npm`/`pm2` step in `version/route.ts` runs against that `PROJECT_ROOT`. The original resolver fell through to `return cwd` on every branch, making `PROJECT_ROOT` a no-op; the walker (with TDD coverage) fixes that. ([#3561](https://github.com/diegosouzapw/OmniRoute/pull/3561) — thanks @oyi77; `PROJECT_ROOT` originally introduced + `version/route.ts` call sites wired in [#3423](https://github.com/diegosouzapw/OmniRoute/pull/3423) — thanks @ViFigueiredo) +- **fix(plugins):** wire plugin lifecycle hooks (`onInstall`/`onActivate`/`onDeactivate`/`onUninstall`) in the loader so `manager.ts` can register them with `emitHook` — they were declared in the manifest schema and dispatched by the manager, but the loader never built the `plugin.onX` methods, leaving the lifecycle hooks declared-but-dead. Also addresses #3518 review comments (redundant `RegExp(/.../)` → literals, `logs/[id]` route awaits the Next route `params`, indentation). ([#3562](https://github.com/diegosouzapw/OmniRoute/pull/3562) — thanks @oyi77) + +--- + +## [3.8.20] — Unreleased + +### ✨ New Features + +- **feat(providers):** add Claude Fable 5 (`claude-fable-5`) — wires the new flagship model across the full pipeline: `cc` and `kiro` provider registries (1M context, 128k output), pricing at $15/$75 per 1M tokens, model spec (adaptive thinking, vision, tool use), fast mode, 1M-context beta header, fallback chain (`claude-fable-5 → claude-opus-4-8 → claude-opus-4-7 → claude-sonnet-4-6`), and cost data. ([#3524](https://github.com/diegosouzapw/OmniRoute/pull/3524) — thanks @ggiak) +- **feat(resilience):** add global provider cooldown tracking to prevent combo re-walking — after a provider fails in a combo request, subsequent requests skip it for a configurable exponential backoff (default 5s min, 5min max, doubling per failure), reducing wasted time on known-failing providers. Configurable and opt-out via Settings → Resilience. ([#3556](https://github.com/diegosouzapw/OmniRoute/pull/3556) — thanks @pizzav-xyz) +- **feat(resilience):** expose provider breaker degradation threshold setting — the consecutive-failure count before a provider enters the DEGRADED state is now configurable in Settings → Resilience alongside the existing open/half-open thresholds. ([#3535](https://github.com/diegosouzapw/OmniRoute/pull/3535) — thanks @rdself) + +### 🔧 Bug Fixes + +- **fix(translator):** scope the Gemini `thoughtSignature` bypass to the Antigravity/CLI path and unwrap array-shaped Gemini error bodies — signature-less historical tool calls on Antigravity/CLI are emitted as native parts carrying the `skip_thought_signature_validator` sentinel (preventing upstream 400s), while the standard Gemini direct path keeps its existing text/context representation untouched. ([#3560](https://github.com/diegosouzapw/OmniRoute/issues/3560) — thanks @oyi77 and @Six7Day via [#3414](https://github.com/diegosouzapw/OmniRoute/pull/3414)) + +- **fix(routing):** combo model substitution no longer forwards a client `thinking:{type:"disabled"}` to a target model that rejects it — when a combo/route swaps the upstream model (e.g. `claude-opus-4-8` → `claude-fable-5`), OmniRoute now strips the now-invalid `thinking.type:"disabled"` for models flagged `rejectsThinkingDisabled` (Fable 5 defaults to adaptive and rejects it), preventing the upstream 400 that silently broke Claude Code's internal title/name-generation calls. Models that accept `disabled` (opus/sonnet) are untouched. ([#3554](https://github.com/diegosouzapw/OmniRoute/issues/3554)) +- **fix(usage):** the budget dashboard can now save a budget with some limit fields left empty and clear all limits — `setBudgetSchema` used `.positive()` (rejecting the `0` the form sends for blank fields) plus a superRefine requiring at least one limit `> 0`, so saving with one field filled 400'd and clearing all limits was impossible. Limits now accept `0` (= "no limit for this period"; enforcement only kicks in above 0) and the cross-field minimum was removed; negatives are still rejected. ([#3537](https://github.com/diegosouzapw/OmniRoute/issues/3537)) +- **fix(gamification):** badge-unlock events no longer re-fire on every request — the "already unlocked?" guard used `getBadges()`, which INNER-JOINs `badge_definitions` (empty until seeded), so it always reported "not earned" and re-emitted `events.badge_unlocked` per request. Added a `hasBadge()` helper that reads `user_badges` directly, so dedup is correct regardless of whether definitions are seeded. ([#3472](https://github.com/diegosouzapw/OmniRoute/issues/3472)) +- **fix(routing):** the `auto` model keyword now works on the Codex `/v1/responses` path — `resolveResponsesApiModel` rewrote the bare `auto` keyword to `codex/auto`, which ChatGPT rejects (`The 'auto' model is not supported when using Codex with a ChatGPT account`). `auto` (OmniRoute's zero-config auto-routing keyword) now passes through untouched so combo routing handles it. ([#3509](https://github.com/diegosouzapw/OmniRoute/issues/3509)) +- **fix(cli-tools):** saving the OpenCode/CLI tool config no longer 400s in cloud mode — every CLI tool card posts `apiKey: null` (the real key is resolved server-side from `keyId`), but `guideSettingsSaveSchema` used `z.string().optional()`, which rejects `null`. The schema now normalizes `null` → `undefined`, so the save succeeds and the `keyId`/default path is used. ([#3552](https://github.com/diegosouzapw/OmniRoute/issues/3552)) +- **fix(catalog):** PublicAI is no longer miscatalogued as keyless/free — it requires an API key (registry `authType:"apikey"`; signup grants a one-time credit, then it bills). The three PublicAI models moved from `freeType:"keyless"` (which could pick them into the no-auth pool and dispatch with no `Authorization` header) to `"one-time-initial"`, and the provider's `hasFree` flag is now `false` — matching `freeTierCatalog.ts`, which already excluded publicai. ([#3558](https://github.com/diegosouzapw/OmniRoute/issues/3558)) +- **fix(gemini-web):** a missing Playwright Chromium browser no longer loops and trips the provider breaker — when the browser binary is not installed, `chromium.launch()` threw an error surfaced as a retryable **500**, so accountFallback marked the account unavailable and retry-looped. It is now classified as a host/config problem and returns **503** with an actionable message (`npx playwright install chromium`) and the `X-Omni-Fallback-Hint: connection_cooldown` header, which skips the provider circuit breaker and applies a short non-exponential cooldown. ([#3516](https://github.com/diegosouzapw/OmniRoute/issues/3516)) +- **fix(proxy):** the SOCKS5 proxy option now follows the runtime `ENABLE_SOCKS5_PROXY` env instead of the build-time `NEXT_PUBLIC_ENABLE_SOCKS5_PROXY` — Next.js inlines `NEXT_PUBLIC_*` at build time, so a prebuilt Docker image ignored a runtime setting and the SOCKS5 type stayed hidden. The proxy modal now reads `socks5Enabled` from `GET /api/settings/proxies` (server-side `ENABLE_SOCKS5_PROXY`), with the build-time value kept only as a static-deploy fallback. ([#3508](https://github.com/diegosouzapw/OmniRoute/issues/3508)) +- **fix(playground):** the playground model selector now lists models from custom-endpoint (OpenAI/Anthropic-compatible) providers — it filtered `/v1/models` by the provider's connection id, but the catalog emits compatible-provider models under the node's custom prefix (`prefix/model`), so the list came up empty ("None"/"-"). The selector now filters by the node prefix (exposed additively as `modelPrefix` on provider options; the connection id is unchanged, so translator send/translate and connection lookups are unaffected). ([#3505](https://github.com/diegosouzapw/OmniRoute/issues/3505)) +- **fix(usage):** the Kiro quota card no longer renders a blank when the account returns no usage breakdown — `getKiroUsage` returned `quotas:{}` for a successful GetUsageLimits response without a `usageBreakdownList` (observed with some AWS IAM / Builder ID accounts), which the dashboard showed as an unexplained empty card. It now returns an informative message (surfaced via the card's connection-message path). ([#3506](https://github.com/diegosouzapw/OmniRoute/issues/3506)) +- **fix(security):** route raw `err.message` through `sanitizeErrorMessage()` in five web executors (`adapta-web`, `deepseek-web`, `perplexity-web`, `qoder`, `veoaifree-web`) and the embeddings + search handlers (Hard Rule #12) — these built error response bodies from the raw upstream/exception message, which could leak internal detail. ([#3494](https://github.com/diegosouzapw/OmniRoute/issues/3494), [#3495](https://github.com/diegosouzapw/OmniRoute/issues/3495)) +- **fix(dashboard):** correct two dashboard fetches that hit non-existent routes (404) — `CustomHostsManager` called `/api/tools/traffic-inspector/custom-hosts` (the real route is `/hosts`), and `FeatureFlagsGrid`'s post-restart liveness probe called `/api/health` (the real lightweight endpoint is `/api/health/ping`). ([#3486](https://github.com/diegosouzapw/OmniRoute/issues/3486), [#3487](https://github.com/diegosouzapw/OmniRoute/issues/3487)) +- **chore(providers):** remove the dead `krutrim` registry entry — it was half-registered (present in `providerRegistry.ts` with a baseUrl + one model, but absent from `providers.ts`, with no executor/translator/OAuth), so it was never selectable. Dropped its `ProviderIcon` entry and the `KNOWN_REGISTRY_ONLY` exception. ([#3483](https://github.com/diegosouzapw/OmniRoute/issues/3483)) +- **docs(api):** fix the agent-bridge per-agent state route in `openapi.yaml` and `AGENTBRIDGE.md` — both documented `/api/tools/agent-bridge/agents/{id}/state`, which has no route; corrected to the real per-agent `/api/tools/agent-bridge/agents/{id}` (global state remains `/api/tools/agent-bridge/state`). ([#3489](https://github.com/diegosouzapw/OmniRoute/issues/3489)) +- **docs(api):** correct `API_REFERENCE.md` endpoints that documented non-existent routes — skills (`PUT /api/skills/[id]`, `POST`/`GET /api/skills/executions`), plugins (`[id]`→`[name]`, `activate`/`deactivate`), ACP (`DELETE`/`POST /api/acp/agents` via `?id`/`{action:"refresh"}`), cache (`DELETE /api/cache/reasoning`, `/api/cache/entries`), and removed the fabricated `/api/admin/circuit-breaker`, `/api/admin/rate-limits`, and `/api/system-info` (admin only exposes `/concurrency`). ([#3497](https://github.com/diegosouzapw/OmniRoute/issues/3497)) +- **fix(executor):** strip provider prefix from versioned built-in tool model field — Anthropic rejects `tools[N].model: "cc/claude-opus-4-8"` from Claude Code's `advisor_20260301` and similar versioned built-in tools; the native Claude OAuth execute path now strips any provider prefix from `model` on tools whose name matches `name_YYYYMMDD`. ([#3532](https://github.com/diegosouzapw/OmniRoute/pull/3532) — thanks @ggiak) +- **fix(dashboard):** handle DEGRADED and unknown provider breaker states on the Runtime page — an unrecognised breaker state (e.g. DEGRADED) caused a crash because the styling map had no entry for it; now falls back to a neutral style so the page never throws on unknown states. ([#3533](https://github.com/diegosouzapw/OmniRoute/pull/3533) — thanks @rdself) +- **fix(usage):** make opencode-go quota fetcher fail-open instead of throwing 500 — the quota API rejects chat API keys with a JSON-401 body even though the same key works for chat; previously this threw and crashed the dashboard with a red error banner. It now returns an informative message and keeps rendering like other connection-message cases. ([#3522](https://github.com/diegosouzapw/OmniRoute/pull/3522) — thanks @wilsonicdev) +- **fix(translator):** map the Codex `local_shell` tool type — `local_shell` was absent from the translator's tool-type map, causing it to fall through as an unknown type; it is now forwarded correctly to the upstream. ([#3534](https://github.com/diegosouzapw/OmniRoute/pull/3534) — thanks @kamaka) +- **fix(images):** prefer bare combo names over built-in image model aliases — a user combo named `gpt-image-2` can now shadow the native OpenAI alias so image requests route through the combo; provider-qualified IDs like `openai/gpt-image-2` still resolve via the built-in path. ([#3527](https://github.com/diegosouzapw/OmniRoute/pull/3527) — thanks @AveryanAlex) +- **fix(translator):** fix OpenAI→Gemini translation of historical tool calls — tool results from earlier turns were being converted to text, causing Gemini to pattern-match the response as prose rather than structured content; they now use the native Gemini `functionResponse` part format. ([#3569](https://github.com/diegosouzapw/OmniRoute/pull/3569) — thanks @hartmark) +- **fix(plugins):** forward plugin lifecycle hooks (`onInstall`, `onActivate`, `onDeactivate`, `onUninstall`) via IPC and wrap `onDeactivate`/`onUninstall` in try/catch so a buggy plugin handler can no longer brick teardown; also removes redundant `RegExp()` wrappers in `accountFallback.ts` and fixes indentation in `requestLogger.ts`. ([#3562](https://github.com/diegosouzapw/OmniRoute/pull/3562) — thanks @oyi77) +- **fix(auto-update):** use a stable PROJECT_ROOT walker instead of frozen `process.cwd()` — `resolveProjectRoot` now walks up from `__dirname` to find the nearest directory containing `package.json` or `.git` (bounded at 16 levels), preventing ENOENT errors when the working directory is not the project root. ([#3561](https://github.com/diegosouzapw/OmniRoute/pull/3561) — thanks @oyi77 / @ViFigueiredo via [#3423](https://github.com/diegosouzapw/OmniRoute/pull/3423)) + --- ## [3.8.19] — 2026-06-09 diff --git a/docs/architecture/RESILIENCE_GUIDE.md b/docs/architecture/RESILIENCE_GUIDE.md index b84f3936ed..8424e50808 100644 --- a/docs/architecture/RESILIENCE_GUIDE.md +++ b/docs/architecture/RESILIENCE_GUIDE.md @@ -1,7 +1,7 @@ --- title: "Resilience Guide" -version: 3.8.2 -lastUpdated: 2026-05-13 +version: 3.8.18 +lastUpdated: 2026-06-09 --- # Resilience Guide @@ -30,16 +30,19 @@ OmniRoute has three distinct but related resilience mechanisms. Each has a diffe **States:** - `CLOSED` — normal traffic allowed +- `DEGRADED` — traffic still allowed, but elevated provider failures are being tracked - `OPEN` — provider temporarily blocked; combo routing skips it - `HALF_OPEN` — reset timeout elapsed; probe request allowed -**Defaults (`open-sse/config/constants.ts`):** +**Configurable defaults (`open-sse/config/constants.ts`, exposed in Dashboard → Settings → Resilience):** -| Class | Threshold | Reset timeout | -| ------- | ---------- | ------------- | -| OAuth | 3 failures | 60s | -| API-key | 5 failures | 30s | -| Local | 2 failures | 15s | +| Class | Degraded at | Opens at | Reset timeout | +| ------- | ----------- | ----------- | ------------- | +| OAuth | 5 failures | 8 failures | 60s | +| API-key | 7 failures | 12 failures | 30s | +| Local | derived | 2 failures | 15s | + +`degradationThreshold` controls when a provider enters `DEGRADED`; `failureThreshold` controls when it opens and is skipped. Local provider profiles are not exposed on the Resilience settings page yet. **Trip codes:** only provider-level statuses `[408, 500, 502, 503, 504]`. Do NOT trip for account-level errors (most 401/403/429 — those belong to cooldown or lockout). diff --git a/docs/frameworks/AGENTBRIDGE.md b/docs/frameworks/AGENTBRIDGE.md index 6e11f7d051..c4656f7bd8 100644 --- a/docs/frameworks/AGENTBRIDGE.md +++ b/docs/frameworks/AGENTBRIDGE.md @@ -367,7 +367,7 @@ Base path: `/api/tools/agent-bridge/` | GET | `/api/tools/agent-bridge/agents` | List all 9 agents with current state | | GET | `/api/tools/agent-bridge/state` | Global server state (running, port, cert info) | | POST | `/api/tools/agent-bridge/server` | Start/stop/restart server (`action: "start"\|"stop"\|"restart"\|"trust-cert"\|"regenerate-cert"`) | -| GET | `/api/tools/agent-bridge/agents/{id}/state` | State of one agent (dns_enabled, cert_trusted, etc.) | +| GET | `/api/tools/agent-bridge/agents/{id}` | State of one agent (dns_enabled, cert_trusted, etc.) | | POST | `/api/tools/agent-bridge/agents/{id}/dns` | Enable/disable DNS for agent (`{enabled: boolean}`) | | GET | `/api/tools/agent-bridge/agents/{id}/mappings` | Model mappings for agent | | PUT | `/api/tools/agent-bridge/agents/{id}/mappings` | Update model mappings | diff --git a/docs/guides/USER_GUIDE.md b/docs/guides/USER_GUIDE.md index bfb3c08368..6256824de5 100644 --- a/docs/guides/USER_GUIDE.md +++ b/docs/guides/USER_GUIDE.md @@ -826,10 +826,12 @@ OmniRoute implements provider-level resilience with five components: - **Use Upstream Retry Hints** — Honors authoritative `Retry-After` or reset hints when provided - **Max Backoff Steps** — Maximum exponential backoff level for repeated failures -3. **Provider Circuit Breaker** — Tracks end-to-end provider failures and automatically opens the breaker when the configured threshold is reached: - - **Failure Threshold** — Consecutive provider failures before opening the breaker +3. **Provider Circuit Breaker** — Tracks end-to-end provider failures, marks a provider degraded at the configured warning threshold, and opens the breaker when the configured failure threshold is reached: + - **Degradation Threshold** — Consecutive provider failures before entering `DEGRADED` + - **Failure Threshold** — Consecutive provider failures before entering `OPEN` - **Reset Timeout** — Time window before the provider is tested again - **CLOSED** (Healthy) — Requests flow normally + - **DEGRADED** — Requests still flow while elevated failures are tracked - **OPEN** — Provider is temporarily blocked after repeated failures - **HALF_OPEN** — Testing if provider has recovered diff --git a/docs/i18n/ar/CHANGELOG.md b/docs/i18n/ar/CHANGELOG.md index ea85176eaf..9c868eb317 100644 --- a/docs/i18n/ar/CHANGELOG.md +++ b/docs/i18n/ar/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/az/CHANGELOG.md b/docs/i18n/az/CHANGELOG.md index 48f0fe6436..78a81e2c7a 100644 --- a/docs/i18n/az/CHANGELOG.md +++ b/docs/i18n/az/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/bg/CHANGELOG.md b/docs/i18n/bg/CHANGELOG.md index 48f0fe6436..78a81e2c7a 100644 --- a/docs/i18n/bg/CHANGELOG.md +++ b/docs/i18n/bg/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/bn/CHANGELOG.md b/docs/i18n/bn/CHANGELOG.md index 8cd71c3ed0..3bc97dba38 100644 --- a/docs/i18n/bn/CHANGELOG.md +++ b/docs/i18n/bn/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/cs/CHANGELOG.md b/docs/i18n/cs/CHANGELOG.md index 330ddcc3c5..3f578fd708 100644 --- a/docs/i18n/cs/CHANGELOG.md +++ b/docs/i18n/cs/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/da/CHANGELOG.md b/docs/i18n/da/CHANGELOG.md index c756a024c0..96ac7349d7 100644 --- a/docs/i18n/da/CHANGELOG.md +++ b/docs/i18n/da/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/de/CHANGELOG.md b/docs/i18n/de/CHANGELOG.md index 5947fcff1d..2ed2228d73 100644 --- a/docs/i18n/de/CHANGELOG.md +++ b/docs/i18n/de/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/es/CHANGELOG.md b/docs/i18n/es/CHANGELOG.md index 420689d514..8406ee6b70 100644 --- a/docs/i18n/es/CHANGELOG.md +++ b/docs/i18n/es/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/fa/CHANGELOG.md b/docs/i18n/fa/CHANGELOG.md index 4683e73ed2..fbc906a2be 100644 --- a/docs/i18n/fa/CHANGELOG.md +++ b/docs/i18n/fa/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/fi/CHANGELOG.md b/docs/i18n/fi/CHANGELOG.md index c5384ff40f..41128774dd 100644 --- a/docs/i18n/fi/CHANGELOG.md +++ b/docs/i18n/fi/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/fr/CHANGELOG.md b/docs/i18n/fr/CHANGELOG.md index 529bc4ec3b..24b06106ec 100644 --- a/docs/i18n/fr/CHANGELOG.md +++ b/docs/i18n/fr/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/gu/CHANGELOG.md b/docs/i18n/gu/CHANGELOG.md index 9268a84b53..5fea96c39d 100644 --- a/docs/i18n/gu/CHANGELOG.md +++ b/docs/i18n/gu/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/he/CHANGELOG.md b/docs/i18n/he/CHANGELOG.md index 8541fc252b..6ff1debb5c 100644 --- a/docs/i18n/he/CHANGELOG.md +++ b/docs/i18n/he/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/hi/CHANGELOG.md b/docs/i18n/hi/CHANGELOG.md index 4e6cc263f9..8659716fb3 100644 --- a/docs/i18n/hi/CHANGELOG.md +++ b/docs/i18n/hi/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/hu/CHANGELOG.md b/docs/i18n/hu/CHANGELOG.md index d8dfe1a0ff..660e13038d 100644 --- a/docs/i18n/hu/CHANGELOG.md +++ b/docs/i18n/hu/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/id/CHANGELOG.md b/docs/i18n/id/CHANGELOG.md index 42dfa3a0a9..c535886728 100644 --- a/docs/i18n/id/CHANGELOG.md +++ b/docs/i18n/id/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/in/CHANGELOG.md b/docs/i18n/in/CHANGELOG.md index 18f6442955..14801a0743 100644 --- a/docs/i18n/in/CHANGELOG.md +++ b/docs/i18n/in/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/it/CHANGELOG.md b/docs/i18n/it/CHANGELOG.md index 980ce1f5f4..8c824f3e2b 100644 --- a/docs/i18n/it/CHANGELOG.md +++ b/docs/i18n/it/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ja/CHANGELOG.md b/docs/i18n/ja/CHANGELOG.md index 98542f8153..47b2116189 100644 --- a/docs/i18n/ja/CHANGELOG.md +++ b/docs/i18n/ja/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ko/CHANGELOG.md b/docs/i18n/ko/CHANGELOG.md index fdf97a58e8..717d0b5767 100644 --- a/docs/i18n/ko/CHANGELOG.md +++ b/docs/i18n/ko/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/mr/CHANGELOG.md b/docs/i18n/mr/CHANGELOG.md index b5e765a519..7680c690b4 100644 --- a/docs/i18n/mr/CHANGELOG.md +++ b/docs/i18n/mr/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ms/CHANGELOG.md b/docs/i18n/ms/CHANGELOG.md index 7a1e67c5ea..a2d76e7b58 100644 --- a/docs/i18n/ms/CHANGELOG.md +++ b/docs/i18n/ms/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/nl/CHANGELOG.md b/docs/i18n/nl/CHANGELOG.md index 5852d5dc87..d0615727d8 100644 --- a/docs/i18n/nl/CHANGELOG.md +++ b/docs/i18n/nl/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/no/CHANGELOG.md b/docs/i18n/no/CHANGELOG.md index e3850f5ef3..c71a1f9f63 100644 --- a/docs/i18n/no/CHANGELOG.md +++ b/docs/i18n/no/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/phi/CHANGELOG.md b/docs/i18n/phi/CHANGELOG.md index d9f3f1a080..51d33bfc5b 100644 --- a/docs/i18n/phi/CHANGELOG.md +++ b/docs/i18n/phi/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/pl/CHANGELOG.md b/docs/i18n/pl/CHANGELOG.md index 967bfd4ab8..ee4e983ce7 100644 --- a/docs/i18n/pl/CHANGELOG.md +++ b/docs/i18n/pl/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/pt-BR/CHANGELOG.md b/docs/i18n/pt-BR/CHANGELOG.md index 2ca378d0c6..20802c1888 100644 --- a/docs/i18n/pt-BR/CHANGELOG.md +++ b/docs/i18n/pt-BR/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/pt/CHANGELOG.md b/docs/i18n/pt/CHANGELOG.md index c8b6faee0a..faea410a87 100644 --- a/docs/i18n/pt/CHANGELOG.md +++ b/docs/i18n/pt/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ro/CHANGELOG.md b/docs/i18n/ro/CHANGELOG.md index b9527486fc..91061aa52f 100644 --- a/docs/i18n/ro/CHANGELOG.md +++ b/docs/i18n/ro/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ru/CHANGELOG.md b/docs/i18n/ru/CHANGELOG.md index 17d21ee1c5..9f3e3472e5 100644 --- a/docs/i18n/ru/CHANGELOG.md +++ b/docs/i18n/ru/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/sk/CHANGELOG.md b/docs/i18n/sk/CHANGELOG.md index 5a179a72af..21b026e868 100644 --- a/docs/i18n/sk/CHANGELOG.md +++ b/docs/i18n/sk/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/sv/CHANGELOG.md b/docs/i18n/sv/CHANGELOG.md index b1f32ca974..44d2a4a9f2 100644 --- a/docs/i18n/sv/CHANGELOG.md +++ b/docs/i18n/sv/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/sw/CHANGELOG.md b/docs/i18n/sw/CHANGELOG.md index 7b4bce21ea..701783bdd0 100644 --- a/docs/i18n/sw/CHANGELOG.md +++ b/docs/i18n/sw/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ta/CHANGELOG.md b/docs/i18n/ta/CHANGELOG.md index 2e92ecd137..9342c32e2e 100644 --- a/docs/i18n/ta/CHANGELOG.md +++ b/docs/i18n/ta/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/te/CHANGELOG.md b/docs/i18n/te/CHANGELOG.md index 5f529e9946..c2062c7253 100644 --- a/docs/i18n/te/CHANGELOG.md +++ b/docs/i18n/te/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/th/CHANGELOG.md b/docs/i18n/th/CHANGELOG.md index 997085acac..31b355a8a1 100644 --- a/docs/i18n/th/CHANGELOG.md +++ b/docs/i18n/th/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/tr/CHANGELOG.md b/docs/i18n/tr/CHANGELOG.md index f0465afa79..a2cead6b95 100644 --- a/docs/i18n/tr/CHANGELOG.md +++ b/docs/i18n/tr/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/uk-UA/CHANGELOG.md b/docs/i18n/uk-UA/CHANGELOG.md index 5efee11aa3..d95ecc00d2 100644 --- a/docs/i18n/uk-UA/CHANGELOG.md +++ b/docs/i18n/uk-UA/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ur/CHANGELOG.md b/docs/i18n/ur/CHANGELOG.md index 35a7f6541f..6ea32b06e9 100644 --- a/docs/i18n/ur/CHANGELOG.md +++ b/docs/i18n/ur/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/vi/CHANGELOG.md b/docs/i18n/vi/CHANGELOG.md index 72ce506fe8..c08a7af335 100644 --- a/docs/i18n/vi/CHANGELOG.md +++ b/docs/i18n/vi/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/zh-CN/CHANGELOG.md b/docs/i18n/zh-CN/CHANGELOG.md index 090d67eedc..8bb501c5dc 100644 --- a/docs/i18n/zh-CN/CHANGELOG.md +++ b/docs/i18n/zh-CN/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.20] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.19] — Unreleased _Development cycle in progress._ diff --git a/docs/reference/API_REFERENCE.md b/docs/reference/API_REFERENCE.md index 669c25590b..8fdda8372c 100644 --- a/docs/reference/API_REFERENCE.md +++ b/docs/reference/API_REFERENCE.md @@ -539,7 +539,6 @@ These endpoints mirror Gemini's API format for clients that expect native Gemini | `/api/restart` | POST | Trigger graceful server restart | | `/api/shutdown` | POST | Trigger graceful server shutdown | | `/api/system/env/repair` | POST | Repair OAuth provider environment variables | -| `/api/system-info` | GET | Generate system diagnostics report | > **Note:** These endpoints are used internally by the system or for Ollama client compatibility. They are not typically called by end users. @@ -842,6 +841,8 @@ OmniRoute exposes three independent temporary-failure mechanisms; the management | Connection cooldown | `rateLimitedUntil` on provider connections | `/api/rate-limits`, `/api/providers/[id]` | (re-enables lazily; clear via provider PUT) | | Model lockout | In-memory model-availability registry | `GET /api/resilience/model-cooldowns` | `DELETE /api/resilience/model-cooldowns` | +`PATCH /api/resilience` accepts provider breaker overrides under `providerBreaker.oauth` and `providerBreaker.apikey`. Each profile supports `degradationThreshold`, `failureThreshold`, and `resetTimeoutMs`; the same fields are exposed in Dashboard → Settings → Resilience. + ```bash # Clear a single model lockout curl -X DELETE http://localhost:20128/api/resilience/model-cooldowns \ @@ -1001,9 +1002,8 @@ registration. | Method | Path | Description | | ------ | ----------------------- | ---------------------------------------------------------------------------------------- | | GET | `/api/acp/agents` | List all known CLI agents (built-in + custom) with installation status, version, binary | -| POST | `/api/acp/agents` | Register a custom ACP agent — body: `{id, name, binary, versionCommand, providerAlias, spawnArgs, protocol}` | -| DELETE | `/api/acp/agents/[id]` | Remove a custom ACP agent | -| POST | `/api/acp/agents/refresh` | Force refresh of the agent detection cache (60s TTL) | +| POST | `/api/acp/agents` | Register a custom ACP agent or refresh cache — body: `{id, name, binary, versionCommand, providerAlias, spawnArgs, protocol}` or `{action: "refresh"}` | +| DELETE | `/api/acp/agents` | Remove a custom ACP agent — query param: `?id=` | **Response example** (`GET /api/acp/agents`): @@ -1146,10 +1146,6 @@ Admin-only endpoints for operational management. | ------ | ------------------------------- | ---------------------------------------------------------------------------------------------- | | GET | `/api/admin/concurrency` | Read current concurrency limits (global + per-provider) | | POST | `/api/admin/concurrency` | Update concurrency limits — body: `{global?: number, perProvider?: Record}` | -| GET | `/api/admin/circuit-breaker` | Read circuit breaker states for all providers | -| POST | `/api/admin/circuit-breaker/reset` | Manually reset a circuit breaker — body: `{providerId}` | -| GET | `/api/admin/rate-limits` | Read current rate limit configurations | -| POST | `/api/admin/rate-limits` | Update rate limit configs — body: `{providerId, requestsPerMinute, tokensPerMinute}` | **Auth:** Requires management session with admin scope. @@ -1204,8 +1200,7 @@ Manage the semantic cache and reasoning cache. | DELETE | `/api/cache/entries` | Delete cache entries (filter by query parameters) | | GET | `/api/cache/stats` | Detailed cache statistics (per-provider, per-model) | | GET | `/api/cache/reasoning` | Reasoning cache status (for reasoning replay) | -| POST | `/api/cache/reasoning/clear` | Clear reasoning cache | -| POST | `/api/cache/clear` | Clear all cache entries | +| DELETE | `/api/cache/reasoning` | Clear reasoning cache — query params: `?toolCallId=` (single) or `?provider=

` or no params (all) | **Auth:** Requires management session. @@ -1260,10 +1255,9 @@ Manage Skills (the agentic extensions framework). | GET | `/api/skills` | List all installed skills (built-in + custom) | | POST | `/api/skills/install` | Install a skill from a local path or URL | | DELETE | `/api/skills/[id]` | Uninstall a skill | -| POST | `/api/skills/[id]/enable` | Enable a disabled skill | -| POST | `/api/skills/[id]/disable` | Disable an enabled skill | -| POST | `/api/skills/[id]/execute` | Execute a skill with input | -| GET | `/api/skills/[id]/executions` | List execution history for a skill | +| PUT | `/api/skills/[id]` | Enable or disable a skill — body: `{enabled?: boolean, mode?: "on" \| "off" \| "auto"}` | +| POST | `/api/skills/executions` | Execute a skill — body: `{skillName, apiKeyId, input?, sessionId?}` | +| GET | `/api/skills/executions` | List execution history for all skills (filter by `?apiKeyId=`) | **Auth:** Requires management session or management-scoped API key. @@ -1279,11 +1273,11 @@ Manage OmniRoute plugins (third-party extensions). | ------ | --------------------------------- | -------------------------------------------------------------------------------------------- | | GET | `/api/plugins` | List installed plugins | | POST | `/api/plugins/install` | Install a plugin from a local path or URL | -| DELETE | `/api/plugins/[id]` | Uninstall a plugin | -| POST | `/api/plugins/[id]/enable` | Enable a disabled plugin | -| POST | `/api/plugins/[id]/disable` | Disable an enabled plugin | -| GET | `/api/plugins/[id]/config` | Get plugin configuration | -| PUT | `/api/plugins/[id]/config` | Update plugin configuration | +| DELETE | `/api/plugins/[name]` | Uninstall a plugin | +| POST | `/api/plugins/[name]/activate` | Activate a plugin | +| POST | `/api/plugins/[name]/deactivate` | Deactivate a plugin | +| GET | `/api/plugins/[name]/config` | Get plugin configuration | +| PUT | `/api/plugins/[name]/config` | Update plugin configuration | **Auth:** Requires management session. diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 14e9542c54..3a113de066 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -729,6 +729,9 @@ Anthropic-compatible provider instead. | `PROXY_HEALTH_CACHE_TTL_MS` | `30000` | `src/lib/proxyHealth.ts` | Health check result cache TTL. | | `RATE_LIMIT_MAX_WAIT_MS` | `120000` (2 min) | `open-sse/services/rateLimitManager.ts` | Max time to wait on a 429 before failing the request. | | `RATE_LIMIT_AUTO_ENABLE` | _(unset)_ | `open-sse/services/rateLimitManager.ts` | Force the auto-enable rate limit safety net on/off regardless of the persisted Dashboard setting. Accepts `true`/`1`/`on` to force on, `false`/`0`/`off` to force off. | +| `PROVIDER_COOLDOWN_ENABLED` | _(unset → off)_ | `open-sse/services/providerCooldownTracker.ts` | Opt-in global cross-request provider/connection cooldown tracking. OFF by default (overlaps Connection Cooldown / Provider Circuit Breaker). Accepts `true`/`1`/`on` to enable. | +| `PROVIDER_COOLDOWN_MIN_MS` | `5000` | `open-sse/services/providerCooldownTracker.ts` | Minimum cooldown (ms) before a failed provider/connection is retried. Scaled exponentially with consecutive failures. Only used when `PROVIDER_COOLDOWN_ENABLED`. | +| `PROVIDER_COOLDOWN_MAX_MS` | `300000` (5 min) | `open-sse/services/providerCooldownTracker.ts` | Maximum cooldown (ms) cap before a failed provider/connection is retried regardless. Only used when `PROVIDER_COOLDOWN_ENABLED`. | | `HEALTHCHECK_STAGGER_MS` | `3000` | `src/lib/tokenHealthCheck.ts` | Stagger interval (ms) between provider token healthchecks at startup. | | `REQUEST_RETRY` | `2` | `src/sse/services/cooldownAwareRetry.ts` | Number of automatic retries on model-scoped cooldown responses before returning error to client. | | `MAX_RETRY_INTERVAL_SEC` | `30` | `src/sse/services/cooldownAwareRetry.ts` | Max backoff interval (seconds) between cooldown retries. Capped by this value regardless of upstream `Retry-After`. | diff --git a/docs/reference/openapi.yaml b/docs/reference/openapi.yaml index a99b22841b..ad52734f88 100644 --- a/docs/reference/openapi.yaml +++ b/docs/reference/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 3.8.19 + version: 3.8.20 description: | OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible endpoint that routes requests to multiple AI providers with load balancing, @@ -4082,26 +4082,6 @@ paths: "409": description: Port 443 conflict - /api/tools/agent-bridge/agents/{agentId}/state: - get: - tags: [AgentBridge] - summary: Get state of one agent - parameters: - - name: agentId - in: path - required: true - schema: - $ref: "#/components/schemas/AgentId" - responses: - "200": - description: Agent state - content: - application/json: - schema: - $ref: "#/components/schemas/AgentBridgeAgentState" - "404": - description: Unknown agent ID - /api/tools/agent-bridge/agents/{agentId}/dns: post: tags: [AgentBridge] diff --git a/electron/package-lock.json b/electron/package-lock.json index 00aaee5bc9..8206cb09e3 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute-desktop", - "version": "3.8.19", + "version": "3.8.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute-desktop", - "version": "3.8.19", + "version": "3.8.20", "license": "MIT", "dependencies": { "electron-updater": "^6.8.9" diff --git a/electron/package.json b/electron/package.json index d73387a028..d6c2290c1f 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "omniroute-desktop", - "version": "3.8.19", + "version": "3.8.20", "description": "OmniRoute Desktop Application", "main": "main.js", "author": { diff --git a/open-sse/config/freeModelCatalog.data.ts b/open-sse/config/freeModelCatalog.data.ts index adba8f6bba..5b29278ece 100644 --- a/open-sse/config/freeModelCatalog.data.ts +++ b/open-sse/config/freeModelCatalog.data.ts @@ -407,9 +407,9 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ { provider: "nscale", modelId: "openai/gpt-oss-20b", displayName: "openai/gpt-oss-20b", monthlyTokens: 0, creditTokens: 5000000, freeType: "one-time-initial", poolKey: "nscale", tos: "caution" }, { provider: "nscale", modelId: "meta-llama/Llama-4-Scout-17B-16E-Instruct", displayName: "meta-llama/Llama-4-Scout-17B-16E-Instruct", monthlyTokens: 0, creditTokens: 5000000, freeType: "one-time-initial", poolKey: "nscale", tos: "caution" }, { provider: "nscale", modelId: "meta-llama/Llama-3.3-70B-Instruct", displayName: "meta-llama/Llama-3.3-70B-Instruct", monthlyTokens: 0, creditTokens: 5000000, freeType: "one-time-initial", poolKey: "nscale", tos: "caution" }, - { provider: "publicai", modelId: "swiss-ai/apertus-70b-instruct", displayName: "swiss-ai/apertus-70b-instruct", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "publicai", tos: "caution" }, - { provider: "publicai", modelId: "aisingapore/Qwen-SEA-LION-v4-32B-IT", displayName: "aisingapore/Qwen-SEA-LION-v4-32B-IT", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "publicai", tos: "caution" }, - { provider: "publicai", modelId: "allenai/Olmo-3-32B-Think", displayName: "allenai/Olmo-3-32B-Think", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "publicai", tos: "caution" }, + { provider: "publicai", modelId: "swiss-ai/apertus-70b-instruct", displayName: "swiss-ai/apertus-70b-instruct", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "publicai", tos: "caution" }, + { provider: "publicai", modelId: "aisingapore/Qwen-SEA-LION-v4-32B-IT", displayName: "aisingapore/Qwen-SEA-LION-v4-32B-IT", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "publicai", tos: "caution" }, + { provider: "publicai", modelId: "allenai/Olmo-3-32B-Think", displayName: "allenai/Olmo-3-32B-Think", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "publicai", tos: "caution" }, { provider: "morph", modelId: "morph-v3-large", displayName: "morph-v3-large", monthlyTokens: 400000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "morph", tos: "caution" }, { provider: "morph", modelId: "morph-v3-fast", displayName: "morph-v3-fast", monthlyTokens: 400000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "morph", tos: "caution" }, { provider: "featherless-ai", modelId: "featherless-ai/Qwerky-72B", displayName: "featherless-ai/Qwerky-72B", monthlyTokens: 0, creditTokens: 0, freeType: "discontinued", poolKey: "featherless-ai", tos: "avoid" }, diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index f76d0c2937..4638eda889 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -660,6 +660,12 @@ const _REGISTRY_EAGER: Record = { tokenUrl: "https://api.anthropic.com/v1/oauth/token", }, models: [ + { + id: "claude-fable-5", + name: "Claude Fable 5", + contextLength: 1000000, + maxOutputTokens: 128000, + }, { id: "claude-opus-4-8", name: "Claude Opus 4.8", @@ -1142,6 +1148,12 @@ const _REGISTRY_EAGER: Record = { }, models: [ { id: "auto-kiro", name: "Auto (Kiro picks best model)" }, + { + id: "claude-fable-5", + name: "Claude Fable 5", + contextLength: 1000000, + maxOutputTokens: 128000, + }, { id: "claude-opus-4.8", name: "Claude Opus 4.8", @@ -2474,17 +2486,6 @@ const _REGISTRY_EAGER: Record = { models: [{ id: "auto", name: "Auto" }], }, - krutrim: { - id: "krutrim", - alias: "krutrim", - format: "openai", - executor: "default", - baseUrl: "https://api.krutrim.com/v1/chat/completions", - authType: "apikey", - authHeader: "bearer", - models: [{ id: "krutrim-2-7b-instruct", name: "Krutrim 2 7B" }], - }, - liquid: { id: "liquid", alias: "liquid", diff --git a/open-sse/executors/adapta-web.ts b/open-sse/executors/adapta-web.ts index b144bd8267..b94f3cfcae 100644 --- a/open-sse/executors/adapta-web.ts +++ b/open-sse/executors/adapta-web.ts @@ -1,5 +1,6 @@ import { BaseExecutor, type ExecuteInput } from "./base.ts"; import { prepareToolMessages, buildToolAwareResult } from "../translator/webTools.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; const ADAPTA_APP_URL = "https://agent.adapta.one"; const ADAPTA_CLERK_URL = "https://clerk.agent.adapta.one"; @@ -378,7 +379,7 @@ export class AdaptaWebExecutor extends BaseExecutor { const msg = err instanceof Error ? err.message : String(err); log?.warn?.("ADAPTA-WEB", `Auth failed: ${msg}`); return { - response: makeErrorResponse(401, `Adapta auth failed: ${msg}`), + response: makeErrorResponse(401, `Adapta auth failed: ${sanitizeErrorMessage(msg)}`), url: ADAPTA_STREAM_URL, headers: {}, transformedBody: body, diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 82dda4a0d2..d9267e0a7e 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -661,7 +661,9 @@ export class AntigravityExecutor extends BaseExecutor { if (typeof p.text === "string" && p.text === "") return false; if (p.functionCall && !p.functionCall.name) return false; - return !p.thought && (hasFunctionCall || !p.thoughtSignature); + // Only strip if it's NOT our bypass sentinel. + // Antigravity models (like Gemini) need this sentinel to bypass 400 errors. + return !p.thought && (hasFunctionCall || !p.thoughtSignature || p.thoughtSignature === "skip_thought_signature_validator"); }) || []; return { ...c, role, parts }; }) || []; diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 8bda0a1107..f940e630d6 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -317,6 +317,27 @@ export function sanitizeReasoningEffortForProvider( return body; } +/** + * Strip the OmniRoute provider prefix from versioned built-in tool model + * fields (e.g. `cc/claude-opus-4-8` → `claude-opus-4-8`). Versioned built-in + * tool types carry an 8-digit date suffix (`advisor_20260301`, `bash_20250124`); + * the real Claude CLI sends a bare model id there, never a prefixed one, so a + * leaked OmniRoute prefix makes Anthropic reject the request. Mutates in place. + */ +export function stripVersionedToolModelPrefix(tools: unknown): void { + if (!Array.isArray(tools)) return; + for (const t of tools as Array>) { + if ( + typeof t.type === "string" && + /^[a-z][a-z0-9_]*_\d{8}$/.test(t.type) && + typeof t.model === "string" && + t.model.includes("/") + ) { + t.model = t.model.split("/").pop(); + } + } +} + /** * BaseExecutor - Base class for provider executors. * Implements the Strategy pattern: subclasses override specific methods @@ -824,6 +845,9 @@ export class BaseExecutor { for (const t of tb.tools as Array>) { delete t.cache_control; } + // Also strip OmniRoute provider prefix from versioned built-in tool + // model fields (e.g. cc/claude-opus-4-8 → claude-opus-4-8). + stripVersionedToolModelPrefix(tb.tools); } // Per-request behavior overrides via custom client headers. diff --git a/open-sse/executors/deepseek-web.ts b/open-sse/executors/deepseek-web.ts index 55b10aa2da..242973924f 100644 --- a/open-sse/executors/deepseek-web.ts +++ b/open-sse/executors/deepseek-web.ts @@ -5,6 +5,7 @@ import { parseToolCallsFromText, type OpenAIToolCall, } from "../translator/webTools.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; export const DEEPSEEK_WEB_BASE = "https://chat.deepseek.com"; const DEEPSEEK_API_BASE = `${DEEPSEEK_WEB_BASE}/api`; @@ -1100,7 +1101,7 @@ export class DeepSeekWebExecutor extends BaseExecutor { } return { - response: errorResponse(502, `DeepSeek error: ${msg}`), + response: errorResponse(502, `DeepSeek error: ${sanitizeErrorMessage(msg)}`), url: COMPLETION_URL, headers: {}, transformedBody: body, diff --git a/open-sse/executors/gemini-web.ts b/open-sse/executors/gemini-web.ts index 0d21f99437..4c06e2a9f0 100644 --- a/open-sse/executors/gemini-web.ts +++ b/open-sse/executors/gemini-web.ts @@ -19,6 +19,19 @@ import { sanitizeErrorMessage } from "../utils/error.ts"; // ─── Constants ────────────────────────────────────────────────────────────── const GEMINI_URL = "https://gemini.google.com/app"; + +/** + * Whether an error came from Playwright failing to launch because the browser binary is not + * installed (`chromium.launch: Executable doesn't exist at ...`). This is a host/config + * problem, not a transient upstream fault, so the executor must NOT surface it as a retryable + * 500 (which marks the account unavailable and loops / trips the provider breaker). See #3516. + */ +export function isMissingBrowserExecutable(message: string): boolean { + if (!message) return false; + return /executable doesn't exist|executablenotfound|playwright install|chromium.*download/i.test( + message + ); +} const GEMINI_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"; @@ -292,10 +305,36 @@ export class GeminiWebExecutor extends BaseExecutor { transformedBody: body, }; } catch (error) { + const rawMessage = error instanceof Error ? error.message : "Unknown error"; + // #3516: a missing Playwright browser is a host/config problem, not a transient upstream + // fault. Surface an actionable error and tag it with the connection-cooldown hint so + // accountFallback skips the provider circuit breaker and applies a short, non-exponential + // cooldown instead of looping on a retryable 500. + if (isMissingBrowserExecutable(rawMessage)) { + return { + response: new Response( + JSON.stringify({ + error: + "Gemini Web requires the Playwright Chromium browser, which is not installed. " + + "Run `npx playwright install chromium` on the host (or rebuild the Docker image with browsers).", + }), + { + status: 503, + headers: { + "Content-Type": "application/json", + "X-Omni-Fallback-Hint": "connection_cooldown", + }, + } + ), + url: GEMINI_URL, + headers: {}, + transformedBody: body, + }; + } return { response: new Response( JSON.stringify({ - error: sanitizeErrorMessage(error instanceof Error ? error.message : "Unknown error"), + error: sanitizeErrorMessage(rawMessage), }), { status: 500, headers: { "Content-Type": "application/json" } } ), diff --git a/open-sse/executors/perplexity-web.ts b/open-sse/executors/perplexity-web.ts index 6d694ac59d..bca64552ae 100644 --- a/open-sse/executors/perplexity-web.ts +++ b/open-sse/executors/perplexity-web.ts @@ -14,6 +14,7 @@ import { type TlsFetchResult, } from "../services/perplexityTlsClient.ts"; import { prepareToolMessages, buildToolAwareResult } from "../translator/webTools.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; const PPLX_SSE_ENDPOINT = "https://www.perplexity.ai/rest/sse/perplexity_ask"; const PPLX_API_VERSION = "client-1.11.0"; @@ -756,8 +757,8 @@ export class PerplexityWebExecutor extends BaseExecutor { JSON.stringify({ error: { message: isTlsUnavail - ? `Perplexity TLS client unavailable: ${(err as Error).message}` - : `Perplexity connection failed: ${err instanceof Error ? err.message : String(err)}`, + ? `Perplexity TLS client unavailable: ${sanitizeErrorMessage((err as Error).message)}` + : `Perplexity connection failed: ${sanitizeErrorMessage(err instanceof Error ? err.message : String(err))}`, type: "upstream_error", }, }), diff --git a/open-sse/executors/qoder.ts b/open-sse/executors/qoder.ts index 97b8af595e..3c96e6b61a 100644 --- a/open-sse/executors/qoder.ts +++ b/open-sse/executors/qoder.ts @@ -12,6 +12,7 @@ import { } from "../config/providerHeaderProfiles.ts"; import { sanitizeQwenThinkingToolChoice } from "../services/qwenThinking.ts"; import { buildCosyHeadersForValidation } from "../services/qoderCli.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; function getAuthToken(credentials: ProviderCredentials): string { if (typeof credentials.apiKey === "string" && credentials.apiKey.trim()) { @@ -230,7 +231,7 @@ export class QoderExecutor extends BaseExecutor { response: new Response( JSON.stringify({ error: { - message: `Qoder fetch error: ${error.message}`, + message: `Qoder fetch error: ${sanitizeErrorMessage(error.message)}`, type: "provider_error", }, }), diff --git a/open-sse/executors/veoaifree-web.ts b/open-sse/executors/veoaifree-web.ts index 9cfeedb5a9..56e81ae41c 100644 --- a/open-sse/executors/veoaifree-web.ts +++ b/open-sse/executors/veoaifree-web.ts @@ -7,6 +7,7 @@ * No auth required. Rate limited to 6 requests/hour per IP. */ import { BaseExecutor, type ExecuteInput } from "./base.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; const BASE_URL = "https://veoaifree.com"; const AJAX_URL = `${BASE_URL}/wp-admin/admin-ajax.php`; @@ -362,7 +363,7 @@ export class VeoAIFreeWebExecutor extends BaseExecutor { nonce = await fetchNonce(input.signal); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to get nonce"; - return { response: errResp(msg), url: BASE_URL, headers: {}, transformedBody: null }; + return { response: errResp(sanitizeErrorMessage(msg)), url: BASE_URL, headers: {}, transformedBody: null }; } // Extract aspect ratio from system prompt or default diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 83d69993b5..2f911bfb6c 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -34,6 +34,7 @@ import { import { resolveModelAlias } from "../services/modelDeprecation.ts"; import { getUnsupportedParams } from "../config/providerRegistry.ts"; import { supportsMaxTokens } from "@/lib/modelCapabilities.ts"; +import { normalizeThinkingForModel } from "@/shared/constants/modelSpecs.ts"; import { buildErrorBody, createErrorResult, @@ -3553,6 +3554,14 @@ export async function handleChatCore({ } translatedBody.model = finalModelToUpstream; + // #3554: a combo/route may substitute the upstream model AFTER the client chose its + // `thinking` value. Claude Code sends `thinking:{type:"disabled"}` for internal calls, + // which claude-fable-5 (adaptive-only) rejects with a 400. Drop the now-invalid value + // when the resolved target model rejects it; models that accept `disabled` are untouched. + if (typeof finalModelToUpstream === "string") { + translatedBody = normalizeThinkingForModel(translatedBody, finalModelToUpstream); + } + const previousResponseIdPolicy = applyResponsesPreviousResponseIdPolicy(translatedBody, { mode: settings.responsesPreviousResponseIdMode, sourceFormat, diff --git a/open-sse/handlers/embeddings.ts b/open-sse/handlers/embeddings.ts index a85cefc921..e3ea746064 100644 --- a/open-sse/handlers/embeddings.ts +++ b/open-sse/handlers/embeddings.ts @@ -24,6 +24,7 @@ import { isDetailedLoggingEnabled } from "@/lib/db/detailedLogs"; import { getCallLogPipelineCaptureStreamChunks } from "@/lib/logEnv"; import { toJsonErrorPayload } from "@/shared/utils/upstreamError"; import { stripStaleEncodingHeaders } from "../utils/upstreamResponseHeaders.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; interface ClientRawRequest { endpoint: string; @@ -339,7 +340,7 @@ export async function handleEmbedding({ return { success: false, status: 502, - error: `Embedding provider error: ${err.message}`, + error: `Embedding provider error: ${sanitizeErrorMessage(err.message)}`, }; } } diff --git a/open-sse/handlers/search.ts b/open-sse/handlers/search.ts index b86c22dcac..2187051493 100644 --- a/open-sse/handlers/search.ts +++ b/open-sse/handlers/search.ts @@ -22,6 +22,7 @@ import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { z } from "zod"; +import { sanitizeErrorMessage } from "../utils/error.ts"; // ── Types ──────────────────────────────────────────────────────────────── @@ -1164,7 +1165,7 @@ async function tryZaiMCPProvider( return { success: false, status: isTimeout ? 504 : 502, - error: `Search provider ${isTimeout ? "timeout" : "error"}: ${err.message}`, + error: `Search provider ${isTimeout ? "timeout" : "error"}: ${sanitizeErrorMessage(err.message)}`, }; } } @@ -1434,7 +1435,7 @@ async function tryProvider( return { success: false, status: isTimeout ? 504 : 502, - error: `Search provider ${isTimeout ? "timeout" : "error"}: ${err.message}`, + error: `Search provider ${isTimeout ? "timeout" : "error"}: ${sanitizeErrorMessage(err.message)}`, }; } } diff --git a/open-sse/package.json b/open-sse/package.json index e726967a03..09ebc933a8 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -1,6 +1,6 @@ { "name": "@omniroute/open-sse", - "version": "3.8.19", + "version": "3.8.20", "description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration", "type": "module", "main": "index.js", diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index c9c12cb085..ad0b2a344e 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -42,11 +42,12 @@ export type ProviderProfile = { maxBackoffLevel: number; circuitBreakerThreshold: number; circuitBreakerReset: number; - // Provider-level circuit breaker fields + // Adaptive circuit breaker fields + degradationThreshold?: number; + // Provider-level cooldown fields providerFailureThreshold: number; providerFailureWindowMs: number; providerCooldownMs: number; - degradationThreshold?: number; maxBackoffMultiplier?: number; backoffEscalationCount?: number; }; @@ -309,10 +310,10 @@ function buildProviderProfile( maxBackoffLevel: connectionCooldown.maxBackoffSteps, circuitBreakerThreshold: providerBreaker.failureThreshold, circuitBreakerReset: providerBreaker.resetTimeoutMs, - // Provider-level circuit breaker fields (not configurable via settings, use PROVIDER_PROFILES defaults) + degradationThreshold: providerBreaker.degradationThreshold, + // Provider-level cooldown fields are not exposed in resilience settings yet. providerFailureThreshold: PROVIDER_PROFILES[category].providerFailureThreshold, providerFailureWindowMs: PROVIDER_PROFILES[category].providerFailureWindowMs, - degradationThreshold: PROVIDER_PROFILES[category].degradationThreshold, maxBackoffMultiplier: PROVIDER_PROFILES[category].maxBackoffMultiplier, backoffEscalationCount: PROVIDER_PROFILES[category].backoffEscalationCount, providerCooldownMs: PROVIDER_PROFILES[category].providerCooldownMs, @@ -674,12 +675,13 @@ export function getAllModelLockouts(): ModelLockoutInfo[] { // ─── Provider Breaker Compatibility Wrappers ──────────────────────────────── // Legacy helpers now delegate to the shared provider circuit breaker. -type ProviderBreakerProfile = Partial< - Pick< - ProviderProfile, - "failureThreshold" | "resetTimeoutMs" | "circuitBreakerThreshold" | "circuitBreakerReset" - > ->; +type ProviderBreakerProfile = { + failureThreshold?: number; + degradationThreshold?: number; + resetTimeoutMs?: number; + circuitBreakerThreshold?: number; + circuitBreakerReset?: number; +}; function getProviderBreaker(provider: string | null | undefined) { return provider ? getCircuitBreaker(provider) : null; @@ -876,7 +878,7 @@ export function parseRetryAfterFromBody(responseBody: unknown): { // OpenAI: "Please retry after 20s" in message const msg = String(error.message || body.message || ""); - const retryMatch = RegExp(/retry\s+after\s+(\d+)\s*s/i).exec(msg); + const retryMatch = /retry\s+after\s+(\d+)\s*s/i.exec(msg); if (retryMatch) { return { retryAfterMs: Number.parseInt(retryMatch[1], 10) * 1000, @@ -901,13 +903,13 @@ export function parseRetryAfterFromBody(responseBody: unknown): { function parseDelayString(value: unknown): number | null { if (!value) return null; const str = String(value).trim(); - const msMatch = RegExp(/^(\d+)\s*ms$/i).exec(str); + const msMatch = /^(\d+)\s*ms$/i.exec(str); if (msMatch) return Number.parseInt(msMatch[1], 10); - const secMatch = RegExp(/^(\d+)\s*s$/i).exec(str); + const secMatch = /^(\d+)\s*s$/i.exec(str); if (secMatch) return Number.parseInt(secMatch[1], 10) * 1000; - const minMatch = RegExp(/^(\d+)\s*m$/i).exec(str); + const minMatch = /^(\d+)\s*m$/i.exec(str); if (minMatch) return Number.parseInt(minMatch[1], 10) * 60 * 1000; - const hrMatch = RegExp(/^(\d+)\s*h$/i).exec(str); + const hrMatch = /^(\d+)\s*h$/i.exec(str); if (hrMatch) return Number.parseInt(hrMatch[1], 10) * 3600 * 1000; // Bare number → seconds const num = Number.parseInt(str, 10); @@ -930,9 +932,9 @@ export function parseRetryFromErrorText(errorText: unknown): number | null { // timestamp instead of a relative duration (e.g. "Try again at // 2026-05-17T10:00:00Z" or "Please wait until 2026-05-17T10:00:00.000Z"). // Convert to a future-duration in milliseconds if it parses. - const isoMatch = RegExp( + const isoMatch = /\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 - ).exec(msg); + .exec(msg); if (isoMatch) { const parsedTs = Date.parse(isoMatch[1]); if (Number.isFinite(parsedTs)) { @@ -941,15 +943,15 @@ export function parseRetryFromErrorText(errorText: unknown): number | null { } } - const match = RegExp(/reset after (\d+h)?(\d+m)?(\d+s)?/i).exec(msg); + const match = /reset after (\d+h)?(\d+m)?(\d+s)?/i.exec(msg); if (match?.[1] || match?.[2] || match?.[3]) return computeDurationMs(match); // Variant without "reset after": "will reset after XhYmZs" - const altMatch = RegExp(/will reset after (\d+h)?(\d+m)?(\d+s)?/i).exec(msg); + const altMatch = /will reset after (\d+h)?(\d+m)?(\d+s)?/i.exec(msg); if (altMatch?.[1] || altMatch?.[2] || altMatch?.[3]) return computeDurationMs(altMatch); // Antigravity / Cloud Code phrasing: "Resets in 164h27m24s". - const resetsInMatch = RegExp(/resets? in (\d+h)?(\d+m)?(\d+s)?/i).exec(msg); + const resetsInMatch = /resets? in (\d+h)?(\d+m)?(\d+s)?/i.exec(msg); if (resetsInMatch?.[1] || resetsInMatch?.[2] || resetsInMatch?.[3]) { return computeDurationMs(resetsInMatch); } diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index 1eb33cca15..9350f9ab0b 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -52,7 +52,7 @@ const CLAUDE_CODE_COMPATIBLE_DEFAULT_SYSTEM_BLOCKS = [ text: "You are a Claude agent, built on Anthropic's Claude Agent SDK.", }, ]; -const CONTEXT_1M_SUPPORTED_MODELS = ["claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6"]; +const CONTEXT_1M_SUPPORTED_MODELS = ["claude-fable-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6"]; export const CLAUDE_CODE_COMPATIBLE_STAINLESS_TIMEOUT_SECONDS = getStainlessTimeoutSeconds( process.env ); diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index ed78df440a..629a17c98d 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -94,6 +94,12 @@ import { type RoutingTagMatchMode, } from "../../src/domain/tagRouter.ts"; import { normalizeRoutingStrategy } from "../../src/shared/constants/routingStrategies.ts"; +import { + isProviderInCooldown, + recordProviderCooldown, + recordProviderSuccess, +} from "./providerCooldownTracker.ts"; +import { resolveResilienceSettings, type ResilienceSettings } from "../../src/lib/resilience/settings"; // Status codes that should mark round-robin target semaphores as cooling down. const TRANSIENT_FOR_SEMAPHORE = [429, 502, 503, 504]; @@ -2717,6 +2723,10 @@ export async function handleComboChat({ const relayConfig = strategy === "context-relay" ? resolveContextRelayConfig(relayOptions?.config || null) : null; + const resilienceSettings: ResilienceSettings = settings + ? resolveResilienceSettings(settings) + : resolveResilienceSettings(null); + const universalHandoffConfig = resolveUniversalHandoffConfig( (combo.universal_handoff || combo.universalHandoff) as | Record @@ -3344,6 +3354,19 @@ export async function handleComboChat({ return null; } + if ( + resilienceSettings.providerCooldown.enabled && + Boolean(provider && provider !== "unknown") && + isProviderInCooldown(provider, target.connectionId ?? undefined, resilienceSettings) + ) { + log.info( + "COMBO", + `Skipping ${modelStr} — provider ${provider} in global cooldown` + ); + if (i > 0) fallbackCount++; + return null; + } + // Use pre-screened profile if available, otherwise fetch on demand const preScreenEntry = preScreenMap.get(target.executionKey); const profile = preScreenEntry?.profile ?? (await getRuntimeProviderProfile(provider)); @@ -3580,6 +3603,11 @@ export async function handleComboChat({ target: toRecordedTarget(target), }); recordedAttempts++; + + // Reset cooldown on success + if (provider && provider !== "unknown") { + recordProviderSuccess(provider, target.connectionId ?? undefined); + } // Webhook fan-out: best-effort, never blocks the response stream. notifyWebhookEvent("request.completed", { combo: combo.name, @@ -3920,6 +3948,10 @@ export async function handleComboChat({ if (i > 0) fallbackCount++; log.warn("COMBO", `Model ${modelStr} failed, trying next`, { status: result.status }); + if (resilienceSettings.providerCooldown.enabled && provider && provider !== "unknown") { + recordProviderCooldown(provider, target.connectionId ?? undefined, resilienceSettings); + } + const fallbackWaitMs = fallbackDelayMs > 0 && cooldownMs > 0 && cooldownMs <= MAX_FALLBACK_WAIT_MS ? Math.min(cooldownMs, fallbackDelayMs) @@ -4091,6 +4123,10 @@ async function handleRoundRobinCombo({ const retryDelayMs = resolveDelayMs(config.retryDelayMs, 2000); const fallbackDelayMs = resolveDelayMs(config.fallbackDelayMs, 0); + const resilienceSettings: ResilienceSettings = settings + ? resolveResilienceSettings(settings) + : resolveResilienceSettings(null); + const orderedTargets = resolveComboTargets(combo, allCombos); const tagFilteredTargets = await applyRequestTagRouting(orderedTargets, body, log); const evalRankedTargets = orderTargetsByEvalScores(tagFilteredTargets, config.evalRouting, log); @@ -4167,6 +4203,19 @@ async function handleRoundRobinCombo({ } } + if ( + resilienceSettings.providerCooldown.enabled && + Boolean(provider && provider !== "unknown") && + isProviderInCooldown(provider, target.connectionId as string | undefined, resilienceSettings) + ) { + log.info( + "COMBO-RR", + `Skipping ${modelStr} — provider ${provider} in global cooldown` + ); + if (offset > 0) fallbackCount++; + continue; + } + // #1731: Skip targets from a provider that already signaled full quota exhaustion // this request. if (provider && exhaustedProviders.has(provider)) { @@ -4263,6 +4312,11 @@ async function handleRoundRobinCombo({ target: toRecordedTarget(target), }); recordedAttempts++; + + if (provider && provider !== "unknown") { + recordProviderSuccess(provider, target.connectionId ?? undefined); + } + if (provider) { const connId = target.connectionId || undefined; void (async () => { @@ -4457,6 +4511,10 @@ async function handleRoundRobinCombo({ if (offset > 0) fallbackCount++; log.warn("COMBO-RR", `${modelStr} failed, trying next model`, { status: result.status }); + if (resilienceSettings.providerCooldown.enabled && provider && provider !== "unknown") { + recordProviderCooldown(provider, target.connectionId ?? undefined, resilienceSettings); + } + const fallbackWaitMs = fallbackDelayMs > 0 && cooldownMs > 0 && cooldownMs <= MAX_FALLBACK_WAIT_MS ? Math.min(cooldownMs, fallbackDelayMs) diff --git a/open-sse/services/modelFamilyFallback.ts b/open-sse/services/modelFamilyFallback.ts index 06f2d4d05a..03d4c34e57 100644 --- a/open-sse/services/modelFamilyFallback.ts +++ b/open-sse/services/modelFamilyFallback.ts @@ -14,6 +14,7 @@ import { getModelContextLimit } from "../../src/lib/modelCapabilities"; import { parseModel } from "./model.ts"; import { CONTEXT_OVERFLOW_REGEX } from "./errorClassifier.ts"; +import { getRegistryEntry } from "../config/providerRegistry.ts"; // ── Model Family Definitions ───────────────────────────────────────────────── @@ -72,6 +73,10 @@ const MODEL_FAMILIES: Record = { "gemini-2.5-pro": ["gemini-2.5-pro-preview-06-05", "gemini-2.5-pro-exp-03-25"], "gemini-2.5-pro-preview-06-05": ["gemini-2.5-pro", "gemini-2.5-pro-exp-03-25"], + // Claude Mythos family (Fable 5) — flagship falls to the next-best Opus + // tiers before the cheaper Sonnet, matching the Opus family ordering. + "claude-fable-5": ["claude-opus-4-8", "claude-opus-4-7", "claude-sonnet-4-6"], + // Claude Opus family "claude-opus-4-8": ["claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6"], "claude-opus-4-7": ["claude-opus-4-6", "claude-opus-4-5-20251101", "claude-sonnet-4-6"], @@ -142,14 +147,28 @@ export function getNextFamilyFallback( ): string | null { const parsed = parseModel(currentModel); const bareModel = parsed.model || currentModel; - const prefix = - parsed.provider || parsed.providerAlias ? `${parsed.provider || parsed.providerAlias}/` : ""; + const provider = parsed.provider || parsed.providerAlias || ""; + const prefix = provider ? `${provider}/` : ""; - const family = MODEL_FAMILIES[bareModel]; + // Normalize dots to hyphens for the lookup so kiro/claude-opus-4.8 finds the right entry + const lookupKey = bareModel.replace(/\./g, "-"); + const family = MODEL_FAMILIES[lookupKey]; if (!family) return null; + // Resolve the provider's supported model IDs so we can match notation (dot vs hyphen) + const registryEntry = provider ? getRegistryEntry(provider) : null; + const supportedIds = registryEntry ? new Set(registryEntry.models.map((m) => m.id)) : null; + for (const candidate of family) { - const fullCandidate = `${prefix}${candidate}`; + let resolvedCandidate = candidate; + if (supportedIds && !supportedIds.has(candidate)) { + // Try dot-notation variants: claude-opus-4-8 → claude-opus-4.8 + const dotVariant = candidate.replace(/-(\d+)-(\d+)$/, "-$1.$2"); + const dotVariant2 = candidate.replace(/-(\d+)-(\d+)-/, "-$1.$2-"); + if (supportedIds.has(dotVariant)) resolvedCandidate = dotVariant; + else if (supportedIds.has(dotVariant2)) resolvedCandidate = dotVariant2; + } + const fullCandidate = `${prefix}${resolvedCandidate}`; if (!triedModels.has(fullCandidate)) { return fullCandidate; } diff --git a/open-sse/services/providerCooldownTracker.ts b/open-sse/services/providerCooldownTracker.ts new file mode 100644 index 0000000000..afb5a9303f --- /dev/null +++ b/open-sse/services/providerCooldownTracker.ts @@ -0,0 +1,178 @@ +/** + * Provider Cooldown Tracker + * + * Global, cross-request cooldown state for failed providers/connections. + * Prevents subsequent combo requests from re-walking the same failing + * providers by remembering failure timestamps and enforcing a configurable + * minimum/maximum cooldown window. + */ + +import { + DEFAULT_RESILIENCE_SETTINGS, + type ResilienceSettings, +} from "../../src/lib/resilience/settings"; + +interface CooldownEntry { + /** Timestamp of last recorded failure (ms since epoch) */ + lastFailureAt: number; + /** Number of consecutive failures (resets on success) */ + failureCount: number; +} + +// Global cooldown state: keyed by "provider:connectionId" or "provider" +const cooldownMap = new Map(); + +// Evict entries older than this to prevent unbounded memory growth +const MAX_ENTRY_AGE_MS = 30 * 60 * 1000; // 30 minutes +const CLEANUP_INTERVAL_MS = 60 * 1000; // Cleanup every 60s + +let cleanupTimer: ReturnType | null = null; + +function startCleanupIfNeeded(): void { + if (cleanupTimer) return; + cleanupTimer = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of cooldownMap) { + if (now - entry.lastFailureAt > MAX_ENTRY_AGE_MS) { + cooldownMap.delete(key); + } + } + }, CLEANUP_INTERVAL_MS); + // Allow Node.js to exit even if the timer is running + if (cleanupTimer.unref) cleanupTimer.unref(); +} + +/** + * Build a cooldown key from provider and optional connectionId. + */ +function cooldownKey(provider: string, connectionId?: string): string { + return connectionId ? `${provider}:${connectionId}` : provider; +} + +/** + * Record a failure for a provider/connection. + * + * @param provider - Provider ID (e.g. "openai", "anthropic") + * @param connectionId - Optional connection ID for per-connection tracking + * @param settings - Resilience settings for cooldown configuration + */ +export function recordProviderCooldown( + provider: string, + connectionId: string | undefined, + settings?: ResilienceSettings +): void { + if (!provider || provider === "unknown") return; + + const key = cooldownKey(provider, connectionId); + const existing = cooldownMap.get(key); + const now = Date.now(); + + if (existing) { + existing.lastFailureAt = now; + existing.failureCount++; + } else { + cooldownMap.set(key, { lastFailureAt: now, failureCount: 1 }); + } + + startCleanupIfNeeded(); +} + +/** + * Check if a provider/connection is currently in cooldown and should be skipped. + * + * @param provider - Provider ID + * @param connectionId - Optional connection ID + * @param settings - Resilience settings for cooldown configuration + * @returns true if the provider should be skipped (still in cooldown) + */ +export function isProviderInCooldown( + provider: string, + connectionId: string | undefined, + settings?: ResilienceSettings +): boolean { + if (!provider || provider === "unknown") return false; + + const key = cooldownKey(provider, connectionId); + const entry = cooldownMap.get(key); + if (!entry) return false; + + if (entry.failureCount === 0) return false; + + const now = Date.now(); + const elapsed = now - entry.lastFailureAt; + + const minCooldownMs = + settings?.providerCooldown?.minRetryCooldownMs ?? + DEFAULT_RESILIENCE_SETTINGS.providerCooldown.minRetryCooldownMs; + + const maxCooldownMs = + settings?.providerCooldown?.maxRetryCooldownMs ?? + DEFAULT_RESILIENCE_SETTINGS.providerCooldown.maxRetryCooldownMs; + + const exponent = Math.min(Math.max(0, entry.failureCount - 1), 10); + const scaledCooldownMs = Math.min(minCooldownMs * Math.pow(2, exponent), maxCooldownMs); + + return elapsed < scaledCooldownMs; +} + +/** + * Get the remaining cooldown time for a provider/connection. + * Returns 0 if not in cooldown. + */ +export function getRemainingCooldownMs( + provider: string, + connectionId: string | undefined, + settings?: ResilienceSettings +): number { + if (!provider || provider === "unknown") return 0; + + const key = cooldownKey(provider, connectionId); + const entry = cooldownMap.get(key); + if (!entry) return 0; + + const now = Date.now(); + const elapsed = now - entry.lastFailureAt; + + const minCooldownMs = + settings?.providerCooldown?.minRetryCooldownMs ?? + DEFAULT_RESILIENCE_SETTINGS.providerCooldown.minRetryCooldownMs; + + const maxCooldownMs = + settings?.providerCooldown?.maxRetryCooldownMs ?? + DEFAULT_RESILIENCE_SETTINGS.providerCooldown.maxRetryCooldownMs; + + const exponent = Math.min(Math.max(0, entry.failureCount - 1), 10); + const scaledCooldownMs = Math.min(minCooldownMs * Math.pow(2, exponent), maxCooldownMs); + + const remaining = scaledCooldownMs - elapsed; + return remaining > 0 ? remaining : 0; +} + +/** + * Record a successful request for a provider/connection. + * Resets the failure count (but keeps the entry for reference). + */ +export function recordProviderSuccess(provider: string, connectionId: string | undefined): void { + if (!provider || provider === "unknown") return; + + const key = cooldownKey(provider, connectionId); + const entry = cooldownMap.get(key); + if (entry) { + // Reset failure count but keep the entry + entry.failureCount = 0; + } +} + +/** + * Clear all cooldown state. Useful for testing or manual reset. + */ +export function clearCooldownState(): void { + cooldownMap.clear(); +} + +/** + * Get the number of entries in the cooldown map (for diagnostics). + */ +export function getCooldownEntryCount(): number { + return cooldownMap.size; +} diff --git a/open-sse/services/providerCostData.ts b/open-sse/services/providerCostData.ts index 7397b4641f..461caf0046 100644 --- a/open-sse/services/providerCostData.ts +++ b/open-sse/services/providerCostData.ts @@ -11,6 +11,7 @@ export interface ModelPricing { export const KNOWN_MODEL_PRICING: Record = { "gpt-4o": { inputCostPer1M: 2.5, outputCostPer1M: 10.0, isFree: false }, "gpt-4o-mini": { inputCostPer1M: 0.15, outputCostPer1M: 0.6, isFree: false }, + "claude-fable-5": { inputCostPer1M: 15.0, outputCostPer1M: 75.0, isFree: false }, "claude-opus-4-8": { inputCostPer1M: 15.0, outputCostPer1M: 75.0, isFree: false }, "claude-opus-4-7": { inputCostPer1M: 15.0, outputCostPer1M: 75.0, isFree: false }, "claude-sonnet-4-6": { inputCostPer1M: 3.0, outputCostPer1M: 15.0, isFree: false }, diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 5a442b4e6c..b951ebb1c7 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -928,17 +928,25 @@ async function getOpenCodeGoUsage(apiKey: string) { }); if (!res.ok) { - if (res.status === 401 || res.status === 403) throw new Error("Invalid OpenCode Go API key"); - throw new Error(`OpenCode Go quota API error (${res.status})`); + if (res.status === 401 || res.status === 403) { + return { message: "OpenCode Go quota endpoint rejected this API key. Chat requests still work." }; + } + return { message: `OpenCode Go quota API error (${res.status})` }; } - const json = await res.json(); - const code = toNumber(json.code, 200); - if (code === 401 || code === 403 || json.success === false) { - throw new Error("Invalid OpenCode Go API key"); + let json: unknown; + try { + json = await res.json(); + } catch { + return { message: "OpenCode Go quota response parsing failed." }; } - const data = toRecord(json.data); + const code = toNumber((json as Record).code, 200); + if (code === 401 || code === 403 || (json as Record).success === false) { + return { message: "OpenCode Go quota endpoint rejected this API key. Chat requests still work." }; + } + + const data = toRecord((json as Record).data); const limits: unknown[] = Array.isArray(data.limits) ? data.limits : []; const quotas: Record = {}; @@ -2720,6 +2728,55 @@ async function getCodexUsage( } } +/** + * Build the Kiro usage result from a GetUsageLimits response. When the account returns no + * usage breakdown (some AWS IAM / Builder ID accounts don't expose per-resource quota via + * GetUsageLimits), return an informative message instead of empty `quotas:{}` — otherwise the + * dashboard renders a blank quota card with no explanation (#3506). Exported for testing. + */ +export function buildKiroUsageResult( + data: JsonRecord +): { plan: string; quotas: Record } | { message: string } { + const usageList = Array.isArray(data.usageBreakdownList) ? data.usageBreakdownList : []; + const quotaInfo: Record = {}; + const resetAt = parseResetTime(data.nextDateReset || data.resetDate); + + usageList.forEach((breakdownValue: unknown) => { + const breakdown = toRecord(breakdownValue); + const resourceType = + typeof breakdown.resourceType === "string" ? breakdown.resourceType.toLowerCase() : "unknown"; + const used = toNumber(breakdown.currentUsageWithPrecision, 0); + const total = toNumber(breakdown.usageLimitWithPrecision, 0); + + quotaInfo[resourceType] = { used, total, remaining: total - used, resetAt, unlimited: false }; + + const freeTrialInfo = toRecord(breakdown.freeTrialInfo); + if (Object.keys(freeTrialInfo).length > 0) { + const freeUsed = toNumber(freeTrialInfo.currentUsageWithPrecision, 0); + const freeTotal = toNumber(freeTrialInfo.usageLimitWithPrecision, 0); + quotaInfo[`${resourceType}_freetrial`] = { + used: freeUsed, + total: freeTotal, + remaining: freeTotal - freeUsed, + resetAt, + unlimited: false, + }; + } + }); + + if (Object.keys(quotaInfo).length === 0) { + return { + message: + "Kiro connected, but the account returned no usage breakdown. Some AWS IAM / Builder ID accounts don't expose per-resource quota via GetUsageLimits.", + }; + } + + return { + plan: String(toRecord(data.subscriptionInfo).subscriptionTitle || "").trim() || "Kiro", + quotas: quotaInfo, + }; +} + /** * Kiro (AWS CodeWhisperer) Usage */ @@ -2754,51 +2811,7 @@ async function getKiroUsage(accessToken?: string, providerSpecificData?: JsonRec } const data = toRecord(await response.json()); - - // Parse usage data from usageBreakdownList - const usageList = Array.isArray(data.usageBreakdownList) ? data.usageBreakdownList : []; - const quotaInfo: Record = {}; - - // Parse reset time - supports multiple formats (nextDateReset, resetDate, etc.) - const resetAt = parseResetTime(data.nextDateReset || data.resetDate); - - usageList.forEach((breakdownValue: unknown) => { - const breakdown = toRecord(breakdownValue); - const resourceType = - typeof breakdown.resourceType === "string" - ? breakdown.resourceType.toLowerCase() - : "unknown"; - const used = toNumber(breakdown.currentUsageWithPrecision, 0); - const total = toNumber(breakdown.usageLimitWithPrecision, 0); - - quotaInfo[resourceType] = { - used, - total, - remaining: total - used, - resetAt, - unlimited: false, - }; - - // Add free trial if available - const freeTrialInfo = toRecord(breakdown.freeTrialInfo); - if (Object.keys(freeTrialInfo).length > 0) { - const freeUsed = toNumber(freeTrialInfo.currentUsageWithPrecision, 0); - const freeTotal = toNumber(freeTrialInfo.usageLimitWithPrecision, 0); - - quotaInfo[`${resourceType}_freetrial`] = { - used: freeUsed, - total: freeTotal, - remaining: freeTotal - freeUsed, - resetAt, - unlimited: false, - }; - } - }); - - return { - plan: String(toRecord(data.subscriptionInfo).subscriptionTitle || "").trim() || "Kiro", - quotas: quotaInfo, - }; + return buildKiroUsageResult(data); } catch (error) { throw new Error(`Failed to fetch Kiro usage: ${error.message}`); } diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index fd35dd2887..2b39499eac 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -92,6 +92,7 @@ export function openaiResponsesToOpenAIRequest( toolType !== "custom" && toolType !== "command" && toolType !== "namespace" && + toolType !== "local_shell" && !WEB_SEARCH_TOOL_TYPES.test(toolType) && !TOOL_SEARCH_TOOL_TYPES.test(toolType) && !IMAGE_GENERATION_TOOL_TYPES.test(toolType) && @@ -300,6 +301,32 @@ export function openaiResponsesToOpenAIRequest( if (WEB_SEARCH_TOOL_TYPES.test(toolType)) { return toolValue; } + // local_shell is a Responses API built-in (Codex CLI injects it for shell + // execution). Non-OpenAI upstreams (Kiro/Claude) have no local_shell type, + // so map it to a regular "shell" function tool. The response translator + // already emits these as function_call, which Codex maps back to a shell call. + if (toolType === "local_shell") { + return { + type: "function", + function: { + name: "shell", + description: "Run a shell command and return its output.", + parameters: { + type: "object", + properties: { + command: { + type: "array", + items: { type: "string" }, + description: "Command and arguments to execute.", + }, + workdir: { type: "string", description: "Working directory." }, + timeout_ms: { type: "number", description: "Timeout in milliseconds." }, + }, + required: ["command"], + }, + }, + }; + } return { type: "function", function: { @@ -343,6 +370,8 @@ export function openaiResponsesToOpenAIRequest( const tcType = toString(tc.type); if (tcType === "function" && tc.name !== undefined && !tc.function) { result.tool_choice = { type: "function", function: { name: tc.name } }; + } else if (tcType === "local_shell") { + result.tool_choice = { type: "function", function: { name: "shell" } }; } else if (tcType && tcType !== "function" && tcType !== "allowed_tools") { // Built-in tool types (web_search_preview, file_search, etc.) have no Chat equivalent throw unsupportedFeature( @@ -612,9 +641,13 @@ export function openaiToOpenAIResponsesRequest( const tool = toRecord(toolValue); if (tool.type === "function") { const fn = toRecord(tool.function); + const name = toString(fn.name); + if (name === "shell") { + return { type: "local_shell" }; + } return { type: "function", - name: toString(fn.name), + name, description: toString(fn.description), parameters: fn.parameters, strict: fn.strict, @@ -632,7 +665,11 @@ export function openaiToOpenAIResponsesRequest( const tc = toRecord(root.tool_choice); if (tc.type === "function" && tc.function) { const fn = toRecord(tc.function); - result.tool_choice = { type: "function", name: fn.name }; + if (toString(fn.name) === "shell") { + result.tool_choice = { type: "local_shell" }; + } else { + result.tool_choice = { type: "function", name: fn.name }; + } } else { result.tool_choice = root.tool_choice; } diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 1f8a2355b0..3a9dc8a09d 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -1,6 +1,5 @@ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; -import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.ts"; import { ANTIGRAVITY_DEFAULT_SYSTEM } from "../../config/constants.ts"; import { buildGeminiThoughtSignatureKey, @@ -17,7 +16,7 @@ import { getDefaultThinkingBudget, } from "../../../src/lib/modelCapabilities.ts"; -import * as crypto from "crypto"; +import * as crypto from "node:crypto"; function generateUUID() { return crypto.randomUUID(); @@ -28,7 +27,6 @@ import { convertOpenAIContentToParts, extractTextContent, tryParseJSON, - generateSessionId, cleanJSONSchemaForAntigravity, } from "../helpers/geminiHelper.ts"; import { buildGeminiTools, sanitizeGeminiToolName } from "../helpers/geminiToolsSanitizer.ts"; @@ -114,6 +112,8 @@ type GeminiToolNameOptions = { // Gemini API DOES use `id` for Gemini 3+ signature matching, so this is scoped to // the vertex provider only. stripFunctionCallId?: boolean; + /** Only Antigravity/Gemini CLI support the thoughtSignature field. Standard Gemini rejects it with 400. */ + supportsSignatureBypass?: boolean; }; // Vertex AI (and Vertex Partner models) reject the OpenAI-style `id` field inside @@ -178,7 +178,7 @@ function applyAntigravityGenerationDefaults(generationConfig: GeminiGenerationCo config.topK = 40; } if (config.topP === undefined) { - config.topP = 1.0; + config.topP = 1; } const thinkingBudget = Number(config.thinkingConfig?.thinkingBudget); @@ -205,19 +205,11 @@ function stringifyHistoricalToolArguments(value: unknown): string { function buildInertHistoricalToolCallText(name: string | undefined, args: unknown): string { const toolName = name || "unknown"; - return [ - "Historical tool-call record only. Do not execute, imitate, or continue this as a tool call.", - `Tool name: ${toolName}`, - `Tool arguments JSON: ${stringifyHistoricalToolArguments(args || "{}")}`, - ].join("\n"); + return `[tool_history_call: ${toolName}] ${stringifyHistoricalToolArguments(args || "{}")}`; } function buildInertHistoricalToolResponseText(name: string, response: unknown): string { - return [ - "Historical tool-response record only. Do not execute, imitate, or continue this as a tool response.", - `Tool name: ${name || "unknown"}`, - `Tool result: ${typeof response === "string" ? response : stringifyHistoricalToolArguments(response)}`, - ].join("\n"); + return `[tool_history_result: ${name || "unknown"}] ${typeof response === "string" ? response : stringifyHistoricalToolArguments(response)}`; } function escapeHistoricalContextAttribute(value: string): string { @@ -234,7 +226,8 @@ function escapeHistoricalContextContent(value: string): string { function buildHistoricalToolResultContext(name: string, response: unknown): string { const source = escapeHistoricalContextAttribute(name || "unknown"); - const rawResult = typeof response === "string" ? response : stringifyHistoricalToolArguments(response); + const rawResult = + typeof response === "string" ? response : stringifyHistoricalToolArguments(response); const result = escapeHistoricalContextContent(rawResult); return [ ``, @@ -343,8 +336,7 @@ function openaiToGeminiBase( // Convert messages if (messages && Array.isArray(messages)) { - for (let i = 0; i < messages.length; i++) { - const msg = messages[i]; + for (const msg of messages) { const role = msg.role; const content = msg.content; @@ -372,7 +364,7 @@ function openaiToGeminiBase( if (msg.reasoning_content) { parts.push({ thought: true, - text: msg.reasoning_content as string, + text: msg.reasoning_content, }); } @@ -414,18 +406,26 @@ function openaiToGeminiBase( if (!fn) continue; const signatureForToolCall = resolvedSignatures.get(id); - if (!signatureForToolCall && contextualizeSignaturelessToolResponses) { - if (!toolCallIds.includes(id)) toolCallIds.push(id); - } - if (!signatureForToolCall && stringifySignaturelessToolCalls) { - const args = fn.arguments || "{}"; - parts.push({ - text: buildInertHistoricalToolCallText(fn.name, args), - }); - continue; - } - if (!signatureForToolCall && signaturelessToolCallMode === "context") { - continue; + + // Non-bypass paths (standard Gemini direct, mode "text"/"context") + // cannot send a thoughtSignature and reject signature-less native tool + // parts, so historical signature-less tool calls are represented as + // inert text/context (#3358). The Antigravity/CLI bypass path + // (supportsSignatureBypass) instead emits native parts carrying the + // skip_thought_signature_validator sentinel below. + if (!toolNameOptions.supportsSignatureBypass) { + if (!signatureForToolCall && contextualizeSignaturelessToolResponses) { + if (!toolCallIds.includes(id)) toolCallIds.push(id); + } + if (!signatureForToolCall && stringifySignaturelessToolCalls) { + parts.push({ + text: buildInertHistoricalToolCallText(fn.name, fn.arguments || "{}"), + }); + continue; + } + if (!signatureForToolCall && signaturelessToolCallMode === "context") { + continue; + } } const args = tryParseJSON(fn.arguments || "{}"); @@ -438,8 +438,15 @@ function openaiToGeminiBase( } // Gemini expects the signature on the functionCall part itself. + // If we are in a mode where missing signatures cause 400s (and we couldn't find one), + // safely default to the bypass string to protect against 400s. + const finalSignature = + embeddedThoughtSignature || + (toolNameOptions.supportsSignatureBypass && signaturelessToolCallMode !== "text" + ? "skip_thought_signature_validator" + : undefined); parts.push({ - ...(embeddedThoughtSignature ? { thoughtSignature: embeddedThoughtSignature } : {}), + ...(finalSignature ? { thoughtSignature: finalSignature } : {}), functionCall: { ...(toolNameOptions.stripFunctionCallId ? {} : { id: id }), name: sanitizeToolName(fn.name), @@ -447,7 +454,13 @@ function openaiToGeminiBase( }, }); - if (!contextualizeSignaturelessToolResponses || signatureForToolCall) { + // Bypass path always emits the native response; non-bypass keeps the + // contextualize-aware bookkeeping (signature-less ids handled as text). + if ( + toolNameOptions.supportsSignatureBypass || + !contextualizeSignaturelessToolResponses || + signatureForToolCall + ) { toolCallIds.push(id); } } @@ -470,7 +483,12 @@ function openaiToGeminiBase( const toolParts: GeminiPart[] = []; for (const fid of toolCallIds) { if (!toolResponses[fid]) continue; - if (contextualizeSignaturelessToolResponses && !resolvedSignatures.has(fid)) continue; + if ( + !toolNameOptions.supportsSignatureBypass && + contextualizeSignaturelessToolResponses && + !resolvedSignatures.has(fid) + ) + continue; let name = tcID2Name[fid]; if (!name) { @@ -484,7 +502,7 @@ function openaiToGeminiBase( name = sanitizeToolName(name); const resp = toolResponses[fid]; - let parsedResp = tryParseJSON(resp as string); + let parsedResp = tryParseJSON(resp); if (parsedResp === null) { parsedResp = { result: resp }; } else if (typeof parsedResp !== "object") { @@ -503,9 +521,12 @@ function openaiToGeminiBase( }); } - if (contextualizeSignaturelessToolResponses) { + if ( + !toolNameOptions.supportsSignatureBypass && + contextualizeSignaturelessToolResponses + ) { // Signature-less historical tool responses are represented as text - // so strict Gemini/Antigravity endpoints don't reject them as native + // so strict standard-Gemini endpoints don't reject them as native // functionResponse parts missing a matching thoughtSignature. // In context mode the matching historical functionCall is omitted, // avoiding pseudo tool-call records that Gemini Flash can repeat as @@ -556,11 +577,11 @@ function openaiToGeminiBase( if (geminiTools && geminiTools.length > 0) { result.tools = geminiTools; if (hasGoogleSearch) { - result.tools.push({ googleSearch: {} } as ToolEntry); + result.tools.push({ googleSearch: {} }); } result.toolConfig = { functionCallingConfig: { mode: "VALIDATED" } }; } else if (hasGoogleSearch) { - result.tools = [{ googleSearch: {} } as ToolEntry]; + result.tools = [{ googleSearch: {} }]; } // Convert response_format to Gemini's responseMimeType/responseSchema @@ -610,8 +631,8 @@ export function openaiToGeminiRequest( // functionCall on the follow-up request. Without this the streaming lookup key didn't // match and Gemini rejected tool calls with 400 "missing thought_signature" (#2504). const signatureNamespace = - credentials && typeof credentials._signatureNamespace === "string" - ? credentials._signatureNamespace + credentials && typeof credentials["_signatureNamespace"] === "string" + ? credentials["_signatureNamespace"] : null; return openaiToGeminiBase(model, body, stream, { signatureNamespace, @@ -636,6 +657,7 @@ export function openaiToGeminiCLIRequest( functionResponseShape: options.functionResponseShape, signatureNamespace: options.signatureNamespace, signaturelessToolCallMode: options.signaturelessToolCallMode, + supportsSignatureBypass: true, }); } @@ -794,7 +816,7 @@ register( FORMATS.GEMINI, (model, body, stream = false, credentials = null) => openaiToGeminiRequest(model, body, stream, credentials, { - signaturelessToolCallMode: "text", + signaturelessToolCallMode: "native", }), null ); @@ -810,8 +832,8 @@ register( signatureNamespace: credentials && typeof credentials === "object" && - typeof (credentials as Record)._signatureNamespace === "string" - ? ((credentials as Record)._signatureNamespace as string) + typeof credentials["_signatureNamespace"] === "string" + ? (credentials["_signatureNamespace"] as string) : null, }), credentials diff --git a/open-sse/utils/error.ts b/open-sse/utils/error.ts index 3927f84724..980fabcd14 100644 --- a/open-sse/utils/error.ts +++ b/open-sse/utils/error.ts @@ -227,7 +227,9 @@ export async function parseUpstreamError(response: Response, provider: string | // Try parse as JSON try { - const json = JSON.parse(text); + const parsed = JSON.parse(text); + // Handle array responses (e.g., from some Gemini APIs) + const json = (Array.isArray(parsed) && parsed.length > 0 ? parsed[0] : parsed) || {}; message = json.error?.message || json.message || json.error || text; errorCode = json.error?.code || json.code; errorType = json.error?.type || json.type; diff --git a/open-sse/utils/requestLogger.ts b/open-sse/utils/requestLogger.ts index d4cfd0dbaa..350cf6ec44 100644 --- a/open-sse/utils/requestLogger.ts +++ b/open-sse/utils/requestLogger.ts @@ -256,7 +256,7 @@ function makeStreamChunkMethods( try { console.warn("[requestLogger] updatePendingRequestStreamChunks failed:", e); } catch {} - } + } }; const append = ( @@ -266,7 +266,7 @@ function makeStreamChunkMethods( ) => { if (!captureChunks) return; push(); - appendBoundedChunk(arr, bytes, chunk, maxBytes, maxItems); + appendBoundedChunk(arr, bytes, chunk, maxBytes, maxItems); }; return { diff --git a/package-lock.json b/package-lock.json index 1db4e31fef..6469f56293 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.8.19", + "version": "3.8.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.8.19", + "version": "3.8.20", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -21587,7 +21587,7 @@ }, "open-sse": { "name": "@omniroute/open-sse", - "version": "3.8.19" + "version": "3.8.20" } } } diff --git a/package.json b/package.json index 80dd32f587..aec3594573 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.8.19", + "version": "3.8.20", "description": "Unified AI router with 160+ providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { diff --git a/scripts/check/check-docs-symbols.mjs b/scripts/check/check-docs-symbols.mjs index 3ba0a6c841..54079a5164 100644 --- a/scripts/check/check-docs-symbols.mjs +++ b/scripts/check/check-docs-symbols.mjs @@ -50,38 +50,21 @@ function isFileRef(p) { // o path na doc, ou remover a menção. NÃO adicione novas aqui sem justificativa — esse // é o ponto do gate. Issues de tracking devem ser abertas para cada cluster. export const KNOWN_STALE_DOC_REFS = new Set([ - // docs/reference/API_REFERENCE.md — tabela de endpoints com várias rotas obsoletas: - "/api/acp/agents/[id]", // só existe /api/acp/agents (sem [id]) - "/api/acp/agents/refresh", // sem rota /refresh - "/api/admin/circuit-breaker", // admin só tem /concurrency - "/api/admin/circuit-breaker/reset", // idem - "/api/admin/rate-limits", // idem - "/api/cache/clear", // cache usa DELETE em /api/cache, não /clear - "/api/cache/reasoning/clear", // /api/cache/reasoning existe; /clear não - "/api/guardrails", // sem dir de API guardrails (feature server-side, sem rota REST) + // docs/reference/API_REFERENCE.md — guardrails/shadow entries fixed in separate issues: + "/api/guardrails", // sem dir de API guardrails (feature server-side, sem rota REST) — #3496 "/api/guardrails/[id]/disable", "/api/guardrails/[id]/enable", "/api/guardrails/logs", "/api/guardrails/test", - "/api/plugins/[id]/disable", // rota real usa [name] + activate/deactivate - "/api/plugins/[id]/enable", // idem - "/api/shadow", // sem dir de API shadow (shadow routing não tem rota REST) + "/api/shadow", // sem dir de API shadow (shadow routing não tem rota REST) — #3498 "/api/shadow/[id]", "/api/shadow/[id]/results", "/api/shadow/metrics", - "/api/skills/[id]/disable", // skills tem [id] e /executions (base), não estas sub-ações - "/api/skills/[id]/enable", - "/api/skills/[id]/execute", - "/api/skills/[id]/executions", - "/api/system-info", // sem rota /system-info - // docs/research/DISCOVERY_TOOL_DESIGN.md — design doc de feature NÃO implementada: + // docs/research/DISCOVERY_TOOL_DESIGN.md — design doc de feature NÃO implementada: — #3498 "/api/discovery/results", "/api/discovery/results/:id", "/api/discovery/scan", "/api/discovery/verify/:id", - // docs/frameworks/AGENTBRIDGE.md — state POR-AGENTE; rota real é o /state GLOBAL - // (mesmo drift congelado em check-openapi-routes.mjs::KNOWN_STALE_SPEC): - "/api/tools/agent-bridge/agents/{id}/state", // docs/reference/ENVIRONMENT.md — endpoint UPSTREAM do provedor Blackbox Web, // citado na descrição de env var (não é rota do OmniRoute): "/api/chat", diff --git a/scripts/check/check-error-helper.mjs b/scripts/check/check-error-helper.mjs index 56a0ac8699..05a577e9d0 100644 --- a/scripts/check/check-error-helper.mjs +++ b/scripts/check/check-error-helper.mjs @@ -31,35 +31,7 @@ const SCAN_DIRS = [ // with no utils/error import) and should become a tracked cleanup issue: route the // message through sanitizeErrorMessage()/buildErrorBody()/makeExecutorErrorResult(). // Do NOT add new entries without a justification — that defeats the gate. -export const KNOWN_MISSING_ERROR_HELPER = new Set([ - // adapta-web: local makeErrorResponse() + `Adapta auth failed: ${msg}` where - // msg = err.message — raw auth/upstream error string in the JSON error body, - // no open-sse/utils/error import. Fix: sanitizeErrorMessage(msg) before forwarding. - "open-sse/executors/adapta-web.ts", - // deepseek-web: local errorResponse() shadow that puts `message` raw into the body, - // fed `DeepSeek error: ${msg}` where msg = err.message — bypasses the canonical - // sanitizer. Fix: route through buildErrorBody()/sanitizeErrorMessage(). - "open-sse/executors/deepseek-web.ts", - // perplexity-web: `new Response({ error: { message: `Perplexity connection failed: - // ${err.message}` }})` (multi-line envelope) for TLS/connection failures — raw - // err.message in the client error body, no sanitizer import. - "open-sse/executors/perplexity-web.ts", - // qoder: `response: new Response({ error: { message: `Qoder fetch error: - // ${error.message}` }})` — raw error.message in the returned response body, - // no sanitizer import. - "open-sse/executors/qoder.ts", - // veoaifree-web: local errResp(msg) on nonce-fetch failure where msg = err.message — - // raw error string in the response body, no sanitizer import. - "open-sse/executors/veoaifree-web.ts", - // embeddings handler: `return { success: false, status: 502, error: `Embedding - // provider error: ${err.message}` }` — raw err.message in the result error field, - // no sanitizer import. (The saveCallLog `error: err.message` rows are internal and - // correctly NOT what is frozen here.) - "open-sse/handlers/embeddings.ts", - // search handler: `return { …, error: `Search provider …: ${err.message}` }` — raw - // err.message in the result error field, no sanitizer import. - "open-sse/handlers/search.ts", -]); +export const KNOWN_MISSING_ERROR_HELPER = new Set([]); // Import specifiers that count as "uses the error helper" (path ends in utils/error). const ERROR_HELPER_IMPORT = diff --git a/scripts/check/check-fetch-targets.mjs b/scripts/check/check-fetch-targets.mjs index 85f5a08d06..7db3051bf5 100644 --- a/scripts/check/check-fetch-targets.mjs +++ b/scripts/check/check-fetch-targets.mjs @@ -28,8 +28,6 @@ const KNOWN_MISSING = new Set([ "/api/gamification/badges", // profile/page.tsx — idem "/api/gamification/badges/earned", // profile/page.tsx — idem "/api/settings/obsidian/webdav", // ObsidianSourceCard.tsx — só existe /api/settings/obsidian - "/api/tools/traffic-inspector/custom-hosts", // CustomHostsManager.tsx — provável typo de /hosts - "/api/health", // FeatureFlagsGrid.tsx — health real é /api/monitoring/health "/api/tools/agent-bridge/upstream-ca/test", // UpstreamCaField.tsx — rota inexistente ]); diff --git a/scripts/check/check-openapi-routes.mjs b/scripts/check/check-openapi-routes.mjs index 398c00785e..d91673602b 100644 --- a/scripts/check/check-openapi-routes.mjs +++ b/scripts/check/check-openapi-routes.mjs @@ -17,8 +17,6 @@ const OPENAPI_PATH = path.join(ROOT, "docs", "reference", "openapi.yaml"); const KNOWN_STALE_SPEC = new Set([ // openapi.yaml documenta um state por-agente, mas a rota real é o state GLOBAL // (/api/tools/agent-bridge/state); por-agente só há /{id}, /{id}/detect, /mappings, /dns. - // Triagem: corrigir a spec para /state ou criar a rota. (pré-existente) - "/api/tools/agent-bridge/agents/{agentId}/state", ]); /** Normaliza qualquer {param} para {} para casar independente do nome do parâmetro. */ diff --git a/scripts/check/check-provider-consistency.ts b/scripts/check/check-provider-consistency.ts index ab08643815..e44a2ea33c 100644 --- a/scripts/check/check-provider-consistency.ts +++ b/scripts/check/check-provider-consistency.ts @@ -11,10 +11,7 @@ import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts"; // Entradas registry-only conhecidas (meia-registro pré-existente). Cada uma com // justificativa. Remover daqui ao registrar o provider em providers.ts. -export const KNOWN_REGISTRY_ONLY: Record = { - krutrim: - "Registry-only (baseUrl + krutrim-2-7b-instruct presentes) mas ausente de providers.ts — meia-registro pré-existente; triar em follow-up (registrar em APIKEY_PROVIDERS ou remover a entrada).", -}; +export const KNOWN_REGISTRY_ONLY: Record = {}; /** Ids do REGISTRY que não são providers canônicos e não estão na allowlist. */ export function findOrphanRegistryIds( diff --git a/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx b/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx index 3a66230b18..379b6c0256 100644 --- a/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx +++ b/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx @@ -52,7 +52,14 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio const { provider, setProvider, providerOptions, loading: loadingProviders } = useProviderOptions( configState.provider ?? "" ); - const { availableModels, loading: loadingModels } = useAvailableModels(provider || undefined); + // #3505: filter models by the selected provider's catalog namespace. Compatible providers + // emit models under their node prefix (e.g. "myprefix/gpt-4o"), not under the connection id, + // so use the option's modelPrefix when present; fall back to the id for built-in providers. + const selectedProviderOption = providerOptions.find( + (opt: { value: string; modelPrefix?: string }) => opt.value === provider + ); + const modelFilterKey = selectedProviderOption?.modelPrefix || provider || undefined; + const { availableModels, loading: loadingModels } = useAvailableModels(modelFilterKey); function update(key: K, value: ConfigState[K]) { setConfigState({ ...configState, [key]: value }); diff --git a/src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx b/src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx index ec429c9b51..7a7176f6aa 100644 --- a/src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx +++ b/src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx @@ -10,7 +10,8 @@ import ProviderIcon from "@/shared/components/ProviderIcon"; // Types // ───────────────────────────────────────────────────────────────────────────── -type BreakerState = "CLOSED" | "OPEN" | "HALF_OPEN"; +type KnownBreakerState = "CLOSED" | "OPEN" | "HALF_OPEN" | "DEGRADED"; +type BreakerState = KnownBreakerState | (string & {}); type ProviderBreaker = { provider: string; @@ -83,6 +84,7 @@ type Connection = { type FeedEventKind = | "circuit-opened" + | "circuit-degraded" | "circuit-recovered" | "circuit-closed" | "cooldown-added" @@ -110,11 +112,11 @@ type FeedFilter = "all" | "circuits" | "cooldowns" | "lockouts" | "sessions" | " const REFRESH_INTERVAL_MS = 5000; const FEED_MAX_EVENTS = 50; +const EMPTY_PROVIDER_BREAKERS: ProviderBreaker[] = []; -const BREAKER_TONE: Record< - BreakerState, - { dot: string; bg: string; ring: string; label: string; icon: string } -> = { +type BreakerTone = { dot: string; bg: string; ring: string; label: string; icon: string }; + +const BREAKER_TONE: Record = { CLOSED: { dot: "#22c55e", bg: "rgba(34,197,94,0.10)", @@ -129,6 +131,13 @@ const BREAKER_TONE: Record< label: "RECOV", icon: "sync", }, + DEGRADED: { + dot: "#f97316", + bg: "rgba(249,115,22,0.10)", + ring: "rgba(249,115,22,0.30)", + label: "DEG", + icon: "warning", + }, OPEN: { dot: "#ef4444", bg: "rgba(239,68,68,0.10)", @@ -138,8 +147,17 @@ const BREAKER_TONE: Record< }, }; +const FALLBACK_BREAKER_TONE: BreakerTone = { + dot: "#64748b", + bg: "rgba(100,116,139,0.10)", + ring: "rgba(100,116,139,0.30)", + label: "UNK", + icon: "help", +}; + const FEED_KIND_META: Record = { "circuit-opened": { icon: "block", color: "#ef4444", group: "circuits" }, + "circuit-degraded": { icon: "warning", color: "#f97316", group: "circuits" }, "circuit-recovered": { icon: "sync", color: "#eab308", group: "circuits" }, "circuit-closed": { icon: "check_circle", color: "#22c55e", group: "circuits" }, "cooldown-added": { icon: "ac_unit", color: "#3b82f6", group: "cooldowns" }, @@ -188,6 +206,16 @@ function untilMs(value: number | string | null | undefined): number { return 0; } +function normalizeBreakerState(state: string | null | undefined): string { + return String(state || "") + .trim() + .toUpperCase(); +} + +function getBreakerTone(normalizedState: string): BreakerTone { + return BREAKER_TONE[normalizedState] || FALLBACK_BREAKER_TONE; +} + function pushFeed(prev: FeedEvent[], events: FeedEvent[]): FeedEvent[] { if (events.length === 0) return prev; const merged = [...events, ...prev]; @@ -210,8 +238,10 @@ function diffSnapshots( for (const [provider, nextB] of nextBreakers) { const prevB = prevBreakers.get(provider); if (!prevB) continue; - if (prevB.state === nextB.state) continue; - if (nextB.state === "OPEN") { + const prevState = normalizeBreakerState(prevB.state); + const nextState = normalizeBreakerState(nextB.state); + if (prevState === nextState) continue; + if (nextState === "OPEN") { out.push({ id: `cb-open-${provider}-${nowTs}`, ts: nowTs, @@ -219,7 +249,7 @@ function diffSnapshots( title: `${provider} circuit OPEN`, detail: `threshold hit · retry in ${fmtMs(nextB.retryAfterMs)}`, }); - } else if (nextB.state === "HALF_OPEN") { + } else if (nextState === "HALF_OPEN") { out.push({ id: `cb-half-${provider}-${nowTs}`, ts: nowTs, @@ -227,7 +257,15 @@ function diffSnapshots( title: `${provider} HALF_OPEN`, detail: `probing recovery`, }); - } else if (nextB.state === "CLOSED" && prevB.state !== "CLOSED") { + } else if (nextState === "DEGRADED") { + out.push({ + id: `cb-deg-${provider}-${nowTs}`, + ts: nowTs, + kind: "circuit-degraded", + title: `${provider} DEGRADED`, + detail: `${nextB.failureCount} failures · degraded but serving`, + }); + } else if (nextState === "CLOSED" && prevState !== "CLOSED") { out.push({ id: `cb-close-${provider}-${nowTs}`, ts: nowTs, @@ -423,16 +461,33 @@ export default function RuntimePageClient() { return connections.filter((c) => c.rateLimitedUntil && untilMs(c.rateLimitedUntil) > 0); }, [connections]); - const breakers = health?.providerBreakers ?? []; + const breakers = health?.providerBreakers ?? EMPTY_PROVIDER_BREAKERS; const lockoutEntries = useMemo>( () => Object.entries(health?.lockouts ?? {}), [health] ); const counts = useMemo(() => { - const openCircuits = breakers.filter((b) => b.state === "OPEN").length; - const halfCircuits = breakers.filter((b) => b.state === "HALF_OPEN").length; + let openCircuits = 0; + let halfCircuits = 0; + let degradedCircuits = 0; + let unknownCircuits = 0; + + for (const breaker of breakers) { + const state = normalizeBreakerState(breaker.state); + if (state === "OPEN") { + openCircuits++; + } else if (state === "HALF_OPEN") { + halfCircuits++; + } else if (state === "DEGRADED") { + degradedCircuits++; + } else if (state !== "CLOSED") { + unknownCircuits++; + } + } + const totalBreakers = breakers.length; + const affectedCircuits = openCircuits + halfCircuits + degradedCircuits + unknownCircuits; const sessions = health?.sessions?.activeCount ?? 0; const lockouts = lockoutEntries.length; const quota = health?.quotaMonitor; @@ -443,6 +498,9 @@ export default function RuntimePageClient() { stickyBound: health?.sessions?.stickyBoundCount ?? 0, openCircuits, halfCircuits, + degradedCircuits, + unknownCircuits, + affectedCircuits, totalBreakers, cooldowns: cooldowns.length, lockouts, @@ -456,7 +514,7 @@ export default function RuntimePageClient() { return feed.filter((ev) => FEED_KIND_META[ev.kind].group === feedFilter); }, [feed, feedFilter]); - const overallHealthy = counts.totalBreakers - counts.openCircuits - counts.halfCircuits; + const overallHealthy = counts.totalBreakers - counts.affectedCircuits; const overallPercent = counts.totalBreakers > 0 ? Math.round((overallHealthy / counts.totalBreakers) * 100) : 100; @@ -519,14 +577,20 @@ export default function RuntimePageClient() { label={t("kpiCircuits")} value={`${counts.openCircuits} / ${counts.totalBreakers}`} hint={ - counts.halfCircuits > 0 - ? t("hintRecovering", { count: counts.halfCircuits }) + counts.halfCircuits + counts.degradedCircuits + counts.unknownCircuits > 0 + ? t("hintRecovering", { + count: counts.halfCircuits + counts.degradedCircuits + counts.unknownCircuits, + }) : counts.openCircuits === 0 ? t("hintAllHealthy") : t("hintOpen") } tone={ - counts.openCircuits > 0 ? "#ef4444" : counts.halfCircuits > 0 ? "#eab308" : "#22c55e" + counts.openCircuits > 0 + ? "#ef4444" + : counts.halfCircuits + counts.degradedCircuits + counts.unknownCircuits > 0 + ? "#eab308" + : "#22c55e" } onClick={() => setFeedFilter("circuits")} active={feedFilter === "circuits"} @@ -561,7 +625,9 @@ export default function RuntimePageClient() { trailing={

✓ {overallHealthy} - ⚠ {counts.halfCircuits} + + ⚠ {counts.halfCircuits + counts.degradedCircuits + counts.unknownCircuits} + ⛔ {counts.openCircuits}
} @@ -585,11 +651,15 @@ export default function RuntimePageClient() { title={t("layer1Title")} description={t("layer1Desc")} badge={t("badgeAffectedOf", { - affected: counts.openCircuits + counts.halfCircuits, + affected: counts.affectedCircuits, total: counts.totalBreakers, })} badgeTone={ - counts.openCircuits > 0 ? "red" : counts.halfCircuits > 0 ? "amber" : "green" + counts.openCircuits > 0 + ? "red" + : counts.halfCircuits + counts.degradedCircuits + counts.unknownCircuits > 0 + ? "amber" + : "green" } > {breakers.length === 0 ? ( @@ -597,13 +667,14 @@ export default function RuntimePageClient() { ) : (
{breakers.map((b) => { - const tone = BREAKER_TONE[b.state]; + const state = normalizeBreakerState(b.state); + const tone = getBreakerTone(state); return (
@@ -613,7 +684,7 @@ export default function RuntimePageClient() {
- {b.state === "OPEN" + {state === "OPEN" ? `retry ${fmtMs(b.retryAfterMs)}` : `${b.failureCount} failures`}
diff --git a/src/app/(dashboard)/dashboard/settings/components/FeatureFlagsGrid.tsx b/src/app/(dashboard)/dashboard/settings/components/FeatureFlagsGrid.tsx index b3deabb0fd..b03e83624a 100644 --- a/src/app/(dashboard)/dashboard/settings/components/FeatureFlagsGrid.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/FeatureFlagsGrid.tsx @@ -185,7 +185,7 @@ export default function FeatureFlagsGrid() { // Server is going down — wait for it to come back, then reload. const stillUp = async () => { try { - const r = await fetch("/api/health", { cache: "no-store" }); + const r = await fetch("/api/health/ping", { cache: "no-store" }); return r.ok; } catch { return false; diff --git a/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx index def88680d2..1aa9f0855f 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx @@ -26,6 +26,7 @@ type ConnectionCooldownProfileSettings = { type ProviderBreakerProfileSettings = { failureThreshold: number; + degradationThreshold: number; resetTimeoutMs: number; }; @@ -35,6 +36,12 @@ type WaitForCooldownSettings = { maxRetryWaitSec: number; }; +type ProviderCooldownSettings = { + enabled: boolean; + minRetryCooldownMs: number; + maxRetryCooldownMs: number; +}; + type ResilienceResponse = { requestQueue: RequestQueueSettings; connectionCooldown: { @@ -46,6 +53,7 @@ type ResilienceResponse = { apikey: ProviderBreakerProfileSettings; }; waitForCooldown: WaitForCooldownSettings; + providerCooldown: ProviderCooldownSettings; }; function formatMs(value: number | null | undefined) { @@ -539,6 +547,14 @@ function ProviderBreakerCard({ setDraft((prev) => ({ ...prev, [key]: { ...prev[key], failureThreshold } })) } /> + + setDraft((prev) => ({ ...prev, [key]: { ...prev[key], degradationThreshold } })) + } + /> {t("resilienceFailureThreshold")} {current.failureThreshold}
+
+ {t("resilienceDegradationThreshold")} + {current.degradationThreshold} +
{t("resilienceResetTime")} {formatMs(current.resetTimeoutMs)} @@ -702,6 +722,109 @@ function WaitForCooldownCard({ ); } +function ProviderCooldownCard({ + value, + onSave, + saving, +}: { + value: ProviderCooldownSettings; + onSave: (next: ProviderCooldownSettings) => Promise; + saving: boolean; +}) { + const t = useTranslations("settings"); + const [editing, setEditing] = useState(value); + const [isEditing, setIsEditing] = useState(false); + + useEffect(() => { + setEditing(value); + }, [value]); + + return ( + +
+
+
+ timer +

{t("resilienceProviderCooldownTitle")}

+
+ +
+ setIsEditing(true)} + onCancel={() => { + setEditing(value); + setIsEditing(false); + }} + onSave={async () => { + await onSave(editing); + setIsEditing(false); + }} + /> +
+ +

{t("resilienceProviderCooldownDesc")}

+ +
+ {isEditing ? ( + <> + setEditing((prev) => ({ ...prev, enabled }))} + /> + + setEditing((prev) => ({ ...prev, minRetryCooldownMs })) + } + /> + + setEditing((prev) => ({ ...prev, maxRetryCooldownMs })) + } + /> + + ) : ( + <> +
+
{t("resilienceProviderCooldownEnabled")}
+
+ {value.enabled ? t("statusEnabled") : t("statusDisabled")} +
+
+
+
{t("resilienceProviderCooldownMin")}
+
+ {formatMs(value.minRetryCooldownMs)} +
+
+
+
{t("resilienceProviderCooldownMax")}
+
+ {formatMs(value.maxRetryCooldownMs)} +
+
+ + )} +
+
+ ); +} + export default function ResilienceTab() { const notify = useNotificationStore(); const t = useTranslations("settings"); @@ -734,6 +857,7 @@ export default function ResilienceTab() { connectionCooldown: json.connectionCooldown, providerBreaker: json.providerBreaker, waitForCooldown: json.waitForCooldown, + providerCooldown: json.providerCooldown, }); } catch (error) { notify.error( @@ -769,6 +893,7 @@ export default function ResilienceTab() { connectionCooldown: json.connectionCooldown, providerBreaker: json.providerBreaker, waitForCooldown: json.waitForCooldown, + providerCooldown: json.providerCooldown, }); notify.success(tx("savedSuccessfully", "Resilience settings updated.")); } catch (error) { @@ -845,6 +970,11 @@ export default function ResilienceTab() { saving={savingSection === "waitForCooldown"} onSave={(waitForCooldown) => savePatch("waitForCooldown", { waitForCooldown })} /> + savePatch("providerCooldown", { providerCooldown })} + />
); } diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/CustomHostsManager.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/CustomHostsManager.tsx index 5673139fdf..2d51a7323a 100644 --- a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/CustomHostsManager.tsx +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/CustomHostsManager.tsx @@ -27,7 +27,7 @@ export function CustomHostsManager({ onClose }: CustomHostsManagerProps) { const fetchHosts = async () => { setLoading(true); try { - const res = await fetch("/api/tools/traffic-inspector/custom-hosts"); + const res = await fetch("/api/tools/traffic-inspector/hosts"); if (res.ok) { const data = (await res.json()) as { hosts: CustomHost[] }; setHosts(data.hosts ?? []); @@ -50,7 +50,7 @@ export function CustomHostsManager({ onClose }: CustomHostsManagerProps) { } const host = parsed.data; try { - const res = await fetch("/api/tools/traffic-inspector/custom-hosts", { + const res = await fetch("/api/tools/traffic-inspector/hosts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ host, enabled: true }), @@ -69,7 +69,7 @@ export function CustomHostsManager({ onClose }: CustomHostsManagerProps) { const deleteHost = async (host: string) => { try { - await fetch(`/api/tools/traffic-inspector/custom-hosts/${encodeURIComponent(host)}`, { + await fetch(`/api/tools/traffic-inspector/hosts/${encodeURIComponent(host)}`, { method: "DELETE", }); await fetchHosts(); diff --git a/src/app/(dashboard)/dashboard/translator/hooks/useAvailableModels.tsx b/src/app/(dashboard)/dashboard/translator/hooks/useAvailableModels.tsx index c0e5ec525e..c3fdbe666e 100644 --- a/src/app/(dashboard)/dashboard/translator/hooks/useAvailableModels.tsx +++ b/src/app/(dashboard)/dashboard/translator/hooks/useAvailableModels.tsx @@ -25,6 +25,18 @@ const FORMAT_MODEL_PREFIXES = { * pickModelForFormat: (format: string) => string * }} */ +/** + * Filter the /v1/models id list to a provider's models. The `provider` key must be the model + * NAMESPACE used in the catalog: built-in providers use their id (e.g. "openai"), while + * compatible providers use the node's custom PREFIX (e.g. "myprefix"), NOT the node id — see + * #3505. Pure + exported for testing. + */ +export function filterModelsByProvider(allModels: string[], provider?: string): string[] { + return provider + ? allModels.filter((m) => m.startsWith(`${provider}/`) || m === provider) + : allModels; +} + export function useAvailableModels(provider?: string) { const [model, setModel] = useState(""); const [allModels, setAllModels] = useState([]); @@ -46,11 +58,10 @@ export function useAvailableModels(provider?: string) { fetchModels(); }, []); - const availableModels = useMemo(() => { - return provider - ? allModels.filter((m) => m.startsWith(`${provider}/`) || m === provider) - : allModels; - }, [allModels, provider]); + const availableModels = useMemo( + () => filterModelsByProvider(allModels, provider), + [allModels, provider] + ); /** * Pick the best model for a given format from the available models. diff --git a/src/app/(dashboard)/dashboard/translator/hooks/useProviderOptions.tsx b/src/app/(dashboard)/dashboard/translator/hooks/useProviderOptions.tsx index f0c2655bc2..f7518c9a20 100644 --- a/src/app/(dashboard)/dashboard/translator/hooks/useProviderOptions.tsx +++ b/src/app/(dashboard)/dashboard/translator/hooks/useProviderOptions.tsx @@ -47,7 +47,12 @@ export function useProviderOptions(initialProvider = "openai") { label = node?.name || t("openaiCompatibleLabel"); if (!info && (pid as string).startsWith(ANTHROPIC_COMPATIBLE_PREFIX)) label = node?.name || t("anthropicCompatibleLabel"); - return { value: pid, label }; + // #3505: compatible providers emit catalog models under the node's custom prefix + // (e.g. "myprefix/gpt-4o"), not under the connection id (`value`). Expose the prefix + // so model-filter consumers (playground) can match; `value` stays the id for + // connection lookups (translator send/translate, ApiTab). + const modelPrefix = !info && node?.prefix ? String(node.prefix) : undefined; + return { value: pid, label, modelPrefix }; }) .sort((a, b) => compareTr(a.label, b.label)); diff --git a/src/app/api/internal/codex-responses-ws/modelResolution.ts b/src/app/api/internal/codex-responses-ws/modelResolution.ts index a6b6d85f1f..340cf7fbb3 100644 --- a/src/app/api/internal/codex-responses-ws/modelResolution.ts +++ b/src/app/api/internal/codex-responses-ws/modelResolution.ts @@ -72,6 +72,14 @@ export async function resolveResponsesApiModel( return { model: requestedModel, changed: false }; } + // #3509: "auto" is OmniRoute's zero-config auto-routing keyword (handled by the + // isAutoRouting path in chat.ts, not a DB combo). It must NEVER be rewritten to + // "codex/auto" — ChatGPT rejects it with "The 'auto' model is not supported when using + // Codex with a ChatGPT account". ("auto/" already returns via the slash guard above.) + if (requestedModel === "auto") { + return { model: requestedModel, changed: false }; + } + // #3227/#3233: a bare combo name (e.g. "n8n-text", "paid-premium") must NOT be // force-prefixed to codex/ — Codex accepts arbitrary model strings, so the rewrite // would shadow the combo and route to codex. Let downstream combo routing handle it. diff --git a/src/app/api/logs/[id]/route.ts b/src/app/api/logs/[id]/route.ts index 0d02bcce22..5fb9c62bbe 100644 --- a/src/app/api/logs/[id]/route.ts +++ b/src/app/api/logs/[id]/route.ts @@ -5,13 +5,15 @@ import { getCompletedDetails, getPendingById } from "@/lib/usage/usageHistory"; export const dynamic = "force-dynamic"; -export async function GET(req: Request) { +export async function GET( + req: Request, + { params }: { params: Promise<{ id: string }> | { id: string } } +) { const authError = await requireManagementAuth(req); if (authError) return authError; try { - const url = new URL(req.url); - const id = url.pathname.split("/").pop(); + const { id } = await params; if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 }); // Prefer in-flight active pending requests first to avoid races where @@ -44,16 +46,19 @@ export async function GET(req: Request) { }; return NextResponse.json(activeEntry); - } + } } catch (e) { - console.warn("/api/logs/[id] - failed to read active pending detail:", e); + console.warn("/api/logs/[id] - failed to read active pending detail:", e); } // Next, try persistent call log by id let persistedRequest = await getCallLogById(id); // If persistent call log doesn't have payloads, try the in-memory completedDetails cache - if (!persistedRequest?.pipelinePayloads || Object.keys(persistedRequest.pipelinePayloads).length === 0) { + if ( + !persistedRequest?.pipelinePayloads || + Object.keys(persistedRequest.pipelinePayloads).length === 0 + ) { try { const completed = getCompletedDetails(); const inMem = completed.get(id); diff --git a/src/app/api/settings/proxies/route.ts b/src/app/api/settings/proxies/route.ts index 11be3f28ca..9d923cf3c2 100644 --- a/src/app/api/settings/proxies/route.ts +++ b/src/app/api/settings/proxies/route.ts @@ -36,7 +36,14 @@ export async function GET(request: Request) { } const proxies = await listProxies({ includeSecrets: false }); - return Response.json({ items: proxies, total: proxies.length }); + // #3508: expose the SOCKS5 feature flag at runtime so the dashboard reflects the live + // ENABLE_SOCKS5_PROXY env (the UI previously gated on NEXT_PUBLIC_*, which is baked at + // build time and ignored a runtime Docker env). + return Response.json({ + items: proxies, + total: proxies.length, + socks5Enabled: process.env.ENABLE_SOCKS5_PROXY === "true", + }); } catch (error) { return createErrorResponseFromUnknown(error, "Failed to load proxies"); } diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 07286291a5..0d5bac6446 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "إعادة تعيين المهلة", "resilienceFailureThresholdLabel": "عتبة الفشل", "resilienceResetTimeoutLabel": "إعادة تعيين المهلة", diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index 0c42d0553f..7ba60d7218 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "Vaxt aşımını sıfırlayın", "resilienceFailureThresholdLabel": "Uğursuzluq həddi", "resilienceResetTimeoutLabel": "Vaxt aşımını sıfırlayın", diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index dd192da310..0a979f4420 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "Нулиране на времето за изчакване", "resilienceFailureThresholdLabel": "Праг на повреда", "resilienceResetTimeoutLabel": "Нулиране на времето за изчакване", diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index d9ed9cc074..18d38b4be3 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "টাইমআউট রিসেট করুন", "resilienceFailureThresholdLabel": "ব্যর্থতার থ্রেশহোল্ড", "resilienceResetTimeoutLabel": "টাইমআউট রিসেট করুন", diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index 724f840844..5370f35c5f 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "Resetovat časový limit", "resilienceFailureThresholdLabel": "Práh selhání", "resilienceResetTimeoutLabel": "Resetovat časový limit", diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index 5f8927dec8..3bced90366 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "Nulstil timeout", "resilienceFailureThresholdLabel": "Fejlgrænse", "resilienceResetTimeoutLabel": "Nulstil timeout", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 40ab3258c5..eeb509339d 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -5285,6 +5285,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "Timeout zurücksetzen", "resilienceFailureThresholdLabel": "Fehlerschwelle", "resilienceResetTimeoutLabel": "Timeout zurücksetzen", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index a6135959e9..fc986794f2 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -5641,6 +5641,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "Reset timeout", "resilienceFailureThresholdLabel": "Failure threshold", "resilienceResetTimeoutLabel": "Reset timeout", @@ -5837,6 +5838,15 @@ "resilienceEnableServerWaitDesc": "When enabled, OmniRoute waits for the first cooldown to expire and retries automatically.", "resilienceMaxAttempts": "Maximum attempts", "resilienceMaxWaitPerAttempt": "Maximum wait per attempt", + "resilienceProviderCooldownTitle": "Provider Cooldown", + "resilienceProviderCooldownScope": "All combo requests", + "resilienceProviderCooldownTrigger": "When a provider/connection fails", + "resilienceProviderCooldownEffect": "Skips the failed provider for a cooldown period before retrying", + "resilienceProviderCooldownDesc": "Prevents subsequent requests from re-walking the same failing providers. Cooldown scales exponentially with consecutive failures.", + "resilienceProviderCooldownEnabled": "Enable global provider cooldown", + "resilienceProviderCooldownEnabledDesc": "When enabled, failed providers are tracked globally and skipped for a cooldown period.", + "resilienceProviderCooldownMin": "Minimum cooldown", + "resilienceProviderCooldownMax": "Maximum cooldown", "forcedFingerprintTitle": "Always enabled for {provider} — required for OAuth account safety; cannot be turned off.", "forcedFingerprintBadge": "Required", "codexSessionAffinityTitle": "Codex session affinity", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index b7c723b79e..060d3f35ba 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "Restablecer el tiempo de espera", "resilienceFailureThresholdLabel": "Umbral de falla", "resilienceResetTimeoutLabel": "Restablecer el tiempo de espera", diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index e63222c405..9b1afb83d1 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "بازنشانی مهلت زمانی", "resilienceFailureThresholdLabel": "آستانه شکست", "resilienceResetTimeoutLabel": "بازنشانی مهلت زمانی", diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index 0a64950627..1c2cd4c08e 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "Nollaa aikakatkaisu", "resilienceFailureThresholdLabel": "Epäonnistumisen kynnys", "resilienceResetTimeoutLabel": "Nollaa aikakatkaisu", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 0608dd4d07..edb077d784 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "Réinitialiser le délai d'attente", "resilienceFailureThresholdLabel": "Seuil de défaillance", "resilienceResetTimeoutLabel": "Réinitialiser le délai d'attente", diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index 7ce47a97fe..759c6781fd 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "સમયસમાપ્તિ રીસેટ કરો", "resilienceFailureThresholdLabel": "નિષ્ફળતા થ્રેશોલ્ડ", "resilienceResetTimeoutLabel": "સમયસમાપ્તિ રીસેટ કરો", diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index 286d7b6a5b..2c1212f9d2 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "אפס פסק זמן", "resilienceFailureThresholdLabel": "סף כשל", "resilienceResetTimeoutLabel": "אפס פסק זמן", diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 58e51963aa..918e44820c 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "टाइमआउट रीसेट करें", "resilienceFailureThresholdLabel": "विफलता की सीमा", "resilienceResetTimeoutLabel": "टाइमआउट रीसेट करें", diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index 03db64abeb..323c0d6252 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "Időkorlát visszaállítása", "resilienceFailureThresholdLabel": "Meghibásodási küszöb", "resilienceResetTimeoutLabel": "Időkorlát visszaállítása", diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index 7b11386cb1..fc970dcae1 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "Setel ulang batas waktu", "resilienceFailureThresholdLabel": "Ambang batas kegagalan", "resilienceResetTimeoutLabel": "Setel ulang batas waktu", diff --git a/src/i18n/messages/in.json b/src/i18n/messages/in.json index 6d2c395b2d..f12d68891d 100644 --- a/src/i18n/messages/in.json +++ b/src/i18n/messages/in.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "Setel ulang batas waktu", "resilienceFailureThresholdLabel": "Ambang batas kegagalan", "resilienceResetTimeoutLabel": "Setel ulang batas waktu", diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 88ab53c4c9..853620a414 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "Reimposta il timeout", "resilienceFailureThresholdLabel": "Soglia di fallimento", "resilienceResetTimeoutLabel": "Reimposta il timeout", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index f2921360f9..cebcaefae0 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "リセットタイムアウト", "resilienceFailureThresholdLabel": "失敗のしきい値", "resilienceResetTimeoutLabel": "リセットタイムアウト", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index fdec653c25..8c2c59e4b1 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "시간 초과 재설정", "resilienceFailureThresholdLabel": "실패 임계값", "resilienceResetTimeoutLabel": "시간 초과 재설정", diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index bb4ed9e102..7f43664c63 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "टाइमआउट रीसेट करा", "resilienceFailureThresholdLabel": "अयशस्वी थ्रेशोल्ड", "resilienceResetTimeoutLabel": "टाइमआउट रीसेट करा", diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index f90dd6db3d..eda6234480 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "Tetapkan semula tamat masa", "resilienceFailureThresholdLabel": "Ambang kegagalan", "resilienceResetTimeoutLabel": "Tetapkan semula tamat masa", diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index f03801d00a..14aef8ba1d 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "Time-out opnieuw instellen", "resilienceFailureThresholdLabel": "Mislukkingsdrempel", "resilienceResetTimeoutLabel": "Time-out opnieuw instellen", diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index b97efd106b..0b71a6b107 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "Tilbakestill tidsavbrudd", "resilienceFailureThresholdLabel": "Feilterskel", "resilienceResetTimeoutLabel": "Tilbakestill tidsavbrudd", diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index c33c56cdd2..ff52d53f9d 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "I-reset ang timeout", "resilienceFailureThresholdLabel": "Ang limitasyon ng pagkabigo", "resilienceResetTimeoutLabel": "I-reset ang timeout", diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index f5c6eb0377..142d74f45c 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "Zresetuj limit czasu", "resilienceFailureThresholdLabel": "Próg awarii", "resilienceResetTimeoutLabel": "Zresetuj limit czasu", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 74e635cc2a..5a8c0a38b7 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -6575,6 +6575,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "Redefinir tempo limite", "resilienceFailureThresholdLabel": "Limite de falha", "resilienceResetTimeoutLabel": "Redefinir tempo limite", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 285b6b1c49..c00d37b459 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "Redefinir tempo limite", "resilienceFailureThresholdLabel": "Limite de falha", "resilienceResetTimeoutLabel": "Redefinir tempo limite", diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index 31537eadba..afe051ea6b 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "Resetați timeout", "resilienceFailureThresholdLabel": "Pragul de eșec", "resilienceResetTimeoutLabel": "Resetați timeout", diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index fa07b693ae..fa4c86f9bb 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -5519,6 +5519,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "Сбросить тайм-аут", "resilienceFailureThresholdLabel": "Порог отказа", "resilienceResetTimeoutLabel": "Сбросить тайм-аут", diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index 9c040cef29..ce3964e6f5 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "Resetovať časový limit", "resilienceFailureThresholdLabel": "Prah zlyhania", "resilienceResetTimeoutLabel": "Resetovať časový limit", diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index 48013c2a23..ddc4c30089 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "Återställ timeout", "resilienceFailureThresholdLabel": "Misslyckande tröskel", "resilienceResetTimeoutLabel": "Återställ timeout", diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index f8d4d7b8d4..7fa0a1ec1e 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "Weka upya muda wa kuisha", "resilienceFailureThresholdLabel": "Kizingiti cha kushindwa", "resilienceResetTimeoutLabel": "Weka upya muda wa kuisha", diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index 72fd5bf605..21c678ed0e 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "காலாவதியை மீட்டமைக்கவும்", "resilienceFailureThresholdLabel": "தோல்வி வரம்பு", "resilienceResetTimeoutLabel": "காலாவதியை மீட்டமைக்கவும்", diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index 6201b47cd9..96ad5f0ee2 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "గడువు ముగిసింది", "resilienceFailureThresholdLabel": "వైఫల్యం థ్రెషోల్డ్", "resilienceResetTimeoutLabel": "గడువు ముగిసింది", diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index 19fbfdf9c9..6661b69f81 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "รีเซ็ตการหมดเวลา", "resilienceFailureThresholdLabel": "เกณฑ์ความล้มเหลว", "resilienceResetTimeoutLabel": "รีเซ็ตการหมดเวลา", diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index 3559ef09d6..64b0fdbac2 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "Zaman aşımını sıfırla", "resilienceFailureThresholdLabel": "Arıza eşiği", "resilienceResetTimeoutLabel": "Zaman aşımını sıfırla", diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index 5395be658c..330b81ab3c 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -5277,6 +5277,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "Скинути час очікування", "resilienceFailureThresholdLabel": "Поріг відмови", "resilienceResetTimeoutLabel": "Скинути час очікування", diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index 8d18fa89f8..5de02a4bed 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "ٹائم آؤٹ ری سیٹ کریں۔", "resilienceFailureThresholdLabel": "ناکامی کی حد", "resilienceResetTimeoutLabel": "ٹائم آؤٹ ری سیٹ کریں۔", diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index bbd742b065..e19a4a3b47 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -5280,6 +5280,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "Đặt lại thời gian chờ", "resilienceFailureThresholdLabel": "Ngưỡng thất bại", "resilienceResetTimeoutLabel": "Đặt lại thời gian chờ", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 702847a75e..e629bcea71 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -5527,6 +5527,7 @@ "resilienceMaxBackoffSteps": "Max backoff steps", "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", "resilienceFailureThreshold": "Failure threshold", + "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "重置超时", "resilienceFailureThresholdLabel": "失败阈值", "resilienceResetTimeoutLabel": "重置超时", diff --git a/src/lib/db/gamification.ts b/src/lib/db/gamification.ts index 01655ea761..84522e939c 100644 --- a/src/lib/db/gamification.ts +++ b/src/lib/db/gamification.ts @@ -212,6 +212,21 @@ export function unlockBadge(apiKeyId: string, badgeId: string): void { .run(apiKeyId, badgeId); } +/** + * Whether a specific badge has already been awarded to an API key. + * + * Reads `user_badges` directly (no JOIN), so the "already unlocked?" check is correct even + * when `badge_definitions` is unpopulated. `getBadges()` INNER-JOINs `badge_definitions`, so + * it returns nothing until the definitions are seeded — using it as a dedup guard caused + * badge-unlock events to re-fire on every request (#3472). + */ +export function hasBadge(apiKeyId: string, badgeId: string): boolean { + const row = db() + .prepare(`SELECT 1 FROM user_badges WHERE api_key_id = ? AND badge_id = ? LIMIT 1`) + .get(apiKeyId, badgeId); + return !!row; +} + export function getBadges(apiKeyId: string): UserBadge[] { const rows = db() .prepare( diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index 42246413ca..c9ec88c002 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -106,7 +106,7 @@ export async function getSettings() { codexServiceTier: { enabled: false }, claudeFastMode: { enabled: false, - supportedModels: ["claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6"], + supportedModels: ["claude-fable-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6"], }, codexSessionAffinityTtlMs: 0, responsesPreviousResponseIdMode: DEFAULT_RESPONSES_PREVIOUS_RESPONSE_ID_MODE, diff --git a/src/lib/gamification/events.ts b/src/lib/gamification/events.ts index 00fbe808d3..cccb2474b0 100644 --- a/src/lib/gamification/events.ts +++ b/src/lib/gamification/events.ts @@ -115,9 +115,11 @@ function getXpForAction(action: string): number { * Check and unlock a specific badge. */ async function checkAndUnlockBadge(apiKeyId: string, badgeId: string): Promise { - const { unlockBadge, getBadges } = await import("../db/gamification"); - const earned = getBadges(apiKeyId); - if (!earned.some((b) => b.badgeId === badgeId)) { + const { unlockBadge, hasBadge } = await import("../db/gamification"); + // #3472: dedup via user_badges directly. getBadges() INNER-JOINs badge_definitions, which is + // empty until seeded, so it falsely reported "not earned" and re-emitted the unlock event on + // every request. + if (!hasBadge(apiKeyId, badgeId)) { unlockBadge(apiKeyId, badgeId); log.info("events.badge_unlocked", { apiKeyId, badgeId }); diff --git a/src/lib/images/imageRouteModel.ts b/src/lib/images/imageRouteModel.ts index 1425044f17..b3461a6483 100644 --- a/src/lib/images/imageRouteModel.ts +++ b/src/lib/images/imageRouteModel.ts @@ -4,11 +4,12 @@ * `/v1/images/generations` and `/v1/images/edits` must resolve a requested model the * same way, and as close as practical to how chat routing resolves models: * - * 1. Built-in image model id / alias (`cgpt-web/...`, `gpt-image-1`, …) — untouched. - * 2. Custom provider *prefix* form (`myImg/gpt-image-2`) — rewritten to the internal + * 1. Bare combo / alias name with no slash (`image`) — resolved to the combo's single + * image target, then that target is itself prefix-resolved. Bare combos intentionally + * override built-in image aliases with the same name. + * 2. Built-in image model id / alias (`cgpt-web/...`, `gpt-image-1`, …) — untouched. + * 3. Custom provider *prefix* form (`myImg/gpt-image-2`) — rewritten to the internal * `/` id (#3205 did this inline in the generations route only). - * 3. Bare combo / alias name with no slash (`image`) — resolved to the combo's single - * image target, then that target is itself prefix-resolved. * * Anything that does not match falls through unchanged, so existing built-in and * already-internal ids keep working. @@ -60,7 +61,8 @@ export async function resolveSingleImageComboTarget(name: string): Promise typeof t?.modelStr === "string" && (t.modelStr as string).trim() + (t: { modelStr?: unknown }) => + typeof t?.modelStr === "string" && (t.modelStr as string).trim() ); return (first?.modelStr as string) ?? null; } catch { @@ -74,18 +76,21 @@ export async function resolveSingleImageComboTarget(name: string): Promise { if (typeof modelStr !== "string" || !modelStr.trim()) return modelStr; - // 1. Built-in image model (alias or provider/model) — leave untouched. - if (parseImageModel(modelStr).provider) return modelStr; - - // 3. Bare combo/alias name (no slash): resolve to its single image target, then + // 1. Bare combo/alias name (no slash): resolve to its single image target, then // prefix-resolve that target (it may itself be a `prefix/model` custom id). + // This intentionally precedes built-in aliases so user combos can shadow names + // like `gpt-image-2`; explicit `provider/model` ids still bypass this branch. if (!modelStr.includes("/")) { const target = await resolveSingleImageComboTarget(modelStr); if (target && target !== modelStr) return resolveImageModelPrefix(target); - return modelStr; } - // 2. Custom provider prefix form — rewrite to internal `/`. + // 2. Built-in image model (alias or provider/model) — leave untouched. + if (parseImageModel(modelStr).provider) return modelStr; + + if (!modelStr.includes("/")) return modelStr; + + // 3. Custom provider prefix form — rewrite to internal `/`. return resolveImageModelPrefix(modelStr); } @@ -125,8 +130,7 @@ export function parseDataUrl(value: unknown): { bytes: Buffer; mime: string } | */ export function extractImageEditInputFromJson(body: unknown): ParsedImageEditInput { const obj = (body && typeof body === "object" ? body : {}) as Record; - const str = (v: unknown): string | null => - typeof v === "string" && v.trim() ? v.trim() : null; + const str = (v: unknown): string | null => (typeof v === "string" && v.trim() ? v.trim() : null); const prompt = typeof obj.prompt === "string" ? obj.prompt.trim() : ""; const model = str(obj.model); diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index ed9c65f34b..1c620b5ef5 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -406,6 +406,7 @@ export { getXp, updateLevel, unlockBadge, + hasBadge, getBadges, getBadgeDefinitions, transferTokens, diff --git a/src/lib/plugins/hooks.ts b/src/lib/plugins/hooks.ts index a0e1f5da68..b5c2260fd5 100644 --- a/src/lib/plugins/hooks.ts +++ b/src/lib/plugins/hooks.ts @@ -253,6 +253,11 @@ export interface Plugin { onRequest?: (ctx: PluginContext) => Promise | PluginResult | void; onResponse?: (ctx: PluginContext, response: unknown) => Promise | unknown | void; onError?: (ctx: PluginContext, error: Error) => Promise | unknown | void; + // ── Lifecycle hooks (fire-and-forget, non-blocking) ── + onInstall?: (payload: unknown) => Promise | void; + onActivate?: (payload: unknown) => Promise | void; + onDeactivate?: (payload: unknown) => Promise | void; + onUninstall?: (payload: unknown) => Promise | void; } /** diff --git a/src/lib/plugins/loader.ts b/src/lib/plugins/loader.ts index dc1ed24180..97173dd14c 100644 --- a/src/lib/plugins/loader.ts +++ b/src/lib/plugins/loader.ts @@ -259,6 +259,32 @@ export async function loadPlugin( }; registeredHooks.push("onError"); } + // ── Lifecycle hooks (fire-and-forget, errors logged but don't block) ── + const lifecycleHooks: Array<{ + key: "onInstall" | "onActivate" | "onDeactivate" | "onUninstall"; + manifestFlag: boolean; + }> = [ + { key: "onInstall", manifestFlag: manifest.hooks.onInstall }, + { key: "onActivate", manifestFlag: manifest.hooks.onActivate }, + { key: "onDeactivate", manifestFlag: manifest.hooks.onDeactivate }, + { key: "onUninstall", manifestFlag: manifest.hooks.onUninstall }, + ]; + + for (const { key, manifestFlag } of lifecycleHooks) { + if (manifestFlag) { + plugin[key] = async (payload: unknown): Promise => { + try { + await callHook(key, payload); + } catch (err: unknown) { + log.error(`plugin.${key}_error`, { + name: manifest.name, + error: err instanceof Error ? err.message : String(err), + }); + } + }; + registeredHooks.push(key); + } + } log.info("loader.loaded", { name: manifest.name, diff --git a/src/lib/providers/claudeFastMode.ts b/src/lib/providers/claudeFastMode.ts index 2418426198..8122fc53f3 100644 --- a/src/lib/providers/claudeFastMode.ts +++ b/src/lib/providers/claudeFastMode.ts @@ -7,6 +7,7 @@ type JsonRecord = Record; * only the latest Opus tiers can request the priority service path. */ export const CLAUDE_FAST_MODE_DEFAULT_MODELS = [ + "claude-fable-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", diff --git a/src/lib/providers/staticModels.ts b/src/lib/providers/staticModels.ts index 0cf74fe5fe..ab65ec66dd 100644 --- a/src/lib/providers/staticModels.ts +++ b/src/lib/providers/staticModels.ts @@ -33,6 +33,7 @@ const STATIC_MODEL_PROVIDERS: Record Array<{ id: string; name: str ], antigravity: () => ANTIGRAVITY_PUBLIC_MODELS.map((model) => ({ ...model })), claude: () => [ + { id: "claude-fable-5", name: "Claude Fable 5" }, { id: "claude-opus-4-8", name: "Claude Opus 4.8" }, { id: "claude-opus-4-7", name: "Claude Opus 4.7" }, { id: "claude-opus-4-6", name: "Claude Opus 4.6" }, diff --git a/src/lib/resilience/settings.ts b/src/lib/resilience/settings.ts index 9883c5a9bc..74cbefabae 100644 --- a/src/lib/resilience/settings.ts +++ b/src/lib/resilience/settings.ts @@ -30,6 +30,7 @@ export interface ConnectionCooldownProfileSettings { export interface ProviderBreakerProfileSettings { failureThreshold: number; + degradationThreshold: number; resetTimeoutMs: number; } @@ -40,6 +41,28 @@ export interface WaitForCooldownSettings { maxRetryWaitMs: number; } +export interface ProviderCooldownSettings { + /** + * Minimum cooldown (ms) before a failed provider/connection can be retried. + * This prevents subsequent requests from immediately re-walking failing providers. + * Scaled exponentially with failure count: minRetryCooldownMs * 2^(failures-1). + * Default: 5000 (5 seconds). + */ + minRetryCooldownMs: number; + /** + * Maximum cooldown (ms) before a failed provider/connection is retried regardless. + * Hard cap to prevent providers from being skipped indefinitely. + * Default: 300000 (5 minutes). + */ + maxRetryCooldownMs: number; + /** + * Enable/disable global provider cooldown tracking. + * When disabled, only per-request cooldown state is used. + * Default: true. + */ + enabled: boolean; +} + export interface QuotaPreflightSettings { /** * Global minimum-remaining cutoff (percent, 0-100). A connection is skipped @@ -72,6 +95,7 @@ export interface ResilienceSettings { connectionCooldown: Record; providerBreaker: Record; waitForCooldown: WaitForCooldownSettings; + providerCooldown: ProviderCooldownSettings; quotaPreflight: QuotaPreflightSettings; } @@ -80,6 +104,7 @@ export interface ResilienceSettingsPatch { connectionCooldown?: Partial>>; providerBreaker?: Partial>>; waitForCooldown?: Partial; + providerCooldown?: Partial; quotaPreflight?: Partial; } @@ -140,10 +165,12 @@ export const DEFAULT_RESILIENCE_SETTINGS: ResilienceSettings = { providerBreaker: { oauth: { failureThreshold: PROVIDER_PROFILES.oauth.circuitBreakerThreshold, + degradationThreshold: PROVIDER_PROFILES.oauth.degradationThreshold, resetTimeoutMs: PROVIDER_PROFILES.oauth.circuitBreakerReset, }, apikey: { failureThreshold: PROVIDER_PROFILES.apikey.circuitBreakerThreshold, + degradationThreshold: PROVIDER_PROFILES.apikey.degradationThreshold, resetTimeoutMs: PROVIDER_PROFILES.apikey.circuitBreakerReset, }, }, @@ -153,6 +180,17 @@ export const DEFAULT_RESILIENCE_SETTINGS: ResilienceSettings = { maxRetryWaitSec: 30, maxRetryWaitMs: 30000, }, + providerCooldown: { + minRetryCooldownMs: Number(process.env.PROVIDER_COOLDOWN_MIN_MS || "5000"), + maxRetryCooldownMs: Number(process.env.PROVIDER_COOLDOWN_MAX_MS || "300000"), + // Opt-in (default OFF): this global cross-request cooldown overlaps the + // existing Connection Cooldown / Provider Circuit Breaker layers, so it is + // disabled by default and must be explicitly enabled by the operator until + // its interaction with those layers is validated in production. + enabled: ["true", "1", "on"].includes( + (process.env.PROVIDER_COOLDOWN_ENABLED || "").trim().toLowerCase() + ), + }, quotaPreflight: { // Remaining-% semantics. 2 = "stop when only 2% remaining" (= 98% used). // Uniform across all providers and windows; operators set per-window @@ -277,11 +315,21 @@ function normalizeProviderBreakerProfile( fallback: ProviderBreakerProfileSettings ): ProviderBreakerProfileSettings { const record = asRecord(next); - return { - failureThreshold: toInteger(record.failureThreshold, fallback.failureThreshold, { + const failureThreshold = toInteger(record.failureThreshold, fallback.failureThreshold, { + min: 1, + max: 1000, + }); + const degradationThreshold = Math.min( + toInteger(record.degradationThreshold, fallback.degradationThreshold, { min: 1, max: 1000, }), + failureThreshold <= 1 ? 1 : failureThreshold - 1 + ); + + return { + failureThreshold, + degradationThreshold, resetTimeoutMs: toInteger(record.resetTimeoutMs, fallback.resetTimeoutMs, { min: 1000, max: 24 * 60 * 60 * 1000, @@ -369,6 +417,24 @@ function normalizeWaitForCooldownSettings( }; } +function normalizeProviderCooldownSettings( + next: unknown, + fallback: ProviderCooldownSettings +): ProviderCooldownSettings { + const record = asRecord(next); + const enabled = toBoolean(record.enabled, fallback.enabled); + const minRetryCooldownMs = toInteger(record.minRetryCooldownMs, fallback.minRetryCooldownMs, { + min: 0, + max: 60 * 60 * 1000, + }); + const maxRetryCooldownMs = toInteger(record.maxRetryCooldownMs, fallback.maxRetryCooldownMs, { + min: minRetryCooldownMs, + max: 24 * 60 * 60 * 1000, + }); + + return { enabled, minRetryCooldownMs, maxRetryCooldownMs }; +} + function buildLegacyFallback(settings: JsonRecord): ResilienceSettings { const profiles = asRecord(settings.providerProfiles); const defaults = asRecord(settings.rateLimitDefaults); @@ -424,6 +490,8 @@ function buildLegacyFallback(settings: JsonRecord): ResilienceSettings { DEFAULT_RESILIENCE_SETTINGS.providerBreaker.oauth.failureThreshold, { min: 1, max: 1000 } ), + degradationThreshold: + DEFAULT_RESILIENCE_SETTINGS.providerBreaker.oauth.degradationThreshold, resetTimeoutMs: toInteger( oauthLegacy.circuitBreakerReset, DEFAULT_RESILIENCE_SETTINGS.providerBreaker.oauth.resetTimeoutMs, @@ -436,6 +504,8 @@ function buildLegacyFallback(settings: JsonRecord): ResilienceSettings { DEFAULT_RESILIENCE_SETTINGS.providerBreaker.apikey.failureThreshold, { min: 1, max: 1000 } ), + degradationThreshold: + DEFAULT_RESILIENCE_SETTINGS.providerBreaker.apikey.degradationThreshold, resetTimeoutMs: toInteger( apikeyLegacy.circuitBreakerReset, DEFAULT_RESILIENCE_SETTINGS.providerBreaker.apikey.resetTimeoutMs, @@ -449,6 +519,7 @@ function buildLegacyFallback(settings: JsonRecord): ResilienceSettings { maxRetryWaitSec: waitMaxRetrySec, maxRetryWaitMs: waitMaxRetrySec * 1000, }, + providerCooldown: DEFAULT_RESILIENCE_SETTINGS.providerCooldown, quotaPreflight: DEFAULT_RESILIENCE_SETTINGS.quotaPreflight, }; } @@ -486,6 +557,10 @@ export function resolveResilienceSettings( current.waitForCooldown, fallback.waitForCooldown ), + providerCooldown: normalizeProviderCooldownSettings( + current.providerCooldown, + fallback.providerCooldown + ), quotaPreflight: normalizeQuotaPreflightSettings( current.quotaPreflight, fallback.quotaPreflight @@ -523,6 +598,10 @@ export function mergeResilienceSettings( updates.waitForCooldown, current.waitForCooldown ), + providerCooldown: normalizeProviderCooldownSettings( + updates.providerCooldown, + current.providerCooldown + ), quotaPreflight: normalizeQuotaPreflightSettings(updates.quotaPreflight, current.quotaPreflight), }; } @@ -537,6 +616,7 @@ export function buildLegacyResilienceCompat(settings: ResilienceSettings) { : settings.connectionCooldown.oauth.baseCooldownMs, maxBackoffLevel: settings.connectionCooldown.oauth.maxBackoffSteps, circuitBreakerThreshold: settings.providerBreaker.oauth.failureThreshold, + degradationThreshold: settings.providerBreaker.oauth.degradationThreshold, circuitBreakerReset: settings.providerBreaker.oauth.resetTimeoutMs, }, apikey: { @@ -546,6 +626,7 @@ export function buildLegacyResilienceCompat(settings: ResilienceSettings) { : settings.connectionCooldown.apikey.baseCooldownMs, maxBackoffLevel: settings.connectionCooldown.apikey.maxBackoffSteps, circuitBreakerThreshold: settings.providerBreaker.apikey.failureThreshold, + degradationThreshold: settings.providerBreaker.apikey.degradationThreshold, circuitBreakerReset: settings.providerBreaker.apikey.resetTimeoutMs, }, }, diff --git a/src/lib/system/autoUpdate.ts b/src/lib/system/autoUpdate.ts index c3dad1ed44..5f4ef49a35 100644 --- a/src/lib/system/autoUpdate.ts +++ b/src/lib/system/autoUpdate.ts @@ -7,15 +7,20 @@ import { homedir } from "node:os"; const execFileAsync = promisify(execFile); -function resolveProjectRoot(fallback: string): string { - try { - const cwd = process.cwd(); - if (existsSync(path.join(cwd, "package.json"))) return cwd; - if (existsSync(path.join(cwd, ".git"))) return cwd; - return cwd; - } catch { - return fallback; +/** @internal — exported for testability. */ +export function resolveProjectRoot( + fallback: string, + startDir: string = typeof __dirname !== "undefined" ? __dirname : process.cwd() +): string { + const markers = ["package.json", ".git"] as const; + let dir = path.resolve(startDir); + while (true) { + if (markers.some((m) => existsSync(path.join(dir, m)))) return dir; + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; } + return fallback; } const FALLBACK_CWD = process.env.HOME || homedir() || "/tmp"; diff --git a/src/shared/components/ProviderIcon.tsx b/src/shared/components/ProviderIcon.tsx index 60cb9bb5e3..65b846b7f8 100644 --- a/src/shared/components/ProviderIcon.tsx +++ b/src/shared/components/ProviderIcon.tsx @@ -84,7 +84,6 @@ const KNOWN_SVGS = new Set([ "sparkdesk", "arcee-ai", "inclusionai", - "krutrim", "liquid", "monsterapi", "nomic", diff --git a/src/shared/components/ProxyConfigModal.tsx b/src/shared/components/ProxyConfigModal.tsx index 83be514d2d..1ec9cb2922 100644 --- a/src/shared/components/ProxyConfigModal.tsx +++ b/src/shared/components/ProxyConfigModal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { useTranslations } from "next-intl"; import Modal from "./Modal"; import Button from "./Button"; @@ -10,10 +10,12 @@ const ALL_PROXY_TYPES = [ { value: "https", label: "HTTPS" }, { value: "socks5", label: "SOCKS5" }, ]; -const SOCKS5_UI_ENABLED = process.env.NEXT_PUBLIC_ENABLE_SOCKS5_PROXY === "true"; -const PROXY_TYPES = SOCKS5_UI_ENABLED - ? ALL_PROXY_TYPES - : ALL_PROXY_TYPES.filter((type) => type.value !== "socks5"); +// Build-time fallback (static deploys). The live value comes from GET /api/settings/proxies +// (server ENABLE_SOCKS5_PROXY) so a runtime Docker env is honoured — #3508. +const BUILD_TIME_SOCKS5 = process.env.NEXT_PUBLIC_ENABLE_SOCKS5_PROXY === "true"; +export function buildProxyTypes(socks5Enabled: boolean) { + return socks5Enabled ? ALL_PROXY_TYPES : ALL_PROXY_TYPES.filter((type) => type.value !== "socks5"); +} type ProxyConfigLevel = "global" | "provider" | "combo" | "key"; @@ -135,7 +137,9 @@ export default function ProxyConfigModal({ const [mode, setMode] = useState("saved"); const [savedProxies, setSavedProxies] = useState([]); const [selectedProxyId, setSelectedProxyId] = useState(""); - const [proxyType, setProxyType] = useState(PROXY_TYPES[0]?.value || "http"); + const [socks5Enabled, setSocks5Enabled] = useState(BUILD_TIME_SOCKS5); + const proxyTypes = useMemo(() => buildProxyTypes(socks5Enabled), [socks5Enabled]); + const [proxyType, setProxyType] = useState("http"); const [host, setHost] = useState(""); const [port, setPort] = useState(""); const [username, setUsername] = useState(""); @@ -166,14 +170,20 @@ export default function ProxyConfigModal({ try { let hasSavedAssignment = false; let registryItems: ProxyRegistryItem[] = []; + let runtimeSocks5 = BUILD_TIME_SOCKS5; const registryRes = await fetch("/api/settings/proxies"); if (registryRes.ok) { const registryPayload = await registryRes.json(); registryItems = Array.isArray(registryPayload?.items) ? registryPayload.items : []; setSavedProxies(registryItems); + if (typeof registryPayload?.socks5Enabled === "boolean") { + runtimeSocks5 = registryPayload.socks5Enabled; + } } else { setSavedProxies([]); } + setSocks5Enabled(runtimeSocks5); + const runtimeProxyTypes = buildProxyTypes(runtimeSocks5); const scope = getAssignmentScope(level); const assignmentParams = new URLSearchParams({ scope }); @@ -194,9 +204,9 @@ export default function ProxyConfigModal({ const assignedProxy = registryItems.find((item) => item.id === target.proxyId); if (assignedProxy?.source === DASHBOARD_CUSTOM_PROXY_SOURCE) { const normalizedType = String(assignedProxy.type || "http").toLowerCase(); - const hasTypeOption = PROXY_TYPES.some((entry) => entry.value === normalizedType); + const hasTypeOption = runtimeProxyTypes.some((entry) => entry.value === normalizedType); setMode("custom"); - setProxyType(hasTypeOption ? normalizedType : PROXY_TYPES[0]?.value || "http"); + setProxyType(hasTypeOption ? normalizedType : runtimeProxyTypes[0]?.value || "http"); setHost(assignedProxy.host || ""); setPort(String(assignedProxy.port || "")); setUsername( @@ -206,7 +216,7 @@ export default function ProxyConfigModal({ isRedactedSecret(assignedProxy.password) ? "" : assignedProxy.password || "" ); setShowAuth(!!(assignedProxy.username || assignedProxy.password)); - if (normalizedType === "socks5" && !SOCKS5_UI_ENABLED) { + if (normalizedType === "socks5" && !runtimeSocks5) { setFormError(t("errorSocks5Hidden")); } } else { @@ -227,15 +237,15 @@ export default function ProxyConfigModal({ const proxy = data.proxy; if (proxy && proxy.host) { const normalizedType = String(proxy.type || "http").toLowerCase(); - const hasTypeOption = PROXY_TYPES.some((entry) => entry.value === normalizedType); - setProxyType(hasTypeOption ? normalizedType : PROXY_TYPES[0]?.value || "http"); + const hasTypeOption = runtimeProxyTypes.some((entry) => entry.value === normalizedType); + setProxyType(hasTypeOption ? normalizedType : runtimeProxyTypes[0]?.value || "http"); setHost(proxy.host || ""); setPort(proxy.port || ""); setUsername(proxy.username || ""); setPassword(proxy.password || ""); setShowAuth(!!(proxy.username || proxy.password)); setHasOwnProxy(true); - if (normalizedType === "socks5" && !SOCKS5_UI_ENABLED) { + if (normalizedType === "socks5" && !runtimeSocks5) { setFormError(t("errorSocks5Hidden")); } if (!hasSavedAssignment) setMode("custom"); @@ -279,7 +289,7 @@ export default function ProxyConfigModal({ }, [isOpen, level, levelId]); const resetFields = () => { - setProxyType(PROXY_TYPES[0]?.value || "http"); + setProxyType(proxyTypes[0]?.value || "http"); setHost(""); setPort(""); setUsername(""); @@ -602,7 +612,7 @@ export default function ProxyConfigModal({ {t("proxyType")}
- {PROXY_TYPES.map((t) => ( + {proxyTypes.map((t) => (